Digital Engineering Monkeys

Exploring FrankenPHP for Laravel with Docker

Exploring FrankenPHP for Laravel with Docker

FrankenPHP, created by Symfony Core team member Kévin Dunglas, has finally reached v1.0 creating hype in the PHP ecosystem to provide an alternative to the classic “NGINX + PHP-FPM” stack – the same that we’re currently using in WeRoad.

Furthermore, it ships with out-of-the-box worker mode, static app/cli building, real-time SSE through Mercure Hub and many more goodies; easy to believe that we wanted to try it out as soon as possible!

Our current stack

As said earlier we have a classic stack: a load balancer dispatches the traffic to our horizontal auto-scaling instances with NGINX and a container exposing the PHP-FPM process, where our app lives.

It’s linear, but it means that to deploy a single Laravel app, we need two containers and, since they’re grouped together, they also scale together so that it could result in a waste of resources.

The idea of deploying a single container is yummy, but having both NGINX and PHP-FPM together does not sound right. Serversideup solved this issue in their images by using S6 Overlay to keep both services running and connected to the container health, but it’s still a bit of overhead.

Right now, however, we have a few different options:

  1. NGINX Unit instead of classic NGINX that, as an evolution of their product, offers a simpler config syntax and a lightweight Web Server.
  2. FrankenPHP working with Caddy to server PHP and remove completely PHP FastCGI.

Today we’re going to expand the FrankenPHP solution, although Serversideup is working on v3.0 of their images to provide Unit instead of classic NGINX, and will be worth exploring it.


There’s nothing really complex about our new Dockerfile: we extend FrankenPHP, set a few variables, and copy our config files to tweak a bit PHP.

First things first: we have the “base image” that is pushed to GCP Artifact Registry and extended from each PHP project for further customisation.

FROM dunglas/frankenphp:1.0.3-php8.2 as base

# Set Caddy server name to "http://" to serve on 80 and not 443
# Read more:

RUN apt-get update \
    && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
    git \
    unzip \
    librabbitmq-dev \

RUN install-php-extensions \
    gd \
    pcntl \
    opcache \
    pgsql \
    pdo_pgsql \
    sockets \
    redis \

COPY --from=composer:2 /usr/bin/composer /usr/bin/composer


RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" \
    && mkdir vendor \
    && chown www-data:www-data vendor

COPY files/weroad-php.ini $PHP_INI_DIR/conf.d/
COPY files/opcache.ini $PHP_INI_DIR/conf.d/

One important variable we had to set is SERVER_NAME: Caddy is HTTPS-by-default, but we don’t need that either in development or production, since we have our load balancers + CloudFlare to manage the SSL certificate. By setting the variable to http:// we ensure that the process will run on port 80 instead of 443.

Our project Dockerfile, as said earlier, will extend this build and add a few more lines for specific environments.

FROM base AS development

RUN cp $PHP_INI_DIR/php.ini-development $PHP_INI_DIR/php.ini; \
    sed -i 's/variables_order = "GPCS"/variables_order = "EGPCS"/' $PHP_INI_DIR/php.ini;

CMD /bin/bash -c "chown -R www-data:www-data /app/storage/logs /app/storage/app /app/storage/framework /app/bootstrap/cache \
    && php artisan migrate \
    && php artisan cache:clear \
    && frankenphp run --config /etc/caddy/Caddyfile --adapter caddyfile"

#       PRODUCTION      #
FROM base AS production

COPY --chown=www-data:www-data ./composer.json ./composer.lock ./
RUN composer install --no-cache --no-dev --no-scripts --no-autoloader --ansi --no-interaction

COPY --chown=www-data:www-data . .
RUN composer dump-autoload -o

RUN php artisan migrate --force

What’s curious here is that in the development stage, we fix a few permission issues at runtime (we run that stage through docker-compose) and then, to make everything work again, we manually run the FrankenPHP binary passing the default Caddyfile config (no changes made there).

What about our other processes?

That configuration is straightforward and works as it should, but what’s the big deal here? In our stack, we need to run also the Queue Worker, the Laravel cronjob manager, and our own custom RabbitMQ worker and that’s done by invoking the commands as easy as php artisan queue:work, but…

But now we would spawn the entire HTTP WebServer every time and that definitely does not feel right.

What are our solutions here? We could use a brand new WORKER_MODE variables to extend different starting images (FrankenPHP or PHP) when starting the image, or, alternatively, always use the FrankenPHP one but prevent the Caddy server from starting and just use a “dump init”, although we still haven’t tried these solutions yet since we’re in an R&D discovery phase.

Laravel Octane

A special mention is of course to Octane: Kevin Dunglas made a huge effort to bring a seamless integration between FrankenPHP and Octane and guess what? To make it work you just have to install Octane as always but then, at the end of the Dockerfile, you just have to run:

php artisan octane:frankenphp

Currently we don’t plan to enable the Octane runner in our projects since we’re pretty happy with the current performances and we would deal with memory leaks by several packages, but never say never 🙂