- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
PHP's reputation for being a security disaster is mostly a 2008 reputation. The runtime is fine. The frameworks default to safe. PDO with prepared statements has been the norm for fifteen years. And yet, every quarter, a postmortem lands on Twitter that reads like a Wordpress 3 changelog: SQL injection, mass assignment, an unverified webhook, a file upload running as PHP, a redirect to an attacker's site.
The bugs didn't go away. They moved. They live in the gaps between the framework's safe defaults and the shortcuts developers take when shipping fast. Five patterns. Each one a real-world bug that's still hitting Laravel and Symfony codebases in 2026.
Bug 1: SQL injection via DB::statement() and raw concatenation
Laravel's query builder is safe. Eloquent is safe. The escape hatch (DB::statement(), DB::raw(), whereRaw()) is not.
The pattern shows up when someone needs "just a quick raw query" for an admin report or a migration helper:
public function search(Request $request)
{
$term = $request->input('q');
// looks innocent. it isn't.
$sql = "SELECT id, title FROM posts WHERE title LIKE '%{$term}%'";
return DB::select($sql);
}
Send q=%' UNION SELECT password, email FROM users-- and you've handed over the users table.
The trap is that DB::select() does support bindings. Developers skip them because string interpolation is shorter. Same for whereRaw():
// vulnerable
Post::whereRaw("user_id = {$request->user_id}")->get();
// safe
Post::whereRaw('user_id = ?', [$request->user_id])->get();
The one-liner fix: never interpolate into a SQL string. Ever. Bindings everywhere, even for integers you "know" are safe. The "I validated it earlier" version of this code keeps showing up in incident reports because someone refactors the validation away two releases later.
If you need dynamic table or column names (genuinely the only case where bindings can't help), allowlist them:
$allowedSort = ['created_at', 'title', 'updated_at'];
$sort = in_array($request->sort, $allowedSort, true)
? $request->sort
: 'created_at';
Post::orderBy($sort)->get();
The true on in_array is strict comparison. Without it, 0 == 'created_at' returns true in older PHP versions. PHP 8 fixed that comparison rule, but the habit is still worth keeping.
Bug 2: Mass assignment via $guarded = []
Eloquent has two ways to control which attributes are mass-assignable: $fillable (allowlist) and $guarded (blocklist). The docs treat them as equivalent. They are not.
class User extends Model
{
// the "I'll fix this later" version
protected $guarded = [];
}
$guarded = [] means nothing is protected. Now this happens:
public function update(Request $request, User $user)
{
$user->update($request->all());
return $user;
}
The user sends is_admin=1 or email_verified_at=2026-01-01 or stripe_customer_id=cus_attacker in the request body. Eloquent dutifully writes it to the database. You've shipped privilege escalation.
This pattern shows up in code reviews constantly. The reasoning is always the same: "we validate the request in form requests, so it's fine." Then someone adds a new column, forgets to update the validator, and the blocklist quietly accepts it.
The one-liner fix: $fillable, not $guarded. Be explicit about what users can write:
class User extends Model
{
protected $fillable = ['name', 'email', 'avatar_url'];
}
When a new column gets added (is_admin, subscription_tier, internal_notes), Eloquent rejects it from mass assignment by default. You have to opt in. That's the property you want.
Laravel 9+ ships with Model::shouldBeStrict() which throws on unguarded mass assignment in non-production. Turn it on in AppServiceProvider::boot():
// catches real bugs in staging before they reach prod
Model::shouldBeStrict(! $this->app->isProduction());
If you absolutely must use $guarded, list every sensitive column. But $fillable is the right default. The "blocklist things I don't want assigned" mental model is the same one that gave us XSS filters in the 2000s. It doesn't scale.
Bug 3: Unverified webhook signatures
Stripe sends you a webhook. GitHub sends you a webhook. Your queue worker picks it up and credits the user's account. Beautiful. What's missing?
public function stripe(Request $request)
{
$event = json_decode($request->getContent(), true);
if ($event['type'] === 'invoice.paid') {
Subscription::where('stripe_id', $event['data']['object']['customer'])
->update(['active' => true]);
}
}
No signature check. Anyone with your webhook URL can POST a fake invoice.paid event and activate any subscription they want. And that URL leaks. It shows up in load balancer logs, error tracking tools, accidentally-public Postman collections.
Stripe signs every webhook with HMAC-SHA256. The signature comes in the Stripe-Signature header. Verifying it is one line with the official SDK:
use Stripe\Webhook;
public function stripe(Request $request)
{
$payload = $request->getContent();
$sig = $request->header('Stripe-Signature');
$secret = config('services.stripe.webhook_secret');
try {
$event = Webhook::constructEvent($payload, $sig, $secret);
} catch (\UnexpectedValueException | \Stripe\Exception\SignatureVerificationException $e) {
return response()->json(['error' => 'invalid signature'], 400);
}
// now $event is safe to act on
}
GitHub uses HMAC-SHA256 too, with the secret you set when configuring the webhook. The header is X-Hub-Signature-256:
$payload = $request->getContent();
$signature = 'sha256=' . hash_hmac('sha256', $payload, config('services.github.secret'));
if (! hash_equals($signature, $request->header('X-Hub-Signature-256'))) {
abort(401);
}
hash_equals() is the important part. A naive === comparison is vulnerable to timing attacks. hash_equals does constant-time comparison. Use it for any HMAC or token check.
The one-liner fix: verify the signature before you trust a single byte of the payload. Even the event type. The whole payload is attacker-controlled until verified.
Bug 4: getClientOriginalExtension() is user-controlled
File uploads are where security and product velocity collide. The framework gives you $request->file('avatar') and an upload helper, and the code looks safe enough:
public function upload(Request $request)
{
$file = $request->file('avatar');
$name = uniqid() . '.' . $file->getClientOriginalExtension();
$file->move(public_path('avatars'), $name);
return ['url' => "/avatars/{$name}"];
}
Two bugs in five lines.
First, getClientOriginalExtension() reads the extension from the filename the client sent. Send a file named shell.php with Content-Type: image/png and that's what gets saved. The MIME type validation everyone adds (mimes:jpg,png) doesn't protect you, because the file ends up at /avatars/abc.php, and the web server happily executes it.
Second, even if you reject .php, attackers will try .phtml, .phar, .pht, .php3, .php7, .inc. Apache and PHP-FPM execute several of these depending on configuration.
The one-liner fix is actually two things. Decide the extension yourself based on the actual MIME type, not the client's claim:
$mime = $file->getMimeType(); // reads the file's magic bytes via finfo
$allowed = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/webp' => 'webp',
];
if (! isset($allowed[$mime])) {
abort(422, 'unsupported file type');
}
$name = uniqid() . '.' . $allowed[$mime];
Then store user uploads outside the document root. This part matters as much as the extension check. Serve them through a controller that sets Content-Disposition: attachment for non-image types and streams from disk. Better still, push them to S3 with a private bucket and serve signed URLs. The attacker can upload whatever they want; if it never lives where the web server executes PHP, it's just bytes on a disk.
Bug 5: Open redirect on return_url
Your login flow has a ?return_url=/dashboard parameter so users land back where they started after authenticating. Helpful. Also a phishing vector:
public function login(Request $request)
{
if (Auth::attempt($request->only('email', 'password'))) {
return redirect($request->input('return_url', '/'));
}
}
The attacker sends a victim a link to your login page: https://yourapp.com/login?return_url=https://yourapp-evil.com/login. Victim logs in (real domain, real cert, looks fine). Your app obediently redirects them to the attacker's lookalike login page, where they re-enter credentials thinking the first attempt failed.
This is called open redirect, and it's catnip for phishing because the initial link is to your real domain. Email scanners, link previews, and the user's spider-sense all check out.
Laravel's redirect()->intended() has the same bug if you're not careful. It honors the url.intended session value, which gets set from redirect()->guest(). The guest() call reads the current request URL, which is fine on its own, but plenty of codebases pass attacker-controlled URLs into intended($default) for the default case.
The one-liner fix: never redirect to a user-supplied absolute URL. Treat the parameter as a path, validate it starts with / (and not //, which browsers parse as protocol-relative), and reject anything else:
private function safeReturnUrl(?string $url): string
{
if (! $url || ! str_starts_with($url, '/') || str_starts_with($url, '//')) {
return '/dashboard';
}
return $url;
}
public function login(Request $request)
{
if (Auth::attempt($request->only('email', 'password'))) {
return redirect($this->safeReturnUrl($request->input('return_url')));
}
}
If you genuinely need to redirect to external URLs (OAuth flows, payment gateways), keep an allowlist of trusted domains and check parse_url($url, PHP_URL_HOST) against it. Don't try to write a generic "is this URL safe" function. That code path is where every CVE in this category lives.
A 60-line scanner that catches all five in CI
You can buy a SAST tool. You can also write a focused scanner in an afternoon that catches these five patterns specifically, runs in three seconds on a CI box, and never gives you a 4000-finding report you'll learn to ignore.
#!/usr/bin/env php
<?php
// scripts/security-scan.php: run in CI, exits non-zero on findings
$root = $argv[1] ?? __DIR__ . '/../app';
$findings = [];
$rules = [
'sql_concat' => [
'/DB::(select|statement|raw)\s*\(\s*["\'][^"\']*\$[a-z_]/i',
'SQL string interpolation in DB:: call. Use bindings.',
],
'where_raw' => [
'/whereRaw\s*\(\s*["\'][^"\']*\$[a-z_][^"\']*["\']\s*\)/i',
'whereRaw with interpolation. Pass bindings as 2nd arg.',
],
'guarded_empty' => [
'/protected\s+\$guarded\s*=\s*\[\s*\]/i',
'$guarded = [] disables mass-assignment protection. Use $fillable.',
],
'request_all_update' => [
'/->update\(\s*\$request->all\(\)\s*\)/i',
'->update($request->all()) without validation/explicit fields',
],
'client_extension' => [
'/getClientOriginalExtension\s*\(\s*\)/',
'getClientOriginalExtension() is client-supplied. Derive from MIME.',
],
'redirect_input' => [
'/redirect\s*\(\s*\$request->(input|get|query)/i',
'redirect() with raw request input. Validate as internal path first.',
],
];
$it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root));
foreach ($it as $file) {
if (! $file->isFile() || $file->getExtension() !== 'php') continue;
$lines = file($file->getPathname());
foreach ($lines as $i => $line) {
foreach ($rules as $key => [$pattern, $msg]) {
if (preg_match($pattern, $line)) {
$findings[] = sprintf(
"%s:%d [%s] %s\n %s",
$file->getPathname(), $i + 1, $key, $msg, trim($line)
);
}
}
}
}
// webhook check: any route to /webhook* that doesn't reference hash_equals or constructEvent
// (left as exercise; pattern depends on your route file structure)
if ($findings) {
echo "Found " . count($findings) . " security issue(s):\n\n";
echo implode("\n\n", $findings) . "\n";
exit(1);
}
echo "No findings.\n";
Add this to your CI:
- name: Security scan
run: php scripts/security-scan.php app/
It's not perfect. It misses things a real SAST catches. But it catches the five bugs that show up in real PHP postmortems, runs faster than your test suite, and you can extend it for whatever pattern just bit your team last sprint.
Security isn't a tool you buy. It's the patterns you reject in code review, the defaults you set, and the small scanner that fails the build before bad code lands.
Which of these five has bitten your team most recently? Or is there a sixth you'd add to the list?
If this was useful
These five bugs are the most common ones, but they share a deeper pattern: they live at the boundary between your domain and the outside world (HTTP requests, file systems, external services). When that boundary is a clear line your code crosses with intent, bugs like these stand out. When the framework's defaults paper over the boundary, they hide. Decoupled PHP is about drawing that line and building applications where the security-sensitive edges are explicit, isolated, and easy to audit instead of scattered through controllers and Eloquent calls.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)