Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

Middle: питання на співбесіді з теми «Eloquent»

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

14 питань

N+1 виникає, коли ви завантажуєте N моделей одним запитом, а потім у циклі звертаєтесь до їхнього зв'язку - це генерує ще N запитів.

$posts = Post::all(); // 1 запит
foreach ($posts as $post) {
    echo $post->author->name; // +1 запит на кожен пост → N запитів
}

Рішення - eager loading через with():

$posts = Post::with('author')->get(); // лише 2 запити загалом

Вкладені та умовні зв'язки:

Post::with(['author', 'comments.user'])->get();        // вкладений eager
Post::with(['comments' => fn ($q) => $q->latest()])->get(); // умовний

Лічильники без завантаження зв'язку - withCount() (без N+1 і без вантаження самих рядків):

$posts = Post::withCount('comments')->get(); // доступ через $post->comments_count

Виявлення: Model::preventLazyLoading(! app()->isProduction()) у boot() кидає виняток на ледачих завантаженнях поза продакшеном; також допомагають Telescope і Debugbar.

Докладніше в документації: Eloquent: Eager Loading

Поліморфний зв'язок дозволяє моделі належати кільком різним типам моделей через один зв'язок.

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

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

Таблиця comments має commentable_id + commentable_type. Тож Comment може належати і Post, і Video без окремих таблиць. Бувають також many-to-many поліморфні зв'язки (morphToMany), напр. теги.

Докладніше в документації: Поліморфні зв’язки

Observer групує слухачів подій моделі (creating, created, updating, saved, deleting тощо) в один клас - замість роздування boot() моделі.

class PostObserver
{
    public function creating(Post $post): void
    {
        $post->slug = Str::slug($post->title);
    }

    public function deleted(Post $post): void
    {
        $post->image()->delete();
    }
}

Реєстрація - атрибутом #[ObservedBy(PostObserver::class)] на моделі або в Service Provider. Зручно для генерації slug, очищення пов'язаних ресурсів, аудиту.

Докладніше в документації: Observers

Scopes інкапсулюють часто вживані умови запитів.

Local scope - викликається вручну:

public function scopePublished(Builder $query): Builder
{
    return $query->where('is_published', true);
}

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

Global scope - застосовується автоматично до всіх запитів моделі:

#[ScopedBy([TenantScope::class])]
class Invoice extends Model {}

SoftDeletes - приклад глобального scope (автоматично додає where deleted_at is null). Глобальний scope можна обійти через withoutGlobalScope().

Докладніше в документації: Query Scopes

Chunking обробляє великі набори даних порціями, щоб не тримати всі рядки в пам'яті одразу.

Post::chunk(200, function ($posts) {
    foreach ($posts as $post) { /* ... */ }
});
  • chunkById(200, ...) - безпечніший, коли під час обробки змінюються записи (нумерує за id, а не за offset).
  • lazy() / cursor() - повертають LazyCollection: ще менше пам'яті, але один активний запит.

Без chunking Post::all() на мільйонній таблиці впаде з браку пам'яті.

Докладніше в документації: Eloquent: chunk

Custom Cast інкапсулює логіку перетворення атрибута між форматом БД та об'єктом PHP.

class Money implements CastsAttributes
{
    public function get($model, $key, $value, $attributes): MoneyValue
    {
        return new MoneyValue($value); // з БД → Value Object
    }

    public function set($model, $key, $value, $attributes): array
    {
        return ['price' => $value->cents]; // VO → у БД
    }
}

protected $casts = ['price' => Money::class];

Застосування: робота з Value Objects, шифрування полів, JSON-структури. Вбудовані касти: array, encrypted, datetime, enum-класи, AsCollection.

Докладніше в документації: Custom Casts

Query Builder Eloquent
Повертає stdClass / масиви моделі
Рівень близько до SQL ORM поверх QB
Зв'язки, події, касти ні так
Оверхед мінімальний невеликий
// Query Builder
DB::table('users')->where('active', 1)->get();

// Eloquent
User::where('active', 1)->get();

Eloquent виразніший і зручніший для бізнес-логіки. Для важких масових операцій (мільйони рядків, складні агрегати) інколи свідомо обирають чистий Query Builder заради швидкості та меншого споживання пам'яті.

Докладніше в документації: Query Builder

У belongsToMany проміжна (pivot) таблиця зберігає зв'язки. Додаткові стовпці на ній оголошують через withPivot():

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

Доступ і керування:

$user->roles->first()->pivot->assigned_at;  // читання pivot-даних

$user->roles()->attach($roleId, ['is_primary' => true]); // додати
$user->roles()->detach($roleId); // прибрати
$user->roles()->sync([1, 2, 3]);// привести до набору
$user->roles()->updateExistingPivot($roleId, [...]); // оновити pivot

Для окремої моделі pivot використовують ->using(RoleUser::class).

Докладніше в документації: Many To Many зв’язки

Через withCount() і схожі методи - вони додають до основного запиту підзапит, і число приходить окремим атрибутом.

$posts = Post::withCount('comments')->get();

