PHP #

PHP is one of the most common languages on the web, and also one of the most frequently misused in Docker. PHP images in production are often > 500 MB, full of build tools, and mixing dev and runtime concerns. Yet PHP can be made very slim, secure, and production-worthy — on par with Node.js or Python, and even approaching Go — if treated correctly.

This article discusses small, production-grade PHP Docker image strategies with an engineering approach, not just a php:apache tutorial. We’ll look at why PHP images bloat easily, the right strategies (multi-stage + PHP-FPM + distroless), and the trade-offs to understand.

1. The Reality of PHP Image Sizes #

Let’s look at realistic production numbers.

SetupImage Size
php:apache (default)600-800 MB
php:fpm + debian400-600 MB
php:8.3-fpm-alpine120-200 MB
Multi-stage + slim90-150 MB
Multi-stage + distroless60-100 MB

Insight: The difference between 800 MB and 60 MB is more than 10x. Optimal PHP images aren’t impossible — they just need Dockerfile discipline different from common habits.

2. Why PHP Images Bloat Easily #

PHP = Runtime + Web Server + Extensions #

The php:apache image (or php:*-apache) carries Apache + the PHP module + many extensions. For modern production, that’s wasteful. Apache consumes memory and CPU that could go to the application.

A more modern approach: separate the web server from the PHP process. PHP-FPM runs as a separate service, with Nginx (or Caddy) in front as a reverse proxy. This is more scalable and slimmer.

PHP Extensions Bloat the Image #

PHP has many extensions that are often needed:

  • pdo_pgsql / pdo_mysql — database drivers.
  • gd — image processing.
  • intl — internationalization.
  • zip — archives.
  • bcmath — high-precision math.
  • opcache — bytecode cache.

Each extension pulls its own OS dependencies. Without control, everything ends up in the image.

Composer Leaking into Runtime #

Composer is PHP’s dependency manager. Without separating build vs runtime, Composer and its caches end up in the final image — even though runtime doesn’t need Composer at all.

Full php:* Default Images #

The php:latest image (or php:8.3) carries a full Debian with many utilities. For production, php:8.3-fpm-alpine or php:8.3-fpm-slim are slimmer alternatives.

3. The Main Principle: FPM Only, Distroless, Composer Separated #

An ideal PHP runtime image contains only: PHP-FPM + production extensions + application source code.

Full stop. No Apache, no Composer, no development extensions, no test frameworks, no documentation.

Three pillars to achieve it:

  1. Multi-stage builds — separate build (needs Composer + dev extensions) from runtime (needs FPM + prod extensions).
  2. PHP-FPM, not mod_php — FPM is more scalable, and php:*-fpm images don’t carry Apache.
  3. Distroless base for runtime — a JRE-like, very slim image without a shell.

4. Multi-Stage Strategy with Composer #

4.1 The Basic Pattern: FPM + Alpine #

# ==== Stage 1: Composer Install ====
FROM composer:2.7 AS vendor

WORKDIR /app

# Copy dependency files first (for caching)
COPY composer.json composer.lock ./
RUN composer install \
    --no-dev \
    --no-scripts \
    --no-autoloader \
    --prefer-dist \
    --no-interaction

# ==== Stage 2: Build Assets (if using Laravel) ====
FROM node:20-alpine AS assets

WORKDIR /app
COPY package*.json ./
RUN npm ci

COPY . .
RUN npm run build

# ==== Stage 3: Runtime ====
FROM php:8.3-fpm-alpine

WORKDIR /app

# Install production extensions
RUN apk add --no-cache \
    libpq \
    icu-libs \
    oniguruma \
    libzip \
    libpng \
    libjpeg-turbo \
    freetype

# Install PHP extensions
RUN docker-php-ext-install -j$(nproc) \
    pdo \
    pdo_pgsql \
    intl \
    zip \
    opcache \
    bcmath

# Copy Composer dependencies
COPY --from=vendor /app/vendor /app/vendor

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

# Copy source code
COPY . .

# Copy built assets
COPY --from=assets /app/public/build /app/public/build

# Optimize Laravel (if used)
RUN php artisan config:cache \
 && php artisan route:cache \
 && php artisan view:cache

# Non-root user
RUN addgroup -g 1001 -S appgroup \
 && adduser -u 1001 -S appuser -G appgroup
USER appuser

EXPOSE 9000
CMD ["php-fpm"]

Typical size: 90-150 MB.

Key explanations:

