Dockerize a PHP App with Multi-Stage Builds

Khimananda Oli 5 min read Programming and Languages
Dockerize a PHP App with Multi-Stage Builds

By Khimananda Oli | Last reviewed: August 2026

Shipping bloated PHP containers is one of the most common infrastructure inefficiencies I see in audits. When you Dockerize a PHP app with multi-stage builds, you separate build-time dependencies from the runtime environment, often reducing final image size by 70–90%. This isn't just about storage costs; smaller images mean faster CI/CD pipelines, quicker horizontal scaling, and a significantly reduced attack surface. If you are still copying your entire vendor directory and source code into a single fat layer, this guide will show you the production-grade alternative.

Why should you Dockerize a PHP app with multi-stage builds instead of single-stage?

A single-stage Dockerfile treats your container as both a factory and a warehouse. You install Git, unzip, Node.js, and hundreds of development-only Composer packages just to build the application, and then all of that remains in the final image. In practice, this leads to three critical failures:

  • Security bloat: Every extra binary is a potential vulnerability. Tools like Trivy or Grype will flag dozens of CVEs in build tools that have no business existing in production.
  • Slow deployments: Pushing and pulling 1GB+ images over constrained networks (common in hybrid setups across Nepal or edge locations) adds minutes to every deploy cycle.
  • Cache invalidation: Changing a single line of code forces a reinstall of all dependencies if layers aren't structured correctly.
Single-Stage (Legacy)OS + Build Tools (Git, Node)Dev Dependencies (phpunit, faker)Source Code + VendorCompiled AssetsWasted Layers (~800MB)Final Size: ~1.2 GBMulti-Stage (Production)Stage 1: Builder (Discarded)Composer Install / NPM BuildRuntime Base (Alpine/FPM)Prod Vendor Only (--no-dev)App Source + Compiled AssetsFinal Size: ~180 MB
Single-stage builds retain unnecessary build artifacts, while multi-stage builds discard them to minimize the final PHP container footprint.

Multi-stage builds solve this by treating the build environment as ephemeral. Only the compiled artifacts and production dependencies cross the boundary into the final image. For teams managing Laravel production deployments, this distinction is often the difference between passing a security audit and spending weeks remediating findings.

How do you write a multi-stage Dockerfile for Laravel or Symfony?

The key to an efficient Dockerfile is layer ordering and explicit separation of concerns. Below is a battle-tested pattern for a modern Laravel application using PHP 8.4 FPM on Alpine. This approach works equally well for Symfony or Slim frameworks with minor path adjustments.

# Stage 1: Build dependencies and assets
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist

FROM node:20-alpine AS assets
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --production=false
COPY resources ./resources
COPY vite.config.js ./
RUN npm run build

# Stage 2: Production Runtime
FROM php:8.4-fpm-alpine AS production
ARG WWWGROUP=1000
ARG WWWUSER=1000

# Install only required PHP extensions
RUN apk add --no-cache \
    nginx \
    supervisor \
    libpng-dev \
    libjpeg-turbo-dev \
    freetype-dev \
    && docker-php-ext-configure gd --with-freetype --with-jpeg \
    && docker-php-ext-install -j$(nproc) gd pdo_mysql opcache \
    && apk del --purge libpng-dev libjpeg-turbo-dev freetype-dev

# Create non-root user
RUN addgroup -g $WWWGROUP sail && \
    adduser -u $WWWUSER -G sail -D -s /bin/sh sail

WORKDIR /var/www/html

# Copy built artifacts from previous stages
COPY --from=vendor /app/vendor ./vendor
COPY --from=assets /app/public/build ./public/build
COPY . .

# Generate optimized autoloader
RUN composer dump-autoload --optimize --no-dev

# Set permissions and ownership
RUN chown -R sail:sail /var/www/html/storage /var/www/html/bootstrap/cache

USER sail
EXPOSE 8000
CMD ["php-fpm"]

Optimizing Composer installation order

Notice that we copy composer.json and composer.lock before copying the rest of the source code. This leverages Docker’s layer caching. Unless your dependency manifest changes, subsequent builds skip the expensive composer install step entirely. In my experience optimizing CI pipelines for Nepali fintech startups, this single change reduced average build times from 4 minutes to under 45 seconds for code-only changes.

