Security 10 min read Markdown

Laravel Mass Assignment: How $fillable and $guarded Go Wrong

Mass assignment turns a tidy one-liner into a privilege-escalation bug. Here is how attackers set is_admin=1 through your profile form, and the exact Eloquent patterns that stop them.

Matt King
Matt King
June 11, 2026
Last updated: June 11, 2026
Laravel Mass Assignment: How $fillable and $guarded Go Wrong

Your registration form has six fields. Name, email, password, and a couple of profile bits. The controller takes the request, calls User::create($request->all()), and you move on. Six months later someone has an admin account they were never granted, and the audit log shows it happened through that same endpoint. Welcome to mass assignment, the bug that hides inside the most convenient line of code Eloquent gives you.

This is not an exotic attack. It needs no special tooling, no race condition, no memory corruption. It needs one extra key in a JSON body.


What mass assignment actually is

Eloquent lets you set many attributes at once from an array:

// app/Http/Controllers/RegisterController.php
$user = User::create($request->all());

$request->all() is the entire request payload. Not the fields your Blade form rendered, the whole body the client decided to send. Eloquent walks that array and writes each key to the matching column. Convenient when the array is exactly what you expect. A liability when it is not, because the client controls the array, not you.

The same applies to update:

// VULNERABLE
$user->update($request->all());

And to fill:

$user->fill($request->all())->save();

All three route through the same internal guard. That guard is the only thing standing between an attacker-supplied key and a database column. So the whole game is configuring that guard correctly, and Eloquent gives you two ways to do it that behave in opposite directions.


$fillable and $guarded: allowlist versus blocklist

$fillable is an allowlist. Only the named attributes can be set through mass assignment; everything else is silently ignored.

// app/Models/User.php
class User extends Authenticatable
{
    protected $fillable = [
        'name',
        'email',
        'password',
    ];
}

With this in place, User::create($request->all()) writes name, email, and password. If the request body also carries is_admin, Eloquent drops it on the floor. No error, no exception, just discarded.

$guarded is a blocklist. Everything is mass-assignable except the named attributes:

// app/Models/User.php
protected $guarded = [
    'id',
    'is_admin',
];

Both can work. The difference is what happens when you add a new column next quarter. With $fillable, a fresh account_credit column is not writable until you add it to the list, so the default is closed. With $guarded, that new account_credit column is mass-assignable the moment the migration runs, because you never added it to the blocklist. The default is open, and open-by-default is how these bugs accumulate.

Then there is the pattern that shows up in tutorials and stays in production far too long:

// app/Models/Product.php
protected $guarded = [];

An empty $guarded means nothing is guarded. Every column, including any you add later, is mass-assignable. People reach for this during prototyping because it makes create($request->all()) "just work", and it never gets tightened. If you only fix one thing after reading this, find every $guarded = [] in your codebase.

A model with neither property set is fully guarded by default in modern Laravel, so create() on it throws a MassAssignmentException rather than silently writing. That is safe but inconvenient, which is why most models end up with one of the two properties declared.


The privilege-escalation attack, step by step

Here is a profile-update endpoint that looks completely ordinary.

// app/Http/Controllers/ProfileController.php
public function update(Request $request, User $user)
{
    $user->update($request->all());

    return back()->with('status', 'Profile updated');
}

The form on the page shows three inputs: display name, bio, and avatar URL. The developer reasons that those are the only fields a user can change, so passing all() is fine. The form is not the boundary, though. The HTTP request is.

The attacker opens dev tools, sees the form posts to /profile, and replays it with curl:

curl -X PATCH https://app.example.com/profile \
  -H "Cookie: laravel_session=..." \
  -H "Content-Type: application/json" \
  -d '{
        "name": "Jordan",
        "bio": "hi",
        "is_admin": 1
      }'

$request->all() now contains is_admin => 1. If the User model uses $guarded = [], or a $fillable list that someone helpfully added is_admin to, that value lands in the database. The attacker reloads the dashboard with an admin badge.

