12 min read

Using Docker to Solve PHP Version Compatibility Issues: A Practical Guide

Deploy Laravel apps requiring newer PHP versions on older servers using Docker, without disrupting existing applications.

Using Docker to Solve PHP Version Compatibility Issues: A Practical Guide

I've lost count of how many times I've seen this exact scenario. You build a Laravel app locally with PHP 8.5 (because that's what Herd ships by default now), install a package that requires it, push to production, and Composer throws an error because your server runs PHP 8.3. Your server also hosts three other apps that are running fine. Upgrading PHP server-wide feels like defusing a bomb while blindfolded.

Docker fixes this completely. You get the exact PHP version your app needs, wrapped in its own container, without touching anything else on the server. I've used this approach across a dozen client deployments, and it hasn't failed me once. The setup is surprisingly simple once you understand the moving pieces.

This guide walks through a real production setup: deploying a Laravel 12 application that requires PHP 8.5 on a server running PHP 8.3, with Apache handling SSL termination in front of Docker. We'll cover multi-stage builds for small images, proper health checks, OPcache tuning, and the reverse proxy configuration that ties it all together.

The Problem: PHP Version Mismatch in Production

Here's a scenario you'll recognize. You've built a new Laravel app with Filament, deployed it to your VPS, and Composer gives you this:

Your lock file does not contain a compatible set of packages.
  Problem 1
    - openspout/openspout is locked to version v4.29.1 and an update
      of this package was not requested.
    - openspout/openspout v4.29.1 requires php ~8.5.0
      -> your php version (8.3.30) does not satisfy that requirement.

One package pulls the rug out from under you. And it's not even your code that requires PHP 8.5. It's a transitive dependency buried three levels deep.

This problem is only getting more common. PHP 8.5 shipped in November 2025, PHP 8.4 came out a year before that, and PHP 8.3 is now in security-fixes-only mode (EOL December 2027). Package maintainers are dropping older PHP versions faster than ever, which means the gap between what your local machine runs and what your server runs keeps widening. Laravel 12 still supports PHP 8.2 as a minimum, but plenty of popular packages have already moved past it.

Your Options (And Why Docker Wins)

You've got four realistic paths when you hit a PHP version mismatch in production.

Upgrade PHP directly on the server. This is the obvious answer, but it's also the most dangerous. If you're running PHP 8.3 and three other apps depend on it, upgrading to 8.5 means testing every single one of those apps against the new version. PHP minor releases generally don't break things, but "generally" isn't "never." I've seen apps break from behavior changes in PDO, changes to how certain string functions handle edge cases, and deprecated features that suddenly throw warnings. Not worth the risk.

Downgrade your application's dependencies. Sometimes you can pin a package to an older version that supports your server's PHP. But this creates technical debt immediately. You're frozen on an older version while bugs get fixed and features land in newer releases. And sometimes the dependency chain makes it impossible. If package A requires PHP 8.5 and package B requires package A, you're stuck.

Run separate PHP-FPM pools. You can install multiple PHP versions on the same server and configure separate FPM pools per application. This works, but the configuration overhead is significant, and you end up managing multiple PHP installations, extension sets, and ini files. Updates become a chore.

Use Docker to isolate your application. This is the approach I recommend. Zero risk to other applications, fully reproducible across any server, and you can switch PHP versions by changing one line in a Dockerfile. The trade-off is a slightly more complex deployment pipeline, but it pays for itself immediately.

For local development, Laravel Sail gives you a Docker-based environment out of the box. And if you're on macOS, Laravel Herd handles PHP version switching with one click (including per-project isolation with herd isolate). But for production deployment on a shared VPS, a custom Docker setup is the way to go.

Step 1: Create a .dockerignore File

Before anything else, create a .dockerignore in your project root. This is the most commonly skipped step, and it makes a huge difference to build speed and image size.

.git
node_modules
vendor
storage/logs/*
storage/framework/cache/*
storage/framework/sessions/*
storage/framework/views/*
.env
.env.backup
docker-compose*.yml
Dockerfile
README.md
tests
phpunit.xml

Without this file, Docker copies your entire node_modules and vendor directories into the build context, then installs them again inside the container. I've seen builds go from 8 minutes to 45 seconds just by adding .dockerignore.

Step 2: Write a Production-Ready Dockerfile

The original single-stage Dockerfile I used to write worked fine but produced bloated images. Multi-stage builds changed everything. You install Composer dependencies in one stage, build frontend assets in another, and copy only the results into the final slim image.

# Stage 1: Install Composer dependencies
FROM composer:2.8 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --prefer-dist --optimize-autoloader
COPY . .
RUN composer dump-autoload --optimize

# Stage 2: Build frontend assets
FROM node:22-alpine AS frontend
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --production=false
COPY . .
RUN npm run build

# Stage 3: Production image
FROM php:8.5-fpm-alpine

RUN apk add --no-cache \
    libpng-dev \
    libzip-dev \
    icu-dev \
    oniguruma-dev \
    && docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd zip intl opcache \
    && apk del --no-cache libpng-dev libzip-dev icu-dev oniguruma-dev

# Configure OPcache for production
RUN echo "opcache.enable=1" >> /usr/local/etc/php/conf.d/opcache.ini \
    && echo "opcache.memory_consumption=256" >> /usr/local/etc/php/conf.d/opcache.ini \
    && echo "opcache.interned_strings_buffer=16" >> /usr/local/etc/php/conf.d/opcache.ini \
    && echo "opcache.max_accelerated_files=20000" >> /usr/local/etc/php/conf.d/opcache.ini \
    && echo "opcache.validate_timestamps=0" >> /usr/local/etc/php/conf.d/opcache.ini

WORKDIR /var/www/html

COPY --from=vendor /app/vendor ./vendor
COPY --from=frontend /app/public/build ./public/build
COPY . .

RUN chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache

USER www-data

EXPOSE 9000
CMD ["php-fpm"]

A few things worth noting here. The Alpine base image (php:8.5-fpm-alpine) is around 50MB instead of 400MB+ for the Debian variant. OPcache with validate_timestamps=0 means PHP never checks if files changed on disk, which is exactly what you want in production since files don't change after deployment. And notice we set USER www-data at the end so the container doesn't run as root.

If you need to change PHP versions later, you literally edit the FROM php:8.5-fpm-alpine line to php:8.4-fpm-alpine and rebuild. That's it.

Want to validate your compose configuration? I usually run it through a YAML to JSON converter first to catch syntax issues before Docker does.

Step 3: Configure Docker Compose

Create a compose.yaml file in your project root. Docker Compose V2 is now the standard (the docker-compose binary with a hyphen is deprecated), and the file is compose.yaml rather than the old docker-compose.yml.

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: myapp_php
    volumes:
      - app-storage:/var/www/html/storage
    networks:
      - myapp_network
    restart: unless-stopped
    healthcheck:
      test: ["CMD-SHELL", "php-fpm-healthcheck || exit 1"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s

  web:
    image: nginx:alpine
    container_name: myapp_web
    ports:
      - "8080:80"
    volumes:
      - ./public:/var/www/html/public:ro
      - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      app:
        condition: service_healthy
    networks:
      - myapp_network
    restart: unless-stopped

networks:
  myapp_network:
    driver: bridge

volumes:
  app-storage:

The depends_on with condition: service_healthy means Nginx won't start accepting requests until PHP-FPM is actually ready. I've debugged too many 502 errors that were just timing issues during container startup.

Notice we're using a named volume (app-storage) for the storage directory instead of a bind mount. This means uploaded files, cached views, and session data persist even when containers are recreated. In production, you don't want a deployment to wipe your users' uploaded files. For the Nginx container, we mount the public directory as read-only (:ro) since Nginx only needs to serve static files from it.

Step 4: Set Up the Nginx Configuration

Create docker/nginx/default.conf:

server {
    listen 80;
    server_name localhost;
    root /var/www/html/public;
    index index.php;

    client_max_body_size 64M;

    # Trust proxy headers from Apache
    set_real_ip_from 172.0.0.0/8;
    real_ip_header X-Forwarded-For;
    real_ip_recursive on;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        fastcgi_pass app:9000;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include fastcgi_params;

        # Pass HTTPS status from Apache proxy
        fastcgi_param HTTPS on;
    }

    location ~ /\.(?!well-known) {
        deny all;
    }

    # Cache static assets
    location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
        expires 30d;
        add_header Cache-Control "public, immutable";
    }
}

The last location block denies access to hidden files (like .env), which is critical for security. And static asset caching saves your PHP container from handling requests it doesn't need to.

Step 5: Configure Apache as a Reverse Proxy

Since Apache already handles SSL certificates on the server, we'll proxy requests to Docker's Nginx container:

<IfModule mod_ssl.c>
<VirtualHost *:443>
    ServerAdmin webmaster@localhost
    ServerName example.com
    ServerAlias www.example.com

    ProxyPreserveHost On
    ProxyPass / http://127.0.0.1:8080/
    ProxyPassReverse / http://127.0.0.1:8080/

    RequestHeader set X-Forwarded-Proto "https"
    RequestHeader set X-Forwarded-Port "443"

    ErrorLog ${APACHE_LOG_DIR}/example-error.log
    CustomLog ${APACHE_LOG_DIR}/example-access.log combined

    Include /etc/letsencrypt/options-ssl-apache.conf
    SSLCertificateFile /etc/letsencrypt/live/example.com/fullchain.pem
    SSLCertificateKeyFile /etc/letsencrypt/live/example.com/privkey.pem
</VirtualHost>
</IfModule>

And the HTTP-to-HTTPS redirect:

<VirtualHost *:80>
    ServerName example.com
    ServerAlias www.example.com

    RewriteEngine on
    RewriteCond %{SERVER_NAME} =example.com [OR]
    RewriteCond %{SERVER_NAME} =www.example.com
    RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [END,NE,R=permanent]
</VirtualHost>

Enable the required Apache modules:

sudo a2enmod proxy proxy_http headers rewrite
sudo systemctl restart apache2

Step 6: Configure Laravel for the Proxy Setup

Update your .env file:

APP_URL=https://www.example.com
ASSET_URL=https://www.example.com

Laravel 12 includes TrustProxies middleware by default. If you're behind Apache, make sure it trusts the right addresses. In bootstrap/app.php:

->withMiddleware(function (Middleware $middleware) {
    $middleware->trustProxies(
        at: '*',
        headers: Request::HEADER_X_FORWARDED_FOR |
                 Request::HEADER_X_FORWARDED_HOST |
                 Request::HEADER_X_FORWARDED_PORT |
                 Request::HEADER_X_FORWARDED_PROTO
    );
})

Setting at: '*' tells Laravel to trust proxy headers from any IP. Since Docker uses internal networking, restricting to specific IPs is impractical. The alternative approach of forcing HTTPS in AppServiceProvider still works, but TrustProxies is the proper solution since it handles protocol detection, port forwarding, and client IP resolution all at once.

Step 7: Build and Deploy

# Build the images
docker compose build

# Start in detached mode
docker compose up -d

# Run migrations
docker compose exec app php artisan migrate --force

# Cache configuration
docker compose exec app php artisan config:cache
docker compose exec app php artisan route:cache
docker compose exec app php artisan view:cache

Check that everything is running:

docker compose ps
docker compose logs -f

If you're building a SaaS application with Filament, this same setup handles multi-tenant deployments. Just make sure your database is accessible from the Docker network (either as a container or via the host's IP).

Common Issues and Quick Fixes

Missing PHP Extensions

If Composer complains about missing extensions after building, add them to the docker-php-ext-install line in your Dockerfile. The most commonly missed ones for Laravel are intl, gd, and zip. Rebuild with docker compose build --no-cache.

Database Connection from Container

If your MySQL runs on the host server (not in Docker), use the special hostname host.docker.internal on Docker Desktop, or the host's actual IP. Add this to your compose network config:

services:
  app:
    extra_hosts:
      - "host.docker.internal:host-gateway"

Then set DB_HOST=host.docker.internal in your .env.

Permission Errors on Storage

The most frustrating Docker issue. If you see "Permission denied" errors for storage or cache, the container's www-data user needs ownership:

docker compose exec app chown -R www-data:www-data /var/www/html/storage
docker compose exec app chmod -R 775 /var/www/html/storage /var/www/html/bootstrap/cache

For a permanent fix, handle permissions in your Dockerfile (which we already did with the RUN chown line before setting USER www-data).

Mixed Content Warnings

If your pages load but assets show mixed content errors, it means Laravel doesn't know it's behind HTTPS. Check three things: ASSET_URL in .env is using https, the TrustProxies middleware is configured, and Apache is sending the X-Forwarded-Proto header. You can verify with a quick HTTP status code check to confirm your redirects are working properly.

Deploying Updates

For subsequent deployments, here's the workflow I use:

# Pull latest code
git pull origin main

# Rebuild images (only layers that changed)
docker compose build

# Restart with zero downtime
docker compose up -d --force-recreate

# Run any new migrations
docker compose exec app php artisan migrate --force

# Clear and rebuild caches
docker compose exec app php artisan optimize

Docker's layer caching means rebuilds only process what actually changed. If you only modified PHP files (not Composer or npm dependencies), the rebuild takes seconds because the vendor and node_modules stages are cached from the previous build.

One thing that catches people off guard: remember that opcache.validate_timestamps=0 in our config means PHP won't pick up file changes automatically. You need to restart the PHP-FPM process after deploying new code. The docker compose up -d --force-recreate command handles this by recreating the container, but if you're doing rolling updates, you'll want docker compose exec app kill -USR2 1 to gracefully reload PHP-FPM without dropping connections.

For applications with background jobs, add a worker container to your compose.yaml that runs the same image but overrides the command:

  worker:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: myapp_worker
    command: php artisan queue:work --tries=3 --timeout=90
    volumes:
      - app-storage:/var/www/html/storage
    networks:
      - myapp_network
    restart: unless-stopped
    depends_on:
      app:
        condition: service_healthy

Same image, same PHP version, same extensions. Just a different entrypoint.

When Docker is Overkill

Be honest about when you don't need this. If your server runs a single Laravel app and nothing else, just upgrade PHP directly. It takes five minutes and there's nothing to break. If you're deploying to a managed platform like Laravel Cloud, Forge, or Vapor, they handle PHP versions for you. You pick a version in the dashboard and the platform does the rest. And for local development, Herd or Sail are simpler than maintaining your own Docker files.

Docker shines specifically in a few scenarios. When you've got multiple apps on one server requiring different PHP versions, that's the classic use case. When you need bit-for-bit reproducibility between staging and production environments, Docker guarantees it. When your CI/CD pipeline builds container images that get deployed identically everywhere. And when you're building a Dockerized Laravel + Vue application that needs consistent environments across a development team.

It's the right tool for this exact job, not every job. Use it when it solves a real problem, not because it's trendy.

If you need help containerizing an existing Laravel application or building an API-driven architecture that's production-ready from day one, Docker is the foundation I always start with.

FAQ

Can I use this setup with Laravel Sail in production?

No. Sail is designed for local development only. It runs containers with relaxed permissions, installs dev dependencies, and isn't optimized for performance or security. For production, build custom images with multi-stage Dockerfiles like the one in this guide. Sail's compose.yaml is a good reference, but don't deploy it as-is.

How do I handle scheduled tasks (cron) in Docker?

Add a scheduler container to your compose.yaml that runs php artisan schedule:work. This command was introduced in Laravel 8 and runs the scheduler continuously without needing cron. Use the same image as your main app and override the command to php artisan schedule:work.

Does running Nginx inside Docker add latency compared to running it on the host?

The overhead is negligible. Docker networking adds microseconds, not milliseconds. The Apache-to-Nginx-to-PHP-FPM chain sounds heavy, but each hop is on localhost. In practice, response times are within 1-2ms of running everything natively.

How do I update PHP versions in an existing Docker setup?

Change the version tag in your Dockerfile (e.g., php:8.5-fpm-alpine to php:8.6-fpm-alpine), rebuild with docker compose build --no-cache, and restart with docker compose up -d. Test locally first. The entire upgrade takes under five minutes, and you can roll back by reverting the Dockerfile.

Should I include the database in Docker Compose for production?

For production, I recommend using your host's database server or a managed database service (like AWS RDS or DigitalOcean Managed Databases). Running MySQL in a container works for development, but production databases need persistent storage, proper backups, and performance tuning that's easier to manage outside Docker.

Wrapping Up

PHP version mismatches used to mean either risky server-wide upgrades or awkward dependency downgrades. Docker eliminates the entire category of problem. Your app gets the exact PHP version it needs, other apps keep running untouched, and deployments become predictable.

The multi-stage build approach keeps images small, OPcache keeps them fast, and health checks keep them reliable. Once you've set this up for one project, you'll use it for every production deployment where the server's PHP version doesn't match your application's requirements.

If you're running into version compatibility issues or need help setting up a production Docker environment for your Laravel project, let's talk about it.

Share: X/Twitter | LinkedIn | | RSS
Hafiz Riaz

About Hafiz

Senior Full Stack Developer. I build production software with Laravel, Filament, Vue, and AI integrations, and write about the real decisions behind shipping it.

Get in touch →

Get web development tips via email

Join 50+ developers • No spam • Unsubscribe anytime