О разделе

Это не повторение KB_3 (там был обзор Eloquent на уровне «что такое»). Здесь — глубокий разбор с упором на:

  • что реально требуется на уровне middle/senior;
  • что встречается в продакшене и ломается без понимания;
  • как избегать race conditions, N+1, мест где «оно работало, а потом перестало».
После прочтения ты должен уметь:
  • спроектировать любые relations включая polymorphic и hasManyThrough без подсказок;
  • выбрать между with(), load(), chunk(), cursor(), lazy() для конкретной задачи;
  • написать кастомный каст и accessor в новом API (Laravel 9+);
  • использовать observers без бесконечных циклов и race conditions;
  • объяснить как Eloquent отличается от Query Builder под капотом.
Prerequisites
  • KB_1 — PHP OOP, traits, magic methods
  • KB_2 — SQL, JOIN, индексы
  • KB_3 разделы 1-5 — базовый Eloquent
  • KB_9 — что наследует Model
Все типы relations — обзор
Назначение

Relation — это объявленный в модели метод, который декларативно описывает связь с другой моделью. На основании этого объявления Eloquent генерирует SQL-запросы (SELECT, JOIN, подзапросы) и гидрирует результат в коллекцию связанных моделей.

Преимущества декларативного подхода относительно ручного построения запросов:

  • отсутствие дублирования JOIN-логики между контроллерами и сервисами;
  • работа с объектной моделью домена ($post->author->name) вместо плоских массивов;
  • встроенная оптимизация: with() и load() автоматически устраняют проблему N+1;
  • композиция relation с произвольными условиями: $user->posts()->where('published', true)->count().

Eloquent предоставляет десять типов отношений, разделённых на четыре категории по кардинальности и характеру связи: один к одному, один ко многим, многие ко многим и полиморфные.

Перечень типов отношений

Каждый тип разбирается по единой схеме: определение, область применения, генерируемый SQL, синтаксис объявления и использования. Порядок изложения: четыре базовых типа, две модификации «через промежуточную модель», четыре полиморфных варианта.

1. hasOne

Определение. Связь, при которой одной записи родительской модели соответствует не более одной записи дочерней модели. Внешний ключ располагается в дочерней таблице.

Применение. Используется при декомпозиции таблицы по соображениям нормализации, разделения прав доступа или производительности. Характерные примеры: User и Profile (расширенные атрибуты профиля), Order и Invoice (счёт-фактура), User и ApiToken.

SQL. Один запрос вида SELECT * FROM profiles WHERE user_id = ? LIMIT 1.

// Таблица profiles: id, user_id, bio, avatar
class User extends Model
{
    public function profile(): HasOne
    {
        return $this->hasOne(Profile::class);
        // эквивалентно: hasOne(Profile::class, 'user_id', 'id')
    }
}

// Использование
$user->profile;             // Profile или null
$user->profile->bio;        // доступ к полю
$user->profile()->create(['bio' => 'Hello']);  // создать привязанный profile

2. belongsTo

Определение. Обратная сторона hasOne и hasMany. Объявляется на модели, таблица которой содержит внешний ключ, ссылающийся на родительскую модель.

Применение. Восходящий обход графа моделей: из дочерней получить родительскую. Примеры: Post::author() (поле posts.user_id), OrderItem::order() (поле order_items.order_id), Comment::post(). Эвристика: внешний ключ *_id находится в таблице той модели, на которой объявляется belongsTo.

SQL. SELECT * FROM users WHERE id = ? LIMIT 1.

class Post extends Model
{
    public function author(): BelongsTo
    {
        // 2-й параметр — имя FK (если не стандартное user_id)
        return $this->belongsTo(User::class, 'user_id');
    }
}

$post->author;                // User или null
$post->author()->associate($user);    // привязать (set user_id)
$post->author()->dissociate();         // отвязать (user_id = null)

3. hasMany

Определение. Одной родительской записи соответствует произвольное количество дочерних. Внешний ключ располагается в дочерней таблице.

Применение. Самое распространённое отношение в реляционных моделях. Примеры: Post и Comment, User и Order, Category и Product, Author и Book.

SQL. При обращении к одной модели: SELECT * FROM comments WHERE post_id = ?. При eager loading коллекции: SELECT * FROM comments WHERE post_id IN (?, ?, ...).

class Post extends Model
{
    public function comments(): HasMany
    {
        return $this->hasMany(Comment::class);
    }
}

$post->comments;                          // Collection<Comment>
$post->comments()->where('approved', true)->get(); // фильтр
$post->comments()->create(['body' => 'Привет']);    // создать привязанный
$post->comments()->count();                  // число коммов (SELECT COUNT)

4. belongsToMany

Определение. Симметричное отношение «многие ко многим», при котором каждая запись одной модели может быть связана с произвольным количеством записей другой и наоборот. Реализуется через промежуточную (pivot) таблицу, содержащую как минимум два внешних ключа.

Применение. Связи без направления подчинённости: User и Role (пользователь имеет несколько ролей, одна роль назначена множеству пользователей), Post и Tag, Student и Course, Product и Warehouse.

SQL. SELECT roles.* FROM roles INNER JOIN role_user ON role_user.role_id = roles.id WHERE role_user.user_id = ?.

// Pivot-таблица role_user: user_id + role_id
class User extends Model
{
    public function roles(): BelongsToMany
    {
        return $this->belongsToMany(Role::class);
    }
}

$user->roles;                          // Collection<Role>
$user->roles()->attach(3);              // добавить роль с id=3
$user->roles()->detach(3);              // отвязать роль с id=3
$user->roles()->sync([1, 2, 5]);       // оставить ровно эти, остальные убрать

Детальный разбор pivot с дополнительными колонками, кастомными pivot-моделями и методами синхронизации вынесен в отдельный подраздел «belongsToMany & Pivot».

5. hasOneThrough

Определение. Доступ к единственной записи из «дальней» таблицы через одну промежуточную модель. Применяется к транзитивным отношениям длины 2.

Применение. Когда между двумя моделями отсутствует прямая связь, но существует промежуточная сущность, дающая логически однозначное соответствие. Пример: Country — User — Profile, где требуется получить профиль главного представителя страны.

Альтернатива — последовательность из двух обращений к relation, однако hasOneThrough выполняется одним SQL-запросом с JOIN, что эффективнее.

// Country → User → Profile
class Country extends Model
{
    public function primaryProfile(): HasOneThrough
    {
        return $this->hasOneThrough(Profile::class, User::class);
        // SQL: SELECT profiles.* FROM profiles
        //      INNER JOIN users ON profiles.user_id = users.id
        //      WHERE users.country_id = ? LIMIT 1
    }
}

$country->primaryProfile;  // Profile или null

6. hasManyThrough

Определение. Аналог hasOneThrough с возвратом коллекции. Позволяет получить все записи «дальней» таблицы, связанные с текущей моделью через одну промежуточную.

Применение. Транзитивный обход агрегатов. Примеры: Country — User — Post (все посты пользователей страны), Project — User — Task, Forum — Thread — Reply.

class Country extends Model
{
    public function posts(): HasManyThrough
    {
        return $this->hasManyThrough(Post::class, User::class);
    }
}

$country->posts;            // Collection<Post> — все посты всех юзеров страны
$country->posts()->count();   // число одним SQL

Сравнение с whereHas и нестандартными вариантами цепочек разобрано в отдельном подразделе «hasManyThrough».

7. morphOne

Определение. Полиморфный вариант hasOne: к текущей модели привязана одна запись, но идентификатор привязки хранится в комбинации из двух колонок — {name}_type (полное имя класса родителя) и {name}_id (его идентификатор).

Применение. Когда несколько различных моделей нуждаются в одинаково устроенной «однократной» связанной сущности. Пример: единственное изображение-аватар у User и единственное изображение-обложка у Post, при этом обе сущности используют общую таблицу images.

// Таблица images: id, imageable_type, imageable_id, url
class User extends Model
{
    public function avatar(): MorphOne
    {
        return $this->morphOne(Image::class, 'imageable');
    }
}

class Post extends Model
{
    public function cover(): MorphOne
    {
        return $this->morphOne(Image::class, 'imageable');
    }
}

$user->avatar->url;
$post->cover->url;

8. morphMany

Определение. Полиморфный вариант hasMany: к текущей модели может быть привязано произвольное количество записей единой связанной таблицы, при этом разные родительские модели делят одно хранилище.

Применение. Классический случай — система комментариев, в которой Comment может относиться к Post, Photo или Video. Альтернативой было бы создание отдельных таблиц post_comments, photo_comments, video_comments, что приводит к дублированию схемы и логики.

// Таблица comments: id, commentable_type, commentable_id, body
class Post extends Model
{
    public function comments(): MorphMany
    {
        return $this->morphMany(Comment::class, 'commentable');
    }
}

class Photo extends Model
{
    public function comments(): MorphMany
    {
        return $this->morphMany(Comment::class, 'commentable');
    }
}

$post->comments;                       // Collection<Comment>
$post->comments()->create(['body' => '!']);  // commentable_type=App\Models\Post автоматически

9. morphTo

Определение. Обратная сторона полиморфного отношения. Объявляется на модели, в таблице которой хранится пара колонок {name}_type и {name}_id. Eloquent определяет конкретный класс родителя по значению из колонки _type и инстанцирует соответствующую модель.

Применение. Используется в паре с morphOne или morphMany: на модели Comment, Image, Like и им подобных. Позволяет из связанной записи получить родительскую сущность произвольного типа.

class Comment extends Model
{
    public function commentable(): MorphTo
    {
        return $this->morphTo();
    }
}

$comment->commentable;     // Post, Photo, или Video — в зависимости от commentable_type

if ($comment->commentable instanceof Post) { ... }

Примечание о хранении типа. По умолчанию Laravel записывает в колонку {name}_type полное имя класса (например, App\Models\Post). Это создаёт жёсткую зависимость данных от структуры неймспейсов: переименование класса или перемещение его в другой каталог делает существующие записи нечитаемыми. Решение — явная регистрация морф-карты через Relation::enforceMorphMap(), разобранная в подразделе «Polymorphic».

10. morphToMany и morphedByMany

Определение. Полиморфный вариант отношения «многие ко многим». Промежуточная таблица содержит, помимо двух внешних ключей, дополнительную колонку {name}_type, что позволяет одной общей таблице связей обслуживать несколько типов родительских моделей. Объявление morphToMany используется на «специализированной» стороне (Post, Photo), morphedByMany — на «общей» (Tag).

Применение. Системы тегирования, категоризации, отметок «избранное» и иных свойств, применимых к разнородным сущностям без дублирования pivot-таблиц для каждой пары.

// Pivot polymorphic: taggables (tag_id, taggable_id, taggable_type)
class Post extends Model
{
    public function tags(): MorphToMany
    {
        return $this->morphToMany(Tag::class, 'taggable');
    }
}

class Tag extends Model
{
    // На стороне «общей» сущности — обратная связь morphedByMany
    public function posts(): MorphToMany
    {
        return $this->morphedByMany(Post::class, 'taggable');
    }

    public function photos(): MorphToMany
    {
        return $this->morphedByMany(Photo::class, 'taggable');
    }
}

$post->tags()->attach($tag->id);    // прикрепить тег
$tag->posts;                       // все посты с этим тегом
$tag->photos;                      // все фото с этим тегом
Правило выбора (когда сомневаешься):
  • FK *_id на текущей модели → belongsTo
  • FK *_id на «дочерней» модели → hasOne (если одна) или hasMany (если много)
  • Нужна отдельная таблица связей с двумя FK → belongsToMany
  • Нужно дотянуться через промежуточную модель → hasManyThrough
  • Одна сущность связана с несколькими разными моделями → morphTo на ней + morphMany/morphToMany на родителях
Сквозной пример: схема блога

Чтобы продемонстрировать взаимодействие всех изложенных типов отношений, рассмотрим схему многопользовательского блога. Большинство десяти relation встретится в одной модельной структуре.

Сущности предметной области:

  • User — зарегистрированный пользователь; автор статей, комментариев и лайков;
  • Profile — расширенная информация о пользователе, вынесенная в отдельную таблицу;
  • Post — опубликованная статья;
  • Comment — комментарий, оставленный к Post или Photo;
  • Photo — самостоятельный медиаобъект в галерее;
  • Tag — тематическая метка, применимая и к Post, и к Photo;
  • Like — реакция, оставленная к Post, Comment или Photo;
  • Country — географическая принадлежность пользователя.
// ═══════════════════ User ═══════════════════
class User extends Model
{
    // hasOne — один профиль
    public function profile(): HasOne
    {
        return $this->hasOne(Profile::class);
    }

    // hasMany — много постов
    public function posts(): HasMany
    {
        return $this->hasMany(Post::class);
    }

    // belongsTo — юзер принадлежит стране
    public function country(): BelongsTo
    {
        return $this->belongsTo(Country::class);
    }
}

// ═══════════════════ Post ═══════════════════
class Post extends Model
{
    // belongsTo — автор
    public function author(): BelongsTo
    {
        return $this->belongsTo(User::class, 'user_id');
    }

    // morphMany — комментарии (полиморфные, могут быть и у Photo)
    public function comments(): MorphMany
    {
        return $this->morphMany(Comment::class, 'commentable');
    }

    // morphToMany — теги (общие с Photo)
    public function tags(): MorphToMany
    {
        return $this->morphToMany(Tag::class, 'taggable');
    }

    // morphMany — лайки
    public function likes(): MorphMany
    {
        return $this->morphMany(Like::class, 'likeable');
    }
}

// ═══════════════════ Comment ═══════════════════
class Comment extends Model
{
    // morphTo — к чему привязан комментарий (Post или Photo)
    public function commentable(): MorphTo
    {
        return $this->morphTo();
    }

    // belongsTo — автор коммента
    public function author(): BelongsTo
    {
        return $this->belongsTo(User::class, 'user_id');
    }
}

// ═══════════════════ Tag ═══════════════════
class Tag extends Model
{
    // morphedByMany — обратная сторона polymorphic many-to-many
    public function posts(): MorphToMany
    {
        return $this->morphedByMany(Post::class, 'taggable');
    }

    public function photos(): MorphToMany
    {
        return $this->morphedByMany(Photo::class, 'taggable');
    }
}

// ═══════════════════ Country ═══════════════════
class Country extends Model
{
    // hasMany — много юзеров
    public function users(): HasMany
    {
        return $this->hasMany(User::class);
    }

    // hasManyThrough — все посты всех юзеров страны
    public function posts(): HasManyThrough
    {
        return $this->hasManyThrough(Post::class, User::class);
    }

    // hasOneThrough — главный модератор страны (один первый по дате)
    public function leadModerator(): HasOneThrough
    {
        return $this->hasOneThrough(Profile::class, User::class)
            ->where('users.role', 'moderator');
    }
}

Типовые сценарии применения объявленных отношений:

// 1. Страница страны: пользователи, посты, главный модератор — одним набором запросов
$country = Country::with(['users.profile', 'posts.author', 'leadModerator'])
    ->findOrFail(1);

// 2. Лента блога — посты с автором, тегами, числом комментов и лайков
$feed = Post::with(['author.profile', 'tags'])
    ->withCount(['comments', 'likes'])
    ->latest()
    ->paginate(10);

// 3. Привязать тег к посту
$post->tags()->attach($tag->id);
$post->tags()->sync([1, 2, 3]); // заменить все теги на эти

// 4. Оставить комментарий к посту (через morphMany)
$post->comments()->create([
    'body' => 'Привет!',
    'user_id' => auth()->id(),
]);

// 5. На странице коммента узнать, к чему он привязан
if ($comment->commentable instanceof Post) {
    return route('posts.show', $comment->commentable);
} elseif ($comment->commentable instanceof Photo) {
    return route('photos.show', $comment->commentable);
}

// 6. Все посты, отмеченные тегом "laravel"
$posts = Tag::where('name', 'laravel')->first()->posts;

// 7. Юзеры, у которых есть хотя бы один опубликованный пост
User::whereHas('posts', fn($q) => $q->where('status', 'published'))
    ->get();
Особые случаи и типичные ошибки
1. Имена внешних ключей. Eloquent предполагает соглашение snake_case с суффиксом _id и именем, производным от связанной модели (user_id, post_id). При отступлении от соглашения — например, использовании author_id вместо user_id — имя ключа необходимо указывать явно вторым параметром: belongsTo(User::class, 'author_id').
2. Имя pivot-таблицы определяется алфавитно. Для пары User и Role Eloquent ожидает таблицу role_user, а не user_role. При несоответствии передавайте имя таблицы вторым параметром: belongsToMany(Role::class, 'user_roles').
3. Обращение к belongsTo при NULL во внешнем ключе. Если поле posts.user_id допускает значение NULL, выражение $post->author->name приведёт к ошибке доступа к свойству у null. Корректные подходы: использование nullsafe-оператора $post->author?->name ?? 'Аноним', либо ограничение NOT NULL на уровне миграции.
4. Утрата ссылки в полиморфных колонках после рефакторинга. В таблице хранится полное имя класса (например, App\Models\Post). Переименование, перенос в другой неймспейс или удаление класса делают связанные записи неразрешимыми. Стандартное решение — декларация явного отображения через Relation::enforceMorphMap() в провайдере приложения.
5. Глубина цепочки в hasManyThrough. Метод поддерживает только одну промежуточную модель. Для цепочек большей длины (например, Country — Region — City — Hotel) необходим либо ручной JOIN-запрос, либо сторонний пакет staudenmeir/eloquent-has-many-deep.
6. Стоимость morphTo при eager loading. В отличие от belongsTo, требующего одного запроса, morphTo вынужден выполнить отдельный запрос для каждого встреченного типа — невозможно построить JOIN, не зная заранее, к какой таблице обращаться. Это следует учитывать при проектировании выборок с большим количеством разнородных родителей.
7. Различие между методом и динамическим свойством. Обращение $user->posts возвращает уже загруженную коллекцию (или выполняет запрос и кеширует результат). Обращение $user->posts() возвращает экземпляр построителя запросов, к которому можно применять дополнительные ограничения: $user->posts()->where('status', 'published')->count(). Смешение этих форм приводит либо к избыточным запросам, либо к ошибкам компиляции.
8. Рекурсивные отношения. Модель может ссылаться на саму себя — например, User с полем manager_id, указывающим на другого User. Это объявляется как belongsTo(User::class, 'manager_id'). При использовании eager loading необходимо ограничивать глубину рекурсии, поскольку выражение вида with('manager.manager.manager') приведёт к экспоненциальному росту количества запросов.
Рекомендация по именованию. Имена relation должны делать выражения в коде читаемыми как естественный текст. Конструкция $post->author->profile->avatar однозначно описывает обход графа моделей. Сокращения наподобие $post->u->p->a экономят символы, но требуют чтения схемы для понимания.
belongsToMany и Pivot
Назначение

Отношение «многие ко многим» реализуется в реляционных базах данных через промежуточную таблицу, традиционно называемую pivot-таблицей. Она содержит как минимум два внешних ключа, ссылающихся на связываемые сущности, и может включать дополнительные атрибуты, описывающие саму связь, — временные метки, статус, автор присвоения, дату истечения.

Объявление belongsToMany в модели Eloquent скрывает работу с этой таблицей: при обращении к свойству выполняется JOIN, при вызове методов attach, detach, sync производятся соответствующие INSERT и DELETE. При этом дополнительные колонки pivot-таблицы доступны через специальный атрибут pivot на связанной модели.

Понимание pivot-таблицы как самостоятельного хранилища данных о связи — ключевое отличие промышленного применения отношений от учебных примеров. Большинство реальных задач (управление ролями с истечением, корзина с количеством товара, участники проекта с уровнем доступа) требуют не только факта связи, но и её атрибутов.

Компоненты механизма

Базовая схема и объявление

Минимальная конфигурация состоит из миграции pivot-таблицы с двумя внешними ключами и двух взаимно-симметричных объявлений belongsToMany на связываемых моделях.

// Миграция pivot-таблицы. Имя соответствует соглашению:
// две модели в алфавитном порядке, snake_case, единственное число.
Schema::create('role_user', function (Blueprint $table) {
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->foreignId('role_id')->constrained()->cascadeOnDelete();
    $table->primary(['user_id', 'role_id']);
});

// На каждой из связываемых моделей объявляется зеркальный метод.
class User extends Model
{
    public function roles(): BelongsToMany
    {
        return $this->belongsToMany(Role::class);
    }
}

class Role extends Model
{
    public function users(): BelongsToMany
    {
        return $this->belongsToMany(User::class);
    }
}

Объявление принимает дополнительные параметры для случаев, когда имена таблицы или ключей отступают от соглашений: belongsToMany(Role::class, 'user_roles', 'user_id', 'role_id').

Дополнительные колонки pivot-таблицы

Если pivot-таблица содержит атрибуты сверх двух внешних ключей, их необходимо явно перечислить через withPivot(), иначе они не будут возвращены в результатах запроса. Метод withTimestamps() подключает обработку колонок created_at и updated_at, обновляемых при attach, updateExistingPivot и связанных операциях.

Schema::create('role_user', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained();
    $table->foreignId('role_id')->constrained();
    $table->foreignId('granted_by_user_id')->constrained('users');
    $table->timestamp('expires_at')->nullable();
    $table->timestamps();
});

public function roles(): BelongsToMany
{
    return $this->belongsToMany(Role::class)
        ->withPivot('expires_at', 'granted_by_user_id')
        ->withTimestamps();
}

// Доступ к атрибутам pivot осуществляется через свойство pivot.
foreach ($user->roles as $role) {
    echo $role->name;
    echo $role->pivot->expires_at;
    echo $role->pivot->created_at;
}

Имя свойства pivot можно переопределить методом as(): belongsToMany(Role::class)->as('membership'). Это особенно полезно, когда pivot-связь имеет собственное доменное значение и обращение через $role->membership->expires_at читается естественнее.

Pivot-модель

При наличии содержательной логики, относящейся к самой связи, pivot-таблица может быть представлена отдельной моделью, наследующей класс Illuminate\Database\Eloquent\Relations\Pivot (или MorphPivot для полиморфных случаев). Такая модель подключается через метод using() в объявлении relation.

use Illuminate\Database\Eloquent\Relations\Pivot;

class UserRole extends Pivot
{
    protected $casts = [
        'expires_at' => 'datetime',
    ];

    public function isExpired(): bool
    {
        return $this->expires_at !== null
            && $this->expires_at->isPast();
    }

    public function grantedBy(): BelongsTo
    {
        return $this->belongsTo(User::class, 'granted_by_user_id');
    }
}

class User extends Model
{
    public function roles(): BelongsToMany
    {
        return $this->belongsToMany(Role::class)
            ->using(UserRole::class)
            ->withPivot('expires_at', 'granted_by_user_id')
            ->withTimestamps();
    }
}

// Теперь pivot — полноценная модель с собственными методами и отношениями.
foreach ($user->roles as $role) {
    if ($role->pivot->isExpired()) {
        continue;
    }
    $grantedByName = $role->pivot->grantedBy->name;
}

Методы управления связями

Eloquent предоставляет набор методов для манипуляции записями pivot-таблицы. Все они вызываются на построителе relation ($user->roles()), а не на коллекции.

МетодПоведение
attach($id)Создаёт связь. Принимает идентификатор, массив идентификаторов или модель.
attach($id, ['expires_at' => ...])Создаёт связь с заданными значениями дополнительных колонок pivot-таблицы.
detach($id)Удаляет указанную связь.
detach()Вызов без аргументов удаляет все связи текущей модели.
sync([1, 2, 3])Приводит набор связей к указанному: добавляет отсутствующие, удаляет лишние.
syncWithoutDetaching([1, 2])Добавляет отсутствующие связи, существующие не затрагивает.
toggle([1, 2])Инвертирует состояние связи: добавляет недостающее, удаляет имеющееся.
updateExistingPivot($id, [...])Обновляет дополнительные колонки pivot-записи без создания новой связи.
syncWithPivotValues([1, 2], ['scope' => 'admin'])Синхронизирует связи и одновременно записывает одинаковые значения в дополнительные колонки pivot.
Сквозной пример: система ролей с временным членством

Рассмотрим типичный сценарий: пользователю можно назначить роль с указанием срока действия и автора назначения. Через определённое время роль автоматически перестаёт считаться действительной, при этом запись об историческом назначении сохраняется.

Схема данных:

// Миграция таблиц
Schema::create('roles', function (Blueprint $table) {
    $table->id();
    $table->string('name')->unique();
    $table->string('description')->nullable();
    $table->timestamps();
});

Schema::create('role_user', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->foreignId('role_id')->constrained()->cascadeOnDelete();
    $table->foreignId('granted_by_user_id')->constrained('users');
    $table->timestamp('expires_at')->nullable();
    $table->timestamps();
    $table->unique(['user_id', 'role_id']);
});

Pivot-модель с инкапсулированной доменной логикой:

class RoleAssignment extends Pivot
{
    protected $table = 'role_user';
    public $incrementing = true;

    protected $casts = [
        'expires_at' => 'datetime',
    ];

    public function isActive(): bool
    {
        return $this->expires_at === null
            || $this->expires_at->isFuture();
    }

    public function grantedBy(): BelongsTo
    {
        return $this->belongsTo(User::class, 'granted_by_user_id');
    }
}

Объявления отношений на основных моделях:

class User extends Model
{
    // Все назначения, в том числе истёкшие — для аудита.
    public function roles(): BelongsToMany
    {
        return $this->belongsToMany(Role::class)
            ->using(RoleAssignment::class)
            ->withPivot(['id', 'expires_at', 'granted_by_user_id'])
            ->withTimestamps();
    }

    // Только действующие роли — для авторизации.
    public function activeRoles(): BelongsToMany
    {
        return $this->roles()->where(function ($q) {
            $q->whereNull('role_user.expires_at')
              ->orWhere('role_user.expires_at', '>', now());
        });
    }
}

Типовые операции и контексты их применения:

// 1. Выдача роли с указанием срока и автора назначения.
$user->roles()->attach($adminRoleId, [
    'expires_at' => now()->addYear(),
    'granted_by_user_id' => auth()->id(),
]);

// 2. Замена набора ролей пользователя в рамках обновления профиля админом.
//    Каждая запись синхронизации получает одного и того же автора.
$user->roles()->syncWithPivotValues(
    $request->input('role_ids', []),
    ['granted_by_user_id' => auth()->id()]
);

// 3. Продление срока действия роли без создания новой записи.
$user->roles()->updateExistingPivot($roleId, [
    'expires_at' => now()->addMonths(6),
]);

// 4. Проверка прав в middleware или политике.
public function hasRole(string $name): bool
{
    return $this->activeRoles()->where('name', $name)->exists();
}

// 5. Журнал назначений конкретной роли с авторами и сроками.
$history = $user->roles()
    ->with('pivot.grantedBy')
    ->orderByPivot('created_at', 'desc')
    ->get();

// 6. Запланированная очистка истёкших назначений из таблицы.
//    Выполняется в Artisan-команде по расписанию.
DB::table('role_user')
    ->whereNotNull('expires_at')
    ->where('expires_at', '<', now()->subMonth())
    ->delete();
Особые случаи и типичные ошибки
1. Семантика sync при пустом массиве. Вызов $user->roles()->sync([]) приводит к удалению всех связей пользователя, поскольку метод приводит набор к указанному, а указанный набор пуст. Для добавления к существующему набору без удаления используется syncWithoutDetaching.
2. attach не предотвращает дублирование. Повторный вызов attach($id) с тем же идентификатором создаёт ещё одну строку в pivot-таблице. Если повторение недопустимо, наложите уникальный составной индекс на пару внешних ключей и используйте syncWithoutDetaching вместо attach.
3. withTimestamps() требуется явно. Колонки created_at и updated_at в pivot-таблице не обновляются автоматически — необходимо вызвать withTimestamps() в объявлении relation. Без этого временные метки останутся равными значениям, проставленным при первой вставке.
4. Доступ к pivot из стандартной коллекции. Свойство pivot присутствует только на моделях, полученных через relation. При загрузке связанной модели напрямую (Role::find($id)) обращение к $role->pivot вернёт null. Это естественно: вне контекста конкретного пользователя у роли нет «своих» pivot-данных.
5. Pivot-модель и события. По умолчанию класс Pivot не вызывает события Eloquent (created, updated). Для их активации необходимо переопределить свойство public $incrementing = true и обращаться к pivot через $role->pivot->save(), а не через методы attach/sync, работающие на уровне Query Builder.
6. Полиморфные pivot-модели. Для отношений morphToMany pivot-модель должна наследовать MorphPivot вместо Pivot. Использование обычного Pivot приведёт к отсутствию поля {name}_type в результатах и неверной маршрутизации запросов.
7. Сортировка по полю pivot. Прямое использование orderBy('expires_at') приведёт к неоднозначности имени колонки. Корректно использовать либо метод-обёртку orderByPivot('expires_at'), либо квалифицированное имя с указанием таблицы: orderBy('role_user.expires_at').
8. Каскадное удаление и pivot. При удалении одной из связываемых моделей записи pivot-таблицы должны удаляться внешним ключом базы данных (cascadeOnDelete). Опора на наблюдатели Eloquent ненадёжна: bulk-удаления и прямые SQL-запросы их не вызывают, оставляя «висящие» pivot-записи.
hasOneThrough и hasManyThrough
Назначение

Отношения hasOneThrough и hasManyThrough описывают транзитивный обход графа моделей через одну промежуточную сущность. Они применяются, когда между исходной и конечной таблицами отсутствует прямая связь, но существует посредник, через которого связь однозначно прослеживается.