Stage 1: Composer

  • composer:2.7 — the official Composer image, small and self-contained.
  • composer install --no-dev — installs only production dependencies, skipping dev (testing, debug tools).
  • --no-scripts — skips scripts that might run during install (e.g. Laravel post-install).
  • --no-autoloader — skips generating the autoloader; it’ll be generated in the runtime stage for optimization.

Stage 2: Assets (optional, only for Laravel using Vite/Mix)

  • Build JavaScript and CSS here.
  • The public/build output is copied to the runtime stage.

Stage 3: Runtime

  • php:8.3-fpm-alpine — FPM on Alpine, already far slimmer than php:apache.
  • docker-php-ext-install — the official script the PHP images provide for installing extensions.
  • -j$(nproc) — parallel compilation to speed up the build.
  • composer dump-autoload with --optimize and --classmap-authoritative for a faster autoloader.
  • php artisan config:cache etc. — pre-compiles configuration, routes, and views.

When to use: The default for production PHP services. The size vs debugging capability trade-off is still balanced — Alpine has a shell.

4.2 The Distroless Version: Mature Production #

# ==== Stage 1: Composer ====
FROM composer:2.7 AS vendor

WORKDIR /app

COPY composer.json composer.lock ./
RUN composer install \
    --no-dev \
    --no-scripts \
    --no-autoloader \
    --prefer-dist

# ==== Stage 2: PHP + Extensions ====
FROM php:8.3-cli-alpine AS builder

WORKDIR /app

RUN apk add --no-cache $PHPIZE_DEPS \
    libpq-dev \
    icu-dev \
    oniguruma-dev \
    libzip-dev \
    libpng-dev \
    libjpeg-turbo-dev \
    freetype-dev

# Build extensions
RUN docker-php-ext-install -j$(nproc) \
    pdo \
    pdo_pgsql \
    intl \
    zip \
    opcache \
    bcmath \
    gd

# Copy Composer deps
COPY --from=vendor /app/vendor /app/vendor
COPY . .

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

# ==== Stage 3: Distroless Runtime ====
FROM gcr.io/distroless/base-debian12:nonroot

WORKDIR /app

# Copy PHP + extensions from the build stage
COPY --from=builder /usr/local /usr/local
COPY --from=builder /app /app

# Copy production OS dependencies
COPY --from=builder /usr/lib/x86_64-linux-gnu/libpq* /usr/lib/x86_64-linux-gnu/
COPY --from=builder /usr/lib/x86_64-linux-gnu/libicu* /usr/lib/x86_64-linux-gnu/

# PHP configuration
ENV PATH="/usr/local/bin:/usr/local/sbin:$PATH"

EXPOSE 9000

USER nonroot:nonroot

# Custom PHP-FPM config
COPY docker/php-fpm.conf /usr/local/etc/php-fpm.d/zz-app.conf
COPY docker/www.conf /usr/local/etc/php-fpm.d/www.conf

CMD ["php-fpm", "-F"]

Typical size: 60-100 MB.

Important notes for distroless PHP:

  • Distroless has no shell, no apt. Everything needed at runtime must be copied from the build stage.
  • PHP CLI + extensions are copied from /usr/local (where official PHP installs on Linux).
  • Needed OS libraries (libpq, libicu) are copied manually from the build stage.
  • The PHP-FPM config must be mounted as files, because it can’t be edited at runtime.
  • php-fpm -F means run in the foreground (important for container PID 1).
  • Interactive debugging isn’t possible — observability must be solid.

When to use: High-maturity production, security-first, size-critical.

5. Choosing a PHP Base Image #

Base ImageSizeNotes
php:8.3-apache600-800 MBApache + mod_php + Debian, don’t use in production
php:8.3-fpm400-600 MBPHP-FPM on Debian, the safe default
php:8.3-fpm-alpine120-200 MBPHP-FPM on Alpine, the best compromise
php:8.3-fpm-slim200-300 MBPHP-FPM on Debian slim
Distroless + custom60-100 MBThe slimmest, needs more setup

Recommendations:

  • Default: php:8.3-fpm-alpine — reasonable size, debugging capability.
  • High-maturity production: Distroless — small size, solid observability.
  • Development: php:8.3-cli-alpine — lightweight, Composer built in.

6. Extension Management #

Installing Official Extensions #

The official PHP images provide the docker-php-ext-install script for installing extensions bundled with the PHP source:

RUN docker-php-ext-install \
    pdo \
    pdo_pgsql \
    intl \
    zip \
    opcache

Installing Extensions with PECL #

For extensions not bundled (e.g. Redis, APCu, Imagick), use PECL:

RUN pecl install redis-6.0.2 \
 && docker-php-ext-enable redis

