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.
Поліморфний зв'язок дозволяє моделі належати кільком різним типам моделей через один зв'язок.
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, очищення пов'язаних ресурсів, аудиту.
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().
Chunking обробляє великі набори даних порціями, щоб не тримати всі рядки в пам'яті одразу.
Post::chunk(200, function ($posts) {
foreach ($posts as $post) { /* ... */ }
});
chunkById(200, ...)- безпечніший, коли під час обробки змінюються записи (нумерує заid, а не за offset).lazy()/cursor()- повертають LazyCollection: ще менше пам'яті, але один активний запит.
Без chunking Post::all() на мільйонній таблиці впаде з браку пам'яті.
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.
| 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 для завдань.
Порада: логіку, без якої дані некоректні, краще тримати явно в сервісі чи дії, а події - для побічних ефектів (кеш, індекс, аудит). Інакше поведінка залежить від того, яким методом збережено модель.