Tangible Bytes

A Web Developer’s Blog

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.