Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

Питання на співбесіді: Міграції

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

7 питань

Міграція - це контроль версій для структури бази даних. Замість того щоб писати SQL вручну й узгоджувати його між розробниками, ви описуєте схему PHP-кодом, який комітиться в git і застосовується однаково в усіх середовищах.

Міграції зберігаються в директорії database/migrations. Кожен файл - це клас із двома методами:

  • up() - описує зміни, які треба застосувати (створити таблицю, додати колонку, індекс).
  • down() - описує зворотну операцію для відкату (видалити таблицю чи колонку).

Створити міграцію:

php artisan make:migration create_posts_table

Згенерований файл заповнюють у методі up():

public function up(): void
{
    Schema::create('posts', function (Blueprint $table) {
        $table->id();
        $table->string('title');
        $table->text('body');
        $table->timestamps();
    });
}

public function down(): void
{
    Schema::dropIfExists('posts');
}

Основні команди:

  • php artisan migrate - застосувати нові міграції.
  • php artisan migrate:rollback - відкотити останній «батч».
  • php artisan migrate:fresh --seed - перестворити всі таблиці й засіяти даними.

Навіщо: будь-який розробник на новій машині отримує ідентичну схему БД однією командою, а зміни структури проходять через code review разом із кодом.

Докладніше в документації: Міграції

migrate:rollback відкочує останній «батч» міграцій, викликаючи їхні методи down():

php artisan migrate:rollback              # останній батч
php artisan migrate:rollback --step=1     # рівно одну міграцію

migrate:reset відкочує всі міграції по черзі. migrate:refresh - відкочує все й накатує заново. migrate:fresh - видаляє всі таблиці й накатує міграції з нуля, взагалі не заглядаючи в down().

Що з цього небезпечне в проді: усі чотири. Кожна знищує дані, а migrate:fresh робить це найшвидше й без шансу на down(). У проді припустимий лише php artisan migrate.

Laravel сам питає підтвердження в продакшн-середовищі, і саме тому --force не варто вписувати в скрипти «щоб не заважало».

Практика, що рятує:

  • Перевіряйте відкат локально одразу після написання: migrate → migrate:rollback --step=1 → migrate. Половина міграцій має неробочий down(), і виявляється це в найгірший момент.
  • Пишіть down() чесно, а якщо відкат неможливий - хай кидає виняток, це краще за мовчазну порожню реалізацію.
  • Не редагуйте вже застосовану міграцію - додавайте нову. Виправлений файл не перезастосується там, де він уже відпрацював.
  • Видалення колонки й перейменування - окремими релізами від коду, що їх читає, інакше деплой ламає працюючий застосунок у проміжку.

Докладніше в документації: Міграції

На таблиці в кілька мільйонів рядків необережна міграція блокує запис на хвилини, і застосунок лежить.

Що блокує довго:

  • Додавання колонки NOT NULL зі значенням за замовчуванням у старих версіях MySQL переписує таблицю цілком. У MySQL 8 і PostgreSQL 11+ це вже метадані, але перевіряти варто саме свою версію.
  • Створення індексу звичайним CREATE INDEX тримає таблицю на час побудови.
  • Зміна типу колонки майже завжди означає перезапис.

Безпечний порядок для нової колонки:

// 1. Спершу nullable - миттєво, без перезапису.
Schema::table('vacancies', function (Blueprint $table) {
    $table->string('short_name')->nullable()->after('title');
});

Далі заповнити дані пачками - окремою командою, не в міграції:

Vacancy::whereNull('short_name')->chunkById(500, function ($vacancies) {
    foreach ($vacancies as $vacancy) {
        $vacancy->update(['short_name' => Str::limit($vacancy->title, 40, '')]);
    }
});

І лише потім, якщо потрібно, робити колонку обовʼязковою - окремою міграцією, коли даних без значення вже немає.

Індекс без блокування:

// PostgreSQL
DB::statement('CREATE INDEX CONCURRENTLY vacancies_level_index ON vacancies (level)');

CONCURRENTLY не можна виконувати всередині транзакції, тож у міграції потрібно вимкнути обгортання:

public $withinTransaction = false;

Загальне правило: розділяйте зміну схеми й заповнення даних. Міграція має бути швидкою операцією над структурою, а перенесення даних - командою, яку можна запустити, зупинити й продовжити.

Докладніше в документації: Міграції

Кожен запуск php artisan migrate записує виконані міграції в таблицю migrations з одним номером пакета (batch).

