All Articles
25 Jul 2026 6 min read 3 views
Laravel

Docker Multi-Stage Builds for PHP: Smaller, Faster Images

Learn how Docker multi-stage builds cut PHP/Laravel image size and build time — full Dockerfile examples, layer caching tips, and common mistakes.

Tushar Modi.
Tushar Modi.
July 25, 2026 · Jaipur, India
6 min 3
Category Laravel
Published Jul 25, 2026
Read 6 min
Views 3
Updated Jul 25, 2026
Docker Multi-Stage Builds for PHP: Smaller, Faster Images

Docker Multi-Stage Builds for PHP: Smaller, Faster Images

A single-stage Dockerfile for a PHP app usually ships Composer, Node, npm packages, dev dependencies, and your entire build toolchain straight into the production image — none of which your app needs at runtime. Multi-stage builds fix this: build in one stage, copy only the finished output into a clean runtime stage, and throw everything else away.

Table of Contents

  1. The Problem With Single-Stage Dockerfiles
  2. How Multi-Stage Builds Actually Work
  3. A Complete Multi-Stage Dockerfile for Laravel
  4. Why the Image Size Difference Is So Large
  5. Optimizing Layer Caching for Faster Builds
  6. Choosing a Base Image: Alpine vs Debian-Slim
  7. Using .dockerignore Correctly
  8. Named Build Stages for Local Development
  9. Common Mistakes
  10. Wrapping Up

1. The Problem With Single-Stage Dockerfiles

A typical naive PHP Dockerfile looks like this:


dockerfile

FROM php:8.3-fpm

RUN apt-get update && apt-get install -y nodejs npm git unzip
COPY . .
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
RUN composer install
RUN npm install && npm run build

This works, but the final image now permanently contains: Node.js, npm, every node_modules package, Composer itself, Git, and every dev dependency Composer installed. None of that is needed once assets are built and the autoloader is generated — it's dead weight shipped to every server, in every deploy, forever.

2. How Multi-Stage Builds Actually Work

A multi-stage Dockerfile uses multiple FROM statements, each starting a fresh, isolated stage. Later stages can selectively COPY --from=<stage> specific files from earlier stages — pulling out only the finished artifacts (compiled assets, vendor folder) while leaving every build tool behind in a stage that never ships anywhere.


dockerfile

FROM node:20 AS frontend-build
# ... build frontend assets here

FROM composer:2 AS composer-build
# ... install PHP dependencies here

FROM php:8.3-fpm AS runtime
# ... copy ONLY the finished output from the stages above

Docker builds every stage, but only the last stage (or whichever one you target) becomes the final image that gets tagged, pushed, and deployed. Everything in earlier stages simply doesn't exist in the output.

3. A Complete Multi-Stage Dockerfile for Laravel


dockerfile

# ---- Stage 1: Build frontend assets ----
FROM node:20-alpine AS frontend-build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY resources/ resources/
COPY vite.config.js ./
RUN npm run build

# ---- Stage 2: Install PHP dependencies ----
FROM composer:2 AS composer-build
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist
COPY . .
RUN composer dump-autoload --optimize --no-dev

# ---- Stage 3: Final runtime image ----
FROM php:8.3-fpm-alpine AS runtime
WORKDIR /var/www

RUN apk add --no-cache libpng libzip freetype icu-libs \
    && docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd

COPY --from=composer-build /app/vendor/ /var/www/vendor/
COPY --from=frontend-build /app/public/build/ /var/www/public/build/
COPY . /var/www

RUN chown -R www-data:www-data /var/www/storage /var/www/bootstrap/cache

USER www-data
EXPOSE 9000
CMD ["php-fpm"]

Notice the final stage never installs Node, npm, or Composer — it only ever receives the two things it actually needs to run: the vendor/ folder and the compiled public/build/ assets, copied in from stages that get discarded entirely once the build finishes.

4. Why the Image Size Difference Is So Large

A single-stage image that includes Node, npm, node_modules, Composer, and Git on top of a PHP base can easily land at 900MB–1.2GB. The equivalent multi-stage build, shipping only PHP-FPM, the compiled vendor/ folder, and built frontend assets on an Alpine base, typically lands between 150–250MB — often a 5–7x reduction.

That difference compounds across your whole pipeline: faster image pushes/pulls in CI, faster deploys, less storage cost in your container registry, and a meaningfully smaller attack surface since tools like Git and npm — potential vectors if a container is ever compromised — simply aren't present in the running image at all.

5. Optimizing Layer Caching for Faster Builds

Docker caches each layer and skips rebuilding it if nothing that layer depends on has changed. Order matters — copy files that change less often earlier:


dockerfile

# Good: composer.json/lock copied BEFORE the rest of the app
COPY composer.json composer.lock ./
RUN composer install --no-dev
COPY . .

If you copy the entire app first and then run composer install, Docker invalidates the Composer layer on every single code change, even ones that don't touch composer.json — forcing a full dependency reinstall on every build. Copying the lock files first means Composer's install layer stays cached until dependencies actually change, which is usually the vast majority of your commits.

The same principle applies to the frontend stage — copy package.json/package-lock.json before the rest of resources/, so npm ci stays cached unless dependencies change.

6. Choosing a Base Image: Alpine vs Debian-Slim

  • Alpine (php:8.3-fpm-alpine) — smallest option, uses musl libc instead of glibc. Great for size, but occasionally hits compatibility issues with certain PHP extensions or compiled dependencies that expect glibc.
  • Debian-slim (php:8.3-fpm) — slightly larger, but broader compatibility with PHP extensions and fewer surprises if you're installing less common packages.

For most Laravel apps, Alpine works fine and is worth defaulting to. If you hit an obscure extension compilation error that traces back to musl vs glibc differences, that's the signal to switch that specific stage to Debian-slim rather than fighting the incompatibility.

7. Using .dockerignore Correctly

Without a .dockerignore, Docker sends your entire project directory — including .git, node_modules, and vendor from your local machine — to the build context on every single build, slowing things down and risking stale local dependencies leaking into a stage that expects a clean install:


.git
node_modules
vendor
.env
storage/logs/*
storage/framework/cache/*
tests
.github
*.md

This one file often has more impact on build speed than most other optimizations combined, simply because it stops gigabytes of irrelevant files from being sent to the Docker daemon in the first place.

8. Named Build Stages for Local Development

You can target an earlier stage directly for local development, where you actually want dev dependencies and hot-reloading:


bash

docker build --target frontend-build -t myapp-dev-assets .

Or maintain a separate development stage with composer install (no --no-dev flag) and Xdebug installed, while your production build targets the lean runtime stage — one Dockerfile, two very different outputs depending on which stage you build.

9. Common Mistakes

  • Copying the whole app before installing dependencies — kills layer caching, turns every commit into a full dependency reinstall
  • Installing dev dependencies in the runtime stage — Xdebug, Faker, and testing libraries have no business in a production image
  • Forgetting --no-dev on composer install — silently ships your entire dev toolchain into production
  • Running as root in the final image — always drop to a non-root user (www-data or similar) for the actual running container
  • Skipping .dockerignore — slow builds and occasional stale-file bugs that are confusing to debug
  • Not pinning base image versionsphp:8.3-fpm-alpine today isn't guaranteed to be identical six months from now; pin to a specific patch version for reproducible builds where it matters

10. Wrapping Up

Multi-stage builds aren't an advanced technique reserved for large teams — they're close to a default best practice at this point for any PHP or Laravel app going into a container. The Dockerfile ends up slightly longer, but the payoff is a production image that's smaller, faster to deploy, and doesn't carry your entire build toolchain into an environment where none of it should exist in the first place.