Safeguard
Supply Chain Security•

PHP2Shell for Laravel and Symfony: Octane, FrankenPHP, Forge, Docker

PHP2Shell hits Laravel and Symfony apps through the PHP runtime. Patch PHP, replace FrankenPHP binaries, rebuild images and fully restart every worker.

Safeguard Security Team
23 min read

PHP2Shell (CVE-2026-109618 and CVE-2026-109619) is a pair of critical flaws in the PHP runtime, so every Laravel and Symfony app that serves web traffic inherits them. A Composer update will not fix it. Move to PHP 8.5.12, 8.4.27, 8.3.36 or 8.2.35, replace any FrankenPHP binary or image, rebuild containers, and fully restart every server process.

Our PHP2Shell explainer covers the two bugs, the leak and the exploitation dispute. This post answers a narrower question for framework teams: in a Laravel or Symfony stack, where does the PHP binary actually come from, and what must you touch so the patched one serves requests? One Laravel app can run on a Forge server's PHP-FPM, on a FrankenPHP binary Octane downloaded into the repo, on a Vapor runtime, and on a CI runner. Each gets PHP from a different place.

What happened, from a framework team's point of view

WhenWhatSource
20 Oct 2026PHP announces a critical release for 21 Oct, 14:00 UTC; both flaws CVSS v4.0 9.2php.net
21 Oct 2026PHP ships 8.5.12, 8.4.27, 8.3.36 and 8.2.35 and publishes two GitHub advisoriesphp.net, php-src
21 Oct 2026Laravel News publishes the fixes with Laravel's statement on Cloud, Forge and VaporLaravel News
21 Oct 2026, when we checkedThe official Docker PHP library lists 8.5.12, 8.4.27, 8.3.36 and 8.2.35 as currentdocker-library/php
21 Oct 2026, when we checkedFrankenPHP's latest release is still v1.13.1 from 16 October, with no mention of the PHP fixesFrankenPHP releases

PHP's advisory says every PHP installation serving web requests is at risk and names PHP-FPM, Apache mod_php, php-cgi, FrankenPHP and LiteSpeed. Laravel News notes that list covers how most Laravel apps run in production. The Hacker News puts it the other way round: updating an application such as Laravel leaves the PHP runtime beneath it untouched.

What Laravel's guidance says

Laravel News quotes Laravel's statement on its own platforms. Here it is with what each line leaves to you.

PlatformLaravel's statement (via Laravel News)What you still own
Laravel CloudApps are patched automatically once the patch is releasedConfirming the PHP version afterwards
Laravel ForgeUpdate the server's PHP through the Forge dashboard; if that is not possible, power the server downThe update, restarts, PHP not managed by Forge, any FrankenPHP binary
Laravel VaporPHP 8 apps are redeployed automatically; no patch for PHP 7, move to PHP 8 nowPHP 7 environments; Docker runtime environments

The article repeats PHP's interim workarounds (memory_limit=-1 set administratively, a lower post_max_size, session.upload_progress.enabled=0, and fatal_error_backtraces=0 on 8.5, each followed by a restart of every worker) and warns that ini_set() and .user.ini apply too late. The parent post explains why each is partial. It does not address Octane, Sail or Docker, so the rest of this post fills that gap from each tool's own documentation.

Where PHP comes from in your stack

Keep this table open while you patch. "Fixed by" is what you must change for the patched PHP to be loaded.

