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

Питання

Найпопулярніші питання з реальних Laravel/PHP співбесід для всіх рівнів

171 питань

Колекції зручні настільки, що ними легко почати робити роботу бази - і заплатити памʼяттю та часом.

Типова помилка:

$active = Post::all()->where('is_published', true);        // вся таблиця в памʼять
$count  = Post::all()->count();                            // теж уся таблиця
$latest = Post::all()->sortByDesc('created_at')->take(5);  // і сортування в PHP

Кожен рядок витягує всі записи, щоб потім відкинути більшість.

Те саме в базі:

$active = Post::where('is_published', true)->get();
$count  = Post::count();
$latest = Post::latest()->limit(5)->get();

Різниця не косметична: база фільтрує за індексом і повертає стільки, скільки потрібно.

Як розрізняти. Методи Builder виконуються в SQL, методи Collection - у PHP. Межа проходить там, де стоїть get(), all() чи first(): усе після них - уже PHP.

Плутанину створює однакова назва: where() є і в білдера, і в колекції. Post::where(...) фільтрує в базі, Post::all()->where(...) - у памʼяті.

Коли колекція доречна:

  • Дані вже отримані, і потрібне ще одне групування чи перетворення - другий запит дорожчий.
  • Логіка не виражається в SQL: складна умова на PHP, робота з обʼєктами-значеннями.
  • Набір свідомо малий - десятки рядків, де різниці немає.

Коли ні: фільтрація, підрахунок, сортування й обмеження на таблиці, розмір якої росте. Це робота бази.

Проміжний варіант - lazy(): обхід пачками з синтаксисом колекції, коли обробити треба всі рядки, але тримати їх у памʼяті не можна.

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

Laravel Scout - драйверна абстракція повнотекстового пошуку. Додаєте трейт до моделі - і вона автоматично синхронізується з пошуковим індексом (Meilisearch, Algolia, Elasticsearch, навіть database).

class Post extends Model
{
    use Searchable;

    public function toSearchableArray(): array
    {
        return ['title' => $this->title, 'body' => $this->body];
    }
}

Post::search('laravel queues')->paginate(15);
  • Індекс оновлюється на події моделі (краще - через чергу).
  • Початкове наповнення: php artisan scout:import "App\Models\Post".
  • Meilisearch дає швидкий typo-tolerant пошук «з коробки»; Elasticsearch - складніші аналітичні запити.

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

Транзакція відкочує записи в базу - і більше нічого. Усе, що встигло вийти за її межі, лишається зробленим.

П'ять місць, де це видно:

1. Побічні ефекти не відкочуються. Лист, відправлений усередині транзакції, піде навіть після rollback - база відкотиться, а користувач уже отримав «Замовлення оформлено».

2. Завдання стартує раніше за commit. Воркер може взяти завдання до завершення транзакції й не знайти запису. Лікується afterCommit() на завданні або ShouldDispatchAfterCommit на події:

ProcessOrder::dispatch($order)->afterCommit();

3. Вкладені транзакції - не транзакції. Друга DB::transaction() усередині першої не створює окрему: використовуються savepoint-и, і зовнішній rollback скасує все одно все.

4. DDL не відкочується в MySQL. Schema::create() усередині транзакції робить неявний commit - міграції на MySQL атомарними не бувають, на відміну від PostgreSQL.

5. Довга транзакція тримає блокування. Звернення до зовнішнього API всередині транзакції означає, що рядки заблоковані на весь час мережевого очікування.

Практичне правило: усередині транзакції - лише запити до бази. Листи, черги, HTTP - після неї, за фактом успіху.

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

Класична гонка: два процеси читають баланс 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(); висока конкуренція за рідкісні конфлікти - оптимістичне.

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

Value Object - невеликий незмінний (immutable) об'єкт, що представляє концепцію домену й порівнюється за значенням, а не за ідентичністю (на відміну від Entity з id).

final class Money
{
    public function __construct(
        public readonly int $cents,
        public readonly string $currency,
    ) {}

    public function add(Money $other): self
    {
        return new self($this->cents + $other->cents, $this->currency);
    }
}

Переваги: інкапсуляція правил (валюта, валідація email), самодокументований код, безпека (незмінність). У Laravel VO зручно зберігати через Custom Casts, перетворюючи між колонкою БД та об'єктом.

CSP - HTTP-заголовок, що визначає, з яких джерел дозволено завантажувати ресурси (скрипти, стилі, зображення). Це потужний захист від XSS: навіть якщо зловмисник впровадить <script>, браузер не виконає його, якщо джерело не дозволене.

Content-Security-Policy: default-src 'self';
    script-src 'self' https://cdn.example.com;
    img-src 'self' data:;

