Перевірка міграцій БД «up → down без слідів» у CI: пакети Migration Checker для Symfony та Laravel
Вітаю! Мене звати Олександр Рослов. Я програміст із
Про що і для кого стаття
У цій статті я коротко розповідаю:
- яку проблему міграцій я закриваю і чому вона часто «не болить», доки не стане пізно;
- як працює підхід «up → down → schema diff» як автоматична перевірка якості;
- що саме роблять мої пакети Migration Checker для Symfony та Laravel і як їх запускати локально та в CI;
- які типові фейли проходять код-рев’ю, і як їх ловить автоматична перевірка.
Чому вирішив написати? Бо бачу, що тема «міграції як артефакт, який треба тестувати» досі недооцінена: часто ми рев’юємо міграції очима, але не маємо механічної гарантії, що відкат поверне схему в нульовий стан. А саме це відрізняє «міграції працюють» від «міграції безпечні».
Стаття буде корисна:
- PHP-розробникам на Symfony/Laravel, які регулярно додають міграції;
- тим, хто займається код-рев’ю і хоче менше «людського фактора»;
- DevOps/Tech Lead’ам, які відповідають за стабільні деплої та rollback;
- командам з raw SQL у міграціях (тригери, індекси, constraints), де ризики вищі.
У більшості команд міграції сприймаються як «код, що еволюціонує схему». Але з практики проблема часто не в up, а в тому, що down не повертає базу в початковий стан: лишаються таблиці, індекси, тригери, «тимчасові» колонки, зміни default-значень, коментарі або інші артефакти. Це може довго не проявлятися — аж поки не знадобиться rollback, відкат релізу або прогін міграцій у чистому середовищі.
Саме через це я зробив набір пакетів Migration Checker, який формалізує просте правило: після up → down стан схеми має бути ідентичним, і це має перевірятися автоматично (локально та в CI).

Чому я взагалі до цього дійшов
Класичний біль міграцій виглядає так:
up()додає колонку/індекс/constraint/тригер, аdown()забуває його прибрати.down()«відкотив» таблицю, але лишились супутні об’єкти або зміни метаданих.- У CI ми проганяємо міграції «тільки вгору», але не перевіряємо, що rollback реально повертає схему назад.
І тут вступає в гру код-рев’ю. Бо реальність така: навіть сильні рев’ювери не тримають у голові всі нюанси БД, і в PR легко пропустити дрібні, але критичні деталі.
Приклади з код-рев’ю: що найчастіше прослизає
Нижче — типові ситуації, які я бачу на рев’ю міграцій (і через які, власне, й народився Migration Checker).
1) «Колонку видалили — значить все ок»... але індекс лишився
На рев’ю зазвичай дивляться на головну дію: «додали колонку — прибрали колонку». Але індекс міг створюватися:
- окремою командою;
- у сирому SQL;
- або автоматично через ORM-хелпер.
В результаті down() колонку прибрав, а індекс — ні. І поки ніхто не робить rollback — все тихо.
Як це виглядає в рев’ю:
«Так,
dropColumn()є. LGTM».
Як це виглядає при реальному відкаті:
«Relation already exists / index already exists / cannot drop column because...»
2) «Додали FK» — але відкат не симетричний
У up() легко додати foreign key, але в down():
- забути
dropForeign; - переплутати ім’я constraint’а;
- або відкотити таблицю, залишивши constraint (залежить від СУБД і сценарію).
На рев’ю часто не ловиться, бо назви constraint’ів в різних командах можуть генеритись по-різному, а людина не завжди запускає міграцію локально «вгору-вниз».
3) «Тригер/функція» через raw SQL — і down() пустий
Все, що виходить за межі стандартного DSL міграцій, на рев’ю стає ризикованим:
CREATE TRIGGER,CREATE FUNCTION,- специфічні типи (наприклад, enum/типи в Postgres).
Часто down() або взагалі пропускають, або пишуть «потім доробимо».
На рев’ю це виглядає як:
«Так, воно треба для бізнес-логіки, давайте мерджити, rollback не плануємо».
А потім rollback «раптом» планується.
4) «Rename» — один з найпідступніших сценаріїв
Перейменування таблиці/колонки вгору може бути коректним, але відкат:
- повертає ім’я, але забуває про індекси;
- або індекси повертаються з іншими назвами;
- або ORM генерує різні імена залежно від версії.
Це складно виявити очима в PR, бо треба «симулювати» кінцеву схему.
5) «Змінили default / nullability / charset / collation» — а назад не повернули
Міграція може міняти:
DEFAULT;NOT NULL;charset/collation;- коментарі;
- порядок колонок (інколи важливо);
- типи (наприклад,
int→bigint).
На рев’ю такі правки виглядають дрібними, але down() часто роблять «спрощеним»: повернули тип — і забули default/collation.
6) «Дропнули таблицю в down()» як універсальний відкат
Це взагалі окремий жанр:
up()додає кілька змін;down()простоdropTable()«щоб гарантовано відкотити».
Таке часто проходить рев’ю, бо «працює». Але воно несиметричне і може:
- знести таблицю, яка існувала до міграції (якщо помилились контекстом);
- або знищити дані в dev/stage, де відкат роблять частіше.
Як працює Migration Checker
Алгоритм простий:
- snapshot-схеми;
up;down;- snapshot-схеми;
- diff;
upі далі по списку.
Якщо після rollback лишився хоч один об’єкт — інструмент показує різницю і падає.