SetupWhere the PHP binary comes fromFixed byHow to check
Forge with PHP-FPMVersions installed through the Forge dashboard, each with its own FPM processRuntime tab, PHP, Update; restart FPMphp-fpm8.4 -v
Octane with FrankenPHP binaryA FrankenPHP release saved as frankenphp in the project root, PHP compiled inA FrankenPHP build on patched PHP./frankenphp version
Octane with RoadRunnerThe rr Go binary starts php worker processesSystem PHP package, full restartphp -v
Octane with SwooleSwoole extension inside the system PHPSystem PHP package, full restartphp -v
FrankenPHP Docker imagesdunglas/frankenphp, built on official PHP imagesPull, rebuild, redeployphp -v in the container
Official php imagesphp:8.x-fpm, php:8.x-apache, php:8.x-cliRebuild with --pull, redeployphp -v in the container
Vapor native runtimeruntime in vapor.yml, such as php-8.4:al2023Laravel's automatic redeployA deployed request
Vapor Docker runtimeFROM laravelphp/vapor:php84 in your DockerfileA redeploy on an updated baseA deployed request
Laravel SailUbuntu 24.04 with PHP packages from the Sury repositorysail build --no-cachesail php --version
symfony-docker templatedunglas/frankenphp:1-php8.5 build stagedocker compose build --pull --no-cachephp -v in the container
Symfony CLI local serverYour local PHP-FPMYour local PHP packagesymfony local:php:list

Three rows are where teams lose hours.

The Octane FrankenPHP binary is not your system PHP. Laravel's Octane docs say Octane "will automatically download and install the FrankenPHP binary for you." Octane's installer source shows it fetches the latest FrankenPHP GitHub release and saves it as frankenphp in the project root. FrankenPHP's static build docs describe that binary as one file holding the PHP interpreter, Caddy and FrankenPHP. Patching PHP through Forge, apt or dnf does nothing to it. It does not update itself either: the installer only offers a download when no binary is found or the one found is older than its minimum (1.13.0 in the current source).

The official php:*-apache image is mod_php. Docker Hub says the Apache variant runs PHP as mod_php with mpm_prefork by default. The CVE-2026-109618 advisory says full RCE was validated over HTTP on mod_php with default limits and multi-worker prefork. Many Laravel and Symfony Dockerfiles start from that image. If yours does, it goes first.

Sail is for development. Laravel's Sail docs say Sail relies on artisan serve, and Sail's PHP 8.4 Dockerfile installs PHP packages from the Sury repository. It is lower priority than production, with one exception: sail share gives the container a public URL through Expose. Do not share an unpatched Sail container.

Long-running workers versus PHP-FPM

Octane, FrankenPHP worker mode, RoadRunner and Swoole boot the app once and keep it in memory. FrankenPHP's worker docs list what survives between requests: static variables and properties, globals in the worker script, in-memory caches, and settings changed with ini_set(). That matters for PHP2Shell in two ways.

Exposure: what the advisories say

CVE-2026-109619 is about stale state. Its advisory describes global state a persistent worker keeps after the previous request's memory is freed, which a later request on the same worker can touch. Both requests must hit the same worker; more workers and recycling lower the odds without removing the condition.

RuntimeCVE-2026-109618 (OOM)CVE-2026-109619 (stale state)
mod_phpFull RCE validated over HTTPConfirmed RCE via the PHP 8.5 fatal backtrace path
PHP-FPMSusceptible by source analysis; no complete exploit shownConditional; session upload progress RCE validated on a normal dynamic pool
FrankenPHP, including worker modeLikely susceptible by source analysis, not validatedNo early trigger in default config; the advisory says that is not immunity
RoadRunner, SwooleNot discussedNot discussed

"Not discussed" is not "not affected." RoadRunner's docs show workers launched with a command like php psr-worker.php, and Swoole is an extension inside PHP, so both run the engine code the fixes change. Upgrade them.

Restarts: a reload is not a runtime upgrade

This is the mistake we expect most in Laravel shops. Octane's docs describe php artisan octane:reload as a graceful restart of the application workers so newly deployed code is loaded. FrankenPHP's admin API has a similar worker restart. Both are for application code.

A running process keeps the executable it started with. FrankenPHP workers live inside the frankenphp process and Swoole runs inside the PHP process that started the server, so a worker reload keeps the old engine. RoadRunner's respawned php workers may pick up a new binary, but do not rely on it. The Hacker News lists long-running PHP workers alongside Apache and PHP-FPM as things to restart after updating. The rule that works everywhere: stop the server process and start it again.

# Supervisor program from your Octane config, e.g. [program:octane]
sudo supervisorctl restart octane:*

# Queue workers and Horizon run on the CLI SAPI; restart them too
php artisan queue:restart
php artisan horizon:terminate     # Supervisor starts it again

