Питання на співбесіді: 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 заради швидкості та меншого споживання пам'яті.
У 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).
Через 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().
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.
Усі три способи записують дані, але працюють на різних рівнях.
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.
Багато таблиць ростуть безкінечно: журнали подій, прочитані сповіщення, прострочені токени, м'яко видалені записи. 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')і перевірити, що лишилися лише нові.
Події життєвого циклу моделі:
| Операція | Події по порядку |
|---|---|
створення (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 для завдань.
Порада: логіку, без якої дані некоректні, краще тримати явно в сервісі чи дії, а події - для побічних ефектів (кеш, індекс, аудит). Інакше поведінка залежить від того, яким методом збережено модель.
Класична гонка: два процеси читають баланс 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(); // видно, що фільтр застосовано
Спершу треба побачити самі запити, а не здогадуватися.
Подивитися, що виконується:
// 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.
Обмеженням у 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 ще до рев'ю.