131% more reported CVEs per day in 2026 than last year.

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

  1. Your code Injection, XSS, deserialization, file handling, sessions Framework defaults, code review, PHPStan, Psalm, Semgrep
  2. Your Composer dependencies Known vulnerabilities, abandoned and malicious packages, lockfile drift, install-time code composer audit, Composer policies, Dependabot or Renovate
  3. 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
The three layers of PHP security, and the tools that work on each

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 in php.ini, the FPM pool or server configuration that applies before the request is parsed, and restart every worker. Do not rely on ini_set() or FPM's .user.ini for 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
PHP supported versions (dates from php.net)

Laravel and Symfony production settings

  • Debug off. APP_DEBUG=false in Laravel, APP_ENV=prod and APP_DEBUG=0 in Symfony. A debug page in production shows configuration and environment values.
  • Keys and secrets. Keep APP_KEY (Laravel) and APP_SECRET (Symfony) out of the repository and rotate them if they leak. Laravel's APP_PREVIOUS_KEYS lets 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 with X-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 $_GET and $_COOKIE to sinks such as echo, include and SQL queries, and reports issues like TaintedSql and TaintedHtml. Current Psalm runs it with type checking; --taint-analysis turns it on for a single run, and --report=results.sarif writes 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.lock for applications, so every environment installs the same versions.
  • Use composer install in CI and deployments, never composer 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 when composer.lock is out of date with composer.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-plugins setting 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 install ends with php artisan package:discover, which loads the autoloader, so a dependency's autoloaded files execute 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:

  1. Update the package that pulls the vulnerable one in. Most findings are transitive; run composer why vendor/package to see the chain, and update the upstream package within its constraint first.
  2. Update one thing at a time. composer update vendor/package --with-dependencies keeps the change small enough to review in the composer.lock diff.
  3. Smoke-test the critical paths first. Login, checkout, the main API endpoints: a handful of end-to-end checks catch most upgrade breakage.
  4. Add characterization tests around what the upgrade touches. Record what the code does today, so you notice when the upgrade changes it.
  5. 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: read stops a compromised step from pushing code or releases with GITHUB_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 keeps composer install running against the lockfile in every job. A restored vendor/ 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 audit and a committed composer.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