If you are stuck on an unpatched FrankenPHP binary, lowering Octane's --max-requests (default 500) is a rough counterpart to the advisory's pm.max_requests=1 suggestion for FPM. The advisories do not discuss Octane, it costs performance, and it is not a fix. A better short-term move is serving through patched PHP-FPM, since Octane is optional.

Framework reset hooks do not help

Symfony's runtime docs explain that FrankenPhpWorkerRunner reuses one kernel across requests, so services should implement ResetInterface, or you can set FRANKENPHP_RESET_KERNEL=1 for a fresh kernel per request. Octane resets first-party framework state similarly. These hooks reset application objects. PHP2Shell lives in the engine's internal state, below anything a framework resets. Keep the hooks for correctness, not as a mitigation.

Platform by platform: deploy the patched runtime

1. Laravel Forge

  1. Open the server's Runtime tab, then PHP. For each installed version, open the dropdown and choose Update. Forge's docs warn "a few seconds of downtime is possible."
  2. Forge only knows about PHP installed through its dashboard, per its docs. PHP installed by hand needs your own package update.
  3. Restart PHP-FPM and confirm:
php-fpm8.4 -v
sudo systemctl restart php8.4-fpm
ps -o pid,lstart,cmd -C php-fpm8.4 | head -3   # start time should follow the update
  1. For Octane sites, restart the Supervisor program. For Octane with FrankenPHP, go to step 3 below.

2. Laravel Vapor

  1. Native runtimes: Laravel says PHP 8 apps are redeployed automatically. Vapor's docs list PHP 8.2 to 8.5 on Amazon Linux 2 and 2023 and recommend AL2023, since AL2 is unmaintained after 30 June 2026.
  2. PHP 7: no patch. Change runtime in vapor.yml to PHP 8 and deploy.
  3. Docker runtimes start from a Vapor base image such as laravelphp/vapor:php84, and Vapor builds the image during your deploys. We could not confirm whether the automatic redeploy covers Docker runtimes, so deploy yourself and check the version that answers requests.
environments:
    production:
        runtime: 'php-8.4:al2023'

3. Octane with the FrankenPHP binary

  1. Check what you run. php artisan about reports the CLI PHP, not the PHP inside FrankenPHP.
./frankenphp version        # FrankenPHP, PHP and Caddy versions
  1. Get a FrankenPHP build on patched PHP. Wait for a release built on the fixed PHP (none was listed when we checked on 21 October), or build a static binary yourself; FrankenPHP's docs say PHP_VERSION selects PHP at build time.
  2. Replace the file. Octane will not replace a binary that meets its minimum, so rename the old one first, or copy in your own build.
mv frankenphp frankenphp.pre-php2shell
php artisan octane:install --server=frankenphp
./frankenphp version
sudo supervisorctl restart octane:*
  1. If no patched build exists yet, serve through patched PHP-FPM for now.

4. Docker images

  1. Find every base image: grep -rn --include='Dockerfile*' '^FROM' .
  2. Rebuild with a fresh pull. FrankenPHP's docs say its images are rebuilt daily when new official PHP images appear, and its February 2026 advisory GHSA-x9p2-77v6-6vhf tells users building on top to force a fresh pull with docker build --pull.
docker build --pull --no-cache -t registry.example.com/app:php2shell .
docker run --rm --entrypoint php registry.example.com/app:php2shell -v

# symfony-docker template, per its README
docker compose build --pull --no-cache && docker compose up --wait
  1. Pinned digests and patch tags such as php:8.4.26-fpm never move. Edit them.
  2. Redeploy, then check what is running, not what was built:
kubectl rollout restart deployment/app
kubectl exec deploy/app -- php -v

5. Laravel Sail and the Symfony CLI

Sail's docs give the rebuild sequence. Their version uses docker compose down -v, which also deletes volumes such as your local database; drop -v to keep data.

docker compose down
sail build --no-cache && sail up -d
sail php --version