Альтернативный способ получить тот же набор данных — последовательно загрузить промежуточные модели и затем обратиться к их связям. Такой подход порождает либо проблему N+1, либо требует ручной агрегации идентификаторов и составления отдельного запроса. Декларация hasManyThrough сводит весь обход к одному SQL-запросу с JOIN.

Принципиальное ограничение метода — глубина цепочки. Стандартный Eloquent поддерживает ровно одну промежуточную модель. Для более длинных цепочек (например, Country — Region — City — Hotel) применяются либо ручной JOIN, либо специализированные пакеты вроде staudenmeir/eloquent-has-many-deep.

Компоненты механизма

Сигнатура и параметры

Оба метода принимают одинаковый набор аргументов. Большинство значений выводятся Eloquent из соглашений об именах, однако в случае нестандартных схем все шесть параметров можно указать явно.

$this->hasManyThrough(
    Post::class,          // 1. Конечная (target) модель
    User::class,          // 2. Промежуточная (through) модель
    'country_id',           // 3. FK на промежуточной таблице, ссылается на текущую
    'user_id',              // 4. FK на конечной таблице, ссылается на промежуточную
    'id',                   // 5. Локальный ключ текущей модели
    'id'                    // 6. Локальный ключ промежуточной модели
);

Значения параметров — это имена колонок, участвующих в JOIN. Структура запроса от них напрямую зависит, поэтому имеет смысл разобрать на конкретной схеме, какая колонка какой роли соответствует.

Принцип работы: разбор по схеме

Рассмотрим классическую трёхзвенную схему: страны, пользователи в каждой стране, посты, написанные пользователями.

// Структура таблиц
countries: id, name
users:     id, country_id, name
posts:     id, user_id, title

// Объявление в Country
class Country extends Model
{
    public function posts(): HasManyThrough
    {
        return $this->hasManyThrough(Post::class, User::class);
    }
}

Поскольку имена полей соответствуют соглашениям (country_id на промежуточной, user_id на конечной), параметры можно опустить. SQL, генерируемый при обращении $country->posts:

SELECT posts.*, users.country_id AS laravel_through_key FROM posts INNER JOIN users ON posts.user_id = users.id WHERE users.country_id = ?

Дополнительная колонка laravel_through_key используется внутренне при eager loading для группировки результатов по идентификатору исходной модели.

hasOneThrough

Идентичен hasManyThrough по сигнатуре, но возвращает единственную запись или null. Применяется, когда транзитивная связь логически однозначна или ограничена дополнительным условием.

class Country extends Model
{
    // Профиль главного модератора страны.
    // Country → User (с ролью moderator) → Profile
    public function moderatorProfile(): HasOneThrough
    {
        return $this->hasOneThrough(Profile::class, User::class)
            ->where('users.role', 'moderator');
    }
}

$country->moderatorProfile;  // Profile или null

Сравнение с whereHas

Оба механизма работают с цепочкой моделей, но решают принципиально разные задачи и возвращают записи разной природы. Их следует чётко различать.

КритерийhasManyThroughwhereHas
Возвращаемая сущностьКонечная модель цепочки (Post)Исходная модель (Country)
Постановка задачи«Получить все посты пользователей данной страны»«Получить страны, в которых есть пользователи с постами»
SQL-конструкцияJOIN промежуточной и конечной таблицПодзапрос с EXISTS
Где объявляетсяМетод relation в исходной моделиПроизвольный запрос в любой модели
Сквозной пример: статистика проектной платформы

Рассмотрим платформу управления проектами, где организация (Organization) содержит команды (Team), а каждая команда выполняет задачи (Task). Требуется получать сводные данные на уровне организации без последовательного обхода команд.

Схема данных:

organizations: id, name, plan
teams:         id, organization_id, name, leader_id
tasks:         id, team_id, title, status, completed_at

Объявления отношений:

class Organization extends Model
{
    public function teams(): HasMany
    {
        return $this->hasMany(Team::class);
    }

    // Все задачи всех команд организации одним запросом.
    public function tasks(): HasManyThrough
    {
        return $this->hasManyThrough(Task::class, Team::class);
    }

    // Завершённые задачи — с дополнительным условием в той же relation.
    public function completedTasks(): HasManyThrough
    {
        return $this->hasManyThrough(Task::class, Team::class)
            ->whereNotNull('tasks.completed_at');
    }
}

class Team extends Model
{
    public function organization(): BelongsTo
    {
        return $this->belongsTo(Organization::class);
    }

    public function tasks(): HasMany
    {
        return $this->hasMany(Task::class);
    }
}

Типовые операции и контексты их применения:

// 1. Общее число задач организации для шапки дашборда.
//    Один SQL вместо обхода всех команд.
$totalTasks = $organization->tasks()->count();

// 2. Список просроченных задач для письма-уведомления администратору.
$overdue = $organization->tasks()
    ->where('tasks.status', 'in_progress')
    ->where('tasks.due_at', '<', now())
    ->get();

// 3. Eager loading в выборке организаций для админ-панели.
//    withCount по through-связи работает аналогично hasMany.
$organizations = Organization::withCount(['tasks', 'completedTasks'])
    ->orderByDesc('tasks_count')
    ->get();

foreach ($organizations as $org) {
    $percent = $org->tasks_count > 0
        ? round($org->completed_tasks_count / $org->tasks_count * 100)
        : 0;
}

// 4. Поиск задачи с проверкой принадлежности организации текущего пользователя.
//    Используется в политике доступа.
public function view(User $user, Task $task): bool
{
    return $user->organization->tasks()
        ->whereKey($task->id)
        ->exists();
}

// 5. Сравнение с whereHas: выбрать организации, в которых есть хотя бы одна
//    задача со статусом «blocked». Возвращает Organization, а не Task.
$blockedOrgs = Organization::whereHas('tasks', function ($q) {
    $q->where('tasks.status', 'blocked');
})->get();
Особые случаи и типичные ошибки
1. Ограничение глубины цепочки. Метод поддерживает ровно одну промежуточную модель. Для цепочек с двумя и более посредниками (Country — Region — City — Hotel) штатное решение отсутствует. Альтернативы: ручной запрос с JOIN, последовательная цепочка hasManyThrough через дополнительные relation на промежуточных моделях, либо пакет staudenmeir/eloquent-has-many-deep.
2. Неоднозначность имён колонок. При добавлении условий (where, orderBy) необходимо указывать имя таблицы явно: where('tasks.status', 'completed'), иначе СУБД может вернуть ошибку ambiguous column либо применить условие к промежуточной таблице.
3. Soft Deletes на промежуточной модели. Если промежуточная модель использует SoftDeletes, hasManyThrough по умолчанию не учитывает её удалённые записи только в том случае, если глобальный scope активен. При прямом построении SQL через JOIN такая фильтрация не применяется. Решение — явно дополнить условие whereNull('teams.deleted_at').
4. hasOneThrough и упорядочивание. Метод возвращает первую запись, найденную базой данных, без явного ORDER BY. Если необходим конкретный экземпляр (например, главный модератор, самый ранний и т. п.), сортировку нужно задавать явно: hasOneThrough(...)->latest('users.created_at').
5. Производительность на больших наборах. Транзитивный JOIN порождает запросы, потенциально обходящие миллионы записей в промежуточной таблице. Для конечных таблиц с высокой кардинальностью имеет смысл добавить покрывающие индексы на колонки FK, либо использовать chunkById при обходе результатов.
6. withCount по through-связи. Работает аналогично обычным relation, но генерирует подзапрос с двумя JOIN, что заметно дороже плоского COUNT. Для часто отображаемых счётчиков рассмотрите денормализацию: денормализованное поле tasks_count на Organization с обновлением через event-слушатели.
Polymorphic relations
Назначение

Полиморфные отношения позволяют единственной таблице обслуживать связи с несколькими различными моделями. В отличие от обычной связи, где внешний ключ ссылается на одну конкретную таблицу, полиморфная связь хранит дополнительную колонку с именем класса родителя. Это даёт возможность сохранить одну запись (например, комментарий или лайк), не привязываясь жёстко к одному типу владельца.

Альтернативой полиморфному подходу было бы создание отдельных таблиц для каждого типа связи: post_comments, photo_comments, video_comments. Такой подход приводит к дублированию схемы, тройной реализации одних и тех же бизнес-операций (создание, валидация, удаление, отображение) и сложной агрегации при выводе универсальных лент.

Полиморфизм реализуется парой колонок: {name}_type хранит имя класса родителя (например, App\Models\Post), а {name}_id — его идентификатор. Eloquent на основе значения _type определяет, в какой таблице искать запись, и инстанцирует соответствующую модель.

Метод применяется в системах комментариев, отметок «нравится», вложений, изображений-обложек, тегов, аудит-логов и любых сценариях, где одна семантика связи распространяется на разнородные сущности.

Компоненты механизма

Схема таблицы и хелпер morphs()

Стандартная полиморфная таблица содержит две дополнительные колонки. Хелпер morphs() в миграции создаёт пару полей и автоматически добавляет составной индекс (name_type, name_id), необходимый для эффективной выборки.

Schema::create('comments', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained();
    $table->text('body');
    $table->morphs('commentable');
    // эквивалентно:
    //   $table->string('commentable_type');
    //   $table->unsignedBigInteger('commentable_id');
    //   $table->index(['commentable_type', 'commentable_id']);
    $table->timestamps();
});

Содержимое таблицы после нескольких вставок:

id | user_id | body | commentable_type | commentable_id ---|---------|-------|----------------------|---------------- 1 | 5 | hi | App\Models\Post | 5 2 | 3 | nice | App\Models\Photo | 12 3 | 11 | wow | App\Models\Video | 3

Если предполагается использование UUID в качестве первичных ключей, вместо morphs() применяется uuidMorphs() или ulidMorphs(). Для допущения NULL в обеих колонках существует nullableMorphs().

Стороны отношения: morphTo

Метод morphTo() объявляется на модели, в таблице которой расположены парные колонки {name}_type и {name}_id. При обращении к relation Eloquent читает значение _type, разрешает его в имя класса и выполняет SELECT в соответствующую таблицу.

class Comment extends Model
{
    public function commentable(): MorphTo
    {
        return $this->morphTo();
    }

    public function author(): BelongsTo
    {
        return $this->belongsTo(User::class, 'user_id');
    }
}

// Eloquent определит конкретный класс родителя автоматически.
$comment = Comment::find(1);
$parent = $comment->commentable;
// $parent instanceof Post / Photo / Video

Стороны отношения: morphOne и morphMany

На каждой родительской модели объявляется свой метод связи. Вторым параметром передаётся имя morph-связи (общий префикс пары колонок) — именно его Eloquent использует для построения условий WHERE.

class Post extends Model
{
    public function comments(): MorphMany
    {
        return $this->morphMany(Comment::class, 'commentable');
    }

    // Обложка — единичная полиморфная связь.
    public function cover(): MorphOne
    {
        return $this->morphOne(Image::class, 'imageable');
    }
}

// Создание связанной записи через relation: тип и id заполняются автоматически.
$post->comments()->create([
    'user_id' => auth()->id(),
    'body'    => 'Текст комментария',
]);

// SQL:
// INSERT INTO comments (user_id, body, commentable_type, commentable_id, ...)
// VALUES (?, ?, 'App\\Models\\Post', ?, ...);

Полиморфная связь «многие ко многим»: morphToMany и morphedByMany

Для случая, когда один тип сущности (например, Tag) должен связываться с несколькими разнородными моделями через общую pivot-таблицу, используется пара morphToMany на специализированных сторонах и morphedByMany на общей. Pivot-таблица содержит, помимо ключа Tag, два полиморфных поля.

Schema::create('taggables', function (Blueprint $table) {
    $table->foreignId('tag_id')->constrained()->cascadeOnDelete();
    $table->morphs('taggable');
    $table->primary(['tag_id', 'taggable_id', 'taggable_type']);
});

class Post extends Model
{
    public function tags(): MorphToMany
    {
        return $this->morphToMany(Tag::class, 'taggable');
    }
}

class Tag extends Model
{
    public function posts(): MorphToMany
    {
        return $this->morphedByMany(Post::class, 'taggable');
    }

    public function videos(): MorphToMany
    {
        return $this->morphedByMany(Video::class, 'taggable');
    }
}

Morph map: устранение зависимости от FQCN

По умолчанию Laravel записывает в колонку {name}_type полное имя класса (например, App\Models\Post). Это создаёт неявную привязку данных к структуре каталогов проекта: переименование класса, изменение пространства имён или перемещение в подкаталог приведёт к тому, что существующие записи перестанут разрешаться в живые модели.

Решение — явная регистрация морф-карты, отображающей короткие строковые алиасы на классы. После её регистрации Laravel записывает в БД алиасы, а при обращении к связи восстанавливает класс по карте. Рефакторинг кода требует только обновления карты, без миграции данных.

// app/Providers/AppServiceProvider.php
use Illuminate\Database\Eloquent\Relations\Relation;

public function boot(): void
{
    Relation::enforceMorphMap([
        'post'  => Post::class,
        'photo' => Photo::class,
        'video' => Video::class,
    ]);
}

Метод enforceMorphMap также запрещает использование классов, не объявленных в карте: попытка сохранить полиморфную запись с неучтённым типом породит исключение, что предотвращает скрытое появление «сырых» FQCN в данных.

Для уже работающих проектов миграция с FQCN на алиасы требует одноразовой команды, обновляющей значения в колонках _type:

DB::table('comments')
    ->where('commentable_type', 'App\\Models\\Post')
    ->update(['commentable_type' => 'post']);

Eager loading и метод morphWith

При выборке коллекции записей с полиморфной связью обычное with('commentable') работает, но для каждой группы родительских типов Eloquent выполняет отдельный запрос. Метод morphWith позволяет дополнительно подгрузить вложенные relation, специфичные для конкретного типа родителя.

$comments = Comment::with([
    'commentable' => function (MorphTo $morphTo) {
        $morphTo->morphWith([
            Post::class  => ['author', 'tags'],
            Video::class => ['thumbnail'],
        ]);
    },
])->get();
Сквозной пример: универсальная система комментариев и реакций

Спроектируем универсальный модуль комментариев и реакций (likes), применимый к постам, фотографиям и видео. Помимо хранения данных, модуль должен поддерживать вложенные ответы, отображение унифицированной ленты активности пользователя и эффективную выгрузку родительских сущностей одним запросом.

Схема таблиц:

posts:     id, user_id, title, body
photos:    id, user_id, url, caption
videos:    id, user_id, source_url, duration_sec

comments:  id, user_id, parent_id, body, commentable_type, commentable_id
reactions: id, user_id, kind, reactable_type, reactable_id

Морф-карта в провайдере (обязательно):

// app/Providers/AppServiceProvider.php
public function boot(): void
{
    Relation::enforceMorphMap([
        'post'  => Post::class,
        'photo' => Photo::class,
        'video' => Video::class,
    ]);
}

Трейт, инкапсулирующий объявление полиморфных связей для упрощения подключения к нескольким моделям:

trait HasCommentsAndReactions
{
    public function comments(): MorphMany
    {
        return $this->morphMany(Comment::class, 'commentable')
            ->whereNull('parent_id');
    }

    public function reactions(): MorphMany
    {
        return $this->morphMany(Reaction::class, 'reactable');
    }
}

class Post extends Model  { use HasCommentsAndReactions; }
class Photo extends Model { use HasCommentsAndReactions; }
class Video extends Model { use HasCommentsAndReactions; }

Модель Comment с поддержкой вложенности и обратной связью:

class Comment extends Model
{
    protected $fillable = ['user_id', 'parent_id', 'body'];

    public function commentable(): MorphTo
    {
        return $this->morphTo();
    }

    public function author(): BelongsTo
    {
        return $this->belongsTo(User::class, 'user_id');
    }

    public function replies(): HasMany
    {
        return $this->hasMany(self::class, 'parent_id');
    }
}

Типовые операции и их применение:

// 1. Создание комментария к произвольной сущности через её relation.
//    commentable_type и commentable_id заполняются Eloquent автоматически.
$post->comments()->create([
    'user_id' => auth()->id(),
    'body'    => $request->input('body'),
]);

// 2. Дерево комментариев с eager loading вложенных ответов.
$tree = $post->comments()
    ->with(['author', 'replies.author'])
    ->latest()
    ->get();

// 3. Универсальная лента активности пользователя.
//    Возвращает комментарии без знания, к чему они привязаны;
//    morphWith подгружает специфичные relation для каждого типа родителя.
$activity = Comment::where('user_id', $user->id)
    ->with([
        'commentable' => function (MorphTo $q) {
            $q->morphWith([
                Post::class  => ['author'],
                Video::class => ['author'],
            ]);
        },
    ])
    ->latest()
    ->paginate(20);

// 4. Маршрутизация при отображении комментария: определяем URL родителя.
public function parentUrl(Comment $comment): string
{
    return match ($comment->commentable_type) {
        'post'  => route('posts.show',  $comment->commentable_id),
        'photo' => route('photos.show', $comment->commentable_id),
        'video' => route('videos.show', $comment->commentable_id),
    };
}

// 5. Подсчёт числа реакций каждого типа для произвольной сущности.
$counts = $post->reactions()
    ->select('kind', DB::raw('COUNT(*) as total'))
    ->groupBy('kind')
    ->pluck('total', 'kind');

// 6. Удаление всех комментариев и реакций при удалении родителя.
//    Реализуется через observer, поскольку morphMany не поддерживает
//    каскадное удаление на уровне БД (там нет FK).
class CleanupCommentsObserver
{
    public function deleting(Model $model): void
    {
        $model->comments()->delete();
        $model->reactions()->delete();
    }
}
Особые случаи и типичные ошибки
1. Хранение FQCN без морф-карты. Это наиболее распространённая ошибка проектирования. По умолчанию в колонке _type сохраняется полное имя класса. Любое изменение пространства имён, переименование или перенос файла приводит к рассинхронизации между БД и кодом, при этом ошибка проявляется не сразу — только при попытке разрешить релейшен на старых записях. Регистрация Relation::enforceMorphMap() должна быть выполнена в проекте до первой работы с полиморфными моделями.
2. Отсутствие внешних ключей на уровне БД. Полиморфные колонки не образуют FK, поскольку ссылаются на разные таблицы. Это означает: каскадное удаление на уровне СУБД невозможно, целостность данных лежит на коде. Удаление родительской модели без обработки в observer (или без явного delete() по связи) оставляет «висящие» записи, ссылающиеся в никуда.
3. Стоимость запросов morphTo. В отличие от обычного belongsTo, который выполняет один SELECT, morphTo вынужден сгруппировать загружаемые записи по типу и выполнить отдельный SELECT для каждого. При выборках, охватывающих много разнородных родителей, это даёт N+M запросов вместо одного. Метод morphWith позволяет одновременно подгрузить вложенные relation, но количество запросов всё равно пропорционально числу типов.
4. Индекс на пару колонок обязателен. Запросы вида WHERE commentable_type = ? AND commentable_id = ? без составного индекса деградируют до полного сканирования. Хелпер morphs() создаёт нужный индекс автоматически; при ручном объявлении пары колонок индекс необходимо добавить явно.
5. Уникальность в полиморфной таблице. Создание условия «один лайк на один объект от одного пользователя» требует уникального индекса на тройке колонок (user_id, reactable_type, reactable_id). Простой unique по двум полям недостаточен.
6. morphTo и удалённый класс. Если значение _type ссылается на класс, который больше не существует (например, после удаления модели), обращение к $comment->commentable породит ошибку. Безопасный обход — обернуть доступ в try / catch или предварительно отфильтровать выборку по списку известных типов.
7. Глобальные scopes на полиморфной модели. Если родительская модель использует глобальные scopes (например, фильтр по тенанту), при разрешении morphTo Laravel применяет их к каждому из запросов. Это может неожиданно скрыть записи, если контекст scope недоступен (например, в фоновой задаче без авторизованного пользователя).
8. Полиморфная pivot-модель. Для отношений morphToMany кастомная pivot-модель должна наследовать Illuminate\Database\Eloquent\Relations\MorphPivot, а не базовый Pivot. В противном случае полиморфная колонка {name}_type не будет учитываться при запросах, и связи окажутся неверными.
Eager loading
Назначение

Eager loading — механизм предварительной загрузки связанных моделей единым набором запросов. Решает проблему N+1, возникающую при ленивом доступе к relation: когда для каждой записи родительской коллекции выполняется отдельный SELECT к таблице дочерней модели.

Принципиальное отличие от ленивой загрузки заключается в количестве запросов. Без eager loading выборка из ста пользователей с обращением к их профилям внутри цикла порождает 101 запрос: один на список пользователей и сто индивидуальных запросов на профили. С использованием with('profile') выполняются ровно два запроса: SELECT по таблице users и SELECT по таблице profiles с условием WHERE user_id IN (...), после чего Eloquent распределяет полученные профили между загруженными пользователями.

Eloquent предоставляет несколько вариантов eager loading, различающихся моментом применения (до или после получения коллекции), полнотой загружаемых данных (записи целиком или только агрегаты) и стратегией обработки уже загруженных relation.

Перечень методов

with() — декларация при построении запроса

Применяется на построителе запросов до вызова get(), first(), paginate(). Принимает имя relation, массив имён или ассоциативный массив с замыканиями для ограничения вложенных выборок. Является основным и наиболее распространённым способом.

// Ленивый доступ — антипаттерн N+1.
foreach (User::all() as $user) {
    echo $user->profile->bio;
}
// Итого: 1 + N запросов.

// Eager loading через with(): два запроса независимо от размера коллекции.
foreach (User::with('profile')->get() as $user) {
    echo $user->profile->bio;
}
// SQL:
//   SELECT * FROM users;
//   SELECT * FROM profiles WHERE user_id IN (1, 2, 3, ...);

Вложенные relation и точечная нотация

Eloquent поддерживает произвольную глубину вложения через точечный синтаксис. Каждый сегмент пути порождает отдельный SELECT-запрос к соответствующей таблице.

// Загрузка трёх уровней связей одной декларацией.
User::with('posts.comments.author')->get();

// SQL:
//   SELECT * FROM users;
//   SELECT * FROM posts    WHERE user_id IN (...);
//   SELECT * FROM comments WHERE post_id IN (...);
//   SELECT * FROM users    WHERE id      IN (...);  -- авторы комментариев

// Несколько независимых relation на одном уровне.
Post::with(['author', 'tags', 'comments.author'])->get();

Constrained eager loading

Замыкание во втором значении массива позволяет ограничить выборку связанной модели произвольными условиями: фильтрацией, сортировкой, лимитом, вложенными relation. Все ограничения применяются на стороне БД, что критично для коллекций с большим количеством связанных записей.

User::with([
    'posts' => function (Builder $query) {
        $query->where('status', 'published')
              ->orderByDesc('published_at')
              ->limit(5);
    },
])->get();

// Выборка по конкретному набору колонок снижает объём передаваемых данных.
Post::with('author:id,name,avatar_url')->get();
// SELECT id, name, avatar_url FROM users WHERE id IN (...);
// При указании списка колонок поле первичного ключа обязательно — иначе Eloquent
// не сможет сопоставить связанные записи с родителями.

load() — eager loading после получения коллекции

Применяется к уже загруженной модели или коллекции, когда необходимость в relation возникает после построения основного запроса (например, в зависимости от условия в коде, или при условном рендеринге). Выполняет ровно один дополнительный SELECT.

$users = User::all();

if ($showProfiles) {
    $users->load('profile');
}

// load принимает тот же синтаксис что и with: вложенность, замыкания, списки колонок.
$users->load([
    'posts' => fn($q) => $q->latest()->limit(10),
    'roles:id,name',
]);

loadMissing() — идемпотентная загрузка

Загружает relation только в том случае, если он ещё не был загружен ранее. Полезен в коде, где трудно отследить, какие relation уже подгружены (например, в Blade-компонентах, View Composers, методах модели).

public function summarize(User $user): array
{
    $user->loadMissing('posts', 'roles');

    return [
        'posts_count' => $user->posts->count(),
        'roles'       => $user->roles->pluck('name'),
    ];
}
// Если вызывающий код уже подгрузил posts через with('posts'),
// повторный запрос не выполнится.

withCount() и loadCount() — подсчёт без выгрузки

Возвращают количество связанных записей через коррелированный подзапрос, не загружая сами записи. Результат сохраняется в атрибуте с именем {relation}_count. Это оптимальный способ для отображения счётчиков в списках (число комментариев у поста, число подписчиков у канала).

$users = User::withCount('posts')->get();
echo $users[0]->posts_count;  // 42

// Можно подсчитать одну и ту же relation с разными условиями.
User::withCount([
    'posts',
    'posts as published_posts_count' => fn($q) => $q->where('status', 'published'),
])->get();
// → $user->posts_count и $user->published_posts_count

// На уже загруженной коллекции:
$users->loadCount('posts');

Агрегатные методы: withSum, withAvg, withMin, withMax, withExists

Семейство методов, аналогичных withCount, но возвращающих не количество, а агрегатные функции по полю связанной таблицы. Результат сохраняется в атрибуте {relation}_{agg}_{column}.

// Сумма по полю amount у связанных orders.
User::withSum('orders', 'amount')->get();
// → $user->orders_sum_amount

// Средний рейтинг комментариев.
Post::withAvg('comments', 'rating')->get();

// Проверка наличия связанных записей без их загрузки.
User::withExists('subscription')->get();
// → $user->subscription_exists = true/false

// Допустимо комбинировать несколько агрегатов в одном запросе.
Project::withCount('tasks')
    ->withSum('tasks', 'estimated_hours')
    ->withMax('tasks', 'updated_at')
    ->get();

Сводная таблица методов

МетодПрименяется кВозвращаетSQL
with(...)Построителю запросовПолные связанные моделиОсновной + по одному на каждую relation
load(...)Модели или коллекцииПолные связанные моделиПо одному на каждую relation
loadMissing(...)Модели или коллекцииПолные связанные моделиПо одному, если relation не загружена
withCount(...)Построителю запросовЦелое число в атрибуте {rel}_countПодзапрос в SELECT основного запроса
loadCount(...)Модели или коллекцииЦелое числоОтдельный запрос с GROUP BY
withSum / withAvg / ...Построителю запросовАгрегат в атрибуте {rel}_{agg}_{col}Подзапрос в SELECT
withExists(...)Построителю запросовБулев флагПодзапрос с EXISTS
Сквозной пример: лента блога без N+1

Рассмотрим типичную задачу: построить страницу-ленту с пагинацией, в которой для каждого поста отображаются заголовок, автор, число комментариев, число лайков и три первых тега. Без правильного eager loading такой шаблон легко превращается в десятки запросов на страницу.

Структура моделей:

class Post extends Model
{
    public function author(): BelongsTo     { return $this->belongsTo(User::class, 'user_id'); }
    public function comments(): MorphMany  { return $this->morphMany(Comment::class, 'commentable'); }
    public function likes(): MorphMany     { return $this->morphMany(Like::class,    'likeable'); }
    public function tags(): MorphToMany    { return $this->morphToMany(Tag::class,  'taggable'); }
}

Контроллер ленты, выполняющий ровно одну выборку с eager loading и тремя агрегатами:

class FeedController extends Controller
{
    public function __invoke(): View
    {
        $posts = Post::query()
            ->where('status', 'published')
            ->with([
                'author:id,name,avatar_url',
                'tags' => fn($q) => $q->limit(3),
            ])
            ->withCount(['comments', 'likes'])
            ->latest('published_at')
            ->paginate(15);

        return view('feed.index', compact('posts'));
    }
}

Полный набор SQL-запросов на одну страницу ленты — четыре запроса, независимо от числа постов:

-- 1. Основная выборка постов с двумя коррелированными COUNT.
SELECT posts.*,
    (SELECT COUNT(*) FROM comments
     WHERE comments.commentable_id = posts.id
       AND comments.commentable_type = 'post') AS comments_count,
    (SELECT COUNT(*) FROM likes
     WHERE likes.likeable_id = posts.id
       AND likes.likeable_type = 'post') AS likes_count
FROM posts
WHERE status = 'published'
ORDER BY published_at DESC
LIMIT 15 OFFSET 0;

-- 2. Авторы постов одной выборкой.
SELECT id, name, avatar_url FROM users WHERE id IN (...);

-- 3. Связи постов с тегами через polymorphic pivot.
SELECT tags.*, taggables.taggable_id AS pivot_taggable_id
FROM tags
INNER JOIN taggables ON taggables.tag_id = tags.id
WHERE taggables.taggable_type = 'post'
  AND taggables.taggable_id IN (...);

-- 4. SELECT COUNT(*) для пагинатора (если используется LengthAwarePaginator).

При использовании Telescope или Debugbar убедитесь, что отображение страницы со 100 постами укладывается в эти четыре запроса. Любое отклонение свидетельствует о неучтённом обращении к relation в шаблоне.

