Mass assignment: why $fillable isn't protecting what you think it is
Mass assignment protection feels like a solved problem — you set $fillable, ship the model, move on. But the protection only covers exactly what you told it to cover, and it's easy to widen that surface by accident: a new column added to fillable without thinking through who can now set it, or a $guarded = [] left over from early development that nobody circled back to lock down.
ON THIS PAGE
What mass assignment protection actually does
When you call User::create($request->all()) or $user->update($request->all()), Eloquent doesn't blindly write every key in that array to the database. It checks each key against the model's mass assignment rules first, and silently drops anything not permitted. That's the entire mechanism — a filter on which keys are allowed to be set via create(), update(), fill(), or forceFill()'s non-forced variants.
It says nothing about validation, authorization, or whether the value makes sense. It only answers one question: is this attribute name allowed to be mass assigned at all.
$fillable and $guarded are two ends of the same decision
$fillable is an allowlist — only these attributes can be mass assigned, everything else is silently ignored:
class Post extends Model
{
protected $fillable = ['title', 'slug', 'content', 'status'];
}
$guarded is a blocklist — everything is mass assignable except these:
class Post extends Model
{
protected $guarded = ['id', 'user_id'];
}
Pick one model per class. Defining both is confusing to reason about and Eloquent's actual precedence rules between them are not worth relying on — an allowlist is almost always the safer default, since it fails closed: a new column you forget to add to $fillable is simply ignored, not silently exposed.
The $guarded = [] trap
Setting protected $guarded = [] disables mass assignment protection entirely — every attribute becomes fillable, including ones you never intended to expose. It's common to see this in early scaffolding because it's convenient during rapid prototyping, and then it survives into production because nothing visibly breaks. The break isn't a crash, it's a silent privilege escalation path the moment a new sensitive column — is_admin, role, account_balance — gets added to the table without anyone updating the model.
If you genuinely want an unguarded model, #[Unguarded] (or the older Model::unguard() call) at least makes that choice explicit and searchable across the codebase, rather than an easy-to-miss empty array.
Where fillable/guarded doesn't save you
A few gaps worth knowing about explicitly:
forceFill()bypasses it entirely. That's the point of the method — use it deliberately for internal, trusted writes, never on request input.- It has nothing to do with authorization.
$fillablemight correctly allowstatusto be mass assigned, but it says nothing about whether this particular user should be allowed to change this particular post's status. That's a policy/gate concern, not a mass assignment concern. - It has nothing to do with validation. A fillable
emailfield will happily accept a string that isn't a valid email address. Form Request validation and mass assignment protection solve two different problems and you need both.
Laravel 13: configuring this with attributes
Laravel 13 adds #[Fillable] and #[Guarded] as attribute-based alternatives to the property syntax, living in Illuminate\Database\Eloquent\Attributes:
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
#[Fillable(['title', 'slug', 'content', 'status'])]
class Post extends Model
{
// no $fillable property needed
}
use Illuminate\Database\Eloquent\Attributes\Guarded;
#[Guarded(['id', 'is_admin'])]
class User extends Model
{
// no $guarded property needed
}
This is entirely optional — the property syntax still works, and Laravel 13 doesn't deprecate it. But putting the rule above the class as an attribute rather than buried a few lines into the class body makes it the first thing you see when opening the file, which matters for exactly this kind of security-relevant config that's easy to stop noticing once it's familiar.
Catching violations with strict mode
Model::shouldBeStrict() turns Eloquent's normally-silent behaviors into thrown exceptions during development — including attempting to mass assign a guarded attribute, which otherwise fails silently and can be genuinely confusing to debug ("why didn't my update work?"):
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
Model::shouldBeStrict(! app()->isProduction());
}
With this on, a guarded field silently being dropped during local development instead throws immediately, pointing you at the actual line rather than leaving you to notice a column just didn't update.
Checklist before shipping a new model
- Does this model use
$fillable(allowlist) rather than$guarded = [], unless you have a specific reason to want everything mass assignable? - When you add a new sensitive column to an existing table, did you double check it isn't accidentally now fillable?
- Is authorization (policies/gates) handling "can this user change this field" separately from mass assignment, rather than treating fillable as if it were an authorization layer?
- Is
Model::shouldBeStrict()enabled outside production so silent drops become loud errors while you're building?