TL;DR: a production stack moved off Caddy and php-fpm onto FrankenPHP running under Laravel Octane's worker mode, cutting the framework's full per-request boot cost.
Laravel, like most PHP frameworks, carries real bootstrap overhead. Every single request builds the whole framework from a cold start. I ran into this myself in my own project - even small requests, like opening the homepage or the policy page, were taking noticeable time and eating CPU. It's something a user notices while using the app.
I'd heard about FrankenPHP before - a single Go binary with a PHP runtime built into Caddy. I decided to give it a try and rework my app to run on it, just to speed things up.
The stack: Laravel with an Inertia/Vue frontend, Postgres, and a Go binary for PDF generation - all on one small VM (2 vCPU, 2GB RAM), running entirely in Docker via docker compose.
Step 1: Replacing Caddy and php-fpm with FrankenPHP
Previously, this meant two images: caddy and php-fpm. Now both get replaced with a single frankenphp image, which handles TLS and PHP execution all on its own.
Dockerfile, before (two targets, two images):
FROM php:8.4-fpm-alpine AS php-fpm
RUN apk add --no-cache postgresql-libs libzip icu-libs oniguruma \
&& apk add --no-cache --virtual .build-deps $PHPIZE_DEPS postgresql-dev libzip-dev icu-dev oniguruma-dev \
&& docker-php-ext-install -j$(nproc) pdo pdo_pgsql mbstring bcmath zip intl opcache \
&& apk del .build-deps
# ...COPY app code, entrypoint, USER www-data...
CMD ["php-fpm"]
FROM caddy:2-alpine AS caddy
COPY Caddyfile /etc/caddy/Caddyfile
COPY --from=php-fpm /var/www/html/public /var/www/html/public
After (one target):
FROM dunglas/frankenphp:1-php8.4-alpine AS frankenphp
RUN apk add --no-cache postgresql-libs libzip icu-libs oniguruma \
&& apk add --no-cache --virtual .build-deps $PHPIZE_DEPS postgresql-dev libzip-dev icu-dev oniguruma-dev libcap \
&& docker-php-ext-install -j$(nproc) pdo pdo_pgsql mbstring bcmath zip intl opcache \
&& setcap cap_net_bind_service=+ep "$(readlink -f "$(which frankenphp)")" \
&& apk del .build-deps
WORKDIR /var/www/html
COPY --from=composer-deps /app/vendor ./vendor
COPY . .
COPY --from=node-build /app/public/build ./public/build
COPY Caddyfile /etc/frankenphp/Caddyfile
RUN php artisan storage:link && chown -R www-data:www-data /var/www/html /data /config
USER www-data
CMD ["frankenphp", "run", "--config", "/etc/frankenphp/Caddyfile"]
FrankenPHP's image runs as root by default, since binding ports 80/443 usually needs it. To keep the same www-data user the php-fpm image used, I grant the binary one narrow capability (cap_net_bind_service) instead of running the whole process as root.
Caddyfile, before:
{$DOMAIN} {
root * /var/www/html/public
encode gzip
php_fastcgi php-fpm:9000
file_server
}
After - one php_server directive replaces both php_fastcgi and file_server:
{$DOMAIN} {
root * /var/www/html/public
encode zstd gzip
php_server
}
docker-compose.prod.yml shrank from two services down to one app service.
A production permissions issue
The caddy_data/caddy_config volumes already existed on the server, written by the old Caddy container running as root. The new app container runs as www-data - it couldn't read its own TLS certificate:
"error":"open /data/caddy/certificates/.../app.example.com.key: permission denied"
Cloudflare saw a failed TLS handshake and returned 525 to visitors. The container couldn't start until those permissions were fixed.
The fix: a one-time ownership correction and an explicit restart:
docker compose -f docker-compose.prod.yml --env-file .env.production run --rm --user root app \
chown -R www-data:www-data /data /config
docker compose -f docker-compose.prod.yml --env-file .env.production restart app
Step 2: adding Laravel Octane worker mode
Classic FrankenPHP still boots the entire Laravel framework from zero on every request. It's a faster web server, since it skips the FastCGI hop to php-fpm, but the request lifecycle stays the same. Worker mode helps with that - it keeps one PHP process alive, working through many requests in a loop, instead of exiting and restarting for each one.
Laravel doesn't support that mode by default, so there's Laravel Octane - a package that lets the framework run as a long-living process, usable with FrankenPHP, Swoole, or RoadRunner.
composer require laravel/octane
php artisan octane:install --server=frankenphp
This generates config/octane.php and public/frankenphp-worker.php - the script FrankenPHP keeps running, handling request after request without rebooting the app in between.
Caddyfile, the final version:
{
email {$LETSENCRYPT_EMAIL}
frankenphp {
worker {
file /var/www/html/public/frankenphp-worker.php
}
}
}
{$DOMAIN} {
root * /var/www/html/public
encode zstd br gzip
php_server {
index frankenphp-worker.php
try_files {path} frankenphp-worker.php
resolve_root_symlink
}
}
A shared state problem
Since the framework core and the project's code now load only once at startup, that same booted instance gets reused across thousands of requests, so any user state the code keeps won't get cleared between requests.
A short checklist for the code:
- No mutable
staticproperties anywhere in the application code. - No data stored in global variables (
$GLOBALS). - Nothing calls
config([...])at runtime - the main way config leaks between requests under Octane.
After that, it's worth testing the app under concurrent use from different users, and confirm none of them ever saw another's session, auth state, or data.
Results
After deploy, I ran a PageSpeed Insights test on the live site. It came back with 100/100 performance on desktop, 0.5s LCP, 0ms total blocking time.
In production, the startup log confirms the worker is actually running:
{"logger":"frankenphp","msg":"FrankenPHP started 🐘","total_threads":5,"worker_threads":4,"max_threads":5}
Conclusion
By the end, the project got migrated to a single frankenphp container running in worker mode, one process that stays loaded instead of rebuilding itself on every request.
It made a difference to the site's performance, and feels quite satisfying.
You can see the project here: https://getyourcv.app.
Top comments (0)