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.sh script 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_mysql extension are baked into the image.
  • The ./app directory must be created manually beforehand.
  • (Linux) If file ownership matters, run commands with -u "$(id -u):$(id -g)" or set a fixed user: in compose.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.