DEV Community

Kamran Khan
Kamran Khan

Posted on

I never hash passwords in my controllers — the `hashed` cast does it

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',
    ];
}
Enter fullscreen mode Exit fullscreen mode

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',
]);
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
mujtbabbas profile image
Syed Mujtaba •

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.

Collapse
 
kamrankhandev profile image
Kamran Khan •

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.

Collapse
 
mujtbabbas profile image
Syed Mujtaba •

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!