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.
| Setup | Image Size |
|---|---|
php:apache (default) | 600-800 MB |
php:fpm + debian | 400-600 MB |
php:8.3-fpm-alpine | 120-200 MB |
| Multi-stage + slim | 90-150 MB |
| Multi-stage + distroless | 60-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:
- Multi-stage builds — separate build (needs Composer + dev extensions) from runtime (needs FPM + prod extensions).
- PHP-FPM, not mod_php — FPM is more scalable, and
php:*-fpmimages don’t carry Apache. - 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/buildoutput is copied to the runtime stage.
Stage 3: Runtime
php:8.3-fpm-alpine— FPM on Alpine, already far slimmer thanphp: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-autoloadwith--optimizeand--classmap-authoritativefor a faster autoloader.php artisan config:cacheetc. — 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 -Fmeans 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 Image | Size | Notes |
|---|---|---|
php:8.3-apache | 600-800 MB | Apache + mod_php + Debian, don’t use in production |
php:8.3-fpm | 400-600 MB | PHP-FPM on Debian, the safe default |
php:8.3-fpm-alpine | 120-200 MB | PHP-FPM on Alpine, the best compromise |
php:8.3-fpm-slim | 200-300 MB | PHP-FPM on Debian slim |
| Distroless + custom | 60-100 MB | The 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.devpackages fromapk add(likegcc,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 = dynamicis more flexible thanpm = staticfor 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 #
| Condition | Choice | Reason |
|---|---|---|
| Standard Laravel API | fpm-alpine + multi-stage | Reasonable size, debugging capability |
| High-maturity production | Distroless | Minimal size, solid observability |
| WordPress / CMS | fpm-alpine + custom config | Needs tools, plugin flexibility |
| Microservices | Distroless | Small images, minimal attack surface |
| Legacy with many extensions | fpm-slim | Needs glibc, debugging capability |
| Serverless | Distroless or alpine | Cold-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 configuration —
validate_timestamps=0for 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/1and/proc/self/fd/2for PHP-FPM. Laravel: configuremonologwithphp://stdout.- Explicit tags —
php:8.3.11-fpm-alpine3.20, notlatest. 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.