Safeguard
AppSec

Security in PHP: Framework-Level Protections and Common Gaps

Security in PHP improved enormously once frameworks took over escaping, CSRF, and query building. The remaining incidents live in the gaps where developers step outside those rails.

Karan Patel
Platform Engineer
6 min read

Security in PHP is no longer mostly a language problem — modern frameworks handle output escaping, CSRF tokens, parameterized queries, and password hashing by default — so the real risk concentrates where developers step off the framework rails: raw queries, unescaped output, unserialize, file uploads, and unmaintained PHP versions. PHP's bad security reputation was earned in the era of mysql_query string concatenation and register_globals. Judging today's ecosystem by that era leads teams to defend the wrong things.

This post maps what the frameworks now do for you, then walks the gaps that still produce incidents.

What the frameworks already handle

PHP framework security has quietly become excellent at the OWASP classics:

  • SQL injection: Laravel's Eloquent and query builder bind parameters automatically; Symfony's Doctrine ORM does the same. Injection requires deliberately using raw SQL interfaces.
  • XSS: Blade's {{ ... }} syntax and Twig's default auto-escaping HTML-encode output. XSS requires opting out.
  • CSRF: both frameworks generate and validate per-session tokens on state-changing forms out of the box.
  • Password storage: password_hash() with bcrypt or Argon2 is the language-level default, wrapped by framework auth systems.
  • Session handling: secure cookie flags, session regeneration on login, and remember-me tokens are framework-managed.

The consequence is that a code auditor's job in a modern PHP app is to find the escape hatches, because that is where the bugs are.

Gap 1: the raw-query escape hatches

Every ORM ships raw interfaces for the queries it cannot express, and that is where injection returns:

// Vulnerable: user input interpolated into a raw expression
$users = DB::select("SELECT * FROM users WHERE name LIKE '%$search%'");

// Safe: bindings survive even in raw queries
$users = DB::select("SELECT * FROM users WHERE name LIKE ?", ["%{$search}%"]);

The subtler variants are orderBy and column names, which cannot be parameter-bound: sort fields from user input must be checked against an allowlist of column names. Grep targets for review: DB::raw, whereRaw, selectRaw, orderByRaw in Laravel; createNativeQuery and string-built DQL in Doctrine.

Gap 2: opting out of escaping

Blade's {!! !!} and Twig's |raw filter exist for rendering trusted HTML. Each use is a decision that this string can never contain attacker-influenced content — a property that changes as code evolves. Two rules keep this survivable: every raw-output usage gets a comment stating why the content is trusted, and user-supplied rich text goes through a real sanitizer (HTMLPurifier remains the standard) rather than a homemade tag-stripper. strip_tags() is not a sanitizer; it does nothing about attribute-based payloads in the tags it keeps.

Gap 3: deserialization and object injection

unserialize() on user-controllable data is the most PHP-specific severe bug class. Crafted serialized strings instantiate arbitrary classes, and "POP chains" through the magic methods (__wakeup, __destruct) of installed libraries can escalate to file writes or code execution. Framework ecosystems are rich enough in classes that usable gadget chains frequently exist.

The fixes are blunt: use JSON for data interchange; if you must unserialize, pass ['allowed_classes' => false]; and treat any signed-then-unserialized cookie scheme as a critical audit target. Related: Laravel's APP_KEY signs encrypted payloads the framework will later decrypt and potentially unserialize — a leaked application key is remote-code-execution adjacent, not just a config leak. Rotate it if it has ever been committed.

Gap 4: file uploads and file inclusion

The classic PHP incident is still alive in the wild: an upload endpoint that accepts avatar.php, stores it under the webroot, and lets the webserver execute it. The layered fix:

  • Allowlist extensions and verify content type by inspection (finfo), not by the client's header.
  • Store uploads outside the document root or in object storage; serve via a handler that forces Content-Disposition and never executes.
  • Ensure the storage path allows no PHP execution (webserver rule), so one missed check does not become code execution.

Dynamic includes (include $page . '.php') belong in the same review bucket; any request-derived value reaching include/require is a path traversal and RCE candidate.

Gap 5: running EOL PHP

Language-level security in PHP depends on running a supported branch, and the support windows are short: each branch gets two years of active support and two of security fixes. PHP 8.1's security support ends this December (December 31, 2025); 8.2 runs on security fixes until the end of 2026, and 8.3 until the end of 2027. Unsupported PHP means every future interpreter CVE stays open on your servers forever. The same discipline applies one level up: framework versions have their own EOL schedules, and Composer dependencies age just like npm ones. Scanning composer.lock continuously with an SCA tool turns "we were three advisories behind" from an audit finding into a pull request; an SCA platform such as Safeguard also flags the abandoned-package case where no fixed version will ever come.

Gap 6: configuration that undoes everything

A short list catches most PHP misconfiguration incidents:

  • display_errors = Off in production (stack traces leak paths, queries, and occasionally credentials).
  • expose_php = Off and honest security headers at the webserver.
  • open_basedir and disabled dangerous functions (exec, system, passthru) where the app doesn't need them — cheap containment for file-inclusion bugs.
  • .env files denied by webserver rule and verified unreachable; leaked Laravel .env files are a recurring breach headline.
  • Composer's vendor/ directory not directly routable.

A DAST pass against staging verifies several of these from the outside — error-page leakage, reachable dotfiles, header hygiene — which is exactly the misconfiguration class dynamic scanning is good at.

A review checklist for PHP codebases

  1. Grep raw-query APIs; verify bindings or allowlists on every hit.
  2. Grep {!! , |raw, echo $ in templates; demand justification comments.
  3. Grep unserialize(; require allowed_classes or replacement with JSON.
  4. Audit every upload path against the layered rules above.
  5. Confirm PHP branch and framework version are in support; check composer.lock against advisories.
  6. Verify the configuration list on a production-identical host.

FAQ

Is PHP inherently less secure than other languages?

Not anymore. The dangerous-by-default primitives are gone or discouraged, and Laravel/Symfony defaults compare well with any ecosystem. The honest residual differences are the deployment long tail of EOL PHP versions and legacy code predating frameworks.

What is the single most important PHP security setting?

Running a supported PHP branch. No application-level control compensates for an interpreter that no longer receives security fixes.

Do I still need to sanitize input if the framework escapes output?

Validate input, escape output — both, for different reasons. Validation enforces business rules and blocks whole payload classes early; output escaping is the actual XSS control. Neither substitutes for the other.

How do I check my Composer dependencies for known vulnerabilities?

composer audit (built into Composer 2.4+) checks your lockfile against the ecosystem advisory database locally; continuous SCA monitoring adds alerting when new advisories land against versions you already ship.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.