foreach ($posts as $post) {
    echo $post->comments_count;   // без завантаження коментарів
}

Чому не $post->comments->count(): це завантажить усі коментарі кожного поста в пам'ять, а без with() ще й дасть N+1 запитів - лише заради одного числа.

Родина методів:

Post::withCount(['comments', 'comments as approved_count' => fn ($q) => $q->where('approved', true)])
    ->withExists('likes')          // likes_exists: true / false
    ->withSum('orders', 'total')   // orders_sum_total
    ->withMax('comments', 'created_at')
    ->get();

Фільтр, а не підрахунок, - це has() і whereHas():

Post::has('comments', '>=', 10)->get();
Post::whereHas('comments', fn ($q) => $q->where('approved', true))->get();

Для вже завантаженої колекції є loadCount(). А якщо лічильник показують на кожній сторінці й рахувати дорого, його зберігають у колонці (comments_count) і оновлюють подіями - денормалізація в обмін на швидкість читання.

Докладніше в документації: Підрахунок пов'язаних моделей

Через hasOne(...)->latestOfMany() - один запис із багатьох, вибраний за правилом.

class User extends Model
{
    public function latestOrder(): HasOne
    {
        return $this->hasOne(Order::class)->latestOfMany();
    }

    public function largestOrder(): HasOne
    {
        return $this->hasOne(Order::class)->ofMany('total', 'max');
    }
}

$users = User::with('latestOrder')->get();

Чому не $user->orders()->latest()->first(): у циклі по користувачах це запит на кожного - N+1. А latestOfMany() - справжній зв'язок: його можна жадібно завантажити одним запитом для всіх користувачів, і Eloquent сам збудує підзапит з MAX(id) на кожного.

Що ще вміє:

  • oldestOfMany() - перший запис;
  • складніші правила: «найновіша опублікована ціна», де спершу береться максимум дати, а нічия розв'язується за id:
public function currentPricing(): HasOne
{
    return $this->hasOne(Price::class)->ofMany(
        ['published_at' => 'max', 'id' => 'max'],
        fn ($query) => $query->where('published_at', '<', now()),
    );
}

Існує й перетворення готового hasMany: $this->orders()->one()->latestOfMany().

Докладніше в документації: Has one of many

hasManyThrough дістає записи через проміжну модель - коли прямого зовнішнього ключа немає.

Приклад: проєкт має середовища, середовища мають деплої. Потрібні всі деплої проєкту.

projects        environments          deployments
id              id, project_id        id, environment_id
class Project extends Model
{
    public function deployments(): HasManyThrough
    {
        return $this->hasManyThrough(Deployment::class, Environment::class);
    }
}

$project->deployments;   // один запит з JOIN через environments

Чому не вручну: $project->environments->flatMap->deployments завантажить усі середовища, а потім деплої окремими запитами. Зв'язок робить один запит і, як будь-який зв'язок, підтримує with(), withCount() і whereHas().

Порядок ключів, якщо імена нестандартні, - найчастіше джерело плутанини:

return $this->hasManyThrough(
    Deployment::class,
    Environment::class,
    'project_id',      // ключ у environments
    'environment_id',  // ключ у deployments
    'id',              // локальний ключ у projects
    'id',              // локальний ключ у environments
);

Читабельніша альтернатива - через уже наявні зв'язки: $this->through('environments')->has('deployments'). Для одного запису є hasOneThrough.

Докладніше в документації: Has many through

Усі три способи записують дані, але працюють на різних рівнях.

updateOrCreate - модель за моделлю:

Price::updateOrCreate(
    ['product_id' => 42, 'currency' => 'UAH'],
    ['amount' => 1999],
);

Два запити (пошук і INSERT чи UPDATE), повний життєвий цикл моделі: події saving, created/updated, спостерігачі, мутатори й касти. Для тисячі рядків - тисячі пар запитів.

upsert - один запит на всю партію:

Price::upsert(
    [
        ['product_id' => 42, 'currency' => 'UAH', 'amount' => 1999],
        ['product_id' => 43, 'currency' => 'UAH', 'amount' => 2499],
    ],
    uniqueBy: ['product_id', 'currency'],
    update: ['amount'],
);

Перетворюється на INSERT ... ON CONFLICT (...) DO UPDATE у PostgreSQL чи INSERT ... ON DUPLICATE KEY UPDATE у MySQL. Eloquent додає created_at і updated_at.

insert - масове вставлення без перевірки існування; при дублікаті - помилка унікальності. insertOrIgnore - пропускає конфлікти мовчки.

Порівняння:

updateOrCreate upsert insert
запитів на N рядків ~2N 1 1
події моделі й спостерігачі так ні ні
касти й мутатори так ні (сирі значення) ні
timestamps так так ні
атомарність між процесами ні (гонка між SELECT і INSERT) так так

