Базы данных: миграции и Query Builder

Миграции, схема, Query Builder, транзакции, сиды и фабрики.

Junior Что такое миграции и как с ними работать?

Миграции — версионирование схемы БД в коде: описание изменений, которые можно накатывать и откатывать в любом окружении.

php artisan make:migration create_posts_table
php artisan migrate              # накатить новые
php artisan migrate:rollback     # откатить последний батч
php artisan migrate:fresh --seed # дропнуть всё и накатить заново + сиды
public function up(): void
{
    Schema::create('posts', function (Blueprint $table) {
        $table->id();
        $table->foreignId('user_id')->constrained()->cascadeOnDelete();
        $table->string('title');
        $table->text('body');
        $table->boolean('published')->default(false);
        $table->timestamps();      // created_at / updated_at
        $table->softDeletes();     // deleted_at
        $table->index('title');
    });
}

Изменение таблицы: Schema::table('posts', ...) — ->nullable(), ->change(), dropColumn, renameColumn. Отслеживание накатанных — таблица migrations (батчи).

Junior Какие типы отношений внешних ключей и каскадов доступны в миграциях?
$table->foreignId('user_id')->constrained();                 // users.id
$table->foreignId('user_id')->constrained('authors');        // authors.id
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();

// Поведение при удалении/обновлении родителя:
->cascadeOnDelete();    // удалить дочерние
->restrictOnDelete();   // запретить удаление родителя
->nullOnDelete();       // обнулить FK (поле nullable)
->cascadeOnUpdate();

// Полный синтаксис:
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');

Составные и обычные индексы:

$table->index('email');
$table->unique(['tenant_id', 'email']);
$table->fullText('body');          // полнотекстовый
$table->index(['a', 'b'], 'name'); // составной — порядок колонок важен

Помнить: foreignId сам по себе индекс не создаёт в некоторых СУБД — но constrained() в MySQL создаёт индекс автоматически.

Junior Что такое сиды и фабрики? Как их используют в разработке и тестах?

Сиды — наполнение БД начальными данными:

php artisan make:seeder UserSeeder
php artisan db:seed --class=UserSeeder
php artisan migrate:fresh --seed
class DatabaseSeeder extends Seeder
{
    public function run(): void
    {
        $this->call([RoleSeeder::class, UserSeeder::class]);
    }
}

Фабрики — генерация моделей с фейковыми данными (Faker):

php artisan make:factory PostFactory
class PostFactory extends Factory
{
    public function definition(): array
    {
        return [
            'title' => fake()->sentence(),
            'user_id' => User::factory(),
            'published' => fake()->boolean(80),
        ];
    }

    public function published(): static   // состояние
    {
        return $this->state(['published' => true]);
    }
}

Использование:

Post::factory()->count(10)->published()->create();   // в БД
User::factory()->make();                             // без сохранения
User::factory()->has(Post::factory(3))->create();    // со связями

В тестах — вместе с RefreshDatabase для детерминированных данных.

Middle Чем Query Builder отличается от Eloquent и сырых запросов? Когда что использовать?

Query Builder (DB::table()) — fluent-интерфейс поверх PDO, без моделей:

DB::table('users')
    ->where('active', true)
    ->orderByDesc('created_at')
    ->limit(10)
    ->get();   // коллекция stdClass

Eloquent (User::where(...)->get()) — модели с отношениями, событиями, мутаторами, но дороже по памяти и медленнее на массовых операциях.

Сырые запросы:

DB::select('SELECT * FROM users WHERE id = ?', [$id]);  // биндинги — обязательно!
DB::statement('DROP TABLE ...');
DB::raw('COUNT(*) as total');  // фрагмент внутри builder'а

Когда что:

  • CRUD с бизнес-логикой, отношения → Eloquent;
  • массовые update/delete, отчёты, сложные join'ы → Query Builder;
  • то, что не выразить builder'ом (специфика СУБД) → raw с биндингами.

Никогда не подставлять пользовательский ввод в raw-строки конкатенацией — SQL-инъекция.

Middle Как работают транзакции в Laravel?
DB::transaction(function () {
    $order = Order::create([...]);
    $order->items()->createMany($items);
    // любое исключение → откат обеих операций
}, 5);  // 5 попыток при deadlock

Ручное управление:

DB::beginTransaction();
try {
    // ...
    DB::commit();
} catch (Throwable $e) {
    DB::rollBack();
    throw $e;
}

Важные нюансы:

  • исключение внутри замыкания автоматически откатывает транзакцию и пробрасывается дальше;
  • return из замыкания — нормальный commit;
  • события моделей (created и т.п.) и queued jobs внутри транзакции опасны: джоба может выполниться до commit → afterCommit() или Bus::dispatch(...)->afterCommit();
  • вложенные транзакции — через savepoint'ы;
  • DDL в MySQL не транзакционен — миграции не откатываются транзакциями.
Middle Как выполнить пагинацию и в чём разница paginate(), simplePaginate() и cursorPaginate()?
$users = User::paginate(15);          // с общим числом страниц
$users = User::simplePaginate(15);    // только «назад/вперёд», без COUNT(*)
$users = User::cursorPaginate(15);    // по курсору (WHERE id > ?)

В шаблоне: {{ $users->links() }}.

  • paginate() делает дополнительный COUNT(*) — нужен для «страница 3 из 120», но дорог на больших таблицах;
  • simplePaginate() без COUNT — дешевле, но нет номеров страниц;
  • cursorPaginate() использует упорядоченный курсор (WHERE id > X LIMIT n) — O(1) независимо от глубины, защищён от «сдвига» данных при вставках, но нельзя перепрыгнуть на произвольную страницу. Подходит для бесконечных лент и API.

Для больших выгрузок без HTTP — chunk() / chunkById() / cursor() (lazy-итерация, одна запись в памяти за раз).