Senior: питання на співбесіді з теми «Eloquent»
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
8 питань
Класична гонка: два процеси читають баланс 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 ще до рев'ю.
Традиційно модель налаштовують захищеними властивостями й методами booted(). Laravel поступово додає PHP-атрибути класу, що описують ту саму конфігурацію декларативно:
use Illuminate\Database\Eloquent\Attributes\{Table, Fillable, Hidden, ObservedBy, ScopedBy, UseFactory, CollectedBy};
#[Table('legacy_orders', key: 'order_id', timestamps: false)]
#[Fillable(['customer_id', 'total', 'status'])]
#[Hidden(['internal_note'])]
#[ObservedBy(OrderObserver::class)]
#[ScopedBy(TenantScope::class)]
#[UseFactory(OrderFactory::class)]
#[CollectedBy(OrderCollection::class)]
final class Order extends Model {}
Те саме через властивості й методи:
class Order extends Model
{
protected $table = 'legacy_orders';
protected $primaryKey = 'order_id';
public $timestamps = false;
protected $fillable = ['customer_id', 'total', 'status'];
protected $hidden = ['internal_note'];
protected static function booted(): void
{
static::observe(OrderObserver::class);
static::addGlobalScope(new TenantScope);
}
}
Що дають атрибути:
- конфігурація відокремлена від поведінки: метадані над класом, а в тілі лишаються зв'язки, касти, бізнес-методи;
- видно одразу, які спостерігачі й глобальні області видимості підключені до моделі - не треба шукати по
booted()і сервіс-провайдерах; - немає побічних ефектів у
booted()і ризику забутиparent::booted()у спадкоємцях; - статичний аналіз: IDE й інструменти читають атрибути через Reflection без запуску коду.
Що варто знати:
- атрибути читаються через Reflection при першому зверненні й кешуються - на швидкість це практично не впливає;
- успадкування: сам PHP атрибути не наслідує, але Eloquent шукає атрибут у класі моделі, її трейтах і далі в батьківських класах - тож конфігурація з базової моделі чи трейту діє в спадкоємцях, а найближчий атрибут перекриває дальші;
- змішування стилів в одному проєкті (частина моделей з властивостями, частина з атрибутами) ускладнює читання - варто домовитися про один стиль;
- не все має атрибут: касти, зв'язки, аксесори лишаються методами.
Інші атрибути Laravel у тому самому стилі: #[UsePolicy] для зв'язку моделі з політикою, #[UseResource] для API Resource, #[Scope] для позначення методу локальною областю видимості без префікса scope.
Практично: для нових моделей атрибути зручніші; переписувати старі заради стилю немає сенсу - поведінка однакова.