Особые случаи и типичные ошибки
1. Constrained eager loading не ограничивает количество записей на родителя. Конструкция with(['posts' => fn($q) => $q->limit(5)]) применяет LIMIT к запросу в целом, а не к каждому пользователю. Для получения, например, последних пяти постов каждого пользователя стандартный Eloquent не имеет средств; применяются сторонние пакеты (staudenmeir/eloquent-eager-limit) или подзапросы с оконными функциями.
2. Указание колонок без первичного ключа. При использовании синтаксиса with('author:name,email') необходимо включать в список первичный ключ (обычно id). Без него Eloquent не сможет сопоставить загруженные записи с родителями, и связь будет пустой. Корректная запись: with('author:id,name,email').
3. Неявная разница между load и loadMissing. Метод load выполняет запрос всегда, даже если relation уже подгружена; полученный результат заменит предыдущий. Это может неожиданно затереть данные, изменённые в памяти после первой загрузки. Для безопасной идемпотентной загрузки используйте loadMissing.
4. withCount и привязанные условия родителя. Подзапрос withCount формируется в SELECT основного запроса. Если основной запрос содержит WHERE, добавляющий JOIN к таблице связанной модели, могут возникать неоднозначности имён колонок. Используйте квалифицированные имена: where('posts.status', 'published').
5. preventLazyLoading в продакшене. Метод Model::preventLazyLoading() в AppServiceProvider запрещает ленивую загрузку relation, кидая LazyLoadingViolationException. Это эффективная защита от N+1, но в production-окружении приведёт к падению любых пользовательских действий, для которых разработчики не предусмотрели eager loading. Рекомендуется включать только в локальной разработке: Model::preventLazyLoading(! app()->isProduction()).
6. Пагинация и withCount. paginate() сам по себе выполняет дополнительный SELECT COUNT для определения общего числа записей. В сочетании с withCount по нескольким relation на больших таблицах это даёт ощутимую нагрузку. Если общее количество не критично (например, для бесконечной прокрутки), используйте simplePaginate(), не вычисляющий total.
7. Eager loading и сортировка по полю связанной модели. with('author')->orderBy('author.name') не работает: ordering применяется к основной таблице, а связанная подгружается отдельным запросом. Для сортировки по полю связанной модели необходимо использовать JOIN: Post::join('users', ...)->orderBy('users.name').
8. Несимметричное eager loading при morphTo. Загрузка with('commentable') на коллекции комментариев порождает по одному запросу на каждый тип родителя. Если требуется дополнительно подгрузить relation специфичные для типа (например, author у Post, но thumbnail у Video), применяется morphWith — см. подраздел Polymorphic.
Casts
Назначение

Casts — механизм автоматического преобразования атрибутов модели между их представлением в базе данных и в коде PHP. При чтении значения из БД Eloquent применяет преобразование «get» (например, превращает JSON-строку в массив), при записи — обратное преобразование «set» (массив сериализуется в JSON).

Без кастов разработчик вынужден повсеместно вручную выполнять json_decode, Carbon::parse, (bool), что приводит к разбросу логики преобразований и ошибкам при их пропуске. Касты централизуют преобразование в декларации модели и гарантируют единообразие во всех точках обращения к полю.

Помимо встроенных типов, Eloquent позволяет создавать собственные классы кастов для произвольных value objects (Money, Color, GeoPoint, EmailAddress). Это даёт возможность хранить в БД примитивы, а в коде работать с типизированными доменными объектами, имеющими методы и инвариантами.

Виды кастов

Объявление и встроенные типы

Касты декларируются в свойстве $casts модели. Ключ массива — имя атрибута, значение — идентификатор типа или класс кастомного каста.

class User extends Model
{
    protected $casts = [
        // Примитивные типы
        'is_admin'     => 'boolean',
        'age'          => 'integer',
        'rating'       => 'float',
        'price'        => 'decimal:2',      // строка с фикс. знаками после запятой

        // JSON-структуры
        'metadata'     => 'array',          // JSON → array
        'options'      => 'json',           // синоним для array
        'tags'         => 'collection',     // JSON → Support\Collection
        'profile'      => 'object',         // JSON → stdClass

        // Даты и время
        'published_at' => 'datetime',       // → Carbon
        'birth_date'   => 'date',           // → Carbon без времени
        'expires_at'   => 'datetime:Y-m-d H:i', // с форматом сериализации
        'login_at'     => 'immutable_datetime', // CarbonImmutable
        'created_on'   => 'immutable_date',
        'duration'    => 'timestamp',       // Unix timestamp как int

        // Изменяемые JSON-обёртки (см. ниже про AsArrayObject)
        'settings'    => AsArrayObject::class,
        'preferences' => AsCollection::class,
        'name'        => AsStringable::class,  // → Stringable, методы Str::*

        // Шифрование
        'api_token'   => 'encrypted',
        'secrets'     => 'encrypted:array',

        // PHP 8.1+ enum
        'status'      => OrderStatus::class,
    ];
}

Начиная с Laravel 11, вместо свойства $casts рекомендуется использовать метод casts(), возвращающий массив. Это позволяет использовать вызовы статических методов и условную логику.

protected function casts(): array
{
    return [
        'options'  => AsCollection::using(UserOptions::class),
        'password' => 'hashed',
    ];
}

Изменяемые JSON-обёртки: AsArrayObject, AsCollection

Каст array возвращает обычный PHP-массив. Изменение значения внутри этого массива не отражается на атрибуте: следующее обращение к атрибуту вернёт прежнюю копию. Это распространённый источник ошибок при работе с JSON-полями.

// При обычном array-касте:
$user->metadata['last_login'] = now();
// Ошибка PHP: непрямое изменение свойства массива.

// Решение через AsArrayObject:
protected $casts = ['metadata' => AsArrayObject::class];

$user->metadata['last_login'] = now();  // корректно: ArrayObject изменяемый
$user->save();

AsCollection работает аналогично, но возвращает экземпляр Illuminate\Support\Collection, что даёт доступ ко всем коллекционным методам (map, filter, where).

protected $casts = ['tags' => AsCollection::class];

$post->tags->push('laravel');
$visible = $post->tags->reject(fn($t) => str_starts_with($t, '_'));

Шифрование на стороне приложения

Касты encrypted, encrypted:array, encrypted:json, encrypted:collection, encrypted:object прозрачно шифруют значение перед сохранением и расшифровывают при чтении. Используется ключ приложения APP_KEY, поэтому при его потере данные становятся нечитаемыми.

protected $casts = [
    'api_token'        => 'encrypted',
    'kyc_documents'    => 'encrypted:array',
    'private_settings' => 'encrypted:collection',
];

$user->api_token = 'sk_live_...';
$user->save();
// В БД: 'eyJpdiI6IjY...' (base64 шифротекст)

echo $user->api_token;
// В коде: 'sk_live_...' (расшифрованное значение)

Поскольку зашифрованное значение хранится как непрозрачная строка, поиск по такому полю через WHERE невозможен. Если необходима возможность поиска (например, по email), хранится отдельная hash-копия, по которой выполняется фильтрация.

Касты enum (PHP 8.1+)

Eloquent поддерживает прямой каст к PHP-перечислениям. Backed enum (с указанным типом-носителем) сохраняется в БД как его значение; pure enum — как имя case.

enum OrderStatus: string
{
    case Pending   = 'pending';
    case Paid      = 'paid';
    case Shipped   = 'shipped';
    case Cancelled = 'cancelled';

    public function label(): string
    {
        return match($this) {
            self::Pending   => 'Ожидает оплаты',
            self::Paid      => 'Оплачен',
            self::Shipped   => 'Отправлен',
            self::Cancelled => 'Отменён',
        };
    }
}

class Order extends Model
{
    protected $casts = ['status' => OrderStatus::class];
}

// Присвоение и сравнение типобезопасны.
$order->status = OrderStatus::Paid;
if ($order->status === OrderStatus::Paid) {
    // типобезопасное сравнение, IDE подсказывает варианты
}

// Использование в Blade.
// {{ $order->status->label() }}

Кастомный каст: интерфейс CastsAttributes

Произвольный класс, реализующий Illuminate\Contracts\Database\Eloquent\CastsAttributes, может выступать в роли каста. Метод get() вызывается при чтении атрибута и должен вернуть значение в нужном виде; set() вызывается при записи и должен вернуть либо скалярное значение для одной колонки, либо ассоциативный массив для нескольких.

Реализация каста для value object Money с хранением суммы и валюты в двух отдельных колонках:

// Value object
final class Money
{
    public function __construct(
        public readonly int $amount,        // в минимальных единицах (копейки, центы)
        public readonly string $currency,
    ) {
        if ($this->amount < 0) {
            throw new InvalidArgumentException('Money amount cannot be negative');
        }
    }

    public static function usd(int $cents): self
    {
        return new self($cents, 'USD');
    }

    public function add(self $other): self
    {
        if ($this->currency !== $other->currency) {
            throw new InvalidArgumentException('Currency mismatch');
        }
        return new self($this->amount + $other->amount, $this->currency);
    }

    public function format(): string
    {
        return number_format($this->amount / 100, 2) . ' ' . $this->currency;
    }
}

// Каст
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;

final class MoneyCast implements CastsAttributes
{
    public function get(Model $model, string $key, $value, array $attributes): ?Money
    {
        if ($attributes['price_amount'] === null) {
            return null;
        }

        return new Money(
            (int) $attributes['price_amount'],
            (string) $attributes['price_currency'],
        );
    }

    public function set(Model $model, string $key, $value, array $attributes): array
    {
        if (! $value instanceof Money) {
            throw new InvalidArgumentException('Expected Money instance.');
        }

        return [
            'price_amount'   => $value->amount,
            'price_currency' => $value->currency,
        ];
    }
}

// Использование в модели
class Product extends Model
{
    protected $casts = ['price' => MoneyCast::class];
}

$product->price = Money::usd(9999);
$product->save();
echo $product->price->format();   // "99.99 USD"

Касты с параметрами и наследуемые касты

Если каст требует параметров, реализуйте интерфейс Castable на классе value object — он возвращает экземпляр каста с переданными аргументами.

use Illuminate\Contracts\Database\Eloquent\Castable;

final class Currency implements Castable
{
    public static function castUsing(array $arguments): CastsAttributes
    {
        return new CurrencyCast($arguments[0] ?? 'USD');
    }
}

// Использование с параметром в декларации
protected $casts = ['price' => Currency::class . ':EUR'];
Сквозной пример: модель Order с типизированными атрибутами

Рассмотрим модель заказа в интернет-магазине, в которой одновременно применяются несколько разновидностей кастов: enum для статуса, кастомный каст Money для суммы, AsCollection для строк заказа, шифрование для адреса доставки, datetime для жизненного цикла.

Схема таблицы:

orders:
  id, user_id,
  status            varchar(20),
  total_amount      bigint,             // в копейках
  total_currency    varchar(3),
  items             json,
  shipping_address  text,                // будет храниться зашифрованным
  placed_at         timestamp,
  paid_at           timestamp nullable,
  cancelled_at      timestamp nullable,
  created_at, updated_at

Объявление модели и сопутствующих типов:

enum OrderStatus: string
{
    case Draft     = 'draft';
    case Placed    = 'placed';
    case Paid      = 'paid';
    case Shipped   = 'shipped';
    case Cancelled = 'cancelled';

    public function canTransitionTo(self $next): bool
    {
        return match ([$this, $next]) {
            [self::Draft,   self::Placed]    => true,
            [self::Placed,  self::Paid]      => true,
            [self::Paid,    self::Shipped]   => true,
            [self::Placed,  self::Cancelled] => true,
            default => false,
        };
    }
}

class Order extends Model
{
    protected function casts(): array
    {
        return [
            'status'           => OrderStatus::class,
            'total'            => MoneyCast::class,
            'items'            => AsCollection::class,
            'shipping_address' => 'encrypted',
            'placed_at'        => 'immutable_datetime',
            'paid_at'          => 'immutable_datetime',
            'cancelled_at'     => 'immutable_datetime',
        ];
    }
}

Типовые операции и контексты их применения:

// 1. Создание заказа: касты применяются автоматически при сохранении.
$order = Order::create([
    'user_id'          => auth()->id(),
    'status'           => OrderStatus::Draft,
    'total'            => Money::usd(12999),
    'items'            => collect([
        ['sku' => 'A1', 'qty' => 2, 'price_cents' => 4999],
        ['sku' => 'B5', 'qty' => 1, 'price_cents' => 3001],
    ]),
    'shipping_address' => $request->input('address'),
    'placed_at'        => now(),
]);

// 2. Бизнес-проверка статуса с использованием метода enum.
if (! $order->status->canTransitionTo(OrderStatus::Paid)) {
    throw new DomainException('Invalid order status transition.');
}

// 3. Изменение коллекции items: AsCollection возвращает Collection, на которой
// доступны map/filter; результат корректно сериализуется обратно в JSON.
$order->items = $order->items->reject(fn($i) => $i['qty'] === 0);
$order->save();

// 4. Работа с зашифрованным адресом доставки.
logger()->info("Ship to: {$order->shipping_address}"); // расшифрованное значение
// В БД хранится непрозрачный шифротекст; даже при утечке дампа
// адреса остаются защищёнными без APP_KEY.

// 5. Сериализация модели в JSON для API.
return response()->json($order);
// Eloquent сериализует status как 'paid', total — как объект (если у Money есть JsonSerializable),
// даты — в формате ISO 8601, items — как массив. Зашифрованные поля попадут в расшифрованном виде,
// поэтому их обычно скрывают через $hidden.

// 6. Применение immutable datetime для предотвращения случайных мутаций.
// $order->placed_at = $order->placed_at->addHour() породит исключение, поскольку
// CarbonImmutable возвращает новый экземпляр; для изменения требуется явное переприсваивание.
Особые случаи и типичные ошибки
1. Поиск по зашифрованному полю невозможен. Шифрование применяется на стороне приложения; БД видит непрозрачный шифротекст и не способна выполнять WHERE по содержимому. Если требуется поиск (например, по email клиента), помимо зашифрованного поля хранится поле с хешем для точного совпадения и/или blind index для частичного поиска.
2. Каст array и непрямые изменения. Конструкция $user->metadata['key'] = $value порождает ошибку Indirect modification of overloaded property, поскольку Eloquent возвращает копию массива через accessor. Используйте AsArrayObject, либо присваивайте полностью новый массив: $user->metadata = [...$user->metadata, 'key' => $value].
3. Каст decimal:n возвращает строку, не число. Это сделано для сохранения точности при сериализации в JSON. Прямое арифметическое сравнение ($product->price > 100) даст корректный результат благодаря приведению типов, но для финансовых вычислений лучше использовать целые числа в минимальных единицах или специализированный value object.
4. $casts игнорируется в Model::insert() и подобных bulk-операциях. Прямые вставки через query builder выполняются в обход цикла гидрации Eloquent: касты, accessors, mutators, observers не вызываются. Если используются bulk-операции, преобразование значений выполняется вручную перед вставкой.
5. Каст enum и невалидное значение в БД. Если в БД оказалось значение, отсутствующее в перечислении (например, после ручной правки или миграции с прежней системой), обращение к атрибуту бросит ValueError. При работе с легаси-данными целесообразно реализовать кастомный enum-каст, возвращающий null или специальное значение по умолчанию для неизвестных строк.
6. Кастомный каст и attribute caching. По умолчанию Eloquent кеширует результат каста при первом обращении и возвращает один и тот же экземпляр объекта при повторных. Для value objects с состоянием это безопасно, но если каст возвращает изменяемый объект и его нужно перечитать из атрибутов, реализуйте SerializesCastableAttributes или используйте refresh().
7. immutable_datetime и привычки изменения дат. При обычном datetime-касте возвращается Carbon с изменяемым внутренним состоянием: $model->date->addDay() модифицирует существующий объект. У immutable_datetime те же методы возвращают новый экземпляр, и результат необходимо присвоить обратно. Использование immutable-варианта рекомендуется для предотвращения трудноуловимых ошибок.
8. Унификация формата сериализации дат. Дополнительный параметр datetime:Y-m-d H:i:s определяет формат вывода при сериализации модели в массив или JSON, а не способ парсинга из БД. Если требуется единый формат для всех дат в API-ответах, переопределите метод serializeDate() на базовой модели приложения.
Accessors и Mutators
Назначение

Accessor — метод, вычисляющий значение атрибута при его чтении. Используется для производных атрибутов (полное имя из первого и фамилии, абсолютный URL аватара из относительного пути, человекочитаемая длительность из секунд) или для нормализации значения, лежащего в БД, перед выдачей в код.

Mutator — метод, преобразующий значение перед его сохранением в атрибут модели. Применяется для хеширования паролей, обрезки пробелов в email, нормализации регистра, упрощения единиц измерения.

Граница между mutator и cast в Laravel размыта: оба механизма преобразуют значение при чтении/записи. Различие в том, что cast описывает тип атрибута (как сериализовать в БД), а accessor/mutator описывает пользовательскую логику (как вычислить или нормализовать). Для повторно используемых преобразований предпочтителен кастомный cast; для логики, специфичной одной модели, — accessor/mutator.

В Laravel 9 был введён новый API на основе класса Illuminate\Database\Eloquent\Casts\Attribute. Он объединяет accessor и mutator в одном методе, поддерживает многоколоночные mutator (запись в несколько столбцов одним присваиванием) и встроенное кеширование результата. Старый стиль (getXxxAttribute / setXxxAttribute) остаётся работоспособным для обратной совместимости.

Перечень механизмов

Старый API: префиксные методы

Метод с именем get{Name}Attribute объявляет accessor, set{Name}Attribute — mutator. Имя атрибута получается преобразованием PascalCase в snake_case: getFullNameAttribute соответствует обращению $user->full_name.

class User extends Model
{
    // Accessor: вычисляет производный атрибут.
    public function getFullNameAttribute(): string
    {
        return trim("{$this->first_name} {$this->last_name}");
    }

    // Mutator: нормализует email при записи.
    public function setEmailAttribute(string $value): void
    {
        $this->attributes['email'] = strtolower(trim($value));
    }

    // Mutator с побочным эффектом: автоматически хеширует пароль.
    public function setPasswordAttribute(string $value): void
    {
        $this->attributes['password'] = Hash::make($value);
    }
}

// Использование
$user->email    = '  Alice@Example.COM  ';
$user->password = 'plain-text';
$user->save();
// В БД: email = 'alice@example.com', password = '$2y$10$...' (bcrypt-хеш)
echo $user->full_name;  // "Alice Brown"

Новый API: класс Attribute

Метод объявляется с типом-возвратом Attribute и возвращает результат Attribute::make(). Передаются именованные аргументы get (accessor) и set (mutator). Это сокращает декларацию и даёт ряд дополнительных возможностей.

use Illuminate\Database\Eloquent\Casts\Attribute;

class User extends Model
{
    // Только accessor.
    protected function fullName(): Attribute
    {
        return Attribute::make(
            get: fn ($value, array $attributes): string =>
                trim("{$attributes['first_name']} {$attributes['last_name']}"),
        );
    }

    // Только mutator с нормализацией.
    protected function email(): Attribute
    {
        return Attribute::make(
            set: fn (string $value): string => strtolower(trim($value)),
        );
    }

    // Сокращённая запись для случая, когда нужен только один из вариантов.
    protected function password(): Attribute
    {
        return Attribute::set(fn (string $value) => Hash::make($value));
    }

    // Accessor с зависимостью от других полей.
    protected function isVerified(): Attribute
    {
        return Attribute::get(fn ($value, array $attributes): bool =>
            $attributes['email_verified_at'] !== null
                && $attributes['phone_verified_at'] !== null);
    }
}

Имя атрибута для внешнего обращения получается преобразованием camelCase метода в snake_case: fullName() доступен как $user->full_name, isVerified() — как $user->is_verified.

Многоколоночные mutator

Если присваивание атрибуту должно затрагивать несколько столбцов БД, mutator возвращает ассоциативный массив 'столбец' => значение. Это полезно для синтетических атрибутов вроде «полное имя» с автоматическим разбиением на части.

protected function fullName(): Attribute
{
    return Attribute::make(
        get: fn ($value, array $attributes): string =>
            trim("{$attributes['first_name']} {$attributes['last_name']}"),

        set: function (string $value): array {
            $parts = explode(' ', trim($value), 2);
            return [
                'first_name' => $parts[0],
                'last_name'  => $parts[1] ?? '',
            ];
        },
    );
}

$user->full_name = 'Alice Brown';
$user->save();
// В БД: first_name='Alice', last_name='Brown'.

Кеширование значения accessor: shouldCache()

По умолчанию accessor вызывается при каждом обращении к атрибуту. Если вычисление затратно (преобразование больших данных, обращение к внешним сервисам, генерация подписанных URL), результат можно закешировать в памяти инстанса модели на время её жизни.

protected function avatarUrl(): Attribute
{
    return Attribute::make(
        get: fn (): string => Storage::disk('s3')->temporaryUrl(
            $this->avatar_path,
            now()->addHour(),
        ),
    )->shouldCache();
}

// Первое обращение генерирует подписанный URL, второе и далее возвращает его из кеша.
echo $user->avatar_url;  // сетевой вызов
echo $user->avatar_url;  // без вызова

Метод shouldCache() кеширует значение в свойстве модели. При вызове $model->refresh() или $model->fresh() кеш сбрасывается. Для критичных к актуальности данных кеширование не применяется.

Связь с appended-атрибутами

По умолчанию accessor не попадает в сериализованное представление модели (toArray(), toJson()). Чтобы вычисленное значение появилось в API-ответе, имя атрибута перечисляется в свойстве $appends.

class User extends Model
{
    protected $appends = ['full_name', 'avatar_url'];

    protected function fullName(): Attribute { /* ... */ }
    protected function avatarUrl(): Attribute { /* ... */ }
}

Более точный подход к управлению сериализацией — через Eloquent Resource, особенно если набор полей зависит от роли запрашивающего пользователя.

Сквозной пример: профиль пользователя с производными полями

Рассмотрим модель User в проекте, где требуется ряд производных атрибутов: полное имя, инициалы, возраст по дате рождения, флаг полной верификации, временный URL аватара. Mutators применяются для нормализации входных данных: тримминг email, хеширование пароля, разбиение полного имени на составляющие.

use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Storage;

class User extends Model
{
    protected $fillable = [
        'first_name', 'last_name', 'email', 'password',
        'birth_date', 'avatar_path', 'phone',
        'email_verified_at', 'phone_verified_at',
    ];

    protected $hidden   = ['password', 'remember_token'];
    protected $appends  = ['full_name', 'initials', 'age', 'is_fully_verified'];

    protected function casts(): array
    {
        return [
            'birth_date'         => 'immutable_date',
            'email_verified_at'  => 'datetime',
            'phone_verified_at'  => 'datetime',
        ];
    }

    // ───── Mutators (нормализация при записи) ─────

    protected function email(): Attribute
    {
        return Attribute::set(fn (string $v): string => strtolower(trim($v)));
    }

    protected function phone(): Attribute
    {
        return Attribute::set(fn (?string $v): ?string =>
            $v === null ? null : preg_replace('/\\D+/', '', $v));
    }

    protected function password(): Attribute
    {
        return Attribute::set(fn (string $v): string => Hash::make($v));
    }

    // ───── Accessors (производные атрибуты) ─────

    protected function fullName(): Attribute
    {
        return Attribute::make(
            get: fn ($value, array $attributes): string =>
                trim("{$attributes['first_name']} {$attributes['last_name']}"),

            set: function (string $value): array {
                $parts = explode(' ', trim($value), 2);
                return ['first_name' => $parts[0], 'last_name' => $parts[1] ?? ''];
            },
        );
    }

    protected function initials(): Attribute
    {
        return Attribute::get(fn ($value, array $a): string =>
            mb_substr($a['first_name'], 0, 1) . mb_substr($a['last_name'], 0, 1));
    }

    protected function age(): Attribute
    {
        return Attribute::get(fn (): ?int =>
            $this->birth_date?->diffInYears(now()));
    }

    protected function isFullyVerified(): Attribute
    {
        return Attribute::get(fn (): bool =>
            $this->email_verified_at !== null
            && $this->phone_verified_at !== null);
    }

    protected function avatarUrl(): Attribute
    {
        return Attribute::get(fn (): ?string =>
            $this->avatar_path
                ? Storage::disk('s3')->temporaryUrl($this->avatar_path, now()->addHour())
                : null)->shouldCache();
    }
}

Применение модели в типовых сценариях:

// 1. Регистрация: ввод преобразуется автоматически.
$user = User::create([
    'full_name'  => 'Алиса Браун',                // разобьётся на first_name, last_name
    'email'      => '  Alice@Example.COM  ',        // нормализуется до 'alice@example.com'
    'phone'      => '+7 (701) 555-12-34',           // сохранится как '77015551234'
    'password'   => $request->input('password'),     // захешируется bcrypt
    'birth_date' => '1990-06-15',
]);

// 2. Чтение производных полей.
echo $user->full_name;          // "Алиса Браун"
echo $user->initials;           // "АБ"
echo $user->age;                // 35
echo $user->is_fully_verified;  // false (email/phone ещё не подтверждены)
echo $user->avatar_url;         // S3-URL, кешируется в памяти инстанса

// 3. Сериализация для API: благодаря $appends, производные атрибуты попадают в JSON.
return response()->json($user);
// {
//   "id": 1, "first_name": "Алиса", "last_name": "Браун",
//   "email": "alice@example.com", "phone": "77015551234",
//   "full_name": "Алиса Браун", "initials": "АБ", "age": 35,
//   "is_fully_verified": false
// }
Особые случаи и типичные ошибки
1. Mutator не заменяет валидацию. Mutator нормализует значение, но не отвергает невалидные данные. Логика «email должен быть валидным» относится к слою валидации (FormRequest, правила Eloquent). Mutator выполняет последний штрих — приведение к канонической форме (lowercase, trim).
2. Накладные расходы accessor в циклах. Accessor вызывается при каждом обращении к атрибуту. Если он выполняет тяжёлую работу (например, формирует подписанный URL, обращается к кешу), цикл по большой коллекции порождает соответствующее число вызовов. Для таких случаев применяется shouldCache(), либо данные вычисляются на этапе выборки одним пакетом.
3. Accessor не виден в WHERE. Запросы Eloquent работают с реальными колонками БД. Условие where('full_name', 'Alice Brown') приведёт к SQL-ошибке — столбца full_name в таблице нет. Для поиска по производным значениям применяется фильтрация по составляющим (where('first_name', 'Alice')->where('last_name', 'Brown')), индексированные сгенерированные столбцы БД, либо полнотекстовый поиск.
4. Конфликт mutator и cast на одном атрибуте. Если на одном атрибуте объявлены и mutator, и cast, выполняется только mutator. Это легко не заметить, что приводит к молчаливой потере преобразования. Не размещайте оба механизма на одном поле; для одиночных трансформаций выбирайте либо cast (если логика переиспользуется), либо mutator.
5. save() не вызывает mutator при прямом изменении массива attributes. Конструкция $user->attributes['email'] = $value минует mutator. Используйте присваивание через свойство ($user->email = $value) или метод setAttribute(), чтобы цепочка преобразований сработала.
6. Bulk-обновления через query builder. Вызов User::query()->update(['password' => $value]) не вызывает mutator — обновление происходит напрямую через query builder. Для применения логики mutator используется обход через инстансы (User::find($id)->update(...)), либо явное преобразование значения перед bulk-операцией.
7. Зависимости accessor от других atributes. Accessor получает второй аргумент — массив всех текущих атрибутов модели. Не следует обращаться к $this->attribute_name внутри accessor, поскольку это рекурсивно вернёт результат самого accessor (если он на это поле тоже определён), либо вызовет повторный проход по тому же accessor. Используйте параметр $attributes.
8. Mutator на полях, не входящих в $fillable. При массовом присваивании через Model::create() или Model::update() учитываются только поля из $fillable. Если mutator определён на поле вне этого списка, его вызов при mass-assignment не произойдёт. Для критичных преобразований (хеширование пароля) удостоверьтесь, что поле явно разрешено к массовому присваиванию, либо присваивайте его отдельно через свойство.
Hidden, Visible, Appended
Назначение

При сериализации модели в массив или JSON Eloquent по умолчанию включает все колонки таблицы, известные накопителю атрибутов, и не включает производные accessor-атрибуты. Свойства $hidden, $visible и $appends позволяют точечно настроить, какие поля попадают в результирующее представление.

Главная задача механизма — предотвращение случайной утечки чувствительных данных (пароли, токены, внутренние пометки) при сериализации модели и явное включение вычисляемых полей, не существующих в БД (полное имя, инициалы, число дочерних записей).

Свойства модели задают поведение по умолчанию для всех её экземпляров; дополнительно существуют методы инстанса, временно изменяющие список видимых полей для конкретного объекта (makeHidden, makeVisible, setHidden, setVisible, append). Для сложных сценариев, в которых форма ответа зависит от прав запрашивающего пользователя или версии API, рекомендуется использовать Eloquent Resource — отдельный класс-преобразователь, явно описывающий структуру выходных данных.

Перечень механизмов

Свойство $hidden

Чёрный список колонок и accessor-атрибутов, исключаемых из сериализации. Применяется, когда подавляющее большинство полей открыто, а скрыть нужно лишь несколько чувствительных. Это наиболее распространённый сценарий.

class User extends Model
{
    protected $hidden = [
        'password',
        'remember_token',
        'api_secret',
        'two_factor_secret',
        'two_factor_recovery_codes',
    ];
}

$user->toArray();
// [
//   'id' => 1, 'name' => 'Alice', 'email' => 'alice@example.com',
//   'created_at' => '...', 'updated_at' => '...'
// ]
// password, remember_token и остальные не появятся в выводе.

Свойство $visible

Белый список: в сериализованном виде окажутся только перечисленные поля, остальные будут скрыты. Используется, когда требуется строгое ограничение состава полей — например, для специализированной таблицы аудита, ответа справочника API, минимального профиля гостевого пользователя.

class PublicProfile extends Model
{
    protected $visible = ['id', 'name', 'avatar_url'];
}

$profile->toJson();
// {"id":1,"name":"Alice","avatar_url":"https://..."}

Одновременное использование $hidden и $visible на одной модели допустимо, но логически избыточно: $visible имеет приоритет, $hidden применяется только к полям, попавшим в белый список.

