
Table of Contents
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.
builder stage to install Composer dependencies and compile assets, then use a fresh COPY --from=builder instruction in a minimal runtime stage (like php:8.4-fpm-alpine). This discards build tools, dev packages, and intermediate layers, resulting in a secure, optimized production artifact.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.
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:
- Purge build dependencies after extension compilation: Notice the
apk del --purgecommand above. Development headers (-devpackages) are needed to compile PHP extensions but are useless at runtime. Removing them in the sameRUNinstruction prevents them from being stored in the layer history. - Use specific base image tags: Never use
php:latestorphp:8-fpm. Always pin to a specific version likephp:8.4.3-fpm-alpine. Reproducible builds are a core requirement for SOC 2 compliance and reliable rollbacks. - Combine RUN instructions wisely: Each
RUNcreates a new layer. Combine related commands with&&to reduce metadata overhead, but don’t combine unrelated steps that would break cache granularity. - Exclude unnecessary files via .dockerignore: Your
.gitdirectory, local.envfiles, IDE configs, and test suites should never enter the build context. A proper.dockerignorespeeds up the context transfer and prevents accidental secret leakage.