Search by

magenxcommerce / module-graph-ql-app-server

magenxcommerce

PoC: serve Magento /graphql from a FrankenPHP worker that keeps the application bootstrapped in memory and resets request state between requests.

Package info

github.com/magenxcommerce/module-graph-ql-app-server

Type:magento2-module

pkg:composer/magenxcommerce/module-graph-ql-app-server

Statistics

Installs: 0

Dependents: 1

Suggesters: 0

Stars: 1

Open Issues: 0

v1.2.2 2026-10-15 19:04 UTC

This package is auto-updated.

Last update: 2026-10-24 19:25:32 UTC


README

Built with AI assistance. This module is built with significant use of Claude Code, under active human review and guidance. It is open source and in active development. AI assistance does not lower the engineering bar: every change must meet the same correctness, security, documentation and maintainability requirements as any other contribution.

No warranty. We are not responsible for any incompatibility. By installing this software, you accept responsibility for its security, upgrades, patches and monitoring.

Serves Magento's /graphql from a FrankenPHP worker that boots Magento once per worker and keeps it in memory, instead of re-bootstrapping on every request like PHP-FPM. Open-source counterpart of the idea behind Adobe Commerce's (Commerce-only, Swoole-based) Magento_ApplicationServer; no Adobe code is used.

Status: proof of concept. It has not run against a real store yet. Do not send production traffic to it before the verification gate below passes with every module you run enabled.

How it works

Magento 2.4.7+ core already contains the state-reset machinery (ResetAfterRequestInterface::_resetState(), the Resetter, reset.json, and GraphQlStateTest), but in Open Source nothing feeds the Resetter: the object factory that registers instances with it ships only with Commerce. This module supplies that piece and the request loop.

File Role
bin/graphql-worker.php FrankenPHP worker entrypoint: bootstrap once, loop on frankenphp_handle_request()
Worker/GraphQlWorker.php Per-worker setup (error handler, graphql area, area DI config, front controller) and per-request hydrate → dispatch → send → reset
Worker/Limits.php Recycle limits: requests, memory, age
Worker/Scope.php Worker mode (MAGENX_WORKER_MODE): default admits every request, restricted only anonymous, read-only, allowlisted queries; anything out of scope is refused with 503 for nginx to replay on PHP-FPM
ObjectManager/ObjectManagerFactory.php Worker-only ObjectManagerFactory that swaps in the environment below
ObjectManager/EnvironmentFactory.php, Environment/Compiled.php Compiled DI environment with the reset-tracking factory; refuses to boot without setup:di:compile output
ObjectManager/Factory/Compiled.php Subclass of the core Compiled factory that registers every created object with the core Resetter
bin/check-reset-json.php Checks etc/reset.json against the installed classes (property exists, default matches, not set in the constructor)
Plugin/Quote/RestoreTotalCodes.php graphql-area plugin that gives quote totals back the code core AbstractTotal::_resetState() clears; _resetState() itself cannot take a plugin
frankenphp/ Worker image and wiring for magenx/Magento-2-docker-configuration: Dockerfile, Caddyfile, zz-worker.ini, compose override, nginx templates, .env block, install.sh

