In a Laravel 13 User model, what does the 'hashed' cast do on assignment, and what happens if you also pass the value through Hash::make()?
answer
- declared in the casts() method
- hashes on set, not on read
- Hash::isHashed() skips already-hashed values
- cost above config is rejected
- Could not verify the hashed value's configuration
basics
~20 sThe hashed cast runs Hash::make() when a plain value is assigned. A value Hash::isHashed() recognises is kept as-is, so hashing first does not double-hash, but a hash with another algorithm or a higher cost than configured throws a RuntimeException.
solid answer
~40 sIn Laravel 13 the skeleton `User` model returns `'password' => 'hashed'` from `casts()`. When you set the attribute, Eloquent calls `Hash::isHashed()`: a plain value goes through `Hash::make()`; a value that already looks like a password hash is kept, after `Hash::verifyConfiguration()` confirms it uses the configured algorithm and a cost no higher than configured. So `Hash::make()` plus the cast stores one hash, not a hash of a hash. The traps are elsewhere: a hash from another driver, or one made with more rounds than `BCRYPT_ROUNDS` (common when fixtures made at cost 12 meet a test suite running at 4), throws `RuntimeException` "Could not verify the hashed value's configuration." And because detection only asks whether the string parses as a hash, a user who submits a literal bcrypt string as their password has it stored raw.
code
php · 13 lines<?php
use App\Models\User;
use Illuminate\Support\Facades\Hash;
$user = new User(['name' => 'Ada', 'email' => '[email protected]']);
$user->password = 'plain-text'; // cast hashes it
$user->password = Hash::make('plain-text'); // already hashed: kept as-is
// BCRYPT_ROUNDS=12 in config:
$user->password = Hash::make('x', ['rounds' => 14]);
// RuntimeException: Could not verify the hashed value's configuration.go deeper
Recall that the User model's hashed cast hashes a plain password on assignment and that reading the attribute gives the hash.
Explain how Hash::isHashed() and the configuration check decide between hashing, keeping and rejecting an assigned value.
Anticipate where the cast throws in practice: low-cost test configs, imported hashes, lowered rounds, and choose the right import path.
Judge where framework conveniences like write-time casts should stop and explicit validation or import tooling should take over.
## Where the cast lives An Eloquent **cast** converts an attribute when it is read from or written to a model. Laravel 13 models declare them in a `casts()` method, and the skeleton's `App\Models\User` ships with: - `'email_verified_at' => 'datetime'` - `'password' => 'hashed'` The `hashed` cast is **write-only**: it acts when a value is assigned, and reading `$user->password` returns the stored hash unchanged. It exists so that controllers, factories and seeders can assign a plain password without remembering to hash it. ## What happens on assignment When you write `$user->password = $value` (or pass it through `fill()`, `create()` or `update()`), Eloquent runs these steps for a non-null value: 1. **Is it already a hash?** `Hash::isHashed($value)` asks PHP's `password_get_info()` whether the string parses as a known password-hash format. 2. **If not**, the value is replaced with `Hash::make($value)`, using the current driver and cost. 3. **If it is**, `Hash::verifyConfiguration($value)` checks it against the current driver: the algorithm must match, and the cost parameters must not exceed the configured ones. For bcrypt that means the cost is at most `hashing.bcrypt.rounds`; for Argon, memory, time and threads each at most the configured values. 4. **If that check fails**, a `RuntimeException` is thrown: "Could not verify the hashed value's configuration." 5. `null` stays `null`. ## Hash::make plus the cast | Assigned value | Result | |---|---| | `'s3cret'` | hashed once by the cast | | `Hash::make('s3cret')` with current settings | stored as given, not re-hashed | | a bcrypt hash made with cost 14 while `BCRYPT_ROUNDS=12` | `RuntimeException` | | an Argon2id hash while the driver is `bcrypt` | `RuntimeException` | | `null` | `null` | So calling `Hash::make()` before assigning is redundant rather than harmful. Older code, written before the cast existed, did exactly that; it keeps working. ## Where it bites - **Test fixtures.** The skeleton's `phpunit.xml` sets `BCRYPT_ROUNDS=4`. The default `UserFactory` hashes with `Hash::make('password')`, which respects that, but a fixture that pastes in a hash produced at cost 12 is rejected under the test config. - **Importing users from another system.** Hashes from a different algorithm are refused, which is usually what you want; import them with the query builder, or switch driver settings deliberately, rather than through the model. - **Lowering the cost.** After reducing `BCRYPT_ROUNDS`, existing higher-cost hashes still verify at login, but assigning one of them through the model throws. - **Literal hash-looking input.** Detection only asks "does this parse as a hash?". If a user chooses a password that is itself a valid bcrypt string within the configured cost, the cast stores it raw and that user can never log in with it. It is a curiosity rather than a vulnerability, since that user only locks themselves out, but it shows the cast is a convenience, not a validator. ## Choosing the import path When you bring existing hashes into a Laravel app, the cast's behaviour decides the route: 1. **Same algorithm, cost at or below the config**: assigning through the model works, and the hash is stored as given. 2. **Same algorithm, higher cost**: raise `BCRYPT_ROUNDS` to match first, or write the rows with the query builder, which applies no casts. 3. **Different algorithm**: write with the query builder and plan a migration window in which the hasher accepts both formats, because both the cast and `Hash::check()` refuse foreign algorithms by default. In every case, never "fix" an import by hashing the imported hash again: that produces a value no password will ever match. ## What the cast does not do - It does not verify passwords; login still goes through the auth guards and `Hash::check()`. - It does not rehash old passwords when the cost rises; that happens at login through `rehash_on_login`. - It does not hide the column from JSON; the skeleton's `User` does that separately with `#[Hidden(['password', 'remember_token'])]`.
- Why can a test that inserts a fixture hash through the User model fail only in the test suite?The skeleton's `phpunit.xml` sets `BCRYPT_ROUNDS=4`. A fixture hash made at cost 12 exceeds the configured rounds, so the `hashed` cast's configuration check throws. Generate fixture hashes with `Hash::make()` inside the test, or insert them without going through the model.
- Does the hashed cast make Hash::make() calls in older controllers a bug?No. The cast recognises an existing hash with `Hash::isHashed()` and keeps it when it matches the configured algorithm and cost, so it is stored once. The call is simply redundant and can be removed for clarity.
saying these in an interview costs you the question
- Hash::make() plus the hashed cast stores a hash of a hash.
- The hashed cast rehashes the password every time the model is saved.
- Reading $user->password through the cast returns the plain password.
- The hashed cast accepts any bcrypt hash regardless of its cost.
- The hashed cast replaces Hash::check() during login.