Свойство $appends

Список accessor-атрибутов, которые необходимо включить в результат сериализации. По умолчанию accessor вычисляется только при прямом обращении к свойству; чтобы он автоматически попал в toArray() и toJson(), его имя добавляется в $appends.

class User extends Model
{
    protected $appends = ['full_name', 'avatar_url'];

    protected function fullName(): Attribute
    {
        return Attribute::get(fn ($_, array $a) => "{$a['first_name']} {$a['last_name']}");
    }
}

$user->toArray();
// [..., 'full_name' => 'Alice Brown', 'avatar_url' => 'https://...']

Имена в $appends используют snake_case, согласно конвенции сериализации Eloquent. Если accessor определён через метод fullName(), в $appends указывается 'full_name'.

Динамическое изменение состава полей

Методы инстанса позволяют изменить видимость полей у конкретного объекта без модификации настроек класса. Возвращают $this, поэтому пригодны к цепочечному применению.

МетодДействие
makeHidden($attrs)Добавляет указанные поля в скрытые на данном экземпляре.
makeVisible($attrs)Удаляет указанные поля из списка скрытых и (если используется $visible) добавляет в список видимых.
setHidden($attrs)Полностью замещает список скрытых полей переданным массивом.
setVisible($attrs)Полностью замещает список видимых полей.
append($attrs)Добавляет имена к списку $appends на данном экземпляре.
// Сериализация одного пользователя для админ-панели с раскрытием служебных полей.
return response()->json(
    $user
        ->makeVisible(['internal_notes', 'risk_score'])
        ->append('last_login_country')
);

// Скрытие части полей для коллекции, отдаваемой публичным API.
$users->each->makeHidden(['email', 'phone']);
return response()->json($users);
Сквозной пример: разные представления модели User в зависимости от роли

Рассмотрим API, в котором один и тот же ресурс User отдаётся в трёх вариантах: гостю — минимальная карточка с именем и аватаром; самому пользователю — полный профиль без чувствительных полей; администратору — полный набор полей, включая внутренние пометки и оценку риска. Поля для каждого случая декларируются комбинацией $hidden, $appends и динамических вызовов.

class User extends Model
{
    // Скрыты всегда — чувствительные поля, никогда не должны попадать в API.
    protected $hidden = [
        'password',
        'remember_token',
        'two_factor_secret',
        'two_factor_recovery_codes',
        'internal_notes',        // только для админов — раскроется через makeVisible
        'risk_score',            // только для админов
    ];

    // Производные поля, всегда отображаемые в JSON-выводе.
    protected $appends = ['full_name', 'avatar_url'];
}

Контроллер с тремя вариантами представления:

class UserController extends Controller
{
    // Гостевой запрос: минимальное представление через setVisible.
    public function publicShow(User $user): JsonResponse
    {
        return response()->json(
            $user->setVisible(['id', 'full_name', 'avatar_url'])
        );
    }

    // Личный кабинет: всё кроме чувствительных полей.
    public function selfShow(Request $request): JsonResponse
    {
        return response()->json($request->user());
        // password и токены скрыты через $hidden, остальное возвращается как есть.
    }

    // Админ: раскрытие внутренних полей и добавление вычисляемых.
    public function adminShow(User $user): JsonResponse
    {
        return response()->json(
            $user
                ->makeVisible(['internal_notes', 'risk_score'])
                ->append('last_login_at')
                ->loadCount(['posts', 'comments'])
        );
    }
}

Тот же подход применим к коллекциям:

// Для списка пользователей в гостевом справочнике — обнуляем чувствительные поля каждого.
$users = User::latest()->paginate(20);
$users->getCollection()->each->setVisible(['id', 'full_name', 'avatar_url']);
return response()->json($users);

Для проектов, в которых вариантов представления становится много, целесообразен переход на Eloquent Resources — отдельные классы-преобразователи, в которых форма ответа описана явно. Использование $hidden и компании остаётся актуальным как «защитная сетка» от случайной утечки полей при любом способе сериализации.

Особые случаи и типичные ошибки
1. $hidden срабатывает только при сериализации. Прямое обращение через свойство ($user->password) возвращает значение независимо от $hidden. Защита от утечки работает в момент toArray() / toJson() / автоматического приведения модели к JSON в роутах API; для абсолютного скрытия значения необходимо не загружать его из БД (например, через select() в запросе).
2. $appends увеличивает стоимость сериализации. Каждый accessor, перечисленный в $appends, выполняется при каждой сериализации модели. Если accessor обращается к БД (например, вычисляет число дочерних записей), это легко превращается в N+1 при отдаче коллекции. Используйте withCount, кеширование accessor через shouldCache(), либо Eloquent Resources.
3. Конфликт $hidden и сериализации связанных моделей. Если на родителе скрыто поле password, оно не появится в выводе самого родителя, но в связанных моделях (например, $post->author) видимость определяется настройками User. Убедитесь, что $hidden объявлен на самой модели User, а не только в одном контроллере.
4. makeVisible на закешированной коллекции. При вызове на коллекции ($users->makeVisible(...)) изменение применяется к каждой модели по отдельности, но не возвращает новую коллекцию — работает по ссылкам. Если та же коллекция используется ещё где-то ниже по стеку, изменения видимости будут видны и там.
5. Поля, объявленные в $appends, должны быть accessor. Если в $appends указано имя, для которого не определён accessor, при сериализации Laravel попытается получить значение через стандартный механизм атрибутов; обычно это вернёт null без ошибки, что приводит к молчаливо неправильному JSON.
6. $visible блокирует даже служебные поля. Если в $visible не включено поле id или created_at, они исчезнут из вывода. Это может неожиданно сломать клиентов API, ожидающих идентификаторы. При работе с белым списком всегда явно перечисляйте обязательные служебные поля.
7. Связь между $hidden и Mass Assignment. Списки $fillable / $guarded и $hidden / $visible регулируют разные стороны: первые — что разрешено присваивать при массовом обновлении, вторые — что выводится в сериализации. Их часто путают, в результате чего одно и то же поле может быть «защищено» от записи, но утекать при чтении, или наоборот.
8. $hidden и логирование. Некоторые инструменты логирования (Telescope, debug-логи) дампят полное состояние модели, обходя сериализацию. $hidden их не защищает; для чувствительных значений необходима явная фильтрация на уровне логгера или передача в логи специально подготовленного представления.
Scopes: локальные и глобальные
Назначение

Scope — именованный фрагмент query builder, инкапсулирующий часто используемое условие или последовательность условий. Применение scope позволяет переиспользовать запросные правила в нескольких местах кода и сделать места их применения декларативными: вместо where('status', 'active')->where('deleted_at', null)->where(...) пишется active().

Eloquent различает два рода scope. Локальные объявляются как методы модели и применяются явно при построении запроса. Глобальные применяются автоматически ко всем запросам модели без дополнительных вызовов; их используют для системных условий, которые должны действовать всегда (фильтр по тенанту, скрытие soft-deleted, ограничение по правам доступа).

Глобальные scopes — основа таких функций Laravel, как Soft Deletes (SoftDeletingScope) и встроенная фильтрация архивных записей. Этот же приём активно применяется в коммерческих пакетах: spatie/laravel-permission, spatie/laravel-multitenancy, tenancy/tenancy.

Перечень механизмов

Локальный scope: декларация

Метод модели с префиксом scope и аргументом-построителем запросов. При вызове из контекста модели (статически или цепочкой) префикс отбрасывается, имя приводится к camelCase: scopeActive вызывается как active().

use Illuminate\Database\Eloquent\Builder;

class User extends Model
{
    // Простой scope без параметров.
    public function scopeActive(Builder $query): Builder
    {
        return $query->where('status', 'active');
    }

    // Scope с параметром.
    public function scopeOfRole(Builder $query, string $role): Builder
    {
        return $query->where('role', $role);
    }

    // Scope, инкапсулирующий несколько условий и подзапрос.
    public function scopeWithActiveSubscription(Builder $query): Builder
    {
        return $query->whereHas('subscription', function (Builder $q) {
            $q->whereNull('cancelled_at')
              ->where('expires_at', '>', now());
        });
    }
}

// Использование
User::active()->get();
User::active()->ofRole('admin')->get();
User::withActiveSubscription()->orderBy('name')->paginate(20);

Возврат $query из scope формально не обязателен (методы query builder возвращают сам построитель), но является явной документацией намерения и удобен при анализе кода.

Локальный scope: новый PHP-атрибутный синтаксис (Laravel 11+)

Начиная с Laravel 11, поддерживается альтернативная декларация scope через PHP-атрибут #[Scope]. Префикс scope в имени метода не требуется.

use Illuminate\Database\Eloquent\Attributes\Scope;

class Post extends Model
{
    #[Scope]
    protected function published(Builder $query): Builder
    {
        return $query->where('status', 'published');
    }
}

Post::published()->get();

Оба синтаксиса равноценны функционально; атрибутный вариант предпочтительнее в новых проектах за счёт явного объявления намерения.

Глобальный scope как отдельный класс

Реализация интерфейса Illuminate\Database\Eloquent\Scope. Класс должен содержать единственный метод apply(Builder $builder, Model $model), в котором накладываются условия. Регистрация выполняется методом addGlobalScope() в обратном вызове booted() модели.

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;

final class TenantScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        $tenantId = app(TenantManager::class)->currentTenantId();

        if ($tenantId !== null) {
            $builder->where($model->qualifyColumn('tenant_id'), $tenantId);
        }
    }
}

class Project extends Model
{
    protected static function booted(): void
    {
        static::addGlobalScope(new TenantScope());
    }
}

// Все запросы Project автоматически дополняются условием по tenant_id:
Project::all();
Project::where('status', 'active')->get();
Project::find(42);

Метод qualifyColumn() возвращает имя колонки с префиксом таблицы (projects.tenant_id), что критично при использовании JOIN: без квалификации возможна ошибка ambiguous column.

Глобальный scope через замыкание

Для простых случаев класс не обязателен: addGlobalScope() принимает имя scope и замыкание. Имя используется при последующем удалении scope из запроса.

class Post extends Model
{
    protected static function booted(): void
    {
        static::addGlobalScope('published', function (Builder $builder): void {
            $builder->where('status', 'published');
        });
    }
}

Управление глобальными scope в конкретном запросе

Когда требуется получить данные в обход глобального условия (например, в админ-панели или фоновой задаче), scope временно отключается.

МетодДействие
withoutGlobalScope(Class::class)Отключает один scope, переданный по классу или строковому имени.
withoutGlobalScopes()Отключает все scopes у текущего запроса.
withoutGlobalScopes([A::class, B::class])Отключает указанный список scopes.
// Получить заявки всех арендаторов из админ-панели.
Project::withoutGlobalScope(TenantScope::class)->get();

// Системный обходчик без фильтров и soft-delete-учёта.
Project::withoutGlobalScopes()->chunkById(500, ...);

// Phone-confirmation worker — снимает только tenant, но сохраняет soft-deletes.
Project::withoutGlobalScopes([TenantScope::class])->get();
Сквозной пример: мультитенантный SaaS

Рассмотрим типичную многоарендную (multi-tenant) SaaS-платформу, в которой пользователи нескольких организаций работают в одной базе данных, но каждый видит только данные своей. Глобальный TenantScope гарантирует автоматическую изоляцию; локальные scope упрощают повседневные фильтры.

Сервис, хранящий идентификатор текущего арендатора в контейнере:

final class TenantManager
{
    private ?int $tenantId = null;

    public function setCurrentTenantId(?int $id): void  { $this->tenantId = $id; }
    public function currentTenantId(): ?int                  { return $this->tenantId; }
}

// Регистрация как singleton в провайдере.
$this->app->singleton(TenantManager::class);

Middleware определяет арендатора по поддомену запроса и устанавливает его в менеджер:

class SetCurrentTenant
{
    public function handle(Request $request, Closure $next): Response
    {
        $subdomain = explode('.', $request->getHost())[0];
        $tenant    = Tenant::where('slug', $subdomain)->firstOrFail();

        app(TenantManager::class)->setCurrentTenantId($tenant->id);

        return $next($request);
    }
}

Глобальный scope, накладываемый на все тенант-зависимые модели через трейт:

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;

final class TenantScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        $tenantId = app(TenantManager::class)->currentTenantId();
        if ($tenantId !== null) {
            $builder->where($model->qualifyColumn('tenant_id'), $tenantId);
        }
    }
}

trait BelongsToTenant
{
    public static function bootBelongsToTenant(): void
    {
        static::addGlobalScope(new TenantScope());

        // Автоматически подставлять tenant_id при создании.
        static::creating(function (Model $model): void {
            if ($model->tenant_id === null) {
                $model->tenant_id = app(TenantManager::class)->currentTenantId();
            }
        });
    }

    public function tenant(): BelongsTo
    {
        return $this->belongsTo(Tenant::class);
    }
}

class Project extends Model
{
    use BelongsToTenant;

    public function scopeArchived(Builder $query): Builder
    {
        return $query->whereNotNull('archived_at');
    }

    public function scopeOwnedBy(Builder $query, User $user): Builder
    {
        return $query->where('owner_user_id', $user->id);
    }
}

Типовые операции в коде приложения:

// 1. Обычные пользовательские запросы — TenantScope невидим, фильтр накладывается автоматически.
$projects = Project::latest()->paginate(20);
// SELECT * FROM projects WHERE tenant_id = ? ORDER BY ... LIMIT 20

// 2. Комбинация локальных scope с глобальным.
$myArchived = Project::archived()->ownedBy($user)->get();

// 3. Системная задача аналитики, обходящая все тенанты.
Project::withoutGlobalScope(TenantScope::class)
    ->selectRaw('tenant_id, COUNT(*) as cnt')
    ->groupBy('tenant_id')
    ->get();

// 4. Artisan-команда для миграции данных одного конкретного тенанта.
app(TenantManager::class)->setCurrentTenantId($argTenantId);
Project::archived()->chunkById(200, function ($batch) {
    // бизнес-логика
});

// 5. Создание проекта: tenant_id подставляется автоматически из BelongsToTenant.
Project::create(['name' => 'Q4 Roadmap', 'owner_user_id' => auth()->id()]);
Особые случаи и типичные ошибки
1. Отсутствие контекста при работе глобального scope. Если scope опирается на состояние, доступное только в HTTP-запросе (текущий пользователь, идентификатор арендатора), его выполнение в Artisan-команде, очереди или системной задаче приведёт к одному из двух исходов: либо запрос вернёт пустой результат (контекст не установлен), либо ошибочно выберет данные «по умолчанию». Перед выполнением системных задач необходимо явно установить контекст или отключить scope через withoutGlobalScope.
2. Неоднозначность колонок при JOIN. Если scope накладывает условие where('tenant_id', ...), а запрос содержит JOIN с другой таблицей, в которой также есть колонка tenant_id, SQL завершится ошибкой ambiguous column. Решение — всегда использовать qualifyColumn() в scope.
3. Глобальный scope не применяется к raw-запросам. Конструкции вида DB::table('projects')->get() не проходят через цикл Eloquent и игнорируют все глобальные scopes. Для запросов с обходом ORM фильтрация требуется вручную.
4. Локальный scope и orWhere. Условия внутри scope, объединённые с внешним orWhere, могут давать неожиданный результат из-за приоритета операторов. Например, User::active()->orWhere('role', 'admin') формирует SQL WHERE status = 'active' OR role = 'admin', что соответствует «либо активный, либо администратор». Для корректной группировки используйте замыкание: User::active()->orWhere(fn($q) => $q->where('role', 'admin')), либо явное where(function ($q) { ... }).
5. Глобальный scope и insert/update. Bulk-операции через построитель запросов (Project::query()->update(...)) выполняют scope-условия в WHERE. Это означает, что update затронет только записи, прошедшие фильтр — что обычно и желаемое поведение, но требует осознанности при системных миграциях данных.
6. Порядок применения глобальных scopes. Несколько глобальных scopes применяются в порядке регистрации. Если один зависит от другого (например, второй ожидает уже наложенный JOIN), порядок имеет значение. Регистрируйте scopes явно и не полагайтесь на «случайный» порядок при использовании трейтов.
7. Тестирование с глобальными scopes. При написании unit-тестов фабрика может неожиданно не находить созданные ею же записи, если scope требует контекста (например, тенанта). В тестах необходимо либо установить контекст до запросов, либо отключать scope через withoutGlobalScopes().
8. Зависимость от глобального scope как меры безопасности. Глобальный scope — удобный механизм, но он защищает только в той мере, в какой выполняется. Любой запрос, выполненный с withoutGlobalScope или через DB::table(), его обходит. Для критичных требований изоляции данных (например, регуляторных) применяется дополнительная проверка на уровне политик, ограничений БД (row-level security) или отдельных схем.
Soft Deletes
Назначение

Soft Deletes — механизм логического удаления, при котором запись остаётся в таблице, но помечается как удалённая через timestamp в специальной колонке deleted_at. Eloquent автоматически исключает помеченные записи из стандартных выборок, оставляя возможность восстановления и аудита.

Технически реализован через глобальный scope SoftDeletingScope, добавляющий условие deleted_at IS NULL ко всем запросам модели, и переопределённый метод delete(), который вместо SQL DELETE выполняет UPDATE, устанавливая текущее время в deleted_at.

Применяется в нескольких типовых сценариях: пользователю предоставляется возможность отменить удаление (корзина, восстановление аккаунта), система обязана сохранять историю изменений для аудита или регуляторных требований, удаление родительской записи не должно нарушать ссылочную целостность для связанных записей в логах и архивах. Альтернативой soft delete служит выделенная архивная таблица или event-sourcing, однако soft delete сохраняет простоту работы с одной таблицей и сохраняет существующие индексы.

Перечень компонентов

Подключение к модели

Активация включает два шага: добавление колонки deleted_at в миграции и подключение трейта SoftDeletes к модели. Трейт автоматически регистрирует глобальный scope и подменяет поведение метода delete().

// Миграция
Schema::create('posts', function (Blueprint $table) {
    $table->id();
    $table->string('title');
    $table->text('body');
    $table->timestamps();
    $table->softDeletes();  // добавляет колонку deleted_at TIMESTAMP NULL
});

// Модель
use Illuminate\Database\Eloquent\SoftDeletes;

class Post extends Model
{
    use SoftDeletes;
}

Имя колонки можно изменить, переопределив константу DELETED_AT или свойство $deletedAtColumn (в Laravel 12+). Это полезно при работе с legacy-схемами, в которых поле называется иначе (removed_at, archived_at).

Операции с моделью

МетодПоведение
$model->delete()Логическое удаление: устанавливает deleted_at = now().
$model->forceDelete()Физическое удаление: выполняет SQL DELETE, обходя soft-delete.
$model->restore()Восстановление: устанавливает deleted_at = NULL.
$model->trashed()Возвращает true, если запись помечена удалённой.
Model::onlyTrashed()Построитель, выбирающий только удалённые записи.
Model::withTrashed()Построитель, включающий и активные, и удалённые записи.
Model::withoutTrashed()Явное исключение удалённых (поведение по умолчанию). Полезно, когда требуется отменить активированный ранее withTrashed.
$post->delete();
// UPDATE posts SET deleted_at = NOW() WHERE id = ?

$post->restore();
// UPDATE posts SET deleted_at = NULL WHERE id = ?

$post->forceDelete();
// DELETE FROM posts WHERE id = ?

// Запросы
Post::all();                        // только активные (deleted_at IS NULL)
Post::withTrashed()->get();          // все записи включая удалённые
Post::onlyTrashed()->get();          // только удалённые
Post::withTrashed()->find($id);     // найти, даже если удалён

События модели

Soft delete добавляет два собственных события Eloquent: trashed (после успешного логического удаления) и restoring/restored (до и после восстановления). Стандартные deleting/deleted вызываются и при soft delete, и при forceDelete — различить их можно через метод isForceDeleting().

class PostObserver
{
    public function deleting(Post $post): void
    {
        if ($post->isForceDeleting()) {
            // окончательное удаление — стираем связанные файлы
            Storage::delete($post->attachments->pluck('path')->all());
        } else {
            // soft delete — каскад на связанные сущности
            $post->comments()->delete();
        }
    }

    public function restored(Post $post): void
    {
        $post->comments()->onlyTrashed()->restore();
    }
}

Soft delete на отношениях

При обращении к связанным моделям SoftDeletes применяется к каждой из них по отдельности. Если родительская модель использует soft delete, а дочерняя нет (или наоборот), это может приводить к ситуациям, когда у удалённого родителя «висят» активные дочерние записи. Для согласованного поведения трейт подключается ко всем участникам цепочки, а каскад реализуется явно в observer.

// На уже удалённой модели relation выбирает только живые дочерние.
$post->delete();
$post->comments;                          // только активные комментарии
$post->comments()->withTrashed()->get();   // все, включая удалённые

Массовое восстановление

Метод restore() на построителе запросов восстанавливает все записи, удовлетворяющие условию. Применяется в bulk-сценариях: восстановление всех записей пользователя при его реактивации, отмена ошибочного массового удаления.

Post::onlyTrashed()
    ->where('user_id', $user->id)
    ->restore();
// UPDATE posts SET deleted_at = NULL WHERE deleted_at IS NOT NULL AND user_id = ?
Сквозной пример: корзина с возможностью восстановления

Рассмотрим административную панель CMS, в которой статьи удаляются логически. Пользователю в течение 30 дней доступно восстановление из корзины, после чего отдельная задача физически очищает старые записи. При окончательном удалении статьи освобождаются связанные файлы из хранилища, а при логическом удалении каскадом удаляются комментарии и реакции.

Структура моделей и схемы:

Schema::create('posts', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained();
    $table->string('slug');
    $table->string('title');
    $table->text('body');
    $table->json('attachments')->nullable();
    $table->timestamps();
    $table->softDeletes();

    // Условный (partial) уникальный индекс на slug — только среди активных записей,
    // чтобы освободить slug после soft delete и не блокировать переиспользование.
    // Синтаксис специфичен СУБД: для PostgreSQL ниже, для MySQL 8 — generated column.
});

// PostgreSQL partial unique index
DB::statement('CREATE UNIQUE INDEX posts_slug_unique ON posts(slug) WHERE deleted_at IS NULL');

class Post extends Model
{
    use SoftDeletes;

    protected $casts = [
        'attachments' => 'array',
    ];

    public function comments(): HasMany
    {
        return $this->hasMany(Comment::class);
    }
}

class Comment extends Model
{
    use SoftDeletes;
}

Observer, реализующий каскадное поведение:

use Illuminate\Support\Facades\Storage;

class PostObserver
{
    public function deleting(Post $post): void
    {
        if ($post->isForceDeleting()) {
            // Окончательное удаление — освобождаем хранилище и удаляем связанные.
            foreach ((array) $post->attachments as $path) {
                Storage::delete($path);
            }
            $post->comments()->withTrashed()->forceDelete();
        } else {
            // Логическое удаление — каскад только в пределах живых дочерних.
            $post->comments()->delete();
        }
    }

    public function restored(Post $post): void
    {
        // При восстановлении возвращаем комментарии, удалённые именно вместе с постом.
        $post->comments()
            ->onlyTrashed()
            ->where('deleted_at', '>=', $post->deleted_at)
            ->where('deleted_at', '<=', $post->deleted_at->addSeconds(5))
            ->restore();
    }
}

Контроллер корзины и операций восстановления:

class TrashController extends Controller
{
    // 1. Просмотр корзины: только удалённые записи текущего пользователя.
    public function index(Request $request): View
    {
        $posts = Post::onlyTrashed()
            ->where('user_id', $request->user()->id)
            ->orderByDesc('deleted_at')
            ->paginate(20);

        return view('trash.index', compact('posts'));
    }

    // 2. Восстановление: поиск по withTrashed, чтобы найти даже удалённую запись.
    public function restore(int $id): RedirectResponse
    {
        $post = Post::withTrashed()->findOrFail($id);
        $post->restore();

        return redirect()->route('posts.show', $post);
    }

    // 3. Окончательное удаление по запросу пользователя.
    public function forceDelete(int $id): RedirectResponse
    {
        Post::withTrashed()->findOrFail($id)->forceDelete();

        return redirect()->route('trash.index');
    }
}

Запланированная Artisan-команда, очищающая корзину старше 30 дней:

class PruneOldTrash extends Command
{
    protected $signature = 'trash:prune {--days=30}';

    public function handle(): int
    {
        $threshold = now()->subDays((int) $this->option('days'));

        Post::onlyTrashed()
            ->where('deleted_at', '<', $threshold)
            ->chunkById(200, function (Collection $batch) {
                $batch->each->forceDelete();
                // Через каждый экземпляр — чтобы сработал observer с очисткой файлов.
            });

        return self::SUCCESS;
    }
}

// app/Console/Kernel.php
$schedule->command('trash:prune')->dailyAt('03:00');
Особые случаи и типичные ошибки
1. Уникальные ограничения и soft delete. Стандартный уникальный индекс БД не различает активные и удалённые записи: после soft delete пользователя с email alice@example.com повторная регистрация с тем же адресом будет отклонена СУБД. Решения: условный (partial) уникальный индекс WHERE deleted_at IS NULL (поддерживается PostgreSQL, SQLite, частично MySQL 8 через generated columns), либо валидация на уровне приложения через Rule::unique('users')->whereNull('deleted_at') — при этом БД-уровневая гарантия отсутствует.
2. Cascade soft delete не работает «из коробки». Внешние ключи БД с ON DELETE CASCADE срабатывают только при физическом удалении. При soft delete родителя дочерние записи не затрагиваются. Каскад реализуется либо в observer (метод deleting), либо в сервис-классе, выполняющем удаление, либо через сторонние пакеты вроде iatstuti/laravel-cascade-soft-deletes.
3. Eager loading и soft-deleted родители. При Comment::with('post')->get() метод with учитывает глобальный scope, поэтому комментарии к soft-deleted постам получат $comment->post === null. Для включения удалённых: with(['post' => fn($q) => $q->withTrashed()]).
4. find() и findOrFail() игнорируют удалённые. Если требуется получить запись независимо от статуса (для админ-панели или восстановления), используется Model::withTrashed()->find($id). Прямое обращение по find для удалённой записи вернёт null, что может неожиданно вести к 404.
5. Route Model Binding и SoftDeletes. По умолчанию неявное связывание модели в маршрутах не включает удалённые записи. Для маршрутов корзины это требуется явно: либо через Route::resource(...)->withTrashed(), либо ручным разрешением модели в контроллере (Post::withTrashed()->findOrFail($id)).
6. forceDelete игнорирует SoftDeletes-каскады. При окончательном удалении observer должен сам решить, что делать с дочерними записями: оставить их (нарушив ссылочную целостность, если нет FK), удалить логически, удалить физически. Реализация различается в зависимости от регуляторных требований (история комментариев может быть необходима даже после удаления статьи).
7. Производительность запросов с большим количеством удалённых записей. Если таблица содержит миллионы soft-deleted строк, фильтр deleted_at IS NULL становится узким местом. Для PostgreSQL и MySQL рекомендуется частичный индекс или индекс (deleted_at, frequently_queried_column). Альтернатива — периодическое перемещение старых удалённых записей в архивную таблицу через scheduled-задачу.
8. Конфликт $dates и автоматического каста deleted_at. Трейт SoftDeletes сам добавляет deleted_at в массив $casts как datetime. В моделях, ранее использовавших устаревший массив $dates, дублирование может вызвать неожиданное поведение. Используйте только $casts; $dates считается устаревшим, начиная с Laravel 10.
Subqueries и raw expressions
Назначение

Стандартные методы Query Builder (where, join, orderBy) покрывают большую часть запросов. Однако в продакшен-задачах регулярно требуется встроить подзапрос в SELECT-список, наложить условие EXISTS, выполнить арифметическое выражение или агрегатную функцию, для которых нет специализированного метода. Для таких случаев Eloquent предоставляет API подзапросов и сырых SQL-выражений.

Подзапросы (addSelect, selectSub, whereExists, fromSub) позволяют декларативно встроить SELECT внутрь SELECT, при этом сохраняя биндинг параметров и защиту от SQL-инъекций. Сырые выражения (DB::raw, selectRaw, whereRaw, orderByRaw) дают возможность вставить произвольный фрагмент SQL для случаев, когда даже API подзапросов недостаточен.

Сырой SQL — обоюдоострый инструмент. С одной стороны, он раскрывает полную мощь СУБД (оконные функции, выражения CASE, JSON-операторы PostgreSQL, специфичные функции). С другой стороны, при некорректном использовании он открывает уязвимость SQL-инъекции и ломает переносимость кода между разными СУБД. Главное правило: пользовательский ввод всегда передаётся через биндинг параметров, никогда не конкатенируется в строку SQL.

Перечень механизмов

addSelect и selectSub: подзапрос как столбец

Позволяют добавить к выборке вычисляемый столбец, полученный из подзапроса. Применяется для отображения связанных агрегированных данных без N+1, без необходимости eager loading. Альтернатива withSum/withAvg, дающая полную свободу формулировки подзапроса.

use App\Models\Login;

$users = User::query()
    ->addSelect([
        'last_login_at' => Login::select('created_at')
            ->whereColumn('logins.user_id', 'users.id')
            ->orderByDesc('created_at')
            ->limit(1),
        'failed_attempts_7d' => Login::selectRaw('COUNT(*)')
            ->whereColumn('logins.user_id', 'users.id')
            ->where('success', false)
            ->where('created_at', '>=', now()->subWeek()),
    ])
    ->get();

echo $users[0]->last_login_at;
echo $users[0]->failed_attempts_7d;