Handling frontend assets separately

Frontend toolchains (Node, Vite, Webpack) are notoriously heavy. By isolating them in an assets stage, you ensure that node_modules never touches your PHP runtime image. The compiled output in public/build is typically just a few megabytes of static files, compared to the 300MB+ Node ecosystem required to generate them.

What are the best practices for optimizing PHP container layers?

Writing a multi-stage Dockerfile is step one; writing an efficient one requires understanding how Docker caches and stores layers. Here are the practices I enforce during code reviews:

  1. Purge build dependencies after extension compilation: Notice the apk del --purge command above. Development headers (-dev packages) are needed to compile PHP extensions but are useless at runtime. Removing them in the same RUN instruction prevents them from being stored in the layer history.
  2. Use specific base image tags: Never use php:latest or php:8-fpm. Always pin to a specific version like php:8.4.3-fpm-alpine. Reproducible builds are a core requirement for SOC 2 compliance and reliable rollbacks.
  3. Combine RUN instructions wisely: Each RUN creates a new layer. Combine related commands with && to reduce metadata overhead, but don’t combine unrelated steps that would break cache granularity.
  4. Exclude unnecessary files via .dockerignore: Your .git directory, local .env files, IDE configs, and test suites should never enter the build context. A proper .dockerignore speeds up the context transfer and prevents accidental secret leakage.
composer.jsonLayer 1 (Cached)composer installLayer 2 (Cached)Source Code ChangeLayer 3 (Rebuilt)COPY . .Layer 4 (Rebuilt)Cache Behavior Explained✓ Layers 1 & 2 remain cached if lock file unchanged✗ Layer 3 invalidates cache for itself and all below⚡ Result: Dependency install skipped on code-only commits

Frequently Asked Questions

It uses multiple FROM statements to separate dependency installation from the final runtime image, keeping production containers small and secure by excluding build tools.

Single-stage images retain compilers and dev libraries, inflating size and attack surface. Multi-stage builds discard these artifacts, producing minimal production images focused solely on runtime execution.

Use php:8.4-cli-alpine or php:8.4-fpm-alpine for the final stage. The alpine variant reduces image size significantly compared to Debian-based images while maintaining compatibility with most Laravel applications.

Copy composer.json and composer.lock first, run composer install --no-dev --optimize-autoloader in the build stage, then copy only the vendor directory to the final stage to maximize layer caching.

Initial builds take slightly longer due to extra stages, but subsequent builds are faster because dependency layers cache independently. Net pipeline time typically decreases after the first successful build.

No. Debugging tools belong in development stages only. Use xdebug in a separate dev Dockerfile target or attach debuggers locally, never in production multi-stage final images.

Bake default configs into the image during build, then override at runtime using environment variables or mounted config files. Never embed secrets or environment-specific values during the build stage.

Forgetting .dockerignore causes bloated contexts. Copying entire source before installing dependencies invalidates cache. Missing --no-dev flag includes unnecessary packages. Always validate each stage independently before combining.

Final images exclude package managers, compilers, and shell utilities. This eliminates entire vulnerability classes since attackers cannot install exploits or modify system binaries without these tools present.

Yes. BuildKit enables parallel stage execution, better caching, and secret mounting. Enable it via DOCKER_BUILDKIT=1 or docker buildx for significantly faster PHP application builds in 2026.

Remove unnecessary PHP extensions, use upx compression on binaries, clean apk cache in alpine images, and avoid copying documentation or test files into the final runtime stage.

Yes. Define shared dependencies in an early base stage, then reference it using FROM base AS name in subsequent stages. This avoids redundant installations across compiler and runtime phases.

Run PHPUnit or Pest in an intermediate test stage that extends the build stage. Only promote artifacts to the final stage if tests pass, preventing broken code from reaching production.

Extensions like gd, intl, and bcmath need system libraries installed in the build stage. Install build dependencies, compile extensions, then copy only the compiled .so files to the final alpine stage.

Structure Dockerfile so composer.json copy precedes source code copy. Changing application code won't invalidate the dependency layer. Only modify lock file contents trigger full dependency reinstallation.