PHP has been running the majority of the server-side web since before "Web 2.0" was a phrase, and its most-exploited bug classes have barely changed in two decades. The mysql_* extension — the source of countless string-concatenated SQL injection bugs — wasn't removed from the language core until PHP 7.0, released in December 2015, nearly a decade after it was first deprecated. PHP Object Injection, the mechanism behind gadget-chain remote code execution bugs like CVE-2018-14630 in Moodle, still depends on the same unserialize() function that shipped in PHP 4. And Remote File Inclusion, once one of the most devastating bug classes on the web, was only closed off by default when allow_url_include flipped to Off starting in PHP 5.2. OWASP's 2021 Top 10 folded cross-site scripting into Injection (A03:2021) and rebuilt the old deserialization category into the broader A08:2021 "Software and Data Integrity Failures." This post walks through why these three classes keep resurfacing in PHP codebases specifically, and what actually stops them in 2026.
Why is SQL injection still common in PHP applications?
SQL injection persists in PHP because the language has always offered a fast, insecure path alongside the safe one, and legacy code habits die slowly. The original mysql_* extension had no native support for parameterized queries, so a generation of PHP developers learned to build SQL strings with direct variable interpolation — a pattern that survived in production code long after the extension itself was deprecated in PHP 5.5 (2013) and removed entirely in PHP 7.0 (2015). CWE-89, the weakness class behind SQL injection, sits inside OWASP's A03:2021 Injection category alongside OS command injection and path traversal. The fix has been available for just as long: PDO, introduced in PHP 5.1 (2005), and mysqli, both support prepared statements with bound parameters, which separate the SQL structure from the data and make injection structurally impossible for that query. Modern frameworks push this further — Laravel's Eloquent ORM and Symfony's Doctrine both parameterize queries by default, meaning a developer has to actively opt out (typically via DB::raw() or a raw SQL builder call) to reintroduce the vulnerability.
What makes PHP deserialization uniquely dangerous?
PHP deserialization is uniquely dangerous because unserialize() doesn't just rebuild data — it can silently instantiate arbitrary classes and invoke their magic methods before your application logic ever runs. When attacker-controlled data reaches unserialize(), PHP will construct any object described in the serialized string and automatically call methods like __wakeup() or __destruct() if that class defines them. Security researchers call the resulting attack a POP chain (property-oriented programming): an attacker doesn't need to inject new code, only find a chain of already-loaded classes whose magic methods, when triggered in sequence, add up to file writes, SQL execution, or full remote code execution. CVE-2018-14630 in Moodle is a documented real-world example of exactly this pattern. PHP's own mitigation has existed since the allowed_classes option was added to unserialize() in PHP 7.0, letting a call restrict deserialization to a known-safe list of classes, or pass false to disallow objects entirely. The more durable fix is architectural: never call unserialize() on data that crossed a trust boundary, and use json_decode() for anything coming from a client, cookie, or cache that an attacker could influence.
How did Remote File Inclusion get eliminated as a default risk?
Remote File Inclusion (RFI) got eliminated as a default risk through a single configuration change rather than through code-level fixes: PHP disabled allow_url_include by default starting in PHP 5.2, cutting off the ability for include() or require() to fetch and execute a remote URL like http://attacker.example/shell.txt unless an administrator deliberately re-enabled it. That directive shift is one of the most consequential security defaults in PHP's history, because RFI at its peak allowed one-shot remote code execution against any application that passed unsanitized user input into an include path. Local File Inclusion (LFI, CWE-73 path traversal) is the half of this bug class that never went away, because it doesn't depend on a URL-fetching setting — it depends on whether an application resolves a user-supplied filename without validation. OWASP groups both under Injection (A03:2021) rather than as a standalone category. The current mitigation guidance is allow-listing valid filenames or template identifiers and calling realpath() to confirm the resolved path stays inside an expected directory, rather than relying on blacklisting strings like ../, which is trivially bypassed with encoding tricks or null-byte variants on older PHP versions.
How does command injection differ from the other two classes, and how is it stopped?
Command injection differs from SQL injection and file inclusion because the "query language" being manipulated is the operating system's shell, not a database or filesystem API, which makes the blast radius of a successful exploit larger by default. PHP functions like exec(), shell_exec(), system(), and passthru() hand a string straight to the shell, and if any part of that string comes from user input without escaping, an attacker can chain additional commands using shell metacharacters like ;, |, or backticks. PHP's built-in mitigations, escapeshellarg() and escapeshellcmd(), have existed for this exact purpose since early PHP 4, wrapping or escaping arguments so shell metacharacters lose their special meaning. Both functions are still commonly misused, though — escapeshellarg() protects a single argument, not an entire command string, and mixing it up with escapeshellcmd() is a recurring source of bypasses. The pattern security teams now recommend over either function is avoiding shell invocation entirely: PHP's proc_open() with an array of arguments (rather than a concatenated string) passes arguments directly to the target process without a shell interpreter in between, which removes the injection surface structurally instead of trying to escape around it.
What has actually changed in how PHP applications get secured?
What has changed is less about the PHP language itself and more about the tooling wrapped around it. Composer, PHP's package manager since 2012, turned what used to be copy-pasted library code into declared, versioned dependencies that software composition analysis (SCA) tools can actually scan against CVE databases — a prerequisite that simply didn't exist for most PHP applications a decade ago. Static analysis tools built for PHP's dynamic typing and magic-method behavior (Psalm, PHPStan, and commercial SAST engines) now run in CI on a large share of professionally maintained PHP projects, catching raw unserialize() calls and string-concatenated queries before merge. Framework defaults have shifted too: both Laravel and Symfony ship secure-by-default patterns — parameterized queries through their ORMs, escapeshellarg()-wrapped process helpers, and (in Symfony's case) a serializer component that avoids native unserialize() for untrusted payloads — meaning a developer starting a new project in 2026 has to work harder to introduce these bugs than to avoid them.
How Safeguard helps with PHP-specific risk
Composer's dependency graph is exactly the kind of surface where SCA alone produces noise: a vulnerable version of a PHP package sitting in composer.lock gets flagged whether or not your code ever calls the affected function. Safeguard's reachability analysis is Composer-aware and Symfony/Laravel-aware, tracing whether a flagged deserialization sink, SQL builder call, or file-inclusion path in a dependency is actually reachable from your application's entry points before it gets ranked as urgent. Static reachability for PHP is currently in Preview and dynamic reachability is GA, so teams get runtime-verified reachability today while static call-graph coverage continues to mature. Combined with Griffin's plain-language explanations of each finding, that means a team triaging a wave of Composer CVEs can focus on the handful where attacker-controlled input genuinely reaches an unserialize() call or an unparameterized query, instead of re-litigating every CVE in vendor/ from scratch.