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

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();   // видно, що фільтр застосовано

Докладніше в документації: 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: суворий режим

Традиційно модель налаштовують захищеними властивостями й методами 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.

Практично: для нових моделей атрибути зручніші; переписувати старі заради стилю немає сенсу - поведінка однакова.

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