August 25, 2026
Mass Assignment in PHP and Laravel — When User Input Becomes More Than It Should
No SQL injection. No XSS. No path traversal. Just an unexpected field in a legitimate request and suddenly a user may be modifying data…
By Nchiminyi Jezreel
8 min read
No SQL injection. No XSS. No path traversal. Just an unexpected field in a legitimate request and suddenly a user may be modifying data they were never supposed to control.
This is the sixteenth article in a series on PHP and Laravel application security.
So far we have covered:
- Detecting SQL injection attempts in PHP logs
- Why URL encoding blinds most PHP security checks
- The decode bomb problem with unlimited URL decoding
- Why parameterized queries are the only real fix for SQL injection
- XSS prevention in Laravel and why {!! !!} is the line between safe and hacked
- How attackers enumerate your Laravel app before exploiting it
- File upload security — the file that isn't what it claims to be
- Path traversal in PHP — how ../ escapes your application
- Command injection in PHP — when
exec()becomes an attack surface - Broken access control in Laravel — why being logged in is not enough
- Secrets in Laravel — why
.envis only the beginning - Session security in PHP — what most developers get wrong
- Rate limiting in Laravel and PHP — how to stop brute force before it starts
- Security headers in PHP and Laravel — the lines that harden every response
- IDOR in PHP and Laravel — when changing one number exposes someone else's data
Mass assignment is often described as a Laravel vulnerability. The underlying problem is much broader: trusting user-controlled field names and values more than the application should.
Laravel gives this problem a specific name and provides built-in defenses. Plain PHP does not. But the security principle is the same across both:
Never let the client decide which application attributes it is allowed to modify.
The Core Problem
Your application receives a profile update request. A legitimate user sends:
name=John&email=john@example.comname=John&email=john@example.comAn attacker sends:
name=John&email=john@example.com&role=admin&is_admin=1&balance=99999name=John&email=john@example.com&role=admin&is_admin=1&balance=99999The HTTP method is the same. The endpoint is the same. The only difference is the extra fields.
If your application processes every field it receives without filtering which ones are allowed the attacker just wrote role, is_admin, and balance to your database through a profile update form.
No exploit. No special tool. Browser developer tools and knowledge of what column names to try.
Mass Assignment in Plain PHP
Laravel makes mass assignment easy to recognize because Eloquent has explicit methods like create() and update(). Plain PHP does not have a built-in mass assignment mechanism but plain PHP applications are equally vulnerable.
The problem appears when developers accept arbitrary request keys and map them directly into objects or database fields:
// Dangerous — client controls which properties are modified
foreach ($_POST as $key => $value) {
$user->$key = $value;
}// Dangerous — client controls which properties are modified
foreach ($_POST as $key => $value) {
$user->$key = $value;
}Now the client is choosing which properties the application modifies. A legitimate user updates their name and email. An attacker adds role=admin and is_admin=1 to the same request. Both are processed identically.
The dangerous assumption is:
"If the client sent the field, the application should process it."
That is an input-trust problem. The client should never decide which fields the application processes.
The correct plain PHP approach explicit allowlist:
$allowed = ['name', 'email'];
foreach ($allowed as $field) {
if (isset($_POST[$field])) {
$user->$field = $_POST[$field];
}
}$allowed = ['name', 'email'];
foreach ($allowed as $field) {
if (isset($_POST[$field])) {
$user->$field = $_POST[$field];
}
}Or more explicitly:
$data = [
'name' => $_POST['name'] ?? null,
'email' => $_POST['email'] ?? null,
];$data = [
'name' => $_POST['name'] ?? null,
'email' => $_POST['email'] ?? null,
];The attacker can send role=admin and is_admin=1 but those fields never enter the update operation because only name and email are in the allowlist.
Parameterized queries do not solve this:
This is a critical distinction. Parameterized queries protect against SQL injection they do not decide which fields a user is allowed to modify.
// Good SQL injection protection
$sql = "UPDATE users SET name = :name, email = :email WHERE id = :id";
$stmt = $pdo->prepare($sql);
$stmt->execute([':name' => $name, ':email' => $email, ':id' => $id]);// Good SQL injection protection
$sql = "UPDATE users SET name = :name, email = :email WHERE id = :id";
$stmt = $pdo->prepare($sql);
$stmt->execute([':name' => $name, ':email' => $email, ':id' => $id]);This query is safe from SQL injection. But the application still needs to decide separately that name and email are the only fields this request is allowed to change. SQL injection protection and mass assignment protection solve different problems. Both are required.
Mass Assignment in Laravel
Laravel's Eloquent ORM gives the vulnerability a specific name and provides built-in defenses. But those defenses only work when developers understand them and apply them correctly.
Code like this:
User::create($request->all());
$user->update($request->all());User::create($request->all());
$user->update($request->all());passes every field the user submitted directly to the model. $request->all() is the problem it contains whatever the attacker decides to send.
$fillable the allowlist approach:
class User extends Model
{
protected $fillable = [
'name',
'email',
'password',
];
}class User extends Model
{
protected $fillable = [
'name',
'email',
'password',
];
}Only fields listed in $fillable can be mass assigned. If an attacker submits role=admin or is_admin=1 those fields are silently ignored they are not in the allowlist so Eloquent will not write them.
$guarded the blocklist approach:
class User extends Model
{
protected $guarded = [
'role',
'is_admin',
'balance',
];
}class User extends Model
{
protected $guarded = [
'role',
'is_admin',
'balance',
];
}Everything except fields in $guarded can be mass assigned. The danger is that the blocklist becomes incomplete as the application grows. A developer adds a sensitive column six months later and forgets to add it to $guarded. An allowlist has a safer failure mode a newly added field is not automatically mass assignable unless explicitly added to $fillable.
$guarded = []the most dangerous configuration:
class User extends Model
{
protected $guarded = [];
}class User extends Model
{
protected $guarded = [];
}This disables mass assignment protection entirely. Every field can be mass assigned. You see this in tutorials and prototypes. In production it deserves particular scrutiny.
The forceFill() trap:
Laravel provides a method that bypasses mass assignment protection entirely:
// This ignores $fillable and $guarded completely
$user->forceFill($request->all())->save();// This ignores $fillable and $guarded completely
$user->forceFill($request->all())->save();forceFill() is legitimate for trusted internal operations database seeding, administrative scripts, migration tooling where you control the data. Using it with user-supplied input removes the model-level safety boundary completely.
Search your codebase now:
grep -r "forceFill" app/grep -r "forceFill" app/Every result needs manual review. If any forceFill() call receives data that originates from user input directly or indirectly it is a vulnerability.
One critical point: $fillable does not replace authorization. A request can contain perfectly valid fields and still come from a user who is not allowed to modify that resource. Mass assignment protection and access control solve different problems. Both are required.
Beyond Role Escalation The Scenarios Developers Miss
Most developers know to protect role and is_admin. Here are the scenarios they miss:
Email verification bypass:
email=attacker@example.com&email_verified_at=2024-01-01email=attacker@example.com&email_verified_at=2024-01-01A user changes their email and simultaneously marks it as verified skipping your verification flow entirely.
Balance manipulation:
amount=10&balance=99999amount=10&balance=99999A user submits a legitimate transaction amount but also sets their own account balance.
Soft delete recovery:
name=John&deleted_at=nullname=John&deleted_at=nullA user recovers their soft-deleted account or restores deleted content by setting deleted_at to null.
Timestamp manipulation:
name=John&created_at=2020-01-01name=John&created_at=2020-01-01A user changes when their account was created useful for bypassing time-based restrictions or trial period limits.
Billing relationship hijacking:
name=John&stripe_customer_id=cus_someone_elses_idname=John&stripe_customer_id=cus_someone_elses_idA user points their account to another customer's Stripe billing relationship accessing another customer's payment methods.
Every one of these is possible when mass assignment protection is absent or incomplete.
The Three Layers of Protection
Strong protection uses all three layers. Each catches what the previous one might miss.
Layer 1 — Model level with $fillable:
class User extends Model
{
protected $fillable = [
'name',
'email',
'password',
];
}class User extends Model
{
protected $fillable = [
'name',
'email',
'password',
];
}Your safety net. Even if a developer makes a mistake in the controller the model-level protection limits the damage.
Layer 2 — Controller level with $request->only():
public function update(Request $request)
{
$request->user()->update(
$request->only(['name', 'email'])
);
return redirect('/profile')->with('success', 'Profile updated.');
}public function update(Request $request)
{
$request->user()->update(
$request->only(['name', 'email'])
);
return redirect('/profile')->with('success', 'Profile updated.');
}$request->only() extracts only the fields you specify. Even if the attacker sends additional fields they never reach the model.
Layer 3 — Form Request with $request->validated():
// app/Http/Requests/UpdateProfileRequest.php
class UpdateProfileRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'email',
'unique:users,email,' . $this->user()->id,
],
];
}
}
public function update(UpdateProfileRequest $request)
{
$request->user()->update($request->validated());
return redirect('/profile')->with('success', 'Profile updated.');
}// app/Http/Requests/UpdateProfileRequest.php
class UpdateProfileRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'email',
'unique:users,email,' . $this->user()->id,
],
];
}
}
public function update(UpdateProfileRequest $request)
{
$request->user()->update($request->validated());
return redirect('/profile')->with('success', 'Profile updated.');
}$request->validated() only returns fields that have validation rules defined. No rule means no field regardless of what the user submitted. Validation and field filtering happen in the same place. This is the cleanest and most maintainable approach.
API Mass Assignment — The Most Dangerous Context
API endpoints are where mass assignment is most commonly overlooked. When a form submits extra fields developers sometimes notice because they can see the HTML. When a JSON API body contains extra fields there is no visible form to audit.
// Dangerous API endpoint
Route::put('/api/profile', function (Request $request) {
$request->user()->update($request->all());
return response()->json(['message' => 'Updated']);
})->middleware('auth:sanctum');// Dangerous API endpoint
Route::put('/api/profile', function (Request $request) {
$request->user()->update($request->all());
return response()->json(['message' => 'Updated']);
})->middleware('auth:sanctum');An attacker sends a crafted JSON body:
{
"name": "John",
"email": "john@example.com",
"role": "admin",
"is_admin": true,
"balance": 99999,
"stripe_customer_id": "cus_someone_elses_id"
}{
"name": "John",
"email": "john@example.com",
"role": "admin",
"is_admin": true,
"balance": 99999,
"stripe_customer_id": "cus_someone_elses_id"
}If the model has no $fillable protection every field gets written to the database.
The safe API pattern:
// Using only() to filter
Route::put('/api/profile', function (Request $request) {
$request->user()->update(
$request->only(['name', 'email'])
);
return response()->json(['message' => 'Updated']);
})->middleware('auth:sanctum');// Using only() to filter
Route::put('/api/profile', function (Request $request) {
$request->user()->update(
$request->only(['name', 'email'])
);
return response()->json(['message' => 'Updated']);
})->middleware('auth:sanctum');Or with a Form Request:
public function update(UpdateProfileRequest $request)
{
$request->user()->update($request->validated());
return response()->json(['message' => 'Updated']);
}public function update(UpdateProfileRequest $request)
{
$request->user()->update($request->validated());
return response()->json(['message' => 'Updated']);
}Auditing Your Existing Codebase
If you have an existing PHP or Laravel application run these searches now:
# Plain PHP — dynamic property assignment from request data
grep -r "foreach (\$_POST" app/
grep -r "\$_POST\[" app/
# Laravel - update() or create() with all() - highest risk
grep -r "->update(\$request->all())" app/
grep -r "::create(\$request->all())" app/
grep -r "->update(\$request->input())" app/
# Laravel - forceFill usage - needs manual review
grep -r "forceFill" app/
# Laravel - models with no fillable or guarded
grep -rL "fillable\|guarded" app/Models/# Plain PHP — dynamic property assignment from request data
grep -r "foreach (\$_POST" app/
grep -r "\$_POST\[" app/
# Laravel - update() or create() with all() - highest risk
grep -r "->update(\$request->all())" app/
grep -r "::create(\$request->all())" app/
grep -r "->update(\$request->input())" app/
# Laravel - forceFill usage - needs manual review
grep -r "forceFill" app/
# Laravel - models with no fillable or guarded
grep -rL "fillable\|guarded" app/Models/Each result is a potential mass assignment vulnerability that deserves manual review.
The Mass Assignment Checklist
For plain PHP:
- Never use
foreach ($_POST as $key => $value)to map request data to object properties - Always define an explicit allowlist of fields before processing request data
- Never assume parameterized queries protect against mass assignment they solve a different problem
- Review every place request data is mapped to database columns or object properties
For Laravel:
- Every model that accepts user input has
$fillabledefined $guarded = []does not appear in any production model without scrutiny- Every controller uses
$request->only()or$request->validated()never$request->all()withupdate()orcreate() - Every API endpoint filters fields explicitly JSON bodies are as dangerous as form submissions
forceFill()is only used with controlled internal data never with user input- Sensitive fields are explicitly absent from
$fillable role,is_admin,balance,email_verified_at,deleted_at,stripe_customer_id,created_at - Form Requests are used for complex update operations
$request->validated()is the default pattern $fillableis used alongside authorization mass assignment protection and access control are separate concerns
Where Kriosa Fits
Mass assignment vulnerabilities are prevented at the application layer by allowlists in plain PHP and by $fillable, $request->only(), and Form Requests in Laravel. A request containing extra fields looks identical to a legitimate request at the network level.
What Kriosa adds is visibility into the reconnaissance that precedes exploitation. Attackers probing for mass assignment vulnerabilities often send systematic requests testing which fields get written, checking responses for evidence that extra fields were accepted, scanning error messages for column names that reveal the database structure.
That probing behavior not the exploit itself is what a request-level security layer can surface. Unusual field patterns in update requests, systematic testing of sensitive field names, and behavioral anomalies that precede a successful mass assignment attack can appear in the XAI dashboard before the attacker finds what they are looking for.
Prevention through allowlists and Form Requests stops the breach. Detection through behavioral monitoring tells you someone is looking for one.
In Prolify every client review workflow update goes through a Form Request. $request->validated() is the only way data reaches the model.
In bellefull every order update uses scoped queries and explicit field filtering. Neither application processes $request->all() directly.
Try it free: kriosa.com Install it: composer require kriosa-ai/kriosa-php
Documentation : Kriosa Documentation
Built by a developer from Cameroon, for developers who want to understand their security — not just outsource it.
The Series So Far
- Article 1: What your PHP logs actually look like during a SQL injection attack
- Article 2: Why URL encoding can break PHP security checks
- Article 3: The decode bomb problem — why unlimited URL decoding can be its own vulnerability
- Article 4: Parameterized queries — the only real fix for SQL injection
- Article 5: XSS prevention in Laravel and why
{!! !!}is the line between safe and hacked - Article 6: How attackers enumerate your Laravel app before exploiting it
- Article 7: File upload security in PHP and Laravel
- Article 8: Path traversal in PHP — how
../escapes your application - Article 9: Command injection in PHP — when
exec()becomes an attack surface - Article 10: Broken access control in Laravel — why being logged in is not enough
- Article 11: Secrets in Laravel — why
.envis only the beginning - Article 12: Session security in PHP — what most developers get wrong
- Article 13: Rate limiting in Laravel and PHP — how to stop brute force before it starts
- Article 14: Security headers in PHP and Laravel — the lines that harden every response
- Article 15: IDOR in PHP and Laravel — when changing one number exposes someone else's data
- Article 16: This article — mass assignment in PHP and Laravel and when user input becomes more than it should