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