Per request:

  1. Hydrate. The shared Request\Http is reset and refilled from the superglobals FrankenPHP set for this request. It is changed in place, never replaced, because many singletons captured it when they were built. This also clears request properties that core _resetState() misses in 2.4.8 (originalPathInfo, isSafeMethod, route, ...). Cookies are dropped ($_COOKIE, HTTP_COOKIE; Caddy also strips the header): GraphQL clients authenticate with tokens, and a session cookie must never resume a PHP session inside a long-lived thread.

  2. Scope check. A request outside the worker mode's scope (see Worker modes) gets 503 before Magento runs, and nginx replays it on PHP-FPM. In restricted mode the query is parsed with the core QueryParser, which Magento then reuses, so each query is parsed once.

  3. Maintenance check. Answers 503 while var/.maintenance.flag exists; nginx replays it on PHP-FPM, which answers for maintenance.

  4. Dispatch through the graphql front controller, so its plugins (store header, cache tags, ...) run as usual. Then fire controller_front_send_response_before and send.

  5. Clean up, after the response is sent. Roll back any DB transaction left open, abort any PHP session a request started, then run Resetter::_resetState() on every tracked object. The core QueryParser cache is emptied above 1000 parsed queries. This runs once frankenphp_handle_request() has returned. FrankenPHP holds the whole response until the handler returns, so cleanup inside the handler would add the reset and its gc_collect_cycles() calls to every request's time to first byte.

  6. Recycle check. The worker exits cleanly (FrankenPHP starts a fresh one) on any of:

    • a throwable escaped,
    • a transaction was left open,
    • MAGENX_WORKER_MAX_REQUESTS (default 500) reached,
    • memory reached MAGENX_WORKER_MAX_MEMORY_MB (default 512),
    • the worker is older than MAGENX_WORKER_MAX_AGE seconds (default 1800),
    • app/etc/env.php changed,
    • the config cache was rebuilt or flushed (admin config save, cache:clean config, cache:flush): checked at most once a second by reading core's system_scopes config cache entry, which System config re-encrypts with a fresh nonce on every rebuild.

    Request and age limits get +-20% jitter per worker, so workers that booted together do not all recycle and reboot at the same moment.

    Each boot and each exit is logged to Magento's var/log/system.log (notice), so restarts counted by frankenphp_worker_restarts can be told apart:

    magenx-app-server: worker 1/9f3c2a1b booted in restricted mode in 612 ms, memory 38.2 MB (peak 40.1 MB), opcache 41210 scripts (130987 max), 301/512 MB, interned 38/64 MB, hit 99.2%, restarts oom 0 hash 0
    magenx-app-server: worker 1/9f3c2a1b recycling (max-age) after 87/463 requests, 1712/1712 s, memory 61.4 MB (peak 80.3 MB) (limit 512 MB), opcache ...
    magenx-app-server: worker 1/4d07e6c5 stopped by FrankenPHP (idle 62 s) after 12/541 requests, 140/1930 s, memory 52.0 MB (peak 70.9 MB) (limit 512 MB), opcache ...
    

    worker <pid>/<id>: all worker threads share the FrankenPHP pid (1 in the container), the random id pairs a boot with its exit.

    Reasons: error, open-transaction, reset-failed, max-requests, max-memory, max-age, env-changed, config-changed. stopped by FrankenPHP means FrankenPHP stopped the thread between requests; it does not tell PHP why:

    • idle about GRAPHQL_MAX_IDLE_TIME or more: autoscale downscale. With GRAPHQL_MAX_THREADS above GRAPHQL_WORKERS, FrankenPHP adds a worker thread (a fresh Magento boot) when requests queue, and stops it again once idle that long (FrankenPHP default 5s; this Caddyfile 60s);
    • otherwise: shutdown, Caddy config reload, or admin API workers/restart.

    FrankenPHP logs which one at INFO in the container log (upscaling worker thread, downscaling thread, rebooting all PHP threads, workers restarted from admin api):

    docker logs frankenphp 2>&1 | grep -E 'upscaling|downscaling|rebooting|restarted|stopped'
    grep 'magenx-app-server: worker' var/log/system.log | grep -oE 'booted|recycling \([^)]*\)|stopped by FrankenPHP' | sort | uniq -c

The core factory scheme is unchanged for PHP-FPM: only the worker builds the custom ObjectManagerFactory. Enabling the module adds no DI, plugins or observers.

Install

composer require magenxcommerce/module-graph-ql-app-server   # or copy to app/code/Magenx/AppServer
bin/magento module:enable Magenx_AppServer
bin/magento setup:upgrade && bin/magento setup:di:compile

Run the worker container from frankenphp/ against the same code base and app/etc/env.php as PHP-FPM. Requirements:

  • ZTS PHP 8.3/8.4. The official dunglas/frankenphp images are ZTS.
  • Production mode only: compiled DI (setup:di:compile) is required, and the worker refuses to start without it. There is no developer-mode support.
  • MAGENTO_ROOT set.

The worker file path in the Caddyfile assumes a composer install. For an app/code install, set GRAPHQL_WORKER_FILE=app/code/Magenx/AppServer/bin/graphql-worker.php.

magenx docker stack

For magenx/Magento-2-docker-configuration (/opt/BRAND/docker, release symlinks, hardened images, UID 65532):

cd /opt/BRAND/docker
cp -r /opt/BRAND/magento/public/current/vendor/magenxcommerce/module-graph-ql-app-server/frankenphp ./frankenphp
bash frankenphp/install.sh          # nginx templates + includes, .env block (idempotent)
vim .env                            # pin FRANKENPHP_RUNTIME_IMAGE (dhi.io/debian:13) by digest
docker compose -f docker-compose.yml -f frankenphp/docker-compose.frankenphp.yml up -d --build frankenphp
docker compose up -d --build nginx

