Урок по треку PHP 8 мин

Eloquent, N+1 и миграции

Связи, жадная загрузка, N+1, массовые операции, миграции без простоя.

Eloquent это реализация Active Record в Laravel. Почти на каждом собесе спрашивают про N+1, жадную загрузку и как не положить продакшен миграцией.

Связи

class Post extends Model
{
    public function author(): BelongsTo { return $this->belongsTo(User::class); }
    public function comments(): HasMany { return $this->hasMany(Comment::class); }
    public function tags(): BelongsToMany { return $this->belongsToMany(Tag::class)->withTimestamps(); }
}

$post->comments;                                  // коллекция, загружается один раз и кешируется
$post->comments()->where('approved', true)->get(); // построитель запроса

Есть ещё hasOne, hasManyThrough и полиморфные morphTo/morphMany.

N+1

$posts = Post::all();
foreach ($posts as $post) {
    echo $post->author->name;   // запрос на каждый пост
}

Лечение:

  • Post::with(['author', 'comments.user'])->get(): по запросу на каждую связь (WHERE id IN (...));
  • $posts->load('author') для уже полученной коллекции;
  • withCount('comments'), withSum('items', 'price'), withExists: агрегаты подзапросом, без загрузки связей;
  • Model::preventLazyLoading(! app()->isProduction()): ленивая загрузка бросает исключение в разработке.

При ограничении колонок в with('author:id,name') обязательно берите ключи, иначе связи не сопоставятся.

Большие объёмы

  • chunkById(1000, fn($orders) => ...) и lazyById(): порции по первичному ключу, надёжно при изменении записей.
  • cursor(): одна модель в памяти, но один долгий запрос.
  • Массовые изменения одним запросом: Order::where(...)->update([...]), ->delete(). Они не вызывают события моделей.
  • upsert и insert для пакетной вставки.

Транзакции и блокировки

DB::transaction(function () use ($id, $amount) {
    $account = Account::lockForUpdate()->findOrFail($id);
    if ($account->balance < $amount) {
        throw new InsufficientFunds();
    }
    $account->decrement('balance', $amount);
});

Без lockForUpdate два параллельных запроса прочитают одинаковый баланс и спишут дважды. Альтернатива: атомарный UPDATE ... WHERE balance >= ? с проверкой числа изменённых строк.

Массовое присваивание

$fillable (разрешённые поля) или $guarded. В create() передают только валидированные данные: $request->validated().

Миграции

Schema::create('orders', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->string('status')->index();
    $table->timestamps();
});
  • Применённые миграции не правят, изменения делают новыми.
  • В продакшене migrate --force при деплое, --pretend показывает SQL.
  • Изменения схемы совместимы со старым и новым кодом одновременно. Переименование колонки делают в несколько релизов: добавить новую, писать в обе, перенести данные, переключить чтение, удалить старую (expand and contract).
  • Индексы на больших таблицах создаются с блокировками: планируйте окно или используйте возможности СУБД (CREATE INDEX CONCURRENTLY в PostgreSQL).

Active Record и Data Mapper

Eloquent смешивает данные и сохранение в одном классе: быстро писать, сложнее тестировать без базы. Doctrine (Data Mapper) отделяет сущности от EntityManager. Для сложного домена держите модели Eloquent тонкими, логику выносите в сервисы.

Частые ошибки

  • Ленивая загрузка в циклах и шаблонах.
  • Model::all() на больших таблицах.
  • ->count() на связи в цикле вместо withCount.
  • Изменение колонки за один релиз без совместимости.
  • Отправка заданий внутри транзакции без afterCommit.

Что спросят на собесе

  • Что такое N+1 и как его найти и исправить в Laravel?
  • Чем $user->orders отличается от $user->orders()?
  • Как обработать миллион записей?
  • Как предотвратить двойное списание при параллельных запросах?
  • Как выкатывать миграции без простоя?

Закрепите за 5 минут

После урока в тренажёре 3-5 проверочных вопросов, дальше модуль уходит в повторения.