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

Питання на співбесіді: Eloquent

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

31 питань

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: події

Класична гонка: два процеси читають баланс 100, кожен додає 50, обидва пишуть 150 - одне списання зникло. Читання й запис мають бути однією неподільною операцією.

Атомарний вираз - найдешевше, коли значення рахується з поточного:

Account::whereKey($id)->increment('balance', 50);
// UPDATE accounts SET balance = balance + 50 WHERE id = ?

База сама рахує нове значення, читати наперед не потрібно.

Песимістичне блокування - коли між читанням і записом є логіка:

DB::transaction(function () use ($id) {
    $account = Account::whereKey($id)->lockForUpdate()->first();

    if ($account->balance < 50) {
        throw new InsufficientFunds;
    }

    $account->decrement('balance', 50);
});

lockForUpdate() тримає рядок до кінця транзакції - інші процеси чекають. Обовʼязково всередині DB::transaction(), інакше блокування знімається одразу. Є ще sharedLock() - дозволяє читати, забороняє змінювати.

Оптимістичне блокування - без блокувань, через версію рядка:

$updated = Account::whereKey($id)
    ->where('version', $account->version)
    ->update(['balance' => $new, 'version' => $account->version + 1]);

if ($updated === 0) {
    // хтось випередив - перечитати й повторити
}

Дешевше під навантаженням, але вимагає обробки повтору.

Поза базою - Cache::lock() для операцій, що зачіпають не тільки БД:

Cache::lock('import:'.$id, 10)->block(5, function () {
    // виконується лише в одному процесі
});

Що обирати: просте збільшення - атомарний вираз; логіка між читанням і записом - lockForUpdate(); висока конкуренція за рідкісні конфлікти - оптимістичне.

Докладніше в документації: Запити до бази даних

Глобальний скоп додає умову до всіх запитів моделі автоматично. SoftDeletes - саме такий скоп: він дописує where deleted_at is null, і тому видалені записи не з'являються у вибірках.

Свій скоп через атрибут:

use Illuminate\Database\Eloquent\Attributes\ScopedBy;

#[ScopedBy(PublishedScope::class)]
class Post extends Model
{
}

class PublishedScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        $builder->where('is_published', true);
    }
}

Зняти на один запит:

Post::withoutGlobalScope(PublishedScope::class)->get();
Post::withoutGlobalScopes()->get();

У чому небезпека. Умова застосовується там, де її ніхто не бачить у коді виклику:

  • Адмінка показує не все. Редактор відкриває список і не бачить чернеток, бо скоп мовчки їх відрізав. Помилка виглядає як зникнення даних.
  • Підрахунки брешуть. Post::count() рахує лише опубліковані, і звіт розходиться з базою без видимої причини.
  • Зв'язки теж під скопом. $user->posts віддає відфільтроване, і зовні це не помітно.
  • updateOrCreate може створити дубль. Пошук не знаходить існуючий запис, бо той відрізаний скопом, і замість оновлення відбувається вставка.

Практичне правило: глобальний скоп доречний, коли умова справді інваріант для всієї моделі - як SoftDeletes чи розділення по тенантах. Для «зазвичай потрібні лише опубліковані» краще локальний скоп: він явний у місці виклику.

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

Post::published()->get();   // видно, що фільтр застосовано

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

Спершу треба побачити самі запити, а не здогадуватися.

Подивитися, що виконується:

// AppServiceProvider::boot()
DB::listen(function ($query) {
    if ($query->time > 100) {
        Log::warning('Slow query', ['sql' => $query->sql, 'ms' => $query->time]);
    }
});

Разово по одному запиту допомагає ->toSql() і ->dd(). У розробці - Telescope, Debugbar, Clockwork.

Прочитати план виконання. Це головний інструмент, бо він показує, чи використано індекс:

Post::where('is_published', true)->orderByDesc('published_at')->explain()->dd();

У PostgreSQL шукайте Seq Scan на великій таблиці - це повний перебір; у MySQL - type: ALL і rows, близьке до розміру таблиці.

Найчастіші причини:

  • Немає індексу на колонці з where чи order by. Для складених умов порядок колонок в індексі має значення: індекс (is_published, published_at) працює для фільтра за is_published із сортуванням, а (published_at, is_published) - ні.
  • Функція на колонці вбиває індекс: whereRaw('lower(email) = ?') змусить перебір, поки немає функціонального індексу.
  • LIKE '%текст%' індексом не користується - для пошуку потрібен повнотекстовий індекс або окремий рушій.
  • OFFSET на глибоких сторінках: offset 100000 змушує базу відкинути сто тисяч рядків. Рятує курсорна пагінація - cursorPaginate().

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

Докладніше в документації: База даних

Обидва фільтрують за пов'язаною таблицею, але роблять це по-різному.

whereHas() будує підзапит EXISTS:

$posts = Post::whereHas('comments', function ($query) {
    $query->where('is_approved', true);
})->get();

Модель повертається одна й без дублікатів, зв'язок не витягується. Для простої перевірки «чи є хоч один» є коротший has('comments'), а для кількості - has('comments', '>=', 3).

join() зшиває таблиці в одному запиті:

$posts = Post::join('comments', 'comments.post_id', '=', 'posts.id')
    ->where('comments.is_approved', true)
    ->select('posts.*')
    ->distinct()
    ->get();

Різниця, яка вирішує:

  • join множить рядки: пост із десятьма коментарями повернеться десять разів, тому потрібен distinct() або groupBy - і разом з ними падає перевага в швидкості.
  • join дає доступ до колонок другої таблиці - сортувати чи вибирати за ними можна тільки так.
  • whereHas читається ближче до наміру й не ламає зв'язки моделі.

