Eloquent ORM

Модели, отношения, N+1, скоупы, аксессоры, события моделей.

Junior Как устроена Eloquent-модель? Основные свойства и конвенции.
class Post extends Model
{
    protected $table = 'posts';            // по умолчанию — snake_case множественное от класса
    protected $primaryKey = 'id';          // по умолчанию id, автоинкремент
    public $timestamps = true;             // created_at / updated_at

    protected $fillable = ['title', 'body'];     // разрешённые для mass-assignment
    protected $guarded = ['id'];                  // наоборот — запрещённые (остальные можно)

    protected $hidden = ['secret_token'];  // скрыть из toArray()/JSON
    protected $casts = [                   // приведение типов
        'published_at' => 'datetime',
        'meta' => 'array',
        'is_admin' => 'boolean',
    ];
}

Основные операции:

Post::all(); Post::find(1); Post::findOrFail(1);
Post::where('published', true)->orderByDesc('id')->get();
$post = Post::create(['title' => 'Hi']);        // требует $fillable
$post->update(['title' => 'New']);
$post->delete();
Post::firstOrCreate(['slug' => 'x'], ['title' => 'X']);
Post::updateOrCreate(['id' => 1], ['title' => 'Y']);

findOrFail / firstOrFail кидают ModelNotFoundException → 404 в HTTP-контексте.

Middle Перечислите типы отношений Eloquent с примерами.
// Один к одному
public function profile() { return $this->hasOne(Profile::class); }

// Один ко многим (и обратное)
public function posts() { return $this->hasMany(Post::class); }
public function user()  { return $this->belongsTo(User::class); }

// Многие ко многим — через pivot-таблицу post_tag
public function tags()
{
    return $this->belongsToMany(Tag::class)
        ->withPivot('sort')       // доп. колонки pivot
        ->withTimestamps();
}
$post->tags()->attach($tagId, ['sort' => 1]);
$post->tags()->detach($tagId);
$post->tags()->sync([1, 2, 3]);           // привести к точному набору
$post->tags()->syncWithoutDetaching([4]);

// Сквозные и полиморфные
public function comments() { return $this->morphMany(Comment::class, 'commentable'); }
public function posts()    { return $this->hasManyThrough(Post::class, Country::class); } // hasManyThrough

Ключи по конвенции: user_id, commentable_id + commentable_type. Проверка на собеседовании: уметь нарисовать схему таблиц для belongsToMany и morphMany.

Middle Что такое проблема N+1 и как её решают в Laravel?

Классика собеседований. Ленивая загрузка отношений в цикле порождает запрос на каждую итерацию:

$posts = Post::all();              // 1 запрос
foreach ($posts as $post) {
    echo $post->user->name;        // +N запросов — по одному на пост!
}

Решение — eager loading:

Post::with('user')->get();                     // 2 запроса: posts + users WHERE id IN (...)
Post::with(['user', 'comments.author']);       // вложенные
Post::with(['comments' => fn ($q) => $q->latest()->limit(5)]); // с ограничениями
Post::withCount('comments');                   // comments_count без загрузки моделей
Post::withSum('orders', 'total');

Дополнительно:

  • $post->load('user') — eager loading для уже полученной коллекции;
  • Model::preventLazyLoading() — в dev-окружении запретить ленивую загрузку, чтобы N+1 падала ошибкой:
Model::preventLazyLoading(! app()->isProduction());
  • инструменты поиска N+1: Laravel Telescope, Debugbar, DB::listen().
Middle Что такое Query Scopes (локальные и глобальные)?

Локальный скоуп — переиспользуемое ограничение запроса:

class Post extends Model
{
    public function scopePublished(Builder $query): void
    {
        $query->where('published', true)->whereNotNull('published_at');
    }

    public function scopeOfAuthor(Builder $query, User $user): void  // с параметром
    {
        $query->whereBelongsTo($user, 'author');
    }
}

Post::published()->ofAuthor($user)->get();

Глобальный скоуп — применяется ко ВСЕМ запросам модели:

class TenantScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        $builder->where('tenant_id', auth()->user()->tenant_id);
    }
}

protected static function booted(): void
{
    static::addGlobalScope(new TenantScope);
    // или анонимно:
    static::addGlobalScope('active', fn (Builder $q) => $q->where('active', true));
}

