Docker for PHP Developers: A Practical Guide to Replacing LAMP Stack

Docker for PHP Developers: A Practical Guide to Replacing LAMP Stack

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 LAMPDocker LAMP
Setup time (new dev)2–4 hours< 5 minutes
PHP version switchphpenv / Homebrew conflictsChange one line in Dockerfile
“Works on my machine”Common problemEliminated by design
OnboardingManual docs, varies by OSdocker compose up — done
Multiple projectsPort conflicts, config clashesEach project isolated
CleanupMessy uninstalldocker compose down -v
Production parityOften different from localUse same image everywhere
DB GUIInstall phpMyAdmin separatelyIncluded in compose file
LogsScattered across /var/logdocker compose logs -f
Resource usageAlways running, system-wideStart/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.

Author

Max Pronko

E-commerce Architect

  • 20+ years in software development
  • 16+ years working with Magento / Adobe Commerce
  • Founder of Pronko Consulting
  • 10k+ developers on YouTube
GET IN TOUCH

Need help with your e-commerce platform?

I help companies design scalable Magento and e-commerce architectures.

✓ Architecture reviews
✓ Magento technical audits
✓ Performance optimization

Work Width Me
Scroll to Top