---
title: "Running, benchmarking, making Magento 2 survive with FrankenPHP"
author: "Uladzislau Marudzenka"
date: 2026-05-20T12:00:00.000Z
updated: 2026-08-04T20:47:56.000Z
canonical: https://inpvlsa.dev/posts/running-benchmarking-making-magento-2-survive-with-frankenphp
---

# Running, benchmarking, making Magento 2 survive with FrankenPHP

Project GitHub (PHP 8.4, Validated on 2.4.8): [https://github.com/INPVLSA/magento-frankenphp-dev](https://github.com/INPVLSA/magento-frankenphp-dev)

Here we will look at the setup and usage of [FrankenPHP](https://frankenphp.dev/), focusing on development, not production.

## Preface

I had encountered Franken before. Once I discussed with the DevOps of our team the just-emerged possible replacement for PHP-FPM, however at that point it was not given due attention, since it was too fresh in perception to compete with PHP-FPM.

About a year after this, I was going to deploy Shopware on my server with [Dokploy](https://dokploy.com/). And what do I see in the documentation - FrankenPHP is specified in the **default** **Docker image of Shopware** in the documentation. This rather surprised me, and I nevertheless decided to try it. And I used the image with FrankenPHP in the project's Dockerfile. And after a couple of nudges and reconfigurations (I needed to read the docs more carefully and set the permissions in the Dockerfile), it started up and worked great, which I did not expect. I thought I would try it, grumble that it's slow or problematic, and go back to the previous FPM.

And on this matter an investigation awaited me - how did it turn out that PHP-FPM can simply be replaced with something on such a large project (product)?

And here I would highlight a couple of reasons that seem obvious to me:

*   Shopware 6 is built on Symfony. Kévin Dunglas is from the Symfony core team and the author of FrankenPHP. For the Symfony/Shopware ecosystem, FrankenPHP looks like an obvious choice. The trust is higher than in RoadRunner (Spiral ecosystem) or Swoole (an external C extension from a different development culture)
    
*   Worker mode works without the need to change the application code (hello Swoole and RoadRunner, with the need to adapt PHP code just to even start up with them)
    

And on this matter, I decided to check how it would handle Magento. After all, I use Shopware more for third-party/pet projects, while Magento is my main specialization.

## Setup

I do not install anything on the host; I have a Docker Compose that contains nginx, MySQL 8, OpenSearch, PHP-FPM of several versions, and utilities like Mailhog. In general, it's a fairly default setup; the only difference is slightly custom nginx configs, which allowed projects to be accessed by directory name.

FrankenPHP has two modes: ***classic***, where each request boots and discards the app like FPM does, and ***worker***, where the app is booted once and reused across requests - fast, but hostile to Magento's stateful design. We will check both.

### 1\. Dockerfile

Magento requires PHP extensions, so accordingly the standard Franken image won't suit us. I took as a basis the Dockerfile for PHP-FPM that I had, cut out a couple of specific things, and got the following Dockerfile

```dockerfile
FROM dunglas/frankenphp:php8.4  
  
ENV PHP_VERSION=8.4 \  
    USER=magento  
  
# System packages  
RUN apt-get update && apt-get install -y --no-install-recommends \  
    git \  
    tzdata \  
    patch \  
    unzip \  
    jq \  
    jpegoptim \  
    optipng \  
    pngquant \  
    make \  
    findutils \  
    which && \  
    rm -rf /var/lib/apt/lists/*  
  
# PHP extensions
RUN install-php-extensions \  
    bcmath \  
    gd \  
    intl \  
    mbstring \  
    pdo_mysql \  
    soap \  
    sockets \  
    sodium \  
    xsl \  
    zip \  
    opcache \  
    xdebug  
  
# Installing composer  
RUN curl -sS https://getcomposer.org/download/2.8.4/composer.phar --output /usr/local/bin/composer && \  
    chmod +x /usr/local/bin/composer && \  
    apt-get clean && \  
    rm -rf /var/lib/apt/lists/*  
  
# Configuring permissions  
RUN useradd -s /bin/bash -m $USER && \  
    mkdir -p /home/$USER/.composer && \  
    chown -R $USER:$USER /home/$USER && \  
    mkdir -p /var/www/vhosts/$PHP_VERSION && \  
    chown -R $USER:$USER /var/www/vhosts  
  
EXPOSE 8080  
  
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s \  
    CMD curl -fsS http://127.0.0.1:8080/health_check.php || exit 1  
  
CMD ["frankenphp", "run", "--config", "/etc/caddy/Caddyfile"]
```

In general, it doesn't differ much from the base PHP images; it only uses FrankenPHP specifics in the form of an entrypoint and `install-php-extensions` built into the image.

### 2\. Caddyfile

Next followed the configuration of Caddy, which in FrankenPHP takes on the role of nginx. I have a fairly specific nginx config that allows me to work with several projects simultaneously, and rewriting it using the syntax of a language I had not encountered before would have taken a considerable amount of time to write by hand (and besides, it's a declarative language, so there's not much point in going deeper than getting acquainted with it). So most of this file generated with Claude Code.

A feature of the local config is that any directory under `$compose_root/data/8.4/` can be opened as a separate URL. For example, `$compose_root/data/8.4/248p3/` is `248p3.docker.loc`. And if it's a multistore setup, for example with a website with the code `b2b` - `b2b--248p3.docker.loc`

You can find the full Caddyfile, without the specifics of my setup, in the [repository](https://github.com/INPVLSA/magento-frankenphp-dev/tree/main/docker/docker-files/caddy).

```Caddyfile
{  
    frankenphp  
    order php_server before file_server  
    admin off  
    auto_https off  
    servers {  
       protocols h1 h2 h2c h3  
    }  
}  
  
# Multi-store: <store>--<magento>.docker.loc OR <magento>.docker.loc  
*.docker.loc {  
    tls /etc/caddy/certs.d/docker.loc/ssl_certificate.cert /etc/caddy/certs.d/docker.loc/id_rsa.key  
  
    # ─── Xdebug routing ───  
    # Route page loads carrying XDEBUG_SESSION cookie to the Xdebug-enabled container.    # XHRs (X-Requested-With: XMLHttpRequest) stay on the fast instance to avoid the    # Magento AJAX storm (customerData, sections, mini-cart) hitting breakpoints.    @xdebug {  
       header Cookie *XDEBUG_SESSION=*  
       not header X-Requested-With XMLHttpRequest  
    }  
    reverse_proxy @xdebug frankenphp84-xdebug:80 {  
       header_up Host {host}  
       header_up X-Forwarded-For {remote_host}  
       header_up X-Forwarded-Proto {scheme}  
    }  
  
    @multistore header_regexp host Host ^(?P<store>[a-z0-9\-]+)--(?P<magento>[a-z0-9\-]+)\.docker\.loc$  
    @singlestore header_regexp host Host ^(?P<magento>[a-z0-9\-]+)\.docker\.loc$  
  
    vars @multistore MAGE_RUN_TYPE website  
    vars @multistore MAGE_RUN_CODE {http.regexp.host.store}  
    vars @multistore MAGENTO {http.regexp.host.magento}  
    vars @singlestore MAGE_RUN_TYPE website  
    vars @singlestore MAGE_RUN_CODE ""  
    vars @singlestore MAGENTO {http.regexp.host.magento}  
  
    # Resolve docroot per-host  
    root * /var/www/vhosts/8.4/{vars.MAGENTO}/pub  
    encode zstd gzip  
  
    # ─── Deny rules (order matters: most specific first) ───  
    @denied_paths path /.user.ini /media/customer/* /media/downloadable/* /media/import/* /media/custom_options/*  
    @denied_media_theme path_regexp denied_media_theme ^/media/theme_customization/.*\.xml$  
    @denied_pub_media path_regexp denied_pub_media ^/pub/media/(downloadable|customer|import|custom_options)(/.*)?$  
    @denied_pub_media_theme path_regexp denied_pub_media_theme ^/pub/media/theme_customization/.*\.xml$  
    @denied_errors_xml path_regexp denied_errors_xml ^/errors/.*\.xml$  
    @denied_setup {  
       path /setup/*  
       not path /setup/pub/*  
    }  
    @denied_update {  
       path /update/*  
       not path /update/pub/*  
    }  
    respond @denied_paths 403  
    respond @denied_media_theme 403  
    respond @denied_pub_media 403  
    respond @denied_pub_media_theme 403  
    respond @denied_errors_xml 403  
    respond @denied_setup 403  
    respond @denied_update 403  
  
    # ─── Static assets with versioning rewrite ───  
    @static_versioned path_regexp static ^/static/version\d+/(.*)$  
    rewrite @static_versioned /static/{http.regexp.static.1}  
  
    @static_missing {  
       path /static/*  
       not file  
    }  
    rewrite @static_missing /static.php?resource={path}  
  
    @media_missing {  
       path /media/*  
       not file  
    }  
    rewrite @media_missing /get.php?{query}  
  
    # Cache headers for static assets  
    @cacheable path_regexp \.(ico|jpg|jpeg|png|gif|svg|js|css|swf|eot|ttf|otf|woff|woff2|html|json)$  
    header @cacheable {  
       Cache-Control "public, max-age=31536000"  
       X-Frame-Options "SAMEORIGIN"  
    }  
  
    @nocache path_regexp \.(zip|gz|gzip|bz2|csv|xml)$  
    header @nocache {  
       Cache-Control "no-store"  
       X-Frame-Options "SAMEORIGIN"  
    }  
  
    # ─── PHP entry points (whitelist) ───  
    @php_entrypoints path_regexp ^/(index|get|static|errors/report|errors/404|errors/503|health_check)\.php$  
    @setup_php path /setup/index.php  
    @update_php path /update/index.php  
    @mftf_php path /dev/tests/acceptance/utils/command.php  
  
    php_server @php_entrypoints {  
       env MAGE_RUN_TYPE {vars.MAGE_RUN_TYPE}  
       env MAGE_RUN_CODE {vars.MAGE_RUN_CODE}  
    }  
    php_server @setup_php {  
       root /var/www/vhosts/8.4/{vars.MAGENTO}  
    }  
    php_server @update_php {  
       root /var/www/vhosts/8.4/{vars.MAGENTO}  
    }  
    php_server @mftf_php {  
       root /var/www/vhosts/8.4/{vars.MAGENTO}  
    }  
  
    # ─── Block any other PHP / sensitive files ───  
    @banned {  
       path_regexp banned \.(php|phtml|htaccess)$|\.git  
       not path /index.php /get.php /static.php /errors/report.php /errors/404.php /errors/503.php /health_check.php /setup/index.php /update/index.php /dev/tests/acceptance/utils/command.php  
    }  
    respond @banned 403  
  
    # ─── Default handler ───  
    @root path /  
    rewrite @root /index.php?{query}  
    try_files {path} /index.php?{query}  
    php_server {  
       env MAGE_RUN_TYPE {vars.MAGE_RUN_TYPE}  
       env MAGE_RUN_CODE {vars.MAGE_RUN_CODE}  
    }  
  
    # 404 fallback to Magento error page  
    handle_errors {  
       @404 expression {err.status_code} == 404  
       rewrite @404 /errors/404.php  
       php_server {  
          root /var/www/vhosts/8.4/{vars.MAGENTO}/pub  
          env MAGE_RUN_TYPE {vars.MAGE_RUN_TYPE}  
          env MAGE_RUN_CODE {vars.MAGE_RUN_CODE}  
       }  
    }  
}
```

### 3\. Docker-compose

Accordingly, FrankenPHP should become a replacement for FPM.

#### Before

```yaml
nginx:  
  container_name: nginx  
  hostname: nginx  
  image: nginx  
  restart: unless-stopped  
  ports:  
    - "80:80/tcp"  
    - "443:443/tcp"  
  networks:  
    default:  
      ipv4_address: 172.20.0.101  
  volumes:  
    - ./docker-files/nginx/nginx.conf:/etc/nginx/nginx.conf:ro  
    - ./docker-files/nginx/conf.d:/etc/nginx/conf.d  
    - ./docker-files/nginx/cert/certs.d:/etc/nginx/certs.d  
    - ./docker-files/nginx/html:/etc/nginx/html:ro  
    - ./data:/var/www/vhosts:delegated
      
  
php84:  
  container_name: php84  
  hostname: php84  
  working_dir: /var/www/vhosts/8.4  
  image: local/php-fpm:8.4  
  build:  
    context: ./Dockerfiles  
    dockerfile: r8.php-fpm.8.4.Dockerfile  
  restart: unless-stopped  
  networks:  
    - default  
  volumes:  
    - ./data/8.4:/var/www/vhosts/8.4:delegated  
    - composer-cache:/home/magento/.composer  
    - ./docker-files/php-fpm/php-fpm.conf:/etc/php-fpm.conf  
    - ./docker-files/php-fpm/zzz-custom.ini:/etc/php.d/zzz-custom.ini:ro  
  dns:  
    - 172.20.0.100  
  environment:  
    - COMPOSER_MEMORY_LIMIT=-1  
    - XDEBUG_CONFIG  
    - XDEBUG_MODE  
    - PHP_IDE_CONFIG  
    - INSTALL_DOMAIN  
  extra_hosts:  
    - ${host:-host}:host-gateway
```

#### After

```yml
frankenphp84:  
  container_name: frankenphp84  
  hostname: frankenphp84  
  working_dir: /var/www/vhosts/8.4  
  image: local/frankenphp:8.4  
  build:  
    context: ./Dockerfiles  
    dockerfile: frankenPhp.Dockerfile  
  volumes:  
    - ./data/8.4:/var/www/vhosts/8.4:delegated  
    - composer-cache:/home/magento/.composer  
    # mounting frankenphp configuration files, read-only  
    - ./docker-files/frankenphp/franken:/etc/frankenphp:ro  
    # mounting additional PHP configuration files, read-only  
    - ./docker-files/frankenphp/conf.d/magento.ini:/usr/local/etc/php/conf.d/zz-magento.ini:ro  
    - ./docker-files/frankenphp/conf.d/opcache.ini:/usr/local/etc/php/conf.d/zz-opcache.ini:ro  
    - ./docker-files/frankenphp/conf.d/xdebug.ini:/usr/local/etc/php/conf.d/zz-xdebug.ini:ro  
    - ./docker-files/frankenphp/Caddyfile:/etc/caddy/Caddyfile:ro  
    - ./docker-files/frankenphp/dev-reload.js:/etc/caddy/dev-reload.js:ro  
    - ./docker-files/nginx/cert/certs.d:/etc/caddy/certs.d:ro  
  dns:  
    - 172.20.0.100  
  ports:  
    - "80:80"  
    - "443:443"  
    - "443:443/udp"  
  environment:  
    - XDEBUG_MODE=off  
    - XDEBUG_CONFIG  
    - PHP_IDE_CONFIG  
  extra_hosts:  
    - ${host:-host.docker.internal}:host-gateway

```

The block in compose for this container, besides a different Dockerfile, should additionally have a mount of the Caddyfile to apply your web server configuration, and the ports that were previously under the nginx container.

### Launching with FrankenPHP Classic Mode, Benchmarks

FrankenPHP has two modes: classic (as a hot replacement for nginx+fpm) and worker (which is where the main speed gain lies).

We will look at worker mode further, however I consider it also necessary to do a benchmark in classic mode as well.

I disabled all caches, made sure Magento is in developer mode. Magento is clean, only sample products. There's nothing special in the benchmark, curl requests. And here is its result

![](https://cdn.hashnode.com/uploads/covers/69f0b3c210a70b3335b6f150/28e3d272-62bd-4215-84c9-9eb0cdd46c47.png align="center")

The first value in this table is probably the opcache warm-up, so we exclude it. Thus we get approximately the same response time, and even on average 20ms faster. I also checked the category page.

The results on it are approximately the same. Average values: FPM = 3.177s, Franken = 3.113s

### Advantages of FrankenPHP classic mode

Right now FrankenPHP is running in classic mode. This is still a cold start of PHP; in this mode I found only one advantage for development

### Xdebug

In a setup with FPM, enabling/disabling Xdebug can be implemented through one of three approaches:

1.  Changing the config (xdebug.ini) and restarting the container with FPM `sed -i 's/XDEBUG_MODE = off/XDEBUG_MODE = debug/' /etc/php/8.4/fpm/pool.d/www.conf`
    
2.  Changing the config `sed -i 's/XDEBUG_MODE = off/XDEBUG_MODE = debug/' /etc/php/8.4/fpm/pool.d/www.conf` and FPM pool reload `kill -USR2 $(pidof php-fpm)`
    
3.  Having two separate containers with FPM, in one of which Xdebug is enabled. Xdebug mode trigger, routing the request based on the cookie value via the nginx config
    

FrankenPHP, with the help of Caddy, gives us the possibility of a similar configuration

```plaintext
@xdebug header_regexp Cookie XDEBUG_SESSION

handle @xdebug {
  reverse_proxy localhost:8081
}

handle {
  reverse_proxy localhost:8080
}
```

Thus we will have one container that switches depending on the `XDEBUG_SESSION` cookie. It can be enabled using the Xdebug Helper browser extension. That is, without restarting containers.

The benefit is of course minor, but it's worth mentioning such a possibility. Unfortunately, Franken does not address the main problems and speed with Xdebug enabled in Magento. An average Magento store with a huge amount of AJAX will still process slowly. This is a limitation of the approach to using Xdebug, not of the web server.

## Worker mode

Simply enabling Magento in worker mode won't work; Magento is not designed for this out of the box. Singletons, stateful objects, the Registry beloved by everyone. Shared state is a huge problem for this kind of approach. Many vendors ignore the existence of the ViewModel approach, which was aimed at getting rid of stateful context in blocks. And many, many more stones can be thrown in this direction.

At this point, one should abandon the idea due to its untenability because of the implementation complexity, however we will nevertheless look at ways to implement worker mode in Magento:

1.  Blacklist for stateful services - cursed. I sincerely feel sorry for whoever dares to do this. Even with clean Magento with third-party modules, it's cursed x100.
    
    However, it's worth noting that Adobe seems to be striving to make this possible, as indicated by the presence of `Magento\Framework\ObjectManager\ResetAfterRequestInterface`. I assumed Magento core might work with it, but later we will face a problems with current implementations.
    
2.  Whitelist for stateless services - the opposite approach. Sounds better, however we lose a huge part of the performance boost that worker mode can give us.
    
3.  Fork per request (preforking) - FrankenPHP doesn't currently support this
    
4.  Selective worker mode - use the worker only in situations where state doesn't particularly matter. Although I think this is inapplicable to Magento without using a blacklist. At least because `current_category` and `current_product` exist in the Registry
    

We will consider whitelist option as the main one. We'll release into the worker what we can release.

\--- at this point, hours of Caddyfile configuration go by ---

### Implementing worker handler

Worker implementation requires different entrypoint. Magento was written for a share-nothing SAPI model (mod\_php / php-fpm): a fresh PHP process per request, everything thrown away at the end. FrankenPHP's worker mode flips that - one long-lived PHP process serves N requests in a loop. Magento has no native answer for this, so this file is the adapter that makes Magento survive worker mode without bleeding state across requests.

Here is the final version of worker implementation. After code snippet you can find problems I faced during setting up worker mode for Magento and solutions I found.

```php
<?php  

ignore_user_abort(true);  
  
// Resolve the Magento base path, repo install it as a symlink mount  
$mageRoot = __DIR__;  
  
while ($mageRoot !== '/' && !is_dir($mageRoot . '/app/etc')) {  
    $mageRoot = dirname($mageRoot);  
}  
  
if ($mageRoot === '/') {  
    fwrite(STDERR, "[frankenphp-worker] cannot locate Magento root from " . __DIR__ . "\n");  
  
    exit(1);  
}  
require $mageRoot . '/app/bootstrap.php';  
  
use Magento\Framework\App\Bootstrap;  
use Magento\Framework\App\ObjectManager as AppObjectManager;  
  
const MAX_REQUESTS = 500;  

$whitelist = [
	\Magento\Framework\ObjectManagerInterface::class,  
	\Magento\Framework\ObjectManager\ConfigInterface::class,
    ...APPROX_160_CLASSES  
];  
  
// Wildcard prefixes — anything starting with one of these survives  
$whitelistPrefixes = [  
    'Magento\\Framework\\App\\Cache\\Type\\',  
    // Interceptor/proxy classes for whitelisted services would be added here if generated under known prefixes  
];  
  
$whitelistMap = array_flip($whitelist);  
  
$bootstrap = Bootstrap::create(BP, $_SERVER);  
$bootstrap->createApplication(\Magento\Framework\App\Http::class); // prime DI graph  
$objectManager = AppObjectManager::getInstance();  
  
$resetSkipPrefixes = [  
    // BUGFIX: Magento\Framework\Session\Storage::_resetState() reassigns $_data = []  
    'Magento\\Framework\\Session\\Storage',  
    // BUGFIX: SessionManager already wipes per-request volatile state via session_write_close() at the end of request  
    'Magento\\Customer\\Model\\Session\\Storage',  
];  
  
$resetSharedState = \Closure::bind(  
    function (array $skipPrefixes): void {  
        foreach ($this->_sharedInstances as $instance) {  
            if (!$instance instanceof \Magento\Framework\ObjectManager\ResetAfterRequestInterface) {  
                continue;  
            }  
            $class = get_class($instance);  
  
            foreach ($skipPrefixes as $prefix) {  
                if (str_starts_with($class, $prefix)) {  
                    continue 2;  
                }  
            }  
  
            try {  
                $instance->_resetState();  
            } catch (\Throwable $e) {  
                error_log('[frankenphp-worker] _resetState failed on '  
                    . $class . ': ' . $e->getMessage());  
            }  
        }  
    },  
    $objectManager,  
    \Magento\Framework\ObjectManager\ObjectManager::class,  
);  
  
// Build a Closure bound to the ObjectManager class scope so it can read and mutate the protected $_sharedInstances  
$pruneShared = \Closure::bind(  
    function (array $whitelistMap, array $whitelistPrefixes): void {  
        foreach ($this->_sharedInstances as $key => $instance) {  
            if (isset($whitelistMap[$key])) {  
                continue;  
            }  
            $keep = false;  
  
            foreach ($whitelistPrefixes as $prefix) {  
                if (str_starts_with($key, $prefix)) {  
                    $keep = true;  
  
                    break;  
                }  
            }  
  
            if (!$keep) {  
                unset($this->_sharedInstances[$key]);  
            }  
        }  
    },  
    $objectManager,  
    \Magento\Framework\ObjectManager\ObjectManager::class,  
);  

// BUGFIX problem 3  
$factoryRef = (function () {  
    return $this->_factory;  
})->bindTo($objectManager, \Magento\Framework\ObjectManager\ObjectManager::class)();  
  
$resetCreationStack = \Closure::bind(  
    function () {  
        $this->creationStack = [];  
    },  
    $factoryRef,  
    \Magento\Framework\ObjectManager\Factory\AbstractFactory::class,  
);  
  
$pluginListRef = $objectManager->get(\Magento\Framework\Interception\PluginListInterface::class);  
$resetPluginInstances = \Closure::bind(  
    function () {  
        $this->_pluginInstances = [];  
    },  
    $pluginListRef,  
    \Magento\Framework\Interception\PluginList\PluginList::class,  
);  
  
$requestCount = 0;  
$workerPid = getmypid();  
  
// Detect developer mode once at boot. MAGE_MODE lives in app/etc/env.php, not in the container env  
$envConfig = @include $mageRoot . '/app/etc/env.php';  
$devMode = is_array($envConfig) && ($envConfig['MAGE_MODE'] ?? '') === 'developer';  
  
$dropKeys = \Closure::bind(  
    function (array $keys): void {  
        foreach ($keys as $k) {  
            unset($this->_sharedInstances[$k]);  
        }  
    },  
    $objectManager,  
    \Magento\Framework\ObjectManager\ObjectManager::class,  
);  
$dropKeys([  
    \Magento\Framework\App\RequestInterface::class,  
    \Magento\Framework\App\Request\Http::class,  
    \Magento\Framework\App\ResponseInterface::class,  
    \Magento\Framework\App\Response\Http::class,  
    \Magento\Framework\HTTP\PhpEnvironment\Request::class,  
    \Magento\Framework\HTTP\PhpEnvironment\Response::class,  
]);  
  
  
$handler = static function () use (  
    $bootstrap,  
    $objectManager,  
    $resetSharedState,  
    $resetSkipPrefixes,  
    $pruneShared,  
    $resetCreationStack,  
    $resetPluginInstances,  
    $whitelistMap,  
    $whitelistPrefixes,  
    $devMode  
) {  
    $t0 = microtime(true);  
  
    // Dev-only: inject the live-reload snippet into HTML responses just before  
    // the closing </head>. Idempotent — skips bodies that already reference it.    // Gated on MAGE_MODE=developer so this is a no-op in staging/production.    $devReload = $devMode;  
  
    if ($devReload) {  
        ob_start(function (string $body): string {  
            if ($body === '' || !str_contains($body, '</head>') || str_contains($body, 'dev-reload.js')) {  
  
                return $body;  
            }  
  
            return str_replace(  
                '</head>',  
                '<script src="/dev-reload.js"></script></head>',  
                $body  
            );  
        });  
    }  
  
    try {  
        /** @var \Magento\Framework\App\Http $app */  
        $app = $objectManager->create(\Magento\Framework\App\Http::class);  
        $tCreate = microtime(true);  
        $bootstrap->run($app);  
        $tRun = microtime(true);  
    } catch (\Throwable $e) {  
        http_response_code(500);  
        error_log('[frankenphp-worker] ' . $e->getMessage() . "\n" . $e->getTraceAsString());  
        $tCreate = $tRun = microtime(true);  
    } finally {  
        // Flush the dev-reload ob_start buffer (no-op when $devReload is false).  
        if ($devReload && ob_get_level() > 0) {  
            @ob_end_flush();  
        }  
        $resetCreationStack();  
        $resetPluginInstances();  
        /**  
         * In classic SAPI $_SESSION is zeroed between requests; in worker mode         * it survives. Close any active session and clear the superglobal so         * the next request starts from a clean slate — defense in depth         * against stale session data leaking across cookies in the same         * worker process.         */        if (session_status() === PHP_SESSION_ACTIVE) {  
            @session_write_close();  
        }  
        $_SESSION = [];  
        $resetSharedState($resetSkipPrefixes);  
        $tReset = microtime(true);  
        $pruneShared($whitelistMap, $whitelistPrefixes);  
        $tPrune = microtime(true);  
        $rpSize = function_exists('realpath_cache_size') ? realpath_cache_size() : -1;  
        $mem = memory_get_usage(true);  
        error_log(sprintf(  
            '[timing] create=%.3fs run=%.3fs reset=%.3fs prune=%.3fs total=%.3fs mem=%dMB rp=%dK uri=%s',  
            $tCreate - $t0,  
            $tRun - $tCreate,  
            $tReset - $tRun,  
            $tPrune - $tReset,  
            $tPrune - $t0,  
            (int)($mem / 1024 / 1024),  
            (int)($rpSize / 1024),  
            $_SERVER['REQUEST_URI'] ?? '-'  
        ));  
    }  
};  
  
while (frankenphp_handle_request($handler)) {  
    $requestCount++;  
    gc_collect_cycles();  
  
    if ($requestCount >= MAX_REQUESTS) {  
        error_log(sprintf('[frankenphp-worker] pid=%d recycling after %d requests', $workerPid, $requestCount));  
  
        break;  
    }  
}
```

### <s>Problem 1</s> Limitation 1. Environment-specific

A problem specific to my environment, which I described earlier. In worker mode, it's impossible to set up dynamic determination of the directory from the URL. For obvious reasons, because the very concept changes.

Franken starts -> immediately launches the necessary files.

Quick solution: a Caddyfile at the project level Dynamic solution: create a script that will generate `worker {}` constructs for the Caddyfile

Going with quick one for now.

### Problem 1. Missing static files, cached propertries

#### Problem

Static files started returning 404 after visiting the page once. That is, after a reload or opening another page.

The problem: Magento app is just generating wrong URLs (`_view` instead of `Magento/luma`)

It's that some services (like `View\Asset\Repository`) cache internal data across requests. They implement `ResetAfterRequestInterface` precisely to support clearing that data. And the worker's manual `unset($_sharedInstances[...])` approach doesn't trigger `_resetState()`. The cached `$defaults` (with no theme) survives even after the shared instance is removed, because other long-lived objects still hold references to the same Repository.

#### Solution

Thus, the following addition makes sense:

```php
$resetSharedState = \Closure::bind(
    function (): void {
        foreach ($this->_sharedInstances as $instance) {
            if ($instance instanceof \Magento\Framework\ObjectManager\ResetAfterRequestInterface
                || \method_exists($instance, '_resetState')
            ) {
                try {
                    $instance->_resetState();
                } catch (\Throwable $e) {
                    error_log('[frankenphp-worker] _resetState failed on '
                        . get_class($instance) . ': ' . $e->getMessage());
                }
            }
        }
    },
    $objectManager,
    \Magento\Framework\ObjectManager\ObjectManager::class,
);
```

#### Universal solution. Supporting ResetAfterRequestInterface for \\Magento

This led me to the thought that the `ResetAfterRequestInterface` interfaces that were introduced into Magento are nevertheless worth using. But not on the principle of requiring the ENTIRE codebase to use these interfaces, but rather by allowing the Magento vendor code to use them. However, here too, Magento happened to Magento. Even a manual search through the codebase showed that we cannot rely on this interface alone; there were classes with state that did not implement this interface.

> Magento even has an integration test for this case `vendor/magento/magento2-base/dev/tests/integration/testsuite/Magento/Framework/ObjectManager/ResetAfterRequestTest.php`. to find unannotated stateful classes. it diffs object state before/after a fake request and flags mismatches. That's the gap between "implements the interface" and "all stateful classes.
> 
> But it finds only **some** of them.

### Problem 2. Sessions, add-to-cart

The problem was that `Magento\Framework\Session\Storage::_resetState()` (inherits the `ResetAfterRequestInterface` interface) did `$this->_data = []`. The session data array is bound by reference to `$_SESSION` during `session_start()`. Reassigning `$_data` to a new empty array severs that reference. Subsequent reads of session data return empty. Cart-add fails because `quote_id`, `form_key`, etc. all vanish from the session.

Fix: skip `Session\Storage` (and `Customer\Model\Session\Storage`) in the worker's `_resetState()` pass. Sessions are already lifecycle-managed by `SessionManager::start()` / `session_write_close()` per request — they don't need (and break under) the worker's reset.

And this tells us that the implementation of this interface method `ResetAfterRequestTest` in Magento **is incorrect** for the above-mentioned classes. At least because it does `$this->_data = []` instead of `unset($this->_data[$k])`.

### Problem 3. Sessions again, admin login

The admin login stopped working, even after adding workarounds for the two session-related bugs.

Oh, I don't even know where to start. Studying the problem and fixing it (and the other consequences that the root cause creates) took me a couple of evenings. `\Magento\Framework\Interception\PluginList\PluginList` is whitelisted in the worker (correctly, because it owns the immutable interception graph), but it also caches live plugin instances in a property.

*   The cached `Backend\App\Action\Plugin\Authentication` plugin pinned `Session\Storage`.
    
*   `pruneShared` in the worker, which is responsible for clearing `ObjectManager::_sharedInstances`, clears them
    
*   however, this doesn't help because `PluginList` still pointed at the plugin object, keeping the whole tree alive
    
*   Then `Storage::_data` was reference-bound to a `$_SESSION['admin']` from a dead request; `regenerateId` re-init'd it from the empty new `$_SESSION['admin']`, wiping the user
    

Here, clearing the `_pluginInstances` property with the runtime cache helped, as in the previous fix. (I hope its final problem relates to it)

### Benchmarks with cache disabled

The benchmarks in this post focus on TTFB

### Home

![](https://cdn.hashnode.com/uploads/covers/69f0b3c210a70b3335b6f150/97a9cd92-9a59-4039-acc8-db2c3cc68e80.png align="center")

~8.9 times faster

### PLP

![](https://cdn.hashnode.com/uploads/covers/69f0b3c210a70b3335b6f150/39a54adb-6212-420f-99eb-e6d1ee36d5a6.png align="center")

~7 times faster

### Enabling cache

After switching the cache, I expected to immediately see TTFB numbers reduced by several times after +1 load, but nothing changed (and it even became slower by 50-100ms)

My first thought about the causes was that the worker spends additional time reading the cache (`load()` call — serialization, tag checks, I/O + docker+ARM pair). But, by my feeling, the time costs for the listed operations could not be so significant as to equalize the results between reading from the cache and rendering the block from scratch.

So I decided to take some measurements and take the `block_html` cache as a test subject. I added a time logger to `AbstractBlock::_loadCache()`

clarification: the worker is warmed up before the requests

| Cache | TTFB | Render time | Cache load |
| --- | --- | --- | --- |
| Enabled | 44ms | 18.32ms | 208µs |
| Disabled | 142ms | 138.8ms | 0 |

Thus, it turned out that the page loads 3 times faster. But I did say that the time was the same, didn't I? And the answer lies in the fact that **switching the cache required a restart**. Why? Because `\Magento\Framework\App\DeploymentConfig` has state (`->data`) and does not implement `ResetAfterRequestInterface`.

However, I would not clear `DeploymentConfig`. Calls to it can be quite frequent, and it allows saving a considerable amount of time. Thus, we need an approach that fulfills two conditions:

1.  When the cache state changes, reload the workers
    
2.  Do not touch Magento 2 files
    

A fairly simple option that came to mind - create a wrapper for `bin/magento`. We don't rewrite or patch anything, we just call `bin/mage-worker` instead of `bin/magento`. Under the same condition, we handle all commands that affect `/app/etc/env.php` by comparing fingerprints before and after running command

```bash
set -e  
  
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"  
ENV_FILE="$SCRIPT_DIR/../app/etc/env.php"  
RELOAD_MARKER="${MAGE_WORKER_RELOAD_MARKER:-$SCRIPT_DIR/../var/.mage-worker-reload}"  
  
fingerprint() {  
    if command -v md5sum >/dev/null 2>&1; then  
        md5sum < "$1" 2>/dev/null | awk '{print $1}'  
    elif command -v md5 >/dev/null 2>&1; then  
        md5 -q "$1" 2>/dev/null  
    else  
        # last-resort: size + mtime epoch seconds  
        stat -f '%z-%m' "$1" 2>/dev/null || stat -c '%s-%Y' "$1" 2>/dev/null || echo 0  
    fi  
}

before=$(fingerprint "$ENV_FILE")  
  
"$SCRIPT_DIR/magento" "$@"  
STATUS=$?  
  
after=$(fingerprint "$ENV_FILE")  
  
if [ "$STATUS" -eq 0 ] && [ "$before" != "$after" ]; then
    if kill -USR1 1 2>/dev/null; then  
        echo "[mage-worker] env.php changed — sent SIGUSR1 to FrankenPHP (worker reloaded)." >&2  
    else  
        : > "$RELOAD_MARKER"  
        echo "[mage-worker] env.php changed — touched $RELOAD_MARKER (host should signal worker)." >&2  
    fi  
fi  
  
exit $STATUS
```

### Benchmarks with cache enabled (without full\_page)

Here it's not as interesting anymore, since these measurements assume that most of the page content will be read from the cache rather than rendered. However, this is quite an interesting case to understand how heavy Magento's cold start is.

Comparison charts can be found below

Home page

![](https://cdn.hashnode.com/uploads/covers/69f0b3c210a70b3335b6f150/b7ca2ffd-b17b-4929-b068-9d8a42c74306.png align="center")

Category page:

![](https://cdn.hashnode.com/uploads/covers/69f0b3c210a70b3335b6f150/26ec1158-6b27-4d3e-bd09-cb70dd4fb3e1.png align="center")

## Development tools

### Hot Module Replacement

If you have ever worked, for example, with Vite, then you have seen how the reloading of page content works with HMR right away when you change a file. In Magento development, such things could only be dreamed of, and even then I think those would be nightmares, knowing the page load times.

It's of course not impossible to make a small setup for a file watcher just with Node.js, but it will give approximately nothing because of the cache, yeah. Although you can also attach a cache flush depending on the file type.

By now, though, we have found out that you can get a response for pages with the cache disabled in less than a second (in most test cases in less than 0.5s). There is a nuance here, however: **the changes being made don't reach the worker**. It started up, the PHP files are already read. And the worker will die only after N number of requests have elapsed (or won't die at all, but for Symfony they set a ceiling of 500). So we need a watcher that:

1.  Tracks file changes
    
2.  Reloads the workers
    
3.  Sends an event to the browser to reload (Mercure)
    

### Mercure

FrankenPHP comes with a built-in [Mercure](https://mercure.rocks/)! Mercure allows you to push real-time events to all the connected devices: they will receive a JavaScript event instantly. It’s a convenient alternative to WebSockets that is simple to use and is natively supported by all modern web browsers.

We use it to publish our events to the browser.

#### HMR setup. Caddyfile

We set up the Mercure thing under `/.well-known/mercure`. To our Caddyfile we add:

```Caddyfile
 order mercure after encode
```

and blocks

```Caddyfile
mercure {
  publisher_jwt !ChangeThisMercureHubJWTSecretKey! HS256
  subscriber_jwt !ChangeThisMercureHubJWTSecretKey! HS256
  anonymous
  cors_origins https://*.docker.loc http://*.docker.loc
  publish_origins https://*.docker.loc http://*.docker.loc
}

route /.well-known/mercure* {
    mercure {
        publisher_jwt !ChangeThisMercureHubJWTSecretKey! HS256
        subscriber_jwt !ChangeThisMercureHubJWTSecretKey! HS256
        anonymous
        cors_origins https://*.docker.loc http://*.docker.loc
        publish_origins https://*.docker.loc http://*.docker.loc
    }
}
```

#### HMR setup. JS Listener

Next, we need to start listening to our SSE. For this, we need to attach a script to the site. On this matter, we create a JS script that reloads the page upon receiving a message from `/.well-known/mercure`

```javascript
(function () {
  if (window.__devReloadAttached) return;
  window.__devReloadAttached = true;

  var topic = 'dev/reload';
  var url = '/.well-known/mercure?topic=' + encodeURIComponent(topic);
  var es;

  function connect() {
    es = new EventSource(url);
    es.onmessage = function (e) {
      try {
        var msg = JSON.parse(e.data);
        console.info('[dev-reload]', msg);
      } catch (_) {}
      location.reload();
    };
    es.onerror = function () {
      es.close();
      setTimeout(connect, 1000);
    };
  }
  connect();
})();

```

#### HMR setup. Integrating JS listener to all Magento pages

I considered several options for how to inject the script into the page:

1.  The simplest: this script can be injected into the Magento layout, for example `default_head_blocks.xml`. Cons — changes to Magento code (or patching)
    
2.  Use the [caddy-replace-response](https://github.com/caddyserver/replace-response) module for Caddy. We drop our script before `</head>`. Sounds great, until you realize the need to additionally build the frankenphp-build image, which takes 5-10 minutes for the first build and adds 2-5 minutes for rebuilds
    
3.  `ob_start`. The worker is our new `index.php`, so we have full control over I/O. No cons visible
    

Therefore, the third option was chosen. We gate it behind the condition `MAGE_MODE == 'developer'` and get something like:

```php
$envConfig = @include __DIR__ . '/../app/etc/env.php';  
$devMode = is_array($envConfig) && ($envConfig['MAGE_MODE'] ?? '') === 'developer';

if ($devMode) {  
    ob_start(function (string $body): string {  
        if ($body === '' || !str_contains($body, '</head>') || str_contains($body, 'dev-reload.js')) {  
            return $body;  
        }  
        return str_replace(  
            '</head>',  
            '<script src="/dev-reload.js"></script></head>',  
            $body  
        );  
    });  
}
```

That's all. The event stream is integrated without changes to Magento files

![](https://cdn.hashnode.com/uploads/covers/69f0b3c210a70b3335b6f150/8d9dd055-abe2-4012-aad8-a53139f7c382.png align="center")

### HMR setup. File watcher

Now we need a script that will send events to Mercure. In the project directory, we create `/dev/dev-watch.sh`. This script is aimed at working with FrankenPHP in the container, while the script itself runs on the host. The full script can be found in the project's GitHub; let's go through its main logic, skipping checks and secondary things.

Reloading the workers on a change. We simply send a signal to the process in the container

```bash
reload_worker() {  
  # FrankenPHP listens for SIGUSR1 on PID 1 to gracefully reload PHP workers  
  docker exec "$CONTAINER" sh -c 'kill -USR1 1' >/dev/null 2>&1 \  
    && echo "  ↻ worker reloaded (SIGUSR1)" \  
    || echo "  ! worker reload failed" >&2  
}
```

Publishing reload to Mercure. Just a `curl`. To endpoint we setup before with Caddy `/.well-known/mercure`

```bash
publish_reload() {  
  local kind="$1"  
  local payload  
  payload=$(printf '{"kind":"%s","ts":%s}' "$kind" "$(date +%s)")  
  curl -sk -o /dev/null -w "  → mercure %{http_code} ($kind)\n" \  
    -X POST "$MERCURE_URL" \  
    -H "Authorization: Bearer $JWT" \  
    --data-urlencode "topic=$TOPIC" \  
    --data-urlencode "data=$payload" \  
    || true  
}
```

And the actual body itself - we use `fswatch`. `fswatch` does not support an `include` parameter so that we could, for example, filter by a wildcard condition like `*.php` directly, so we do this internally.

The script also has some debounce serving as a rate limiter to group several actions into one request to Mercure

```bash
fswatch -x --latency=0.2 --event=Created --event=Updated --event=Removed --event=Renamed \  
        --exclude '\.swp$' --exclude '\.tmp$' --exclude '/\.git/' --exclude '/generated/' \  
        "${existing[@]}" \  
| while IFS= read -r line; do  
    # Line format: "<path> <FLAG1 FLAG2 ...>"  
    path="${line%% *}"  
    [[ -z "$path" ]] && continue  
  
    # Skip directory-only events.  
    case " $line " in *" IsDir "*) continue;; esac  
  
    ext="${path##*.}"  
    case "$ext" in  
      php|xml)        kind="php" ;;  
      phtml)          kind="phtml" ;;  
      css|less|js)    kind="asset" ;;  
      *)              continue ;;  # ignore everything else  
    esac  
  
    # Debounce: collapse bursts within DEBOUNCE_MS into one action per kind.  
    last_var="LAST_${kind}_MS"  
    last_val="${!last_var:-0}"  
    if (( now_ms - last_val < DEBOUNCE_MS )); then continue; fi  
    printf -v "$last_var" '%d' "$now_ms"  
  
    echo "[$(date +%H:%M:%S)] $kind  ${path#"$MAGE_ROOT/"}"  
    if [[ "$kind" == "php" ]]; then  
      reload_worker  
    fi  
    publish_reload "$kind"  
  done
```

That's all! A working HMR with Magento

## Observability

FrankenPHP provides built-in observability features: [Prometheus-compatible metrics](https://frankenphp.dev/docs/metrics/) and [structured logging](https://frankenphp.dev/docs/logging/).

FrankenPHP exposes Prometheus-compatible metrics for threads, workers, request processing, and queue depth when [Caddy metrics](https://caddyserver.com/docs/metrics) are enabled. Thus, the metrics can, for example, be connected to Grafana by enabling the admin endpoint in the Caddyfile:

```Caddyfile
{
    admin 0.0.0.0:2019
    servers {
        metrics
    }
}
```

obtaining them in Prometheus, by adding a job to `prometheus.yml`

```yml
scrape_configs:
  - job_name: frankenphp
    static_configs:
      - targets: ['frankenphp:2019']
```

and pointing Grafana to Prometheus for the metrics

> This metrics setup is exclusively local! **Exposing** `:2019` **publicly is dangerous** (it's the admin API, not just metrics) — keep it on an internal network or front it with auth

## Summary/Cut

### Pros. Devtool

FrankenPHP turned out to be a working replacement for the Nginx + PHP-FPM bundle for local Magento. Even in classic mode, it gives practically identical results in terms of TTFB, while simplifying the infrastructure and unlocking options like the one-button-in-the-browser Xdebug switch trigger.

With worker mode, things get even more interesting. Even partial, whitelist-limited, careful application gives a noticeable performance boost. As it turned out, the most noticeable gap appears in development scenarios, when the cache is disabled.

Fast page loading allows using a near-HMR, for which Franken provides Mercure. For production, such a scheme requires a separate, much more rigorous study. For local development, FrankenPHP already looks a good tool right now.

And yes... 0 Magento files were changed.

### Cons or "Should you actually run Magento in worker mode?"

Be clear-eyed about what the previous sections demonstrated.

Getting Magento to ***survive*** worker mode took a hand-maintained whitelist of ~160 classes, three separate session/state bugs each costing evenings to root-cause, and reliance on `ResetAfterRequestInterface` - an interface Magento ships ***but does not fully honor***, with a core test that is itself incorrect for some classes. None of this is supported by Adobe, none of it is documented, and all of it is load-bearing.

Concretely:

*   **Do not run this in production.** One stateful service missing from the list leaks data across requests, and across *users*
    
*   **Expect it to break on Magento upgrades.** The whitelist is pinned to the internal state behavior of a specific Magento version (validated here on 2.4.8). Any minor upgrade can add a stateful class, change a service's lifecycle, or alter a `_resetState()` implementation. Every upgrade is a re-audit, not a `composer update`
    
*   **Expect it to break on third-party modules.** Every vendor module is unaudited surface area. A single extension holding request state in a singleton reintroduces the class of bug documented above, and you own the investigation
    
*   Again, **this is good only for development**
