Docker images for PHP applications have a reputation for ballooning past 1 GB. The main culprits are build tools, development packages, and layer cache bloat that never gets trimmed. Multi-stage builds solve this cleanly — you build in one stage and copy only the runtime artefacts into a lean final image.

The Problem

A typical single-stage PHP Dockerfile pulls in Composer, Node, npm, build-essential, and assorted dev headers. All of that ends up in the final image even though none of it is needed at runtime. Our starting point was a 1.2 GB image for a medium Laravel application.

The Multi-Stage Approach

The trick is to separate build dependencies from runtime dependencies. Each FROM statement starts a new stage; the final stage only receives what you explicitly COPY --from.

# Stage 1: Composer dependencies
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --optimize-autoloader --no-scripts

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

# Stage 3: Runtime image
FROM php:8.3-fpm-alpine
WORKDIR /var/www/html
COPY . .
COPY --from=vendor /app/vendor ./vendor
COPY --from=assets /app/public/build ./public/build
RUN php artisan config:cache && php artisan route:cache

Results

After applying this pattern the image dropped from 1.2 GB to 180 MB — an 85% reduction. Build time stayed roughly the same because Docker layer caching means the vendor and asset stages only rebuild when their respective lock files change.

What to Watch Out For

  • Run composer install --no-scripts in the vendor stage to avoid running artisan commands before the full app is assembled
  • Don't copy .env into the image — inject environment variables at runtime
  • The php artisan config:cache in the runtime stage bakes your config at build time; make sure all required env vars are set in your CI environment