Dockerize your PHP / Symfony application, to eliminate "works on MY machine".
I'm describing here a solution that ensures consistent development environment, to run the project locally with just a few commands, without having to worry about:
- PHP version / extensions
database / search engine / messaging queue / services setup
- Dockerignore
- Compose
- Going further
Dockerfile
The following Dockerfile will build an image with:
- Alpine Linux
- Lightweight distribution (5-10MB compared to 100MB for Ubuntu)
- uses musl libc instead of glibc, expect incompatibility issues with some binaries
- PHP 8.3
- this is needed for any PHP applications
- change the version to your liking
- bash
- not required, but I like to use bash as my shell when I connect to the container
- Composer
- in production, you don't need the Composer binary in the container
- in development, it's useful to have the same running environment for your app and Composer
- PostgreSQL
- my favourite database
- skip it or switch it to MySQL, SQLite, etc
- Symfony CLI
- in production, you don't need the Symfony CLI binary in the container
- in development, useful to start a web server
# syntax=docker/dockerfile:1
###
# PHP Dev Container
# Utility Tools: PHP, bash, Composer, PostgreSQL, Symfony CLI
###
FROM php:8.3-cli-alpine AS php_dev_container
# Composer environment variables:
# * default user is superuser (root), so allow them
# * put cache directory in a readable/writable location
# _Note_: When running `composer` in container, use `--no-cache` option
ENV COMPOSER_ALLOW_SUPERUSER=1 \
COMPOSER_CACHE_DIR=/tmp/.composer/cache
# Install dependencies:
# * bash for shell access and scripting
# * postgresql for the database
# * zip for composer packages that use ZIP archives
# _Note (Alpine)_: `--no-cache` includes `--update` and keeps image size minimal
#
# Then install PHP extensions
#
# _Note (Hadolint)_: No version locking, since Alpine only ever provides one version
# hadolint ignore=DL3018
RUN apk add --update --no-cache \
bash \
libzip-dev \
postgresql-dev \
zip \
&& docker-php-ext-install \
bcmath \
zip \
pdo_pgsql
# Copy Symfony CLI binary from image
# _Note_: Avoid using Symfony CLI installer, use Docker image instead
# See: https://github.com/symfony-cli/symfony-cli/issues/195#issuecomment-1273269735
# _Note (Hadolint)_: False positive as `COPY` works with images too
# See: https://github.com/hadolint/hadolint/issues/197#issuecomment-1016595425
# hadolint ignore=DL3022
COPY --from=ghcr.io/symfony-cli/symfony-cli:v5 /usr/local/bin/symfony /usr/local/bin/symfony
# Copy Composer binary from composer image
# _Note (Hadolint)_: False positive as `COPY` works with images too
# See: https://github.com/hadolint/hadolint/issues/197#issuecomment-1016595425
# hadolint ignore=DL3022
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /app
# Caching `composer install`, as long as composer.{json,lock} don't change.
COPY composer.json composer.lock ./
RUN composer install \
--no-cache \
--no-interaction \
--no-plugins \
--no-scripts \
--optimize-autoloader
# Copy the remaining application files (excluding those listed in .dockerignore)
COPY . .
You can check the validity of your Dockerfile syntax here: hadolint
Here's how to build the image, and then run the container:
Note:
-v "$(PWD)":/appmounts current directory for live code changes.
docker build -t app .
# Run with interactive shell
docker run --rm -it -v "$(PWD)":/app app bash
# Run composer
docker run --rm -it -v "$(PWD)":/app app symfony composer install -o
# Run symfony's console
docker run --rm -it -v "$(PWD)":/app -e APP_ENV=prod app symfony console
# Run PHPUnit, phpstan, PHP CS Fixer
docker run --rm -it -v "$(PWD)":/app app symfony php vendor/bin/phpunit
docker run --rm -it -v "$(PWD)":/app app symfony php vendor/bin/phpstan analyze
docker run --rm -it -v "$(PWD)":/app app symfony php vendor/bin/php-cs-fixer check --verbose
docker run --rm -it -v "$(PWD)":/app app symfony php vendor/bin/php-cs-fixer fix --verbose
# Start Symfony CLI's web server
docker run --rm -it -v "$(PWD)":/app -p 8000:8000 app symfony server:start --port=8000 --host=0.0.0.0
Dockerignore
When using COPY . . in Dockerfile, it's useful to limit what's going to be copied, with a .dockerignore:
## composer
vendor
## git
.git/
## friendsofphp/php-cs-fixer
.php-cs-fixer.php
.php-cs-fixer.cache
## phpstan/phpstan
phpstan.neon
## phpunit/phpunit
phpunit.xml
.phpunit.cache
## symfony/framework-bundle
.env.local
.env.local.php
.env.*.local
var/cache/
var/log/
Compose
When the PHP application relies on other services,
such as a database (eg PostgreSQL), search engine (eg Elasticsearch), or message queue (eg RabbitMQ),
having a compose.yaml file will make the development experience much smoother
by handling services, networking, and volumes automatically:
services:
app:
build: .
# Mount current directory into container for live code changes
volumes:
- .:/app
# Database should be started first
depends_on:
- db
ports:
- "8000:8000"
command: symfony serve --no-tls --port=8000 --listen-ip=0.0.0.0
db:
image: postgres:${POSTGRES_VERSION:-16}-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB:-app}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-ChangeMe}
POSTGRES_USER: ${POSTGRES_USER:-app}
# Persist database data between container restarts
volumes:
- db-data:/var/lib/postgresql/data:rw
# Port mapping to avoid conflict with locally running PostgreSQL
ports:
- "5433:5432"
# Define the db-data volume used above
volumes:
db-data:
Now usage commands will be a bit different:
# Build docker images
docker compose build --pull
# Start services (no logs)
docker compose up --detach
# Show live logs
docker compose logs --tail=0 --follow
# Stop services
docker compose down --remove-orphans
# Run with interactive shell
docker compose exec app bash
# Run composer
docker compose exec app symfony composer
# Run symfony's console
docker compose exec -e APP_ENV=prod app symfony console
# Run PHPUnit, phpstan, PHP CS Fixer
docker compose exec -e APP_ENV=prod app symfony php vendor/bin/phpunit
docker compose exec -e APP_ENV=prod app symfony php vendor/bin/phpstan analyze
docker compose exec -e APP_ENV=prod app symfony php vendor/bin/php-cs-fixer check --verbose
docker compose exec -e APP_ENV=prod app symfony php vendor/bin/php-cs-fixer fix --verbose
Going further
SQLite
To setup SQLite, you'll need to modify Dockerfile:
RUN apk add --update --no-cache \
...
sqlite \
&& docker-php-ext-install \
...
pdo_sqlite
As well as compose.yaml:
services:
app:
...
volumes:
...
# Mount SQLite database directory to persist data
- sqlite-data:/app/var/data
volumes:
...
sqlite-data:
This is assuming your SQLite database file is located in the projects' var/data folder.
Make sure to set up the following environment varaible in .env:
DATABASE_URL="sqlite:///%kernel.project_dir%/var/data/database.sqlite"
RabbitMQ
For RabbitMQ, modify Dockerfile:
RUN apk add --update --no-cache \
...
rabbitmq-c-dev \
&& docker-php-ext-install \
...
sockets \
&& pecl install amqp \
&& docker-php-ext-enable amqp
Also compose.yaml:
services:
app:
...
depends_on:
...
- rabbitmq
rabbitmq:
image: rabbitmq:${RABBITMQ_VERSION:-3.13}-management-alpine
environment:
RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-app}
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-ChangeMe}
# Persist RabbitMQ data between container restarts
volumes:
- rabbitmq-data:/var/lib/rabbitmq:rw
ports:
# Port mapping to avoid conflict with locally running RabbitMQ
- "5673:5672"
# Management UI port
- "15673:15672"
volumes:
...
rabbitmq-data:
Again, make sure to set up the following environment varaible in .env:
RABBITMQ_URL="amqp://app:ChangeMe@rabbitmq:5672/"
The RabbitMQ management interface will be available at http://localhost:15673, with the credentials defined in the environment variables.
Maintenance
Here's a list of helpful commands to maintain the images and containers:
docker images: lists imagesdocker images --filter dangling=true: lists untagged / unused images
docker container ls: lists running containersdocker container ls -a: lists running and stopped containers
docker system prune: removes dangling containers, networks and imagesdocker system prune --volumes: removes dangling containers, networks, volumes and images
docker history <image>: Inspects layers of an image
Conclusion
With this, we can finally write bugs once, and run them everywhere!
Comments
Nothing yet. Say the first thing.
Sign in to join the conversation.