Эквивалентный SQL:

SELECT users.*, (SELECT created_at FROM logins WHERE logins.user_id = users.id ORDER BY created_at DESC LIMIT 1) AS last_login_at, (SELECT COUNT(*) FROM logins WHERE logins.user_id = users.id AND success = 0 AND created_at >= ?) AS failed_attempts_7d FROM users;

Метод orderBy() также принимает построитель запросов как первый аргумент — это позволяет сортировать по значению, вычисленному подзапросом.

fromSub и joinSub: подзапрос как источник

Когда необходимо выполнить агрегацию по результату другой агрегации, либо присоединить группированную выборку к основному запросу, подзапрос используется в качестве источника. fromSub подставляет его на место FROM, joinSub — на место JOIN.

// Подзапрос: для каждого пользователя — последний оплаченный заказ.
$latestPaid = Order::select('user_id', DB::raw('MAX(paid_at) as last_paid_at'))
    ->whereNotNull('paid_at')
    ->groupBy('user_id');

// joinSub присоединяет результат к users одним SQL.
$users = User::query()
    ->joinSub($latestPaid, 'lp', function ($join) {
        $join->on('lp.user_id', '=', 'users.id');
    })
    ->select('users.*', 'lp.last_paid_at')
    ->get();

whereExists и whereNotExists

Условие «существует хотя бы одна связанная запись, удовлетворяющая критерию». В отличие от whereHas (работающего через Eloquent relations), whereExists принимает произвольный построитель запросов и более универсален, особенно для подзапросов с JOIN или агрегатами.

// Пользователи, оставившие хотя бы один комментарий за последние 30 дней.
User::whereExists(function (Builder $q) {
    $q->selectRaw(1)
      ->from('comments')
      ->whereColumn('comments.user_id', 'users.id')
      ->where('comments.created_at', '>=', now()->subDays(30));
})->get();

Применение SELECT 1 внутри подзапроса — общепринятая практика: значение не используется (важно только наличие хотя бы одной строки), но это сигнализирует СУБД о возможной оптимизации.

DB::raw и контекстные selectRaw/whereRaw/orderByRaw

Сырые SQL-выражения встраиваются в запрос напрямую. DB::raw() создаёт Expression, который Query Builder использует без экранирования. Контекстные методы (selectRaw, whereRaw и т. п.) являются обёртками с поддержкой биндингов.

// selectRaw с биндингами параметров.
Order::selectRaw('SUM(amount) as total, currency')
    ->groupBy('currency')
    ->get();

// whereRaw с биндингами — пользовательский ввод передаётся параметром, не конкатенируется.
Post::whereRaw('views > ? AND created_at >= ?', [
    100,
    now()->subWeek(),
])->get();

// orderByRaw для сортировки по выражению (например, расстояние).
Place::orderByRaw('ST_Distance(point, ST_MakePoint(?, ?))', [$lng, $lat])
    ->limit(10)
    ->get();

// havingRaw — для условий на агрегаты.
Order::select('user_id')
    ->selectRaw('SUM(amount) as total')
    ->groupBy('user_id')
    ->havingRaw('SUM(amount) > ?', [10000])
    ->get();

Сводная таблица методов

МетодЧто вставляетПоддержка биндингов
DB::raw($sql)Произвольный фрагмент SQLНет (биндинги добавляются отдельно)
selectRaw($sql, $bindings)В SELECTДа
whereRaw($sql, $bindings)В WHEREДа
orderByRaw($sql, $bindings)В ORDER BYДа
groupByRaw($sql, $bindings)В GROUP BYДа
havingRaw($sql, $bindings)В HAVINGДа
addSelect(['alias' => $builder])Скалярный подзапрос как столбецДа (через построитель)
fromSub($builder, 'alias')Подзапрос в FROMДа
joinSub($builder, 'alias', $closure)Подзапрос в JOINДа
whereExists($closure)EXISTS-подзапросДа
whereColumn('a.x', 'b.y')Сравнение двух колонок (для корреляций)—
Сквозной пример: аналитический отчёт по пользователям

Рассмотрим страницу отчёта в админ-панели: для каждого пользователя необходимо отобразить число заказов, общую сумму, дату последнего заказа, число неудачных попыток входа за последние 7 дней, среднюю оценку оставленных отзывов. Все эти данные должны быть получены одним SQL-запросом, без N+1 и без множественных eager loadings.

Реализация на построителе с подзапросами:

use App\Models\Login;
use App\Models\Order;
use App\Models\Review;

class UserReportController extends Controller
{
    public function __invoke(Request $request): View
    {
        $users = User::query()
            ->addSelect([
                // Сумма всех оплаченных заказов.
                'orders_total' => Order::selectRaw('COALESCE(SUM(amount), 0)')
                    ->whereColumn('orders.user_id', 'users.id')
                    ->whereNotNull('paid_at'),

                // Количество оплаченных заказов.
                'orders_count' => Order::selectRaw('COUNT(*)')
                    ->whereColumn('orders.user_id', 'users.id')
                    ->whereNotNull('paid_at'),

                // Дата последнего оплаченного заказа.
                'last_order_at' => Order::select('paid_at')
                    ->whereColumn('orders.user_id', 'users.id')
                    ->whereNotNull('paid_at')
                    ->orderByDesc('paid_at')
                    ->limit(1),

                // Число неудачных логинов за последние 7 дней.
                'failed_logins_7d' => Login::selectRaw('COUNT(*)')
                    ->whereColumn('logins.user_id', 'users.id')
                    ->where('success', false)
                    ->where('created_at', '>=', now()->subWeek()),

                // Средняя оценка оставленных отзывов.
                'avg_review_rating' => Review::selectRaw('AVG(rating)')
                    ->whereColumn('reviews.user_id', 'users.id'),
            ])
            ->// Фильтр: только активные пользователи с хотя бы одним заказом.
            ->where('status', 'active')
            ->whereExists(function (Builder $q) {
                $q->selectRaw(1)
                  ->from('orders')
                  ->whereColumn('orders.user_id', 'users.id')
                  ->whereNotNull('paid_at');
            })
            ->// Сортировка по подзапросу — наиболее ценные клиенты сверху.
            ->orderByDesc(
                Order::selectRaw('COALESCE(SUM(amount), 0)')
                    ->whereColumn('orders.user_id', 'users.id')
                    ->whereNotNull('paid_at')
            )
            ->paginate(50);

        return view('admin.reports.users', compact('users'));
    }
}

Альтернативная реализация через fromSub для группировки данных, когда логика отчёта сложнее и требует промежуточной агрегации:

// Топ-10 продуктов по объёму продаж за квартал, с долей в общем обороте.
$totals = DB::table('order_items')
    ->selectRaw('product_id, SUM(qty * price) as revenue')
    ->whereBetween('created_at', [now()->subQuarter(), now()])
    ->groupBy('product_id');

$report = DB::query()
    ->fromSub($totals, 't')
    ->join('products', 'products.id', '=', 't.product_id')
    ->select('products.name', 't.revenue')
    ->selectRaw('t.revenue / SUM(t.revenue) OVER () * 100 AS share_pct')
    ->orderByDesc('t.revenue')
    ->limit(10)
    ->get();
Особые случаи и типичные ошибки
1. SQL-инъекция через DB::raw. Конкатенация пользовательского ввода в строку SQL (DB::raw("WHERE name = '$input'")) — критическая уязвимость. Любой пользовательский ввод передаётся параметром через биндинги: whereRaw('name = ?', [$input]). Это правило безусловно: даже «проверенный» ввод подлежит параметризации.
2. Подзапрос с несколькими столбцами в addSelect. Скалярный подзапрос в SELECT-списке должен возвращать ровно один столбец и не более одной строки. Попытка вернуть несколько столбцов или несколько строк завершится ошибкой СУБД. Для возврата нескольких связанных значений используйте joinSub.
3. whereExists и неоднозначные имена таблиц. В корреляционном подзапросе обязательно указывать полные имена колонок (logins.user_id, users.id). Без квалификации СУБД может выбрать неправильную таблицу или вернуть ошибку ambiguous column.
4. Различия диалектов SQL. Сырые выражения теряют переносимость между СУБД. SUM(amount) OVER () (оконная функция) работает в PostgreSQL, MySQL 8+, SQLite 3.25+, но не в MySQL 5.7. JSON-операторы ->> различаются. При работе с несколькими СУБД (например, SQLite в тестах и PostgreSQL в продакшене) сырые выражения проверяются на обоих окружениях.
5. Подзапрос с limit и пагинатором. При использовании addSelect с подзапросом, включающим LIMIT, обращение через paginate() может работать некорректно из-за того, что метод count() построителя дублирует SELECT-список, включая подзапросы. В таких случаях применяется simplePaginate, либо отдельная обёртка fromSub.
6. Производительность подзапросов. Скалярный коррелированный подзапрос выполняется один раз для каждой строки внешнего запроса. На выборках в десятки тысяч строк это даёт ощутимую нагрузку, особенно без покрывающих индексов. Альтернатива — joinSub с предварительно сгруппированными данными, либо денормализованные счётчики, обновляемые наблюдателями событий.
7. DB::raw в условиях с биндингами. Если внутри DB::raw() необходимы параметры, они не привязываются автоматически. В таких случаях используется обёртка с биндингами явно: $query->whereRaw('? = ANY(tags)', [$tag]) — либо специальные методы построителя, понимающие массивы (whereIn, whereBetween).
8. Тестирование запросов с подзапросами. Сложные запросы с подзапросами и сырым SQL должны покрываться тестами, фиксирующими как корректность результата на типовых данных, так и план выполнения (через EXPLAIN). При изменении запроса легко получить регрессию в производительности, незаметную в обычных тестах с малыми объёмами данных.
chunk, chunkById, lazy и cursor
Назначение

Стандартные методы get() и all() загружают полный результат запроса в память приложения. При выборках в десятки тысяч и миллионы строк это приводит к исчерпанию памяти PHP, длительной задержке перед началом обработки и невозможности параллельной работы. Eloquent предоставляет четыре механизма потоковой обработки больших выборок: chunk, chunkById, lazy и cursor.

Эти механизмы решают одну задачу — обработать большой набор данных, не загружая его полностью в память — но реализуют её разными способами и имеют разные ограничения. Различия касаются количества выполняемых SQL-запросов, расхода памяти, безопасности при модификации обрабатываемых записей и интерфейса доступа (массив порциями, итератор по одному, ленивая коллекция).

Выбор метода критически важен для корректности и производительности bulk-операций. Неправильно выбранный механизм может пропускать или дублировать записи, исчерпать память на длинных циклах, либо заблокировать таблицу на время обработки.

Перечень методов

chunk($size, $callback)

Разбивает результат запроса на партии указанного размера, поочерёдно загружает каждую партию в память и передаёт её в callback. Для каждой партии выполняется отдельный SQL с LIMIT $size OFFSET $offset.

User::where('newsletter', true)
    ->chunk(500, function (Collection $users) {
        foreach ($users as $user) {
            Mail::to($user)->queue(new NewsletterMail($user));
        }
    });

Прерывание обхода: если callback вернёт false, итерация прекращается. Это удобно при пакетной обработке с условием остановки (например, обработка до определённого критерия).

Ограничение: метод использует OFFSET, что делает его уязвимым при модификации обрабатываемых записей. Если в callback удаляется текущая партия записей, следующий OFFSET сдвинется в исходно нумерованных строках, но реально пропустит те, что подтянулись на их место.

chunkById($size, $callback, $column = null)

Усовершенствованная версия chunk, использующая первичный ключ (или указанную колонку) для пагинации вместо OFFSET. Каждая партия выбирается условием WHERE id > $lastId ORDER BY id LIMIT $size. Это устраняет проблему «сдвига» при модификации.

// Безопасная пакетная очистка неактивных пользователей.
User::where('last_login_at', '<', now()->subYears(2))
    ->chunkById(200, function (Collection $users) {
        foreach ($users as $user) {
            $user->delete();
        }
    });

// SQL:
// 1: SELECT * FROM users WHERE last_login_at < ? ORDER BY id ASC LIMIT 200;
// 2: SELECT * FROM users WHERE last_login_at < ? AND id > 199 ORDER BY id ASC LIMIT 200;
// 3: ...

Метод требует, чтобы используемая колонка была монотонной (обычно автоинкрементный primary key) и индексированной. Без индекса каждый шаг будет приводить к полному сканированию таблицы.

lazy($size = 1000) и lazyById($size, $column)

Возвращают LazyCollection — коллекцию, элементы которой подгружаются партиями по мере итерации. В отличие от chunk, лениво вычисляются и поддерживают полный API коллекции (map, filter, each, reduce), при этом не загружают полный результат в память.

// Цепочечная обработка через коллекционные методы без загрузки всего в память.
User::where('plan', 'pro')
    ->lazyById(500)
    ->filter(fn (User $u) => $u->requiresRetention())
    ->each(fn (User $u) => RetentionMail::dispatch($u));

Принципиальная разница с chunk: callback вызывается по одному элементу, а не по партии. Это удобно для последовательной обработки, но менее эффективно для bulk-операций (например, массовых обновлений), где партия обрабатывается одной SQL-командой.

cursor()

Возвращает LazyCollection, итерация по которой выполняется с использованием SQL-курсора СУБД. Один запрос отправляется в БД, и она поочерёдно отдаёт результирующие строки. В памяти PHP в каждый момент находится один объект модели.

// Экспорт миллионов записей в CSV без расхода памяти.
$handle = fopen('php://output', 'w');

foreach (Transaction::where('year', 2024)->cursor() as $tx) {
    fputcsv($handle, [$tx->id, $tx->amount, $tx->currency, $tx->created_at]);
}

Минимальный расход памяти, наивысшая скорость для чисто read-only операций. Ограничения: один открытый запрос на всё время обхода (может удерживать соединение и блокировки), невозможность eager loading связанных моделей (для них требуется отдельный SELECT).

Сравнительная таблица

МетодПамять (PHP)SQL-запросовБезопасен при мутации обходимых записейПоддерживает eager loading
chunk($size)Средняя (одна партия)Число записей / $sizeНет (использует OFFSET)Да
chunkById($size)Средняя (одна партия)Число записей / $sizeДа (пагинация по PK)Да
lazy($size)Низкая (партии лениво)Число записей / $sizeНетДа
lazyById($size)НизкаяЧисло записей / $sizeДаДа
cursor()Минимальная (одна модель)1 (потоковый)Условно (зависит от изоляции)Нет (только текущая модель)

Критерии выбора метода

  • cursor() — read-only обход максимального размера: экспорт, генерация отчётов, агрегации в памяти. Не подходит, если требуется обращение к relation внутри цикла.
  • chunkById() — bulk-операции, изменяющие обходимые записи (массовое удаление, обновление статуса). Безопасен для одновременной модификации.
  • chunk() — обход без модификации обходимых записей (рассылка писем, индексация в поисковый движок, экспорт с обработкой партиями).
  • lazy() / lazyById() — обработка через цепочку коллекционных методов (filter, map, each) для случаев, где удобство декларативного API важнее скорости.
Сквозной пример: ежемесячный отчёт о транзакциях

Рассмотрим запланированную Artisan-команду, которая еженощно генерирует CSV-отчёт о транзакциях за прошедший месяц, отправляет уведомления пользователям с подозрительной активностью и архивирует записи старше двух лет. Для каждой подзадачи используется наиболее подходящий механизм обхода.

use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Storage;

class MonthlyTransactionReport extends Command
{
    protected $signature = 'reports:transactions {--month=}';

    public function handle(): int
    {
        $month = Carbon::parse($this->option('month') ?? 'last month');
        $this->exportToCsv($month);
        $this->notifySuspiciousAccounts($month);
        $this->archiveOldTransactions();

        return self::SUCCESS;
    }

    // 1. Экспорт: только чтение, миллионы строк, минимальная память — используем cursor().
    private function exportToCsv(Carbon $month): void
    {
        $path   = storage_path("reports/transactions-{$month->format('Y-m')}.csv");
        $handle = fopen($path, 'w');
        fputcsv($handle, ['id', 'user_id', 'amount', 'currency', 'created_at']);

        foreach (Transaction::whereBetween('created_at', [
            $month->copy()->startOfMonth(),
            $month->copy()->endOfMonth(),
        ])->cursor() as $tx) {
            fputcsv($handle, [
                $tx->id,
                $tx->user_id,
                $tx->amount,
                $tx->currency,
                $tx->created_at->toIso8601String(),
            ]);
        }

        fclose($handle);
        $this->info("CSV сохранён: {$path}");
    }

    // 2. Рассылка уведомлений: обходим пользователей партиями, модификация не задевает обходимых.
    //    Подходит обычный chunk().
    private function notifySuspiciousAccounts(Carbon $month): void
    {
        User::whereHas('transactions', function ($q) use ($month) {
            $q->whereBetween('created_at', [$month->startOfMonth(), $month->endOfMonth()])
              ->where('amount', '>', 10000);
        })->chunk(200, function (Collection $users) use ($month) {
            foreach ($users as $user) {
                $user->notify(new SuspiciousActivityNotification($month));
            }
        });
    }

    // 3. Архивация: каждая обработанная запись удаляется → используем chunkById,
    //    чтобы не пропускать «сдвинувшиеся» строки.
    private function archiveOldTransactions(): void
    {
        Transaction::where('created_at', '<', now()->subYears(2))
            ->chunkById(500, function (Collection $batch) {
                DB::transaction(function () use ($batch) {
                    ArchivedTransaction::insert($batch->map->toArchiveArray()->all());
                    Transaction::whereIn('id', $batch->modelKeys())->delete();
                });
            });
    }
}

Использование lazyById для случая, когда требуется сначала отфильтровать записи по полю, не индексированному в БД, а затем выполнить дальнейшую обработку:

// Поиск дублирующихся email-адресов среди миллионов пользователей
// без загрузки всех в память.
$duplicateGroups = User::lazyById(2000)
    ->groupBy(fn (User $u) => strtolower($u->email))
    ->filter(fn (LazyCollection $group) => $group->count() > 1);
Особые случаи и типичные ошибки
1. chunk и одновременная модификация. Самая частая ошибка использования метода: внутри callback изменяется или удаляется текущая партия записей. Из-за OFFSET-пагинации следующая партия сдвинется в нумерации, и часть данных будет пропущена. Для bulk-операций над обходимыми записями всегда используется chunkById.
2. cursor и относительно медленные операции. Метод удерживает открытое соединение с БД на всё время обхода. Если внутри цикла выполняется длительная работа (сетевые запросы, тяжёлая логика), соединение остаётся занятым, что может исчерпать пул соединений и замедлить остальное приложение. Для таких случаев предпочтителен chunkById, освобождающий соединение между партиями.
3. cursor без буферизации (MySQL). По умолчанию PDO для MySQL загружает весь результат в память драйвера, что нивелирует преимущества cursor(). Для истинно потокового режима необходимо включить PDO::MYSQL_ATTR_USE_BUFFERED_QUERY = false в конфигурации соединения, а также избегать выполнения других запросов во время обхода.
4. Eager loading с cursor невозможен. Метод выполняет один SELECT и стримит результат построчно; для eager loading требуется второй SELECT, который не может быть выполнен во время активного курсора. При необходимости обращаться к relation в цикле используется chunkById с with(...).
5. chunkById и нестандартный primary key. Метод по умолчанию опирается на колонку id с числовым возрастающим типом. Для UUID-ключей, составных ключей или ситуаций, когда требуется пагинация по другой колонке (created_at), необходимо передать имя колонки третьим аргументом и убедиться в наличии индекса.
6. Транзакции и chunk. Открытие транзакции на всю операцию chunkById по большой таблице приведёт к удержанию блокировок на всё время обхода. Корректный подход — открывать транзакцию внутри callback на каждую партию (как в примере с архивацией), чтобы блокировки удерживались только в пределах одной партии.
7. lazy внутри коллекционных методов с агрегацией. Методы вроде sum(), avg(), count() на LazyCollection требуют обхода всех элементов, что подгружает их в память по очереди и в итоге выполняет всю выборку. Преимущество лени теряется, если в конце цепочки используется операция, требующая полного материализованного набора.
8. Тайм-ауты PHP и долгие обходы. При обработке миллионов записей время выполнения может превысить лимиты PHP (max_execution_time, лимиты PHP-FPM, веб-сервера). Длительные обходы целесообразно выносить в Artisan-команды, выполняемые в очереди или планировщике, где ограничения мягче.
upsert, insert bulk, firstOrCreate
Назначение

Стандартные методы Eloquent для создания записи (create, save) работают через цикл гидрации модели: вызываются события, observers, mutators, casts, проставляются timestamps. Это удобно для одиночных операций, но неэффективно для массовых: вставка тысячи записей по одной займёт тысячи SQL-запросов и значительное время.

Eloquent предоставляет специализированные методы для массовых операций и условного создания:

  • insert() — bulk-вставка через Query Builder, минуя цикл Eloquent;
  • upsert() — вставка с автоматическим обновлением при конфликте уникального индекса (INSERT ... ON CONFLICT/DUPLICATE KEY UPDATE);
  • firstOrCreate(), firstOrNew(), updateOrCreate() — идемпотентное создание единичной записи на основе условия поиска.

Выбор между ними определяется характером задачи: разовое создание/обновление с активной бизнес-логикой (используется firstOrCreate/updateOrCreate) или массовая операция без бизнес-обработки (используется insert/upsert). Эти методы критичны при импорте данных, синхронизации с внешними системами, обновлении справочников и подобных задачах.

Перечень методов

insert($rows) — массовая вставка

Выполняет один SQL INSERT с многократным набором значений. В большинстве СУБД он значительно быстрее последовательных одиночных вставок: и за счёт уменьшения числа round-trip к базе, и за счёт батчевой обработки на стороне СУБД.

User::insert([
    ['name' => 'Alice', 'email' => 'alice@x.com', 'created_at' => now(), 'updated_at' => now()],
    ['name' => 'Bob',   'email' => 'bob@x.com',   'created_at' => now(), 'updated_at' => now()],
    ['name' => 'Cara',  'email' => 'cara@x.com',  'created_at' => now(), 'updated_at' => now()],
]);

// SQL:
// INSERT INTO users (name, email, created_at, updated_at) VALUES
//   ('Alice', 'alice@x.com', ?, ?),
//   ('Bob',   'bob@x.com',   ?, ?),
//   ('Cara',  'cara@x.com',  ?, ?);

Особенности:

  • Не вызываются Eloquent events (creating, created) и observers;
  • Не применяются casts и mutators — значения отправляются в БД как есть;
  • Колонки created_at и updated_at необходимо проставлять вручную;
  • Возвращает true при успехе (не идентификаторы вставленных записей).

upsert($rows, $uniqueBy, $update)

Вставка записей с автоматическим обновлением, если в таблице уже существует запись с тем же значением по указанным колонкам. Под капотом транслируется в SQL-конструкцию INSERT ... ON DUPLICATE KEY UPDATE (MySQL) или INSERT ... ON CONFLICT (...) DO UPDATE (PostgreSQL, SQLite 3.24+).

Product::upsert(
    [
        ['sku' => 'A1', 'name' => 'Apple',  'price' => 100, 'stock' => 50],
        ['sku' => 'B2', 'name' => 'Banana', 'price' => 50,  'stock' => 120],
        ['sku' => 'C3', 'name' => 'Cherry', 'price' => 200, 'stock' => 30],
    ],
    uniqueBy: ['sku'],
    update:   ['name', 'price', 'stock'],
);

Параметры:

  • $rows — массив ассоциативных массивов с данными;
  • $uniqueBy — колонки, по которым определяется дубликат (должен существовать уникальный индекс или primary key на этих колонках);
  • $update — список колонок, обновляемых при конфликте. Если опущен, обновляются все колонки из $rows.

Как и insert, не вызывает Eloquent events и не применяет casts/mutators. Колонка updated_at при конфликте обновляется автоматически, если она есть в таблице и не указана в uniqueBy.

firstOrCreate($attributes, $values = [])

Идемпотентное создание единичной записи. Сначала выполняется поиск по $attributes; если запись найдена — возвращается без изменений; если нет — создаётся новая, в которую попадают объединённые $attributes и $values. В отличие от insert, метод проходит через стандартный цикл Eloquent (events, observers, casts).

// Подписать пользователя на канал, если ещё не подписан.
$subscription = Subscription::firstOrCreate(
    ['user_id' => $user->id, 'channel_id' => $channel->id],
    ['subscribed_at' => now(), 'plan' => 'basic'],
);

// Узнать, была ли запись реально создана.
if ($subscription->wasRecentlyCreated) {
    $subscription->notify(new WelcomeNotification());
}

updateOrCreate($attributes, $values)

Принципиальное отличие от firstOrCreate: если запись найдена, она обновляется значениями из $values, после чего возвращается. Если не найдена — создаётся. Метод применяется при синхронизации с внешними источниками данных и идемпотентной обработке webhook-событий.

// Обработка вебхука от платёжного провайдера: обновить статус заказа или создать новый.
$order = Order::updateOrCreate(
    ['external_id' => $webhook->orderId()],
    [
        'status'      => $webhook->status(),
        'paid_at'     => $webhook->paidAt(),
        'amount'      => $webhook->amount(),
        'raw_payload' => $webhook->payload(),
    ],
);

firstOrNew($attributes, $values = [])

Возвращает инстанс модели — либо найденный, либо новый, но несохранённый. Удобен в случаях, когда после поиска необходимо выполнить дополнительную настройку перед сохранением.

$user = User::firstOrNew(['email' => 'alice@x.com']);
$user->name     = $externalProfile->name();
$user->timezone = request()->header('X-Timezone');
$user->save();

Сравнительная таблица

МетодЗапросовEloquent eventsCasts / mutatorsTimestampsПрименение
insert($rows)1 (bulk)Не вызываютсяНе применяютсяВручнуюИмпорт больших объёмов
upsert(...)1 (bulk)Не вызываютсяНе применяютсяupdated_at автоматическиСинхронизация справочников
create($data)1ВызываютсяПрименяютсяАвтоматическиСоздание одной записи
firstOrCreate(...)1-2Вызываются (при создании)ПрименяютсяАвтоматическиИдемпотентное создание
updateOrCreate(...)1-2ВызываютсяПрименяютсяАвтоматическиИдемпотентная синхронизация
firstOrNew(...)1 (без save)Не вызываются (до save)Применяются после saveПри saveСоздание с подгонкой полей
Сквозной пример: ежедневная синхронизация каталога с поставщиком

Рассмотрим запланированную задачу, которая загружает свежий прайс-лист поставщика (CSV с несколькими тысячами товаров), обновляет каталог: добавляет новые позиции, обновляет цены и остатки существующих, помечает отсутствующие как недоступные. Реализация комбинирует upsert для массового обновления и updateOrCreate для случаев, требующих бизнес-логики.

use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\LazyCollection;

class SyncSupplierCatalog extends Command
{
    protected $signature = 'catalog:sync {file}';

    public function handle(): int
    {
        $file = $this->argument('file');
        $now  = now();

        // 1. Считываем файл лениво, чтобы не загружать его в память целиком.
        $rows = LazyCollection::make(function () use ($file) {
            $h = fopen($file, 'r');
            $headers = fgetcsv($h);
            while (($line = fgetcsv($h)) !== false) {
                yield array_combine($headers, $line);
            }
            fclose($h);
        });

        // 2. Массовое обновление каталога через upsert по партиям.
        //    Для каждой партии формируется ровно один SQL.
        $processedSkus = [];

        $rows->chunk(500)->each(function (LazyCollection $batch) use ($now, &$processedSkus) {
            $payload = $batch->map(fn (array $row): array => [
                'sku'          => $row['sku'],
                'name'         => $row['name'],
                'price'        => (int) $row['price_cents'],
                'stock'        => (int) $row['stock'],
                'is_available' => true,
                'created_at'   => $now,
                'updated_at'   => $now,
            ])->all();

            Product::upsert(
                $payload,
                uniqueBy: ['sku'],
                update:   ['name', 'price', 'stock', 'is_available', 'updated_at'],
            );

            $processedSkus = array_merge($processedSkus, array_column($payload, 'sku'));
        });

        // 3. Товары, отсутствующие в файле — помечаются недоступными.
        //    Здесь требуется не bulk-операция, а обработка с событием Eloquent (для индексации).
        Product::whereNotIn('sku', $processedSkus)
            ->where('is_available', true)
            ->chunkById(200, function ($batch) {
                foreach ($batch as $product) {
                    $product->update(['is_available' => false]);
                    // событие updated сработает → search index пересчитается через observer
                }
            });

        return self::SUCCESS;
    }
}

Дополнительные сценарии применения idempotent-методов:

// 1. Регистрация подписки на email-рассылку — не создаёт дубликат.
NewsletterSubscriber::firstOrCreate(
    ['email' => $email],
    ['subscribed_at' => now(), 'source' => 'landing-page'],
);

// 2. Идемпотентная обработка webhook платёжной системы:
//    повторная доставка не создаст дубликат, но обновит статус.
PaymentEvent::updateOrCreate(
    ['provider' => $provider, 'external_id' => $webhook->id()],
    ['status' => $webhook->status(), 'received_at' => now()],
);

