<? phpukraine СПІВБЕСІДИ
Пошук по платформі
SYMFONY · SENIOR

Як вести Doctrine-міграції в команді, щоб схема не розходилась?

diff дає єдине джерело істини для схеми, але його результат треба перечитувати й перегенеровувати після rebase; розходження ловиться в CI прогоном міграцій на порожній базі плюс doctrine:schema:validate, а перенесення даних живе в окремих міграціях на сирому SQL, не в тих, що змінюють схему.

Двоє розробників у різних гілках зробили doctrine:migrations:diff, після мержу міграції падають. Що не так у процесі?
На стейджі schema:validate каже, що база не синхронізована з мапінгом, хоча всі міграції накатані. Звідки різниця?
Навіщо взагалі diff, якщо міграцію можна написати руками акуратніше?
У міграції ви через EntityManager перенесли дані зі старої колонки в нову. Що станеться з цією міграцією через рік?
Doctrine Migrations schema:validate CI міграції даних DBAL

doctrine:migrations:diff не «читає ваші зміни в сутностях». Він будує цільову схему з мапінгу через SchemaTool, знімає поточну схему інтроспекцією DBAL, порівнює їх Comparator-ом і рендерить різницю в SQL поточної платформи. З цієї механіки й ростуть усі практичні наслідки. Локальна база відстала на дві міграції або її правили руками через клієнт: різниця рахується від стану, якого більше немає ні в кого, і в міграцію приїде DROP INDEX на індекс, який ви ж самі й забули завести. Розробник сидить на SQLite при проді на MySQL 8: згенерований SQL просто не той. Звідси й цикл роботи. Дропнути локальну БД, прогнати doctrine:migrations:migrate, і лише тоді робити diff, обов'язково на тій самій СУБД і тій самій мажорній версії, що в проді. Ручні міграції такої звірки не дають узагалі: вони описують намір автора, а не різницю між мапінгом і базою.

Командний біль починається на гілках. Двоє роблять diff паралельно, кожен від бази без змін сусіда, і після мержу файли мирно лежать поруч, бо git не бачить у них конфлікту. Порядок виконання задає таймстемп у назві класу, а не черга мержів, тож той, хто почав раніше, а домержився пізніше, поїде першим. Далі варіанти: дубльоване створення індексу, FK на колонку з іншим типом, DROP колонки, яку сусідня гілка щойно додала. Дисципліна тут одна, і перевіряє її машина: після кожного rebase накатити всі міграції на порожню базу і подивитись, що doctrine:migrations:diff мовчить. Задеплоєні міграції не редагують ніколи, виправлення йде наступним файлом; doctrine:migrations:version --add і --delete існують для розбору аварій, у щоденній роботі їм не місце. Коли файлів стає кілька сотень і прогін з нуля займає хвилини, історію згортають через doctrine:migrations:rollup або dump-schema.

Перевірка в CI складається з двох кроків, і перший важливіший. Спочатку піднімається порожня БД тієї ж версії, що в проді, і виконується doctrine:migrations:migrate --no-interaction: так видно, що набір міграцій відтворює схему з нуля, а не тільки надбудовує чиюсь стару базу. Потім doctrine:schema:validate порівнює отриману схему з мапінгом. Він повертає 1 при помилках мапінгу, 2 при розсинхроні бази і 3 при обох, тож у логах видно, що саме зламалось; окремі частини вимикаються прапорцями --skip-sync і --skip-mapping. Ненульовий код тут читається однозначно: хтось поміняв атрибути сутності й не згенерував міграцію. Третім рядком корисно додати doctrine:migrations:up-to-date --fail-on-unregistered для зворотної ситуації, коли в базі є версії, яких немає в коді. Щоб ці перевірки не сварились на службові таблиці, у doctrine.dbal.schema_filter виносять регулярку на кшталт ~^(?!messenger_messages)~, а транспорту Messenger ставлять auto_setup: false.

Міграції даних живуть окремо від DDL з двох причин. Перша технічна: DDL і великий UPDATE мають різні профілі блокувань, і спільна транзакція означає лок таблиці на весь час backfill. Друга організаційна: міграція заморожена в часі, а код рухається далі. Будь-яке звернення до Order::class чи до репозиторію вистрелить пізніше, бо мапінг сьогодні описує схему після всіх наступних міграцій, а не ту, що існувала в цій точці історії. Сирий SQL через addSql() або $this->connection->executeStatement() залишається валідним роками. Для великих обсягів у міграції переозначають isTransactional(): false і йдуть батчами по первинному ключу. Якщо backfill триває десятки хвилин, його взагалі виносять в консольну команду або Messenger-хендлер, залишаючи в міграції тільки сумісну зі старим кодом зміну схеми. Деталі щодо блокувань і фаз expand/contract розібрані в картці про zero-downtime міграції.