Locally, the Symfony CLI server uses your installed PHP-FPM, picking a version from .php-version. Update local PHP, then run symfony local:php:list. On servers, Symfony under Nginx or Apache with PHP-FPM follows our fleet patching guide.

CI images and Composer platform config

CI is where an old PHP quietly survives, and the cheapest place to catch one.

setup-php. Its README says "Pre-installed PHP versions are not updated to their latest patch release by default." Set update to true:

- uses: shivammathur/setup-php@v2
  with:
    php-version: '8.4'
  env:
    update: true

When php-version is unset, setup-php reads .php-version, then platform-overrides.php in composer.lock, then config.platform.php in composer.json.

Composer config.platform does not describe your runtime. Composer's docs say it lets you fake platform packages so dependencies resolve as if that PHP were present, and warn that dependencies "are not checked correctly anymore." Setting it to 8.4.27 patches nothing. composer check-platform-reqs ignores config.platform and checks the real PHP against your packages' requirements, and Composer recommends it in deployment. It knows nothing about CVEs, so PHP 8.4.26 passes.

Add an explicit floor. This guard fails CI or an image build on an unpatched upstream PHP:

php -r '$min = ["8.2"=>"8.2.35","8.3"=>"8.3.36","8.4"=>"8.4.27","8.5"=>"8.5.12"];
$b = PHP_MAJOR_VERSION.".".PHP_MINOR_VERSION;
if (!isset($min[$b]) || version_compare(PHP_VERSION, $min[$b], "<")) { fwrite(STDERR, "Unpatched PHP ".PHP_VERSION."\n"); exit(1); }
echo "PHP ".PHP_VERSION." OK\n";'

Use it only for upstream builds (official images, setup-php, FrankenPHP). Distro packages with backported fixes often keep older version numbers; search their changelog for the CVE instead.

Verify the patch took

  1. Process start time later than install time. An FPM master or Octane process older than the update runs old code.
  2. Ask the serving runtime. ./frankenphp version, or exec into the running container rather than a fresh one from the same tag.
  3. Every node behind the load balancer. One unpatched node is enough.
  4. Your test suite on the new runtime. The Hacker News warns that reaching the fix's new hard memory limit "stops shutdown handlers from running," so review memory_limit and post_max_size and watch heavy import jobs.

What would have helped

Teams that knew which runtime each app used spent 21 October rebuilding. Teams that first had to discover an Octane-downloaded FrankenPHP binary in the repo spent it investigating.

Practices any team can use

  • Record the serving runtime per app, for example "Octane on FrankenPHP 1.13.1 binary" or "php:8.4-apache, mod_php," in the README or service catalog.
  • Treat downloaded runtime binaries as dependencies. Pin frankenphp or rr, record the source, and keep one command that replaces it.
  • Write the restart rule down: octane:reload for code, a process restart for the runtime.
  • Rebuild images weekly with --pull, so a security rebuild is routine.
  • Fail CI on an old patch level with the guard above and update: true.
  • Leave session upload progress off if unused; PHP suggests keeping it disabled after patching.
  • Leave end-of-life PHP early. Vapor had no PHP 7 patch, and PHP 8.2 reaches end of life on 31 December 2026, per php.net.

Where Safeguard fits

Safeguard would not have prevented PHP2Shell, and it does not patch servers or restart workers. It helps with where the vulnerable runtime sits and with making the change.

  • Images. safeguard scan --image <name> builds an SBOM of an image and matches components against vulnerability databases, with JSON results under ./output. That works best where PHP came from OS packages. For PHP compiled into an image from source or into a static FrankenPHP binary, confirm with php -v or ./frankenphp version.
  • Which apps. The platform shows which projects contain a given component and version. Our SBOM incident response post walks through it.
  • Gates. Policies and gates evaluated in the platform can block a build still carrying a vulnerable PHP package.
  • The change itself. The Safeguard CLI's assistant can edit a Dockerfile to a patched base tag and run your build and tests, asking before each command under its default mode. The desktop and phone apps can send that work to a computer running the CLI for step-by-step approval.