Про продуктивність. Поширене «join завжди швидший» невірне: сучасні планувальники виконують EXISTS ефективно й часто зупиняються на першому збігу, тоді як join матеріалізує всі пари. Різниця залежить від індексів і селективності - міряйте EXPLAIN, а не вгадуйте.

Практичне правило: фільтруєте за наявністю - whereHas; потрібні колонки пов'язаної таблиці для вибірки чи сортування - join.

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

Обмеженням у with() - з Laravel 11 limit() усередині працює для кожного батька окремо.

$posts = Post::with([
    'comments' => fn ($query) => $query->latest()->limit(3),
])->paginate(20);

Чому це не було очевидно: жадібне завантаження - це один запит WHERE post_id IN (...) для всіх постів. Звичайний LIMIT 3 обрізав би весь результат до трьох коментарів на всю сторінку. Тепер Eloquent будує запит з віконною функцією (ROW_NUMBER() OVER (PARTITION BY post_id ...)), і кожен пост отримує свої три. У старіших версіях для цього брали пакет eager-limit або окремий запит на кожен пост.

Що ще варто обмежувати в жадібному завантаженні:

  • колонки - with('author:id,name'), обов'язково разом з ключем зв'язку;
  • умову - лише схвалені коментарі, лише активні товари.

Альтернативи, коли кількість потрібна в іншій формі:

  • лише один запис - зв'язок latestOfMany();
  • лише число - withCount();
  • дуже великі вкладені дані - окремий ендпойнт з пагінацією, а не все одразу.

Віконні функції потрібні від бази: MySQL 8+, PostgreSQL, SQLite 3.25+.

Докладніше в документації: Обмеження жадібного завантаження

Змусити застосунок падати на ліниве завантаження під час розробки й тестів - тоді N+1 не доходить до рев'ю.

// AppServiceProvider::boot()
Model::preventLazyLoading(! app()->isProduction());

Тепер $post->author без попереднього with('author') у локальному оточенні й тестах кидає LazyLoadingViolationException. Тести, що проходять сторінку, ловлять пропущені with() автоматично.

У продакшені не падати, а повідомляти:

Model::handleLazyLoadingViolationUsing(function (Model $model, string $relation) {
    Log::warning('Lazy loading', ['model' => $model::class, 'relation' => $relation]);
});

Model::shouldBeStrict() вмикає разом три перевірки: ліниве завантаження, мовчазне відкидання атрибутів поза $fillable і звернення до відсутніх атрибутів.

Інші інструменти:

  • Model::automaticallyEagerLoadRelationships() - Laravel сам довантажить зв'язок для всієї колекції, щойно звернулися до нього в однієї моделі. Зручна страховка, але приховує, які дані сторінка насправді тягне;
  • chaperone() на hasMany - дочірні моделі отримують посилання на батька без зайвого запиту ($comment->post у циклі);
  • лічильник запитів у тестах: перевірка, що сторінка робить не більше N запитів незалежно від кількості записів.

Пастка: ліниве завантаження в Blade-компонентах і вкладених Livewire-компонентах ховається найкраще - саме там ця заборона окупається.

Докладніше в документації: Заборона лінивого завантаження

Eloquent за замовчуванням поблажливий: багато помилок не падають, а мовчки дають неправильний результат. Model::shouldBeStrict() вмикає три перевірки одночасно:

// AppServiceProvider::boot()
Model::shouldBeStrict(! $this->app->isProduction());

1. preventLazyLoading - заборона лінивого завантаження зв'язків:

$posts = Post::all();
foreach ($posts as $post) {
    $post->author->name;   // LazyLoadingViolationException
}

Ловить N+1 у момент написання коду, а не після скарг на повільну сторінку. Виправлення - Post::with('author').

2. preventSilentlyDiscardingAttributes - помилка при масовому заповненні полями, яких немає в $fillable:

User::create(['name' => 'Olena', 'is_admin' => true]);   // MassAssignmentException

Без суворого режиму is_admin мовчки відкидається. Звучить безпечно, але це й джерело багів: додали поле у форму, забули в $fillable - дані не зберігаються, і ніхто не розуміє чому.

3. preventAccessingMissingAttributes - помилка при зверненні до атрибута, якого немає в моделі:

$user = User::select('id', 'name')->first();
$user->email;   // MissingAttributeException замість тихого null

Ловить запити з неповним select() і опечатки в назвах атрибутів.

Чому не на продакшені: виняток у лінивому завантаженні ламає сторінку для користувача там, де раніше вона просто працювала повільніше. Тому типова схема - суворо в розробці й тестах, м'яко на продакшені з логуванням:

Model::preventLazyLoading();

Model::handleLazyLoadingViolationUsing(function (Model $model, string $relation): void {
    if (app()->isProduction()) {
        Log::warning('Lazy loading', ['model' => $model::class, 'relation' => $relation]);

        return;
    }

    throw new LazyLoadingViolationException($model, $relation);
});

Нюанси:

  • одна модель - лінивий виклик зв'язку для моделі, отриманої не в колекції, не вважається порушенням (N+1 там неможливий);
  • Model::automaticallyEagerLoadRelationships() - альтернативний підхід: Eloquent сам довантажує зв'язок для всієї колекції при першому зверненні;
  • увімкнення на старому проєкті покаже десятки порушень - вмикайте поступово й лагодьте, інакше команда просто вимкне перевірку;
  • тести - найкраще місце для суворого режиму: порушення в тестах ламають CI ще до рев'ю.

Докладніше в документації: Eloquent: суворий режим