Що важливо знати про upsert:

  • колонки uniqueBy мають бути покриті первинним ключем чи унікальним індексом - на ньому база визначає конфлікт. Без індексу в PostgreSQL запит падає;
  • MySQL ігнорує uniqueBy і використовує будь-який унікальний індекс таблиці - результат може здивувати, якщо їх кілька;
  • події не спрацьовують: спостерігачі, що оновлюють кеш чи пошуковий індекс, не дізнаються про зміни - це треба зробити окремо;
  • касти не застосовуються: JSON-колонку треба передати вже закодованим рядком, enum - значенням.

Гонка в updateOrCreate: два паралельні запити можуть обидва не знайти запис і обидва спробувати вставити - один отримає помилку унікальності. upsert такої проблеми не має, бо конфлікт розв'язує сама база.

Коли що: одиничне збереження з бізнес-логікою в подіях - updateOrCreate; імпорт, синхронізація цін, лічильники - upsert.

Докладніше в документації: Eloquent: upsert

Багато таблиць ростуть безкінечно: журнали подій, прочитані сповіщення, прострочені токени, м'яко видалені записи. Laravel дає вбудований механізм їх періодично прибирати.

Prunable - описати, які записи застаріли:

use Illuminate\Database\Eloquent\Prunable;

class LoginAttempt extends Model
{
    use Prunable;

    public function prunable(): Builder
    {
        return static::where('created_at', '<=', now()->subMonth());
    }

    protected function pruning(): void
    {
        // перед видаленням кожної моделі: прибрати пов'язані файли тощо
    }
}

Запуск за розкладом:

// routes/console.php
Schedule::command('model:prune')->daily();

Команда знаходить усі моделі з трейтами Prunable / MassPrunable у app/Models і видаляє записи, які повертає prunable().

php artisan model:prune --pretend    # скільки буде видалено, без видалення
php artisan model:prune --model="App\Models\LoginAttempt"

Prunable проти MassPrunable:

Prunable MassPrunable
як видаляє завантажує моделі порціями й викликає delete() для кожної один DELETE за запитом (порціями)
події deleting/deleted, спостерігачі так ні
хук pruning() так ні
швидкість на мільйонах рядків повільно швидко

М'яке видалення: якщо модель використовує SoftDeletes, Prunable видаляє записи остаточно (forceDelete). Типова схема - м'яко видалені записи старші за 30 днів прибирати назавжди.

Що важливо:

  • індекс на колонці умови (created_at) - інакше щоденне очищення читає всю таблицю;
  • перший запуск на таблиці з роками даних може видалити мільйони рядків і довго тримати блокування - краще спершу почистити вручну порціями, а потім ввімкнути розклад;
  • MassPrunable не викликає подій - пов'язані файли, кеш, пошуковий індекс не очищаються автоматично;
  • політика зберігання даних (скільки зберігати журнали, персональні дані) - це юридичне питання, а prunable() - місце, де вона виконується в коді;
  • тестування: у тесті можна створити старі й нові записи фабрикою, викликати $this->artisan('model:prune') і перевірити, що лишилися лише нові.

Докладніше в документації: Eloquent: очищення моделей

Події життєвого циклу моделі:

Операція Події по порядку
створення (save нової моделі) saving → creating → INSERT → created → saved
оновлення (save існуючої) saving → updating → UPDATE → updated → saved
видалення deleting → DELETE → deleted (з SoftDeletes ще trashed)
відновлення (restore) restoring → restored (з проміжними saving/updating)
остаточне видалення forceDeleting → forceDeleted
завантаження з бази retrieved
replicate() replicating

Події «до» (-ing) можуть скасувати операцію: якщо слухач поверне false, запис не відбудеться, а save() поверне false.

saving проти creating/updating: saving - для логіки, однакової при створенні й оновленні (нормалізація, генерація slug); creating - лише для нових записів (встановити автора).

Оновлення без змін не спричиняє updating/updated: якщо жоден атрибут не змінився (isDirty() порожній), запит не виконується. Але saving і saved спрацьовують.

Що НЕ запускає події:

Post::where('status', 'draft')->update(['status' => 'archived']);   // масове оновлення
Post::where('created_at', '<', $date)->delete();                   // масове видалення
Post::insert([...]);
Post::upsert([...], ...);

Запит іде напряму в базу, моделі не завантажуються, тож спостерігачі не дізнаються про зміни. Це часте джерело багів: «кеш оновлюється при редагуванні, але не при масовій архівації».

Зберегти без подій:

$post->saveQuietly();
$post->updateQuietly(['views' => $post->views + 1]);
$post->deleteQuietly();

Post::withoutEvents(function () {
    // усі операції всередині - без подій
});

Корисно для технічних оновлень (лічильники переглядів), міграцій даних і сидерів.

Події й транзакції: подія created спрацьовує до коміту транзакції. Якщо слухач ставить завдання в чергу, воркер може почати його раніше, ніж дані стануть видимими, або транзакція відкотиться після відправленого листа. Рішення - спостерігач з інтерфейсом ShouldHandleEventsAfterCommit чи afterCommit для завдань.

Порада: логіку, без якої дані некоректні, краще тримати явно в сервісі чи дії, а події - для побічних ефектів (кеш, індекс, аудит). Інакше поведінка залежить від того, яким методом збережено модель.

Докладніше в документації: Eloquent: події