An Eloquent model sets $guarded = [] and a controller calls Reservation::create($request->all()); why is that a mass-assignment hole, and how do you harden it?
answer
- $request->all() returns every posted key
- empty guarded list means no protection
- allow-list with #[Fillable]
- forceFill() for server-owned fields
- preventSilentlyDiscardingAttributes outside production
basics
~20 sWith $guarded = [] every posted key that names a column is written, so a user can add is_vip or user_id to the request. Harden it with a $fillable allow-list, explicit input arrays, server-set privileged fields and strict discarding in development.
solid answer
~30 s`$guarded = []` disables mass-assignment protection, and `$request->all()` returns every key the client sent, not just the form's fields. So an attacker who adds `is_vip=1`, `rate_cents=100` or `user_id=17` to the POST body gets those columns written, with no error. To harden it, switch the model to an allow-list with `$fillable` or `#[Fillable([...])]`, pass only the validated or explicitly picked fields to `create()`, and set server-owned columns such as the owner or the price yourself by direct assignment or `forceFill()`. Then call `Model::preventSilentlyDiscardingAttributes(! $this->app->isProduction())` in `AppServiceProvider::boot()` so a dropped key throws during development, and keep `Model::unguard()` out of application code.
go deeper
Recall that request input contains whatever the client sends, and that $guarded = [] lets all of it reach the model.
Explain the chain from $request->all() through fill() to the insert, and why an allow-list plus explicit input stops it.
Lay out the layered fix: allow-lists, server-set privileged fields via forceFill(), strict discarding outside production and a regression test that posts an extra field.
Decide how strictness is enforced across teams: a lint rule or review checklist banning $guarded = [], and whether production should throw or log discarded keys.
## The hole **Mass assignment** is Eloquent's ability to set many attributes from one array. A **mass-assignment vulnerability** happens when that array comes straight from the request and the model accepts keys the form never showed. Consider a hotel booking endpoint: ```php class Reservation extends Model { protected $guarded = []; } // in a controller action $reservation = Reservation::create($request->all()); ``` `$guarded = []` means "nothing is guarded". `$request->all()` returns every input key the client sent, not only the ones the form rendered. An attacker adds fields to the POST body: - `is_vip=1` to unlock perks; - `rate_cents=100` to pay one euro a night; - `paid_at=2026-10-01` to mark the stay as paid; - `user_id=17` to book on someone else's account. Each of these is a real column, so each is written. Nothing errors, nothing logs. ## Why Eloquent's defaults did not stop it Out of the box a model is **totally guarded** (`$fillable = []`, `$guarded = ['*']`) and `create()` throws `MassAssignmentException`. Developers who hit that exception often "fix" it with `$guarded = []` or `#[Unguarded]`, which removes the protection completely instead of choosing what to allow. The only filters left are small ones: when `$fillable` is empty, keys containing a dot or starting with an underscore are still refused. ## Hardening, in order of impact 1. **Allow-list the model.** Replace `$guarded = []` with `$fillable` or Laravel 13's `#[Fillable(['guest_name', 'check_in', 'check_out', 'room_type'])]`. A new column stays closed until someone opens it deliberately. 2. **Never hand a raw request array to a write.** Pass only the fields you validated or picked explicitly; the validated subset of the request is the usual source. 3. **Set server-owned fields on the server.** Assign `user_id`, `rate_cents` and `paid_at` from the authenticated user and your pricing logic, by direct assignment or `forceFill()`, which exist precisely to bypass the lists for trusted values. 4. **Make silent drops loud in development.** `Model::preventSilentlyDiscardingAttributes(! $this->app->isProduction())` in `AppServiceProvider::boot()` throws `MassAssignmentException` whenever `fill()` discards a key, so a missing fillable entry is caught in development instead of shipping as a lost field. `Model::shouldBeStrict()` turns it on together with other strictness checks. 5. **Report instead of throwing if you want production visibility.** `Model::handleDiscardedAttributeViolationUsing(fn ($model, $keys) => ...)` receives the model and the discarded keys, so you can log them rather than fail the request. 6. **Keep `Model::unguard()` out of application code.** It disables protection globally; `db:seed` already wraps seeders in `Model::unguarded()`. ## What `$guarded` still does for you A specific deny-list is not useless: `isGuarded()` rejects listed columns case-insensitively and also any key that is not an actual column in the table's schema listing, unless a mutator or class cast claims it. That blocks JSON-path keys such as `options->is_vip` from sneaking past a deny-list. But the deny-list still exposes every new column by default, so it is the weaker choice for user-facing models. ## Checking your fix | Situation | Result after hardening | |---|---| | POST includes `is_vip=1` | dropped by the allow-list; throws in development with strict discarding on | | POST includes `user_id=17` | never read; the controller sets the owner from the authenticated user | | New column `discount_pct` added later | closed until added to the fillable list | | Seeder creates VIP reservations | still works; seeding runs unguarded | A feature test that posts an extra privileged field and asserts it was not stored keeps the fix from regressing. ## Spotting it in code review These patterns deserve a second look on any model or controller that handles user input: - `protected $guarded = [];` or `#[Unguarded]` on a model that is written from requests; - `->all()` or `->input()` passed straight into `create()`, `update()`, `fill()` or `new Model(...)`; - `Model::unguard()` called outside seeders or one-off console commands; - `forceFill()` fed from request input, which bypasses the lists by design; - a new sensitive column added to `$fillable` in the same change that introduced it. None of these is automatically a bug, but each removes one layer of the defence described above.
- If you switch the model to `$fillable` but keep `create($request->all())`, is the endpoint safe?Safer, not safe. The allow-list now drops `is_vip` and `user_id`, but any column you list as fillable is still writable with whatever unvalidated value the client posts, and a developer who later adds a sensitive column to the list reopens the hole. Pairing the allow-list with an explicit, validated input array closes both gaps.
- What does `handleDiscardedAttributeViolationUsing()` add over `preventSilentlyDiscardingAttributes()`?`preventSilentlyDiscardingAttributes()` decides whether a discarded key is a violation; by default a violation throws `MassAssignmentException`. `handleDiscardedAttributeViolationUsing()` registers a callback that receives the model and the discarded keys instead of throwing, so you can log unexpected fields in production without breaking the request.
saying these in an interview costs you the question
- $guarded = [] is fine because the HTML form only shows safe fields
- $request->all() only returns fields that were rendered in the form
- Mass-assignment protection also stops direct $model->is_vip = true writes
- Calling Model::unguard() in AppServiceProvider is a harmless convenience
- forceFill() is a security risk that should never appear in application code