Docker Strategy for Testing PHP Version Upgrades
An elephant in the Serengeti can sense an approaching storm from miles away through low-frequency vibrations that travel through the earth—infrasound that warns of coming rain, thunder, and lightning. This early warning system lets the herd prepare, protect vulnerable calves, and navigate to safety before danger arrives.
Breaking changes in PHP function similarly: you need to detect them early in your upgrade process, assess their impact on vulnerable parts of your codebase, and navigate around them before they cause production failures. Docker provides that early detection capability—a controlled environment where you can test your application against new PHP versions and sense incompatibilities before they strike.
In this guide, we’ll walk through a comprehensive Docker-based strategy to ensure your application is fully compatible with a new PHP version before you make the switch in production.
Before we dive into Docker specifically, let’s discuss three common approaches to PHP upgrade testing: staging servers, virtual machines, and Docker. Understanding the trade-offs will help you make an informed decision about which approach fits your circumstances.
First, staging servers are full server environments that mirror your production setup. The advantage here is fidelity—your staging environment can be an exact copy of production, including the same operating system, web server, database, and PHP version. This makes it excellent for final verification before deployment. However, staging servers have significant drawbacks: they can be costly to maintain, may not be readily available for experimental upgrades, and often require coordination across teams. Additionally, testing multiple PHP versions simultaneously on a single staging server is cumbersome.
Second, virtual machines (using tools like Vagrant, VirtualBox, or VMware) provide complete isolation. You can spin up a VM specifically for upgrade testing, install the target PHP version, and destroy it when done. VMs offer good environment control and avoid affecting your main development machine. The main downside is speed—VMs are resource-intensive, boot times can be minutes rather than seconds, and managing multiple VMs for different PHP versions becomes unwieldy. They also consume significant disk space.
Third, Docker containers—the approach we’ll focus on—provide a middle ground. Like VMs, containers offer isolation, but they’re much lighter weight because they share the host kernel. Spinning up a new container with a different PHP version typically takes seconds, not minutes. Dockerfiles and docker-compose.yml files provide reproducible, version-controlled definitions of your environment. You can run multiple containers simultaneously on different ports without conflicts. The trade-off is that containers share the host kernel, so the isolation isn’t as complete as a true VM—but for PHP upgrade testing, this level of isolation is usually more than sufficient.
I’ve chosen to focus on Docker in this guide because, for the specific problem of testing PHP version compatibility, it offers the best balance of speed, isolation, and reproducibility. That said, if your team already has a well-maintained staging environment and the infrastructure budget, that approach can work well too. Similarly, if you need extreme isolation (e.g., testing kernel-level changes or different operating systems), VMs might be more appropriate. Nevertheless, Docker’s advantages make it the most accessible and efficient solution for most PHP upgrade scenarios.
Why Use Docker for PHP Upgrade Testing?
Before Docker, testing a version upgrade often involved provisioning a new server, using a brittle virtual machine, or modifying the local development environment directly. These methods are slow, costly, and not easily reproducible.
Docker solves these problems by providing:
- Isolation: Each version can be tested in a self-contained environment, leaving your local machine untouched.
- Reproducibility: A
Dockerfileanddocker-compose.ymldefine the exact environment, ensuring consistency across all tests and developers. - Speed: Spinning up a new container with a different PHP version typically takes seconds, not hours.
- Disposability: Test environments can be created and destroyed with a single command, keeping your workflow clean.
Prerequisites
Before you begin, ensure you have the following:
- Docker and Docker Compose installed on your development machine. The commands in this guide assume Docker Compose V2 syntax (
docker compose) but legacy V1 (docker-compose) will also work with minor adjustments. - Current and target PHP versions clearly identified. For example, you might be testing an upgrade from PHP 8.1 to PHP 8.2. You’ll need to know both versions before proceeding.
- Composer dependencies committed and up to date. Your
composer.jsonandcomposer.lockshould reflect your current production state. - Test suite configured and passing on your current PHP version. This could be PHPUnit, Pest, or any other testing framework. The strategy relies on your test suite to detect incompatibilities.
- Basic understanding of Docker concepts such as images, containers, volumes, and Dockerfiles. If you’re new to Docker, consider reviewing the official Docker documentation first.
- Familiarity with your application’s service dependencies (e.g., database credentials, environment variables, external APIs). You’ll need to replicate these in your Docker environment.
The Strategy: A Step-by-Step Guide
Our strategy involves creating a dedicated Docker environment that mirrors production, running your test suite against it, and then swapping out the PHP version to identify incompatibilities.
Step 1: Define Your Test Environment with a Dockerfile
Create a Dockerfile specifically for testing. This file defines the environment for your application, starting from a base PHP image. For this example, let’s assume we are testing an upgrade from PHP 8.1 to 8.2. Of course, you’ll need to adjust the version numbers to match your specific situation.
Start with your current version to establish a baseline.
# Dockerfile.test
# Start with your current PHP version to establish a baseline
FROM php:8.1-fpm
# Install system dependencies
RUN apt-get update && apt-get install -y \
libzip-dev \
unzip \
&& docker-php-ext-install pdo_mysql zip
# Install Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# Set the working directory
WORKDIR /var/www/html
# Copy application code
COPY . .
# Install application dependencies
RUN composer install --no-interaction --prefer-dist
This Dockerfile creates a baseline image with PHP 8.1, installs necessary extensions, and installs your project’s dependencies using Composer.
Step 2: Orchestrate Services with Docker Compose
Your application likely depends on other services like a web server (Nginx) or a database (MySQL). A docker-compose.yml file is used to manage these services together.
# docker-compose.test.yml
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile.test
volumes:
- .:/var/www/html
db:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: my_app_test
ports:
- "3307:3306" # Use a different host port to avoid conflicts
web:
image: nginx:latest
ports:
- "8081:80"
volumes:
- .:/var/www/html
- ./nginx.conf:/etc/nginx/conf.d/default.conf
Step 3: Run Your Baseline Test Suite
Now, build the Docker images and run the containers in detached mode.
docker-compose -f docker-compose.test.yml build
docker-compose -f docker-compose.test.yml up -d
Safety reminder: Before bringing up services, ensure you don’t have any containers already running on the same ports that could cause conflicts. The docker-compose.test.yml file uses alternate ports (3307, 8081) to avoid this, but it’s good practice to check with docker ps if you’re uncertain.
Now, with the environment running, execute your test suite. This could be PHPUnit, Pest, or any other testing framework. The goal is to ensure your tests pass on the current PHP version.
docker-compose -f docker-compose.test.yml exec app vendor/bin/phpunit
For a more comprehensive baseline, consider adding these additional checks:
- Static Analysis: Run PHPStan or Psalm to catch type-related issues that may not surface in tests.
- Deprecation Detection: Enable
E_DEPRECATEDandE_USER_DEPRECATEDin your test environment to surface any deprecated functionality. You can do this by settingPHP_FLAGS=-d error_reporting=E_ALLin your docker-compose or Dockerfile. - Composer Validation: Run
composer validateto ensure yourcomposer.jsonis valid and all dependencies resolve correctly. - Database Migrations: If your app uses a database, run migrations and verify they execute without errors:
docker-compose -f docker-compose.test.yml exec app php artisan migrate(for Laravel) or the equivalent for your framework.
If all tests pass, you therefore have a stable baseline from which to proceed.
Step 4: Swap the PHP Version and Re-Test
With your baseline established, we now come to the critical step: swapping the PHP version to surface incompatibilities. Modify your Dockerfile.test to use the new PHP version.
# Dockerfile.test
# Change the FROM line to the target PHP version
FROM php:8.2-fpm
# ... rest of the file remains the same
Rebuild your application image with the new PHP version.
docker-compose -f docker-compose.test.yml build
Once the build is complete, spin up the new environment and re-run your test suite.
docker-compose -f docker-compose.test.yml up -d
docker-compose -f docker-compose.test.yml exec app vendor/bin/phpunit
This run will expose any issues, including:
- Fatal Errors: From breaking changes in the new PHP version.
- Deprecation Notices: Your test runner will report any functions or features that are deprecated and will be removed in the future. It’s crucial to fix these now.
- Test Failures: Subtle changes in language behavior might cause existing tests to fail.
Address the issues, rebuild, and re-test until your entire suite passes on the new PHP version.
Troubleshooting
When issues arise—and they often do during version upgrades—you’ll need to diagnose and resolve them systematically. Here are common problems you might encounter and their solutions:
Missing PHP Extensions
Symptom: Errors like “Class ‘PDO’ not found” or “Call to undefined function mb_strlen()”.
Solution: The new base PHP image might not include extensions that were present in your previous version. You’ll need to install them in your Dockerfile.
First, identify which extensions your application needs. Check your composer.json for platform requirements, and review any php.ini files in your project. Common extensions include:
RUN apt-get update && apt-get install -y \
libzip-dev \
unzip \
libpng-dev \
libjpeg-dev \
libfreetype6-dev \
libpq-dev \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install -j$(nproc) gd \
&& docker-php-ext-install pdo_mysql pdo_pgsql zip mbstring exif pcntl bcmath opcache
The specific extensions depend on your application. For Laravel, you typically need pdo_mysql (or pdo_pgsql), mbstring, exif, and bcmath. For WordPress, mysqli and gd are common.
Dependency Conflicts
Symptom: Composer fails with messages like “Your requirements could not be resolved to an installable set of packages” or specific packages requiring a different PHP version.
Solution: Some of your third-party packages may not yet support the target PHP version. There are several approaches:
-
Update your dependencies: Run
composer updateto get newer versions that may have added compatibility. Before updating, check each package’s changelog and the Packagist page for PHP version support. -
Find alternatives: If a critical dependency has no plans to support the new PHP version, you may need to replace it with an alternative package. Search Packagist for compatible replacements.
-
Use conflict resolution strategies: Composer provides flags like
--with-all-dependenciesto update dependencies along with your packages. Be cautious—this can introduce breaking changes from libraries. -
Temporarily patch: For open-source dependencies, you can fork the repository, apply compatibility fixes, and point your
composer.jsonto your fork:
{
"require": {
"vendor/package": "dev-php8.2-compatibility as 1.2.3"
},
"repositories": [
{
"type": "vcs",
"url": "https://github.com/yourusername/package"
}
]
}
Deprecation Notices
Symptom: Tests pass but you see warnings about deprecated functions, or tests fail because deprecations are treated as errors.
Solution: Deprecation notices are your friends—they tell you what will break in future versions. Don’t ignore them.
First, ensure your test environment surfaces deprecations. In your phpunit.xml or test bootstrap:
<php>
<ini name="error_reporting" value="-1" />
<ini name="display_errors" value="1" />
</php>
Or set E_ALL in your Dockerfile:
RUN echo "error_reporting = E_ALL" >> /usr/local/etc/php/conf.d/custom.ini \
&& echo "display_errors = On" >> /usr/local/etc/php/conf.d/custom.ini
Once deprecations are visible, address them systematically:
- For internal code: Update your code to use recommended alternatives. PHP’s migration guides (e.g., php.net/manual/en/migration81.php) list all removed and deprecated features.
- For vendor code: If a dependency triggers deprecations, consider updating to a newer version. If none exists, you might need to fork and fix the package yourself, or look for alternatives.
- Suppress temporarily (not recommended): If you must suppress deprecations to unblock testing, use
error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED)but treat this as a temporary measure only.
Docker Build Failures
Symptom: docker-compose build fails with network errors, package not found, or permission denied.
Common causes and solutions:
-
Network or proxy issues: If you’re behind a corporate firewall, Docker may not be able to reach
packages.php.netor Debian mirrors. Configure Docker’s proxy settings or ensure your host machine has internet access. -
Out of disk space: Docker images can be large. Check with
docker system df. Clean up unused images withdocker system prune -a(be cautious—this removes stopped containers and unused images). -
Permission errors: On Linux, you might need to prefix Docker commands with
sudo, or add your user to thedockergroup (sudo usermod -aG docker $USER). -
Dockerfile syntax errors: Ensure your
Dockerfilefollows correct syntax. EachRUNcommand should be on its own line or properly continued with\. The base imagephp:8.x-fpmmust exist—check Docker Hub for available tags. -
Composer memory limits: Composer can require more memory than default PHP limits. Add this to your Dockerfile before running
composer install:
RUN echo "memory_limit = -1" >> /usr/local/etc/php/conf.d/custom.ini
Database Connection Issues
Symptom: Tests fail with “SQLSTATE[HY000] [2002] Connection refused” or similar database errors.
Solution: Verify that your database service is running and your application is configured to connect to it correctly.
-
Check service status:
docker-compose -f docker-compose.test.yml psshould show bothappanddbcontainers as “Up”. -
Verify database credentials: In your test environment configuration, ensure DB_HOST points to the Docker service name (usually
dbfor the service defined in docker-compose, notlocalhost). The port should be 3306 (the internal port), not 3307 (the host-mapped port). -
Wait for database initialization: Some databases take a moment to start. If your app container starts before the database is ready, connections will fail. Add a
depends_onwith health check in your docker-compose:
services:
app:
depends_on:
db:
condition: service_healthy
db:
image: mysql:8.0
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
- Check volume persistence: If you’re using named volumes, ensure they’re properly configured and not corrupted.
Test Environment Not Reflecting Production
Symptom: Tests pass in Docker but fail in production, or vice versa.
Solution: Your test environment should mirror production as closely as possible. Common discrepancies:
-
Different PHP configuration: Check
phpinfo()in both environments and compare settings likememory_limit,upload_max_filesize,post_max_size,error_reporting. -
Missing environment variables: Ensure all environment variables your app expects are defined in your docker-compose.yml under the
appservice’senvironmentsection. -
Different service versions: Your test MySQL might be 8.0 while production uses 5.7. Use the same version numbers if possible.
-
Filesystem differences: Case sensitivity (Linux vs macOS/Windows), symlink handling, and file permissions can differ. Ensure your Docker environment uses a Linux filesystem (the PHP Docker images do) and that volume mounts maintain correct permissions.
-
Network configuration: If your app communicates with external APIs, ensure those endpoints are accessible from within Docker. You may need to configure
extra_hostsor network settings.
Rebuilder’s Remorse: Forgetting to Rebuild
Symptom: You changed the Dockerfile but keep running old tests.
Solution: Always rebuild after changing the Dockerfile. Use --no-cache if you suspect caching issues:
docker-compose -f docker-compose.test.yml build --no-cache
docker-compose -f docker-compose.test.yml up -d
You can also combine with docker-compose -f docker-compose.test.yml up -d --build to build and start in one step.
Volumes Causing Unexpected Behavior
Symptom: Changes to application code don’t appear in the container, or test artifacts persist between runs.
Solution: Understand your volume mounts. The .:/var/www/html mount means the container uses your local files, which is great for development but means changes are immediately visible. However, cached data or generated files might persist.
To start fresh:
docker-compose -f docker-compose.test.yml down -v # removes volumes
docker-compose -f docker-compose.test.yml up -d
The -v flag removes named volumes, but anonymous volumes are not removed. Use docker volume prune to clean all unused volumes (be cautious).
Parallel Test Interference
Symptom: Tests fail intermittently only when run in parallel.
Solution: If you use parallel testing (e.g., phpunit --processes), ensure your test database can handle concurrent connections and that tests are properly isolated. Use separate databases or schemas per process. Docker’s network isolation should not interfere, but shared resources like caches or filesystems might need adjustment.
Performance Bottlenecks in Containerized Testing
Symptom: Tests run significantly slower in Docker than on host machine.
Solution: Filesystem performance can be a factor, especially with bind mounts on macOS/Windows Docker Desktop. Strategies:
- Use cached or delegated mount options for better I/O performance:
volumes: - .:/var/www/html:cached - Run tests inside the container without bind mounts (copy code into image instead) if you’re benchmarking performance.
- Ensure Composer dependencies are cached in the Docker image layers to avoid reinstalling on every build.
- Use PHP OPcache in your Docker image to speed up test runs.
When in doubt, check container resource limits—ensure Docker has sufficient CPU and memory allocated in Docker Desktop settings or your host’s Docker configuration.
Conclusion
By leveraging Docker, you can transform a risky PHP upgrade into a methodical, safe, and predictable process. This strategy provides a reliable feedback loop, allowing you to identify and fix incompatibilities in an isolated environment. Adopting this Docker-based approach will give you the confidence to keep your applications secure, performant, and up-to-date with the latest PHP versions. To take this a step further, you can integrate this Docker-based testing strategy into a CI/CD pipeline to automate the process. Learn how to set up Continuous Integration for PHP Version Testing.
Sponsored by Durable Programming
Need help with your PHP application? Durable Programming specializes in maintaining, upgrading, and securing PHP applications.
Hire Durable Programming