php artisan migrate:rollback            # відкотити останній пакет цілком
php artisan migrate:rollback --step=1   # рівно одну останню міграцію
php artisan migrate:rollback --batch=3  # конкретний пакет
php artisan migrate:status              # що виконано і в якому пакеті

Відкат викликає down(), тож вона має справді повертати попередній стан: видалити створену колонку, відновити змінений тип.

Інші команди - і чим вони небезпечні:

  • migrate:reset - відкотити все;
  • migrate:refresh - відкотити все й виконати знову (проходить усі down());
  • migrate:fresh - видалити всі таблиці й виконати міграції з нуля, down() не викликається.

Останні три знищують дані - на продакшені їх не запускають, а локально з копією прод-бази - теж обережно.

На продакшені відкат міграції, яка видалила колонку з даними, не поверне дані. Тому руйнівні зміни роблять окремим релізом пізніше, а відкат релізу - новою міграцією вперед, а не rollback.

Докладніше в документації: Відкат міграцій

Schema::create('comments', function (Blueprint $table) {
    $table->id();
    $table->foreignId('post_id')->constrained()->cascadeOnDelete();
    $table->foreignIdFor(User::class)->nullable()->constrained()->nullOnDelete();
    $table->timestamps();
});
  • foreignId('post_id')->constrained() - колонка unsignedBigInteger плюс обмеження на posts.id (таблицю Laravel виводить з назви колонки);
  • foreignIdFor(User::class) - назву колонки бере з моделі.

Що буде з дітьми при видаленні батька - вирішують явно:

  • cascadeOnDelete() - видалити разом (коментарі поста);
  • nullOnDelete() - обнулити посилання (автор видалив акаунт, коментар лишається анонімним; колонка має бути nullable);
  • restrictOnDelete() - заборонити видалення, поки є діти (клієнт із рахунками).

Що варто знати:

  • обмеження працює на рівні бази й обходить Eloquent - події моделей для каскадно видалених рядків не спрацюють;
  • м'яке видалення батька каскад не запускає - це звичайний UPDATE;
  • MySQL сам створює індекс для зовнішнього ключа, PostgreSQL - ні, тож там індекс на post_id додають окремо;
  • видалити ключ: $table->dropForeign(['post_id']).

Докладніше в документації: Обмеження зовнішніх ключів

Міграції виконуються один раз на базу, а не на кожному сервері.

Як цього досягти:

  • окремий крок деплою (job у CI, init-контейнер, release-фаза) замість migrate у старті кожного контейнера;
  • якщо запускають з кожного сервера - php artisan migrate --isolated --force: перший бере атомарне блокування в кеші, інші тихо завершуються. Кеш має бути спільним;
  • --force потрібен, щоб команда не питала підтвердження на продакшені.

Сумісність схеми й коду. Під час rolling-деплою одночасно працюють стара й нова версії коду, тож міграція має підходити обом:

  1. Розширення: додати нову колонку (nullable чи з дефолтом), код пише в обидві.
  2. Перенесення даних фоново порціями.
  3. Перемикання коду на нову колонку.
  4. Звуження: видалити стару колонку окремим наступним релізом.

Що ще враховують:

  • довгі блокування на великих таблицях: instant() на MySQL, online() для індексів на PostgreSQL, нічні вікна для важкого;
  • PostgreSQL обгортає міграцію в транзакцію, а CREATE INDEX CONCURRENTLY у транзакції не працює;
  • сідери довідників - окремо від міграцій і ідемпотентні.

Докладніше в документації: Ізоляція виконання міграцій

Сотні файлів сповільнюють створення бази для тестів і CI, а історія давно не потрібна покроково.

php artisan schema:dump --prune

Команда записує поточну схему в database/schema/{connection}-schema.sql і з --prune видаляє наявні міграції. Нова база спершу виконує SQL зі схеми, а потім - міграції, створені після дампу.

На що зважати:

  • Різні СУБД. Дамп робиться для конкретного підключення. Якщо тести йдуть на іншій базі (SQLite замість PostgreSQL), потрібен дамп і для неї - інакше тести не зберуть схему.
  • Інструменти. Для дампу потрібні mysqldump чи pg_dump на машині, де виконується команда.
  • Наявні середовища не зачіпаються: продакшен уже має всі ці міграції в таблиці migrations.
  • Дані в міграціях. Якщо старі міграції наповнювали довідники, після стискання цих даних у новій базі не буде - їх переносять у сідери.

Альтернатива без стискання - тримати міграції, але прискорити тести: RefreshDatabase мігрує базу один раз на прогін і далі працює транзакціями, тож кількість міграцій впливає лише на старт.

Докладніше в документації: Стискання міграцій