Laravel on FrankenPHP: why classic mode, not Octane

Brewless runs Laravel on FrankenPHP in classic mode and refuses worker mode and Octane. State that outlives a request is a risk, and serverless gains nothing.

In this post
  1. Why does Brewless run FrankenPHP in classic mode only?
  2. What FrankenPHP is
  3. Classic mode and worker mode
  4. The risk: state that outlives a request
  5. Why worker mode saves nothing on serverless
  6. The build refuses a worker
  7. What else Brewless leaves out
  8. What this means for your app

FrankenPHP can run a Laravel application two ways: fresh for every request, or booted once and kept in memory. The second sounds like free speed, and it is what Laravel Octane uses. Brewless runs only the first, and refuses a build that asks for the second.

This post explains what the two modes are, what goes wrong when state outlives a request, and why a serverless container gains nothing from keeping the application warm.

Why does Brewless run FrankenPHP in classic mode only?

Because in classic mode every request starts with a clean state, so nothing one request leaves behind reaches the next, and every Laravel application works as it is. Worker mode only pays off on a container that keeps running, and a serverless container that scales to zero does not. The risk stays and the gain does not.

What FrankenPHP is

FrankenPHP is an application server for PHP, written in Go on top of the Caddy web server. PHP runs inside the server process itself: one program answers HTTP and runs your code, where the classic setup has Nginx in front of a pool of PHP-FPM processes. It was started by Kévin Dunglas and is developed in the PHP project's own GitHub organisation.

Brewless uses it because a serverless container should run one thing:

Nginx + PHP-FPM FrankenPHP
Processes Two, plus a supervisor One
Configuration Nginx config and an FPM pool One Caddyfile
Compression Configured in Nginx zstd, Brotli and gzip in the server
Your code Unchanged Unchanged, in classic mode

Every release is one image, built on Brewless's public base image, which starts from the FrankenPHP project's own Docker image. The default configuration is short:

docker/Caddyfile
{
	# TLS ends at the edge and at the provider's own endpoint.
	auto_https off
	admin off

	frankenphp
}