What the image does:

  • Build: in dunglas/frankenphp (ZTS PHP 8.4), with the same extensions as the php service, plus phpredis built with igbinary + lz4 through PIE, as your php image does. The stack's dhi.io/php images are NTS, and FrankenPHP embeds PHP and needs ZTS.
  • Runtime: the binary, extensions and their shared libraries are copied onto dhi.io/debian:13: no shell, no package manager.
  • Container: runs as DHI_STACK_UID, root filesystem read-only, cap_drop: ALL, no-new-privileges, tmpfs for Caddy's /config, /data and /tmp. The image strips FrankenPHP's cap_net_bind_service file capability: with cap_drop: ALL, exec would fail with "operation not permitted". The worker listens on unprivileged 8081.
  • Ports: 8081 (worker, for nginx) and 2020 (read-only /metrics, for other containers) are exposed to the docker networks only, with no host port. The Caddy admin API, which can rewrite the whole config and restart workers, binds to 127.0.0.1:2019 inside the container and is never exposed.
  • Mounts: the same code mounts as the php service, read-only. shared/var is writable for logs and tmp.
  • Environment: the same env as php, because app/etc/env.php reads its credentials with getenv(). Verified: the worker sees them at boot and per request.
  • Healthcheck: a real storeConfig query through the worker (busybox wget).

nginx (nginx/conf_m2/graphql_worker*.conf.template, included by install.sh):

  • Routing: location = /graphql proxies a canary share (default 10%) of requests to frankenphp:8081. The same config serves both worker modes.
  • Fallback: the rest of the canary split, anything the worker refuses (503 out of scope or maintenance), and requests it never received (502/504 with nothing sent: worker down or unreachable) are internally rewritten to /index.php and served by php-fpm. That is the same path location / { try_files … /index.php } takes today: REQUEST_URI stays /graphql and the buffered body is replayed. A POST that reached the worker and then failed (502, 504, or a 503 the worker did not mark) is not replayed: it may be a mutation the worker already ran, so the client gets the error. GETs are always replayable: Magento runs no mutation over GET.
  • Startup: the upstream uses resolve (nginx ≥ 1.27.3; the stack runs 1.31.5), so nginx starts even when the frankenphp container is stopped or missing. A static upstream would stop nginx from starting and take the whole site down.
  • Client IP: X-Real-IP carries the client IP after real_ip. The worker adopts it as REMOTE_ADDR (MAGENX_WORKER_TRUST_X_REAL_IP=1), so the maintenance allowlist and logs match php-fpm.

Worker modes

MAGENX_WORKER_MODE (worker container env) picks what the worker serves:

Mode Served by the worker Replayed on PHP-FPM
default every /graphql request: guest and customer-token queries, cart, checkout, every mutation bodies over MAGENX_WORKER_MAX_BODY_KB, maintenance, worker unreachable
restricted anonymous, read-only, allowlisted catalog/CMS queries everything else, plus the above

Unset means restricted, so upgrading the module never widens what an existing install sends to the worker. The .env block from install.sh sets restricted too. Any other value stops the worker from booting.

Switching needs no nginx change: set the variable in .env and recreate the worker container (up -d; restart keeps the old environment). Each worker logs its mode at boot (booted in restricted mode in N ms).

docker compose -f docker-compose.yml -f frankenphp/docker-compose.frankenphp.yml up -d frankenphp

An existing install keeps restricted until MAGENX_WORKER_MODE is added to its .env: install.sh does not append the block again. Re-run install.sh and rebuild nginx anyway, for the replay rules below.

