FrankenPHP: modern php app server

Running Laravel on FrankenPHP — faster PHP with long‑running workers

Modern PHP runtimes are evolving from the old request-per-process model (PHP-FPM) to long‑running process models that keep PHP in memory between requests. FrankenPHP is one such approach: it runs PHP as an always-on, embedded runtime, reducing request overhead and enabling much lower latency and higher throughput for some workloads.

This article explains what FrankenPHP-style runtimes bring to the table, whether Laravel is a good fit, what needs to change in your app, and a practical migration checklist (with code examples) so you can try it safely.


  • FrankenPHP-style runtimes keep a PHP process alive between requests. That reduces boot time and removes repeated process startup costs.
  • Laravel can run on these runtimes, but you must avoid leaking request-scoped state across requests and manage resources (DB connections, caches, event loops) explicitly.
  • The two practical short steps to try: (1) add request-reset middleware that reconnects/clears per-request state, and (2) run Laravel under the runtime behind an HTTP reverse proxy (Nginx, Caddy).
  • Measure throughput and memory — the biggest gains are on apps with heavy bootstrap work (many service providers, big ORMs, heavy Composer autoload).

What FrankenPHP-like runtimes change (conceptually)

  • Traditional PHP-FPM: each request executes PHP from a fresh state, then exits. OS process creation + PHP bootstrap cost each request.
  • Long-running runtimes (FrankenPHP, Swoole, RoadRunner): a PHP process stays alive; the runtime accepts HTTP requests and dispatches them to your application. The application is not reloaded between requests, so startup is faster, but any in-memory state remains unless you reset it.

Benefits:

  • Lower per-request latency (no repeated PHP process bootstrap).
  • Higher throughput per CPU.
  • Ability to integrate with asynchronous IO models or event loops in some runtimes.

Tradeoffs:

  • Risk of memory leaks and stale global state across requests.
  • Some third-party packages assume a fresh process per request (and may break unless reset).
  • Different deployment/observability model (worker lifecycle, memory limits, restarts).

Is Laravel compatible?

Yes — many teams run Laravel on long‑running environments (Swoole, RoadRunner, and newer runtimes). Laravel itself is framework-agnostic about the process model, but typical Laravel apps expect a fresh request environment. That means some adaptation is required.

Common compatibility concerns:

  • Static caches and singletons (class-level caches).
  • Open DB connections, external resource handles (Redis, Sockets).
  • Global state (session, cookie parsing, view factory caches).
  • Service providers that bootstrap heavy one-off work on process start — those will stay resident in memory (often desirable), but they must be safe.

With a few defensive measures (reset request-scoped state at the end of each request, use connection pooling properly, avoid leaking closures that capture request scope), Laravel runs very well on these runtimes.

Migration checklist (practical)

  1. Pick a runtime and test locally (FrankenPHP, Swoole, RoadRunner). For each runtime, follow its quickstart to make sure you can serve a simple public/index.php.
  2. Run your Laravel app with the runtime behind a reverse proxy. Validate basic routing and pages.
  3. Add a “request reset” middleware — ensure DB/Redis connections, caches, and other request-scoped resources are reset between requests.
  4. Replace singletons that hold request-scoped resources with factories or resetters.
  5. Add memory and request counters that restart workers gracefully (e.g., restart after N requests or memory threshold).
  6. Run end-to-end tests and benchmarks. Watch for memory growth across requests.
  7. Deploy behind a process supervisor or orchestrator. Ensure rolling restarts for new code and graceful shutdown on signals.
  8. Monitor memory/latency and tune worker count/limits.

Concrete examples

1) Request-reset middleware (Laravel)

Place this middleware early in the stack (after the framework bootstrap) to ensure each request is isolated. It should close DB connections, flush caches that should not persist, and reset singletons.

app/Http/Middleware/ResetPerRequestState.php

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Redis;

class ResetPerRequestState
{
    public function handle($request, Closure $next)
    {
        try {
            return $next($request);
        } finally {
            // Close any DB connections to avoid leaking transaction state
            DB::disconnect();

            // Flush any per-request cache entries if you used Cache::remember
            // (Only do this if you used a cache intended to be request-scoped.)
            // Cache::flush(); // cautious: only if safe in your app

            // Close Redis connection if using predis/extension client
            try {
                if (class_exists('\\Illuminate\\Support\\Facades\\Redis')) {
                    Redis::connection()->disconnect();
                }
            } catch (\\Throwable $e) {
                // ignore
            }

            // Clear any custom in-memory registries that should not persist
            // For example: Some libraries keep handler state in static properties
            // MyApp\SomeRegistry::clear();

            // Clear Laravel event listeners added on-the-fly (rare)
            Event::forget('*');
        }
    }
}

Register it in app/Http/Kernel.php near the top of $middleware (global middleware) so it runs for every request. Adjust the body to your app’s specifics.

Notes:

  • DB::disconnect() forces the next DB call to create a fresh connection. Most drivers support reconnect.
  • Avoid calling Cache::flush() globally unless your cache is page-level and safe to clear. Instead, only clear caches you know are request-scoped.

2) Avoid sticky in-memory caches for request data

If you have code like:

class Foo {
  private static $last;
  public static function compute(...) {
    if (self::$last) return self::$last;
    self::$last = expensive();
    return self::$last;
  }
}

This will persist across requests. Rework it so caches are bound to a request scope (pass a cache store or use the container) or manually reset self::$last = null in the reset middleware.

3) Docker pattern — run Laravel behind FrankenPHP

Below is a generic example that shows the architecture (reverse proxy + runtime). Adapt the runtime executable name to your chosen FrankenPHP binary.

Dockerfile (conceptual)

FROM php:8.2-cli

# install system deps and PHP extensions your app needs...
RUN apt-get update && apt-get install -y libzip-dev unzip libpng-dev

# copy app
WORKDIR /app
COPY . /app

# install composer deps
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
RUN composer install --no-dev --optimize-autoloader

# copy or install your runtime binary (frankenphp binary)
# e.g. COPY frankenphp /usr/local/bin/frankenphp
# make sure it's executable

EXPOSE 8080

# run runtime to serve public/index.php
CMD ["frankenphp", "serve", "--host=0.0.0.0", "--port=8080", "public/index.php"]

Then place an Nginx/Caddy proxy in front to terminate TLS and handshake with the runtime.

Nginx (conceptual)

server {
  listen 443 ssl;
  server_name example.com;

  # SSL config...
  location / {
    proxy_pass http://app:8080;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
  }
}

Notes:

  • Some runtimes accept a public/ directory and an entry script.
  • Ensure graceful shutdown is supported so requests finish on worker restart.

Testing & benchmarking

I’ll create the file and then run a quick content lint (if project has tooling) — but first I’ll check the blog content folder to avoid overwriting. The new file is src/content/blog/laravel-frankenphp.mdx and it was created. Now I’ll run a quick check for the new file. Let’s confirm the file exists. I’ll list the folder. (I will not run external commands unless you ask.)


More blogs from me