Under the hood
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
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:
{
# 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_timestampsis 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:cacheandview:cache, because your variables only exist in the running container.
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,frankenphpandphp_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.