Laravel is a very performant framework, but a standard architecture has one big flaw derived from how PHP works: It has to rebuild the entire framework on every single request.
Even with optimizations, this process still takes 40 to 60 ms on my machine with PHP 8.4. Luckily, for years, the PHP and Laravel worlds have had a solution that dramatically reduces this load time: Laravel Octane and FrankenPHP. The booting time for the Laravel framework can drop to just 4 to 6 ms per request. Incredible, isn't it?
Now, if you're new to Laravel Octane or FrankenPHP, you may wonder how this is possible. The simple answer is that the framework is kept in memory. After FrankenPHP starts, Laravel is always ready to serve requests without a full reboot. The real explanation is more complex and out of scope for this article. If you're curious, you can read the official Laravel Octane and FrankenPHP docs for a deeper dive.
Before continuing, I should mention that FrankenPHP isn't the only application server for Laravel Octane. However, it's the one I've tried, and I was so satisfied with its features and performance that I didn't feel the need to try the others (like Swoole or RoadRunner).
The Deployment Challenge: From Manual Start to Automation
Great, but how can I run this on my server?
That's a good question. First off, Laravel Octane exposes a very convenient command: php artisan octane:start. Since this is a long-lived process (meaning it keeps running for days or more), we can't just start it manually and walk away. This is where a process manager, like a Supervisor, comes into play. You tell the process manager to start the process, and it runs it in the background and takes care of restarting it if it crashes or the system reboots.
But using a system like Supervisor has one very big downside, in my opinion: system pollution. That's why I chose another solution: Docker Compose.
Why Docker Compose is the Perfect Fit
Docker Compose has some huge advantages that allow me to make the most of Laravel Octane and its related tools.
Isolation
By building a custom Docker image, I can bake in just the minimal requirements to run each process. This allows me to separate the software/asset compilation (I'm looking at you, humongous node_modules folder) from the final running application.
For example, my web server image (which I just call app) only has the FrankenPHP requirements. It doesn't even include Composer, as the vendor directory is copied from a separate build stage. On the other hand, my worker image only includes the PHP CLI without FrankenPHP, because it doesn't need it.
Process Management
By separating every process that needs to run (horizon, pulse, scheduler, redis, db, web) into its own container, we ensure that a problem with one doesn't directly impact another. Of course, if the database dies, the app dies too, but if the scheduler dies, the app may continue working, perhaps with reduced functionality.
Plus, if a container dies, Docker automatically restarts it without human intervention.
Easy Traefik Integration
It's probably obvious from my blog, but I love Traefik. With it, I can run 30 web applications composed of nearly 80 containers with minimal interference. The only slowdown is when I build a new Docker image directly on the server, which can hog CPU resources. With Traefik, I can just place some labels on the container in my docker-compose.yml file, and Traefik automatically exposes it to the web on ports 80 and 443.
The stack
So, now that I've explained the what and the why, let's look at the infrastructure I deployed to run my site, coz.jp.
The stack is composed of three main parts.
1. The Multi-Stage Dockerfile
A multi-stage Dockerfile with multiple targets supports building lean, specialized images for the frankenphp web server and the command-line worker.
Dockerfile
A multi-stage dockerfile image with multiple targets supported, frankenphp for the web and worker for non web process.
# Stage 1: Vendor (shared between all) FROM composer:latest AS vendor WORKDIR /app # Install PHP extensions (needed for composer) ADD --chmod=0755 https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/ RUN install-php-extensions gd bcmath intl pcntl redis pdo_mysql ARG SPARK_USERNAME ARG SPARK_API_TOKEN ENV SPARK_USERNAME=${SPARK_USERNAME} ENV SPARK_API_TOKEN=${SPARK_API_TOKEN} COPY composer.json composer.lock ./ #RUN composer config http-basic.spark.laravel.com "$SPARK_USERNAME" "$SPARK_API_TOKEN" #RUN composer install --no-dev --prefer-dist --no-interaction --no-scripts --no-progress # Temporary Spark auth for private packages RUN set -eux; \ composer config --global http-basic.spark.laravel.com "$SPARK_USERNAME" "$SPARK_API_TOKEN"; \ composer install --no-dev --prefer-dist --no-interaction --no-scripts --no-progress; \ composer config --global --unset http-basic.spark.laravel.com; \ rm -f /root/.composer/auth.json || true; \ rm -f /app/.composer/auth.json || true; \ rm -f /tmp/* /var/tmp/* || true # Stage 2: Assets (build frontend with Node 22 + Yarn) FROM node:22-alpine AS assets WORKDIR /app # Copy dependency manifests and install dependencies using Yarn (via Corepack) COPY package.json yarn.lock ./ RUN corepack enable \ && corepack prepare [email protected] --activate \ && yarn install --frozen-lockfile # Copy only what is needed for building assets COPY vite.config.js ./ COPY tailwind.config.js ./ COPY postcss.config.js ./ COPY resources ./resources COPY public ./public ENV NODE_ENV=production RUN yarn build # Stage 3: Worker image (CLI) FROM php:8.4-cli-alpine AS worker COPY --from=vendor /usr/local/bin/install-php-extensions /usr/local/bin/ RUN install-php-extensions bcmath intl pcntl gd curl pdo_mysql mbstring redis ARG APP_ENV=production WORKDIR /app COPY . /app COPY ".env.${APP_ENV:-production}" .env COPY --from=vendor /app/vendor /app/vendor RUN mkdir -p storage bootstrap/cache; RUN chown -R www-data:www-data storage bootstrap/cache; RUN chmod -R 775 storage bootstrap/cache; USER www-data CMD ["php", "artisan", "queue:work", "--tries=3", "--sleep=1"] # Stage 4: FrankenPHP image (Web) FROM dunglas/frankenphp:latest AS frankenphp WORKDIR /app ARG APP_ENV=production ADD --chmod=0755 https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/ RUN install-php-extensions bcmath intl pcntl gd curl pdo_mysql mbstring redis COPY . /app COPY ".env.${APP_ENV:-production}" .env COPY --from=vendor /app/vendor /app/vendor # Copy compiled frontend assets without installing Node/Yarn in this stage COPY --from=assets /app/public/build /app/public/build COPY --from=vendor /usr/local/bin/install-php-extensions /usr/local/bin/install-php-extensions COPY --from=vendor /usr/bin/composer /usr/bin/composer RUN mkdir -p storage bootstrap/cache; RUN chown -R www-data:www-data storage bootstrap/cache; RUN chmod -R 775 storage bootstrap/cache; #EXPOSE 80 CMD ["php", "artisan", "octane:frankenphp", "--host=0.0.0.0", "--port=80"] This is my docker file code, not everything may make sense to you and there could be space for improvements for sure, fill free to suggest improvements.
As you can see, I use a vendor stage to download all Composer packages, including private ones. I then explicitly remove the authentication credentials from the image to reduce security risks. The other two PHP stages just copy the vendor directory without needing to run composer install in each final image.
A similar assets stage handles the frontend. I install everything with Node 22 and Yarn, build the production assets, and then copy only the compiled files into the final web image. This keeps the huge node_modules directory and Node itself out of my production container, which is a great plus in my book.
The compose.yml File
This file orchestrates all the services, linking them together and configuring them for production with Traefik.
# Docker Compose setup for local and production (Traefik) with FrankenPHP # - Build a single image and reuse it for web, worker, and scheduler services # - For production behind Traefik, set labels and SERVER_NAME appropriately x-env: &default-env env_file: - .env x-volumes: &laravel-volumes volumes: - ./.storage/logs/:/app/storage/logs - ./.storage/app/:/app/storage/app - ./.storage/framework/:/app/storage/framework # helper map to merge env and volumes in one << per service x-common: &common <<: [*default-env, *laravel-volumes] name: coz_jp_${APP_ENV} services: app: container_name: coz_jp_web_${APP_ENV} image: coz_jp:frankenphp pull_policy: never build: context: ${CONTEXT_LOCATION:-.} dockerfile: docker/Dockerfile target: frankenphp args: APP_ENV: ${APP_ENV:-production} SPARK_USERNAME: ${SPARK_USERNAME} SPARK_API_TOKEN: ${SPARK_API_TOKEN} <<: *laravel-volumes # labels: - traefik.enable=true - traefik.http.routers.coz_jp_${APP_ENV}-https.rule=Host(`${APP_DOMAIN}`) - traefik.http.routers.coz_jp_${APP_ENV}-https.tls=true - traefik.http.services.coz_jp_${APP_ENV}-https.loadbalancer.server.port=80 - traefik.http.routers.coz_jp_${APP_ENV}-https.tls.certresolver=cloudflare - traefik.http.routers.coz_jp_${APP_ENV}-https.entrypoints=websecure #- "traefik.http.routers.coz_jp_${APP_ENV}.middlewares=forward-auth" #- "traefik.http.middlewares.forward-auth.headers.customrequestheaders.X-Forwarded-Proto=https" #- "traefik.http.middlewares.forward-auth.headers.customrequestheaders.X-Forwarded-Host=${APP_URL}" environment: SERVER_NAME: ${APP_DOMAIN:-:80} SERVER_ROOT: /app/public depends_on: - redis - db networks: - internal - traefik # uncomment in production restart: unless-stopped command: ["php", "artisan", "octane:frankenphp", "--host=0.0.0.0", "--port=80", "--workers=8", "--log-level=info"] healthcheck: test: [ "CMD-SHELL", "curl -fsS http://127.0.0.1:80/up || { rc=$$?; echo \"[healthcheck] GET /up failed with code $$rc\" >&2; exit 1; }" ] interval: 15s timeout: 5s retries: 20 start_period: 10s worker: container_name: coz_jp_worker_${APP_ENV} image: coz_jp:worker pull_policy: never build: context: ${CONTEXT_LOCATION:-.} dockerfile: docker/Dockerfile target: worker args: APP_ENV: ${APP_ENV:-production} SPARK_USERNAME: ${SPARK_USERNAME} SPARK_API_TOKEN: ${SPARK_API_TOKEN} <<: *common command: ["php", "artisan", "horizon"] # command: ["php", "artisan", "queue:work"] depends_on: - redis - db networks: - internal restart: unless-stopped healthcheck: test: ["CMD-SHELL", "php artisan inspire >/dev/null 2>&1 || exit 1"] interval: 15s timeout: 2s retries: 10 scheduler: container_name: coz_jp_scheduler_${APP_ENV} image: coz_jp:worker <<: *laravel-volumes command: ["php", "artisan", "schedule:work"] depends_on: - redis - db networks: - internal restart: unless-stopped pulse_check: container_name: coz_jp_pulse_check_${APP_ENV} image: coz_jp:worker <<: *laravel-volumes command: ["php", "artisan", "pulse:check"] depends_on: - redis - db networks: - internal restart: unless-stopped pulse_work: container_name: coz_jp_pulse_work_${APP_ENV} image: coz_jp:worker <<: *laravel-volumes command: ["php", "artisan", "pulse:work"] depends_on: - redis - db networks: - internal restart: unless-stopped db: image: mysql:8.2 container_name: coz_jp_db_${APP_ENV} <<: *default-env environment: MYSQL_DATABASE: ${DB_DATABASE:-laravel} MYSQL_USER: ${DB_USERNAME:-laravel} MYSQL_PASSWORD: ${DB_PASSWORD:-secret} MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-secret} ports: - "${DB_EXPOSE_PORT:-13306}:3306" # change or remove in production volumes: - ./.mysql-db/:/var/lib/mysql networks: - internal restart: always healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] timeout: 20s retries: 10 start_period: 30s redis: image: redis:alpine container_name: coz_jp_redis_${APP_ENV} volumes: - .redis/redis:/data networks: - internal healthcheck: test: ["CMD", "redis-cli", "ping"] restart: unless-stopped # Optional: database backups using tiredofit/db-backup # db_backup: # image: tiredofit/db-backup # container_name: db_backup_coz_jp # depends_on: # - db # volumes: # - ./backups/:/backup # <<: *default-env # networks: # - internal # restart: always networks: internal: traefik: external: true name: traefik This is my current compose.yml file feel free to suggest improvements.
In the compose.yml file, you can see how I've separated each logical part of the application (web, worker, scheduler, pulse) into its own service. This aligns with the reasons I mentioned earlier.
3. The run.sh Deployment Script
Finally, to run and deploy this process, I adopted a simple .sh script to automate all the steps. Now I only need to type bash run.sh, and the code gets pulled, built, and deployed automatically.
#!/bin/bash # Define directories GITFOLDER="../coz_jp" LOCALFOLDER=$(pwd) # Load environment variables from .env source "$LOCALFOLDER/.env" echo "***Pulling repo."; # Change to the GIT folder, exit if fails cd "$GITFOLDER" || exit 1 # Pull latest changes git pull # Return to local folder cd "$LOCALFOLDER" || exit 1 echo "***Copying files"; # Copy compose.yml from git, overwriting local cp -f "${GITFOLDER}/compose.yml" "$LOCALFOLDER/compose.yml" cp -f "${LOCALFOLDER}/.env" "${GITFOLDER}/.env.${APP_ENV}" # Ensure folders are owned by user 82 sudo chown -R 82:82 .storage sudo chown -R 999:999 .redis .mysql-db echo "***Builidng docker"; # Launch Docker containers with rebuild docker compose build && \ docker compose up -d --force-recreate && \ # Change ownership of /app inside container as root docker compose exec -u root coz_jp_web_${APP_ENV} chown -R www-data: /app && \ # Run Laravel optimize command inside container docker compose exec coz_jp_web_${APP_ENV} php artisan optimize # Play terminal bell echo -en "\007" # Send notification about deployment #curl -d "Coz.jp Deployed ${APP_ENV}" https://ntfy update url As always, feel free to suggest corrections.
The Folder Structure
All these files are designed and laid out in a way that allows for their execution in any environment, from local to staging and production. To achieve this, I have laid out the following file structure, which you may have guessed from the run.sh script.
This structure means I can just cd into an environment's directory and run the script to deploy the entire project easily.
- project root - - Git source folder (coz.jp in my case) - - Stage env directory - - - .env - - - run.sh - - - .mysql-db - - - .redis - - - .storage - - Prod env directory - - - .env - - - run.sh - - - .mysql-db - - - .redis - - - .storage Using this configuration and structure makes it so that just by going to the environment directory and executing the previously mentioned command, I can easily deploy the project.
Thank you for reading this far! If you found this helpful, why not give it a like below or share it with a fellow developer who might benefit? I run this blog and share these guides for free. This infrastructure took me weeks to perfect, building on years of experience with similar setups using PHP-FPM, Apache and Traefik. ❤️