The same shape works for other sensitive columns:

  • "role": "admin" flips a string-based role column if it is mass-assignable.
  • "account_id": 42 moves a record into another tenant in a multi-tenant schema, which is both privilege escalation and a data-isolation breach.
  • "email_verified_at": "2026-01-01 00:00:00" marks an unverified account verified, skipping whatever gate sits behind verification.

None of these fields appear on the form. They do not need to. The model decided they were writable, and the controller handed the model the raw request. Authorisation can mask part of this, which is why every model that touches tenant boundaries should be paired with proper policies; see our notes on authorization gates and policies for where that line sits. But authorisation does not undo a column that should never have been mass-assignable in the first place.


The fix: stop trusting the array

Three layers, and you want more than one of them.

1. An explicit $fillable on every model

Make the allowlist match exactly the columns a user is permitted to set through that model, and nothing else.

// app/Models/User.php
protected $fillable = [
    'name',
    'bio',
    'avatar_url',
    'email',
    'password',
];

is_admin, role, account_id, and email_verified_at are absent, so no request can mass-assign them. To change a role you write it explicitly, on purpose:

$user->role = 'admin';
$user->save();

That line is searchable, reviewable, and intentional. It does not depend on what a request body happened to contain.

2. FormRequest validation, then persist only the validated data

A FormRequest defines which fields are accepted and persists only those:

// app/Http/Requests/UpdateProfileRequest.php
class UpdateProfileRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('update', $this->route('user'));
    }

    public function rules(): array
    {
        return [
            'name'       => ['required', 'string', 'max:255'],
            'bio'        => ['nullable', 'string', 'max:1000'],
            'avatar_url' => ['nullable', 'url', 'max:2048'],
        ];
    }
}
// app/Http/Controllers/ProfileController.php
public function update(UpdateProfileRequest $request, User $user)
{
    $user->update($request->validated());

    return back()->with('status', 'Profile updated');
}

$request->validated() returns only the keys that have rules. is_admin has no rule, so it is not in the returned array, so it cannot reach update(). This holds even if someone later loosens the model $fillable, because the controller is now the gate rather than the model. Validation alone does not strip extra keys, mind you. The protection comes from persisting validated() rather than all().

3. $request->only() when a FormRequest is overkill

For small internal endpoints, name the fields inline:

$user->update($request->only(['name', 'bio', 'avatar_url']));

only() returns just those keys regardless of what else was sent. It is the same principle as validated() without the rule set, useful for a quick admin action where you still want an explicit list.

The rule that ties all three together: never hand $request->all() to create, update, or fill. The moment you do, you have delegated your security boundary to whoever is sending the request.


$fillable versus $guarded versus unguarded

Approach Behaviour New column added later When to use
$fillable = [...] Allowlist; only listed attributes are mass-assignable Not writable until you add it (closed by default) Default choice for every model
$guarded = [...] Blocklist; everything writable except listed attributes Mass-assignable immediately (open by default) Rarely; only when the safe set genuinely outnumbers the unsafe set
$guarded = [] Nothing guarded; all attributes mass-assignable Mass-assignable immediately Never in production
Neither set Fully guarded; create() throws MassAssignmentException Throws until you declare a property Acceptable but inconvenient

The column that bites you is almost always the one added after the model was written. That is why the "closed by default" behaviour of $fillable matters more than any single review.


The pitfalls that survive a good $fillable

Getting $fillable right closes the front door. These are the side doors.

Model::unguard()

unguard() disables mass-assignment protection globally for the lifetime of the request:

// VULNERABLE if left on for normal request handling
Model::unguard();
User::create($request->all());

It has a legitimate home in seeders and migrations where you control the data. It has no business in a controller. If you find unguard() outside database/, treat it as a finding. The scoped Model::unguarded(fn () => ...) is slightly better because it re-enables protection afterwards, but the same rule applies: not with request data.

forceFill()

forceFill() bypasses $fillable and $guarded entirely. It exists precisely to ignore them.

// VULNERABLE
$user->forceFill($request->all())->save();

This re-opens everything you closed. forceFill is fine with a hand-built array of trusted values, never with raw request input.

