Is FrankenPHP worth it for Shopware?

“Process incoming requests without having to boot your app again.”
Kévin Dunglas, creator of FrankenPHP, in his introduction to worker mode (slide 18, 2022).
That is the appeal of worker mode in one sentence. For a Shopware team running its own web tier, the harder question is whether the saved work justifies the changes needed to reuse a booted application safely.
I compared PHP-FPM, FrankenPHP classic mode and FrankenPHP worker mode with Shopware’s Docker images, then tested sustained Admin traffic and browser visits to a populated music shop. This is an evaluation of development code with known compatibility gaps, not a production recommendation for released Shopware.
Shopware already recommends FrankenPHP in its Docker deployment guide. Using that server and keeping Shopware objects alive between requests are separate decisions. Classic mode is the first without the second. The measurements are grouped under Benchmarks. The choice is in Should you try FrankenPHP with Shopware?.
What changes when you replace FPMLink to this section
FrankenPHP is a PHP application server built on Caddy. It serves HTTP and runs PHP in one process. Worker mode is optional: the application can stay in memory between requests. Adopting the server does not require that. See the FrankenPHP overview.
| Capability | What it means for Shopware | What this post actually checked |
|---|---|---|
| Integrated Caddy + PHP | Removes the separate FPM service and FastCGI connection | Used in classic and worker tests; the operational saving was not measured |
| Automatic HTTPS; HTTP/2 and HTTP/3 | Can terminate TLS and speak modern HTTP when it faces the public | HTTP/2 was on, including in the FPM control. The browser lab used local TLS. Public certificate automation and HTTP/3 were not tested |
| Worker mode | Reuses the booted application | Measured on patched trunk; request cleanup still has gaps |
| Metrics and structured logging | Building blocks for watching the runtime | Used to watch these runs; a production dashboard is still your job |
| Early Hints, Mercure, standalone binaries | Optional hints, live events, and a single packaged binary | Not tried with Shopware |
From the browser to PHPLink to this section
In a typical FPM deployment, a web server accepts the HTTP request and forwards PHP work through FastCGI to an FPM process. FrankenPHP embeds PHP in the web server, removing that separate connection and service configuration.
The arrows show the incoming dynamic request path. Responses travel back to the browser. Static assets can be served directly by the web server in either setup.
For a team maintaining many environments, fewer configuration boundaries can mean less work packaging, reproducing and troubleshooting the web tier. That is an architectural benefit to evaluate, not a saving the benchmark measured. It also couples server and PHP upgrades more closely. Certificate automation adds less when an ingress or CDN already handles TLS, and managed FPM hosting may already make that work inexpensive. These are reasons to try classic mode without treating it as a speed upgrade.
Worker mode boots onceLink to this section
FrankenPHP workers serve HTTP. They are not Shopware’s messenger:consume workers, which process background messages.
With PHP-FPM, each request starts a fresh application lifecycle. The FPM process and OPcache can survive, so this does not mean PHP source is recompiled on every request. The application still has to initialize its kernel and services again. FrankenPHP’s classic mode also avoids retaining the application’s objects across requests.
In worker mode, the application boots once. A worker handles a request, completes its request lifecycle, and waits for another. Already initialized shared services can be reused. That is where repeated bootstrap work can be avoided.
This is a conceptual lifecycle. Cleanup hooks and reset timing depend on the framework integration. Request state has to be clean before the next request uses the application. FrankenPHP cannot infer how to reset arbitrary service properties.
The next request can belong to a different customer, language or sales channel. A worker does not belong to a user. Stable dependencies may live with the application; the current customer’s identity and permissions must have a shorter lifetime. A service constructor that captures the current request once can preserve that value long after its intended use.
This is the central tradeoff: avoiding repeated initialization makes object lifetimes part of application correctness.
One request kept the wrong hostLink to this section
In storefront checks, a request for domain B received JavaScript and font URLs pointing at domain A. The page returned HTTP 200. The browser blocked the cross-origin assets. A shared asset service had kept the previous request’s origin. The wrong host showed up in 20 of 20 worker responses. The FPM control, on the same code and database, returned the current host all 20 times. The reproduction is Shopware issue #21063.
An extension can do the same thing by storing the current host when the service is constructed and building links from that property later.
| Pattern | What the next request sees |
|---|---|
| Capture the host in the constructor and reuse it | Domain B can still get domain A’s URLs |
| Read the host from the request that is being handled | The link follows that request |
The second pattern is the lifetime rule, not a proposed patch for the core asset service. The same review applies to a cached customer, language or sales channel. Keep stable configuration on shared services, resolve request-specific values when the request is handled, and give any cache built from the request a reset that actually runs between requests.
Test A, then B, on the same worker. Two separate checks can both pass and still miss this.
HTTP/2 does not add PHP workersLink to this section
An Administration screen may send several API requests at once. HTTP/2 allows those streams on one connection. It does not create more PHP. Ten requests can arrive together while only five workers exist; five run and five wait. FPM has the same kind of limit. A comparison has to hold both constant, or HTTP/2 gets credit for a worker-mode gain.
BenchmarksLink to this section
The charts are in this section. If you want the choice without the tables, skip to Should you try FrankenPHP with Shopware?.
The logging fixLink to this section
Earlier runs slowed down as the same workers kept serving. Recycling every 500 requests was the workaround. Three Shopware decorators in Core/Framework/Log/Monolog never forwarded reset() to the buffer underneath: ExcludeFlowEventHandler , ErrorCodeLogLevelHandler and ExcludeExceptionHandler . The inner FingersCrossedHandler kept log records from previous requests. Memory grew, and each request spent more time in garbage collection. That is Shopware issue #21124.
I forwarded reset() in all three and applied that patch to every runtime. The charts below are that build, with no 500-request restart cap.
Admin searchesLink to this section
The public lab compares Shopware’s FrankenPHP image in classic and worker modes with Shopware’s Caddy/PHP-FPM image. The application is pinned to development commit 868f25121f76, with the plugin-init correction and the logging fix on all three runtimes. This is trunk, not Shopware 6.6 or 6.7. PHP is 8.4.26, Caddy is 2.11.4, FrankenPHP is 1.12.7. Production mode, debug off, Shopware’s HTTP cache off, five PHP execution slots, recycling off. The point is to exercise PHP, not a cached catalog page.
The images are not the same PHP build. FrankenPHP uses ZTS PHP on Debian 13. The FPM image uses NTS PHP on Alpine 3.24.2. This compares Shopware’s shipped stacks, not one isolated runtime variable.
Each virtual session issues four parallel read-only Admin searches, then immediately repeats the burst. At 20 sessions over HTTP/2 with gzip, medians of three 15-second runs were:
| Measurement | PHP-FPM | FrankenPHP classic | FrankenPHP worker |
|---|---|---|---|
| Completed requests per second | 520.1 | 559.3 | 911.3 |
| Request p95 latency | 185.2 ms | 157.2 ms | 95.0 ms |
| Web-container CPU seconds per 1,000 requests | 8.24 | 7.51 | 4.55 |
Both axes start at zero. Bars show medians; dots show individual runs, not confidence intervals. These are short workload-specific results. The chart rounds the medians; the table uses the recorded values.
Across 1, 5 and 20 sessions, all 224,952 measured responses passed status, HTTP/2 and entity ID/total validation. Every response used gzip, with the same mean compressed body size across runtimes. Workers delivered 1.75 times FPM’s throughput at 20 sessions. Classic and FPM had overlapping run ranges (FPM 466.7–541.2 req/s, classic 522.2–566.7), so their median difference is not a convincing reason to choose classic mode.
The lower worker CPU time per request is a potential efficiency benefit. It excludes database and client CPU and does not establish a hosting-cost saving. An uncompressed control at the same load narrowed the worker/FPM ratio to 1.50. The measurement report has that control, the other load levels and the fixture. The benchmark guide has the commands.
These tests ran on an Apple M4 Pro, inside an ARM64 Linux VM with 8 CPUs, over HTTP/2 without TLS, with one admin account, no writes and no human think time.
Workers that kept runningLink to this section
The same five workers then kept going, with FRANKENPHP_LOOP_MAX=0 , for ten more minutes of Admin searches at 20 sessions. All 546,432 responses passed. Throughput stayed between 885 and 927 requests per second: 916 in the first minute, 917 in the last. Container memory stayed around 164 to 166 MiB. By the end of the whole sequence, each worker had handled more than 140,000 requests. For this workload, they did not need a restart after 500 requests to avoid that slowdown.
A short test on fresh containers had overlapping throughput ranges, so turning recycling off is not automatically faster. Both controls are in the measurement report.
This is one Admin-search workload on a patched build, not a Shopware release that is ready to leave workers running. Checkout, writes, failures and plugins can still hold state the log fix does not touch. Recycling can limit how long a leftover lives. It still cannot make the second request safe if the first one already left something behind.
Storefront pagesLink to this section
The 1.75× Admin result does not predict storefront page speed. I added browser tests with Shopware’s HTTP cache on. A first fixture compared all three runtimes over HTTPS and HTTP/2, on a catalog with no product photos. Those results stay available. The music shop below is a separate dataset.
A music shop with real photosLink to this section
The follow-up used populated music shops: a homepage with a hero image, guitar search results, and an acoustic-guitar product page. Every visible image had to load before the timing counted. Every measured largest element was an image.
This is a different installation from the public lab above. FPM and workers share code, database and theme, on commit 868f25121f76 plus the plugin-init correction. FPM is PHP 8.4.17 NTS. Workers are PHP 8.4.26 ZTS. This build does not have the logging reset fix, so these timings do not replace the corrected Admin run. Chromium used HTTP/1.1 with gzip, not HTTP/2. HTTP caching is on, and both sides have five PHP execution slots. Worker request-count recycling stays off.
A third trunk shop, with a different revision and a slightly different catalog, is on the chart as context. The same-code FPM shop is the control.
At 1, 5 and 10 sessions, each session did two warm-up cycles and six measured ones. The comparison repeated three times. All 2,592 navigations and 864 warm-ups passed. At ten sessions, against the same-code FPM:
| Page | FPM TTFB | Worker TTFB | FPM LCP | Worker LCP |
|---|---|---|---|---|
| Homepage | 33 ms | 29 ms | 120 ms | 124 ms |
| Guitar search | 184 ms | 98 ms | 232 ms | 160 ms |
| Product detail | 35 ms | 19 ms | 80 ms | 84 ms |
Search got faster, including the time until the largest image painted. The homepage and the product page did not: a faster first byte did not paint the image sooner. LCP here is watched until 500 ms after load and image readiness. It is a lab observation on a shared host, without a mobile throttle, without scrolling lazy images, and without a separate cache-hit count.
The music catalog guide explains how to repeat it. The report has the other charts.
Running workers day to dayLink to this section
With PHP-FPM, the process may survive, but the application starts again for each request. With workers, the application survives too. That changes the operating routine.
Start with deployment: boot new instances, check that the application is ready, move traffic, then drain the old instances. Verify the release through a dynamic request. A static health page does not prove the application booted.
Rolling replacement needs spare capacity and compatible migrations. A single instance may need an interruption.
After plugin:deactivate , FPM stopped serving the plugin immediately. Workers kept serving it until they were restarted. The same applies to activate, install, update, .env and config/packages changes. A worker-script restart can reload the application. PHP INI or preloaded code needs a process restart, and changed container environment variables need a new container.
| Boundary | What changes with a persistent application | Practical check |
|---|---|---|
| Request cleanup | Shared services, static values and in-memory caches can outlive the request | Run two different user contexts through one worker, and check cleanup after an exception too |
| Connection lifetime | Database and API clients can stay alive through idle periods | Wait past a connection timeout, then test the next request and recovery, including a rolled-back transaction |
| Memory lifetime | Retained application objects survive the request | Plot memory and p95 against requests handled since the worker started, including large responses and errors |
| Worker availability | A failed startup or a crash loop removes serving capacity | Test a boot failure in staging. Readiness and alerts should notice missing workers even while Caddy still responds |
Persistent connections can exist under FPM too, so check the client settings you actually run. PHP’s memory_limit is not a whole-container budget. Measure the process together with busy workers, queues and restarts. A memory plateau caused by repeated restarts is a different shape from a stable long-lived worker. FrankenPHP’s observability guide describes the metrics. The lab’s configuration audit separates production tuning that was verified from experiments that are only proposed. Required PHP extensions, profilers and monitoring agents also need a check against the runtime compatibility notes.
Try it in DockerLink to this section
The unofficial Shopware + FrankenPHP lab uses standard Docker Compose and isolated named volumes. No existing Shopware checkout, local PHP, Composer or Node installation is needed.
This is a compatibility playground, not a production distribution. Shopware has not been comprehensively tested with FrankenPHP here, in either mode. Classic mode is the default so you can establish a baseline before introducing persistent application state.
git clone https://github.com/BrocksiNet/shopware-frankenphp-lab.git
cd shopware-frankenphp-lab
docker compose up --build -d
docker compose logs -f initThe initializer installs dependencies, creates the database and storefront, builds the Administration and Storefront assets, and assigns the theme. Wait for it to finish before opening http://localhost:8080 or /admin . The local demo login is admin / shopware . The catalog starts empty. The music catalog guide adds a populated template with photography. The benchmark guide has the synthetic Admin fixture.
The app binds only to your machine’s loopback interface. Demo credentials belong in this disposable lab. The stack omits background consumers, scheduled-task runners, OpenSearch and production TLS.
Why the Shopware version mattersLink to this section
“Supports Symfony” does not by itself establish Shopware compatibility. Shopware has its own kernel integration, plugin loading, storefront context and theme services. Those lifecycles have to work with the persistent Symfony application.
The comparison uses a pinned development revision that includes the kernel reset work from PR #19121, plus a local correction to initialize plugins before Symfony builds the container. Without that correction, a successful page response can hide the fact that plugin bundles were never loaded: KernelPluginLoader::getBundles() returns nothing when the loader is not initialized, and CLI tests call boot() first, which hides the gap. The logging correction above is on every runtime in the comparison. The repository includes the patches. It does not download an ever-changing PR head on startup.
The integration uses symfony/runtime 7.4’s FrankenPhpWorkerRunner . Enabling a worker in the server configuration selects the persistent runner. Registering reset methods in services is not enough if the application’s kernel does not invoke the reset lifecycle. A separate runtime package or a worker configuration alone cannot supply missing Shopware lifecycle fixes.
The Twig globals fix had merged upstream as of 2026-10-01, but it is absent from this older pinned baseline. The kernel lifecycle PR and long-running compatibility work remain relevant. A merged fix helps only after it is included in the version you actually run.
The asset-host failure above is still open on this checkout. So are these:
- An idle MySQL connection is not reconnected after wait_timeout (#14313).
- After a write, the primary/replica connection could stay on the primary for the worker’s life. That was fixed upstream in #18857 and was not re-tested here.
- public/index.php checks install.lock and the maintenance flag only when the worker starts. Install first, then start workers, or the worker crash-loops. A running worker will not start showing maintenance mode mid-update.
Switch to workers after checking the baselineLink to this section
docker compose -f compose.yaml -f compose.worker.yaml up -d --no-deps --force-recreate webThis keeps the same code, database, URL and cache setting, and starts five workers with recycling disabled ( FRANKENPHP_LOOP_MAX=0 ). New installs include the logging fix. An existing lab needs the patch update steps first; rebuilding alone does not update the application volume. Classic mode also has five PHP execution slots. Verify the runtime state instead of guessing the mode from response time:
docker compose exec web curl -s http://localhost:2019/frankenphp/threadsFor classic mode, the response lists regular PHP threads without a worker script. To return to that baseline:
docker compose up -d --no-deps --force-recreate webRun the browser comparisonLink to this section
From the classic baseline, with the lab already up:
npm ci
npx playwright install chromium
export ADMIN_BENCH_PASSWORD=shopware
node tools/seed-benchmark.mjs --seed-lab
node tools/seed-browser-domain.mjs --seed-lab
node tools/browser-matrix.mjs --output measurements/browser-h2-cache-onThe matrix switches the lab between FPM, classic and worker at the same storefront URL, then puts classic mode back. Keep other traffic off it while it runs. That command runs the synthetic fixture. For a populated catalog and your own theme, use the music catalog guide. The browser guide covers the reports.
Test the shop on the same workerLink to this section
With PHP-FPM, the next visitor gets a new application. With workers, they get the one that just served someone else, after a reset. A homepage that loads only tells you the first request worked. The request that matters is the next one, when the customer, the domain, or the cart has changed, and it still has to land on that same worker.
An extension does this as soon as it remembers something about the current customer. A custom price, a discount, a customer group, a currency, a rule. A service is one instance of a class: Symfony builds it once and injects that same object wherever it is needed. Store the customer’s price on it, and the next request still sees that property. A class you create for the current request still dies when the function returns. PHP-FPM hides the mistake, because it builds the container again for every request. A worker keeps the instance.
class CustomerPriceProvider
{
private ?float $unitPrice = null;
public function priceFor(SalesChannelContext $context, string $productId): float
{
if ($this->unitPrice !== null) {
return $this->unitPrice;
}
$this->unitPrice = $this->resolve($context, $productId);
return $this->unitPrice;
}
}The first customer fills $unitPrice. The next customer never reaches resolve(), and pays the first customer’s price until the worker restarts. The same thing happens when a constructor stores the context, the customer, or the host. Pass the context into the call, and keep the result in a local variable:
public function priceFor(SalesChannelContext $context, string $productId): float
{
return $this->resolve($context, $productId);
}
private function resolve(SalesChannelContext $context, string $productId): float
{
return $this->prices->forCustomer($context->getCustomerId(), $productId);
}Two checks that pass on their own can both miss a value that survived between them. Walk the store as sequences:
| After this | Then this, on the same worker | What can stick from the previous request |
|---|---|---|
| A logged-in customer | A guest, or a different account | Customer, custom price, rules, and the cart |
| Login, then logout | A new guest session | The session that just ended |
| Storefront domain A | Domain B | Asset host, language, currency, sales channel |
| One guest cart | A second browser’s cart | The cart token |
| Registration through a test payment | The same path as someone else | Order and payment state from the first run |
| A failed request or an exception | An ordinary page | Cleanup that only runs when the request succeeds |
| A product, price, theme, or plugin change | The storefront, without a restart | Configuration and caches captured at boot |
| An admin with full permissions | A restricted admin user | Permissions and the admin context |
That is the full shop: your theme, your plugins, checkout, and the path where one visitor follows another. I only loaded the homepage, search, a product page, and two guest carts. The lab’s testing guide is a starting list for this setup. Your Shopware version, theme, and plugins still need the sequences above.
Should you try FrankenPHP with Shopware?Link to this section
For a platform team maintaining its own web tier, the integrated server and packaging options may be enough to justify testing classic mode first. That decision should include the classic-mode results as well as the operational cost of maintaining the deployment.
For a team with substantial dynamic PHP work and control over its extensions, worker mode deserves a trial. Measure correctness and sustained efficiency with the actual application, including deployment and recovery behavior. The potential benefit is capacity from doing less repeated work. The cost is managing persistent state reliably.
For a merchant on reliable managed FPM hosting, waiting can be the sensible choice. A runtime change may offer little when the main constraint is database queries, external integrations or frontend delivery. Existing hosting knowledge and predictable operations have value too.
For a production trial, keep a tested FPM rollback that still matches the application and the database. The decision is whether the simpler deployment, or the application work you no longer repeat, is worth the ownership your team takes on.
TL;DRLink to this section
FrankenPHP offers a simpler web server and PHP package. Worker mode adds application reuse, and the job of managing that longer lifetime. On patched development code, read-only Admin searches reached 911 requests per second, about 1.75 times PHP-FPM, and held that pace for ten minutes without a request-count restart cap.
The storefront benefit depended on the page. On the music shop, search went from 184 ms to 98 ms to first byte, and the largest image from 232 ms to 160 ms. Homepage and product-page images did not get faster. That run used HTTP/1.1 and different PHP builds. Use the Docker lab on your own workload and extensions. These results support further testing, not a general production recommendation.