Installing from Source #

For custom extensions, compile from source:

RUN apk add --no-cache $PHPIZE_DEPS \
    librdkafka-dev \
 && pecl install rdkafka \
 && docker-php-ext-enable rdkafka \
 && apk del $PHPIZE_DEPS

Important: Always clean up $PHPIZE_DEPS after installing to shrink the image.

Production vs Dev Extensions #

Separate runtime-required extensions from development-only ones:

Mandatory for production:

  • opcache — bytecode cache, mandatory for performance.
  • pdo + database drivers (pdo_pgsql, pdo_mysql).
  • intl — internationalization.
  • mbstring — string handling.
  • bcmath — precision math.
  • zip / gd / imagick — per application needs.

Development only (don’t use in production):

  • xdebug — debugger, adds ~10 MB.
  • pcov / xdebug — code coverage.
  • dev packages from apk add (like gcc, make).

7. Composer Optimization #

Production Install Flags #

composer install \
  --no-dev \                  # Skip dev dependencies
  --no-scripts \              # Skip composer scripts
  --prefer-dist \             # Use dist archives, not git clones
  --classmap-authoritative \  # Strict autoloader, faster
  --no-interaction \          # Non-interactive mode
  --optimize-autoloader       # Optimize classmaps

Bonus: Add --no-progress for cleaner CI logs.

Autoloader Optimization #

After installing, generate an optimal autoloader:

composer dump-autoload \
  --optimize \                # PSR-0/PSR-4 -> classmap
  --no-dev \                  # Skip dev classes
  --classmap-authoritative    # Classes won't be looked up via PSR-4 anymore

For Laravel, this can lower memory usage and speed up bootstrap.

Autoload Caching in CI #

For faster builds, cache the Composer cache:

# GitHub Actions
- name: Cache Composer
  uses: actions/cache@v4
  with:
    path: ~/.composer/cache
    key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
    restore-keys: ${{ runner.os }}-composer-

8. PHP Frameworks (Laravel / Symfony) #

Laravel Optimization #

In the build stage, run Laravel optimizations:

# Pre-compile config, routes, views
RUN php artisan config:cache \
 && php artisan route:cache \
 && php artisan view:cache

Make sure APP_ENV=production at compile time so the generated caches fit production.

Trick: php artisan optimize runs all the above caches at once.

Important: The runtime image must not have Node.js, npm, or frontend build tools. Assets must be compiled in the build stage and copied to runtime.

Symfony Optimization #

For Symfony:

# Pre-compile the container
RUN APP_ENV=prod php bin/console cache:clear --no-warmup \
 && APP_ENV=prod php bin/console cache:warmup

Or for a stricter production build, use Composer autoloader optimization.

9. PHP-FPM Configuration #

Default PHP-FPM configuration is often not optimal for containers. Create a custom config:

docker/php-fpm.d/zz-app.conf:

[www]
listen = 0.0.0.0:9000
pm = dynamic
pm.max_children = 10
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 5
pm.max_requests = 1000

Container tuning:

  • pm.max_children — must fit the container memory limit. Formula: (container_memory_limit) / (avg_php_memory_per_child).
  • pm.max_requests — set so workers recycle periodically to prevent memory leaks.
  • pm = dynamic is more flexible than pm = static for variable traffic.

OPCache configuration:

; /usr/local/etc/php/conf.d/opcache.ini
opcache.enable=1
opcache.enable_cli=0
opcache.memory_consumption=128
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=10000
opcache.validate_timestamps=0
opcache.revalidate_freq=0
opcache.fast_shutdown=1

For production, opcache.validate_timestamps=0 means the container must be restarted when code changes (immutable deployment). This matches the container pattern.

10. Logging #

PHP-FPM logs to /var/log/php-fpm.log by default. For containers, redirect to STDOUT/STDERR:

docker/php-fpm.d/zz-app.conf:

[global]
error_log = /proc/self/fd/2
log_level = warning

[www]
access.log = /proc/self/fd/1
catch_workers_output = yes
  • /proc/self/fd/1 = STDOUT
  • /proc/self/fd/2 = STDERR

For Laravel, configure logging in config/logging.php to use a single channel with STDOUT output:

'channels' => [
    'stderr' => [
        'driver' => 'monolog',
        'level' => 'debug',
        'handler' => Monolog\Handler\StreamHandler::class,
        'with' => [
            'stream' => 'php://stderr',
        ],
    ],
],

11. Separate Web Server #

Best practice: Separate PHP-FPM from the web server. Don’t use php:apache.

