Skip to content

Eloquent & База данных — лучшие практики

Дополняет внутренние правила ../models.md, ../migrations.md, ../relations.md. Здесь — общепринятые паттерны Laravel-сообщества и официальной доки.

N+1 — главный враг производительности

N+1 — это баг, не оптимизация. Защита на трёх уровнях:

php
// 1. Явный eager load в месте вызова
$posts = Post::with(['author', 'tags'])->get();

// 2. Подсчёт без загрузки
$users = User::withCount('posts')->get();          // $user->posts_count

// 3. Глобальная защита в dev (AppServiceProvider::boot)
Model::preventLazyLoading(! app()->isProduction());
Model::preventSilentlyDiscardingAttributes(! app()->isProduction());

preventLazyLoading в dev/test кидает исключение при ленивой загрузке — N+1 ловится на этапе разработки, не в проде.

Вложенный и условный eager load

php
Post::with(['comments' => fn ($q) => $q->where('approved', true)->latest()])->get();
Post::with('author:id,name')->get();               // только нужные колонки
$post->loadMissing('author');                      // догрузить, если ещё не загружено

Запросы: что использовать

ЗадачаПравильноИзбегать
Все записи с условием->where()->get()Model::all() (вся таблица в память)
Первая запись->orderBy()->first()->first() без сортировки (недетерминирован)
Существование->exists()->count() > 0 (лишняя агрегация)
Большие выборки->chunkById(1000, fn) / ->lazy()->get() на >10k строк (OOM)
Массовый апдейт->update([...]) (один запрос)цикл с ->save() (N запросов)
Агрегаты->sum(), ->avg() в SQLзагрузка + PHP-цикл

chunkById, не chunk

При мутации в процессе обхода chunk() пропускает строки (сдвиг offset). chunkById() идёт по PK — безопасен:

php
User::where('active', false)->chunkById(1000, function ($users) {
    foreach ($users as $user) { $user->update(['archived' => true]); }
});

Транзакции

php
// Замыкание — авто-commit/rollback, retry на deadlock
DB::transaction(function () use ($data) {
    $order = Order::create($data);
    $order->items()->createMany($data['items']);
    $this->inventory->reserve($order);
}, attempts: 3);
  • Несколько связанных записей → всегда транзакция (атомарность).
  • Внешние вызовы (API, mail) — вне транзакции или после commit (DB::afterCommit() / событие).
  • Долгие транзакции держат блокировки — минимизировать тело.

Индексы и схема (PostgreSQL-проекты)

Детали и спец-индексы (GIN, partial, functional, covering) — в ../migrations.md. Ключевое:

  • FK → constrained() + index(), осознанный onDelete (cascade/null/restrict).
  • Индексы на столбцы в WHERE / ORDER BY / JOIN.
  • CREATE INDEX CONCURRENTLY + $withinTransaction = false на таблицах >1M строк.
  • После массового ETL → ANALYZE table (статистика планировщика).
  • Кириллица + ILIKE → functional index на lower(col).

Модель — что внутри, что снаружи

Внутри моделиСнаружи (Service)
casts(), связи, scopesбизнес-расчёты, формулы
Accessor/Mutator (нормализация)вызовы внешних API
Простые предикаты (isPublished())транзакции с несколькими моделями
  • НЕ $with в модели (грузит всегда) → ->with() в запросе.
  • НЕ request()/auth() в модели (HTTP-контекст) → передавать данными.
  • Accessor НЕ делает запросов к БД (скрытый N+1).

Mass assignment

php
protected $fillable = ['title', 'body', 'category_id'];   // whitelist — предпочтительно
// $guarded = [] — только для моделей без пользовательского ввода (сидеры, internal)

preventSilentlyDiscardingAttributes (dev) кидает исключение при попытке заполнить не-fillable поле — ловит опечатки.

Scopes — переиспользуемые условия

php
// Laravel 11+ атрибут #[Scope] или классический scopeXxx
public function scopePublished(Builder $q): void
{
    $q->where('status', PostStatus::Published)->where('published_at', '<=', now());
}
// Post::published()->latest()->get();

Scope создавать когда условие в 2+ местах. Одноразовое — не scope.

Anti-patterns

ПаттернПроблемаРешение
Model::all()вся таблица в RAMусловия + paginate/chunk
Цикл с save()N запросовbulk update()/upsert()
DB::raw() с конкатенациейSQL-инъекцияbindings / Eloquent
$with в моделигрузит relation всегда->with() в запросе
first() без orderByнедетерминированный результатorderBy()->first()
Транзакция с API-вызовом внутривисящая блокировкавынести за commit
Accessor с запросом к БДскрытый N+1Service

Внутренняя база знаний студии