PHP security
PHP security: preventing PHP vulnerabilities in your code, your Composer dependencies and your build
Securing PHP means three layers. In the code you write: validate input, use parameterized queries and the framework's escaping, and never unserialize untrusted data. In the Composer packages you depend on: run composer audit, commit composer.lock and block insecure versions. In the CI that installs them: record what was really installed and restrict where the build can connect. Most PHP security guides stop at the first layer. This one covers all three.
Updated
- Your code Injection, XSS, deserialization, file handling, sessions Framework defaults, code review, PHPStan, Psalm, Semgrep
- Your Composer dependencies Known vulnerabilities, abandoned and malicious packages, lockfile drift, install-time code composer audit, Composer policies, Dependabot or Renovate
- Your build What CI actually installed and ran, where it fetched from, what it could reach Pinned actions, least-privilege tokens, egress policy, build-time SBOM
On this page: PHP2Shell, the PHP vulnerabilities that still matter, configuration and supported versions, static analysis, Composer dependency security, PHP supply chain attacks, the CI build, SBOMs and tools by layer.
PHP2Shell: the PHP vulnerability to patch now
On 21 October 2026 the PHP project released PHP 8.5.12, 8.4.27, 8.3.36 and 8.2.35 to fix two critical vulnerabilities in PHP itself that can allow unauthenticated remote code execution. Both are rated 9.2 under CVSS v4.0, and together they are known as PHP2Shell:
- CVE-2026-109618: out-of-memory handling can interrupt PHP while request data is only partly initialized, and request cleanup may then treat the invalid data as valid.
- CVE-2026-109619: state owned by a request can survive after its memory has been freed, and the next request handled by the same worker may use it.
PHP's advisory says every installation serving web requests is at risk, including PHP-FPM, Apache with mod_php, php-cgi, FrankenPHP and LiteSpeed, and rates mod_php the highest risk. PHP 8.1 and older get no new release from the PHP project. Given the severity, though, the PHP security team published minimal source backports for PHP 7.4, 8.0 and 8.1 for people who build PHP from source or have no LTS vendor, and some distributions and LTS vendors ship backported fixes of their own. What to do:
- Upgrade to a fixed release. On 8.1 or older, upgrade to a supported branch, take your vendor's backported package, or apply the official source backport if you build PHP yourself.
-
Rebuild every container image that bundles PHP and redeploy it. An old image tag keeps running the old PHP until it
is replaced, so check what is deployed, not only what is in the repository:
docker run --rm IMAGE php -v. -
If you cannot upgrade today, apply the interim mitigations from the
PHP security advisory, such as
session.upload_progress.enabled=0. Set it inphp.ini, the FPM pool or server configuration that applies before the request is parsed, and restart every worker. Do not rely onini_set()or FPM's.user.inifor it: the advisory warns against both. PHP is clear that these mitigations reduce exposure but do not make an installation safe.
Note what composer audit cannot tell you here: PHP2Shell is in the runtime, not in a Composer package, so
it never appears in composer.lock. The PHP you run comes from your base image or your OS packages, which
is the third layer of this guide. For the version table, timeline and a script that checks every image tag, see
PHP2Shell: what to patch and how to find every vulnerable PHP.
The PHP vulnerabilities that still matter
The vulnerability classes in PHP applications have barely changed in a decade. What changed is that Laravel, Symfony and modern PHP close most of them by default, so real bugs now cluster in the places where code steps around those defaults. Injection is A05 in the OWASP Top 10:2025, and Software Supply Chain Failures is new at A03, which is the subject of the second half of this guide.
| Vulnerability | How it shows up in PHP | The fix | Laravel and Symfony |
|---|---|---|---|
| SQL injection | Request data concatenated into SQL, including raw-query escape hatches | PDO prepared statements with bound parameters | Eloquent and Doctrine bind parameters; whereRaw(), DB::raw() and concatenated DQL do not |
| Cross-site scripting (XSS) | User input echoed into HTML without escaping | Escape on output with htmlspecialchars() or the template engine |
Blade {{ }} and Twig escape by default; {!! !!} and |raw do not |
| Cross-site request forgery (CSRF) | A form or AJAX endpoint that changes state accepts any request carrying the session cookie | A per-session token checked on every POST, PUT, PATCH and DELETE, plus SameSite cookies |
Laravel checks the token on web routes (@csrf in forms); Symfony Forms include and check one by default; routes excluded from the check have none |
Code injection through eval() |
Request data, or a template or formula a user can edit, reaching eval() |
Do not use eval(); map input to a fixed set of operations, or use a parser for the expressions you need |
No framework protects your own eval() calls |
| Object injection | unserialize() on cookies, request data or cache entries an attacker can write |
Use JSON for data from outside; if you must unserialize, refuse objects | No framework protects your own unserialize() calls |
| File inclusion and uploads | A path built from input passed to include; uploads stored under the web root with the client's file name |
Allow-list included files; store uploads outside the web root under generated names | Laravel's store() generates file names; validate type and size on the server |
| Type juggling | == comparing tokens or hashes, where "0e123" == "0e456" is true |
===, hash_equals() for secrets, password_verify() for passwords |
Framework helpers compare safely; your own comparisons are up to you |
| Weak sessions and cookies | Session IDs readable by JavaScript, sent over HTTP or kept across login | Secure, HttpOnly and SameSite cookies, strict mode, a new session ID after login | Both regenerate the session ID on login |
| Server-side request forgery (SSRF) | file_get_contents() or cURL on a user-supplied URL, including wrappers such as file:// and phar:// |
Allow-list schemes and hosts, block internal addresses, turn off allow_url_fopen where unused |
No default protection |
| Command injection | Input passed to exec(), shell_exec(), system() or proc_open() |
Avoid the shell; pass arguments as an array, or escape each with escapeshellarg() |
Symfony Process and Laravel's Process facade accept an argument array |
| Mass assignment | User::create($request->all()) lets a request set is_admin |
List fillable fields, or pass only validated input | Eloquent models are guarded by default; $guarded = [] turns that off |
SQL injection: vulnerable and fixed
// Vulnerable: the id goes straight into the SQL string
$rows = $pdo->query("SELECT * FROM orders WHERE id = " . $_GET['id']);
// Fixed: a prepared statement with a bound parameter
$stmt = $pdo->prepare('SELECT * FROM orders WHERE id = ?');
$stmt->execute([$_GET['id']]);
$rows = $stmt->fetchAll();
// Laravel: whereRaw() takes SQL as written, where() binds the value
Order::whereRaw("status = '" . $request->input('status') . "'")->get(); // vulnerable
Order::where('status', $request->input('status'))->get(); // fixed
XSS: vulnerable and fixed
<?php // Plain PHP: escape on output
echo '<p>' . $comment . '</p>'; // vulnerable
echo '<p>' . htmlspecialchars($comment, ENT_QUOTES, 'UTF-8') . '</p>'; // fixed ?>
{{-- Blade: {{ }} escapes, {!! !!} prints raw HTML --}}
<p>{!! $comment !!}</p> {{-- vulnerable --}}
<p>{{ $comment }}</p> {{-- fixed --}}
{# Twig: autoescape is on, |raw turns it off #}
<p>{{ comment|raw }}</p> {# vulnerable #}
<p>{{ comment }}</p> {# fixed #}
Object injection: vulnerable and fixed
// Vulnerable: an attacker-controlled cookie becomes PHP objects, and any
// class with a __wakeup() or __destruct() method in vendor/ is a gadget
$prefs = unserialize($_COOKIE['prefs']);
// Fixed: JSON carries data, not objects
$prefs = json_decode($_COOKIE['prefs'], true, 512, JSON_THROW_ON_ERROR);
// If you must read serialized data, refuse objects
$prefs = unserialize($data, ['allowed_classes' => false]);
Phar deserialization, the variant where a file operation on a phar:// path unserialized the archive's
metadata, was narrowed in PHP 8.0: phar metadata is
no longer unserialized when a
phar is opened, only when code calls getMetadata(). It is one more reason to leave PHP 7 behind.
File inclusion: vulnerable and fixed
// Vulnerable: ?page=../admin/reset reaches any .php file on the disk
include __DIR__ . '/pages/' . $_GET['page'] . '.php';
// Fixed: only names on an allow-list can be included
$pages = ['home' => 'home.php', 'pricing' => 'pricing.php'];
$file = $pages[$_GET['page'] ?? 'home'] ?? 'home.php';
include __DIR__ . '/pages/' . $file;
Configuration and runtime hardening
A secure application can still leak through its runtime. These php.ini settings follow the
OWASP PHP
Configuration Cheat Sheet:
| Setting | Production value | Why |
|---|---|---|
display_errors |
Off (with log_errors = On) |
Stack traces show paths, queries and sometimes secrets |
expose_php |
Off |
Stops advertising the exact PHP version in response headers |
allow_url_include |
Off |
Prevents include of remote URLs; deprecated since PHP 7.4 |
allow_url_fopen |
Off if the application does not fetch URLs with file functions |
Narrows SSRF through file_get_contents() |
disable_functions |
For example exec, shell_exec, system, passthru, proc_open, popen |
Removes the shell from an attacker who gets code running; list only what your application never calls |
open_basedir |
The application directory | Limits the paths PHP file functions can open; a guardrail, not an isolation boundary |
| Session cookies | session.cookie_secure = 1, session.cookie_httponly = 1, session.cookie_samesite = Lax or Strict, session.use_strict_mode = 1 |
Keeps session IDs off plain HTTP and away from JavaScript, and rejects IDs the server did not issue |
file_uploads |
Off if the application takes no uploads |
Removes a whole input path |
Stay on a supported PHP version
PHP gives each branch two years of active support and then two years of security fixes only, according to php.net. PHP 8.1 reached end of life on 31 December 2025, and PHP 8.2 follows on 31 December 2026. An end-of-life branch gets no new releases from the PHP project, which is the position PHP2Shell put 8.1 users in: the only official fix for them was a source backport.
- PHP 8.1 End of life, ended Dec 31, 2025
- PHP 8.2 Security fixes only, until Dec 31, 2026
- PHP 8.3 Security fixes only, until Dec 31, 2027
- PHP 8.4 Active support, until Dec 31, 2026
- PHP 8.5 Active support, until Dec 31, 2027
Laravel and Symfony production settings
-
Debug off.
APP_DEBUG=falsein Laravel,APP_ENV=prodandAPP_DEBUG=0in Symfony. A debug page in production shows configuration and environment values. -
Keys and secrets. Keep
APP_KEY(Laravel) andAPP_SECRET(Symfony) out of the repository and rotate them if they leak. Laravel'sAPP_PREVIOUS_KEYSlets you rotate the key without logging every user out. -
Trusted proxies. Behind a load balancer, list the proxies you trust (Laravel's trusted proxies
middleware, Symfony's
framework.trusted_proxies), so a client cannot fake its IP address or HTTPS withX-Forwarded-*headers.
Static analysis for PHP (SAST)
Static analysis reads your code without running it. Three open-source tools cover most PHP teams:
-
PHPStan finds type errors and bugs at increasing strictness levels. It is not a security scanner,
but many security bugs start as type confusion or an unchecked
null. -
Psalm adds taint analysis:
it follows data from sources such as
$_GETand$_COOKIEto sinks such asecho,includeand SQL queries, and reports issues likeTaintedSqlandTaintedHtml. Current Psalm runs it with type checking;--taint-analysisturns it on for a single run, and--report=results.sarifwrites SARIF for GitHub code scanning. - Semgrep supports PHP as a generally available language, with community rules for common PHP mistakes and cross-function dataflow in its commercial tier.
SAST is noisy on legacy PHP for structural reasons: dynamic includes, variable variables, magic methods such as
__get() and __call(), and framework conventions that route data through containers and
facades the analyzer cannot follow. The tool either guesses, and reports false positives, or gives up, and misses
real flows.
Adopting it on an old codebase works best as a ratchet. Record today's findings as a baseline
(vendor/bin/phpstan analyse --generate-baseline writes phpstan-baseline.neon; Psalm has
--set-baseline), fail the build only on new findings, and raise the level as you pay the baseline down.
We hear from teams with long-lived PHP codebases that AI autofix works well for dependency bumps and is uneven on
code findings, so review generated fixes for SAST findings as carefully as any other change. SAST and SCA answer
different questions; see SAST vs SCA.
Composer dependency security
A Laravel or Symfony application installs dozens of Composer packages, most of them transitive. Composer has gained real security features in the last few releases; this section reflects Composer 2.11, the current release.
composer audit
composer audit checks the packages you have installed against security advisories from the
Packagist.org API, which collects
advisories from sources such as the GitHub Advisory Database and FriendsOfPHP/security-advisories. It also reports
abandoned packages and, since Composer 2.10, package versions flagged as malware. See the
Composer docs for every option.
composer audit # audit what is installed in vendor/
composer audit --locked # audit composer.lock, whatever is in vendor/
composer audit --no-dev # skip require-dev packages
composer audit --direct # direct dependencies only (Composer 2.11)
composer audit --format=json # table, plain, json or summary
composer audit --abandoned=report # ignore, report or fail on abandoned packages
composer audit --ignore-severity=low
Since Composer 2.10 the exit code is 0 when there are no issues and 1 when anything failed
the audit, so it works as a CI gate without parsing. Abandoned packages fail the audit by default.
Accepted findings belong in composer.json, with a reason, not in a CI flag nobody remembers. Composer
2.10 replaced most of the old audit config with a policy block:
{
"config": {
"policy": {
"advisories": {
"ignore-id": {
"CVE-1234": "Not affected: we never call the vulnerable parser."
}
},
"abandoned": {
"audit": "report"
}
}
}
}
The legacy keys (audit.ignore, audit.abandoned, audit.block-insecure) still
work as a fallback while the matching policy section is absent, but they are deprecated. Migrate all the
advisory settings at once: once policy.advisories exists, every advisory-related audit.* key
is ignored.
Blocking insecure versions
Since Composer 2.9 (November 2025), composer update and composer require refuse package
versions that have a security advisory. It was the audit.block-insecure setting and is now
policy.advisories.block, on by default. Composer 2.10 added malware blocking from Packagist's malware
feed, which applies to composer install as well. Composer 2.11 added an optional cooldown
policy that withholds newly released versions for a period you choose, such as "7 days", because many
malicious versions are found and removed within hours or days.
Two details matter in CI. First, advisory blocking applies when versions are resolved: a version already in
composer.lock still installs with composer install after an advisory is published, so you
still need composer audit as the gate. Second, --no-blocking turns every policy off for one
command, which is worth searching your pipelines for.
Before Composer 2.9, the usual way to block insecure versions was
roave/security-advisories, a package that
declares a conflict with every known vulnerable version, built from the GitHub Advisory Database and
FriendsOfPHP. It still works, and still helps on older Composer versions; on Composer 2.9 or later, the built-in
blocking covers the same advisories without the extra package.
Lockfile discipline
- Commit
composer.lockfor applications, so every environment installs the same versions. -
Use
composer installin CI and deployments, nevercomposer update, which resolves new versions on the fly. - Install production builds with
--no-dev, so test tooling never ships. -
Run
composer validate --strict: it fails whencomposer.lockis out of date withcomposer.json.
A lockfile records what Composer resolved. See what a lockfile does not record.
Install-time code: plugins, scripts and autoloaded files
Composer runs code from your dependencies in more places than most teams expect, which makes it the PHP equivalent of npm install scripts:
-
Plugins run inside Composer itself. Since Composer 2.2, the
allow-pluginssetting must list every plugin allowed to run, and it allows none by default. Treat a new entry there like a new dependency with shell access. -
Scripts run only from your root
composer.json, not from dependencies. But they often boot your application. -
Autoloaded files run whenever the autoloader is loaded. In a standard Laravel application,
composer installends withphp artisan package:discover, which loads the autoloader, so a dependency's autoloadedfilesexecute inside your CI job during the install.
If a job does not need plugins or scripts, --no-plugins --no-scripts keeps dependency code from running
during the install; run the steps you need explicitly afterwards.
Repository sources and dependency confusion
Composer can install from Packagist.org, private Composer repositories such as Private Packagist, VCS repositories
(Git URLs) and local paths. Mixing private and public sources is where
dependency confusion lives: if your private package name also exists on
Packagist.org with a higher version, which one do you get? In Composer 2,
every repository is
canonical by default: once Composer finds a package in a repository, it does not look for it further down the
list, and Packagist.org is always last. Setting "canonical": false on a repository removes that
protection, so do it only on purpose. VCS repositories pull whatever the Git host serves for a tag, so pin them by
commit in the lockfile and review changes to them.
Abandoned and unmaintained packages
Legacy PHP estates carry packages whose maintainers moved on. Packagist marks a package abandoned when its maintainer
says so, often naming a replacement, and composer audit fails on it by default. Many unmaintained
packages are never formally abandoned, though. Look at the last release date for anything that handles input,
authentication or cryptography, and plan replacements before a vulnerability forces the issue.
Updating safely when tests are thin
The hardest case is a large legacy application with a vulnerable dependency and few tests. What works:
-
Update the package that pulls the vulnerable one in. Most findings are transitive; run
composer why vendor/packageto see the chain, and update the upstream package within its constraint first. -
Update one thing at a time.
composer update vendor/package --with-dependencieskeeps the change small enough to review in thecomposer.lockdiff. - Smoke-test the critical paths first. Login, checkout, the main API endpoints: a handful of end-to-end checks catch most upgrade breakage.
- Add characterization tests around what the upgrade touches. Record what the code does today, so you notice when the upgrade changes it.
- Stage the rollout and watch error rates before it reaches every customer.
What composer audit misses
The lockfile is a plan, not a record of the build. composer audit reads vendor/ or
composer.lock, so it does not see:
- PHP itself and its extensions, which come from your base image or OS packages (PHP2Shell is one example).
- PECL extensions and system libraries installed in the Docker image.
- Tools your CI fetches with
curl, actions it runs, and binaries a plugin or script downloads. - Malicious code that has no advisory or malware flag yet.
The same limits apply to npm audit and pip-audit in their ecosystems.
PHP supply chain attacks
Four incidents show where supply chain attacks on PHP actually land, and what would have caught each one.
March 2021: malicious commits to php-src
On 28 March 2021, two malicious commits were pushed to the php-src repository under the names of two core developers. The PHP team announced that it would stop running its own Git server and make the GitHub repositories canonical. Its follow-up found the commits had been pushed over HTTPS with password authentication, possibly with credentials from a leaked user database, rather than through a compromised server. What would have caught it: nothing in your own pipeline. It targeted the language itself, which is a reminder that the PHP runtime is a dependency too.
April 2021: Composer command injection (CVE-2021-29472)
A flaw in how Composer handled Mercurial repository URLs allowed command injection. Packagist reported that Packagist.org and Private Packagist were theoretically exposed through crafted package URLs, did not believe it had been exploited, and fixed it in Composer 2.0.13 and 1.10.22. What would have caught it: keeping Composer itself up to date in developer machines and CI images. The package manager is part of your supply chain.
May 2023: Packagist account takeovers
On 1 May 2023, an attacker logged into four inactive Packagist.org accounts with passwords leaked elsewhere, which
between them controlled 14 packages, and pointed the packages at forked repositories. The forks carried a changed
description and no malicious code.
Packagist
restored them the next morning. What would have caught it: a committed composer.lock, since
composer install uses the sources recorded in the lockfile, and reviewing lockfile diffs for unexpected
URLs, which Packagist itself recommends. For maintainers: unique passwords and two-factor authentication.
2026: hijacked tags on laravel-lang and intercom-php
In 2026, attackers with stolen access published malicious releases of existing packages. Packagist names
intercom/intercom-php (30 April) and the laravel-lang packages (22 May) in its
update on
supply chain security. In the laravel-lang case, version tags pointed at commits in a malicious fork, and the
payload, a src/helpers.php file
loaded
by Composer's autoloader, fetched a credential stealer for cloud keys, SSH keys and .env files. Aikido
counted 233 compromised versions. What would have caught it: once flagged, Composer 2.10's malware
policy blocks those versions even on composer install; before that, a cooldown period keeps brand-new
versions out, an egress policy stops the payload from reaching its server, and a record of each build shows which
builds installed a bad version.
Since then, Packagist has announced that it rejects re-tagged stable versions, publishes a transparency log of ownership and tag changes, and has flagged malware on Packagist.org since March 2026.
Securing the PHP build in CI
The build is where your code, your dependencies and your secrets meet, so it is where a malicious package does the most damage. A GitHub Actions workflow for a production build:
name: build
on: [push, pull_request]
permissions:
contents: read # least privilege for GITHUB_TOKEN
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0
with:
php-version: "8.4"
tools: composer:v2
coverage: none
- run: composer validate --strict
- id: composer-cache
run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: composer-${{ hashFiles('composer.lock') }}
- run: composer install --no-dev --no-interaction --no-progress --optimize-autoloader
- run: composer audit --no-dev
-
Least-privilege token.
permissions: contents: readstops a compromised step from pushing code or releases withGITHUB_TOKEN. See GitHub token permissions. - Pinned actions. A full commit SHA cannot be moved the way a tag can; the comment keeps the version readable for Dependabot or Renovate.
-
Cache downloads, not
vendor/. Caching Composer's download cache keepscomposer installrunning against the lockfile in every job. A restoredvendor/is trusted as it is, and caches can be poisoned across workflows; see GitHub Actions cache. - Record what was installed. The lockfile says what should be installed. An SBOM recorded from the build says what was, including the PHP runtime, OS packages and downloads outside Composer. See build-time SBOMs.
- Restrict where the build can connect. A Composer build needs Packagist, GitHub and your private repository. An egress policy that allows only those stops a credential stealer from sending what it found.
Generating an SBOM for a PHP project
The standard open-source option is the
CycloneDX Composer plugin. As a
plugin, it needs an allow-plugins entry:
composer require --dev cyclonedx/cyclonedx-php-composer
composer CycloneDX:make-sbom --output-format=JSON --output-file=sbom.json
It reads composer.json together with the installed packages or composer.lock, so it lists
your Composer dependencies accurately and nothing else. The PHP runtime, its extensions, the base image's apt or apk
packages and the tools the job downloaded are not in it. A build-time SBOM records those too, because it is taken
from what the job fetched rather than from Composer's files. For the formats and what an SBOM should contain, see
what is an SBOM.
PHP security tools by layer
| Layer | Tools | What they cover | What they miss |
|---|---|---|---|
| Code | PHPStan, Psalm, Semgrep | Bugs and type errors, tainted data flows, known insecure patterns | Vulnerable dependencies, the runtime, the build |
| Dependencies | composer audit, Composer policies, roave/security-advisories, Dependabot or Renovate |
Published advisories, abandoned and flagged packages, update pull requests | Your own code, and anything outside composer.lock |
| Image | Container scanners | OS and language packages in a built image | What the build fetched and did not keep, and images nobody scanned |
| SBOM | CycloneDX Composer plugin | Composer packages as a CycloneDX SBOM | Everything outside Composer |
| Build | CRACI | What each GitHub Actions job actually installed, from Composer and every other source, monitored over time | Your PHP code (no SAST); CI systems other than GitHub Actions |
More on images: container scanning. Fewer, better findings across all layers: how to reduce vulnerability noise.
PHP security and CRACI
CRACI is a GitHub Actions runner that records what each job actually installs. Change runs-on to
craci, and every Composer package the job downloads is recorded with its package URL
(pkg:composer/vendor/package), together with packages from the OS package manager, container images, Git
sources and other downloads in the same job, including packages restored from CI caches. Every component in the SBOM
was in the build.
CRACI matches those components against known vulnerabilities and keeps re-checking the SBOMs of what you shipped as new advisories are published, so a CVE disclosed next year still leads you to the builds that contain it. Policy gates can stop a build on findings, including a build that contains a specific CVE, and the egress policy limits where the job can connect.
CRACI does not scan your PHP code: there is no SAST, by design. Keep PHPStan, Psalm or Semgrep for the code layer. See every package your PHP build installs.
When CRACI is not the answer
-
You deploy by FTP or to shared hosting without CI. There is no build to record. Start with
composer auditand a committedcomposer.lock. - Your risk is mainly in your own code, such as a legacy application full of raw SQL. SAST, review and tests come first.
- You build outside GitHub Actions, on GitLab CI or Bitbucket Pipelines. CRACI supports GitHub Actions today; GitLab CI and Jenkins are on the roadmap.
PHP security: frequently asked questions
How do I secure PHP code?
Work in three layers. In your code, validate input, use prepared statements, rely on the framework's output escaping and never unserialize untrusted data. In your dependencies, commit composer.lock, run composer audit in CI and keep Composer's blocking of insecure versions on. In your build, pin actions, limit the token, restrict network access and record what the job installed.
What are the most common PHP vulnerabilities?
SQL injection, cross-site scripting, cross-site request forgery, code injection through eval(), object injection through unserialize(), file inclusion and unsafe uploads, type juggling with ==, weak session settings, SSRF through URL wrappers, command injection and mass assignment. Vulnerable Composer dependencies and unpatched PHP versions, such as the PHP2Shell bugs, are as common a cause of incidents.
What is the best PHP security scanner?
No single tool covers PHP security. Use a static analyzer such as PHPStan, Psalm or Semgrep for your code, composer audit for your Composer dependencies, a container scanner for the image, and a build-time record for what CI actually installed.
How does composer audit work, and what does it miss?
It checks installed packages, or composer.lock with --locked, against advisories from the Packagist.org API, and reports abandoned and malware-flagged packages. It exits with 1 when anything fails the audit. It does not see PHP itself, PECL extensions, OS packages in your image, or anything CI downloads outside Composer.
How do I block insecure Composer packages in CI?
Use Composer 2.9 or later, which blocks versions with advisories during update and require by default (policy.advisories.block). Since a locked vulnerable version still installs, also run composer audit after composer install and fail the job on its exit code. Never pass --no-blocking in CI.
Is Laravel secure by default?
Mostly. Blade escapes output, Eloquent binds query parameters, models are guarded against mass assignment and the session ID is regenerated on login. Those defaults stop working when code uses {!! !!}, whereRaw() with input, $guarded = [] or APP_DEBUG=true in production, or when APP_KEY leaks.
How do I update dependencies in a legacy PHP app with few tests?
Update the upstream package that pulls in the vulnerable one, one package at a time with composer update vendor/package --with-dependencies. Smoke-test the critical paths, add characterization tests around what the upgrade touches, and stage the rollout.
How do I generate an SBOM for a PHP project?
The CycloneDX Composer plugin (cyclonedx/cyclonedx-php-composer) generates one from your Composer packages with composer CycloneDX:make-sbom. It lists only Composer packages. A build-time SBOM also records the PHP runtime, OS packages and other downloads in the build.
What is PHP2Shell?
PHP2Shell is the name for CVE-2026-109618 and CVE-2026-109619, two critical vulnerabilities in PHP itself that can allow unauthenticated remote code execution. They are fixed in PHP 8.5.12, 8.4.27, 8.3.36 and 8.2.35, released on 21 October 2026. See PHP2Shell.
See every package your PHP build installs
Run one GitHub Actions workflow on CRACI and compare its recorded SBOM with your composer.lock.
Book a demo