Before We Begin: The 'Empty Project' Setup
A clean starting point: Docker images, Symfony setup, and database choices for consistent builds.
2025-09-09
Choosing the Stack
⚡️ Shortcut for the impatient: Don’t feel like reading all this? Just grab the ready-made project archive: GitHub repository.
What follows is what I call an “empty project” — a set of container configs, software choices, and version settings to create a predictable environment.
Important note: this “empty project” is optimal only within the scope of this blog. It is not a recommended baseline for starting any new project in 2025.
For the framework, we’ll use Symfony — still #1 for corporate PHP development (yes, it even says so on Wikipedia). And we all believe Wikipedia, right?
The recommended Symfony version as of September 2025 is Symfony v7.3 (requires PHP 8.2).
For the database, we’ll go with MySQL.
Sure, MariaDB is better in some respects, and many prefer it. But remember that MariaDB is not fully compatible with MySQL syntax.
Since this is a test environment, we don’t need to squeeze out maximum performance or rely on edge-case features. That’s why we’ll stick with the “native” Oracle MySQL instead of MariaDB.
And since Doctrine v4.3 explicitly prefers MySQL 8.4, that’s the version we’ll use.
Yes, MySQL 9.4 LTS exists — the latest LTS branch (released in summer 2024) with support through July 2032. Doctrine can run against it, but it will still treat it as 8.4 (via the MySQL84Platform). Doctrine itself was tested specifically with 8.4.
So, for maximum compatibility and predictability, our choice is MySQL 8.4 LTS.
Our stack looks like this:
- PHP 8.2
- MySQL 8.4
- Symfony 7.3
We’ll run everything in containers using Docker Compose, not Kubernetes.
Because this is a local test setup — not a production cluster — Docker Compose is more than enough and won’t be overkill. In fact, Docker itself says that’s what it’s for. And we respect official docs.
Helper Files
.dockerignore
.git
.gitignore
node_modules
vendor
var
.idea
.DS_Store
Project bootstrap script
If you’ve taken the empty project from my repository, at the root you’ll find init.sh, which launches the environment and creates the Symfony “empty project” (see below).
Settings for working with the project, and MySQL in particular, are kept in the root .env file.
The script is intended to run on Linux and macOS systems.
💡 Windows note: The
init.shscript requires bash. On Windows, run it through WSL2 or another bash environment.
compose.yml Fragment — MySQL 8.4
services:
db:
image: mysql:8.4
container_name: db
restart: unless-stopped
ports:
- "3306:3306"
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ${MYSQL_DATABASE}
MYSQL_USER: ${MYSQL_USER}
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
command: >
--character-set-server=utf8mb4
--collation-server=utf8mb4_0900_ai_ci
volumes:
- db_data:/var/lib/mysql
- ./mysql-init:/docker-entrypoint-initdb.d:ro
- ./mysql-conf/my.cnf:/etc/mysql/conf.d/zzz_my.cnf:ro
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -p\"${MYSQL_ROOT_PASSWORD}\" --silent"]
interval: 10s
timeout: 5s
retries: 5
volumes:
db_data:
.env (next to compose.yml)
MYSQL_ROOT_PASSWORD=secretroot
MYSQL_DATABASE=appdb
MYSQL_USER=appuser
MYSQL_PASSWORD=secretpass
TZ=Europe/Kyiv
MySQL init scripts
project-root/
mysql-init/
00_schema.sql
01_seed.sql
MySQL config
[mysqld]
sql_mode=STRICT_ALL_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,ONLY_FULL_GROUP_BY
innodb_flush_log_at_trx_commit=1
character-set-server=utf8mb4
collation-server=utf8mb4_0900_ai_ci
Key points:
- Port 3306 is exposed — bad practice except in a home lab or if you really know what you’re doing.
- UTF8MB4 — the real UTF-8 in MySQL (not the old crippled version).
- Collation
utf8mb4_0900_ai_ci— case/diacritic-insensitive string comparison. In practice: café = cafe, Hello = hello. healthcheck— useful for service dependencies.- Init scripts and a dedicated my.cnf make for a clean, strict starting point.
PHP image with Composer & pdo_mysql
Create docker/php/Dockerfile:
# docker/php/Dockerfile
FROM composer:2 AS composer_src
FROM dunglas/frankenphp:1-php8.2
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends git zip unzip; \
rm -rf /var/lib/apt/lists/*; \
docker-php-ext-install pdo_mysql
COPY --from=composer_src /usr/bin/composer /usr/local/bin/composer
WORKDIR /var/www/html
compose.yml Fragment — PHP 8.2 (FrankenPHP + Composer)
services:
php:
build: ./docker/php
container_name: php
restart: unless-stopped
ports:
- "8080:80"
working_dir: /var/www/html
volumes:
- ./app:/var/www/html:cached
- ./docker/php/Caddyfile:/etc/frankenphp/Caddyfile:ro
environment:
SYMFONY_CLI_VERSION: stable
COMPOSER_ALLOW_SUPERUSER: 1
TZ: Europe/Kyiv
depends_on:
db:
condition: service_healthy
Key points
- FrankenPHP includes Caddy — for dev this is “batteries included”.
- Composer and
pdo_mysqlextension are baked into the image. - The
./appdirectory must be created manually beforehand. - (Linux) If file ownership matters, run commands with
-u "$(id -u):$(id -g)"or set a fixeduser:incompose.yml.
Caddyfile — Custom Server Config
FrankenPHP ships with Caddy, which by default redirects HTTP → HTTPS.
To avoid confusion during local dev, we add our own Caddyfile so that Symfony responds directly on port 8080.
Create docker/php/Caddyfile:
:80 {
root * /var/www/html/public
php_server
file_server
}
This disables automatic HTTPS and ensures your app is available at http://localhost:8080.
Doctrine Config Cleanup
On Linux, Symfony recipes may generate a config/packages/doctrine.yaml that includes PostgreSQL-related options and commented defaults.
Unexpectedly, on macOS the installer does not generate this file at all.
Our init.sh script normalizes this file to a clean MySQL setup.
So we either adjust app/config/packages/doctrine.yaml if it was generated by the installer, or create it ourselves like this:
doctrine:
dbal:
url: '%env(resolve:DATABASE_URL)%'
server_version: '8.4'
orm:
auto_generate_proxy_classes: true
enable_lazy_ghost_objects: true
mappings:
App:
is_bundle: false
type: attribute
dir: '%kernel.project_dir%/src/Entity'
prefix: 'App\\Entity'
alias: App
This way, your Doctrine config matches the MySQL 8.4 DB used in the container, without PostgreSQL leftovers — well, at least I hope so, since Doctrine keeps changing what it generates in this config.
Final Project Structure
docker-empty-project
├── compose.yml
├── .env
├── .dockerignore
├── init.sh
├── app/
├── docker/
│ └── php/
│ ├── Dockerfile
│ └── Caddyfile
├── mysql-init/
│ ├── 00_schema.sql
│ └── 01_seed.sql
└── mysql-conf/
└── my.cnf
Bootstrap
One step from the project root:
chmod +x ./init.sh
./init.sh # or `sudo ./init.sh`, depending on your setup
The script will bring up containers, create ./app, install the Symfony skeleton, disable Flex Docker recipes, install ORM, and configure MySQL DATABASE_URL in app/.env. It also writes app/config/packages/doctrine.yaml with server_version: '8.4'.
The project will be available at: http://localhost:8080
Here’s the content of this script — nothing unusual if you’ve worked with Symfony before:
# init.sh
#!/usr/bin/env bash
set -euo pipefail
if [[ ! -f .env ]]; then
echo "ERROR: .env not found next to compose.yml"
exit 1
fi
export $(grep -E '^(MYSQL_ROOT_PASSWORD|MYSQL_DATABASE|MYSQL_USER|MYSQL_PASSWORD|TZ)=' .env | xargs)
DB_URL="mysql://${MYSQL_USER}:${MYSQL_PASSWORD}@db:3306/${MYSQL_DATABASE}?charset=utf8mb4"
GITKEEP_PRESENT=0
if [ -f app/.gitkeep ]; then
GITKEEP_PRESENT=1
rm -f app/.gitkeep
fi
if [ -n "$(ls -A app 2>/dev/null)" ]; then
echo "ERROR: ./app is not empty. Please clean it (even a single .gitignore will block create-project)."
exit 1
fi
docker compose up -d
docker compose run --rm php composer create-project symfony/skeleton .
docker compose run --rm php composer config extra.symfony.docker false
docker compose run --rm \
-e DATABASE_URL="$DB_URL" \
php composer require symfony/orm-pack
docker compose run --rm php composer require --dev symfony/maker-bundle
if grep -q '^###> doctrine/doctrine-bundle ###' app/.env; then
awk -v repl="###> doctrine/doctrine-bundle ###\nDATABASE_URL=\"${DB_URL}\"\n###< doctrine/doctrine-bundle ###" '
/^###> doctrine\/doctrine-bundle ###/ {print repl; skip=1; next}
skip && /^###< doctrine\/doctrine-bundle ###/ {skip=0; next}
!skip {print}
' app/.env > app/.env.tmp && mv app/.env.tmp app/.env
else
{
echo '###> doctrine/doctrine-bundle ###'
echo "DATABASE_URL=\"${DB_URL}\""
echo '###< doctrine/doctrine-bundle ###'
} >> app/.env
fi
DOCTRINE_YAML="app/config/packages/doctrine.yaml"
if [ -f "$DOCTRINE_YAML" ]; then
awk '
/^ *#server_version/ { print " server_version: '\''8.4'\''"; next }
/identity_generation_preferences:/ { skipblock=1; next }
skipblock && /^[^[:space:]]/ { skipblock=0 } # end of block
skipblock { next }
{ print }
' "$DOCTRINE_YAML" > "${DOCTRINE_YAML}.tmp" && mv "${DOCTRINE_YAML}.tmp" "$DOCTRINE_YAML"
echo "✓ Doctrine config normalized for MySQL (server_version=8.4, removed Postgres hints)."
else
mkdir -p app/config/packages
cat > "$DOCTRINE_YAML" <<'EOF'
doctrine:
dbal:
url: '%env(resolve:DATABASE_URL)%'
server_version: '8.4'
orm:
auto_generate_proxy_classes: true
enable_lazy_ghost_objects: true
mappings:
App:
is_bundle: false
type: attribute
dir: '%kernel.project_dir%/src/Entity'
prefix: 'App\\Entity'
alias: App
EOF
echo "✓ Doctrine config created fresh for MySQL (server_version=8.4)."
fi
if [ "$GITKEEP_PRESENT" -eq 1 ]; then
touch app/.gitkeep
fi
echo
echo "✔ Done. Symfony skeleton installed in ./app"
echo " App runs at: http://localhost:8080"
echo " DATABASE_URL used: $DB_URL"
Linux note
If files in ./app appear owned by root, run commands with:
docker compose run --rm -u "$(id -u):$(id -g)" php …
Or fix user: in compose.yml (for dev environments).
Final
Done: a clean Symfony skeleton + DB, without extra packages.
⚡️ Reminder: You can still just grab the ready-made project archive: GitHub repository.
Check:
- Inside
app/, you’ll see the standard Symfony structure (bin/,config/,public/,src/,var/,vendor/,composer.json, etc.). - The empty project responds at http://localhost:8080.