down() у більшості команд мертвий: його ніхто не тестує, а відкат даних неможливий у принципі, тому реальна стратегія на проді forward-only, і замість rollback пишуть компенсуючу міграцію. Прапорець --all-or-nothing виглядає як страховка, але в MySQL кожен DDL робить неявний commit, тож група операцій не відкотиться; у PostgreSQL DDL транзакційний і це працює по-справжньому, хоча CREATE INDEX CONCURRENTLY доведеться виносити в міграцію з isTransactional(): false. Вбудованого блокування паралельних запусків у doctrine/migrations 3.x немає, тому migrate роблять одним кроком пайплайна, а не в entrypoint контейнера, і за потреби обгортають у GET_LOCK() чи pg_advisory_lock(). Остання пастка: після апгрейду DBAL перший diff майже завжди шумний, бо змінюється те, як бібліотека читає схему. У DBAL 4 зникли DC2Type-коментарі, і цей шум розбирають руками один раз, замість гасити його щоразу новою міграцією.

// migrations/Version20260904101500.php: тільки схема, жодного рядка даних
final class Version20260904101500 extends AbstractMigration
{
    public function up(Schema $schema): void
    {
        // diff згенерував SQL під MySQL, на іншій платформі краще впасти одразу
        $this->abortIf(! $this->connection->getDatabasePlatform() instanceof AbstractMySQLPlatform);

        $this->addSql('ALTER TABLE orders ADD currency VARCHAR(3) DEFAULT NULL');
        $this->addSql('CREATE INDEX idx_orders_currency ON orders (currency)');
    }

    public function down(Schema $schema): void
    {
        $this->addSql('DROP INDEX idx_orders_currency ON orders');
        $this->addSql('ALTER TABLE orders DROP currency');
    }
}

// migrations/Version20260904101800.php: окрема міграція даних, окремий крок деплою
final class Version20260904101800 extends AbstractMigration
{
    public function isTransactional(): bool
    {
        return false;   // мільйони рядків не тримаємо в одній транзакції
    }

    public function up(Schema $schema): void
    {
        // Сирий SQL замість Order::class: сутність через рік матиме інші поля, таблиця ні
        $max = (int) $this->connection->fetchOne('SELECT COALESCE(MAX(id), 0) FROM orders');

        for ($from = 0; $from < $max; $from += 10_000) {   // executeStatement обходить --dry-run
            $this->connection->executeStatement(
                'UPDATE orders SET currency = ? WHERE currency IS NULL AND id > ? AND id <= ?',
                ['UAH', $from, $from + 10_000],
            );
        }
    }

    public function down(Schema $schema): void
    {
        $this->throwIrreversibleMigrationException('Backfill не відкочується');
    }
}
Що diff це порівняння двох входів: цільової схеми з мапінгу і реальної схеми з інтроспекції поточної БД, тому брудна або відстала локальна база дає сміттєвий SQL.
Що згенерований SQL прив'язаний до платформи розробника: diff на SQLite і прод на MySQL 8 це готовий інцидент.
Що порядок виконання визначається таймстемпом у назві класу, а не порядком мержу, і саме тому після rebase міграцію треба перевіряти на чистій базі, а не сподіватись на git.
Що дрейф ловиться механічно: у CI підняти порожню БД, прогнати doctrine:migrations:migrate, потім doctrine:schema:validate; ненульовий код виходу означає, що хтось змінив мапінг без міграції.
Що міграції даних відділяють від DDL: сутності змінюються, а міграція заморожена в часі, тому в ній має бути сирий SQL і власний isTransactional().
Що вже задеплоєну міграцію не редагують, а виправляють наступною; правка метаданих через doctrine:migrations:version це аварійний інструмент.
Робити diff на локальній базі, яку вручну правили через IDE, і комітити SQL, що дропає чужі індекси.
Комітити згенерований файл не читаючи: DROP TABLE для таблиці, якої Doctrine не знає, проходить непоміченим до продакшену.
Покладатись на --all-or-nothing у MySQL: неявний commit навколо кожного DDL робить відкат групи операцій неможливим, на відміну від PostgreSQL.
Використовувати в міграції репозиторії й сутності: через рік поле перейменують, і стара міграція перестане навіть завантажуватись.
Виконувати важкий backfill усередині DDL-міграції й тримати блокування таблиці на весь деплой.
Запускати migrate у entrypoint кожної репліки замість одного кроку пайплайна з блокуванням через GET_LOCK або pg_advisory_lock.
Ігнорувати вічний diff на службових таблицях (messenger_messages, таблиці іншого сервісу) замість того, щоб налаштувати schema_filter.
ПОРАДА

Опишіть процес як два запобіжники, а не як домовленість: після кожного rebase міграція перевіряється на чистій базі, а в CI прогін усіх міграцій плюс doctrine:schema:validate є обов'язковою перевіркою. Фраза «якщо diff у CI щось знайшов, значить хтось змінив мапінг без міграції» одразу показує, що ви це налаштовували, а не читали.

оновлено 6 вересня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

diff корисний саме тим, що звіряє мапінг з реальною базою, але його треба перегенеровувати після мержу; єдина механічна гарантія проти дрейфу це прогін усіх міграцій з нуля в CI плюс schema:validate, а дані переносять окремо, бо сутності змінюються, а міграції заморожені.