Laravel Frankenphp Octane Sail
My Laravel project is in part a headless CMS - this means it has and API that gets called by a frontend system - with around 10 API requests per page view.
We can cache some of that - but sometimes caches are empty and the site still has to be responsive.
The meant I needed to optimise my Laravel site and after a few experiments I found that using [Laravel Octane]](https://laravel.com/docs/12.x/octane) with FrankenPHP gave me the performance boost I needed without needing any significant code change.
Custom Building FrankenPHP
Most of the documentation I could find either used the pre-built FrankenPHP binary or didn’t use Octane.
To get the full performance benefit I needed to use these together
I found this post FrankenPHP and Laravel Octane with Docker very helpful
I need mysql support, imagick and various other PHP modules that the pre-built binary doesn’t offer.
The big thing Octane offers is that it runs Laravel in worker mode where the application is booted once and used for many requests - this drops response time considerably.
Build tools - but not in production
My site uses InertiaJs to run an admin UI, I need to build this in my build pipeline to create a Docker image which contains the compiled JS files - but I don’t need node itself in the final image.
Similarly I run PHPs composer in the build process but don’t need it in production.
To achieve this I take advantage of Dockers multi stage builds
This allows me to use one image, generate some output, and use that output in my final image which doesn’t hae to contain the build tools.
Local Dev
I like Laravel Sail as a quick way to get started - but I had a couple of bugs due to inconsistency between dev and prod.
This pull request shows that you cant modify php.ini using sail
https://github.com/laravel/sail/pull/595
By using docker compose natively I was able to create a local dev environment that really closely mirrors production
Having node in a separate container I can make npm run dev
happen automatically with docker compose up
I no longer need to run it manually each time (or forget and wonder why my changes aren’t working)
Also docker compose up
runs the full compose and npm installs meaning that another team member can just check out the project and get going.
Dockerfile
FROM composer:2.8.6 as composer_base
# because if I'm running locally the build picks up my .env
# which sets env=local, which causes dusk to try and load in non-dev mode - which errors
ENV APP_ENV=docker
# First, create the application directory
RUN mkdir -p /app
# Next, set our working directory
WORKDIR /app
# We need to create a group and user, and create a home directory for it, so we keep the rest of our image safe,
# And not accidentally run malicious scripts
# TODO - maybe parametrise the uid/gid
RUN addgroup -g 1000 -S cms \
&& adduser -u 1000 -S cms -G cms \
&& chown -R cms /app
# Drop privileges so any extra scripts that composer runs
# don't have access to the root filesystem.
USER cms
# Copy in our dependency files.
# We want to leave the rest of the code base out for now,
# so Docker can build a cache of this layer,
# and only rebuild when the dependencies of our application changes.
COPY --chown=cms composer.json composer.lock ./
# Install all the dependencies without running any installation scripts.
# We skip scripts as the code base hasn't been copied in yet and script will likely fail,
# as `php artisan` available yet.
# This also helps us to cache previous runs and layers.
# As long as composer.json and composer.lock doesn't change the install will be cached.
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist
# Copy in our actual source code so we can run the installation scripts we need
# At this point all the PHP packages have been installed,
# and all that is left to do, is to run any installation scripts which depends on the code base
COPY --chown=cms . .
# Now that the code base and packages are all available,
# we can run the install again, and let it run any install scripts.
RUN composer install --no-dev --prefer-dist
# For the frontend, we want to get all the Laravel files,
# and run a production compile
FROM node:20.19.0 AS frontend
# node image has node user uid=1000
# We need to copy in the Laravel files to make everything is available to our frontend compilation
COPY --from=composer_base --chown=node /app /app
WORKDIR /app
RUN mkdir /app/node_modules && chown node /app/node_modules
USER node
# We want to install all the NPM packages,
# and compile for production
RUN npm install && \
npm run build
FROM dunglas/frankenphp:1.4.4
# mysql client is needed for testing
# and some artisan functions
# could maybe be removed from production
RUN apt update && apt install -y default-mysql-client
# add additional extensions here:
RUN install-php-extensions \
bcmath \
pdo_mysql \
gd \
intl \
zip \
opcache \
redis \
imagick \
pcntl \
xdebug
## php config
COPY .docker/php.ini $PHP_INI_DIR/
RUN addgroup --gid 1000 cms \
&& adduser --uid 1000 cms --gid 1000
RUN setcap CAP_NET_BIND_SERVICE=+eip /usr/local/bin/frankenphp; \
# Give write access to /data/caddy and /config/caddy
chown -R cms:cms /data/caddy && chown -R cms:cms /config/caddy
# We have to copy in our code base from our initial build which we installed in the previous stage
COPY --from=composer_base --chown=cms /app /app
COPY --from=frontend --chown=cms /app/public /app/public
USER cms
# We want to cache the event, routes, and views so we don't try to write them when we are in Kubernetes.
# Docker builds should be as immutable as possible, and this removes a lot of the writing of the live application.
RUN APP_ENV=docker php artisan event:cache && \
APP_ENV=docker php artisan route:cache && \
APP_ENV=docker php artisan view:cache
# don't run artisan: optimize in the build because the includes config cacheing which reads env vars - which are not set yet
RUN cp /app/.docker/entrypoint-prod.sh /app/entrypoint.sh
ENTRYPOINT ["/app/entrypoint.sh"]
#ENTRYPOINT ["php", "artisan", "config:cache", "&&", "php", "artisan", "octane:frankenphp"]
docker-compose.yaml
services:
laravel.test:
build:
dockerfile: Dockerfile
# max-requests=1 means that each worker only servers one request
# it undoes the optimisation of octane - but means changes take immediate effect in dev
entrypoint: php artisan octane:frankenphp --max-requests=1
extra_hosts:
- 'host.docker.internal:host-gateway'
ports:
- "8000:8000" # HTTP
environment:
# SAIL env vars are remnants of old dev - unsure if they matter
LARAVEL_SAIL: 1
XDEBUG_MODE: '${SAIL_XDEBUG_MODE:-off}'
XDEBUG_CONFIG: '${SAIL_XDEBUG_CONFIG:-client_host=host.docker.internal}'
SERVER_NAME: http://laravel.test
LOG_DEPRECATIONS_CHANNEL: "null"
volumes:
- ./:/app/
# load dev config for php - turn errors on etc
- .docker/php-dev.ini:/usr/local/etc/php/conf.d/php-dev.ini
networks:
- hub-net
depends_on:
- mysql
- meilisearch
- mailpit
- selenium
composer:
# by using the build target from my multi stage build I ensure I use the same version of composer here as in my production build
# I could just specify the image - but I'd have to keep it in sync
build:
dockerfile: Dockerfile
target: composer_base
user: '${UID:-1000}:${GID:-1000}'
working_dir: /app
command: composer --version
volumes:
- ./:/app
networks:
- hub-net
node:
# same trick here to ensure I use the same node version
build:
dockerfile: Dockerfile
target: frontend
user: '${UID:-1000}:${GID:-1000}'
ports:
- 5173:5173
working_dir: /app
volumes:
- ./:/app
command: "npm run dev"
networks:
- hub-net
mysql:
image: 'mysql/mysql-server:8.0'
ports:
- '${FORWARD_DB_PORT:-3306}:3306'
environment:
MYSQL_ROOT_PASSWORD: '${DB_PASSWORD}'
MYSQL_ROOT_HOST: '%'
MYSQL_DATABASE: '${DB_DATABASE}'
MYSQL_USER: '${DB_USERNAME}'
MYSQL_PASSWORD: '${DB_PASSWORD}'
MYSQL_ALLOW_EMPTY_PASSWORD: 1
volumes:
- 'hub-mysql:/var/lib/mysql'
# copied from sail
- '.docker/create-testing-database.sh:/docker-entrypoint-initdb.d/10-create-testing-database.sh'
networks:
- hub-net
healthcheck:
test:
- CMD
- mysqladmin
- ping
- '-p${DB_PASSWORD}'
retries: 3
timeout: 5s
# the rest of this is the same as in Laravel Sail
meilisearch:
image: 'getmeili/meilisearch:latest'
ports:
- '${FORWARD_MEILISEARCH_PORT:-7700}:7700'
environment:
MEILI_NO_ANALYTICS: '${MEILISEARCH_NO_ANALYTICS:-true}'
volumes:
- 'hub-meilisearch:/meili_data'
networks:
- hub-net
healthcheck:
test:
- CMD
- wget
- '--no-verbose'
- '--spider'
- 'http://localhost:7700/health'
retries: 3
timeout: 5s
mailpit:
image: 'axllent/mailpit:latest'
ports:
- '${FORWARD_MAILPIT_PORT:-1025}:1025'
- '${FORWARD_MAILPIT_DASHBOARD_PORT:-8025}:8025'
networks:
- hub-net
selenium:
image: selenium/standalone-chromium
extra_hosts:
- 'host.docker.internal:host-gateway'
volumes:
- '/dev/shm:/dev/shm'
networks:
- hub-net
networks:
hub-net:
driver: bridge
volumes:
hub-mysql:
driver: local
hub-meilisearch:
driver: local
entrypoint.sh
#!/bin/bash
# prod env vars are only available at run time - cache on start
php artisan config:cache
php artisan octane:frankenphp
create-testing-database.sh
https://github.com/laravel/sail/blob/1.x/database/mysql/create-testing-database.sh
#!/usr/bin/env bash
mysql --user=root --password="$MYSQL_ROOT_PASSWORD" <<-EOSQL
CREATE DATABASE IF NOT EXISTS testing;
GRANT ALL PRIVILEGES ON \`testing%\`.* TO '$MYSQL_USER'@'%';
EOSQL
Utility scripts
I use a few scripts so that I don’t have to type docker exec
att the time
for example npm.sh
#!/bin/bash -e
set -euo pipefail
cd "$(dirname "$(realpath -- "$0")")";
docker compose run --rm -T node npm "$@"
then instead of npm install foo
I can type ./npm.sh install foo
and it works as expected - but via my docker container.