У Laravel заголовок додають через middleware (вручну або пакетом на кшталт spatie/laravel-csp).

Практики:

  • Уникати 'unsafe-inline' - використовувати nonce для інлайн-скриптів.
  • Спершу режим report-only (Content-Security-Policy-Report-Only) зі збором звітів, щоб не зламати сайт.

Middleware утворюють «цибулю» навколо обробки запиту: кожен бачить запит на вході й відповідь на виході у зворотному порядку. Порядок вирішує, бо одні залежать від роботи інших.

Зазвичай він визначається місцем реєстрації: глобальні → групові → маршрутні. Але middleware, призначені маршруту, сортуються за списком пріоритетів, а не за тим, як їх перелічили в ->middleware([...]).

Чому це важливо. SubstituteBindings резолвить {post} у модель. Політика, що перевіряє власника поста, має спрацювати після цього - інакше отримає рядок замість моделі. Так само аутентифікація мусить йти раніше за авторизацію: перевіряти права нема в кого, поки користувач не визначений.

Задати свій порядок у bootstrap/app.php:

->withMiddleware(function (Middleware $middleware) {
    $middleware->priority([
        \Illuminate\Cookie\Middleware\EncryptCookies::class,
        \Illuminate\Session\Middleware\StartSession::class,
        \Illuminate\Auth\Middleware\Authenticate::class,
        \Illuminate\Routing\Middleware\SubstituteBindings::class,
        \App\Http\Middleware\EnsureSubscribed::class,
        \Illuminate\Auth\Middleware\Authorize::class,
    ]);
})

Ті, що є в списку, ідуть у вказаному порядку; решта - за порядком призначення.

Ознака, що ви натрапили саме на це: middleware працює на одному маршруті й «не бачить» даних на іншому, хоча код той самий. Зазвичай це означає, що щось потрібне ще не встигло відпрацювати.

Порада: не переставляйте наосліп. Спершу зʼясуйте, від чого залежить ваше middleware - від сесії, від резолвленої моделі, від автентифікованого користувача, - і поставте його після цього.

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

BDD розширює TDD, зміщуючи фокус на поведінку системи з погляду бізнесу/користувача, а не на технічні деталі. Сценарії описують зрозумілою мовою (Gherkin: Given-When-Then).

Scenario: Успішний вхід
  Given користувач зареєстрований
  When він вводить правильні дані
  Then він потрапляє на дашборд

У PHP-екосистемі - Behat. Pest також заохочує «describe behavior» стиль:

it('redirects to dashboard after login', function () {
    // ...
});

