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

Simulator · mass assignment

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.

3 keys accepted. 3 keys written. Everything the form did not show was stopped.

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_admin and nickname. The row does not change. This is the one job $fillable does well.
  • Switch the model to $guarded = []. Same request, same table. role reaches the database as admin, 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. Add is_admin to 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() is static::unguarded(fn () => $this->fill($attributes)), so forceFill() and Model::unguarded() are one code path with two different scopes. The panel shows them as one control for that reason.
  • create() and update() both call fill(), so the panel does not give them separate buttons. forceCreate() is the force version of create().
  • 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.
  • $fillable and $guarded are per class. A pivot model starts at $guarded = [] rather than ['*'].
  • The table in the panel leaves out created_at and updated_at. They are two more columns that never take part in a guard decision.
  • Validation rules, policies, and $hidden on 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() raises InvalidCastException instead.
  • Measured against laravel/framework v13.34.0. These paths have been stable across Laravel 10 to 13, but the exception wording has changed before.

Further reading

Spotted a problem, or have a way to make this clearer? Suggest an improvement.