:{$PORT:8080} {
	root * /app/public
	encode zstd br gzip

	# Fingerprinted build output can be cached for a year.
	@build path /build/*
	header @build Cache-Control "public, max-age=31536000, immutable"

	php_server
}

There is no worker directive in it. That is the whole difference between the two modes in this file.

Classic mode and worker mode

Classic mode works like PHP-FPM. A request comes in, Laravel boots, handles it and is thrown away. Statics, singletons and anything else in memory are gone before the next request.

Worker mode boots the application once and hands it request after request. The boot is skipped, which is where the speed comes from. Everything that happened to live in memory is kept as well, which is where the trouble comes from.

Laravel Octane is a package that runs Laravel in worker mode, on FrankenPHP, Swoole or RoadRunner. Octane resets a lot of the framework between requests, but it cannot know about every static property in your code or in the packages you use.

The risk: state that outlives a request

In worker mode, a few ordinary patterns leak:

  • A value cached in a static property, such as the current customer, a locale or a feature flag.
  • A singleton that was given request data when it was resolved, and keeps it.
  • A package that registers listeners or macros on every boot, so the list grows request by request.
  • A database connection that one request switched to another schema or database.

In a single-tenant application that usually means odd bugs. In a multi-tenant application it can mean one tenant's data shown to another. Brewless's documentation names stancl/tenancy as an example: its maintainer does not support Octane, because a tenant set up by one request can still be there when the next request comes in for someone else.

That is not a risk a hosting platform should hand to its customers by default. You cannot see it in a test that makes one request, and you find out in production.

Why worker mode saves nothing on serverless

Worker mode speeds up the second and later requests on a container that is already running. It does nothing for the first one.

Brewless runs Laravel on Scaleway Serverless Containers. They start when requests come in and scale back to zero when it is quiet. A container that was scaled to zero starts cold, whatever mode it runs in: the image is pulled, the process starts and the application boots. Worker mode only helps once a container runs, and a serverless container does not promise to keep running.

What does help a cold start is already in the image:

  • OPcache never checks the disk. The code in an image never changes, so opcache.validate_timestamps is off and no file is looked at again on later requests.
  • Configuration, routes and views are cached when the container starts. The start script runs config:cache, route:cache and view:cache, because your variables only exist in the running container.
docker/entrypoint.sh
if [ -f artisan ]; then
    php artisan config:cache --no-interaction >/dev/null
    php artisan route:cache --no-interaction >/dev/null
    php artisan view:cache --no-interaction >/dev/null
fi

With those in place, the boot that worker mode skips is a small part of a request.

Note

Classic mode has costs of its own. FrankenPHP runs PHP in threads, so it needs the thread-safe build, and an extension that is not thread-safe cannot be loaded. When a C extension crashes, the whole server process stops, where PHP-FPM would lose one child. Scaleway starts the container again, but requests in flight on it fail.

The build refuses a worker

Brewless does not just leave worker mode off. It refuses it, so it cannot be switched on by accident.

You can replace the server configuration with three files in your repository: docker/Caddyfile, docker/php.ini and docker/entrypoint.sh. The build checks two of them:

File Refused when it Example
docker/Caddyfile Declares a worker A worker line in the frankenphp block
docker/entrypoint.sh Starts Octane octane:start or octane:frankenphp

Either one stops the build before anything is built. The build log, and brewless deploy, end with a line that starts with Refused: and names the file. The release that serves visitors keeps doing so.

Tip

Need other headers, larger uploads or a step before the server starts? Copy the default file from Server configuration, change what you need, and keep :{$PORT:8080}, auto_https off, frankenphp and php_server.

What else Brewless leaves out

FrankenPHP can do more than Brewless switches on. Besides worker mode:

  • Automatic HTTPS is off in the container. Visitors connect to bunny.net, or to Scaleway without bunny.net, and that is where the certificate lives.
  • HTTP/3 would never be reached: Scaleway passes plain HTTP to the container, and bunny.net decides which HTTP version a visitor gets.
  • 103 Early Hints never reach the visitor: Scaleway drops them before they leave its platform, so bunny.net never receives them. We tested this in October 2026.

The PHP page lists each of these with the reason.

What this means for your app

If your Laravel app runs on PHP-FPM today, it runs on Brewless as it is. There is nothing to make Octane-safe, no octane.php to keep in step, and no class of bugs that only shows up on the second request.

If you use Octane today, make sure no docker/entrypoint.sh of yours starts it before you move. Keep sessions and cache in the database or Redis and files on the s3 disk, as for any serverless container; the EU-sovereign hosting guide covers those settings.

Long-running work belongs in a queued job, not in a process that stays up. How Brewless runs the queue without a worker that stays up is in Laravel queues without a long-running worker.

Frequently asked questions

Does Brewless support Laravel Octane?

No. Octane runs Laravel in worker mode, where the application stays in memory between requests. Brewless refuses a build whose docker/entrypoint.sh starts Octane, with octane:start or octane:frankenphp, before anything is built.

What is FrankenPHP classic mode?

In classic mode FrankenPHP runs every request with a clean state, the way PHP-FPM does. Nothing a request puts in a static property or a singleton is there for the next one. An application that runs on PHP-FPM today runs in classic mode without changes.

Why does Brewless use FrankenPHP instead of Nginx and PHP-FPM?

A serverless container runs one thing. Nginx with PHP-FPM is two processes, two configurations and a supervisor to keep both alive, while FrankenPHP is one binary configured with one Caddyfile. The base image starts from the FrankenPHP project's own Docker image.

Would worker mode make my app faster on Brewless?

Only on a container that is already running, and a serverless container does not promise to keep running. A container that was scaled to zero starts cold whatever the mode. Brewless caches configuration, routes and views when a container starts, and OPcache never checks the disk for changed files.

How does Brewless refuse worker mode?

The build looks at your docker/Caddyfile and docker/entrypoint.sh. A Caddyfile with a worker line, or a start script that runs octane:start or octane:frankenphp, stops the build with a line that starts with Refused and names the file. The release that is live keeps serving visitors.

All posts Read this post as markdown

Try it on your own app

Brewless deploys your Laravel app to Scaleway and bunny.net, in your own accounts. We open access to Laravel teams in small batches.