Цінність BDD - спільна мова між розробниками, QA та бізнесом; тести стають живою документацією очікуваної поведінки.

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

  • Великий серіалізований стан - Livewire серіалізує всі public-властивості в кожен запит. Тримайте в public лише необхідне; похідні дані вираховуйте у computed properties (#[Computed]).
  • Надмірний wire:model.live - кожне натискання шле запит. Використовуйте deferred або .debounce.
  • Відсутність wire:key у циклах - ламає узгодження DOM, спричиняє «стрибки» та зайвий ререндер.
  • «Божественні» компоненти - розбивайте на дрібніші, ізольовані.
  • Завантаження всіх записів - використовуйте пагінацію та #[Lazy]-компоненти.
@foreach ($posts as $post)
    <livewire:post-row :post="$post" :key="$post->id" />
@endforeach

Також допомагають islands (частковий ререндер) та винесення важкої логіки в черги замість синхронних дій.

Для Livewire - хелпер livewire() (із pest-plugin-livewire):

livewire(SearchPosts::class)
    ->set('query', 'laravel')
    ->assertSee('Laravel Queues')
    ->call('clear')
    ->assertSet('query', '')
    ->assertDispatched('posts-updated');

Для Filament - спершу автентифікуйте користувача, потім тестуйте сторінки ресурсів:

livewire(CreatePost::class)
    ->fillForm(['title' => 'Hello'])
    ->call('create')
    ->assertHasNoFormErrors();

livewire(ListPosts::class)
    ->callAction(TestAction::make('publish')->table($post))
    ->assertNotified();

Ключові асерти: assertSet, assertSee, assertDispatched, assertHasFormErrors, assertCanSeeTableRecords.

Глобальний скоп додає умову до всіх запитів моделі автоматично. 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

Eloquent сам розсилає події життєвого циклу: creating, created, updating, updated, saving, saved, deleting, deleted, restored, replicating.

Зручно для дрібниць, які мають виконуватися завжди:

class Post extends Model
{
    protected static function booted(): void
    {
        static::creating(function (Post $post) {
            $post->slug ??= Str::slug($post->title);
        });
    }
}

Для повного набору краще окремий observer - інакше booted() розростається:

#[ObservedBy(PostObserver::class)]
class Post extends Model
{
}

Чим небезпечні:

  • Масові операції їх не викликають. Post::where(...)->update([...]), ->delete() та insert() йдуть повз Eloquent, тож жоден слухач не спрацює. Дані тихо розходяться з тим, що гарантував observer.
  • Логіка стає невидимою. $post->save() у контролері виглядає простим рядком, а насправді тягне за собою запис в інші таблиці й виклик API.
  • Тести сповільнюються. Кожна фабрика в кожному тесті тягне весь ланцюжок подій, включно з тими, що ходять у мережу.
  • Помилка в слухачі ламає збереження. Виняток у creating скасовує операцію - зокрема там, де про це ніхто не думав.
  • Рекурсія. Слухач saved, який оновлює ту саму модель, викликає сам себе; рятує saveQuietly().

Правило: модельні події - для інваріантів самої моделі (слаг, нормалізація поля, службові звʼязки). Бізнес-процес - оформлення замовлення, розсилка, інтеграції - належить явному сервісу чи доменній події, які видно в місці виклику.

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

Одна команда робить більшість:

php artisan optimize        # config + route + view + event cache

Окремо:

php artisan config:cache # об'єднує конфіг у один файл
php artisan route:cache # компілює маршрути
php artisan view:cache # прекомпілює Blade
php artisan event:cache # кеш мапінгу подій/слухачів
composer install --no-dev --optimize-autoloader

Додатково: увімкнений OPcache (а краще з JIT), prebuilt ассети (npm run build).

Підводний камінь: після config:cache виклики env() поза config/ повертають null - усі env-значення мають читатися лише у конфіг-файлах. На деплої не забути php artisan optimize:clear перед повторним кешуванням.

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

На одному сервері планувальник працює очевидно. Щойно серверів стає два, зʼявляються дві різні проблеми, які плутають між собою.

1. Завдання виконується двічі. Cron стоїть на обох серверах, тож щохвилинний schedule:run запускається двічі - і звіт розсилається двічі. Лікується onOneServer():

Schedule::command('reports:send')
    ->daily()
    ->onOneServer();

Перший сервер бере атомарне блокування в кеші, другий пропускає запуск. Драйвером за замовчуванням має бути database, memcached, dynamodb або redis, і всі сервери мусять дивитися в один центральний кеш; з file у кожного вузла кеш свій, тож блокування нічого не гарантує.

2. Завдання накладається саме на себе. Імпорт триває сім хвилин, а запускається щоп'ять - і другий екземпляр стартує поверх першого. Це вже про час виконання, а не про кількість серверів:

Schedule::command('import:run')
    ->everyFiveMinutes()
    ->withoutOverlapping();

За замовчуванням блокування тримається 24 години; якщо процес упав без звільнення, наступний запуск чекатиме. Тому варто задавати межу: withoutOverlapping(10).

Разом, коли потрібні обидві гарантії:

Schedule::command('import:run')
    ->everyFiveMinutes()
    ->withoutOverlapping(10)
    ->onOneServer();

Ще дві дрібниці, які кусаються. Часовий пояс планувальника береться з конфігурації застосунку - ->timezone('Europe/Kyiv') задає його явно. І завдання, що не пише жодного логу, мовчки не виконується місяцями; emailOutputOnFailure() або ->onFailure() роблять збій помітним.

Докладніше в документації: Планування завдань

Головний ризик - несумісність схеми зі старим кодом під час деплою та блокування таблиць.

Безпечні зміни (expand → migrate → contract):

  1. Додати нову колонку (nullable) - старий код працює.
  2. Задеплоїти код, що пише і в стару, і в нову.
  3. Перенести дані (фоновий job), перемкнути читання.
  4. Окремим релізом видалити стару колонку.

Практики:

  • Не покладатися на migrate:rollback у проді - down() може втрачати дані. Краще forward-fix.
  • Великі ALTER на величезних таблицях блокують → онлайн-міграції (pt-online-schema-change, gh-ost).
  • php artisan migrate --force у пайплайні; --isolated, щоб не виконати паралельно на кількох воркерах.
  • Бекап перед руйнівними операціями; прогін міграцій на staging.

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

Питання з реальних співбесід Laravel і PHP - 171 питання у 41 темі, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.

Рівні
Junior 52 Middle 57 Senior 62

Готуєтесь до співбесіди не просто так: зараз на сайті 167 відкритих вакансій Laravel і PHP. Переглянути вакансії