Models & data
Mass assignment: what $fillable does not protect
The mistake
$fillable reads like a list of what a user is allowed to change. It is a list of
keys that one method will copy out of an array. That method never asks who sent the
array, and the array does not have to come from your form.
There are four ways to declare the rule, and they look like four settings of one switch. They are four different pieces of code. Two of them ask your database what your columns are, and they keep the answer until the process ends.
The machine
Every verdict runs on the tested reducer. All of it is checked against laravel/framework v13.34.0 and a real MySQL by docs/verify-mass-assignment.sh, 44 checks.
Drive it
The panel opens on a model that allows three keys, and a request carrying four. The row in the table is correct.
- Add the keys the form never showed. The post now carries
role,is_adminandnickname. The row does not change. This is the one job$fillabledoes well. - Switch the model to
$guarded = []. Same request, same table.rolereaches the database asadmin, and the insert fails on a key that matches no column. - Guard one key by name, add a column, then restart. With
$guarded = ['role']the model asks for the column list once per process. Addis_adminto the table and the running process keeps dropping it. Restart and it is writable. No code changed.
The mechanism
One method does all of this. create() and update() both call fill(), so all
three behave the same way. fill() works in three stages.
Stage one, before the loop. If $fillable is not empty, fillableFromArray()
keeps only the keys it names and drops the rest. Those keys reach no check at all.
This is why _token disappears without a word. The difference matters again in
stage three. A key removed here and a key refused inside the loop are handled by
different code.
Stage two, the loop. Each surviving key goes through isFillable(), which asks
isGuarded(). Read its first line:
public function isGuarded($key)
{
if (empty($this->getGuarded())) {
return false;
}
return $this->getGuarded() == ['*'] ||
! empty(preg_grep('/^'.preg_quote($key, '/').'$/i', $this->getGuarded())) ||
! $this->isGuardableColumn($key);
}
With $guarded = [] the check stops there. It never reads your columns, it caches
nothing, and it accepts every key. One filter is left, on the last line of
isFillable(). That line refuses a key that starts with _ or contains a dot. On
an open model, _token is the only key it stops.
A partial list takes a different path. $guarded = ['role'] is not empty, so
it gets past that first line. It is not ['*'] either, and for most keys the name
in the list does not match. The check therefore reaches isGuardableColumn(). That
method asks the database for the column list, then keeps it in a static property,
keyed by class:
if (! isset(static::$guardableColumns[get_class($this)])) {
$columns = $this->getConnection()
->getSchemaBuilder()
->getColumnListing($this->getTable());
if (empty($columns)) {
return true;
}
static::$guardableColumns[get_class($this)] = $columns;
}
A key that matches no column counts as guarded. That is useful, because a partial list stops typos.
The static property also freezes the answer at the first request that asks. Deploy a migration, and the worker that is already running keeps dropping the new column. It is comparing against a list it read before the column existed. The next process accepts the key, and nobody edited any code in between.
isGuardableColumn() has a short circuit, and it runs before the column list is
read at all. The method returns true if the key has a set mutator, an attribute set
mutator, or a cast to a class. Under a partial list this is a real hole. nickname
is not a column, and setNicknameAttribute() still makes it assignable. The value
then goes wherever that method sends it.
A cast to a class means a custom cast or an enum, and the word class is doing
the work. isClassCastable() checks the cast against a list of built in types
first, and string, int, array and json are all on that list. A primitive
cast returns false and the column check still runs. So
$casts = ['nickname' => 'string'] leaves the key guarded, and
$casts = ['nickname' => Nickname::class] does not. Under $guarded = [] the short
circuit changes nothing, because isGuarded() has already returned false at the
top.
Stage three, after the loop. fill() compares the number of keys it was given
against the number that survived stage one.
By default a key that fails is dropped in silence.
Model::preventSilentlyDiscardingAttributes() raises an exception instead, and it
has two places to do it. Inside the loop it names the one key. After the loop it
names everything stage one removed. The second place catches keys the first never
saw.
One case needs no switch. Take a model with no $fillable, and $guarded still
at its default ['*']. That is what make:model writes. Such a model raises an
exception on the first key it is handed. totallyGuarded() is that exact shape,
and $guarded = [] is not that shape.
The model is not the last stop either. Under $guarded = [] the model accepts a
key that matches no column, and then sends it to the database. The database
replies:
SQLSTATE[42S22]: Column not found: 1054 Unknown column 'is_admin' in 'field list'
People read that error as a bug in the form. The form is fine. The model accepted a key that was never a column.
In your code
Decide what is allowed where you can also see who is asking. A form request can do both. A model property can only do the first.
public function update(UpdateUserRequest $request, User $user)
{
$user->update($request->validated());
return to_route('users.show', $user);
}
validated() returns only the keys the request class has rules for, so nothing
extra reaches fill(). Keep $fillable as well. An allow list on the model is a
useful second net, as long as it is not the only one.
In a service provider, stop the silence while you develop:
Model::preventSilentlyDiscardingAttributes(! $this->app->isProduction());
The fine print
forceFill()isstatic::unguarded(fn () => $this->fill($attributes)), soforceFill()andModel::unguarded()are one code path with two different scopes. The panel shows them as one control for that reason.create()andupdate()both callfill(), so the panel does not give them separate buttons.forceCreate()is the force version ofcreate().isGuardableColumn()returns true when the column listing comes back empty, so a missing table makes every key pass. A forgotten migration and a wide open model look identical from outside.$fillableand$guardedare per class. A pivot model starts at$guarded = []rather than['*'].- The table in the panel leaves out
created_atandupdated_at. They are two more columns that never take part in a guard decision. - Validation rules, policies, and
$hiddenon the way out are separate subjects. So are writes through a relation. - Casts and mutators appear here only as something the column check skips, and the panel models a set mutator rather than a cast. They have their own page.
- An unknown cast that is neither a primitive nor an existing class does not return
false.
isClassCastable()raisesInvalidCastExceptioninstead. - Measured against
laravel/frameworkv13.34.0. These paths have been stable across Laravel 10 to 13, but the exception wording has changed before.
Further reading
- Laravel: mass
assignment is the
reference, and the place to read about
unguarded()before you reach for it. - GuardsAttributes.php
is about seventy lines long and holds every rule on this page. Read
isFillable(), thenisGuarded(), in that order. - Laravel: form request validation is where an allow list that knows who is asking belongs.
Spotted a problem, or have a way to make this clearer? Suggest an improvement.