ADR-0019: Explicit Model Hydration
Accepted Cross-Project UniversalDate: 2026-04-13
Context
Laravel's mass-assignment system ($fillable / $guarded) was designed for an era when controllers received raw request data and passed arrays directly to Model::create() or $model->fill(). It is a guard against untrusted input reaching the database.
In a codebase that enforces the FormRequest → DTO Flow and Action Class Architecture, that guard is redundant. The request is validated in a FormRequest, transformed into a typed DTO, and the Action receives only the DTO — never raw input. The model is hydrated explicitly by the Action, one property at a time.
The Problem with $fillable
PHPStan blind spot.
Model::create($array)and$model->fill($array)acceptarray<string, mixed>. PHPStan cannot verify that the keys match real model properties or that the values match the expected types. Typos, missing fields, and type mismatches are invisible until runtime.Implicit contract.
$fillableis the only thing standing between an array of unknown shape and the database. When the DTO pipeline is the actual boundary,$fillablebecomes a second, weaker version of the same contract — one that PHPStan can't analyze.Defense-in-depth illusion.
$fillableprotects against mass assignment from untrusted input. When the caller is always a trusted Action that already received a validated DTO, the "defense" protects against nothing. Worse, it creates a false sense of security — developers add fields to$fillableby habit, expanding the attack surface for any code path that does pass raw input.Maintenance tax. Every model change requires updating
$fillable, the DTO, and the Action. The DTO + Action change is necessary; the$fillablechange is ceremony.
Current State
| Territory | Models with $fillable | create()/fill() usage | Status |
|---|---|---|---|
| kendo | 0 | 0 | Clean |
| brick-inventory | 0 | 0 | Clean |
| the-laboratory (all 5) | 0 | 0 | Clean |
| entreezuil | 2 | 3 call sites | Migration needed |
| ublgenie | 9 | 4 call sites | Migration needed |
Kendo (41 models), brick-inventory (11 models), and the laboratory (all 5 experiments) already follow this pattern. The decision formalizes and enforces what is already proven at scale.
Decision
Models MUST NOT declare $fillable or $guarded properties. All model hydration MUST use explicit property assignment in Actions.
Banned
// ❌ Mass-assignment properties
protected $fillable = ['name', 'email'];
protected $guarded = [];
protected $guarded = ['id'];
// ❌ Mass-assignment methods
$user = User::create(['name' => $dto->name, 'email' => $dto->email]);
$user->fill(['name' => $dto->name]);
$user->forceFill(['password' => $hashed]);
$user->update(['active' => false]);Required Pattern
// ✅ Explicit property assignment in Action
$user = new User();
$user->name = $dto->name;
$user->email = $dto->email;
$user->password = Hash::make($dto->password);
$user->save();
// ✅ Explicit update
$user->name = $dto->name;
$user->email = $dto->email;
$user->save();Why This Works
With no $fillable and no $guarded declaration, Laravel defaults to $guarded = ['*']. This means:
Model::create()throwsMassAssignmentException— catches accidental usage at runtime$model->fill()throwsMassAssignmentException— same protection$model->forceFill()bypasses guards but is also banned — explicit assignment is clearer- Direct property assignment (
$model->name = 'value') is unaffected — this is the only permitted path
PHPStan can verify every property assignment: the property exists on the model, and the value type matches the property type (via @property annotations or Laravel IDE Helper).
Scope
The rule applies to all Eloquent models in application code. It does not apply to:
- Framework/package models (e.g., Passport's
Client, Sanctum'sPersonalAccessToken) — we don't control their internals - Database seeders and factories — these are test infrastructure, not business logic. Factories use
$fillableinternally; that's acceptable.
Options Considered
| Option | Verdict | Reason |
|---|---|---|
Keep $fillable as defense-in-depth | Rejected | Redundant with DTO pipeline. Creates false security, PHPStan blind spot, and maintenance overhead. |
Use $guarded = [] (unguard all) | Rejected | Worse — removes the runtime safety net while keeping the untyped array pattern. |
| Ban mass assignment, require explicit property assignment | Accepted | Full PHPStan coverage, zero maintenance overhead, aligns with existing doctrine. Proven across 55+ models in 3 territories. |
Consequences
Positive
- PHPStan catches typos, missing fields, and type mismatches in model hydration — previously invisible
- One fewer file to update when model schema changes (
$fillablelist gone) MassAssignmentExceptionacts as runtime guard against accidentalcreate()/fill()usage by allies- Consistent with the "explicit over implicit" doctrine principle
- Actions become the single source of truth for what gets written — no competing
$fillablelist
Negative
- More lines of code per Action — explicit assignment is verbose compared to
create($array) - Allies accustomed to Laravel conventions will need onboarding — the architecture test provides immediate feedback
- Factory definitions still work (factories call
fill()internally viaModel::unguard()inCreatesApplication) — no impact on test infrastructure
Risks
- Third-party packages that call
fill()orcreate()internally — Mitigation: package models are excluded from scope. If a package requires$fillableon our model (rare), document the exception in the architecture test. - Developer habit of reaching for
::create()— Mitigation: architecture test catches at CI time,MassAssignmentExceptioncatches at runtime.
Enforcement
| What | Mechanism | Scope |
|---|---|---|
No $fillable on models | Architecture test: ->not->toHaveProperty('fillable') | App\Models |
No $guarded on models | Architecture test: ->not->toHaveProperty('guarded') | App\Models |
No Model::create() in app code | Architecture test: ->not->toUse(['Illuminate\Database\Eloquent\Builder::create']) | App |
No fill()/forceFill() in app code | Architecture test: grep-based or custom rule | App |
| Runtime safety net | Laravel's default $guarded = ['*'] | All models without explicit $guarded |
Reference Architecture Test
arch('models must not declare fillable')
->expect('App\Models')
->not->toHaveProperty('fillable');
arch('models must not declare guarded')
->expect('App\Models')
->not->toHaveProperty('guarded');Resolved Questions
What about forceFill() for sensitive fields like password?
Resolved 2026-04-13. forceFill() was the pre-ADR pattern for fields excluded from $fillable (passwords, roles, tokens). With no $fillable at all, the distinction is meaningless. Direct property assignment handles all fields equally: $user->password = Hash::make($dto->password). Clearer, type-safe, no special method needed.
What about $model->update(['field' => 'value'])?
Resolved 2026-04-13. update() calls fill() internally — same mass-assignment path, same PHPStan blind spot. Use explicit assignment + save() instead.
Implementation
| Territory | State | Notes |
|---|---|---|
| kendo | Evergreen | 41 models, 0 $fillable, 0 mass-assignment calls. Architecture test needed. |
| brick-inventory | Evergreen | 11 models, 0 $fillable, 0 mass-assignment calls. Architecture test needed. |
| the-laboratory | Evergreen | All 5 experiments clean. Architecture test in experiment template needed. |
| entreezuil | Evergreen | PR #36: $fillable stripped, mass-assignment methods banned, 3 architecture tests enforcing. |
| ublgenie | In Progress | PR #89 open (as of 2026-04-16). Originally 9 models with $fillable; Cartographer M3 verified 6 remaining. No ModelsTest.php arch test yet. |