magenxcommerce / module-graph-ql-app-server
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
Requires
- php: ~8.3.0||~8.4.0||~8.5.0
- magento/framework: >=103.0.8 <104
- magento/module-graph-ql: *
- magento/module-quote: *
- webonyx/graphql-php: ^15.0
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
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:
-
Hydrate. The shared
Request\Httpis 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. -
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
restrictedmode the query is parsed with the coreQueryParser, which Magento then reuses, so each query is parsed once. -
Maintenance check. Answers 503 while
var/.maintenance.flagexists; nginx replays it on PHP-FPM, which answers for maintenance. -
Dispatch through the
graphqlfront controller, so its plugins (store header, cache tags, ...) run as usual. Then firecontroller_front_send_response_beforeand send. -
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 coreQueryParsercache is emptied above 1000 parsed queries. This runs oncefrankenphp_handle_request()has returned. FrankenPHP holds the whole response until the handler returns, so cleanup inside the handler would add the reset and itsgc_collect_cycles()calls to every request's time to first byte. -
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_AGEseconds (default 1800), app/etc/env.phpchanged,- the config cache was rebuilt or flushed (admin config save,
cache:clean config,cache:flush): checked at most once a second by reading core'ssystem_scopesconfig 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 byfrankenphp_worker_restartscan 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 FrankenPHPmeans FrankenPHP stopped the thread between requests; it does not tell PHP why:- idle about
GRAPHQL_MAX_IDLE_TIMEor more: autoscale downscale. WithGRAPHQL_MAX_THREADSaboveGRAPHQL_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/frankenphpimages 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_ROOTset.
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'sdhi.io/phpimages 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,/dataand/tmp. The image strips FrankenPHP'scap_net_bind_servicefile capability: withcap_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) areexposed to the docker networks only, with no host port. The Caddy admin API, which can rewrite the whole config and restart workers, binds to127.0.0.1:2019inside the container and is never exposed. - Mounts: the same code mounts as the php service, read-only.
shared/varis writable for logs and tmp. - Environment: the same env as php, because
app/etc/env.phpreads its credentials withgetenv(). Verified: the worker sees them at boot and per request. - Healthcheck: a real
storeConfigquery through the worker (busyboxwget).
nginx (nginx/conf_m2/graphql_worker*.conf.template, included by install.sh):
- Routing:
location = /graphqlproxies a canary share (default 10%) of requests tofrankenphp: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.phpand served by php-fpm. That is the same pathlocation / { try_files … /index.php }takes today:REQUEST_URIstays/graphqland 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-IPcarries the client IP afterreal_ip. The worker adopts it asREMOTE_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.confin magenxcommerce/magenxcommerce, thenginx:8080server the Next.js container calls)./graphqlgoes to the worker first (upstream graphql_workerindeploy/nginx/maps.conf). Customer-token requests skip it. Refusals (503) and outages (502/504) are replayed on Magento viaerror_page … = @graphql_fpm. That config replays every 502/504 and skips the worker for customer tokens, so it is forrestrictedmode only: port$graphql_worker_replayfrom the magenx templates before usingdefaultmode behind it. - Magento nginx on the magenx stack:
frankenphp/nginx/conf_m2/*.template, installed byinstall.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 movespublic/current; restart the container so the worker boots from the new release. nginx serves/graphqlfrom 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}/metricsto the other containers (point the Magento platform module there), and on the loopback admin API. Every other path on the metrics port returns 404. Afrankenphp_worker_queue_depthabove 0 means requests are waiting for a free worker; raiseGRAPHQL_WORKERS(andGRAPHQL_MAX_THREADS) if it stays there. Frequentupscaling worker thread/downscaling threadlines mean bursts regularly exceedGRAPHQL_WORKERS: raise it, or raiseGRAPHQL_MAX_IDLE_TIMEto keep added threads booted longer. Every thread up toGRAPHQL_MAX_THREADSmay hold a booted Magento, so sizeFRANKENPHP_MEM_LIMITfor 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 configorcache:flush: each worker recycles after its next request (checked at most once a second). With theconfigcache type disabled there is nothing to watch: workers then see the change after at mostMAGENX_WORKER_MAX_AGEseconds, or right away after the restart call above. Other admin edits held in memory (anything not in the config cache) are bounded byMAGENX_WORKER_MAX_AGE. -
env.phpchange: 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)
- State test with your modules enabled. Run core
dev/tests/integration/testsuite/Magento/GraphQl/App/GraphQlStateTest.php(plusGraphQlCheckoutMutationsStateTestandGraphQlCustomerMutationsTest) 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 anetc/reset.jsonentry for code you cannot change. - Shadow comparison. Replay real queries (guest, customer token, cart, checkout) against both backends in interleaved order and diff the responses.
- 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.
defaultmode 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-indebian:trixie-slimruntime, sincedhi.ioneeds 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'sdocker-compose.yml.
A run against a real Magento store is still to do.
-
Plugins on
Magento\Framework\App\Httpdo not run. In core that is onlyNewRelicReporting, 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. -
$_FILESis not mapped. Core GraphQL has no multipart uploads. -
MAGE_PROFILER=2is 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.