default mode runs customer data, carts and orders inside long-lived workers. Before you use it, the verification gate below must pass with every module you run, and the open items in STATE_AUDIT.md must not apply to your store. Do not use it with Stripe (stripe/module-payments) installed: Stripe keeps per-customer state on shared objects that etc/reset.json cannot reset (STATE_AUDIT.md #5).

Rollout: canary via nginx

nginx cannot see the operation inside a POST body, so the worker enforces its mode itself (Worker/Scope.php). An out-of-scope request (in restricted mode: an Authorization header, a mutation, or a root field outside the allowlist) gets 503 + X-Magenx-Worker: out-of-scope before Magento runs, and nginx replays it on PHP-FPM. Customer-token requests reach the worker in both modes; in restricted mode that costs one refusal round trip, well under 1 ms.

nginx replays a worker error only when the worker cannot have run the request ($graphql_worker_replay in graphql_worker_maps.conf): nothing was sent to it, it marked the refusal with X-Magenx-Worker, or the method is GET. Any other replay could run a mutation twice, for example place an order twice.

Two tested configurations, depending on which nginx proxies /graphql:

  • Storefront nginx (deploy/nginx/vhost.conf in magenxcommerce/magenxcommerce, the nginx:8080 server the Next.js container calls). /graphql goes to the worker first (upstream graphql_worker in deploy/nginx/maps.conf). Customer-token requests skip it. Refusals (503) and outages (502/504) are replayed on Magento via error_page … = @graphql_fpm. That config replays every 502/504 and skips the worker for customer tokens, so it is for restricted mode only: port $graphql_worker_replay from the magenx templates before using default mode behind it.
  • Magento nginx on the magenx stack: frankenphp/nginx/conf_m2/*.template, installed by install.sh (see "magenx docker stack").

Do not use an upstream with a backup server and proxy_next_upstream http_503 for this. That replay path was never verified, and in a real deployment the worker's 503 reached the storefront.

nginx 1.31.5+ can also read operationName from the body (client_body_early_read + json_set) to skip the worker for known non-read operations. That only saves the refusal round trip: operationName is client-chosen, so the worker's own check stays the boundary.

Default allowlist for restricted mode (Scope::DEFAULT_ROOT_FIELDS):

  • core: products, categories, categoryList, storeConfig, availableStores, cmsPage, cmsBlocks, route, urlResolver, countries, country, currency, customAttributeMetadataV2, customAttributeMetadata, productReviewRatingsMetadata;
  • Magenx: bestSellers, blog*, deals, garanNotice, gdprCookieGroups, quickSearchSuggestions, storePickupLocations.

Override it with MAGENX_WORKER_ALLOWED_FIELDS (comma separated). Add a field only after checking that its resolvers keep no per-customer state.

Bodies over MAGENX_WORKER_MAX_BODY_KB (default 512) are refused before they are decoded or parsed, and replayed on PHP-FPM. Caddy rejects anything over 2 MB outright.

Add $upstream_addr and $graphql_backend to the graphql_timing log format to compare latency between backends and see how often requests are replayed.

Deploys and config changes

Workers hold system config, store config and DI config in memory.

  • Deploy (setup:upgrade, setup:di:compile, static deploy, new code): restart the workers after the switch. On the magenx stack, the release switch moves public/current; restart the container so the worker boots from the new release. nginx serves /graphql from php-fpm meanwhile.

    docker compose -f docker-compose.yml -f frankenphp/docker-compose.frankenphp.yml restart frankenphp
    # or, same release, workers only (admin API on loopback inside the container):
    docker compose exec frankenphp /bin/busybox wget -q -O - --post-data '' http://127.0.0.1:2019/frankenphp/workers/restart
  • Load: Prometheus metrics are served read-only on http://frankenphp:${FRANKENPHP_METRICS_PORT:-2020}/metrics to the other containers (point the Magento platform module there), and on the loopback admin API. Every other path on the metrics port returns 404. A frankenphp_worker_queue_depth above 0 means requests are waiting for a free worker; raise GRAPHQL_WORKERS (and GRAPHQL_MAX_THREADS) if it stays there. Frequent upscaling worker thread / downscaling thread lines mean bursts regularly exceed GRAPHQL_WORKERS: raise it, or raise GRAPHQL_MAX_IDLE_TIME to keep added threads booted longer. Every thread up to GRAPHQL_MAX_THREADS may hold a booted Magento, so size FRANKENPHP_MEM_LIMIT for that.

    docker compose exec frankenphp /bin/busybox wget -qO- http://127.0.0.1:2020/metrics \
      | grep -E 'frankenphp_(worker_queue_depth|busy_workers|total_workers)'
    # from the php container, as the platform module does:
    docker compose exec php php -r 'echo file_get_contents("http://frankenphp:2020/metrics");' | grep frankenphp_
  • Admin config save, cache:clean config or cache:flush: each worker recycles after its next request (checked at most once a second). With the config cache type disabled there is nothing to watch: workers then see the change after at most MAGENX_WORKER_MAX_AGE seconds, or right away after the restart call above. Other admin edits held in memory (anything not in the config cache) are bounded by MAGENX_WORKER_MAX_AGE.

  • env.php change: each worker recycles after its next request.

Checking worker mode and opcache

Worker mode: frankenphp_total_workers counts booted worker threads, and every boot logs a booted in N ms line with its own id.

docker compose exec frankenphp /bin/busybox wget -qO- http://127.0.0.1:2020/metrics \
  | grep -E 'frankenphp_(total_workers|busy_workers|ready_workers|worker_restarts|opcache_restarts)'
grep 'magenx-app-server: worker' var/log/system.log | tail

Two booted lines minutes apart for the same thread, with no recycle in between, mean FrankenPHP is not keeping the worker (check the container log).

Opcache: all worker threads share one opcache. Every boot and exit line ends with its fill (opcache N scripts (max), used/total MB, interned, hit rate, restarts). It should rise over the first boots and then stay flat, with the hit rate near 100% and no restarts. FULL at the end of the line, or a rising frankenphp_opcache_restarts (FrankenPHP 1.13+, PHP 8.4+), means opcache.memory_consumption, opcache.interned_strings_buffer or opcache.max_accelerated_files in zz-worker.ini is too small, and every boot then recompiles. opcache off means the extension is not loaded: check FRANKENPHP_EXTENSION contains opcache.

At boot each worker runs MAGENX_WORKER_WARMUP_QUERY (default {storeConfig{store_code}}, off to skip) once through the front controller, so the first real request does not pay for autoloading, config and schema loading. The response is discarded and state is reset after it.

GraphQL sessions: core default graphql/session/disable=0 lets GraphQL start PHP sessions. The worker drops cookies and aborts any session a request opens, but turn sessions off anyway (the storefront uses tokens):

bin/magento config:set graphql/session/disable 1

A worker logs a warning at boot while it is on.

Verification gate (before any real traffic)

  1. State test with your modules enabled. Run core dev/tests/integration/testsuite/Magento/GraphQl/App/GraphQlStateTest.php (plus GraphQlCheckoutMutationsStateTest and GraphQlCustomerMutationsTest) and this module's companion state tests (below) with every Magenx module and StripeIntegration enabled. Every object that changes state across requests is a potential cross-customer leak. Fix it with _resetState() in the owning module, or with an etc/reset.json entry for code you cannot change.
  2. Shadow comparison. Replay real queries (guest, customer token, cart, checkout) against both backends in interleaved order and diff the responses.
  3. Load test. Compare p50/p95 with FPM, and watch memory per worker and recycle frequency.

Companion state tests

Test/Integration runs every GraphQL operation of the Magenx companion modules the way core GraphQlStateTest runs core ones: each operation twice in one application, with the core Comparator failing on any shared object that changes state across the two requests or drifts from its constructed state. It covers what the worker refuses today, so a green run is the precondition for serving customer tokens and mutations:

Test Operations
CompanionCatalogStateTest public reads: best sellers, blog, deals, GARAN, GDPR cookie groups, quick search, pickup locations, help desk and RMA metadata, every ProductInterface extension
CompanionCustomerStateTest private reads (reward points, product alerts, Stripe subscriptions, GDPR requests and export, help desk tickets, returns, order fields) and every non-cart mutation
CompanionCartStateTest cart fields (GARAN, Stripe, reward, pickup) and cart mutations (reward points, pickup location)
CompanionSchemaCoverageTest fails when a companion resolver has no case, a case no longer matches the schema, or a core state skip/filter list names a Magenx\ class

Private cases run customer A, then customer B or a guest. A's data is seeded with the marker STATE-LEAK-A, and the second response must not contain it. Each pair also runs entirely under the core Resetter, with every module's reset.json applied, as in the worker.

The cases live in Test/Integration/Cases/CompanionCases.php; add one there for every new query, mutation or field (the coverage test names what is missing). Never add a companion class to a core state skip list to get green: that hides the leak from the test, not from the worker.

Run from the Magento root, against an integration test database (dev/tests/integration/etc/install-config-mysql.php) with all companion modules installed. Add a suite to dev/tests/integration/phpunit.xml:

<testsuite name="Magenx companion state">
    <directory>../../../vendor/magenxcommerce/module-graph-ql-app-server/Test/Integration</directory>
</testsuite>
cd dev/tests/integration
../../../vendor/bin/phpunit -c phpunit.xml --testsuite "Magenx companion state"

Copy Test/Integration/_files/state-filter-list-magenx.php and state-skip-list-magenx.php into dev/tests/integration/framework/Magento/TestFramework/ApplicationStateComparator/_files/: they cover what carries no request state, such as AbstractDb::$_uniqueFields, the parsed-query cache and the total codes that RestoreTotalCodes puts back (each entry is explained in the file). On Mage-OS, also copy state-filter-list-mageos.php there: it filters the plugin references Mage-OS's compiled interceptors cache and its Ignition error handler, which the core lists do not know about.

The settings the cases need are in CompanionCases::CONFIG, written to core_config_data by _files/companion_config.php. Do not use @magentoConfigFixture in these tests: it sets values on the test framework's own config object, and the requests run in a fresh object manager that never sees them.

Cases of a disabled module are skipped. For the gate, no case of a module you run may be skipped. phpunit.xml.dist in this module only runs Test/Unit, which checks the case catalogue without Magento.

CI runs all of this in .github/workflows/graphql-state.yml, started by hand (Actions → GraphQL state → Run workflow): a fresh Mage-OS install on PHP 8.4 ZTS (the FrankenPHP build), MariaDB and OpenSearch services, then the companion and core state suites. The comparator keeps every test's object graph until PHP exits (about 200 MB a test), so each suite runs in several PHPUnit processes capped at 4G: the companion cases split with MAGENX_STATE_SLICE=k/n (see CompanionCases::provide()), the core suite one test class per process. Everything is installed with composer, as a store does: this module from the branch it runs on, every magenxcommerce/module-* package (from Packagist once published, from its GitHub repository until then), and Stripe (stripe/stripe-payments) from the Magento Marketplace. It uses the organisation secrets MAGENTO_PUBLIC_KEY/MAGENTO_PRIVATE_KEY for repo.magento.com and MAGENX_MODULES_TOKEN for private module repositories; without them Stripe or those modules are not installed and their cases are skipped. A manual run takes a Mage-OS version and a branch name to install from every module repository that has it, e.g. a fix branch before it is merged.

reset.json format: {"Vendor\\Class": {"property": <reset value>}}. Do not ship an empty {}: the core Resetter rejects it. This module's own etc/reset.json clears the core TransportBuilder, so recipients from a failed send don't carry into the next email.

It also resets per-request state in the Stripe Payments helpers and models, in core order loading (ShippingAssignmentBuilder, the SalesGraphQl order item cache), and the per-request lookups of the best-seller and product-link modules. Run php vendor/magenxcommerce/module-graph-ql-app-server/bin/check-reset-json.php from the Magento root after installing or upgrading Stripe; it checks every entry against the installed classes. The GraphQL state workflow runs it too.

STATE_AUDIT.md lists the state findings in our own modules, ranked. The "must fix" items block worker traffic.

Known gaps

  • Not tested end to end with Magento. default mode in particular has only the state tests behind it. What was tested:

    • The factory and resetter wiring, smoke-tested against the 2.4.8 framework sources.
    • The worker image, built and run with the compose hardening (read-only root, cap_drop: ALL, UID 65532) on a stand-in debian:trixie-slim runtime, since dhi.io needs a login. The build used a stub worker, plus sandbox-only substitutions for the CA and PIE/PECL downloads.
    • The routing, behind nginx 1.31.6 and php-fpm 8.4:
      • anonymous POST and GET go to the worker;
      • mutations and cart queries are refused and replayed on php-fpm with the body intact;
      • token requests go straight to php-fpm;
      • with the worker stopped, requests fall back (+0.3 s while Docker DNS is stale) and come back once it is up;
      • nginx starts with no worker container at all.
    • The replay rules ($graphql_worker_replay), on nginx 1.24 against stub upstreams: refusals, connection failures and GETs replay on php-fpm; a POST the worker received and then failed on (timeout, dropped connection, unmarked 503) returns the error.
    • The stack's own nginx templates with the includes pass nginx -t, and the compose override merges cleanly with the stack's docker-compose.yml.

    A run against a real Magento store is still to do.

  • Plugins on Magento\Framework\App\Http do not run. In core that is only NewRelicReporting, and the New Relic agent does not support ZTS anyway.

  • Plain static properties and register_shutdown_function() callbacks registered per request are not reset. Recycling bounds them.

  • exit() / die() in any module ends the worker thread mid-request.

  • $_FILES is not mapped. Core GraphQL has no multipart uploads.

  • MAGE_PROFILER=2 is not supported: it wraps the factory in a decorator, and the worker refuses to start.

  • One request per thread. Never enable coroutine-style concurrency: Magento's singletons are not safe for it.