Перед початком роботи: «порожній проєкт»

Базовий старт: Docker, Symfony і база даних для передбачуваних збірок.

2025-09-09

Вибір стеку

⚡️ Скорочений шлях для нетерплячих: Не хочете читати все це? Просто заберіть готовий архів проєкту: GitHub-репозиторій.


Далі йде те, що я називаю «порожнім проєктом» — добірка контейнерів, софта, конфігурацій та налаштувань, які створюють передбачуване середовище.

Важливо: цей «порожній проєкт» є оптимальним лише в межах цього блогу. Це не рекомендація або базовий шаблон для старту будь-якого нового проєкту у 2025 році.

Для фреймворку ми використаємо Symfony — досі №1 для корпоративної PHP-розробки (так навіть на Вікіпедії  написано). І ми ж усі довіряємо Вікіпедії, чи не так?

Рекомендована версія Symfony станом на вересень 2025 року — Symfony v7.3 (потребує PHP 8.2).

Для бази даних обираємо MySQL.

Так, MariaDB у деяких аспектах краща, і багато хто їй надає перевагу. Але пам’ятайте, що MariaDB не є повністю сумісною із синтаксисом MySQL.

Оскільки це лише тестове середовище, немає сенсу гнатися за максимальною продуктивністю чи гратися з нестандартними функціями. Саме тому ми залишимось із «рідним» Oracle MySQL замість MariaDB.

До того ж Doctrine v4.3 відверто віддає перевагу MySQL 8.4, тому саме цю версію ми й використаємо.

Звісно, існує MySQL 9.4 LTS — остання LTS-гілка (випущена влітку 2024 року) з підтримкою до липня 2032-го. Doctrine може працювати з нею, але все одно вважатиме її 8.4 (через MySQL84Platform). Doctrine тестувався саме на 8.4.

Тож, для максимальної сумісності й передбачуваності наш вибір — MySQL 8.4 LTS.

Наш стек виглядає так:

  • PHP 8.2
  • MySQL 8.4
  • Symfony 7.3

Ми запускатимемо все в контейнерах за допомогою Docker Compose, а не Kubernetes.

Оскільки це локальне тестове середовище, а не продакшн-кластер, Docker Compose більш ніж достатній і не буде надмірним рішенням. До речі, сам Docker прямо каже, що власне для цього він і створений. А ми поважаємо офіційну документацію.


Допоміжні файли

.dockerignore

.git
.gitignore
node_modules
vendor
var
.idea
.DS_Store

Скрипт ініціалізації проєкту

Якщо ви взяли «порожній проєкт» із мого репозиторію, у корені ви знайдете init.sh, який запускає середовище та створює Symfony «порожній проєкт» (див. нижче).

Налаштування для роботи з проєктом, а особливо з MySQL, зберігаються в кореневому файлі .env.

Скрипт призначений для роботи в системах Linux і macOS.

💡 Примітка для Windows: скрипт init.sh вимагає bash. У Windows запускайте його через WSL2 або інше bash-середовище.


compose.yml Фрагмент — 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 (наступний за compose.yml)

MYSQL_ROOT_PASSWORD=secretroot
MYSQL_DATABASE=appdb
MYSQL_USER=appuser
MYSQL_PASSWORD=secretpass
TZ=Europe/Kyiv

MySQL скрипти ініціалізації

project-root/
  mysql-init/
    00_schema.sql
    01_seed.sql

MySQL налаштування

[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

Основні моменти

  • Порт 3306 відкритий — це погана практика, окрім випадків домашньої лабораторії або якщо ви дійсно знаєте, що робите.
  • UTF8MB4 — це справжній UTF-8 у MySQL (не стара урізана версія).
  • Порівняння utf8mb4_0900_ai_ci — нечутливе до регістру та діакритики. На практиці: café = cafe, Hello = hello.
  • healthcheck — корисна річ для залежностей між сервісами.
  • Скрипти ініціалізації разом із власним my.cnf формують передбачувану початкову конфігурацію.

Образ PHP із Composer та pdo_mysql

Створіть 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 — 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

Основні моменти

  • FrankenPHP включає Caddy — для розробки це «все з коробки».
  • Composer і розширення pdo_mysql вже вбудовані в образ.
  • Каталог ./app потрібно створити вручну заздалегідь.
  • (Linux) Якщо важлива власність файлів, виконуйте команди з параметром -u "$(id -u):$(id -g)" або задайте фіксоване значення user: у compose.yml.

Caddyfile — власна конфігурація сервера

FrankenPHP постачається з Caddy, який за замовчуванням перенаправляє HTTP → HTTPS. Щоб уникнути плутанини під час локальної розробки, додаємо власний Caddyfile, щоб Symfony відповідав безпосередньо на порту 8080.

Створіть docker/php/Caddyfile:

:80 {
  root * /var/www/html/public
  php_server
  file_server
}

Це вимикає автоматичний HTTPS і гарантує, що ваш застосунок буде доступний за адресою http://localhost:8080.


Очищення конфігурації Doctrine

У Linux рецепти Symfony можуть згенерувати файл config/packages/doctrine.yaml, який містить параметри, пов’язані з PostgreSQL, та закоментовані значення за замовчуванням. Неочікувано, але в macOS інсталятор взагалі не створює цей файл.

Наш скрипт init.sh нормалізує цей файл, приводячи його до чистої конфігурації MySQL.

Отже, ми або коригуємо app/config/packages/doctrine.yaml, якщо він був створений інсталятором, або створюємо його самостійно ось так:

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

Таким чином, наша конфігурація Doctrine відповідатиме базі даних MySQL 8.4, що використовується в контейнері, без «залишків» від PostgreSQL — принаймні я на це сподіваюся, адже Doctrine постійно змінює формат цього файлу.


Підсумкова структура проєкту

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

Ініціалізація

Одна команда з кореня проєкту:

chmod +x ./init.sh
./init.sh   # або `sudo ./init.sh`, залежно від вашої системи

Скрипт запустить контейнери, створить каталог ./app, встановить каркас Symfony, вимкне Docker-рецепти Flex, інсталює ORM і налаштує MySQL DATABASE_URL у app/.env. Також він запише app/config/packages/doctrine.yaml із параметром server_version: '8.4'.

Застосунок буде доступний за адресою: http://localhost:8080

Ось вміст цього скрипту — нічого незвичного, якщо ви вже працювали з Symfony:

# 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 "✔ Готово. Каркас Symfony встановлено у ./app"
echo "   Застосунок працює за адресою: http://localhost:8080"
echo "   Використано DATABASE_URL: $DB_URL"

Примітка для Linux

Якщо файли в ./app мають власника root, виконуйте команди з:

docker compose run --rm -u "$(id -u):$(id -g)" php

Або задайте параметр user: у compose.yml (для середовищ розробки).


Ітоги

На прикінці маємо чистий каркас Symfony + база даних без зайвих пакетів.

⚡️ Нагадування: Ви все ще можете просто завантажити готовий архів проєкту: GitHub-репозиторій.

Перевірте:

  • Усередині app/ ви маєте побачити стандартну структуру Symfony (bin/, config/, public/, src/, var/, vendor/, composer.json тощо).
  • Порожній проєкт відповідає за адресою http://localhost:8080.