# docker-compose.yml
services:
  app:
    image: myapp:latest
    expose:
      - "9000"
  
  web:
    image: nginx:alpine
    ports:
      - "80:80"
    volumes:
      - ./public:/var/www/html/public:ro
    depends_on:
      - app

Benefits:

  • Slimmer PHP images (no Nginx/Apache).
  • Independent scaling (Nginx can scale separately from PHP-FPM).
  • Cacheable web server (Nginx caches static assets).
  • Nginx serves static assets faster than PHP.

12. Production-Grade Logging #

// config/logging.php (Laravel)
'channels' => [
    'production' => [
        'driver' => 'stack',
        'channels' => ['stdout_json'],
    ],
    'stdout_json' => [
        'driver' => 'monolog',
        'handler' => Monolog\Handler\StreamHandler::class,
        'with' => [
            'stream' => 'php://stdout',
        ],
        'formatter' => Monolog\Formatter\JsonFormatter::class,
    ],
],

This configuration:

  • Logs to STDOUT (not files).
  • Uses JSON format (easy for aggregators to parse).
  • Can include trace IDs if using OpenTelemetry.

13. Security Hardening #

Non-Root Users #

# For alpine
RUN addgroup -g 1001 -S appgroup \
 && adduser -u 1001 -S appuser -G appgroup
USER appuser

# For distroless, nonroot already exists
USER nonroot:nonroot

File Permissions #

Make sure source files are owned by the user that will run PHP:

COPY --chown=appuser:appgroup . /app

Don’t Expose .env #

.env must be mounted at runtime, never baked into the image.

# docker-compose.yml
services:
  app:
    env_file: .env

Make sure .env is in .dockerignore just in case:

# .dockerignore
.env
.env.*

Vulnerability Scanning #

- name: Build
  run: docker build -t myapp:${{ github.sha }} .
- name: Scan
  run: trivy image --exit-code 1 --severity CRITICAL myapp:${{ github.sha }}

PHP images usually pull many OS packages (libraries for extensions). Rebuild regularly to pull patches.

14. Anti-Patterns to Avoid #

✗ Using php:apache #

// ✗ 600+ MB image, Apache isn't scalable
FROM php:8.3-apache
COPY . /var/www/html/

Solution: Separate the web server from PHP-FPM. Use php:8.3-fpm-alpine.

✗ Composer in Runtime #

// ✗ Composer and its caches sit in runtime
FROM php:8.3-fpm
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /app
COPY . .
RUN composer install

Solution: Multi-stage. Composer in the build stage, copy only vendor/ to runtime.

✗ Installing Dev Dependencies #

// ✗ phpunit, debug tools go to production
RUN composer install

Solution: composer install --no-dev in production builds.

✗ Build Tools Leaking #

// ✗ gcc, make, header files enter the runtime image
FROM php:8.3-fpm-alpine
RUN apk add $PHPIZE_DEPS gcc make
RUN docker-php-ext-install pdo_pgsql

Solution: The build stage has build tools, the runtime stage doesn’t.

✗ No OPcache #

// ✗ PHP interpreted on every request (slow)
FROM php:8.3-fpm

Solution: Always install and enable OPcache in production.

✗ Tags Without Versions #

// ✗ Non-deterministic builds
FROM php:latest
FROM php:8

Solution: Pin the tag: php:8.3.11-fpm-alpine3.20.

15. A Production-Grade PHP Dockerfile Example (Laravel) #

# syntax=docker/dockerfile:1.7

# ==== Stage 1: Composer ====
FROM composer:2.7.7 AS vendor

WORKDIR /app

COPY composer.json composer.lock ./
RUN composer install \
    --no-dev \
    --no-scripts \
    --no-autoloader \
    --prefer-dist \
    --no-interaction

# ==== Stage 2: Frontend Assets ====
FROM node:20-alpine AS assets

WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY . .
RUN npm run build

# ==== Stage 3: Runtime ====
FROM php:8.3.11-fpm-alpine3.20

WORKDIR /app

# Install production extensions
RUN apk add --no-cache \
    libpq \
    icu-libs \
    oniguruma \
    libzip \
    libpng \
    libjpeg-turbo \
    freetype

RUN docker-php-ext-install -j$(nproc) \
    pdo \
    pdo_pgsql \
    intl \
    zip \
    opcache \
    bcmath

# OPcache config for production
COPY docker/opcache.ini /usr/local/etc/php/conf.d/opcache.ini

# PHP-FPM config
COPY docker/php-fpm.conf /usr/local/etc/php-fpm.d/zz-app.conf