// 3. Импорт CSV-справочника валютных курсов: тысячи строк, один SQL.
ExchangeRate::upsert(
    $rates,
    uniqueBy: ['pair', 'date'],
    update:   ['rate'],
);
Особые случаи и типичные ошибки
1. insert() и отсутствие казтов/мутаторов. Метод выполняется на уровне Query Builder; значения отправляются в БД без преобразования. Если в модели объявлен каст 'options' => 'array', при insert массив необходимо сериализовать вручную (json_encode). Игнорирование этого даёт ошибку SQL «invalid string».
2. insert() и timestamps. Колонки created_at и updated_at при использовании insert не проставляются автоматически. Их необходимо включить в каждый ряд вручную, иначе СУБД либо отвергнет вставку (если поля NOT NULL), либо вставит NULL, что нарушает целостность временных меток.
3. upsert требует уникального индекса на колонках из uniqueBy. Без индекса операция либо не сработает (PostgreSQL вернёт ошибку), либо приведёт к нежелательному поведению (MySQL может молча применить ON DUPLICATE KEY UPDATE по неверной колонке). Перед использованием upsert проверяется наличие соответствующего unique или primary key.
4. upsert и события Eloquent. Метод не вызывает события creating, created, updating, updated. Если в проекте на этих событиях висит логика (например, отправка уведомлений, инвалидация кэша, обновление поискового индекса), bulk-операция оставит данные в каталоге, но не приведёт к выполнению этой логики. Решение — ручной вызов соответствующих действий после upsert, либо реализация логики через триггеры БД для бизнес-критичных случаев.
5. firstOrCreate и race condition. Если несколько процессов одновременно вызывают firstOrCreate с одинаковыми $attributes, оба могут не найти запись и попытаться её создать. Без уникального индекса на колонках поиска один из них упадёт с ошибкой constraint violation. Решение — добавление unique-индекса плюс обработка исключения, либо использование upsert.
6. updateOrCreate и большие наборы изменений. Метод выполняет два запроса (SELECT для поиска, INSERT или UPDATE). При синхронизации тысяч записей это даёт тысячи отдельных операций. В таких случаях upsert на 1-2 порядка быстрее.
7. wasRecentlyCreated только после возврата. Свойство wasRecentlyCreated модели становится true только в случае фактического создания записи через firstOrCreate/updateOrCreate. На моделях, найденных или загруженных иными способами, оно всегда false. Это удобный способ узнать, что запись новая, не выполняя дополнительный запрос.
8. Транзакции и bulk-операции. Большие upsert и insert внутри открытой транзакции удерживают блокировки до её завершения. На больших таблицах с активной нагрузкой это может вызвать лавинообразное замедление: другие транзакции ожидают освобождения. Партиционирование bulk-операций на меньшие порции с отдельными транзакциями для каждой смягчает проблему.
Model Events
Назначение

Жизненный цикл модели Eloquent включает несколько этапов: чтение из БД, создание, обновление, удаление, восстановление. На каждом из них Eloquent публикует событие, к которому может быть привязана реакция: логирование, отправка уведомлений, инвалидация кэша, синхронизация со сторонними системами, проверка прав доступа.

Механизм событий моделей реализует pattern Domain Events: бизнес-логика побочных эффектов отделяется от основной операции записи и выносится в independent reaction. Это позволяет добавлять и снимать слушателей без модификации модели, тестировать побочные эффекты изолированно, переиспользовать одни и те же реакции в разных контекстах.

Eloquent предоставляет четыре способа подписки на события: статические методы в booted() модели (для самой модели), Observer-классы (для группировки нескольких реакций в одном файле), bootable traits (для переиспользования между моделями), свойство $dispatchesEvents (для интеграции с системой Event/Listener Laravel и преобразования в полноценные события приложения).

Перечень событий

Полный список событий жизненного цикла

Все события приходят парами «до операции» и «после операции», за исключением retrieved (срабатывает только после чтения). Возврат false из обработчика «до операции» отменяет её выполнение.

СобытиеМоментВозврат false отменяетПрименение
retrievedПосле загрузки записи из БДНетАудит чтения, lazy-инициализация связанных данных
creatingПеред INSERTДаПодстановка дефолтных значений, валидация инвариантов, генерация slug/uuid
createdПосле INSERTНетУведомления, создание связанных сущностей, инвалидация кэша
updatingПеред UPDATEДаПроверка допустимости перехода состояний, логирование изменений
updatedПосле UPDATEНетУведомления об изменении, синхронизация с внешними системами
savingПеред creating ИЛИ updatingДаОбщая логика подготовки, применяемая и при создании, и при обновлении
savedПосле created ИЛИ updatedНетОбщая логика реакции на сохранение
deletingПеред DELETE (или soft delete)ДаКаскадная очистка, проверка прав, валидация бизнес-правил
deletedПосле DELETE (или soft delete)НетОсвобождение ресурсов, логирование, уведомления
trashedПосле soft deleteНетСпецифическая реакция только на логическое удаление
forceDeletedПосле форсированного DELETEНетОкончательная очистка файлов, внешних записей
restoringПеред restore (soft-deleted)ДаПроверка возможности восстановления
restoredПосле restoreНетВосстановление связанных сущностей, уведомление
replicatingПри вызове $model->replicate()НетОчистка полей, не подлежащих копированию (slug, токены)

Подписка через booted()

Самый прямой способ подписаться на события — вызвать соответствующий статический метод модели внутри обратного вызова booted(). Каждый метод (creating, updated и т. п.) принимает замыкание, получающее модель.

class Article extends Model
{
    protected static function booted(): void
    {
        // Генерация slug перед сохранением, если не задан явно.
        static::creating(function (Article $article) {
            $article->slug ??= Str::slug($article->title);
        });

        // Сброс кэша после изменения.
        static::saved(function (Article $article) {
            Cache::forget("article:{$article->id}");
        });
    }
}

Отмена операции через возврат false

Обработчики «до» (creating, updating, saving, deleting, restoring) могут вернуть false, и Eloquent отменит выполнение операции. Метод сохранения вернёт false в вызывающий код, а не выкинет исключение — вызывающая сторона должна это проверять.

static::deleting(function (User $user): ?bool {
    if ($user->is_protected) {
        return false;
    }
    return null;  // Возврат null или void = продолжить операцию.
});

// Вызывающий код
if (! $user->delete()) {
    throw new RuntimeException('User is protected and cannot be deleted.');
}

Свойство $dispatchesEvents — интеграция с Event/Listener

Позволяет привязать события жизненного цикла к собственным классам событий Laravel. Это превращает «внутренние» события Eloquent в полноправные доменные события приложения, на которые можно подписывать множество слушателей, отправлять в очередь, тестировать через Event::fake().

class Order extends Model
{
    protected $dispatchesEvents = [
        'created' => OrderPlaced::class,
        'updated' => OrderUpdated::class,
        'deleted' => OrderDeleted::class,
    ];
}

// Класс события получает модель через конструктор.
class OrderPlaced
{
    use Dispatchable, SerializesModels;

    public function __construct(public Order $order) {}
}

// Слушатели подключаются стандартно через EventServiceProvider или атрибуты.
class SendOrderConfirmation implements ShouldQueue
{
    public function handle(OrderPlaced $event): void
    {
        Mail::to($event->order->user)->send(new OrderConfirmationMail($event->order));
    }
}

Состояние модели в событии: isDirty, wasChanged, getOriginal

Внутри обработчиков доступны методы, позволяющие узнать, какие именно поля изменились. Это полезно для условной логики: реагировать только на изменения конкретных колонок.

static::updating(function (User $user): void {
    // До сохранения: что изменилось в текущем save().
    if ($user->isDirty('email')) {
        $user->email_verified_at = null;  // сброс верификации при смене email
    }
});

static::updated(function (User $user): void {
    // После сохранения: что было изменено в только что прошедшем save().
    if ($user->wasChanged('email')) {
        $oldEmail = $user->getOriginal('email');
        AuditLog::record('email_changed', ['from' => $oldEmail, 'to' => $user->email]);
    }
});

Различие методов:

  • isDirty() — «изменено по сравнению с последним загруженным состоянием, ещё не сохранено». Используется в обработчиках «до операции» (saving, updating).
  • wasChanged() — «было изменено в только что завершённом сохранении». Используется в обработчиках «после операции» (saved, updated).
  • getOriginal($key) — значение поля до изменения. Полезно для логов и сравнений.

Подавление событий: withoutEvents() и saveQuietly()

Иногда требуется выполнить операцию без срабатывания обработчиков — например, при импорте, миграции данных, обходе бесконечной рекурсии внутри обработчика.

// Тихое сохранение одной модели.
$user->saveQuietly();
$user->deleteQuietly();
$user->restoreQuietly();
$user->forceDeleteQuietly();

// Блок кода без событий для всех моделей данного типа.
User::withoutEvents(function () {
    // Здесь можно безопасно вызывать save(), update(), delete()
    // без срабатывания observers и событий.
    User::factory()->count(10000)->create();
});
Сквозной пример: жизненный цикл статьи в CMS

Рассмотрим модель Article в CMS, использующую события для нескольких задач одновременно: автоматическая генерация slug, аудит изменений, инвалидация кэша, отправка уведомлений редактору, контроль за допустимыми переходами статусов.

use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;

class Article extends Model
{
    protected $fillable = ['title', 'slug', 'body', 'status', 'published_at'];

    protected $dispatchesEvents = [
        'created' => ArticleCreated::class,
        'updated' => ArticleUpdated::class,
    ];

    protected static function booted(): void
    {
        // 1. Перед сохранением: автогенерация slug, если не задан.
        static::saving(function (Article $article): void {
            $article->slug ??= Str::slug($article->title);
        });

        // 2. Перед обновлением: проверка допустимости перехода статуса.
        static::updating(function (Article $article): ?bool {
            if (! $article->isDirty('status')) {
                return null;
            }

            $from = $article->getOriginal('status');
            $to   = $article->status;
            if (! ArticleStatus::canTransition($from, $to)) {
                throw new InvalidStateTransition($from, $to);
            }

            // При публикации проставляем дату.
            if ($to === 'published') {
                $article->published_at = now();
            }

            return null;
        });

        // 3. После сохранения: аудит изменений в журнале.
        static::updated(function (Article $article): void {
            $changes = $article->getChanges();
            if (! empty($changes)) {
                AuditLog::create([
                    'auditable_type' => Article::class,
                    'auditable_id'   => $article->id,
                    'action'         => 'updated',
                    'changes'        => $changes,
                    'user_id'        => auth()->id(),
                ]);
            }
        });

        // 4. После любого сохранения — инвалидация кэшей.
        static::saved(function (Article $article): void {
            Cache::forget("article:{$article->slug}");
            Cache::tags(['articles'])->flush();
        });

        // 5. Перед удалением — запрет на удаление опубликованных статей с комментариями.
        static::deleting(function (Article $article): ?bool {
            if ($article->status === 'published' && $article->comments()->exists()) {
                return false;
            }
            return null;
        });

        // 6. После удаления — освобождение прикреплённых файлов.
        static::deleted(function (Article $article): void {
            $article->attachments()->each->delete();
        });

        // 7. При репликации — очищаем уникальный slug, чтобы он сгенерировался заново.
        static::replicating(function (Article $article): void {
            $article->slug         = null;
            $article->published_at = null;
            $article->status       = 'draft';
        });
    }
}

Стороннее использование событий через $dispatchesEvents и Event/Listener:

class SendPublishedNotification implements ShouldQueue
{
    public function handle(ArticleUpdated $event): void
    {
        if ($event->article->wasChanged('status')
            && $event->article->status === 'published') {

            $event->article->author->notify(new ArticlePublishedNotification($event->article));
        }
    }
}

// Регистрация в EventServiceProvider или через атрибут #[OnEvent].
Особые случаи и типичные ошибки
1. Bulk-операции не вызывают события. Это самый часто встречающийся источник «исчезнувшей» бизнес-логики. Model::insert(), upsert(), Model::query()->update(...), Model::query()->delete() работают на уровне Query Builder и не проходят цикл Eloquent. Если на событии висит критическая логика (отправка нотификации, обновление поискового индекса), при bulk-операциях её необходимо вызывать явно.
2. Бесконечная рекурсия в saved. Если внутри обработчика saved вызвать save() на той же модели (например, чтобы заполнить производное поле), это снова вызовет saved — и так до stack overflow. Решение — использовать saveQuietly(), либо переносить логику в saving (где изменения попадают в тот же UPDATE).
3. retrieved срабатывает на каждое чтение. Событие retrieved вызывается при каждой гидрации модели из БД, включая чтения внутри циклов и eager loading. Использование тяжёлой логики в этом обработчике (внешние HTTP-запросы, длительные вычисления) даст экспоненциальную деградацию производительности.
4. isDirty и wasChanged: разница в семантике. Перед сохранением (saving, updating) используется isDirty — «что собирается измениться». После сохранения (saved, updated) — wasChanged — «что только что изменилось». Путаница приводит к тому, что условие либо всегда срабатывает, либо никогда.
5. Транзакции и события с побочными эффектами. Если обработчик отправляет email или диспетчит job, а сохранение модели происходит внутри транзакции, побочный эффект сработает до commit транзакции. При rollback письмо уже отправлено, queue worker может получить job, ссылающийся на несуществующую запись. Решение — DB::afterCommit() или свойство public bool $afterCommit = true на job-классе (см. подраздел «Транзакции»).
6. $dispatchesEvents требует совпадения сигнатур. Класс, указанный в $dispatchesEvents, должен принимать в конструкторе модель в качестве первого аргумента. При несовпадении сигнатуры будет выброшено исключение в момент сохранения модели, что трудно отлаживать в продакшене.
7. Серилизация модели в очередь. Если событие, привязанное через $dispatchesEvents, обрабатывается в очереди (ShouldQueue), модель сериализуется при постановке job и заново загружается из БД при выполнении. Если до этого времени запись была удалена, job упадёт с ModelNotFoundException. Это нормально и обычно желательно (повторная попытка имеет смысл только если данные актуальны), но требует учёта при проектировании поведения retry.
8. Тестирование событий моделей. Для unit-тестов, в которых не должны срабатывать побочные эффекты, используется Event::fake() для классов, перечисленных в $dispatchesEvents. Для внутренних событий жизненного цикла (creating, saved и т. п.) Event::fake() не работает; для их отключения применяется Model::withoutEvents() или явная замена модели в тесте.
Observers
Назначение

Observer — класс, группирующий обработчики нескольких событий жизненного цикла одной модели в едином файле. По функциональности эквивалентен набору подписок через booted(), но даёт лучшую структуру кода и поддерживает Dependency Injection в методы.

Применяется, когда количество обработчиков события превышает 2-3, либо когда логика реакций тесно связана между собой и должна развиваться как единое целое: подсистема аудита, нормализация данных, каскадные операции по жизненному циклу.

Observer не подменяет $dispatchesEvents и Event/Listener — они решают разные задачи. Observer удобен для логики, привязанной к модели и её жизненному циклу (создал User — создай Profile). Event/Listener подходит для широких доменных событий, на которые подписывается множество независимых слушателей (OrderPlaced — отправь email, обнови inventory, обнови аналитику). Часто оба механизма используются параллельно: observer выполняет «инфраструктурную» логику, а $dispatchesEvents диспетчит event для бизнес-реакций.

Перечень компонентов

Создание Observer

Класс генерируется командой Artisan. Опция --model создаёт каркас с пустыми методами для всех типовых событий выбранной модели.

php artisan make:observer UserObserver --model=User
namespace App\Observers;

use App\Models\User;
use App\Services\AdminAlertService;
use Illuminate\Support\Facades\Mail;

class UserObserver
{
    // Dependency injection через конструктор.
    public function __construct(
        private readonly AdminAlertService $alerts,
    ) {}

    public function created(User $user): void
    {
        $user->profile()->create(['bio' => '']);
        Mail::to($user)->queue(new WelcomeMail($user));
    }

    public function updated(User $user): void
    {
        if ($user->wasChanged('email')) {
            $user->notify(new EmailChangedNotification());
        }
    }

    public function deleted(User $user): void
    {
        $this->alerts->notify("Пользователь удалён: {$user->email}");
    }
}

Имена методов observer'а должны точно соответствовать именам событий жизненного цикла Eloquent: retrieved, creating, created, updating, updated, saving, saved, deleting, deleted, trashed, restoring, restored, forceDeleted, replicating. Методы, отсутствующие в классе, просто игнорируются.

Регистрация: ServiceProvider

Классический способ — явный вызов Model::observe() в методе boot() провайдера. Применяется во всех версиях Laravel.

// app/Providers/AppServiceProvider.php
use App\Models\User;
use App\Observers\UserObserver;

public function boot(): void
{
    User::observe(UserObserver::class);
    Article::observe(ArticleObserver::class);
    Order::observe([OrderObserver::class, OrderAuditObserver::class]);
}

К одной модели можно подключить несколько observer'ов, в том числе разделяя реакции по ответственностям (отдельный observer для аудита, отдельный для уведомлений). Они вызываются в порядке регистрации.

Регистрация: атрибут #[ObservedBy] (Laravel 11+)

Альтернативный декларативный способ — атрибут на самой модели. Не требует изменений в провайдерах, делает связь модели и observer явной в её собственном файле.

use Illuminate\Database\Eloquent\Attributes\ObservedBy;

#[ObservedBy([UserObserver::class])]
class User extends Authenticatable
{
    // ...
}

// Можно подключить несколько observers через массив.
#[ObservedBy([UserObserver::class, UserAuditObserver::class])]
class User extends Authenticatable { }

Управление событиями: withoutEvents, saveQuietly

При необходимости временно отключить срабатывание observer'а используются те же механизмы, что и для обычных событий модели.

// Тихое сохранение конкретного инстанса.
$user->saveQuietly();        // observers не вызовутся
$user->deleteQuietly();
$user->restoreQuietly();

// Блок кода без событий — для импортов, миграций, рекурсивных сценариев.
User::withoutEvents(function () {
    User::factory()->count(10_000)->create();
});

Сравнение Observer / Event-Listener / Bootable Trait

ПодходПрименениеОсобенности
ObserverЛогика, тесно связанная с одной моделью (User создаёт Profile, Article генерирует slug)Один класс на модель; DI в конструктор; всегда выполняется синхронно
Event + ListenerДоменные события с множеством независимых реакций (OrderPlaced → email, inventory, analytics)Через $dispatchesEvents; слушатели могут быть в очереди (ShouldQueue); тестируются через Event::fake()
Bootable TraitПереиспользуемое поведение на нескольких моделях (HasSlug, HasUuid, HasTimestamps)Подключается через use Trait; обработчики в методе boot{TraitName}()
Сквозной пример: полная подсистема жизненного цикла пользователя

Рассмотрим модель User, для которой требуется обширная инфраструктурная логика: при создании автоматически формируется профиль, отправляется welcome-email, генерируется API-токен; при изменении email сбрасывается верификация и логируется событие; при удалении пользователя соблюдаются регуляторные требования по сохранности данных; при смене пароля очищаются все активные сессии и оповещаются ответственные администраторы. Использование двух observers разделяет ответственности.

// app/Observers/UserLifecycleObserver.php — инфраструктурная логика
namespace App\Observers;

use App\Models\User;
use App\Notifications\PasswordChangedNotification;
use App\Notifications\WelcomeNotification;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;

class UserLifecycleObserver
{
    // 1. Перед сохранением — нормализация email и автогенерация UUID при создании.
    public function creating(User $user): void
    {
        $user->uuid  ??= (string) Str::uuid();
        $user->email   = strtolower(trim($user->email));
    }

    // 2. После создания — создание зависимых сущностей в одной транзакции.
    public function created(User $user): void
    {
        DB::afterCommit(function () use ($user) {
            $user->profile()->create(['bio' => '']);
            $user->apiTokens()->create(['name' => 'default', 'token' => Str::random(60)]);
            $user->notify(new WelcomeNotification());
        });
    }

    // 3. При смене email — сброс верификации и логирование.
    public function updating(User $user): void
    {
        if ($user->isDirty('email')) {
            $user->email_verified_at = null;
        }
    }

    // 4. При смене пароля — очистка всех сессий и уведомление пользователя.
    public function updated(User $user): void
    {
        if ($user->wasChanged('password')) {
            DB::table('sessions')->where('user_id', $user->id)->delete();
            $user->notify(new PasswordChangedNotification());
        }
    }

    // 5. При удалении — анонимизация и сохранение для отчётности.
    public function deleting(User $user): void
    {
        if (! $user->isForceDeleting()) {
            // soft delete — анонимизируем PII, сохраняя структурную целостность.
            $user->forceFill([
                'email' => "deleted-{$user->id}@example.invalid",
                'name'  => '(deleted)',
                'phone' => null,
            ])->saveQuietly();
        }
    }
}

// app/Observers/UserAuditObserver.php — отдельный observer для аудита.
class UserAuditObserver
{
    public function created(User $user): void
    {
        AuditLog::record('user.created', $user);
    }

    public function updated(User $user): void
    {
        AuditLog::record('user.updated', $user, [
            'changes' => $user->getChanges(),
        ]);
    }

    public function deleted(User $user): void
    {
        AuditLog::record('user.deleted', $user);
    }
}

Подключение обоих observers через атрибут на модели:

use Illuminate\Database\Eloquent\Attributes\ObservedBy;

#[ObservedBy([UserLifecycleObserver::class, UserAuditObserver::class])]
class User extends Authenticatable
{
    use SoftDeletes;
}
Особые случаи и типичные ошибки
1. Бесконечная рекурсия в saved. Метод saved вызывается после каждого сохранения; внутри него вызов $model->save() снова триггерит saved и приводит к stack overflow. Решение — использовать saveQuietly(), либо переносить логику в saving, где значения попадают в тот же UPDATE без дополнительного запроса.
2. Bulk-операции не вызывают observers. Прямые операции через Query Builder (insert, update на запросе, delete на запросе) минуют цикл Eloquent. Если бизнес-логика реализована в observer, при bulk-операциях она не сработает. Это особенно опасно при миграциях данных и пакетных обновлениях.
3. Side effects до commit транзакции. Если observer отправляет email, диспетчит job или вызывает внешний API, а сохранение модели происходит внутри транзакции, побочный эффект сработает до commit. При rollback транзакции письмо уже отправлено, и пользователь получит уведомление о действии, которого фактически не произошло. Защита — DB::afterCommit(fn() => ...) внутри observer, либо public bool $afterCommit = true на job-классе.
4. Observer и retrieved. Метод retrieved вызывается при каждой гидрации модели из БД, включая внутренние операции eager loading. Тяжёлая логика в этом обработчике (внешние запросы, тяжёлые вычисления) приводит к экспоненциальной деградации производительности при выборках большого числа записей.
5. Порядок выполнения нескольких observers. При подключении нескольких observers на одну модель они вызываются в порядке регистрации. Если порядок имеет значение (например, один observer полагается на результат другого), это должно быть явно отражено в коде и документации, поскольку случайное переупорядочивание провайдеров может изменить поведение.
6. Observer и тестирование. Стандартные методы тестирования событий (Event::fake()) не подавляют observers. Для тестов, в которых требуется обойти observer (например, при создании фабрикой большого количества записей), используется Model::withoutEvents():
User::withoutEvents(fn () => User::factory()->count(100)->create());
7. Зависимости в конструкторе observer. Observer разрешается через service container Laravel и поддерживает DI. Это удобно для подстановки сервисов, но требует осторожности при работе с request-scoped зависимостями (текущий пользователь, идентификатор тенанта): observer на event может сработать в очереди или Artisan-команде, где этих зависимостей нет.
8. Observer и Soft Deletes: deleted для обоих видов удаления. Метод deleted в observer срабатывает как при soft delete, так и при force delete. Чтобы различить ситуации, используется $model->isForceDeleting(). Для исключительно soft delete существует отдельный метод trashed; для force delete — forceDeleted (Laravel 11+).
Bootable traits
Назначение

Bootable trait — механизм автоматической инициализации поведения модели при её загрузке. Если в трейте определён статический метод с именем boot{TraitName}(), Eloquent вызывает его автоматически при первой загрузке использующей модели. Это позволяет инкапсулировать как обработчики событий жизненного цикла, так и регистрацию scopes, в переиспользуемом виде.

На этом механизме реализованы все встроенные трейты Eloquent: SoftDeletes, HasFactory, Notifiable, MassPrunable, HasUuids. Каждый из них — обычный PHP-трейт с методом boot{Name}(), регистрирующим необходимые scopes и обработчики событий.

Применение собственных bootable traits оправдано, когда одно и то же поведение требуется на нескольких моделях: автогенерация slug, проставление UUID, аудит изменений, кеширование, привязка к тенанту, поддержка тегов. Альтернативы — абстрактные базовые классы (нарушают возможность наследования от Authenticatable) или копирование кода в каждую модель (нарушает DRY).

Помимо boot{Name}(), трейт может определять метод initialize{TraitName}(), вызываемый для каждого инстанса модели при его создании — это полезно для проставления значений по умолчанию, не зависящих от события creating.

Перечень компонентов

Статический инициализатор boot{TraitName}()

Метод вызывается один раз при первой загрузке модели приложения. Используется для регистрации глобальных scopes, подписки на события жизненного цикла, объявления Macros и любой инициализации, общей для всех инстансов.

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;

trait HasSlug
{
    public static function bootHasSlug(): void
    {
        static::creating(function (Model $model): void {
            if (empty($model->slug)) {
                $model->slug = Str::slug($model->getSlugSource());
            }
        });

        static::updating(function (Model $model): void {
            // При изменении источника slug перегенерируем slug,
            // если не задан явно.
            if ($model->isDirty($model->getSlugSourceColumn())
                && ! $model->isDirty('slug')) {
                $model->slug = Str::slug($model->getSlugSource());
            }
        });
    }

    // Контракт, который реализуют использующие модели.
    abstract public function getSlugSource(): string;

    public function getSlugSourceColumn(): string
    {
        return 'title';
    }
}

Инстанс-инициализатор initialize{TraitName}()

Метод вызывается в конструкторе каждой новой модели, до применения mass-assignment из переданных атрибутов. Применяется для проставления значений по умолчанию, добавления полей в $fillable или $casts программно.

trait HasUuids
{
    public function initializeHasUuids(): void
    {
        // Добавляем колонку uuid в fillable программно,
        // чтобы её не нужно было перечислять в каждой модели вручную.
        $this->mergeFillable(['uuid']);
        $this->mergeCasts(['uuid' => 'string']);
    }

    public static function bootHasUuids(): void
    {
        static::creating(function (Model $model): void {
            $model->uuid ??= (string) Str::uuid();
        });
    }
}

Сравнение методов инициализации

МетодГде определяетсяКогда вызываетсяПрименение
boot()В классе моделиОдин раз при первой загрузке классаБазовая инициализация. Требует вызова parent::boot().
booted()В классе моделиПосле завершения boot() и всех boot{Trait}Регистрация global scopes и event subscriptions. Не требует вызова parent.
boot{TraitName}()В трейтеОдин раз при первой загрузке модели, использующей трейтИнициализация повторно используемого поведения.
initialize{TraitName}()В трейтеПри создании каждого экземпляра моделиДополнение $fillable, $casts, $hidden; значения по умолчанию.

Контракты и абстрактные методы в трейтах

Поскольку трейт не может полноценно объявить контракт, для обязательных методов используется один из подходов:

  • Абстрактный метод в трейте — самый простой способ; PHP при компиляции обяжет дочернюю модель реализовать метод. Однако это работает только если использующий класс сам абстрактный или явно реализует метод.
  • Интерфейс — чище: трейт ничего не требует, но в документации трейта указано, что использующая модель должна реализовать конкретный интерфейс. PHP-IDE и статический анализ это поймут.
  • Значения по умолчанию через переопределяемые методы — если поведение опционально настраиваемо, трейт предоставляет метод с разумным значением по умолчанию, который модель может переопределить.
interface Sluggable
{
    public function getSlugSource(): string;
}

trait HasSlug
{
    // Метод по умолчанию — модель может его переопределить.
    public function getSlugSourceColumn(): string
    {
        return 'title';
    }

    // boot и initialize методы как раньше.
}

class Post extends Model implements Sluggable
{
    use HasSlug;

    public function getSlugSource(): string
    {
        return $this->title;
    }
}
Сквозной пример: набор переиспользуемых трейтов

Рассмотрим набор трейтов, типичный для среднего и крупного приложения: HasUuid для публичных идентификаторов, HasSlug для URL-friendly строк, TracksUserActivity для проставления автора создания и последнего изменения. Каждый трейт самодостаточен, не конфликтует с другими и может быть подключён к произвольной модели.

// app/Models/Concerns/HasUuid.php
namespace App\Models\Concerns;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;

trait HasUuid
{
    public function initializeHasUuid(): void
    {
        $this->mergeCasts(['uuid' => 'string']);
    }

    public static function bootHasUuid(): void
    {
        static::creating(function (Model $model): void {
            $model->uuid ??= (string) Str::uuid();
        });
    }

    // Для маршрутизации по UUID вместо id.
    public function getRouteKeyName(): string
    {
        return 'uuid';
    }
}

// app/Models/Concerns/HasSlug.php
trait HasSlug
{
    public static function bootHasSlug(): void
    {
        static::saving(function (Model $model): void {
            if (empty($model->slug) || $model->isDirty($model->getSlugSourceColumn())) {
                $model->slug = $model->generateUniqueSlug(
                    $model->getAttribute($model->getSlugSourceColumn())
                );
            }
        });
    }

    public function getSlugSourceColumn(): string
    {
        return 'title';
    }

    protected function generateUniqueSlug(string $source): string
    {
        $base  = Str::slug($source);
        $slug  = $base;
        $count = 2;

        while (static::where('slug', $slug)
            ->when($this->exists, fn ($q) => $q->whereKeyNot($this->getKey()))
            ->exists()
        ) {
            $slug = "{$base}-{$count}";
            $count++;
        }

        return $slug;
    }
}