Як це допомагає саме в процесі код-рев’ю
Я сприймаю Migration Checker як «другу пару очей», яка:
- не втомлюється;
- не пропускає дрібниці;
- не покладається на «я впевнений, що down ок»;
- економить час код-рев’ю.
І практично це змінює рев’ю так:
- Рев’ювер більше фокусується на сенсі міграції (чи правильно ми міняємо домен), а не на «чи всі дрібні об’єкти видалені».
- Якщо в PR є raw SQL — checker стає обов’язковою страховкою.
- Зникає клас «LGTM, але down потім» — бо CI просто не пропустить.
Встановлення та локальний запуск
Symfony
composer require --dev roslov/migration-checker-bundle php bin/console migration-checker:check --env=test -vv
Laravel
composer require --dev roslov/laravel-migration-checker php artisan migration-checker:check --env=testing -vv
Підключення до CI
Нижче наведу приклад перевірки міграцій на Jenkins’і:
// Підготовлюємо тестове середовище
sh """
docker stop ${service}-test-db || true
docker rm --force ${service}-test-db || true
docker network rm ${service}-network || true
docker network create ${service}-network
"""
// Запускаємо тестову базу
sh """
docker run --name ${service}-test-db --network=${service}-network -d --rm \
-e MYSQL_ROOT_PASSWORD=rootpass \
-e MYSQL_DATABASE=${service}_test \
-e MYSQL_USER=${service}testuser \
-e MYSQL_PASSWORD=${service}testpass \
mysql:8.4.5 \
--character-set-server=utf8mb4 \
--collation-server=utf8mb4_0900_ai_ci \
--log_bin_trust_function_creators=1 \
--explicit_defaults_for_timestamp=0
"""
// Очікуємо, доки база буде готова приймати запити
sh """
while ! docker exec ${service}-test-db \
mysql --user=${service}testuser --password=${service}testpass \
-e 'SELECT 1' \
>/dev/null 2>&1; do
echo 'Waiting for database connection...'
sleep 1
done
"""
if (service == 'service1') {
// Запускаємо перевірку міграцій для сервіса `service1`.
// В цьому випадку це Symfony-мікросервіс, в якому ми прописали підключення до тестової бази
// в файлі `.env.test`
sh """
docker run --network=${service}-network --rm \
${service}-migration-check \
php bin/console migration-checker:check --env=test -vv
"""
}
if (service == 'service2') {
// Запускаємо перевірку міграцій для сервіса `service2`.
// В цьому випадку це Laravel-мікросервіс, в якому ми НЕ прописали підключення до тестової бази
// в файлі `.env.testing`, тому використовуємо змінну середовища для підключення
sh """
docker run --network=${service}-network --rm \
-e DB_URL="mysql://${service}testuser:${service}testpass@${service}-test-db:3306/${service}_test
?serverVersion=8.4.5&charset=utf8mb4" \
${service}-migration-check \
php artisan migration-checker:check --env=testing -vv
"""
}
// Зупиняємо тестове середовище
sh """
docker stop ${service}-test-db
docker network rm ${service}-network
"""
Де подивитися й спробувати
- roslov/migration-checker-bundle — додає консольну команду для перевірки міграцій в Symfony.
- roslov/laravel-migration-checker — додає консольну команду для перевірки міграцій в Laravel.
- roslov/migration-checker — в цьому пакеті знаходиться основна бізнес-логіка.
Пакети наближаються до стабільних релізів та вже «прикручені» до CI кількох комерційних проєктів. Буду вдячний за коментарі та знайдені баги.
4 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарівОце реально корисно для CI: PR падає, якщо down() не прибрав усе назад, і diff одразу видно. Дякую — забираю в пайплайн
У Symfony є налаштування доктрини: transactional, all_or_nothing. Використання цих параметрів дозволяє уникнути проміжних станів бд.
symfony.com/.../index.html#configuration
Як часто і у яких випадках ви користуєтесь down() на production оточеннях?
Питання не в проміжних станах. А в тому що частенько багатьом колегам фіолетово як ролбек їх міграції пройде. І в автора статті такий самий біль:
Загалом на проді живого активного проекту запустити ролбек можна буде лише один раз. І йти оновлювати резюме. XD
І так, коли в тяп-ляп релізі на прод всрали міграцію.. імхо краще написати нову, яка лікує, а не намагатись підлатати down в старій.
Повністю згоден, що в продакшн-середовища такі фікси робляться новою up-міграцією. І навіть в цьому випадку її простіше зробити вже маючи перевірену down-міграцію (просто скопіювавши код з
down()поточної міграції вup()нової). Але також є різні тестові та стейджинг-середовища, де допустимо виконати down-міграцію, а потім реверт коду.Ще є кейси, де локально треба переключитись на іншу гілку з оновленою схемою бази (наприклад, допомогти колезі продебажити код), а потім повернутись назад. Так от відсутність правильної down-міграції призведе до великих втрат часу (і часто відмові в допомозі, щоб потім не морочитись).