Limits of Laravel mass assignment protection when teams mix $fillable and $guarded conventions
0 reputation · 14 Dec 2025, 01:01 UTC
0 reputation · 14 Dec 2025, 01:01 UTC
Laravel Eloquent provides mass-assignment protection through either a $fillable whitelist or a $guarded blacklist, but the framework does not enforce a single default strategy. Teams that allow both patterns across models may face inconsistent protection as new columns are added or new developers contribute code. The choice affects whether newly added database attributes are assignable by default or protected by default, and Laravel 9 introduced Model::preventSilentlyDiscardingAttributes() to change how non-fillable attributes are handled, making the exception behavior version-sensitive.
Global state toggles such as Model::unguard() and Model::reguard() add further uncertainty because they can alter mass-assignment behavior beyond a single model boundary. Nested relationship writes also do not automatically inherit the parent model's fillable or guarded configuration, which complicates enforcement when arrays are passed to create or update methods.
What factors determine whether a codebase should standardize on a single mass-assignment guard strategy or permit both $fillable and $guarded with explicit boundaries? What approach should teams take to account for the changed exception behavior introduced around Laravel 9 when auditing existing models? Where is the safest scope for global unguard calls so they do not leak into unrelated requests, seeders, or queued jobs?
Standardize on $fillable unless you have a narrow, audited reason to use $guarded. $fillable is fail-safe: new database columns are protected by default. $guarded is fail-open: new columns are assignable unless explicitly blocked, and setting $guarded = [] disables protection entirely for that model. Permit both patterns only if you enforce boundaries with automated checks and clear documentation.
For Laravel 9+, treat Model::preventSilentlyDiscardingAttributes() as a behavioral change, not just a new helper. Audit by enabling it in test environments first, then fixing every model that previously relied on silent filtering.
Restrict Model::unguard() to seeder and migration scripts, wrapped in try/finally with Model::reguard(). Never call it in a service provider, middleware, or queued job without an explicit, symmetric guard restoration.
When a codebase contains both $fillable and $guarded models, the effective protection for any given column depends on which pattern the model author used. Because $fillable is a whitelist and $guarded is a blacklist, a new migration column is automatically assignable in $guarded models and automatically ignored in $fillable models. Developers moving between models may assume the wrong default.
Declaring both properties on the same model does not merge them. Laravel resolves the arrays internally, and the effective behavior can vary by version. Treat any model that declares both as ambiguous and refactor it to use only one property.
Before Laravel 9, non-fillable attributes passed to create() or update() were silently discarded. Laravel 9 added Model::preventSilentlyDiscardingAttributes(), which throws a MassAssignmentException instead. This makes over-posting visible but can break existing code that relied on silent filtering.
Audit approach:
Model::preventSilentlyDiscardingAttributes() in your test bootstrap.MassAssignmentException.$fillable or removed from the input array.If your application runs Laravel 8 or earlier, do not assume the new exception behavior exists; verify the exact minor version before changing model contracts.
Model::unguard() mutates static state on the base model. Once called, it affects every Eloquent model in the same PHP process, including unrelated requests handled by the same worker and any queued jobs processed later.
The safest scope is a single seeder or migration file:
use Illuminate\Database\Eloquent\Model;
Model::unguard();
try {
// seed or migrate data
} finally {
Model::reguard();
}
Avoid calling unguard() in AppServiceProvider::boot() or in middleware. If a queued job legitimately requires unguarded writes, call unguard() at the start of the job's handle() method and restore the guard state in a finally block, but prefer explicit $fillable definitions instead.
Passing nested arrays to create() or update() does not recursively apply the parent model's $fillable or $guarded rules to related models. Each related model applies its own configuration. If a parent uses $fillable and a child uses $guarded, the child's blacklist governs the nested write independently.
Use these read-only searches to audit the current state:
# Locate global unguard calls
grep -rn "Model::unguard" app/ database/
# Find strict-mode enablement
grep -rn "preventSilentlyDiscardingAttributes" app/ tests/
Then run a targeted test in your version to confirm whether non-fillable attributes are ignored or throw an exception.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.