Every password write in my starter kit flows through one line on the User model:
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
Search my whole backend for Hash::make and you will find nothing. The only Hash call is Hash::check on login, which only reads. Nobody ever writes a hash by hand.
So the register endpoint does this:
$user = User::create([
'name' => $request->validated('name'),
'email' => $request->validated('email'),
'password' => $request->validated('password'),
'role' => 'user',
]);
The controller passes the raw password straight through and the model hashes it before it touches the database. Same story in the admin create-user flow, and same in the seeder — DatabaseSeeder passes 'password' => 'password' in plain text and it lands hashed. The seeder does not know or care about bcrypt.
Two things I like about this. First, hashing is a decision that can never be forgotten. There is no new controller where someone (me, three months from now) forgets to hash before saving. The wrong thing is impossible. Second, the cast does not double-hash: if the value is already hashed it leaves it alone, so the factory's pre-hashed 'password' stays exactly as it is.
The login side never touches storage:
if (! $user || ! Hash::check($request->validated('password'), $user->password)) {
return response()->json(['message' => 'Invalid credentials'], 401);
}
Check on the way in, cast on the way out. One job each.
I have stopped treating password hashing as something each controller does and started treating it as something the model guarantees. That is the whole pattern.
Patterns like this one — auth, roles, admin CRUD, dashboard — are what my Laravel + React SaaS starter kit is made of, so every project starts from them instead of rediscovering them: https://kamranofficial.gumroad.com/l/mhekoig
Top comments (1)
Good pattern, and I've run it in production too. The one failure mode I'd flag: the cast hashes blindly, so if a seed, migration backfill, or CSV import ever writes an already-hashed value, you get a hash of a hash and a user who can never log in. My rule now is that the cast only ever receives raw values, and any bulk or import path gets verified with Hash::check afterward. Keeps the elegance, removes the footgun.