If you have been developing PHP applications for more than a few years, you almost certainly started with a LAMP stack — Linux, Apache, MySQL, and PHP running directly on your machine or a shared server. It worked. It still works. But if you have ever spent an afternoon debugging a “works on my machine” problem, onboarded a new developer only to discover their environment is subtly different from yours, or fought with Homebrew conflicts and PHP version switches, you already know the pain that Docker was built to solve.
This guide is a practical, hands-on introduction to Docker for PHP developers. By the end, you will have a fully working Dockerized LAMP stack running locally, understand why each piece exists, and know how to extend it for real-world Magento, Laravel, or WordPress projects.
What Is Docker and Why Should PHP Developers Care?
Docker is a containerization platform. A container is a lightweight, isolated environment that packages your application code together with everything it needs to run — the PHP version, extensions, web server configuration, and system libraries — into a single portable unit.
The key difference from a virtual machine: containers share the host operating system kernel. They start in milliseconds, use a fraction of the memory a VM needs, and run identically on your MacBook, your colleague’s Windows machine, and your production Ubuntu server.
The Classic LAMP Problems Docker Solves
One of the most common frustrations in PHP development is managing multiple PHP versions across projects. You might need PHP 7.4 for a legacy Magento 1 store and PHP 8.3 for a new Laravel project running side by side. With a traditional LAMP setup, this means fighting phpenv, Homebrew, or OS package managers — and even when it works, it is fragile. Docker solves this cleanly: each project defines its own PHP version in a single line of its Dockerfile, and the two never interfere.
The second problem is the infamous “works on my machine” issue. One developer has a slightly different Apache configuration, another is missing the intl extension, a third is running MySQL 5.7 while production uses 8.0. These differences are invisible until they cause bugs — often in production, often at the worst possible time. When you run Docker, every developer on the team starts from the exact same image with the exact same extensions and configuration. There is nothing to drift.
Onboarding is where Docker’s advantage becomes most visible. When a new developer joins a project using a traditional LAMP stack, they typically spend half a day — sometimes a full day — following a setup document, hitting OS-specific problems, and asking colleagues for help. With Docker, they clone the repository, run docker compose up -d, and the entire stack is running in under two minutes. The setup document becomes a single command.
Production parity is a subtler but equally important benefit. A common scenario: your local environment runs Apache, but production runs Nginx. Your local PHP has opcache disabled for easy debugging, but production has it tuned aggressively. These differences accumulate and create bugs that only appear in production. Docker lets you run a configuration that mirrors production as closely as possible — same server, same PHP settings, same environment variables.
Finally, cleanup. Removing a traditional LAMP installation from your machine is genuinely messy: stray config files, MySQL data directories, PHP extensions compiled against the wrong version. With Docker, tearing down an entire environment is one command — docker compose down -v — and your machine is clean.
Core Docker Concepts in Plain English
Before writing any configuration, you need to understand four terms. These will appear constantly and the rest of the guide will make much more sense once they click.
Image
A Docker image is a read-only template — like a snapshot or a blueprint. The official php:8.3-apache image contains Debian Linux, Apache, and PHP 8.3 pre-installed. You download images from Docker Hub or build your own by writing a Dockerfile.
Container
A container is a running instance of an image. You can run ten containers from the same image simultaneously, each isolated from the others. Think of it like this: an image is a class definition, a container is an object instance.
Dockerfile
A Dockerfile is a text file with instructions for building a custom image. You start from an official base image and layer your customizations on top — installing PHP extensions, copying configuration files, setting environment variables.
Docker Compose
Real applications need multiple containers working together: one for PHP/Apache, one for MySQL, one for Redis. Docker Compose is a tool that lets you define and manage all of them in a single docker-compose.yml file. One command starts everything, another stops it.
Project Structure
Before writing any configuration, create the following directory layout. This structure works cleanly for any PHP project — Laravel, Magento, WordPress, or plain PHP.
my-php-project/ ├── docker/ │ ├── php/ │ │ ├── Dockerfile │ │ └── php.ini │ ├── apache/ │ │ └── vhost.conf │ └── mysql/ │ └── init.sql ├── src/ ← your PHP application code │ └── index.php ├── docker-compose.yml └── .env
Keeping Docker configuration in a docker/ subdirectory keeps your project root clean and makes it clear which files are infrastructure versus application code.
The docker-compose.yml File: Your LAMP Stack Defined
This is the heart of the setup. Every service — Apache/PHP, MySQL, and phpMyAdmin for convenience — is defined here. Create docker-compose.yml in your project root:
services:
# ─── PHP + Apache ─────────────────────────────────────────
app:
build:
context: ./docker/php
dockerfile: Dockerfile
container_name: php_app
volumes:
- ./src:/var/www/html # mount your code
- ./docker/apache/vhost.conf:/etc/apache2/sites-enabled/000-default.conf
- ./docker/php/php.ini:/usr/local/etc/php/conf.d/custom.ini
ports:
- "8080:80" # access at http://localhost:8080
depends_on:
db:
condition: service_healthy # wait until MySQL is ready
environment:
- APP_ENV=${APP_ENV:-development}
networks:
- lamp_network
# ─── MySQL ────────────────────────────────────────────────
db:
image: mysql:8.0
container_name: mysql_db
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-rootpassword}
MYSQL_DATABASE: ${MYSQL_DATABASE:-appdb}
MYSQL_USER: ${MYSQL_USER:-appuser}
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-apppassword}
volumes:
- mysql_data:/var/lib/mysql # persist data across restarts
- ./docker/mysql/init.sql:/docker-entrypoint-initdb.d/init.sql
ports:
- "3306:3306"
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
networks:
- lamp_network
# ─── phpMyAdmin ───────────────────────────────────────────
phpmyadmin:
image: phpmyadmin:latest
container_name: phpmyadmin
environment:
PMA_HOST: db
PMA_PORT: 3306
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-rootpassword}
ports:
- "8081:80" # access at http://localhost:8081
depends_on:
- db
networks:
- lamp_network
volumes:
mysql_data:
networks:
lamp_network:
driver: bridge
Notice the healthcheck on the db service and the depends_on condition on the app service. Without this, PHP might try to connect to MySQL before MySQL has finished initializing — a common source of startup errors.
The PHP Dockerfile: Installing Extensions
The official php:8.3-apache image gives you PHP and Apache but ships without the extensions most PHP applications need. Your Dockerfile installs them:
# docker/php/Dockerfile
FROM php:8.3-apache
# Install system dependencies
RUN apt-get update && apt-get install -y \
libzip-dev \
libpng-dev \
libjpeg-dev \
libfreetype6-dev \
libxml2-dev \
libonig-dev \
unzip \
git \
curl \
&& rm -rf /var/lib/apt/lists/*
# Install PHP extensions
RUN docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install -j$(nproc) \
pdo_mysql \
mysqli \
gd \
zip \
bcmath \
intl \
soap \
opcache
# Install Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# Enable Apache mod_rewrite (required for most PHP frameworks)
RUN a2enmod rewrite
# Set working directory
WORKDIR /var/www/html
# Fix file permission issues (maps to host UID on Linux)
ARG UID=1000
RUN usermod -u ${UID} www-data
The docker-php-ext-install helper is unique to official PHP images — it handles the compile configuration automatically. For extensions not available through it (like xdebug or redis), use pecl install.
Adding Xdebug for Local Development
# Add to your Dockerfile after the extensions block: RUN pecl install xdebug && docker-php-ext-enable xdebug # docker/php/php.ini — Xdebug configuration [xdebug] xdebug.mode=debug xdebug.start_with_request=yes xdebug.client_host=host.docker.internal xdebug.client_port=9003 xdebug.idekey=PHPSTORM
host.docker.internal is a special DNS name that resolves to your host machine from inside a Docker container. It works on macOS and Windows automatically. On Linux, add --add-host=host.docker.internal:host-gateway to your service definition.
Apache Virtual Host Configuration
The default Apache configuration does not enable AllowOverride All, which means .htaccess files — used by Laravel, WordPress, and Magento for URL rewriting — will be silently ignored. Fix this in docker/apache/vhost.conf:
# docker/apache/vhost.conf
<VirtualHost *:80>
ServerName localhost
DocumentRoot /var/www/html/public
<Directory /var/www/html/public>
Options Indexes FollowSymLinks
AllowOverride All
Require all granted
</Directory>
# PHP error logging to Docker stdout
ErrorLog /dev/stderr
CustomLog /dev/stdout combined
# Security headers
Header always set X-Content-Type-Options nosniff
Header always set X-Frame-Options DENY
</VirtualHost>
Pointing DocumentRoot to /var/www/html/public follows the convention used by Laravel and Symfony. For plain PHP or WordPress, change it to /var/www/html. For Magento 2, use /var/www/html/pub.
PHP Configuration (php.ini)
PHP ships with conservative defaults designed for shared hosting. For local development and modern applications, you need different values:
# docker/php/php.ini [PHP] memory_limit = 512M max_execution_time = 300 upload_max_filesize = 64M post_max_size = 64M max_input_vars = 10000 [Date] date.timezone = Europe/Kiev [opcache] opcache.enable = 1 opcache.memory_consumption = 256 opcache.max_accelerated_files = 20000 ; Disable in development — enable in production ; opcache.validate_timestamps = 0 [error] display_errors = On display_startup_errors = On error_reporting = E_ALL log_errors = On error_log = /dev/stderr
Keep two php.ini files: one for development (errors on, opcache timestamps on) and one for production (errors off, opcache timestamps off). Use Docker build args or environment variables to switch between them in CI/CD.
Environment Variables with .env
Docker Compose automatically loads a .env file from the project root. This keeps credentials out of docker-compose.yml and out of version control. Create .env:
# .env — never commit this file APP_ENV=development MYSQL_ROOT_PASSWORD=supersecretroot MYSQL_DATABASE=appdb MYSQL_USER=appuser MYSQL_PASSWORD=apppassword # .env.example — commit this file (no real values) APP_ENV=development MYSQL_ROOT_PASSWORD= MYSQL_DATABASE= MYSQL_USER= MYSQL_PASSWORD=
Add .env to .gitignore. Commit .env.example so new developers know what variables are required.
Daily Docker Commands for PHP Developers
Once the stack is running, these are the commands you will use every day:
# Start the entire stack (detached — runs in background) docker compose up -d # View real-time logs from all services docker compose logs -f # View logs from a specific service only docker compose logs -f app # Open a shell inside the PHP container docker compose exec app bash # Run a Composer command inside the container docker compose exec app composer install docker compose exec app composer require vendor/package # Run PHP CLI scripts docker compose exec app php artisan migrate # Laravel docker compose exec app php bin/magento cache:flush # Magento 2 # Rebuild after changing Dockerfile docker compose up -d --build app # Stop everything (keep data volumes) docker compose down # Stop everything AND delete all data (full reset) docker compose down -v
Pro tip: Add shell aliases to your .bashrc or .zshrc to save hundreds of keystrokes per day:
alias dc="docker compose" alias dce="docker compose exec app" # Usage: dce bash dce composer install dce php bin/magento cache:flush
Traditional LAMP vs Docker: Side by Side
| Traditional LAMP | Docker LAMP | |
| Setup time (new dev) | 2–4 hours | < 5 minutes |
| PHP version switch | phpenv / Homebrew conflicts | Change one line in Dockerfile |
| “Works on my machine” | Common problem | Eliminated by design |
| Onboarding | Manual docs, varies by OS | docker compose up — done |
| Multiple projects | Port conflicts, config clashes | Each project isolated |
| Cleanup | Messy uninstall | docker compose down -v |
| Production parity | Often different from local | Use same image everywhere |
| DB GUI | Install phpMyAdmin separately | Included in compose file |
| Logs | Scattered across /var/log | docker compose logs -f |
| Resource usage | Always running, system-wide | Start/stop per project |
Extending the Stack for Magento 2
Magento 2 has specific requirements beyond the base LAMP stack. Here is what to add to your docker-compose.yml:
# Add to services:
redis:
image: redis:7-alpine
container_name: redis
ports:
- "6379:6379"
networks:
- lamp_network
elasticsearch:
image: elasticsearch:8.11.0
container_name: elasticsearch
environment:
- discovery.type=single-node
- xpack.security.enabled=false
- ES_JAVA_OPTS=-Xms512m -Xmx512m
ports:
- "9200:9200"
volumes:
- es_data:/usr/share/elasticsearch/data
networks:
- lamp_network
# Add to volumes:
es_data:
In your Magento env.php, reference services by their container name — db, redis, elasticsearch — not by localhost. Container names are the hostnames within the Docker network.
# Magento 2 bin/magento commands inside Docker: docker compose exec app php bin/magento setup:upgrade docker compose exec app php bin/magento cache:flush docker compose exec app php bin/magento indexer:reindex
Common Mistakes and How to Avoid Them
Using localhost inside PHP to connect to MySQL
Inside a Docker container, localhost refers to the container itself — not your host machine and not the MySQL container. Use the service name defined in docker-compose.yml as the hostname. If your MySQL service is named db, your PHP DSN is:
// Correct inside Docker
mysqli_connect('db', 'appuser', 'apppassword', 'appdb');
// Wrong — will fail
mysqli_connect('localhost', 'appuser', 'apppassword', 'appdb');
Forgetting to rebuild after Dockerfile changes
If you add a PHP extension or change the Dockerfile, you must rebuild the image. Running docker compose up -d without –build will use the old cached image and your change will have no effect.
docker compose up -d --build app # rebuild app service only docker compose build --no-cache app # force full rebuild, ignore cache
Storing uploaded files inside the container
Anything written inside a container (not to a mounted volume) is lost when the container is removed. Map all directories that store user uploads or generated files to host volumes in your docker-compose.yml.
Running as root inside the container
The default www-data user in Apache containers often has a different UID than your host user. Files created by Apache inside the container may be unreadable or uneditable on the host. The ARG UID=1000 pattern in the Dockerfile example above solves this — pass your actual UID at build time:
docker compose build --build-arg UID=$(id -u) app
Conclusion
Switching from a traditional LAMP stack to Docker is one of the highest-impact changes you can make to your PHP development workflow. The upfront investment — learning docker-compose.yml, understanding images and volumes, adjusting your mental model of localhost — pays off immediately in eliminated environment bugs, faster onboarding, and a development setup that matches production.
Start with the basic setup in this guide and run it for a week. Once it feels natural, extend it: add Redis for session caching, Elasticsearch for search, Mailpit for local email testing. The docker-compose.yml file becomes the single source of truth for your entire development environment.
The result: any developer on your team can clone the repository and be fully productive in under five minutes, on any operating system, with zero setup conflicts. That is the promise of Docker, and for PHP developers, it delivers.