Limits: a composer.lock scan will not find PHP2Shell, because PHP is not a Composer package. Reachability analysis does not lower priority, since the bug fires before your code runs. Safeguard cannot see inside Laravel Cloud or Vapor's managed runtimes, or Forge servers you never scan, and same-day advisories can reach databases late.

Zero-Day Discovery and Gold

  • Zero-Day Discovery looks for previously unknown flaws before a CVE exists, in an agentic mode and a deterministic scanner mode, and issues findings as SGZ advisories before they reach public CVE databases. It covers seven package ecosystems.
  • Gold Open Source (gold.safeguard.sh, free, no login) offers searchable CVE intelligence with CVSS, EPSS, KEV status, affected and fixed versions and a recommended safe version, a free read API, and a CI gate GitHub Action. It had no entry for these two CVEs when we wrote this, so use php.net and your vendor.
  • Gold Registry (registry.safeguard.sh, paid) provides hardened drop-in components and container images held to zero exploitable critical or high CVEs, SLSA L3+ and Sigstore-signed, with evidence pages; an artifact loses its badge on regression. The habit it supports is the one that mattered here: pull patched, signed components from a curated source and rebuild fast.

To try the CLI, download Safeguard at https://safeguard.sh/download and read the CLI docs at https://docs.safeguard.sh/docs/cli .

Common mistakes and troubleshooting

  • Running composer update and stopping. Framework releases change app code, not the runtime.
  • Patching Forge PHP while Octane serves through ./frankenphp. The binary carries its own PHP.
  • Using octane:reload after a runtime upgrade. Restart the server process.
  • Trusting php artisan about. It reports CLI PHP, which may not be the serving PHP.
  • Rebuilding on a cached layer or pinned digest. Use --pull --no-cache and edit pins.
  • Bumping config.platform.php. It changes dependency resolution, not the runtime.
  • Setting workarounds with ini_set() or .user.ini. They apply too late.
  • Octane fails to start on the new binary. Check it is executable and the right build; Octane's installer picks a glibc build when getconf GNU_LIBC_VERSION succeeds. Fall back to PHP-FPM while you sort it out.

For how the disclosure played out more widely, see governments taking websites offline over PHP2Shell and AI found vulnerabilities: PHP2Shell and PwnEscape.

FAQ

Does a Laravel or Symfony update fix PHP2Shell?

No. The flaws are in the PHP runtime. You need PHP 8.5.12, 8.4.27, 8.3.36 or 8.2.35, or a vendor build with backported fixes, then a restart of every serving process.

Is Octane safer or riskier than PHP-FPM here?

The advisories do not say. CVE-2026-109619 depends on state persisting in a worker, which is how Octane works. FrankenPHP worker mode had no early trigger in default config, which the advisory says is not immunity. RoadRunner and Swoole were not assessed. Patch either way.

Why is my Octane app on the old PHP after a Forge update?

With FrankenPHP, Octane runs the frankenphp binary in your project root, which has PHP compiled in. Forge updates the system PHP. Replace the binary, or use PHP-FPM until a patched build exists, then restart Octane.

Do I need to act on Laravel Cloud or Vapor?

Laravel says Cloud apps are patched and Vapor PHP 8 apps redeployed automatically. Vapor PHP 7 apps get no patch. For Vapor Docker runtimes, redeploy and confirm the version.

Should I change config.platform.php?

No. It only tells Composer which PHP to resolve for. Add an explicit version floor check in CI instead.

My image uses php:8.3-apache. How urgent is this?

Very. That variant runs mod_php with prefork, where both advisories confirmed remote code execution. Rebuild with --pull, redeploy and verify today.

Fix it without waiting for upstream

In incidents like this one, most of the risk sits in two gaps. The first is the time between a fix being published and that fix actually running in your production builds. The second, and worse, is the time before any fix exists at all, while a maintainer works out a patch and a release schedule. Waiting through both gaps is a habit, not a requirement. Safeguard gives teams three ways to close them.

1. Autonomous remediation: pull requests, not tickets