JSON and array columns

A cast json column is mass-assignable as a unit. If settings is fillable, the attacker controls the entire nested structure:

-d '{"settings": {"theme": "dark", "is_admin": true}}'

The model guard checks the top-level key settings, not what is inside it. If your code later reads $user->settings['is_admin'], you have moved the vulnerability one layer down. Validate nested keys with dot-notation rules (settings.theme) and read flags from real columns, not from user-writable JSON blobs.

Route model binding plus update()

Route model binding resolves the model from the URL, which is convenient and fine. The trouble is the pairing:

public function update(Request $request, Post $post)
{
    $post->update($request->all()); // bound model + raw input
}

The bound $post is the record being changed, and all() is attacker-controlled. If Post is mass-assignable on user_id, an attacker can reassign ownership of the record. This combination also interacts with SQL-layer concerns, since the same controllers often build filtered queries from request input; our write-up on SQL injection in Eloquent and the query builder covers that adjacent failure. Keep the binding, feed update() validated data.


What to do this week

Switch every model to an explicit $fillable, delete every $guarded = [], and stop passing $request->all() into create, update, or fill. Replace those calls with $request->validated() from a FormRequest or an explicit $request->only([...]). Audit for unguard() and forceFill() outside seeders. That single sweep removes the most common privilege-escalation path in a Laravel app, and it pairs neatly with the rest of the Laravel security checklist for 2026.

If you would rather not grep through every model by hand, Run a free StackShield scan and it will list every mass-assignable model and every controller that pipes raw request data into Eloquent.

Free security check

Is your Laravel app exposed right now?

34% of Laravel apps we scan have at least one critical issue. Most teams don't find out until something breaks. Our free scan checks your live application in under 60 seconds.

18% have debug mode on
72% missing security headers
12% have exposed .env
Scan My App Free No signup required. Results in 60 seconds.

Frequently Asked Questions

What is mass assignment in Laravel?

Mass assignment is when you set multiple model attributes in one go from an array, usually request data, via methods like Model::create() or $model->update(). It is convenient because you avoid setting each column by hand. The risk is that the array can contain keys the user was never meant to control, such as is_admin or role. Eloquent guards against this with the $fillable allowlist and the $guarded blocklist, which decide which attributes a mass-assignment call is allowed to write.

Should I use $fillable or $guarded?

Use $fillable. It is an explicit allowlist, so a new column you add to a migration is not writable through mass assignment until you deliberately add it. $guarded is a blocklist, which means every new column is mass-assignable by default unless you remember to block it, and that default-open behaviour is exactly how privilege-escalation bugs slip in. The empty $guarded = [] pattern is the worst of the lot because it disables protection on every attribute the model has.

Is $request->all() safe to pass to create() or update()?

No, not on its own. $request->all() returns every field the client sent, including ones your form never rendered, so an attacker can append arbitrary keys to the request body. If your model uses $fillable correctly the extra keys are silently dropped, but you are then relying entirely on the model definition being right. The safer habit is to pass validated data from a FormRequest via $request->validated(), or an explicit $request->only([...]) list, so the controller decides which fields are accepted rather than the attacker.

Does validation stop mass assignment attacks?

Validation and mass assignment are two separate gates and you want both. Validation checks that the fields you expect are present and well-formed, but a passing validation rule set does not strip extra keys unless you only persist the validated subset. If you validate and then call create($request->all()) anyway, the unvalidated extra fields still reach the model. The fix is to persist $request->validated() so only the keys with rules attached get written.

Can route model binding cause mass assignment problems?

Route model binding itself only resolves a model from the URL, but it pairs badly with $user->update($request->all()) in update controllers. The bound model is the record being changed, and if it is mass-assignable on sensitive columns an attacker can flip those columns on their own record, or on another tenant record if your authorisation is weak. Keep the binding, but feed update() a validated or explicitly filtered array. A free StackShield scan flags update calls that pass unfiltered request data straight into a bound model.

Stay Updated on Laravel Security

Get actionable security tips, vulnerability alerts, and best practices for Laravel apps.