// app/Models/Concerns/TracksUserActivity.php
trait TracksUserActivity
{
    public static function bootTracksUserActivity(): void
    {
        static::creating(function (Model $model): void {
            $model->created_by_user_id ??= auth()->id();
            $model->updated_by_user_id = auth()->id();
        });

        static::updating(function (Model $model): void {
            $model->updated_by_user_id = auth()->id();
        });
    }

    public function createdBy(): BelongsTo
    {
        return $this->belongsTo(User::class, 'created_by_user_id');
    }

    public function updatedBy(): BelongsTo
    {
        return $this->belongsTo(User::class, 'updated_by_user_id');
    }
}

Использование всех трёх трейтов на модели Article:

use App\Models\Concerns\HasSlug;
use App\Models\Concerns\HasUuid;
use App\Models\Concerns\TracksUserActivity;

class Article extends Model
{
    use HasUuid, HasSlug, TracksUserActivity;

    protected $fillable = ['title', 'body', 'status'];

    // Никакой кастомизации для трейтов не требуется — используются дефолты:
    // HasUuid → колонка uuid с автогенерацией
    // HasSlug → колонка slug на основе title
    // TracksUserActivity → колонки created_by_user_id, updated_by_user_id
}

// Кастомизация: использовать другую колонку как источник slug.
class Project extends Model
{
    use HasUuid, HasSlug, TracksUserActivity;

    public function getSlugSourceColumn(): string
    {
        return 'name';
    }
}
Особые случаи и типичные ошибки
1. Несоответствие имени метода и trait. Eloquent ищет метод по точному соответствию имени класса трейта (без неймспейса). Если трейт называется App\Concerns\HasSlug, метод должен быть bootHasSlug, а не bootConcernsHasSlug. Опечатка в имени метода не порождает ошибку — метод просто не вызовется, и поведение трейта не активируется.
2. Конфликт имён трейтов. Если два трейта определяют одинаковый метод с одной сигнатурой, PHP требует явного разрешения конфликта через insteadof или as. Чтобы избежать этого, имена методов делаются специфичными (включающими имя трейта), и трейты не дублируют функциональность.
3. Регистрация global scope в трейте. Если несколько трейтов регистрируют глобальные scopes с одинаковыми именами, последний регистрировавшийся переопределит предыдущие. Используйте уникальные имена scopes или классы-обёртки.
4. boot в модели и parent::boot(). Если в модели переопределяется метод boot(), необходимо вызвать parent::boot() в начале — иначе bootable traits не инициализируются. Метод booted() такого требования не имеет.
5. initialize{Trait} вызывается для каждого инстанса. Этот метод срабатывает не один раз на класс, а на каждой гидрации модели — при чтении, при new Model, при Model::factory()->make(). Тяжёлая логика в нём приведёт к деградации производительности на больших выборках.
6. Зависимость трейта от auth()->id() в фоновых задачах. Трейт вроде TracksUserActivity работает корректно в HTTP-запросе, но в Artisan-командах, очередях, фабриках сидеров auth()->id() возвращает null. Для таких сценариев предусматриваются либо отдельная техническая учётная запись, либо явное проставление значения, либо отключение трейта через withoutEvents.
7. Использование this в boot{Trait}. Метод boot{Trait} — статический, и $this в нём недоступен. Внутри замыканий, регистрируемых на события (static::creating(...)), доступна модель через аргумент замыкания.
8. Тестирование bootable traits. При тестировании трейтов целесообразно использовать «временные» модели — классы, создаваемые внутри теста и наследующие Model, — в которые подключается тестируемый трейт. Это позволяет проверить поведение трейта изолированно, не привязываясь к конкретной доменной модели.
N+1 проблема
Назначение

N+1 — антипаттерн обращения к данным, при котором для получения списка записей и связанных с ними сущностей выполняется один запрос «список» плюс по одному запросу на каждую запись для подгрузки связи. Название отражает суть: один запрос-«родитель» (N=1) плюс N запросов-«детей», итого N+1.

В Eloquent это легко возникает из-за ленивой загрузки связей: обращение к relation на модели автоматически выполняет отдельный запрос. Внутри цикла это превращается в десятки или сотни запросов на одну страницу — результат корректен, но производительность катастрофическая. На локальной разработке с малыми объёмами данных проблема не заметна; в продакшене проявляется как медленные страницы, рост нагрузки на БД и таймауты под нагрузкой.

Решение известно и стандартно: eager loading через with(), load() или агрегатные методы withCount/withSum (подробно разобраны в подразделе «Eager loading»). Главная задача — не допустить попадания N+1 в продакшен, для чего применяются инструменты автоматического обнаружения и защита через preventLazyLoading().

Перечень механизмов

Демонстрация проблемы

Контраст между ленивой загрузкой и eager loading на примере страницы со списком постов и их авторами:

Антипаттерн N+1

foreach (Post::all() as $post) {
    echo $post->author->name;
}

// SQL для коллекции из 100 постов:
// 1: SELECT * FROM posts
// 2: SELECT * FROM users WHERE id = 1
// 3: SELECT * FROM users WHERE id = 2
// 4: SELECT * FROM users WHERE id = 3
// ...
// Итого: 101 запрос.

Eager loading через with()

foreach (Post::with('author')->get() as $post) {
    echo $post->author->name;
}

// SQL для коллекции из 100 постов:
// 1: SELECT * FROM posts
// 2: SELECT * FROM users WHERE id IN (1, 2, ..., 100)
// Итого: 2 запроса.

Подробное рассмотрение всех вариантов eager loading (with, load, loadMissing, withCount, withSum, constrained eager loading) вынесено в подраздел «Eager loading».

Защита: Model::preventLazyLoading()

Метод запрещает ленивую загрузку связей: попытка обратиться к relation, не загруженной заранее, бросает исключение LazyLoadingViolationException. Применяется в окружении разработки для немедленного обнаружения N+1: вместо тихой деградации производительности падает явная ошибка с указанием места.

// app/Providers/AppServiceProvider.php
use Illuminate\Database\Eloquent\Model;

public function boot(): void
{
    // Запретить lazy loading во всех окружениях, кроме production.
    Model::preventLazyLoading(! app()->isProduction());
}

Параметр true включает запрет, false снимает. В production включать опасно: любое незамеченное место с lazy loading приведёт к падению пользовательских запросов. В разработке и CI — обязательная мера, обнаруживающая N+1 на этапе тестирования.

Связанные методы того же семейства:

// Запрет на silent property assignment (опечатки в именах атрибутов).
Model::preventSilentlyDiscardingAttributes(! app()->isProduction());

// Запрет на доступ к атрибутам, не загруженным выборкой.
Model::preventAccessingMissingAttributes(! app()->isProduction());

// Все защитные настройки сразу.
Model::shouldBeStrict(! app()->isProduction());

Инструменты обнаружения и профилирования

ИнструментПрименение
Laravel TelescopeВстроенная панель разработки. Отображает все SQL-запросы каждого HTTP-запроса с группировкой по дубликатам — повторяющиеся запросы с одинаковым шаблоном явно подсвечены.
Laravel PulseОфициальный мониторинг для production. Регистрирует медленные запросы, агрегирует частые шаблоны, показывает корреляцию с конкретными маршрутами.
barryvdh/laravel-debugbarПанель внизу страницы с количеством запросов, временем выполнения и stack trace каждого. Полезна для интерактивной отладки.
ClockworkБраузерное расширение, отображающее запросы и таймлайны через DevTools браузера.
DB::enableQueryLogМинимальный встроенный механизм: захват списка выполненных запросов программно для логирования или тестов.
tighten/jigsaw-toolbox или beyondcode/laravel-query-detectorСпециализированные пакеты с автоматическим обнаружением N+1 во время разработки и логированием в файл.

Использование DB::listen для логирования

Когда нужно отловить N+1 в специфической точке (фоновая задача, тест, специальный сценарий), можно зарегистрировать обработчик каждого выполняемого запроса и проанализировать журнал.

use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

// Например, в middleware или начале команды.
DB::listen(function (QueryExecuted $query) {
    Log::debug('SQL', [
        'sql'      => $query->sql,
        'bindings' => $query->bindings,
        'time_ms'  => $query->time,
    ]);
});

Артизан-команды диагностики

# Отобразить статистику использования соединений и активные транзакции.
php artisan db:monitor

# Просмотреть схему таблицы (часто полезно для проверки индексов).
php artisan db:show --counts
php artisan db:table users

# Анализ структуры запросов через явное логирование в development.
php artisan db:show --views --types
Сквозной пример: тестирование на отсутствие N+1

Лучшая защита от N+1 в продакшене — покрытие критичных эндпоинтов автоматическими тестами, проверяющими количество выполненных SQL-запросов. Ниже — реализация helper и тест для страницы списка постов.

// tests/Concerns/AssertsQueryCount.php — общий helper для тестов.
namespace Tests\Concerns;

use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Support\Facades\DB;

trait AssertsQueryCount
{
    protected function assertQueryCount(int $expected, \Closure $callback): void
    {
        $queries = [];
        DB::listen(function (QueryExecuted $q) use (&$queries) {
            $queries[] = $q->sql;
        });

        $callback();

        $this->assertCount(
            $expected,
            $queries,
            sprintf("Expected %d queries, got %d:\n%s",
                $expected,
                count($queries),
                implode("\n", $queries)
            )
        );
    }
}
// tests/Feature/PostsIndexTest.php — тест на количество SQL-запросов.
use App\Models\Post;
use App\Models\User;
use Tests\Concerns\AssertsQueryCount;
use Tests\TestCase;

class PostsIndexTest extends TestCase
{
    use RefreshDatabase, AssertsQueryCount;

    public function test_posts_index_does_not_have_n_plus_one(): void
    {
        // Подготовка: 25 постов, у каждого свой автор и 5 тегов.
        Post::factory()->count(25)
            ->for(User::factory(), 'author')
            ->hasAttached(Tag::factory()->count(5))
            ->create();

        // Ожидаем не более 4 запросов на страницу:
        // 1: COUNT для пагинатора;
        // 2: SELECT posts;
        // 3: SELECT users (авторы);
        // 4: SELECT tags через polymorphic pivot.
        $this->assertQueryCount(4, function () {
            $this->get('/posts')->assertOk();
        });
    }
}

Альтернативный подход — явное использование preventLazyLoading в тестовой конфигурации, которое автоматически провалит тест при любом N+1:

// tests/TestCase.php или базовый setUp
protected function setUp(): void
{
    parent::setUp();
    Model::preventLazyLoading();
}

// Теперь любой N+1 в коде упадёт с понятной ошибкой:
// Illuminate\Database\LazyLoadingViolationException:
//   Attempted to lazy load [author] on model [App\Models\Post] but lazy loading is disabled.

Дополнительно — обработчик нарушения, который вместо исключения логирует факт N+1, чтобы не ломать UX в staging-окружениях:

Model::preventLazyLoading(! app()->isProduction());

Model::handleLazyLoadingViolationUsing(function (Model $model, string $relation): void {
    Log::warning("N+1 lazy load: " . $model::class . ".{$relation}");
    // В staging — логируем, но не падаем.
});
Особые случаи и типичные ошибки
1. preventLazyLoading в production. Включение защиты в production-окружении опасно: любой необработанный случай N+1 приведёт к падению пользовательских запросов с 500-й ошибкой. Включается только в development и testing, либо с обработчиком, превращающим нарушение в предупреждение в логе.
2. N+1 в шаблонах через accessor. Если accessor модели обращается к relation ($user->total_orders внутри возвращает $this->orders->count()), то eager loading самой модели не помогает: каждый accessor дёргает свой запрос. Решение — использовать withCount для подсчётов, либо передавать предварительно вычисленное значение через DTO.
3. Скрытый N+1 в API Resources. Класс Resource часто содержит обращения к relations при формировании ответа. Если контроллер вернул коллекцию через UserResource::collection($users), а в Resource есть $this->posts, без eager loading возникнет N+1. Тестирование с подсчётом запросов выявляет такие случаи.
4. N+1 с условиями (whereHas + lazy doc). Конструкция User::whereHas('posts', ...)->get() не загружает посты — она только фильтрует юзеров. Если затем требуется получить отфильтрованные посты, нужен либо with('posts', ...), либо отдельный запрос.
5. Eager loading не помогает при ленивых полиморфных relations. Метод with('commentable') на коллекции комментариев порождает один SELECT на каждый тип родителя (Post, Photo, Video). Это естественно для polymorphic, но иногда воспринимается как N+1. Альтернативой служит morphWith с предварительной разбивкой по типам.
6. find() в цикле. Конструкция foreach ($ids as $id) { $user = User::find($id); ... } — форма N+1, где «1» отсутствует, а N запросов выполняются по идентификаторам. Решение — User::whereIn('id', $ids)->get()->keyBy('id'), после чего обращение по идентификатору идёт к коллекции в памяти.
7. Пагинация и N+1. N+1 на пагинированных страницах обычно не виден в логах одного запроса (нагрузка распределена по страницам), но проявляется как линейный рост числа запросов в логах БД. При наличии withCount по нескольким relations плюс N+1 на основной выборке pulse-метрики покажут неожиданно высокую нагрузку.
8. N+1 в queue workers. Логика, написанная для веб-запроса, может срабатывать и в очередях (через события моделей). Если в обработчике события есть N+1, очередь будет медленно «жевать» job-ы, забирая ресурсы БД. Тесты для job'ов должны включать проверку количества запросов так же, как и тесты контроллеров.
Транзакции
Назначение

Транзакция — группа SQL-запросов, выполняемая как атомарная единица. Все запросы группы либо завершаются успешно (commit), либо откатываются целиком (rollback) при возникновении ошибки. Транзакции обеспечивают целостность данных в сценариях, где несколько изменений должны быть согласованы между собой.

Типичные применения: создание сущности и зависимых записей (User + Profile + ApiToken), перевод средств между счетами, оформление заказа с резервированием товара на складе, обновление денормализованных счётчиков параллельно с основной записью. В каждом случае частичное выполнение операции оставит систему в неконсистентном состоянии.

Laravel предоставляет два уровня API для транзакций: высокоуровневый через DB::transaction($closure) с автоматическим откатом при исключении и встроенной поддержкой повторов при deadlock, и низкоуровневый через beginTransaction/commit/rollBack для случаев, когда требуется тонкий контроль над границами транзакции.

Принципиально важный аспект — взаимодействие транзакций с побочными эффектами (отправка email, диспетчер job в очередь, вызов внешнего API). Без специальных мер побочный эффект сработает до commit транзакции, и при rollback в БД ничего не сохранится, но почта уже отправлена. Для разрешения этой коллизии существует механизм DB::afterCommit и свойство $afterCommit на job-классах.

Транзакция и create — не альтернативы

Вопрос «почему DB::transaction, а не просто create» возникает оттого, что их воспринимают как два способа сделать одно и то же. Это разные уровни: create выполняет запись, транзакция задаёт границы, внутри которых несколько записей считаются одной операцией.

Один create транзакции не требует. Это один INSERT, и база выполняет его атомарно сама: он либо прошёл целиком, либо не прошёл вовсе. Оборачивать единственный запрос в DB::transaction бессмысленно — добавятся только BEGIN и COMMIT.

Необходимость появляется там, где запросов несколько. Размещение заказа — ровно такой случай:

INSERT orders           -- 1. сам заказ
INSERT order_items x N  -- 2. позиции
UPDATE orders total     -- 3. посчитанная сумма

Допустим, на втором шаге что-то пошло не так: оборвалось соединение с базой, упал контейнер, обнаружилась ошибка в коде. Разница видна сразу:

// без транзакции: первый запрос уже зафиксирован
$order = Order::create([...]);      // сохранён
$order->items()->createMany($items);   // упало здесь

// в базе остался заказ без позиций, с суммой 0 и статусом «создан»,
// а клиент получил 500 и считает, что заказа нет
// с транзакцией: откатывается всё, будто ничего и не было
return DB::transaction(function () use ($user, $items) {
    $order = Order::create([...]);
    $order->items()->createMany($items);
    $order->update(['total' => $total]);

    return $order;
});
Мусорные строки опаснее, чем выглядят. Заказ без позиций не просто занимает место: он попадёт в списки, в выгрузки, в отчёты по выручке с нулевой суммой, а служба поддержки увидит оплаченный с точки зрения клиента заказ, по которому нечего отгружать. Дальше такие строки находят месяцами и чистят вручную, потому что отличить их от настоящих уже нельзя.
Тест, который это проверяет

Откат — не то, во что стоит верить на слово: отсутствие транзакции никак не проявляется, пока всё идёт хорошо. Поэтому на него пишут отдельный тест, который нарочно ломает второй шаг и смотрит, не осталось ли следов от первого.

public function test_nothing_is_saved_when_creating_items_fails(): void
{
    $user = User::factory()->create();
    Product::factory()->create(['sku' => 'KEY-GTA5']);

    // ломаем второй шаг: слушатель события модели бросает исключение
    // прямо перед вставкой позиции
    Event::listen('eloquent.creating: ' . OrderItem::class, fn () =>
        throw new RuntimeException('сбой при создании позиции')
    );

    $this->expectException(RuntimeException::class);

    try {
        app(PlaceOrder::class)->handle($user, [
            ['sku' => 'KEY-GTA5', 'quantity' => 1],
        ]);
    } finally {
        // главное: заказ с первого шага не должен остаться
        $this->assertDatabaseCount('orders', 0);
        $this->assertDatabaseCount('order_items', 0);
    }
}
Почему проверка в finally. Метод обязан пробросить исключение дальше — контроллер должен узнать о сбое. Но проверить базу нужно всё равно, а после выброшенного исключения обычный код в тесте уже не выполнится. Блок finally отрабатывает в любом случае, и ожидание исключения через expectException при этом сохраняется.
Такой тест бесполезен на базе в памяти без поддержки отката. Проверяйте, что тестовое подключение использует движок с транзакциями: у SQLite он есть, а у MySQL с таблицами MyISAM — нет, и тест пройдёт зелёным при отсутствующей транзакции. Если в наборе тестов используется трейт RefreshDatabase, он сам оборачивает каждый тест в транзакцию — вложенная транзакция внутри превратится в точку сохранения, и это рабочий сценарий, откат сработает.
Перечень механизмов

DB::transaction($closure, $attempts = 1) — основной способ

Принимает замыкание, в котором выполняются операции. При успешном завершении замыкания транзакция фиксируется автоматически. Если из замыкания вылетит исключение, транзакция откатывается, и исключение пробрасывается дальше. Второй аргумент задаёт количество повторов при обнаружении deadlock.

use Illuminate\Support\Facades\DB;

// Базовый случай: создание пользователя и зависимых сущностей.
DB::transaction(function () use ($data) {
    $user    = User::create($data);
    $user->profile()->create(['bio' => '']);
    $user->tokens()->create(['name' => 'default']);
    // При исключении из любой из строк выше — все три операции откатываются.
});

Возвращаемое значение замыкания становится возвращаемым значением DB::transaction(), что удобно для типизации:

$order = DB::transaction(function () use ($items): Order {
    $order = Order::create([...]);
    $order->items()->createMany($items);
    return $order;
});

Ручной контроль: beginTransaction, commit, rollBack

Когда границы транзакции не совпадают с границами одной функции (например, открывается в начале обработчика и закрывается после многих условных операций), используется ручной API. Требует обязательной обработки исключений, чтобы не оставлять транзакцию открытой.

use Illuminate\Support\Facades\DB;

DB::beginTransaction();

try {
    $user    = User::create($data);
    $profile = Profile::create(['user_id' => $user->id]);

    if ($additionalConditionFails) {
        throw new DomainException('...');
    }

    DB::commit();
} catch (\Throwable $e) {
    DB::rollBack();
    throw $e;
}

Вложенные транзакции и savepoints

Laravel поддерживает вложенные вызовы DB::transaction(). На уровне БД это реализуется через SAVEPOINT: внешняя транзакция остаётся одной, внутренние создают точки отката. Если внутренняя «откатывается», откат происходит только до её savepoint, а внешняя продолжается.

DB::transaction(function () {
    User::create(['email' => 'a@x.com']);

    try {
        DB::transaction(function () {
            User::create(['email' => 'b@x.com']);
            throw new RuntimeException('Inner failure');
        });
    } catch (RuntimeException $e) {
        // Откат только внутренней транзакции: пользователь 'a@x.com' остаётся.
    }
});

Метод DB::transactionLevel() возвращает текущий уровень вложенности и удобен для отладки. Метод DB::pretend() — для запуска кода без фактического применения изменений в БД (получает SQL без выполнения).

Повторы при deadlock

Deadlock — ситуация, когда две транзакции взаимно блокируют ресурсы друг друга. СУБД разрешает её, откатив одну из транзакций с ошибкой. Это нормальное явление при конкурентном доступе; правильная реакция — повторить операцию. Второй аргумент DB::transaction задаёт количество автоматических повторов.

// Перевод средств между счетами с автоматическими повторами при deadlock.
DB::transaction(function () use ($from, $to, $amount) {
    $from->decrement('balance', $amount);
    $to->increment('balance', $amount);
}, attempts: 5);

Laravel ловит исключения, соответствующие deadlock-кодам различных СУБД (MySQL 1213, PostgreSQL 40P01), и повторяет замыкание целиком. Важно понимать: побочные эффекты внутри замыкания будут выполнены повторно при каждом retry. Чистая логика модификации БД безопасна; вызовы внешних API, отправка писем должны быть вынесены за пределы транзакции (через DB::afterCommit).

Блокировки строк: lockForUpdate и sharedLock

Внутри транзакции иногда требуется явная блокировка читаемых строк, чтобы предотвратить их изменение другими транзакциями до окончания текущей. Это решает классическую проблему «прочитать-проверить-изменить» в конкурентной среде.

// Безопасное списание средств: блокируем строку пользователя на чтение,
// чтобы параллельная транзакция не успела одновременно списать те же средства.
DB::transaction(function () use ($userId, $amount) {
    $user = User::whereKey($userId)->lockForUpdate()->firstOrFail();

    if ($user->balance < $amount) {
        throw new InsufficientFundsException();
    }

    $user->decrement('balance', $amount);
});

// Альтернатива: SELECT ... LOCK IN SHARE MODE — другие транзакции могут читать,
// но не могут изменять выбранные строки.
User::whereKey($userId)->sharedLock()->firstOrFail();

Побочные эффекты и afterCommit

Распространённый источник тонких багов: побочный эффект (отправка email, диспетчер job в очередь, обращение к внешнему API), выполненный внутри транзакции до её commit. Если транзакция откатывается, побочный эффект уже произошёл, и система оказывается в несогласованном состоянии (письмо отправлено о действии, которого не было).

// АНТИПАТТЕРН: письмо может уйти при rollback транзакции.
DB::transaction(function () use ($user) {
    $user->save();
    Mail::to($user)->send(new WelcomeMail());  // выполнится сразу!
    // Если ниже произойдёт исключение, save() откатится, но письмо уже отправлено.
});

Решения:

// Способ 1: явная регистрация колбэка через DB::afterCommit.
// Выполняется после успешного commit; при rollback не вызывается.
DB::transaction(function () use ($user) {
    $user->save();

    DB::afterCommit(function () use ($user) {
        Mail::to($user)->send(new WelcomeMail());
    });
});

// Способ 2: свойство $afterCommit на job-классе.
class SendWelcomeEmail implements ShouldQueue
{
    public bool $afterCommit = true;

    public function handle(): void { /* ... */ }
}

// Способ 3: глобальная настройка для всех job и notifications.
// config/queue.php:
// 'connections' => ['redis' => [...], 'after_commit' => true]

Сводная таблица методов

МетодНазначение
DB::transaction($cb, $attempts)Атомарная группа операций с авто-rollback и опциональными повторами при deadlock
DB::beginTransaction()Открытие транзакции в ручном режиме
DB::commit()Фиксация транзакции
DB::rollBack()Откат транзакции (всю или до конкретного уровня вложенности)
DB::transactionLevel()Текущий уровень вложенности (0 = вне транзакции)
DB::afterCommit($cb)Регистрация колбэка, выполняемого после успешного commit
Model::lockForUpdate()SELECT с эксклюзивной блокировкой строк (FOR UPDATE)
Model::sharedLock()SELECT с разделяемой блокировкой (LOCK IN SHARE MODE)
Сквозной пример: оформление заказа в интернет-магазине

Рассмотрим типичный сценарий: пользователь оформляет заказ. Необходимо одновременно создать запись заказа, уменьшить остатки товара на складе, зарезервировать сумму со счёта, записать событие в журнал и (после успешного commit) отправить подтверждение по email и поставить в очередь job на формирование документов. Все эти операции должны быть согласованы.

use App\Exceptions\InsufficientStockException;
use App\Exceptions\InsufficientFundsException;
use App\Jobs\GenerateInvoice;
use App\Notifications\OrderConfirmation;
use Illuminate\Support\Facades\DB;

class PlaceOrderService
{
    public function handle(User $user, array $items): Order
    {
        return DB::transaction(function () use ($user, $items): Order {
            // 1. Блокируем пользователя для безопасного списания средств.
            $lockedUser = User::whereKey($user->id)->lockForUpdate()->firstOrFail();

            // 2. Подсчёт итоговой суммы и проверка достаточности средств.
            $total = collect($items)->sum(fn (array $i) => $i['qty'] * $i['price']);

            if ($lockedUser->balance < $total) {
                throw new InsufficientFundsException();
            }

            // 3. Создание заказа.
            $order = Order::create([
                'user_id'  => $lockedUser->id,
                'total'    => $total,
                'status'   => 'placed',
                'placed_at' => now(),
            ]);

            // 4. Резервирование остатков для каждой позиции, с блокировкой строки товара.
            foreach ($items as $item) {
                $product = Product::whereKey($item['product_id'])
                    ->lockForUpdate()
                    ->firstOrFail();

                if ($product->stock < $item['qty']) {
                    throw new InsufficientStockException($product->sku);
                }

                $product->decrement('stock', $item['qty']);

                $order->items()->create([
                    'product_id' => $product->id,
                    'qty'        => $item['qty'],
                    'price'      => $item['price'],
                ]);
            }

            // 5. Списание средств со счёта.
            $lockedUser->decrement('balance', $total);

            // 6. Запись в журнал.
            AuditLog::record('order.placed', $order);

            // 7. Побочные эффекты — только ПОСЛЕ commit транзакции.
            // При rollback эти действия не сработают.
            DB::afterCommit(function () use ($order, $lockedUser) {
                $lockedUser->notify(new OrderConfirmation($order));
                GenerateInvoice::dispatch($order);
            });

            return $order;
        }, attempts: 3);
    }
}

Что обеспечивает эта структура:

  • Атомарность: создание заказа, изменение остатков и списание средств происходят как единое целое. Если что-либо упадёт — всё откатывается, БД остаётся в исходном состоянии.
  • Защита от гонок: lockForUpdate на пользователе и товаре блокирует параллельные транзакции до завершения текущей. Это предотвращает «двойное списание» средств и продажу одного товара двум покупателям одновременно.
  • Безопасность побочных эффектов: уведомление и генерация документа произойдут только после успешного коммита. Если транзакция упадёт — пользователь не получит ошибочного письма о заказе.
  • Устойчивость к deadlock: attempts: 3 автоматически повторит транзакцию при коллизии блокировок, не показывая пользователю ошибку.
Особые случаи и типичные ошибки
1. Side effects внутри транзакции до commit. Самая частая и наиболее опасная ошибка. Отправка email, диспетчер job, обращение к внешнему API, выполненные внутри замыкания, сработают до commit. При rollback побочный эффект уже произошёл. Решение — DB::afterCommit() или свойство $afterCommit = true на job-классе.
2. Повторное выполнение замыкания при deadlock-retry. Аргумент attempts заставляет Laravel повторить замыкание целиком при deadlock. Все операции, не идемпотентные относительно повторного выполнения (отправка email через Mail::raw, синхронные HTTP-запросы), могут выполниться многократно. Чистые SQL-операции на этом не страдают.
3. lockForUpdate без транзакции. Блокировки строк имеют смысл только внутри транзакции, поскольку освобождаются при её commit/rollback. User::find($id)->lockForUpdate() вне транзакции не даст ожидаемого эффекта — блокировка освободится сразу после возврата запроса.
4. Длительные транзакции и блокировки. Транзакция, содержащая операции с большим количеством записей или длительные вычисления, удерживает блокировки на всё время своего выполнения. Под конкурентной нагрузкой это вызывает лавинообразное замедление: другие транзакции ждут освобождения. Решение — партиционировать bulk-операции на меньшие порции с отдельными транзакциями.
5. Вложенные транзакции и savepoints. Laravel поддерживает вложенные DB::transaction, но фактически использует SAVEPOINT, а не настоящие вложенные транзакции (которых нет в SQL-стандарте). Откат внешней транзакции откатит и внутреннюю savepoint, даже если она была «закоммичена». Это особенность, которая может сбить с толку начинающих.
6. RefreshDatabase в тестах и транзакции. Trait RefreshDatabase в тестах оборачивает каждый тест в транзакцию, чтобы откатить состояние БД. Это работает корректно, но создаёт неявную «внешнюю» транзакцию. Все колбэки afterCommit внутри тестируемого кода не сработают, потому что внешняя транзакция никогда не коммитится. Это намеренное поведение, но требует учёта при тестировании логики, зависящей от afterCommit.
7. Несколько соединений с БД. DB::transaction по умолчанию работает с дефолтным соединением. Если в одной операции изменяются записи в разных БД (например, основная и журнал в отдельной БД), стандартная транзакция не охватит обе. Для распределённых сценариев применяются двухфазные коммиты или event-sourcing с компенсирующими действиями.
8. rollBack() и счётчик уровня. При вызове DB::rollBack() внутри ручной транзакции откатывается только текущий уровень вложенности. Если транзакция была вложенной, внешняя продолжает выполняться. Для полного отката используется DB::rollBack(0) с указанием уровня 0.
Relations vs JOIN
Назначение

