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 (3)
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.
Fair flag, Syed, and I've been bitten by this exact shape of bug on a CSV import where the export already contained bcrypt hashes. One nuance worth adding: the cast itself skips values that are already hashed, so going through the model you're covered. The real footgun is the paths that skip the model entirely, backfills with DB::table, raw SQL, an import writing straight to the table. That's where I now normalize everything to plain text at the edge and spot check a few rows with Hash::check after the run. Same rule as yours, just enforced one step earlier.
Great addition, Kamran — that's the more precise way to frame it. The model path is covered, so the real discipline is making sure every write path actually goes through the model, and treating any raw-table write as suspicious by default. Normalizing to plain text at the edge plus a Hash::check spot-check after the run is a solid habit — I'm stealing that one. Thanks for sharing the war story!