Docker tutorials usually stop at "write a docker, run a container, done." But the difference between a container that works and a container that works well comes down to a handful of concepts that rarely get explained in plain language. Here they are, with the PHP-specific angles that most generic Docker guides skip.
#Multi-stage builds: the messy kitchen, clean dining room trick
The problem: to build your app you need Composer, Node, npm packages and dev dependencies. But shipping all of that to production is like inviting dinner guests into your kitchen mid-cooking. Your production image ends up carrying gigabytes of tools it will never use.
The fix: multi-stage builds let you do the messy work in one image, then copy only the finished result into a clean, small final image.
1FROM composer:2 AS vendor
2COPY composer.json composer.lock ./
3RUN composer install --no-dev --optimize-autoloader
4
5FROM node:20 AS assets
6COPY package*.json ./
7RUN npm ci
8COPY resources/ resources/
9RUN npm run build
10
11FROM php:8.3-fpm-alpine
12COPY --from=vendor /app/vendor /var/www/vendor
13COPY --from=assets /app/public/build /var/www/public/build
14COPY . /var/www
The final image never contains Node, npm or Composer at all. It is not unusual for images to shrink from 1.5 GB to 150 MB with this change alone. Smaller images deploy faster, start faster and have a smaller attack surface.
#Layer caching: order your docker like a pyramid of stability
The problem: your builds take ten minutes because every code change re-downloads all your Composer dependencies.
The fix: understand how the cache works. Docker caches each instruction as a layer, and one changed layer invalidates everything below it. So put things that rarely change (system packages, PHP extensions) at the top, and things that change constantly (your source code) at the bottom.
The classic PHP mistake looks like this:
1COPY . .
2RUN composer install
Every time any file changes, the COPY . . layer is invalidated, which invalidates composer install below it, and all your dependencies get reinstalled. The correct order:
1COPY composer.json composer.lock ./
2RUN composer install --no-dev --optimize-autoloader
3COPY . .
Now composer install only re-runs when the lock file actually changes. A ten-minute build becomes thirty seconds.
#BuildKit cache mounts: a persistent pantry for Composer
The problem: even with good layer ordering, when composer.lock does change, Composer re-downloads every single package from scratch.
The fix: cache mounts. They give the build a persistent folder that survives between builds, like a pantry that stays stocked between cooking sessions.
1RUN --mount=type=cache,target=/root/.composer/cache \
2 composer install --no-dev --optimize-autoloader
Composer's download cache lives outside the image, so only genuinely new packages get fetched over the network. The same trick works for npm:
1RUN --mount=type=cache,target=/root/.npm \
2 npm ci
The cache is never included in the final image, so there is no size penalty.
#PID 1 and graceful shutdown: why your deploys drop requests
The problem: every time you deploy, a few requests fail or a queue job dies halfway through, and you cannot figure out why.
The fix: understand what happens when a container stops. Inside a container, your main process runs as PID 1, and PID 1 has a strange special status in Linux: it does not get default signal handlers. When Docker (or Forge, or Kubernetes) stops your container, it sends a SIGTERM signal and waits, usually 10 seconds. If your process never receives that signal properly, Docker gives up and kills it mid-request.
The most common cause is launching your process through a shell script:
1#!/bin/sh
2php-fpm
Here the shell is PID 1 and PHP-FPM is its child. The SIGTERM goes to the shell, which ignores it, and PHP-FPM never hears a thing. The fix is one word:
1#!/bin/sh
2exec php-fpm
exec replaces the shell with PHP-FPM, so PHP-FPM becomes PID 1 and receives signals directly. Alternatively, run containers with --init to get a tiny init process that forwards signals correctly.
This matters doubly for Laravel queue workers. queue:work handles SIGTERM gracefully by finishing the current job before exiting, but only if the signal actually reaches it. Get this wrong and jobs die mid-execution on every deploy.
#OPcache in containers: turn off the paranoia
The problem: you are leaving free performance on the table because OPcache keeps checking whether your files have changed.
The fix: by default, OPcache checks file timestamps to see if code changed since it was compiled. That makes sense on a traditional server where you deploy by copying files. But in a container, code never changes after deploy; you ship a new image instead. So tell OPcache to stop checking:
1opcache.validate_timestamps=0
PHP compiles each file exactly once and never touches the disk for it again. This is free performance, and it only makes sense because containers are immutable.
One warning: remember this setting exists. If you enable it in local development where your code is volume-mounted, you will be very confused about why your edits do nothing. Keep timestamp validation on in dev, off in production images.
#Configuration through environment, not baked in
The problem: you maintain separate images for staging and production, and they occasionally drift apart in ways that cause "works in staging" bugs.
The fix: build one image and treat it as a frozen meal. Environment variables are the seasoning added when serving. The same image gets promoted from staging to production unchanged, configured at runtime through env vars.
Laravel mostly supports this out of the box via .env variables, but there is one gotcha: php artisan config:cache bakes environment values into a file. If you run it in the docker at build time, you freeze the build machine's environment into the image. Run it in the entrypoint at container start instead, after the real environment variables are available:
1#!/bin/sh
2php artisan config:cache
3php artisan route:cache
4exec php-fpm
#The .dockerignore file everyone forgets
The problem: builds are slow, images are bloated, and your local .env file with real credentials just got copied into an image layer.
The fix: without a .dockerignore, COPY . . ships your entire project folder into the build context, including node_modules, the full .git history, local environment files and storage logs. A good starting point for Laravel:
1.git
2node_modules
3vendor
4.env
5.env.*
6storage/logs/*
7tests
8.phpunit.cache
This makes builds faster (less data to send to the Docker daemon), keeps layers lean, and avoids the genuinely dangerous case of leaking secrets into image layers that anyone with access to the image can extract.
#Do not run as root
The problem: default containers run everything as root. If your app gets compromised, the attacker has a root shell inside the container, which makes escaping to the host much easier.
The fix: create an unprivileged user and switch to it:
1RUN adduser -D -u 1000 app
2RUN chown -R app:app /var/www/storage /var/www/bootstrap/cache
3USER app
The Laravel wrinkle is that storage/ and bootstrap/cache/ must be writable by the application, so chown them before switching users. Everything after the USER instruction, including the running container, executes as the unprivileged user.
#Healthchecks that check the right thing
The problem: a container can be "running" while PHP-FPM inside it is deadlocked or out of workers. Your orchestrator happily keeps routing traffic to a zombie.
The fix: a HEALTHCHECK that verifies the application actually responds, not just that the process exists. For web containers, hit a real endpoint or use the FPM status page (the php-fpm-healthcheck script wraps this nicely):
1HEALTHCHECK --interval=10s --timeout=3s \
2 CMD php-fpm-healthcheck || exit 1
Queue workers have no HTTP to check, so a common trick is having the worker touch a file periodically and the healthcheck verifying the file is fresh:
1HEALTHCHECK CMD test $(find /tmp/worker-alive -mmin -1 | wc -l) -eq 1
Now your orchestrator restarts broken containers instead of pretending they are fine.
#One process per container, and why PHP-FPM makes that awkward
The problem: the Docker philosophy says one process per container, but a classic PHP setup needs both nginx and PHP-FPM, which talk to each other over FastCGI.
The fix: there are two legitimate approaches, and it is worth knowing the tradeoff rather than cargo-culting either one.
The "pure" approach runs nginx and PHP-FPM as separate containers, sharing the code either through a volume or by baking the code into both images. They scale and update independently, and logs stay cleanly separated. The cost is deployment complexity: two images to build, two containers to coordinate.
The pragmatic approach runs both in one container under supervisord. It is simpler to deploy and reason about, at the cost of the two processes restarting together and slightly muddier logs. Plenty of production systems run this way without regret.
Laravel Octane sidesteps the whole question: the application serves HTTP itself through Swoole or FrankenPHP, so there is genuinely just one process and the container model fits perfectly.
#Wrapping up
None of these tricks is exotic on its own, but together they are the difference between "we put PHP in Docker" and "our containers are small, fast to build, restart cleanly and fail loudly." If you adopt only two, make them multi-stage builds and the exec fix for signal handling. The first shrinks your images by an order of magnitude; the second stops your deploys from quietly eating requests and queue jobs.