Containers

A multi-stage image that ships no build tools, runs as a non-root user, keeps mutable state outside, and runs the worker separately.

Syntaxdocker run --env-file .env.production -e LOG_CHANNEL=stderr shop:1.4.2 php artisan queue:work

A container image is your code and its dependencies, frozen. Anything that changes while the application runs has to live outside it, because the container's filesystem goes away when the container is replaced.

Two stages

The first stage has Composer and runs composer install --no-dev --optimize-autoloader. The second stage is the runtime and copies vendor plus the application out of the first, so the shipped image contains no Composer and no build toolchain. The runtime is either a PHP-FPM image with Nginx in front of it, or FrankenPHP, a PHP application server that serves HTTP itself and starts with frankenphp php-server -r public/. In both cases the document root is public/, never the project root, because the project root holds .env.

Non-root, and what stays out

Add USER www-data after the copies, and make storage and bootstrap/cache writable by that user. Outside the image: the .env file, since values are injected as environment variables; uploads, on an object-storage disk; sessions and cache, in the database or Redis; and logs, on stderr.

The worker is its own container

Same image, different command: php artisan queue:work. Backgrounding it inside the web container hides its exit code and its memory use, and the number of web replicas you need has nothing to do with the number of workers you need.

Example

Example · php
<?php
// bootstrap/app.php

use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
    )
    ->withMiddleware(function (Middleware $middleware): void {
        /*
         * A container never sees the client directly. Without this,
         * $request->ip() returns the proxy's address, $request->isSecure()
         * is false, and url() and route() generate http:// links that
         * browsers then block on an https:// page.
         *
         * trustProxies(array|string|null $at = null, ?int $headers = null).
         * '*' trusts whatever forwarded the request, which is safe only
         * when the ingress is the single route into the container.
         */
        $middleware->trustProxies(at: '*');
    })
    ->withExceptions(function (Exceptions $exceptions): void {
        $exceptions->shouldRenderJsonWhen(
            fn (Request $request) => $request->is('api/*') || $request->expectsJson(),
        );
    })->create();

When to use it

  • Building an image for a Laravel 13 application that a CI pipeline can push on every merge.
  • Cutting an image from 1.2 GB to a few hundred megabytes by dropping the build stage.
  • Moving uploads off the container filesystem after they vanished on the last deploy.
  • Scaling queue workers without scaling web servers.

More examples

The image

The --no-scripts --no-autoloader pair exists because Laravel's post-install Composer scripts call artisan, and artisan needs the application files that have not been copied yet. The two-step install also keeps the dependency layer cacheable: edit a Blade file and only the COPY . . layer is rebuilt. Laravel 13 needs PHP 8.3 or newer, so 8.5 is in range.

Example · dockerfile
# syntax=docker/dockerfile:1

# Stage 1: dependencies. artisan cannot run yet, so install without
# scripts and without the autoloader, then build the autoloader last.
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader \
    --prefer-dist --no-interaction --no-progress
COPY . .
RUN composer dump-autoload --no-dev --optimize --classmap-authoritative

# Stage 2: runtime. No Composer, no git, no node.
FROM php:8.5-fpm-alpine
RUN docker-php-ext-install pdo_mysql opcache
COPY docker/opcache.ini /usr/local/etc/php/conf.d/opcache.ini
COPY docker/entrypoint.sh /usr/local/bin/entrypoint
WORKDIR /var/www/html
COPY --from=vendor --chown=www-data:www-data /app .
RUN chmod -R ug+w storage bootstrap/cache
USER www-data
EXPOSE 9000
ENTRYPOINT ["entrypoint"]
CMD ["php-fpm"]

Caching config at start, not at build

The four commands are what php artisan optimize runs, spelled out; optimize is the shorthand for exactly this list. Writing them out here makes it obvious which one to drop if a package misbehaves under route caching. Nothing in this script writes to the application directory except bootstrap/cache, which is why that directory must stay writable.

Example · shell
#!/bin/sh
# docker/entrypoint.sh
set -e

# The real environment variables exist now, injected by the runtime.
# Build time they did not, which is why this is not in the Dockerfile.
php artisan config:cache
php artisan event:cache
php artisan route:cache
php artisan view:cache

# Hand control to the container's command: php-fpm, or queue:work.
exec "$@"

Web and worker from one image

One image, three commands. stop_grace_period matters: a worker given SIGTERM finishes its current job before exiting, and a grace period shorter than your longest job kills it mid-flight. Use this shape locally so that the thing you test is the thing you deploy.

Example · yaml
# docker-compose.yml
services:
  web:
    image: shop:1.4.2
    env_file: .env.production
    environment:
      LOG_CHANNEL: stderr
    ports: ['8080:9000']

  worker:
    image: shop:1.4.2
    env_file: .env.production
    environment:
      LOG_CHANNEL: stderr
    # Same code, different process. --max-time recycles the worker so a
    # slow memory leak cannot grow for days.
    command: php artisan queue:work --tries=3 --max-time=3600
    stop_grace_period: 60s

  scheduler:
    image: shop:1.4.2
    env_file: .env.production
    command: php artisan schedule:work

Discussion

  • Be the first to comment on this lesson.