Skip to content

ADR-0019: Explicit Model Hydration

Accepted Cross-Project Universal

Date: 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

  1. PHPStan blind spot. Model::create($array) and $model->fill($array) accept array<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.

  2. Implicit contract. $fillable is the only thing standing between an array of unknown shape and the database. When the DTO pipeline is the actual boundary, $fillable becomes a second, weaker version of the same contract — one that PHPStan can't analyze.

  3. Defense-in-depth illusion. $fillable protects 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 $fillable by habit, expanding the attack surface for any code path that does pass raw input.

  4. Maintenance tax. Every model change requires updating $fillable, the DTO, and the Action. The DTO + Action change is necessary; the $fillable change is ceremony.

Current State

TerritoryModels with $fillablecreate()/fill() usageStatus
kendo00Clean
brick-inventory00Clean
the-laboratory (all 5)00Clean
entreezuil23 call sitesMigration needed
ublgenie94 call sitesMigration 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

php
// ❌ 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

php
// ✅ 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() throws MassAssignmentException — catches accidental usage at runtime
  • $model->fill() throws MassAssignmentException — 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's PersonalAccessToken) — we don't control their internals
  • Database seeders and factories — these are test infrastructure, not business logic. Factories use $fillable internally; that's acceptable.

Options Considered

OptionVerdictReason
Keep $fillable as defense-in-depthRejectedRedundant with DTO pipeline. Creates false security, PHPStan blind spot, and maintenance overhead.
Use $guarded = [] (unguard all)RejectedWorse — removes the runtime safety net while keeping the untyped array pattern.
Ban mass assignment, require explicit property assignmentAcceptedFull 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 ($fillable list gone)
  • MassAssignmentException acts as runtime guard against accidental create()/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 $fillable list

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 via Model::unguard() in CreatesApplication) — no impact on test infrastructure

Risks

  • Third-party packages that call fill() or create() internally — Mitigation: package models are excluded from scope. If a package requires $fillable on our model (rare), document the exception in the architecture test.
  • Developer habit of reaching for ::create() — Mitigation: architecture test catches at CI time, MassAssignmentException catches at runtime.

Enforcement

WhatMechanismScope
No $fillable on modelsArchitecture test: ->not->toHaveProperty('fillable')App\Models
No $guarded on modelsArchitecture test: ->not->toHaveProperty('guarded')App\Models
No Model::create() in app codeArchitecture test: ->not->toUse(['Illuminate\Database\Eloquent\Builder::create'])App
No fill()/forceFill() in app codeArchitecture test: grep-based or custom ruleApp
Runtime safety netLaravel's default $guarded = ['*']All models without explicit $guarded

Reference Architecture Test

php
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

TerritoryStateNotes
kendoEvergreen41 models, 0 $fillable, 0 mass-assignment calls. Architecture test needed.
brick-inventoryEvergreen11 models, 0 $fillable, 0 mass-assignment calls. Architecture test needed.
the-laboratoryEvergreenAll 5 experiments clean. Architecture test in experiment template needed.
entreezuilEvergreenPR #36: $fillable stripped, mass-assignment methods banned, 3 architecture tests enforcing.
ublgenieIn ProgressPR #89 open (as of 2026-04-16). Originally 9 models with $fillable; Cartographer M3 verified 6 remaining. No ModelsTest.php arch test yet.

Architecture documentation for contributors and collaborators.