# Copy Composer dependencies
COPY --from=vendor /app/vendor /app/vendor

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

# Copy source code
COPY . .

# Copy built assets
COPY --from=assets /app/public/build /app/public/build

# Laravel optimization
ENV APP_ENV=production
RUN php artisan config:cache \
 && php artisan route:cache \
 && php artisan view:cache

# Non-root user
RUN addgroup -g 1001 -S appgroup \
 && adduser -u 1001 -S appuser -G appgroup
USER appuser

EXPOSE 9000

CMD ["php-fpm"]

Characteristics:

  • Size: 90-150 MB.
  • Composer only in the build stage.
  • Frontend assets built separately.
  • PHP-FPM (not mod_php).
  • Optimal OPcache.
  • Pre-compiled Laravel.
  • Non-root user.

16. When to Use Which Strategy #

ConditionChoiceReason
Standard Laravel APIfpm-alpine + multi-stageReasonable size, debugging capability
High-maturity productionDistrolessMinimal size, solid observability
WordPress / CMSfpm-alpine + custom configNeeds tools, plugin flexibility
MicroservicesDistrolessSmall images, minimal attack surface
Legacy with many extensionsfpm-slimNeeds glibc, debugging capability
ServerlessDistroless or alpineCold-start time matters

17. PHP Dockerfile Review Checklist #

BASE IMAGE:
  □ Explicit tag (php:8.3.11-fpm-alpine3.20, not latest)
  □ Uses FPM, not apache
  □ Uses alpine or slim, not the default debian
  □ Not php:latest (too large)

BUILD:
  □ Multi-stage build
  □ Composer in the build stage, copy only vendor/
  □ composer install --no-dev --optimize-autoloader
  □ Frontend assets in a separate build (if needed)
  □ Laravel optimize / Symfony cache at build
  □ Build tools (gcc, make) only in the build stage

EXTENSION:
  □ Production extensions only (no xdebug, no pcov)
  □ docker-php-ext-install -j$(nproc) for parallelism
  □ apk del $PHPIZE_DEPS after installing

RUNTIME:
  □ USER nonroot
  □ OPcache enabled and optimized
  □ PHP-FPM config tuned
  □ Logs to STDOUT
  □ CMD in exec form

SIZE:
  □ < 200 MB for fpm-alpine
  □ < 100 MB for distroless
  □ docker history shows no odd layers

SECURITY:
  □ No secrets in the image
  □ Strict .dockerignore
  □ .env excluded
  □ Image scanned with trivy/grype
  □ Non-root user
  □ Base image up to date

FRAMEWORK:
  □ Laravel: config:cache, route:cache, view:cache
  □ Symfony: cache:clear --no-warmup + cache:warmup
  □ Composer: --classmap-authoritative --optimize

Summary #

  • Slim PHP images are very possible — it’s not a legacy fate. What separates an 800 MB image from a 90 MB image is choosing FPM (not apache), multi-stage, and separating Composer.
  • Size reality: 60-100 MB (distroless), 90-150 MB (multi-stage + alpine), 120-200 MB (fpm-alpine), 400-600 MB (default fpm), 600-800 MB (apache — anti-pattern).
  • Use PHP-FPM, not mod_php/apache — separate the web server from PHP. Slimmer images, more flexible scaling.
  • Multi-stage builds are mandatory — Composer in the build stage, copy only vendor/ to runtime. Composer and its caches must not enter runtime.
  • Composer install –no-dev –optimize-autoloader –classmap-authoritative — skip dev, optimize classmaps, don’t generate slow autoloaders.
  • Separate dev vs prod extensions — OPcache is mandatory in production, xdebug/pcov only in development. Build tools (gcc, make) only in the build stage.
  • Laravel/Symfony optimization — pre-compile config, routes, and views in the build stage. Runtime must not have Node.js or build tools.
  • Optimal OPcache configurationvalidate_timestamps=0 for immutable deployments (containers restart when code changes).
  • Separate web server — Nginx in front, PHP-FPM behind. Independent scaling, faster static assets.
  • Log to STDOUT/proc/self/fd/1 and /proc/self/fd/2 for PHP-FPM. Laravel: configure monolog with php://stdout.
  • Explicit tagsphp:8.3.11-fpm-alpine3.20, not latest. Reproducibility matters for auditing and rollbacks.
  • Slim images need solid observability — JSON logs, metrics, healthchecks, graceful shutdown. PHP can be as slim as Go with the right Dockerfile discipline.

← Previous: Java (Spring Boot)   Next: TypeScript →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact