The go-to resource for learning PHP, Laravel, Symfony, and your dependencies.

Containerization Strategy for Multiple PHP Versions


Introduction: The Configuration Dilemma

Before we explore containerization as a solution, let’s take a moment to understand the broader landscape of PHP version management. This isn’t a new problem—developers have been grappling with environment consistency since the early days of web development. In the 2000s, PHP applications were often deployed on shared hosting with fixed PHP versions. If you needed a different version, you’d typically resort to manual compilation or switching hosting providers—neither option was particularly elegant.

Fast forward to today, and we have more options—but also more complexity. You might maintain a legacy PHP 7.4 application that depends on deprecated extensions while simultaneously developing a new project using PHP 8.2’s modern features. How do we manage these conflicting requirements on a single development machine?

Exploring Your Options

So far, we’ve discussed a number of different ways to run PHP applications—but what about managing multiple PHP versions themselves? This paints a picture, of course, of many different solutions to the same problem. Let’s examine the major approaches before we settle on containerization.

Traditional Approaches

The most common solution developers reach for is a version manager like phpenv or system package managers. With phpenv, you can install multiple PHP versions side-by-side and switch between them using shell commands. On Ubuntu, you might use apt to install PHP 7.4, while on macOS you could use Homebrew to install PHP 8.2. These approaches certainly work—for a while.

Another option is to use all-in-one packages like XAMPP or MAMP. These bundle Apache, MySQL, and PHP together, allowing you to run different versions by installing multiple instances. Some developers use virtual machines—perhaps one VM per project—to achieve complete isolation.

Each of these approaches has its merits, but none quite solve the core problem: environment drift. Your development environment, no matter how carefully configured, will eventually diverge from production. System updates, dependency changes, or even a new project can break your carefully balanced setup. What’s more, sharing your environment with a teammate becomes a documentation exercise—and we all know how that typically goes.

The Containerization Alternative

Containerization provides a fundamentally different approach. Instead of managing PHP versions on your host system, Docker packages your entire application—including the specific PHP version, extensions, web server, and configurations—into an isolated unit. This unit runs consistently across any Docker-enabled system, regardless of what’s installed on the host.

That said, containerization isn’t a silver bullet. It introduces its own complexity: you’ll need to learn Docker concepts, manage container lifecycles, and understand networking between containers. For very simple projects, the overhead might not be justified. But for teams juggling multiple PHP versions—or for anyone who’s ever been burned by the “it works on my machine” problem—the benefits often outweigh the costs.

We’ll focus on Docker and Docker Compose as our containerization platform of choice. Why Docker specifically? Primarily because it’s the most widely adopted container platform, has excellent documentation, and integrates well with development workflows. Could you use Podman or containerd? Absolutely—but Docker’s ecosystem and tooling make it the most practical choice for most PHP developers.

Understanding the Architecture

Before we dive into configuration files, let’s clarify what’s actually happening. You can think of our containerized setup as having three main components:

  1. Nginx running in one container, acting as a reverse proxy
  2. PHP-FPM 7.4 running in a separate container for legacy projects
  3. PHP-FPM 8.2 running in another container for modern applications

Nginx receives all HTTP requests and routes them to the appropriate PHP-FPM service based on the domain name. This separation allows each PHP version to have its own extensions, configuration, and even operating system libraries—all without interfering with each other.

One may wonder: why use separate PHP-FPM containers rather than a single Nginx container with multiple PHP-FPM processes? The answer is straightforward: isolation. By separating concerns, we can upgrade one PHP version without affecting the other, and we can scale or replace services independently. This also mirrors common production deployments where PHP-FPM runs separately from the web server.

Step-by-Step Implementation

Let’s walk through the setup process together. We’ll create a working example that demonstrates PHP 7.4 and PHP 8.2 running side-by-side. You can follow along with these exact commands—though of course, your specific project requirements may vary.

Step 1: Create the Project Structure

First, let’s create our directory structure. This organization keeps configuration separate from application code, which makes it easier to maintain:

mkdir -p your-dev-environment/{php74,php82,nginx,project-legacy,project-modern}
cd your-dev-environment
tree .

You should see something like this:

.
├── nginx
│   └── default.conf
├── php74
│   └── Dockerfile
├── php82
│   └── Dockerfile
├── project-legacy
└── project-modern

Of course, you don’t actually need the tree command—you could use ls -la instead. The important thing is that we have separate directories for each PHP version’s configuration, an nginx directory for our web server configuration, and directories for our actual projects.

Step 2: Create the PHP Dockerfiles

Now let’s create the Dockerfile for PHP 7.4. This file defines how to build our PHP 7.4 container. We’ll start with the official PHP 7.4 FPM image and add the most common extensions:

php74/Dockerfile

FROM php:7.4-fpm

# Install common extensions
RUN docker-php-ext-install pdo_mysql mysqli && docker-php-ext-enable pdo_mysql

# Optional: Install additional extensions as needed
# RUN docker-php-ext-install gd zip intl opcache

Similarly, for PHP 8.2:

php82/Dockerfile

FROM php:8.2-fpm

# Install common extensions
RUN docker-php-ext-install pdo_mysql mysqli && docker-php-ext-enable pdo_mysql

# Optional: Install additional extensions as needed
# RUN docker-php-ext-install gd zip intl opcache

You also may notice these Dockerfiles are quite minimal. That’s intentional—we’re starting with the simplest working example. In practice, you’ll almost certainly need to customize these based on your project’s requirements. For instance, if your application uses image processing, you’ll need the GD extension; for PDF generation, you might need PDFlib or a similar library. The docker-php-ext-install command can install many extensions directly—but some require system packages first. We’ll address that complexity later.

Step 3: Configure Nginx

Next, we need to configure Nginx to route requests to the appropriate PHP-FPM service. Create the nginx/default.conf file:

nginx/default.conf

# Legacy Project on PHP 7.4
server {
    listen 80;
    server_name legacy.localhost;
    root /var/www/html/project-legacy;
    index index.php;

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

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

# Modern Project on PHP 8.2
server {
    listen 80;
    server_name modern.localhost;
    root /var/www/html/project-modern;
    index index.php;

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

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

Let’s break down what’s happening here. We define two server blocks—each listens on port 80 but responds to different domain names (legacy.localhost vs modern.localhost). The fastcgi_pass directive is the key: it tells Nginx where to send PHP requests. We use the Docker service names (php74 and php82) as hostnames—Docker’s internal DNS will resolve these to the correct container IP addresses.

Step 4: Create the Docker Compose File

Now for the orchestration layer. Create docker-compose.yml in your root directory:

docker-compose.yml

version: '3.8'

services:
  nginx:
    image: nginx:latest
    ports:
      - "8080:80"
    volumes:
      - ./project-legacy:/var/www/html/project-legacy
      - ./project-modern:/var/www/html/project-modern
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf
    depends_on:
      - php74
      - php82

  php74:
    build:
      context: ./php74
    volumes:
      - ./project-legacy:/var/www/html/project-legacy

  php82:
    build:
      context: ./php82
    volumes:
      - ./project-modern:/var/www/html/project-modern

There are a few details worth noting here. First, the depends_on directive ensures Nginx starts after both PHP services—though it doesn’t wait for them to be ready, just for the containers to launch. In production, you’d typically add health checks; for development, this usually suffices.

Second, we mount our project directories as volumes. This is crucial: it means changes to your local files are immediately reflected inside the containers—no need to rebuild images for code changes. However, there are performance implications; on macOS and Windows, Docker volume mounting can be noticeably slower than native filesystem access. If you’re working with many small files, this might affect your workflow.

Step 5: Prepare Your Host System

Before we bring the stack up, we need to configure our host to resolve the domain names. Edit your /etc/hosts file (or C:\Windows\System32\drivers\etc\hosts on Windows) and add:

127.0.0.1 legacy.localhost
127.0.0.1 modern.localhost

This maps our domain names to localhost. Of course, you can choose any domain you like—just be sure to avoid conflicts with real domains. Some developers use .test or .docker TLDs to clearly indicate these are local-only.

Step 6: Bring Everything Up

Now we’re ready to start the containers:

docker-compose up -d

You should see output like:

Creating network "your-dev-environment_default" with the default driver
Creating your-dev-environment-php74-1 ... done
Creating your-dev-environment-php82-1 ... done
Creating your-dev-environment-nginx-1  ... done

To verify everything is running:

docker-compose ps

Expected output:

     Name                     Command               State           Ports
-------------------------------------------------------------------------------
your-dev-environment-nginx-1   "/docker-entrypoint.…"   Up      0.0.0.0:8080->80/tcp
your-dev-environment-php74-1  "docker-php-entrypoi…"   Up      9000/tcp
your-dev-environment-php82-1  "docker-php-entrypoi…"   Up      9000/tcp

We can confirm the PHP services are accessible:

docker-compose exec php74 php -v

Output (version may vary):

PHP 7.4.33 (cli) (built: Feb 17 2022 16:57:09) ...

And for PHP 8.2:

docker-compose exec php82 php -v

Output:

PHP 8.2.10 (cli) (built: Oct  3 2023 15:43:13) ...

Step 7: Test the Setup

Let’s create simple test files to verify everything is working.

project-legacy/index.php

<?php
echo "Hello from PHP 7.4!<br>";
echo "PHP Version: " . phpversion() . "<br>";
phpinfo();

project-modern/index.php

<?php
echo "Hello from PHP 8.2!<br>";
echo "PHP Version: " . phpversion() . "<br>";
phpinfo();

Now visit in your browser:

  • http://legacy.localhost:8080 should show PHP 7.4
  • http://modern.localhost:8080 should show PHP 8.2

You may notice the phpinfo() output differs between the two—not just in version numbers, but in available extensions and configuration defaults. That’s exactly what we want: each environment is self-contained.

Key Benefits and Trade-offs

Let’s examine the advantages of this containerized approach—but also acknowledge the costs.

Benefits

Environment Isolation: Each PHP version runs in its own container with separate extensions, libraries, and configurations. No more conflicts between projects requiring different php.ini settings or extension versions.

Consistency with Production: When your production environment also uses containers (as many modern deployments do), your development environment mirrors it precisely. This reduces deployment surprises significantly.

Onboarding Simplicity: New team members can get started with a single docker-compose up command—no need to install PHP, configure extensions, or troubleshoot version mismatches.

Scalability: Need to add PHP 8.3 for a new project? Simply add a php83 directory with a Dockerfile, update the Nginx configuration, and you’re done. The pattern scales cleanly.

Trade-offs and Limitations

Of course, containerization introduces its own complexities. Let’s discuss them openly.

Learning Curve: If you’re new to Docker, there’s a substantial upfront investment. You’ll need to understand Dockerfiles, images, containers, volumes, networks, and Compose orchestration. The good news is these skills transfer to many other projects beyond PHP.

Resource Overhead: Each container consumes memory, CPU, and disk space. Running multiple PHP-FPM containers plus Nginx isn’t free—though on modern hardware, it’s typically measured in hundreds of megabytes, not gigabytes. If you’re developing on a resource-constrained system, this is worth considering.

Debugging Complexity: When something goes wrong, you have multiple layers to investigate: Nginx logs, PHP-FPM logs, container networking, file permissions in mounted volumes. Traditional debugging tools like var_dump() still work, but you’ll also need to be comfortable with docker-compose logs and docker exec.

Platform-Specific Considerations: Docker’s volume performance varies by host OS. On Linux, mounted volumes are nearly native speed. On macOS and Windows, the performance penalty can be noticeable, especially for lots of small file operations. Some developers work around this by minimizing filesystem operations during development or using Docker sync tools.

Tooling Integration: Your IDE or editor may need configuration to work properly with Docker. Debugging with Xdebug, for instance, requires additional setup to forward ports and configure path mappings. These are solvable problems—but they add friction.

Common Pitfalls and Solutions

Based on experience with this pattern, here are issues you’re likely to encounter and how to address them.

Volume Permission Problems

Problem: Files created by PHP inside containers are owned by the container’s user (typically www-data), causing permission conflicts when your host user tries to edit them.

Solution: Adjust the PHP-FPM user to match your host user’s UID/GID. Add this to your Dockerfiles:

ARG USER_ID=1000
ARG GROUP_ID=1000
RUN groupadd -g $GROUP_ID developer && \
    useradd -u $USER_ID -g $GROUP_ID -s /bin/sh -m developer && \
    usermod -a -G www-data developer

Then pass your actual UID/GID at build time or via the Compose file. Alternatively, set user: "${UID:-1000}:${GID:-1000}" in the Compose service definitions.

Nginx “File Not Found” Errors

Problem: Nginx returns 404 even though your PHP files exist in the mounted volumes.

Solution: Verify the paths in your Nginx configuration match the container mount points. Remember that Nginx runs in its own container; root /var/www/html/project-legacy; refers to the path inside the Nginx container, which should be mounted from your host’s ./project-legacy directory. Use docker-compose exec nginx ls -la /var/www/html/ to inspect from Nginx’s perspective.

Slow Performance on macOS/Windows

Problem: File operations seem sluggish—autoloading takes longer than expected.

Solution: Consider these options:

  • Use cached volume mounts (macOS: :cached, Windows: :cached or :delegated)
  • Minimize filesystem operations in development (opcache helps here)
  • For extreme cases, develop directly in a VM on the same filesystem as your containers
  • Evaluate whether the overhead is acceptable for your workflow

Container Networking Issues

Problem: Services can’t communicate—php74 or php82 not found.

Solution: Docker Compose automatically creates a network and sets up DNS resolution by service name. Ensure you’re using the correct service names (php74, php82) in your Nginx fastcgi_pass directives. Also verify your containers are on the same Docker network with docker network inspect your-dev-environment_default.

Debugging Production Builds

Problem: The Docker images you use in development differ from production.

Solution: Use the same Dockerfiles across environments. You might add build arguments for production optimizations (like disabling Xdebug), but the core images should match. Build a production image with docker-compose -f docker-compose.yml -f docker-compose.prod.yml build and test it locally before deployment.

Extending the Pattern

Our example is intentionally minimal. Let’s discuss how you’d extend it for real-world applications.

Adding More PHP Versions

To add PHP 8.3, create:

mkdir php83
# Add php83/Dockerfile with FROM php:8.3-fpm
# Update nginx/default.conf with a new server block
# Add the php83 service to docker-compose.yml

That’s it—the pattern generalizes cleanly.

Handling Database Services

Most PHP applications need a database. You can add MySQL, PostgreSQL, or any other service to the same docker-compose.yml:

  mysql:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: secret
      MYSQL_DATABASE: myapp
    volumes:
      - mysql-data:/var/lib/mysql

Then in your PHP containers, you’ll need to install the appropriate PHP extension (pdo_mysql we already have) and configure connection strings to use the mysql service name as the hostname.

Environment-Specific Configuration

For different environments (development, staging, production), you can use multiple Compose files:

docker-compose -f docker-compose.yml -f docker-compose.dev.yml up
docker-compose -f docker-compose.yml -f docker-compose.prod.yml up

Or use environment variables in a single file with variable substitution:

ports:
  - "${APP_PORT:-8080}:80"

Custom PHP Extensions

Some PHP extensions require system libraries. For example, to use GD with JPEG support:

RUN apt-get update && apt-get install -y libjpeg-dev libpng-dev && \
    docker-php-ext-configure gd --with-jpeg && \
    docker-php-ext-install gd

Generally, check the official PHP Docker image documentation for extension installation details—they cover most common cases.

When Not to Use This Approach

It’s worth being clear about when containerization might be overkill:

  • Single-project, single-version scenarios: If you only ever work on one PHP project with one stable PHP version, a simple system PHP installation or a version manager like phpenv may be simpler.
  • Tightly constrained resources: On systems with less than 4GB RAM, running multiple containers alongside your IDE may be impractical.
  • Extremely simple deployments: If your deployment target is shared hosting with no Docker support, containerization offers less advantage—though it still provides development consistency.
  • When your team resists the learning curve: If key team members are unwilling to learn Docker basics, adoption will fail regardless of technical merits.

And of course, we should acknowledge that alternatives like phpenv have their own strengths. For running CLI scripts or command-line tools, phpenv might be more convenient than spinning up containers. Likewise, if you’re already comfortable with Vagrant or system package managers and your workflow is working, there’s no imperative to switch—this isn’t a legal requirement, and I am not here to tell you what to do with your development environment.

Conclusion: A Flexible Foundation

We’ve walked through a complete containerized setup for running multiple PHP versions side-by-side. The key insight is this: by isolating each PHP version in its own container and using a reverse proxy to route requests, we achieve version coexistence without dependency conflicts.

That said, this is just one approach. The Docker ecosystem evolves rapidly—new tools, best practices, and patterns emerge regularly. I’d encourage you to explore further: look into multi-stage builds for smaller images, Docker BuildKit for faster builds, or even Kubernetes if you’re scaling to many services. The principles we’ve covered—isolation, reproducibility, clear separation of concerns—apply across these technologies.

Ultimately, the goal is to spend less time fighting environment issues and more time writing PHP code. If containerization helps you achieve that, it’s worth the investment. If not, there are other paths. Choose the approach that works for your context, your team, and your projects.

Happy containerizing!

Sponsored by Durable Programming

Need help with your PHP application? Durable Programming specializes in maintaining, upgrading, and securing PHP applications.

Hire Durable Programming