Eloquent предоставляет два подхода к получению связанных данных: декларативные relations с eager loading (with(), load()) и явные JOIN-запросы через Query Builder (join(), leftJoin()). Подходы решают одну задачу, но имеют разные характеристики по числу SQL-запросов, расходу памяти, читаемости кода и поддерживаемой сложности.

Relations — естественный путь для большинства типовых задач: получить пользователя с его профилем, статью с автором и тегами, заказ с позициями. Каждая выбранная сущность представлена как полноценная модель с relations, accessors, casts. Eager loading устраняет N+1, выполняя по одному дополнительному SELECT на каждую загружаемую relation.

JOIN-запросы становятся предпочтительнее в задачах, требующих фильтрации, сортировки или агрегации по полям связанной таблицы в одном SQL-запросе. Они эффективнее по числу round-trip к БД (один запрос вместо нескольких) и потребляют меньше памяти, но возвращают «плоские» строки без объектной структуры relations. Для read-only выборок с агрегатами и сложными условиями это часто оптимальный выбор.

На практике эти подходы дополняют друг друга: Eloquent с relations покрывает 80% повседневных задач; JOIN применяется на горячих read-only эндпоинтах, отчётах и тяжёлых выборках, где разница в производительности оправдывает потерю удобства.

Перечень характеристик

Сравнительная таблица

КритерийRelations + with()JOIN
Число SQL-запросов2 и более (основной + по одному на каждую relation)1 (все таблицы в одном запросе)
Расход памяти PHPВыше: для каждой связи гидрируются отдельные моделиНиже: одна плоская строка на запись
Возвращаемая структураEloquent-модели с relations, casts, accessorsОбъекты stdClass или массивы со всеми полями в одной строке
Дублирование данныхНет: каждая запись родителя — одна модельЕсть: при один-ко-многим родительские поля дублируются в каждой строке
Фильтрация по полям связанной таблицыЧерез whereHas или has, требует подзапросовПрямой WHERE в одном запросе
Агрегаты по связанной таблицеwithCount, withSum (отдельные подзапросы)Прямой GROUP BY + агрегатные функции
Соблюдение global scopes связанной моделиПрименяются автоматическиНе применяются, необходимо добавлять условия вручную
Soft Deletes на связанной моделиУчитываются автоматическиНе учитываются — нужно явное whereNull(...deleted_at)
Читаемость кода$post->author->name; $post->tags$row->author_name; теги через дополнительную обработку

Когда выбирать Relations

Эталонный сценарий: страница, на которой требуется отобразить родительскую сущность вместе с её связями, без сложной фильтрации или агрегации по этим связям.

// Страница поста: автор, теги, комментарии с авторами комментариев.
// Eager loading исчерпывающе решает задачу: 4 SQL вместо ленивого N+1.
$post = Post::with(['author', 'tags', 'comments.author'])
    ->findOrFail($id);

// Используется в шаблонах естественно:
// {{ $post->author->name }}
// @foreach($post->tags as $tag) ... @endforeach
// @foreach($post->comments as $comment) ... @endforeach

Когда выбирать JOIN

Задачи, где требуется фильтрация/сортировка/агрегация по полям связанной таблицы в едином SQL. Особенно при больших объёмах данных, где минимизация числа round-trip к БД критична.

// 1. Список постов с числом комментариев, отсортированный по автору и числу комментариев.
$report = Post::query()
    ->join('users', 'users.id', '=', 'posts.user_id')
    ->leftJoin('comments', 'comments.post_id', '=', 'posts.id')
    ->select(
        'posts.id',
        'posts.title',
        'users.name as author_name',
    )
    ->selectRaw('COUNT(comments.id) AS comments_count')
    ->groupBy('posts.id', 'posts.title', 'users.name')
    ->having('comments_count', '>', 10)
    ->orderByDesc('comments_count')
    ->orderBy('users.name')
    ->get();
// 2. Поиск по полю связанной модели на больших объёмах.
// Альтернатива whereHas, дающая один запрос вместо подзапроса с EXISTS.
$transactions = Transaction::join('merchants', 'merchants.id', '=', 'transactions.merchant_id')
    ->where('merchants.country_code', 'KZ')
    ->where('transactions.amount', '>', 10000)
    ->select('transactions.*')  // важно, чтобы Eloquent гидрировал только Transaction
    ->get();

Гибридный подход: relations + selectRaw subqueries

Альтернатива чистому JOIN — добавление вычисляемых столбцов в основной запрос через скалярные подзапросы. Сохраняет преимущества Eloquent (модель с relations) и при этом получает агрегированные данные одним SQL.

$posts = Post::query()
    ->with('author')
    ->addSelect([
        'comments_count' => Comment::selectRaw('COUNT(*)')
            ->whereColumn('comments.post_id', 'posts.id'),
        'last_comment_at' => Comment::select('created_at')
            ->whereColumn('comments.post_id', 'posts.id')
            ->latest()
            ->limit(1),
    ])
    ->orderByDesc('comments_count')
    ->paginate(20);

Подробное рассмотрение подзапросов вынесено в подраздел «Subqueries и raw expressions».

Типы JOIN, поддерживаемые Query Builder

МетодSQLНазначение
join('table', 'a', '=', 'b')INNER JOINВозвращает только записи, имеющие совпадение в обеих таблицах.
leftJoin('table', 'a', '=', 'b')LEFT JOINВозвращает все записи левой таблицы и совпадающие из правой (или NULL).
rightJoin('table', 'a', '=', 'b')RIGHT JOINСимметрично leftJoin (редко используется).
crossJoin('table')CROSS JOINДекартово произведение двух таблиц.
joinSub($subquery, 'alias', $closure)JOIN (подзапрос)Присоединяет результат подзапроса как виртуальную таблицу.
joinWhere(...)JOIN ... ON ... WHERE ...Добавляет дополнительные условия в ON.
Сквозной пример: страница отчёта с тысячами записей

Рассмотрим административную страницу-отчёт по транзакциям: за выбранный период необходимо вывести таблицу из 500 строк, в каждой — данные транзакции, имя клиента, страна клиента, статус мерчанта, число связанных событий fraud-check. Сортировка по имени клиента или сумме. Сравним три реализации.

Реализация 1: чистые relations с eager loading. Самая декларативная, но требует множества подгрузок и обработки сортировки на стороне PHP.

$transactions = Transaction::query()
    ->whereBetween('created_at', [$from, $to])
    ->with(['user.country', 'merchant'])
    ->withCount('fraudChecks')
    ->paginate(500);

// Сортировка по client name невозможна — поле в связанной таблице.
// Если требуется такая сортировка, придётся применять JOIN.

Реализация 2: гибридный подход — relations + JOIN для сортировки. Eloquent сохраняется как способ получить модели; JOIN добавляется только для возможности сортировки по полю связанной таблицы.

$transactions = Transaction::query()
    ->join('users', 'users.id', '=', 'transactions.user_id')
    ->whereBetween('transactions.created_at', [$from, $to])
    ->orderBy('users.name')
    ->select('transactions.*')  // гидрируем только Transaction, не смешиваем колонки
    ->with(['user.country', 'merchant'])
    ->withCount('fraudChecks')
    ->paginate(500);

Реализация 3: чистый JOIN на Query Builder. Наиболее эффективная по числу SQL-запросов и памяти, но возвращает плоские объекты — теряется удобство Eloquent.

$rows = DB::table('transactions')
    ->join('users',     'users.id',     '=', 'transactions.user_id')
    ->join('countries', 'countries.id', '=', 'users.country_id')
    ->join('merchants', 'merchants.id', '=', 'transactions.merchant_id')
    ->leftJoin('fraud_checks', 'fraud_checks.transaction_id', '=', 'transactions.id')
    ->whereBetween('transactions.created_at', [$from, $to])
    ->select(
        'transactions.id',
        'transactions.amount',
        'transactions.currency',
        'transactions.created_at',
        'users.name AS user_name',
        'countries.code AS country_code',
        'merchants.name AS merchant_name',
        'merchants.status AS merchant_status',
    )
    ->selectRaw('COUNT(fraud_checks.id) AS fraud_checks_count')
    ->groupBy(
        'transactions.id', 'transactions.amount', 'transactions.currency',
        'transactions.created_at', 'users.name', 'countries.code',
        'merchants.name', 'merchants.status',
    )
    ->orderBy('users.name')
    ->paginate(500);

// Каждая строка — stdClass с плоскими полями: $row->user_name, $row->merchant_status.
// Нет relations, кастов, accessors. Зато 1 SQL вместо 4-5.

Сравнение по количеству SQL-запросов и времени: при 500 строках на странице первая реализация — около 6 запросов (transactions, users, countries, merchants, count fraudChecks, count пагинатора). Вторая — та же стоимость + JOIN. Третья — 2 запроса (основной + count). На больших таблицах разница может составлять десятки миллисекунд.

Рекомендуемая стратегия:

  • Начинайте с relations: код декларативен, развивается без боли, покрывает большинство сценариев.
  • При обнаружении узкого места через профилирование (Telescope, Pulse, EXPLAIN) переходите на гибридный подход с JOIN только там, где нужно.
  • Чистый Query Builder с JOIN применяйте на критичных отчётах и read-only страницах, где разница в производительности оправдывает потерю удобства Eloquent.
Особые случаи и типичные ошибки
1. JOIN с гидрацией Eloquent: дублирование полей. При использовании JOIN внутри Eloquent-выборки колонки связанной таблицы попадают в атрибуты модели. Если в обеих таблицах есть колонки с одинаковыми именами (например, id, created_at), последняя перезаписывает первую. Решение — явный select('posts.*') с указанием конкретных колонок.
2. JOIN игнорирует global scopes. Если связанная модель имеет SoftDeletes или другие глобальные scopes, прямой JOIN их не применит. Soft-deleted записи попадут в результат. Для корректной фильтрации необходимо явно добавить условия (whereNull('users.deleted_at'), where('users.tenant_id', ...)).
3. whereHas vs join: подзапрос против JOIN. whereHas('posts', ...) формирует подзапрос с EXISTS, что в большинстве случаев семантически правильно (возвращает только пользователей, не дублирует строки). JOIN с условием возвращает дубликаты для каждого совпадения и требует DISTINCT или GROUP BY. whereHas предпочтительнее для условий вида «пользователь, имеющий хотя бы один пост».
4. groupBy и совместимость с MySQL ONLY_FULL_GROUP_BY. Современные версии MySQL по умолчанию требуют, чтобы все не-агрегированные колонки в SELECT присутствовали в GROUP BY. Старый код, опирающийся на отключённый ONLY_FULL_GROUP_BY, при переходе на новую версию или на PostgreSQL начнёт падать. Корректное решение — всегда явно перечислять все колонки в GROUP BY.
5. orderBy по полю связанной таблицы без JOIN. Если задача только в сортировке (без фильтрации), и нет необходимости использовать другие поля связанной таблицы, через JOIN это решается с минимальными издержками. Через relations — невозможно без сторонних пакетов или предварительной сортировки на стороне приложения после получения данных.
6. Производительность JOIN на больших таблицах. Без правильных индексов JOIN превращается в полное сканирование одной из таблиц. Перед написанием JOIN-запроса убедитесь, что колонки FK на обеих сторонах индексированы. Используйте EXPLAIN для анализа плана выполнения.
7. JOIN и пагинация с DISTINCT. При JOIN с relation типа hasMany каждая родительская запись дублируется в количестве её дочерних. Подсчёт записей для пагинатора (COUNT) может вернуть некорректное число. Используйте distinct() на построителе или подзапрос-агрегатор.
8. Чтение из плоских строк JOIN. Когда JOIN возвращает stdClass или массив, доступ к полям происходит через атрибуты, имена которых формируются с учётом алиасов. Опечатки и несовпадения не выявляются IDE и проявляются только в runtime. Тесты на ключевые отчёты помогают защититься от этого.
Eloquent\Collection и Support\Collection
Назначение

В Laravel две различных коллекции: базовая Illuminate\Support\Collection и специализированная Illuminate\Database\Eloquent\Collection, наследующая базовую. Базовая — универсальная обёртка над массивом с богатым API (map, filter, reduce, groupBy и около ста других методов). Eloquent-коллекция добавляет к нему методы, специфичные для коллекций моделей: поиск по primary key в памяти, пакетная догрузка relations, обновление коллекции из БД.

Различие важно по двум причинам. Во-первых, методы Eloquent-коллекции (например, $users->load('posts')) работают только на коллекциях моделей; вызов на Support\Collection вернёт ошибку. Во-вторых, операции преобразования (map, filter) на Eloquent-коллекции часто возвращают базовую Support\Collection, а не Eloquent-коллекцию, что меняет доступный API и поведение последующих вызовов.

Понимание, какая коллекция в данный момент в руках, критично при цепочечной обработке выборок: можно неожиданно потерять доступ к Eloquent-методам и получить ошибки или подмену поведения. Это особенно заметно при работе с relations внутри обработчиков коллекции.

Перечень специфичных методов Eloquent\Collection

Поиск и навигация

МетодПоведение
$users->find($id)Возвращает модель с указанным primary key из коллекции в памяти, без обращения к БД. Удобно при необходимости индексированного доступа после выборки.
$users->find([1, 2, 3])Возвращает новую коллекцию с моделями, чьи id указаны в массиве.
$users->findOrFail($id)Аналогично find, но бросает исключение, если модель не найдена в коллекции.
$users->modelKeys()Массив значений primary key всех моделей в коллекции.
$users->contains($id)Проверка, содержит ли коллекция модель с указанным ID.
$users->diff($otherCollection)Возвращает модели текущей коллекции, отсутствующие в переданной.
$users->except($ids) / only($ids)Фильтрация коллекции по списку идентификаторов.

Работа с relations и атрибутами

МетодПоведение
$users->load($relations)Догружает relation для всех моделей коллекции одним пакетным запросом. Решение N+1 после получения коллекции.
$users->loadMissing($relations)То же, но не выполняет запрос, если relation уже загружена.
$users->loadCount($relations)Догружает счётчики ({relation}_count) для всех моделей.
$users->loadAggregate($relation, $column, $function)Произвольный агрегат по relation (sum, avg, min, max).
$users->makeHidden($attributes)Скрывает атрибуты во всех моделях коллекции для последующей сериализации.
$users->makeVisible($attributes)Симметрично makeHidden.
$users->append($attributes)Добавляет accessor-атрибуты в $appends на каждой модели коллекции.

Перечитывание и преобразование

МетодПоведение
$users->fresh($relations)Возвращает новую коллекцию, перечитав каждую модель из БД (опционально с указанными relations).
$users->toQuery()Возвращает построитель запросов с условием WHERE id IN ($modelKeys). Удобно для bulk-обновлений или удалений.
$users->unique() (Eloquent)Уникальность определяется по primary key (для Support\Collection — по значению callback'а).
$users->mapInto($class)Преобразует каждый элемент в экземпляр указанного класса.

Какая коллекция возвращается в каждом случае

ВыражениеТип результата
User::all()Eloquent\Collection
User::get()Eloquent\Collection
User::with('posts')->get()Eloquent\Collection
$user->posts (hasMany)Eloquent\Collection
$user->roles (belongsToMany)Eloquent\Collection
User::pluck('email')Support\Collection (значения, не модели)
User::all()->pluck('email')Support\Collection
$users->map(fn($u) => $u->name)Support\Collection (значения, не модели)
$users->mapInto(UserDto::class)Support\Collection (объекты UserDto)
$users->filter(...)Eloquent\Collection (если все элементы — модели)
collect([1, 2, 3])Support\Collection

Главное правило: операции, возвращающие отфильтрованное подмножество существующих элементов (filter, where, except), сохраняют тип Eloquent\Collection. Операции, преобразующие элементы (map, pluck, mapInto), возвращают Support\Collection, поскольку результат может не быть набором моделей.

Кастомная Eloquent-коллекция

При необходимости можно определить собственный класс коллекции для конкретной модели, расширив Eloquent\Collection и переопределив метод newCollection на модели. Это позволяет добавить доменные методы непосредственно к коллекциям модели.

use Illuminate\Database\Eloquent\Collection;

class PostCollection extends Collection
{
    public function published(): self
    {
        return $this->filter(fn (Post $post) => $post->status === 'published');
    }

    public function groupByMonth(): self
    {
        return $this->groupBy(fn (Post $post) => $post->created_at->format('Y-m'));
    }
}

class Post extends Model
{
    public function newCollection(array $models = []): PostCollection
    {
        return new PostCollection($models);
    }
}

// Теперь все выборки Post возвращают PostCollection с доменными методами.
Post::all()->published()->groupByMonth();
Сквозной пример: пакетная обработка выборки

Рассмотрим типичную задачу: после получения коллекции пользователей выполнить ряд операций — отфильтровать активных, догрузить их подписки и недавние посты, проверить наличие конкретного администратора, обновить статус для группы и удалить устаревших. На этом примере проявляются все ключевые особенности обеих коллекций.

// 1. Получение исходной выборки — Eloquent\Collection.
$users = User::whereBetween('created_at', [now()->subMonth(), now()])->get();

// 2. Фильтрация — результат остаётся Eloquent\Collection.
$activeUsers = $users->filter(fn (User $u) => $u->status === 'active');
// $activeUsers — Eloquent\Collection, методы load/loadCount доступны.

// 3. Пакетная догрузка relations — один SQL вместо N+1.
$activeUsers->load(['subscription', 'posts' => fn ($q) => $q->latest()->limit(5)]);

// 4. Поиск конкретного пользователя в памяти, без запроса к БД.
$admin = $activeUsers->find($adminId);

if ($admin !== null) {
    // Найден без обращения к БД.
}

// 5. Получение списка ID для bulk-операции.
$ids = $activeUsers->modelKeys();
// [1, 5, 12, 23, 41, ...]

// 6. Bulk-обновление через toQuery — один UPDATE для всей коллекции.
$activeUsers->toQuery()->update(['last_seen_at' => now()]);
// SQL: UPDATE users SET last_seen_at = ? WHERE id IN (1, 5, 12, ...)
// События Eloquent не вызываются (это bulk через Query Builder).

// 7. Преобразование в плоский список email — Support\Collection.
$emails = $activeUsers->pluck('email');
// Теперь $emails — Support\Collection, методов load/loadCount нет.

// 8. Преобразование в DTO для API — Support\Collection.
$dtos = $activeUsers->mapInto(UserResponseDto::class);

// 9. Подготовка к сериализации API — скрываем поля во всех моделях коллекции.
$activeUsers->makeHidden(['internal_notes', 'risk_score']);

// 10. Перечитывание из БД с relations.
$refreshed = $activeUsers->fresh(['subscription']);

Пример с кастомной коллекцией для модели Order:

class OrderCollection extends Collection
{
    public function paid(): self
    {
        return $this->filter(fn (Order $o) => $o->paid_at !== null);
    }

    public function totalAmount(): int
    {
        return $this->sum('amount');
    }

    public function byCurrency(): array
    {
        return $this->groupBy('currency')
            ->map->totalAmount()
            ->all();
    }
}

class Order extends Model
{
    public function newCollection(array $models = []): OrderCollection
    {
        return new OrderCollection($models);
    }
}

// Использование
$orders = Order::forUser($user)->get();

echo $orders->paid()->totalAmount();
print_r($orders->byCurrency());
Особые случаи и типичные ошибки
1. map() возвращает Support\Collection. После $users->map(...) результат — Support\Collection даже если каждый возвращённый элемент остаётся моделью. Это значит, что методы вроде $mapped->load('posts') вернут ошибку. Если необходимо сохранить тип, используйте $users->each(...) для модификации существующих моделей или $users->transform(...) для in-place преобразования.
2. $users->find(5) и User::find(5) — разные операции. Первый ищет модель с id=5 в уже загруженной коллекции (в памяти, без SQL); второй выполняет запрос к БД. Путаница приводит к скрытому выполнению лишних SQL-запросов внутри циклов.
3. toQuery() и порядок вызовов. Метод возвращает построитель запросов с условием WHERE id IN ($modelKeys). Если до его вызова на коллекции выполнены filter, map, pluck и она перестала быть Eloquent-коллекцией, toQuery не сработает. Метод доступен только на Eloquent\Collection.
4. load и порядок выполнения. $users->load('posts') модифицирует коллекцию по ссылке: после этого вызова все модели в коллекции имеют загруженную relation. Однако, если коллекция была получена через map и стала Support\Collection, load вызовет ошибку.
5. pluck() и многоуровневые ключи. Метод поддерживает синтаксис pluck('value_column', 'key_column') для построения key-value мапы. Однако значения ключей дублируются: если в коллекции несколько моделей с одинаковым ключом, в результат попадёт только последняя. Для подобных задач лучше использовать keyBy.
6. fresh() и стоимость операции. Метод выполняет SELECT по всем primary key коллекции одним запросом, но возвращает новую коллекцию — старые модели не обновляются. Если необходимо обновить «по месту», используется refresh() на каждой модели, что даёт N запросов.
7. Кастомные коллекции и интерфейсы Laravel. При определении кастомной Eloquent-коллекции необходимо корректно вернуть её тип в методах, возвращающих коллекцию (filter, where, map). Без переопределения они вернут базовый тип. Для типизации возвращаемых значений используется PHPDoc или генериковая типизация.
8. Сериализация коллекций в JSON. Eloquent\Collection при сериализации использует toArray/toJson каждой модели, учитывая $hidden / $visible / $appends. Support\Collection с произвольными элементами сериализует каждый элемент как есть; если элементы — массивы или объекты, поведение зависит от их собственной поддержки JsonSerializable.
Практика — 5 реальных задач

Закрепить материал. Каждая задача — мини-фича на свежем Laravel-проекте. Если все осилишь — Eloquent твой.

1. Многоуровневые комментарии (Polymorphic + nested)

Сделай систему комментариев, где Comment может быть к Post или Photo, а ещё может иметь parent_id (ответ на другой комментарий).

  • Модели: Post, Photo, Comment (с morphTo('commentable') + parent_id)
  • Метод $post->comments возвращает плоский список
  • Метод $post->commentTree() возвращает дерево с replies
  • API endpoint GET /posts/{id}/comments возвращает JSON-дерево
Критерий успеха: на странице с 50 комментариями — не более 3 SQL-запросов (Post, Comments, Users авторов комментариев). Замерь через Telescope.

2. Multi-tenancy через Global Scope

Сделай так, чтобы все модели типа Project, Task, Document автоматически фильтровались по tenant_id текущего юзера.

  • Создай TenantScope implements Scope
  • Middleware определяет tenant из субдомена (acme.app.test → tenant_id 1)
  • Все запросы Project автоматически добавляют WHERE tenant_id = X
  • Artisan-команда tenant:run my:command --tenant=acme для запуска под конкретным тенантом
Критерий успеха: ни один контроллер не упоминает tenant_id явно. Запросы фильтруются автоматически. Юзер тенанта A не видит данные тенанта B даже при попытке прямого Project::find($id).

3. Money value object через кастомный каст

Реализуй Money как value object и кастомный каст к нему.

  • Money хранит amount (int, копейки) + currency (string)
  • В БД хранится в двух колонках: price_amount, price_currency
  • MoneyCast implements CastsAttributes с методами get/set
  • Методы на Money: add(), multiply($factor), format()
Критерий успеха: в контроллере пишется $product->price->add(Money::usd(100)). Никакой математики над сырыми числами.

4. Экспорт 100k записей в CSV — сравнение методов

Сгенерируй 100 000 фейковых юзеров. Сделай 3 реализации экспорта:

  • через cursor()
  • через chunkById(1000)
  • через lazy(1000)

Замерь у каждой:

  • Пиковая память (memory_get_peak_usage())
  • Время выполнения
  • Количество SQL-запросов
Критерий успеха: с memory_limit=128M ни одна реализация не падает. В отчёт включи цифры и сделай вывод какой подход для какой задачи.

5. Observer + queue race condition

Воспроизведи и почини классический баг.

  • OrderObserver на created диспатчит SendInvoice job
  • Job достаёт Order по ID, генерирует PDF, отправляет email
  • Order::create() оборачивается в DB::transaction
  • Иногда (раз в N запусков) job падает Model not found

Почему? Job отправился до commit транзакции, queue worker подхватил быстрее чем БД зафиксировала запись.

Критерий успеха: почини двумя способами — через public $afterCommit = true на job, и через DB::afterCommit() в observer. Напиши тест, который воспроизводит баг (без фикса падает, с фиксом проходит).
Подводные камни (сводная таблица)
КаменьГдеКак избежать
sync([1, 2]) удаляет всё кроме указанногоbelongsToManysyncWithoutDetaching если аддитивно
Polymorphic без morph mapmorphToРегистрировать Relation::enforceMorphMap([...])
insert() минует касты / eventsbulk insertЗнать что events/observers/mutators отключены
Observer на saved + save() внутри = infinite loopobserverssaveQuietly() или withoutEvents()
Mail/queue до commit транзакцииDB transactionsDB::afterCommit() или $afterCommit = true на job
Unique constraint на soft-deleted моделяхsoft deleteRule::unique()->whereNull('deleted_at') + partial index
N+1 в count внутри шаблонаviewswithCount() или loadCount()
chunk() при удалении в процессе пропускает записиchunkИспользовать chunkById()
Зашифрованный каст нельзя в WHEREencrypted castХранить hash отдельно для поиска
Global scope забыли отключить → данные не найденыglobal scopewithoutGlobalScope() в импорте / фоновых задачах
$casts = ['data' => 'array'] мутации не работаютJSON cast'AsArrayObject' вместо 'array' — даст mutability
Accessor с DB-запросом = N+1 в шаблонеaccessorshouldCache() или предзагрузить в контроллере
cursor() + with() не работает вместеcursorИспользовать chunkById() или lazy()
Вопросы для самопроверки

Вопросы уровня middle/senior PHP/Laravel. Попробуй ответить, не подглядывая.

1. В чём разница между with() и load()?

with() — eager load при построении запроса. load() — догрузить relation на уже выбранной коллекции/модели. Оба избавляют от N+1, но load() делает +1 SQL после основного запроса, а with() делает 2 SQL вместе с основным.

2. Как работает belongsToMany под капотом? Какой SQL генерируется attach()?

При вызове $user->roles Laravel JOIN'ит pivot-таблицу с целевой:
SELECT roles.* FROM roles INNER JOIN role_user ON roles.id = role_user.role_id WHERE role_user.user_id = ?.
attach($id) делает INSERT INTO role_user (user_id, role_id) VALUES (?, ?).
detach($id) — DELETE FROM role_user WHERE user_id = ? AND role_id = ?.
sync([1, 2, 3]) — комбинация INSERT и DELETE, чтобы оставить ровно указанные.

3. Как реализовать комментарии для нескольких типов моделей?

Polymorphic relations. Таблица comments с commentable_type + commentable_id. На Comment — morphTo('commentable'). На Post, Photo, Video — morphMany(Comment, 'commentable'). Обязательно зарегистрировать morph map в AppServiceProvider, чтобы избежать боли при ренейме классов.

4. Что делает hasManyThrough и чем отличается от whereHas?

hasManyThrough — «дай мне все Posts через User для Country» — возвращает Post'ы. whereHas — «дай мне User'ов у которых есть Posts» — возвращает User'ов. Первое — про получение «правнуков», второе — про фильтрацию исходной модели.

5. Почему Model::insert() не вызывает observers?

Потому что insert() — это метод Query Builder, а не Eloquent. Он напрямую отправляет SQL без гидрации моделей. События Eloquent (creating, created) кидаются только когда есть инстанс модели. У bulk insert инстансов нет — экономия памяти, но и observers молчат.

6. Как избежать race condition между observer'ом и queued job'ом?

Job отправляется до commit транзакции, queue worker подхватывает быстрее чем БД фиксирует. Решения: DB::afterCommit(fn() => dispatch($job)), или public $afterCommit = true на самом job-классе, или глобально в config/queue.php.

7. Что произойдёт при $user->roles()->sync([])?

Удалятся все связи юзера с ролями. sync() синхронизирует pivot-таблицу с переданным массивом — пустой массив = «оставь ровно ничего».

8. Разница между chunk и chunkById?

chunk использует LIMIT/OFFSET. Опасен, если в процессе удалять/вставлять записи — данные «сдвинутся» и часть пропустится или продублируется. chunkById пагинирует по primary key (WHERE id > last_id) — безопасен при мутации, но требует индексированного автоинкремент-PK.

9. Что такое morph map и зачем он нужен?

Это маппинг короткого имени ('post') на FQCN (App\Models\Post) для polymorphic relations. Без него в БД лежит полный namespace. Если переименуешь класс или сменишь namespace — связи сломаются. Регистрируй через Relation::enforceMorphMap([...]) в AppServiceProvider.

10. Как реализовать многотенантность через global scope?

Создать TenantScope implements Scope с методом apply(Builder $q, Model $m), который добавляет WHERE tenant_id = auth()->user()->tenant_id. Подключить через static::addGlobalScope(new TenantScope) в booted() модели. Помнить про withoutGlobalScope() для админских / фоновых запросов.

Бонус-вопрос: назови 3 способа решить N+1. Ответ: with() при выборке, load() после, withCount()/withSum() для агрегатов, preventLazyLoading() в dev для отлова.