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

Питання на співбесіді рівня Senior

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

62 питань

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.

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

Реплікація розвантажує основний сервер: запис іде на primary, читання - на replicas. Laravel маршрутизує запити автоматично, якщо в конфізі з'єднання задані секції read/write:

'mysql' => [
    'read'  => ['host' => ['10.0.0.2', '10.0.0.3']], // репліки
    'write' => ['host' => ['10.0.0.1']],             // primary
    'sticky' => true,
    // ...спільні параметри
],
  • SELECT → репліка, INSERT/UPDATE/DELETE → primary.
  • sticky => true критично важливе: після запису в межах того ж запиту читання теж піде з primary, інакше через replication lag можна прочитати застарілі дані.
  • Реплікація асинхронна → завжди закладайте можливе відставання реплік у логіці.

Докладніше в документації: Read/Write підключення

Файл, покладений у storage/app, недоступний ззовні - це правильно за замовчуванням. Питання в тому, як віддати його тому, кому можна.

Що робити не варто: класти приватні файли в public/ чи публічний бакет і покладатися на те, що адресу ніхто не вгадає. Посилання розходяться, індексуються й лишаються робочими назавжди.

Спосіб 1 - віддати через контролер із перевіркою прав:

public function download(Document $document)
{
    $this->authorize('view', $document);

    return Storage::disk('private')->download($document->path, $document->name);
}

Кожне звернення проходить авторизацію. Мінус - файл іде крізь PHP, тож на великих файлах воркер зайнятий увесь час передачі.

Спосіб 2 - тимчасове підписане посилання до S3-сумісного сховища:

$url = Storage::disk('s3')->temporaryUrl($document->path, now()->addMinutes(5));

Посилання підписане й саме перестає працювати після строку. Файл віддає сховище напряму, PHP у передачі не бере участі - це те, що потрібно для великих файлів.

Спосіб 3 - підписаний маршрут, коли сховище тимчасових URL не вміє:

URL::temporarySignedRoute('documents.download', now()->addMinutes(5), ['document' => $document->id]);

Middleware signed перевіряє підпис і строк.

Про що часто забувають:

  • Спосіб 1 без authorize() перетворюється на публічний доступ через власний контролер.
  • Строк життя посилання має бути коротким: доки воно живе, ним може скористатися будь-хто, кому воно потрапило.
  • Імʼя файла від користувача не можна підставляти в шлях - Storage::putFile() генерує безпечне імʼя сам.

Докладніше в документації: Файлове сховище

Cache Stampede (dogpile) - коли популярний ключ кешу протермінувався, і сотні одночасних запитів кидаються перераховувати важке значення одночасно, перевантажуючи БД.

Рішення в Laravel:

// блокування: лише один процес перераховує, інші чекають результат
$value = Cache::lock('report:lock', 10)->block(5, function () {
    return Cache::remember('report', 3600, fn () => $this->heavyReport());
});

Інші стратегії:

  • Cache::flexible() (stale-while-revalidate) - віддає «протухле» значення, поки одне фонове оновлення його перераховує.
  • Розмазування TTL (jitter), щоб ключі не протухали одночасно.
  • Прогрів кешу (cache warming) за розкладом, а не за запитом користувача.

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

Завдання можуть падати через тимчасові збої (мережа, rate limit) - потрібна стратегія повторів і обробки остаточних провалів.

class CallApi implements ShouldQueue
{
    public int $tries = 5; // спроб
    public int $maxExceptions = 2;
    public int $timeout = 30;

    // прогресивна затримка між спробами
    public function backoff(): array
    {
        return [10, 30, 60]; // 10с, 30с, 60с...
    }

    public function failed(Throwable $e): void
    {
        // викликається після вичерпання спроб
    }
}
  • Остаточно провалені завдання осідають у таблиці failed_jobs.
  • php artisan queue:retry all - повторити, queue:flush - очистити.
  • releaseAfter, WithoutOverlapping, RateLimited middleware керують поведінкою.
  • Ідемпотентність (idempotency) обов'язкова - бо завдання може виконатися повторно.

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

Вакансії Laravel рівня Senior

Усі вакансії Laravel Senior
Mantah Нова
Сьогодні

Backend Developer (PHP)

Backend розробник для e-commerce проектів та корпоративних порталів. Розробляє архітектуру, обирає стек технологій, розвиває та підтримує PHP-системи, інтегрує зовнішні сервіси (платежі, CRM/ERP, маркетплейси). Вимоги: 3+ років досвіду, PHP 8.x, Laravel/Symfony, Magento 2, WordPress/WooCommerce, REST API/GraphQL, MySQL, Redis, Git/Docker, Upper-Intermediate англійська.

Real Estate Bees Нова
Сьогодні

Backend Engineer (PHP/Laravel)

Розробник Backend на Laravel 12/PHP 8.2 для revenue-critical систем з REST API, бізнес-логікою, інтеграцією третіх сервісів і Redis-черг. Потрібен досвід 3+ років PHP/Laravel, знання Domain-Driven Design, PostgreSQL, тестування та AI-інструментів. Позиція передбачає архітектурну трансформацію з legacy-коду до domain-first структури, з відповідальністю від вимог до production.

Edvantis
4 дні тому

Senior Full-Stack Software Engineer (PHP/Laravel, Vue.js)

Розробник повного стеку для хмарної платформи управління дослідженнями. Розроблення функцій на PHP (Laravel) та Vue.js, побудова REST API, оптимізація продуктивності, написання тестів. Вимоги: міцні знання PHP та Laravel, досвід з Vue.js і JavaScript, англійська Upper-Intermediate+.

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

Інші рівні
Junior 52 Middle 57