Снять глобальный скоуп: Post::withoutGlobalScope(TenantScope::class)->get(). SoftDeletes — тоже глобальный скоуп; withTrashed() его отменяет.

Middle Аксессоры и мутаторы: как переопределять чтение и запись атрибутов?

Современный синтаксис (Laravel 9+) — единый метод Attribute:

use Illuminate\Database\Eloquent\Casts\Attribute;

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

protected function title(): Attribute
{
    return Attribute::make(
        get: fn (string $value) => ucfirst($value),
        set: fn (string $value) => strtolower($value),   // пишется в БД в lowercase
    );
}

$post->full_name;   // аксессор

Старый синтаксис (встречается в легаси): getFullNameAttribute() / setTitleAttribute().

Когда что использовать:

  • аксессор/мутатор — сложная логика преобразования;
  • $casts — простое приведение типов (datetime, array, encrypted);
  • Attribute::shouldCache() — если аксессор дорогой и возвращает объект.

Осторожно: мутатор на set срабатывает только через модель, массовый DB::table()->update() его обходит.

Middle Как работают Soft Deletes и события моделей?

Soft Deletes — запись не удаляется, а помечается deleted_at:

class Post extends Model { use SoftDeletes; }
// + $table->softDeletes(); в миграции

$post->delete();                    // deleted_at = now()
Post::withTrashed()->get();         // включая удалённые
Post::onlyTrashed()->get();
$post->restore();
$post->forceDelete();               // удалить физически

Все запросы по умолчанию исключают «удалённые» (глобальный скоуп). Уникальные индексы с soft deletes — известная засада: unique(email) мешает создать запись после удаления — решают составным индексом с deleted_at или генерацией значения при удалении.

События моделей: creating/created, updating/updated, saving/saved, deleting/deleted, restoring/restored, retrieved.

protected static function booted(): void
{
    static::creating(fn (Post $post) => $post->slug = Str::slug($post->title));
}
// или через Observer: php artisan make:observer PostObserver --model=Post

Важно: массовые update()/delete() через Query Builder события не вызывают — события работают на уровне экземпляров моделей.

Middle Eloquent Collections: чем коллекция Laravel отличается от массива и какие методы полезны?

get() возвращает Illuminate\Database\Eloquent\Collection (наследует базовую Collection) — обёртку с десятками методов в функциональном стиле:

$users->filter(fn ($u) => $u->active)->map(fn ($u) => $u->name)->values();
$users->pluck('email');                 // колонку в коллекцию
$users->sortByDesc('created_at');
$users->groupBy('role');
$users->sum('balance'); $users->avg(...); $users->count();
$users->firstWhere('id', 5);
$users->contains($user);  $users->find(3);      // по первичному ключу
$users->fresh();                          // перечитать из БД
$users->toArray(); $users->toJson();

Ключевой момент для собеседования — разница между where() у Collection и у Builder: первый фильтрует уже загруженное в памяти, второй строит SQL. Загрузить всё и фильтровать коллекцией — антипаттерн.

Lazy-коллекции (cursor(), lazy()) — для больших выборок с обработкой по одной записи, не загружая всё в память. Higher-order сообщения: $users->each->markAsActive().

Middle Как сериализуются модели в API: $hidden, $appends, API Resources?

Модели умеют toArray()/toJson() — при return $post из роута Laravel сам сериализует в JSON.

protected $hidden = ['password', 'remember_token'];   // скрыть
protected $visible = ['id', 'name'];                  // белый список
protected $appends = ['full_name'];                   // добавить аксессор в вывод

Для реальных API — API Resources (трансформация + мета-данные):

php artisan make:resource UserResource
class UserResource extends JsonResource
{
    public function toArray($request): array
    {
        return [
            'id' => $this->id,
            'name' => $this->name,
            'email' => $this->when($request->user()->isAdmin(), $this->email),
            'posts_count' => $this->whenCounted('posts'),
            'created_at' => $this->created_at->toIso8601String(),
        ];
    }
}

return new UserResource($user);
return UserResource::collection(User::paginate());

Преимущества: единый контракт ответа, условные поля, вложенные ресурсы (new PostResource($this->whenLoaded('post'))), автоматическая обёртка data.