Laravel powers a large share of production PHP applications, and most of its security defaults are genuinely good — CSRF middleware ships on by default, Eloquent parameterizes query bindings, and Composer has shipped a built-in composer audit command since version 2.4 landed in August 2022. Yet the framework's own documentation still has to warn developers, model by model, about mass assignment: leave protected $guarded = []; on an Eloquent model and every field on an incoming request — including one an attacker adds by hand, like is_admin — becomes writable through create() or update(). That single misconfiguration maps directly to CWE-915 (improperly controlled modification of dynamically-determined object attributes), and it is one of five recurring risk areas that show up across Laravel codebases regardless of how experienced the team is: mass assignment, raw-query injection, CSRF misconfiguration, unsafe file uploads, and unmanaged Composer dependencies. None of these require an exotic attack — each is a documented framework behavior that becomes a vulnerability only when a default gets overridden or a shortcut gets taken under deadline pressure. This post walks through each one and the concrete Laravel-native fix.
How does mass assignment actually create a vulnerability in Eloquent?
Mass assignment creates a vulnerability when a model accepts more attributes from a request than the developer intended to expose, because create(), update(), and fill() all write whatever array they're given unless the model restricts it. Laravel gives you two controls: $fillable, an allow-list of attributes that can be mass-assigned, and $guarded, a deny-list of attributes that cannot. The dangerous pattern is protected $guarded = [];, which tells Eloquent nothing is protected — combined with a controller that calls User::create($request->all()), an attacker who adds is_admin=1 or role=admin to their registration POST body gets exactly that field written to the database. Mass assignment protection via $fillable/$guarded has been part of Eloquent since Laravel's early versions and remains true in current releases. The fix is to always define $fillable explicitly on models tied to user input, and to use $request->only([...]) or Form Request validation classes rather than $request->all() so the allow-list exists at both the model and controller layer.
Where does Eloquent's injection safety actually break down?
Eloquent's injection safety breaks down specifically at its raw-query escape hatches: whereRaw(), DB::raw(), selectRaw(), and orderByRaw(). The query builder and standard Eloquent methods use parameter binding by default — User::where('email', $input) is safe because $input is passed as a bound parameter, never concatenated into SQL text. But the moment a developer needs a computation the builder doesn't support and drops into DB::raw("... {$request->input('sort')} ..."), that binding protection disappears, and the interpolated value flows straight into the query string — a textbook CWE-89 SQL injection path. This is not a Laravel bug; it is a documented behavior of every one of these raw methods, and the framework's own docs note that raw expressions are injected into the query as-is. The safe pattern when raw SQL is genuinely necessary is to still pass bindings as a second argument — whereRaw('price > ?', [$price]) — rather than string-interpolating user input directly into the raw expression.
What's the most common way teams accidentally disable CSRF protection?
The most common way teams accidentally disable CSRF protection is by adding routes to the $except array on VerifyCsrfToken, Laravel's CSRF middleware that sits in the web middleware group and validates the _token field (or X-CSRF-TOKEN header) on every state-changing request. $except exists for a real reason — webhook endpoints from third parties like payment processors can't attach a Laravel session token — but it's frequently used more broadly than intended, for example to silence CSRF failures during API development instead of properly separating API routes (which should use token or Sanctum-based auth, not session CSRF) from web routes. Blade's @csrf directive embeds the hidden token field automatically in any form, and Laravel's default resources/views scaffolding includes it, so most CSRF failures in practice come from either a genuinely excepted route that shouldn't be, or an AJAX call that forgot to send the X-CSRF-TOKEN header pulled from the csrf-token meta tag. Auditing the $except array during every security review is a cheap, high-value check.
Why is extension-based file upload validation not enough?
Extension-based validation isn't enough because a file's extension is metadata the client controls, not evidence of what the file actually contains, and Laravel exposes both the unsafe shortcut and the safe pattern side by side. Calling $request->file('avatar')->getClientOriginalExtension() and checking it against an allow-list validates a string the browser sent, which an attacker can set to anything regardless of the file's real bytes — this is the general CWE-434 unrestricted-upload pattern, and it becomes remote code execution the moment a .php payload disguised with a .jpg-adjacent name lands in a web-servable directory. Laravel's documented safe pattern instead validates the request through the file, mimes:, or mimetypes: validation rules — which inspect actual file content via finfo, not just the filename — combined with a max: size limit, and then stores the result with store() or storeAs() using a generated filename rather than the client-supplied one. Storing uploads outside the public web root, or on a disk like S3 that never executes code, closes the gap even if a validation rule is ever misconfigured.
How does Composer fit into a Laravel dependency hygiene program?
Composer fits in as the layer where known-vulnerable PHP packages — including the transitive ones nobody remembers adding — actually get caught, provided the tooling is run. composer.lock pins every dependency to an exact resolved version so builds are reproducible, and since Composer 2.4 in 2022, composer audit checks everything in that lock file against the FriendsOfPHP/security-advisories database, the same feed several PHP security scanners draw from. The cross-ecosystem lesson from Alex Birsan's 2021 dependency-confusion research — where public package registries were tricked into serving attacker-uploaded packages instead of internal ones — and from incidents like the xz-utils backdoor apply here too: a composer.lock you never re-audit is a supply chain you're trusting blind. Safeguard's software composition analysis resolves composer.lock directly, alongside npm, PyPI, Maven, Go, Cargo, NuGet, and Bundler manifests, so a vulnerable transitive PHP package pulled in by a Laravel package three levels deep shows up in the same inventory as everything else in a polyglot stack, instead of requiring a separate PHP-only audit workflow.