When a fixed version exists, Safeguard's Griffin models open a tested, policy gated pull request that moves the affected dependency to the safe version. You can run it on one repository or in bulk across every project that carries the vulnerable version. Remediation covers eight categories, including dependencies, licenses, security posture and findings. Reachability analysis ranks the vulnerable code paths your application actually calls first, so the first pull requests you merge are the ones that matter most. Autonomous remediation ships switched off, with every category set to Manual, so your team decides how much runs on its own and what still needs a human approval.

2. Gold Open Source: the safe version in seconds, free

gold.safeguard.sh is free and needs no login. Search any CVE or package to see affected and fixed versions, CVSS, EPSS, CISA KEV status and a recommended safe version, or use the "Am I affected?" checker. The same data is available through a free read API, README status badges and RSS threat feeds. The safeguard-sh/gold-scan-action GitHub Action can fail a build on a known exploited vulnerability, on any known vulnerability, or never, so a version you have just removed cannot quietly come back in next week's merge.

3. Gold Registry: start from components that are already fixed

registry.safeguard.sh provides hardened, drop in replacements for upstream packages and container images. Every Gold artifact is held continuously to zero exploitable critical or high CVEs, zero malware and a completed license check, with SLSA L3 or higher provenance, a Sigstore signature and a public evidence page you can hand to an auditor. An artifact loses its Gold badge if it regresses. Teams can point their own package directory at the registry so hardened equivalents resolve without changing how developers install anything.

When upstream has no fix yet, or will not ship one, Safeguard first works to get the fix accepted upstream. If the issue is severe or upstream will not move, Safeguard builds a fork, fixes the vulnerability there and publishes a hardened Gold build, so you are not stuck on a maintainer's release schedule. You can also request a hardened, compatibility tested build of a specific package. This fork and patch flow is in early access today, so ask which packages it covers before you plan around it.

Your situationWhat to use
A fixed version existsAutonomous remediation opens the upgrade pull request; Gold Open Source confirms the safe version
You need to know today whether you are affectedGold Open Source lookup and "Am I affected?" checker, free and without login
You want vulnerable versions to stop shippingThe Gold scan GitHub Action in CI, plus Safeguard policies and gates
Upstream has no fix yet, or will not ship oneA custom Gold request; fork and patch (early access)
You want to start clean on new projectsPull hardened components and images from Gold Registry

One honest limit: when the vulnerable code lives in a vendor appliance, a hosted service or an operating system runtime you do not build yourself, the fix still has to come from that vendor or distribution. The steps above apply to the open source dependencies and container images you build and ship, which is where most teams carry the largest and least tracked share of their exposure.

See how Safeguard would have helped

These demos show real sessions on other tasks. Each intro says how the same flow applies to PHP2Shell.

The first shows the Safeguard CLI fixing a lodash CVE in a Node repo: it reads the finding, asks before each command, edits the dependency, runs install and tests with approval, and pushes nothing. For PHP2Shell, you would ask it to move your Dockerfile from php:8.4.26-apache to a patched tag and run your build and tests, approving each command the same way.

Click through it at your own pace, or open it full screen.

The second is the desktop app. A CLI session appears in the conversation list, you ask for work on your computer, pick the folder, and approve or deny each command and file edit, ending with an upgrade done and tests passing. For a Laravel team the folder is the app repo, and the work is the base image bump plus replacing the frankenphp binary, each step waiting for your yes.

Click through it at your own pace, or open it full screen.

The third sets up the local runner on your own machine, approves it with a device code, and scans from the terminal. Here you would point it at each PHP image to see which still carry an old PHP package, without the images leaving your network.

Click through it at your own pace, or open it full screen.

Quick check

After patching PHP on a Forge server, is php artisan octane:reload enough?

No. A reload restarts application workers inside the running server, which keeps the old engine. Restart the Octane process through Supervisor, and if Octane uses FrankenPHP, replace the frankenphp binary too.

Does setting config.platform.php to 8.4.27 patch anything?

No. It only tells Composer which PHP to assume when resolving dependencies. The runtime is unchanged, and composer check-platform-reqs ignores that setting.

Sources

Last reviewed: 21 October 2026. This is a developing story; check the linked advisories for the latest status.

Never miss an update

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

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.