From XAMPP to Docker: A Better Way to Develop PHP Applications


All code and resources for this post are available on GitHub. You can follow along by cloning the repository!
Last semester in my Computer Science studies, we built a small backend with PHP. To get started, we used XAMPP — just like I did more than 10 years ago when I first learned web development.
That got me thinking: Is XAMPP still the right tool in 2025, or is it time to switch to Docker?
With tools like Docker becoming standard in professional environments, I wanted to take a step back and compare the two. In this post, I’ll share my thoughts on XAMPP vs Docker - and why I believe Docker offers a more modern and efficient way to develop PHP applications today.
What Is XAMPP And Why Isn’t Enough Anymore
XAMPP is a PHP development environment introduced back in 2002. It made it extremely easy to set up an Apache distribution that includes MariaDB, PHP, and Perl. Thanks to its simplicity, it quickly gained popularity among beginners and hobby developers. I used it myself when I first started learning web development.
However, while XAMPP is still functional today, it no longer meets the needs of modern software development. Here are a few reasons why:
Lack of environment consistency: XAMPP runs natively on the operating system, which can lead to differences between development and production environments, especially when working in a team or deploying to the cloud.
Poor isolation: If we're working on multiple projects with different PHP versions or configurations, managing them with XAMPP quickly becomes messy.
Limited scalability: XAMPP isn't designed for setting up microservices or integrating services like Redis, RabbitMQ, or custom databases, all common in modern web stacks.
Not ideal for CI/CD: XAMPP is meant for local development and doesn't integrate well into automated testing or deployment pipelines.
Why Docker Is a Better Fit for Modern PHP Development
All the limitations mentioned above can be addressed with Docker. Instead of installing services directly on the machine, Docker allows to define the entire development environment in code and run it in isolated containers. This brings several key advantages:
Consistency across environments: With Docker, the local setup can exactly match staging and production. If it works in a container, it works everywhere.
Project isolation: Each project can run in its own container with its own PHP version, extensions, and dependencies. This setup prevents conflicts and manual switching.
Infrastructure as code: With a simple
Dockerfile
anddocker-compose.yml
, your setup is fully documented, versioned, and reproducible. New team members can get started with just adocker compose up
.Scalability and flexibility: Need Redis, MongoDB, or a specific database version? Just add a service to the
docker-compose.yml
, no need for manual installations or config conflicts.CI/CD integration: Docker containers can be used in automated pipelines to build, test, and deploy the application consistently and reliably.
Getting Started: Dockerizing a PHP Application
Let’s walk through a basic example to see how easy it is to set up a PHP project using Docker.
Here’s the simple project structure:
/root
├── compose.yaml
└── /src
└── index.php
The src
folder contains all your application code, such as PHP, CSS, or JavaScript files. This directory will be mounted into the container so that any code changes are immediately reflected without rebuilding the image.
index.php
The index.php
is a basic HTML page with a bit of embedded PHP and Tailwind CSS for styling:
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://unpkg.com/@tailwindcss/browser@4"></script>
<title>PHP Website</title>
</head>
<body class="max-w-4xl mx-auto p-12">
<h1 class="text-4xl font-bold text-clifford mb-6">PHP Website</h1>
<?php
echo "<p>Text from PHP</p>";
?>
</body>
</html>
compose.yaml
With Docker, we can run this site without installing PHP or Apache directly on the machine. Just define the environment in a simple compose.yaml
file:
services:
php-env:
image: php:8.4-apache
volumes:
- ./src:/var/www/html/
ports:
- "9001:80"
This setup does the following:
Uses the official PHP 8.4 image with Apache pre-installed.
Mounts your local
src/
folder into the container’s web root (/var/www/html/
).Maps port 80 inside the container to port 9001 on the local machine.
Running the App
To run the application, simply open a terminal in the project root and run:
docker compose up --build
Then visit http://localhost:9001 in your browser—you’ll see your PHP page live, served through Apache in a Docker container:
Dockerizing a PHP Application with MariaDB
In the previous example, we ran a standalone PHP site in Docker. Now let’s take it further and connect our application to a MariaDB database using Docker Compose.
Here’s the updated project structure:
/root
├── .env
├── compose.yaml
├── Dockerfile
└── /src
├── index.php
├── db_connect.php
└── db-init.sql
The
src/
folder contains your PHP code and an SQL script to initialize the database.The
.env
file stores environment variables (e.g., your database password).The
compose.yaml
file defines all Docker services.The
Dockerfile
builds a custom PHP container with the required extensions.
The .env file
In the .env
file, we store sensitive or environment-specific values to keep them separate from our source code. Docker Compose automatically loads variables from this file, which we reference in compose.yaml
and pass into the container environment.
DB_PASSWORD=dbpassword123
Dockerfile
The php:8.4-apache
image is a good starting point, but it doesn’t come with the PHP extensions needed to connect to a MySQL or MariaDB database. To fix this, we create a custom Dockerfile
where we install the missing extensions:
FROM php:8.4-apache
# Install necessary extensions
RUN docker-php-ext-install pdo pdo_mysql mysqli
compose.yaml
In the compose.yaml
file we define three different services:
The service
db
: This is the MariaDB server, for which we use the latest officialmariadb
image. For the environment variables, we set the database root password (which we defined earlier in the.env
file) and the time zone. We also mount two volumes:db-vol
, which is where the database data is stored persistently, and thedb-init.sql
script from oursrc
folder, which automatically loads some sample data the first time we start the container.The service
pma
: The second service is pma, which stands for phpMyAdmin. This gives us a web interface to manage our MariaDB database directly in the browser. We expose it on port 6080 and link it to the db service so it can connect to the running database.The service
php-env
: The third service is php-env, which is our PHP environment with Apache. Here we use our customDockerfile
that includes the required PHP extensions. We mount the./src
folder into/var/www/html/
so that the container serves our code automatically. We also pass the database password as an environment variable to make it available in the PHP application.
services:
db:
image: mariadb
environment:
- MARIADB_ROOT_PASSWORD=${DB_PASSWORD}
- TZ=Europe/Zurich
restart: always
volumes:
- db-vol:/var/lib/mysql
- ./src/db-init.sql:/docker-entrypoint-initdb.d/db-init.sql:ro # Mount SQL script
pma:
image: phpmyadmin
environment:
- PMA_HOST=db
ports:
- "6080:80"
restart: on-failure:10
depends_on:
- db
php-env:
build: .
volumes:
- ./src:/var/www/html/
ports:
- 9001:80
restart: always
depends_on:
- db
environment:
- DB_PASSWORD=${DB_PASSWORD}
volumes:
db-vol:
The src/ folder
The src/ folder contains all our frontend and backend code—HTML, CSS, JavaScript, and PHP files. This is the directory we mount into our Docker container so it can serve our application.
index.php
In index.php
, we connect to the database, fetch the first five users from the users
table, and display them in an HTML table.
<?php
require_once 'db_connect.php'; // Include the database connection
// Fetch users with error handling
$sql_query = "SELECT id, name, email, created_at FROM users LIMIT 5";
try {
$stmt = $pdo->query($sql_query);
$users = $stmt->fetchAll(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
$users = [];
}
?>
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://unpkg.com/@tailwindcss/browser@4"></script>
<title>PHP with MySQL</title>
</head>
<body class="max-w-4xl mx-auto p-12">
<h1 class="text-4xl font-bold text-clifford mb-6">PHP with MySQL</h1>
<?php if (!empty($users)): ?>
<table class="w-full text-sm text-left text-gray-900 shadow mt-12">
<thead class="text-xs text-gray-700 uppercase bg-gray-50">
<tr>
<th scope="col" class="px-6 py-3">
ID
</th>
<th scope="col" class="px-6 py-3">
Name
</th>
<th scope="col" class="px-6 py-3">
Email
</th>
<th scope="col" class="px-6 py-3">
Created At
</th>
</tr>
</thead>
<tbody>
<?php foreach ($users as $user): ?>
<tr class="bg-white border-b border-gray-200">
<td class="px-6 py-3"><?php echo htmlspecialchars($user['id']); ?></td>
<td class="px-6 py-3"><?php echo htmlspecialchars($user['name']); ?></td>
<td class="px-6 py-3"><?php echo htmlspecialchars($user['email']); ?></td>
<td class="px-6 py-3"><?php echo htmlspecialchars($user['created_at']); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php else: ?>
<p class="text-gray-700 text-lg">No users found.</p>
<?php endif; ?>
</body>
</html>
db_connect.php
The db_connect.php
script handles the connection to our MariaDB database. It uses the service name db
from our compose.yaml
file as the host and gets the password from the .env
file using getenv()
. If the connection fails, it shows a styled error message.
<?php
$host = 'db'; // The service name from docker-compose.yml
$dbname = 'my_database';
$username = 'root';
$password = getenv('DB_PASSWORD');
try {
$pdo = new PDO("mysql:host=$host;dbname=$dbname;charset=utf8", $username, $password);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
} catch (PDOException $e) {
die("<p class='text-red-500 text-lg font-semibold'>Database connection failed. Please try again later:<br>" . $e->getMessage() ."</p>");
}
?>
db-init.sql
This is the initial SQL script that gets executed automatically when we create the MariaDB container for the first time. It creates the database, defines a users table, and inserts three sample users.
Because the script is mounted into the container through the compose.yaml
file, MariaDB runs it automatically the first time the container is created - so the database is ready with data right away.
-- Create database if it doesn't exist
CREATE DATABASE IF NOT EXISTS my_database;
USE my_database;
-- Create a sample table
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(100) UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Insert sample data
INSERT INTO users (name, email) VALUES
('Alice Smith', 'alice@example.com'),
('Bob Johnson', 'bob@example.com'),
('John Doe', 'john@example.com');
Running the App
To get everything running, just execute:
docker compose up --build
Then open http://localhost:9001 to view the PHP app, or http://localhost:6080 to access phpMyAdmin.
Dockerizing a PHP Application with MongoDB
In this example, we build a Dockerized PHP application that connects to a MongoDB database. This setup is slightly more complex than using a traditional SQL database like MySQL, mainly due to how MongoDB integrates with PHP.
One key difference is that the MongoDB extension for PHP is not part of the core PHP source. Instead, it must be installed separately using pecl install mongodb
, and then enabled with docker-php-ext-enable mongodb
. Additionally, we use the official MongoDB PHP library (mongodb/mongodb
) to work with the database in our application code, which is installed using Composer.
To support both development and production workflows cleanly, the setup uses a multi-stage Dockerfile. The development stage allows for live code editing by mounting the local source folder, while the production stage creates a clean, standalone image with the source code and dependencies already baked in.
Because the development container mounts your local src/
directory into the container, it completely replaces the container’s internal source code — including the vendor/
folder that Composer generates. This means the vendor/
folder must exist locally when running in dev mode. To solve this, we provide a helper script, build-vendor.ps1
, which builds the Docker image, extracts the vendor/
folder from the container, and copies it to the local src/
directory. You should run this script once after cloning the project and any time you change composer.json
.
With this setup, you can develop and test your PHP + MongoDB application using live code reloads, and easily switch to a clean, reproducible production build when you're ready to deploy.
The project structure:
/root
├── mongo-init/
│ └── init.js # JavaScript file to initialize MongoDB with seed data
│
├── src/
│ ├── composer.json # Declares PHP dependencies
│ ├── db_connect.php # Handles MongoDB connection
│ ├── index.php # Main entry point of the PHP web application
│ └── vendor/ # Installed PHP libraries (created by build-vendor.ps1 in dev)
│
├── .dockerignore # Excludes files/folders from the Docker build context
├── .env # Contains environment variables like DB credentials
├── build-vendor.ps1 # PowerShell script to extract vendor/ from image to local src/
├── compose.dev.yaml # Docker Compose file for development (live code editing, volume mount)
├── compose.prod.yaml # Docker Compose file for production (clean image, no volume mount)
├── Dockerfile # Multi-stage Dockerfile (composer-builder, dev, prod targets)
The .env file
In the .env
file, we store the db username and the db password.
DB_PASSWORD=dbpassword123
DB_USER=root
Dockerfile
The Dockerfile
is a multi-stage build with three clearly separated stages:
Composer Build Stage: This stage installs PHP dependencies using Composer. It’s used to generate the vendor/ folder based on the composer.json file.
Development Stage: This stage is optimized for development. It supports live code updates via volume mounting (./src:/var/www/html), allowing changes to be reflected without rebuilding the container. Because the volume mount overwrites the container’s internal code, the vendor/ folder must also exist locally in ./src. This can be generated using the build-vendor.ps1 script.
Production Stage: This final stage is optimized for deployment. It copies the complete source code and vendor/ folder into the container, creating a clean, self-contained image without volume mounts or development tools.
# ----------------------------------------
# Stage 1: Composer build stage (shared)
# ----------------------------------------
FROM php:8.4-cli AS composer-builder
RUN apt-get update && apt-get install -y \
unzip curl git zip libssl-dev libcurl4-openssl-dev pkg-config \
&& pecl install mongodb \
&& docker-php-ext-enable mongodb
# Install Composer
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
# Copy composer config and install dependencies
WORKDIR /app
COPY ./src/composer.json ./
RUN composer install --no-dev --no-interaction --optimize-autoloader
# ----------------------------------------
# Stage 2: Development container
# ----------------------------------------
FROM php:8.4-apache AS dev
RUN apt-get update && apt-get install -y \
unzip curl git zip libssl-dev libcurl4-openssl-dev pkg-config \
&& pecl install mongodb \
&& docker-php-ext-enable mongodb
RUN a2enmod rewrite
WORKDIR /var/www/html
# Copy source code for dev (can be overridden with volume)
COPY ./src/ /var/www/html/
COPY --from=composer-builder /app/vendor /var/www/html/vendor/
# ----------------------------------------
# Stage 3: Production container
# ----------------------------------------
FROM php:8.4-apache AS prod
RUN apt-get update && apt-get install -y \
unzip curl git zip libssl-dev libcurl4-openssl-dev pkg-config \
&& pecl install mongodb \
&& docker-php-ext-enable mongodb
RUN a2enmod rewrite
WORKDIR /var/www/html
# Copy source and vendor for final deployable image
COPY ./src/ /var/www/html/
COPY --from=composer-builder /app/vendor /var/www/html/vendor/
build-vendor.ps1
The build-vendor.ps1 script is used to prepare the development environment by generating the vendor/ folder locally inside the src/ directory. This is necessary because the development container mounts the local src/ folder, which must already include the dependencies.
You should run this script the first time after cloning the project and whenever composer.json is updated.
# Set image and build target
$imageName = "php-mongodb-dev"
$buildTarget = "dev"
Write-Host "Building Docker image with target: $buildTarget..."
docker build --target=$buildTarget -t $imageName .
Write-Host "Creating temporary container from image..."
$containerID = docker create $imageName
Write-Host "Cleaning up any existing src/vendor/..."
if (Test-Path "./src/vendor") {
Remove-Item -Recurse -Force "./src/vendor"
}
Write-Host "Copying vendor/ from container to ./src/vendor..."
docker cp ${containerID}:/var/www/html/vendor ./src/vendor
Write-Host "Cleaning up temporary container..."
docker rm $containerID
Write-Host "Done! You can now run: docker compose -f compose.dev.yaml up"
compose.dev.yaml
In the compose.yaml
file we define three different services:
The service
php-apache
: The first service isphp-apache
, which sets up the PHP environment with Apache. Here, we use thedev
build from the custom Dockerfile that includes the required PHP extensions, such as the MongoDB extension. We mount the./src
folder into/var/www/html/
so that the container automatically serves the current version of our code. We also load the.env
file to make the environment variables available inside the PHP application.The service
db
: The second service ismongo
, which provides the MongoDB database. We use the officialmongo
image and configure it using environment variables for the root username and password, which are defined in the.env
file. We also mount two volumes: one named volume calledmongo-data-dev
to store the database data persistently, and themongo-init
folder from our project, which contains an initialization script. This script runs the first time the container starts and loads some sample data into the database.The service
mongo-express
: The third service ismongo-express
, which provides a web interface to manage our MongoDB database directly in the browser. It is exposed on port 8081 and is linked to themongo
service so that it can connect to the running database. The credentials for accessing this interface are also provided via the.env
file, using the same database username and password.
services:
php-apache:
build:
context: .
dockerfile: Dockerfile
target: dev
image: php-mongodb-dev
ports:
- "8080:80"
volumes:
- ./src:/var/www/html
depends_on:
- mongo
env_file:
- .env
mongo:
image: mongo
ports:
- "27017:27017"
environment:
MONGO_INITDB_ROOT_USERNAME: ${DB_USER}
MONGO_INITDB_ROOT_PASSWORD: ${DB_PASSWORD}
volumes:
- ./mongo-init:/docker-entrypoint-initdb.d
- mongo-data-dev:/data/db
mongo-express:
image: docker.io/library/mongo-express:1.0.2-20-alpine3.19
ports:
- "8081:8081"
environment:
ME_CONFIG_MONGODB_ADMINUSERNAME: ${DB_USER}
ME_CONFIG_MONGODB_ADMINPASSWORD: ${DB_PASSWORD}
ME_CONFIG_MONGODB_SERVER: mongo
ME_CONFIG_BASICAUTH_USERNAME: ${DB_USER}
ME_CONFIG_BASICAUTH_PASSWORD: ${DB_PASSWORD}
depends_on:
- mongo
volumes:
mongo-data-dev:
compose.prod.yaml
The compose.prod
.yaml
file is very similar to the one we use for development, but it's made for production. Instead of using the dev
build, we use the prod
build from our Dockerfile, which creates a clean image with everything needed to run the app. In production, we don’t mount the source code folder into the container. Instead, the source code is copied into the image during the build. We also use a separate volume called mongo-data-prod
to store the database data, so it stays safe and doesn't mix with the development data.
services:
php-apache:
build:
context: .
dockerfile: Dockerfile
target: prod
image: php-mongodb-prod
ports:
- "8080:80"
depends_on:
- mongo
env_file:
- .env
mongo:
image: mongo
ports:
- "27017:27017"
environment:
MONGO_INITDB_ROOT_USERNAME: ${DB_USER}
MONGO_INITDB_ROOT_PASSWORD: ${DB_PASSWORD}
volumes:
- mongo-data-prod:/data/db
mongo-express:
image: docker.io/library/mongo-express:1.0.2-20-alpine3.19
ports:
- "8081:8081"
environment:
ME_CONFIG_MONGODB_ADMINUSERNAME: ${DB_USER}
ME_CONFIG_MONGODB_ADMINPASSWORD: ${DB_PASSWORD}
ME_CONFIG_MONGODB_SERVER: mongo
ME_CONFIG_BASICAUTH_USERNAME: ${DB_USER}
ME_CONFIG_BASICAUTH_PASSWORD: ${DB_PASSWORD}
depends_on:
- mongo
volumes:
mongo-data-prod:
composer.json
The composer.json
file defines the PHP dependencies for the project. In this case, it includes the official MongoDB library for PHP, which allows the application to connect and interact with a MongoDB database.
{
"require": {
"mongodb/mongodb": "^2.0"
}
}
db_connect.php
This file handles the connection to the MongoDB database. It loads credentials from environment variables and uses the official MongoDB PHP library to establish a connection. If the connection fails, an error message is displayed.
<?php
require 'vendor/autoload.php';
// Get credentials from environment (injected via Docker Compose env_file)
$mongoUser = getenv('DB_USER') ?: '';
$mongoPass = getenv('DB_PASSWORD') ?: '';
try {
// Connect to MongoDB
$client = new MongoDB\Client("mongodb://$mongoUser:$mongoPass@mongo:27017");
// Select database
$db = $client->myapp;
} catch (MongoDB\Driver\Exception\Exception $e) {
echo "<h3>MongoDB Connection Error:</h3>";
echo "<pre>" . $e->getMessage() . "</pre>";
exit;
}
index.php
This is the main entry point of the web application. It connects to the MongoDB database using the db_connect.php
file and queries the users
collection. The retrieved user data is then displayed in a styled HTML table using Tailwind CSS. If the query fails or no users are found, a fallback message is shown.
<?php
require_once __DIR__ . '/vendor/autoload.php';
require_once './db_connect.php';
$mongoUser = getenv('DB_USER');
$mongoPass = getenv('DB_PASSWORD');
try {
$collection = $db->users;
$users = $collection->find();
} catch (MongoDB\Driver\Exception\Exception $e) {
echo "<h3>Error querying MongoDB:</h3>";
echo "<pre>" . $e->getMessage() . "</pre>";
$users = [];
}
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="https://unpkg.com/@tailwindcss/browser@4"></script>
<title>PHP with MongoDB</title>
</head>
<body class="max-w-4xl mx-auto p-12">
<h1 class="text-4xl font-bold text-clifford mb-6">PHP with MongoDB</h1>
<?php if (!empty($users)): ?>
<table class="w-full text-sm text-left text-gray-900 shadow mt-12">
<thead class="text-xs text-gray-700 uppercase bg-gray-50">
<tr>
<th scope="col" class="px-6 py-3">
ID
</th>
<th scope="col" class="px-6 py-3">
Name
</th>
<th scope="col" class="px-6 py-3">
Email
</th>
</tr>
</thead>
<tbody>
<?php foreach ($users as $user): ?>
<tr class="bg-white border-b border-gray-200">
<td class="px-6 py-3"><?php echo htmlspecialchars($user['_id']); ?></td>
<td class="px-6 py-3"><?php echo htmlspecialchars($user['name']); ?></td>
<td class="px-6 py-3"><?php echo htmlspecialchars($user['email']); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php else: ?>
<p class="text-gray-700 text-lg">No users found.</p>
<?php endif; ?>
</body>
</html>
Running the App
Run in development mode:
This sets up the development environment with live code updates. First, generate the vendor/
folder using the PowerShell script, then start the application using the development Compose file.
./build-vendor.ps1
docker compose -f compose.dev.yaml up --build
Run in production mode:
This starts the application using the production image, where the source code and dependencies are already baked into the container. No volume mounts or extra setup is needed.
docker compose -f compose.prod.yaml up --build
Then open http://localhost:808 to view the PHP app, or http://localhost:8081 to access Mongo Express.
Conclusion
XAMPP is still a quick and simple way to start working with PHP, especially for beginners or in classroom settings. But when it comes to modern development workflows, Docker clearly offers more flexibility, scalability, and control.
With Docker, we can define our whole development environment in one place. That includes the PHP version, installed extensions, the database, and tools like phpMyAdmin or Mongo Express. This makes the setup easy to share, repeat, and more like what we would use in production. It's also simple to switch between different PHP versions. And we're not limited to just MySQL or MariaDB, with Docker Compose, we can easily add services like MongoDB, Redis, or anything else the project needs, all running in separate containers.
It takes a bit of time to understand Docker, but once you're comfortable, it becomes a powerful tool that fits perfectly into modern DevOps and software engineering practices.
If you're still using XAMPP today, it might be time to give Docker a try!
Full Code and Resources
All Dockerfiles, data, and benchmark scripts used in this post are available in my GitHub repository. You can check out the full code and experiment with the tests yourself:
→ GitHub Repository: php-docker-examples
Subscribe to my newsletter
Read articles from Simon directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Simon
Simon
Hi I am Simon, I am the Product owner of Python @Credit Suisse/UBS