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

Питання на співбесіді з Laravel

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

378 питань

Користувачі пакета оновлюють Laravel у різний час. Пакет, що підтримує лише найновішу версію, блокує їм оновлення - або змушує шукати альтернативу.

Обмеження версій у composer.json:

"require": {
    "php": "^8.3",
    "illuminate/support": "^12.0|^13.0",
    "illuminate/database": "^12.0|^13.0"
},
"require-dev": {
    "orchestra/testbench": "^10.0|^11.0",
    "pestphp/pest": "^4.0|^5.0"
}

Матриця в CI - кожна комбінація перевіряється окремо:

strategy:
  matrix:
    php: [8.3, 8.4, 8.5]
    laravel: [12.*, 13.*]
    stability: [prefer-lowest, prefer-stable]
    include:
      - laravel: 12.*
        testbench: 10.*
      - laravel: 13.*
        testbench: 11.*
    exclude:
      - laravel: 13.*
        php: 8.3      # якщо Laravel 13 вимагає новішу версію PHP
steps:
  - run: composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-update
  - run: composer update --${{ matrix.stability }} --prefer-dist

prefer-lowest ловить випадки, коли код використовує метод, якого ще немає в мінімальній заявленій версії.

Як писати код для кількох версій:

  • спільний знаменник API: нові можливості фреймворку використовувати лише тоді, коли мінімальна версія їх містить;
  • якщо дуже треба - перевірка можливостей, а не номера версії: method_exists(Builder::class, 'whereVectorSimilarTo'), class_exists(...);
  • не покладатися на внутрішні класи й поведінку, не описану в документації, - вони змінюються в мінорних версіях;
  • version_compare(app()->version(), ...) - крайній засіб.

Семантичне версіонування пакета:

Зміна Версія
виправлення без зміни API патч 1.4.1
нова можливість, сумісна назад мінорна 1.5.0
видалено чи змінено публічний API, ключ конфігурації, підпис методу, структуру міграцій мажорна 2.0.0
зняття підтримки старої версії Laravel чи PHP зазвичай мажорна (хоча Composer сам не встановить пакет на непідтримуваній версії, багато супровідників вважають це breaking)

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

Політика підтримки: записати в README, які версії Laravel і PHP підтримуються і скільки часу стара мажорна версія пакета отримує виправлення безпеки.

Докладніше в документації: Пакети: вступ

Пакет живе в чужому застосунку. Що менше він нав'язує, то легше його встановити, налаштувати й замінити.

Залежності в коді пакета - через інтерфейси й контейнер:

namespace Acme\Invoices\Contracts;

interface PdfRenderer
{
    public function render(string $html): string;
}
// InvoicesServiceProvider::register()
$this->app->singleton(PdfRenderer::class, fn ($app) => new DompdfRenderer(
    $app['config']->get('invoices.pdf'),
));

$this->app->singleton(InvoiceGenerator::class);
final class InvoiceGenerator
{
    public function __construct(
        private PdfRenderer $renderer,
        private Filesystem $files,     // Illuminate\Contracts\Filesystem\Filesystem
    ) {}
}

Застосунок замінює реалізацію одним рядком у своєму провайдері - без форку пакета:

$this->app->singleton(PdfRenderer::class, BrowsershotRenderer::class);

Фасади:

  • фасад - зручність для користувача, а не спосіб писати внутрішній код пакета. Усередині пакета краще впровадження залежностей: код тестується без фасадів і не залежить від глобального стану;
  • фасад пакета вказує на прив'язку в контейнері (getFacadeAccessor повертає клас чи ключ), тож заміна реалізації працює й для нього;
  • реєструвати аліас через extra.laravel.aliases - необов'язково: багато користувачів імпортують клас фасаду напряму.

Необов'язкові залежності:

"require": { "illuminate/support": "^12.0|^13.0" },
"suggest": { "spatie/browsershot": "Для рендерингу PDF через Chrome" }
if (! class_exists(\Spatie\Browsershot\Browsershot::class)) {
    throw new MissingDependency('Install spatie/browsershot to use the browsershot driver.');
}

Важка залежність (headless Chrome, SDK хмари) не має ставитися всім, кому потрібна лише частина пакета.

Моделі застосунку:

  • не імпортувати App\Models\User - модель береться з конфігурації: config('invoices.user_model', config('auth.providers.users.model'));
  • власні моделі пакета дозволяти розширювати: config('invoices.models.invoice') і використовувати їх через цей ключ.

Події замість жорстких викликів: InvoicePaid дозволяє застосунку реагувати (лист, вебхук, бухгалтерія), не змінюючи код пакета.

Ознака добре спроєктованого пакета: його можна використовувати без фасадів, без публікації файлів і з власною реалізацією кожної зовнішньої залежності.

Докладніше в документації: Пакети: про фасади

На продакшені застосунок кешує конфігурацію, маршрути, події й представлення (php artisan optimize). Пакет, який цього не враховує, працює локально й ламається після деплою.

Що змінюється, коли кеші ввімкнені:

Кеш Наслідок для пакета
config:cache mergeConfigFrom пропускається - значення беруться з кешу; env() у коді пакета повертає null
route:cache loadRoutesFrom пропускається; замикання в маршрутах пакета раніше ламали кешування - краще контролери
event:cache слухачі, знайдені автоматично, кешуються; динамічна реєстрація в рантаймі може не потрапити
view:cache компілює представлення, зокрема пакетів, зареєстровані через loadViewsFrom

Типові помилки пакетів:

  • env() поза файлом конфігурації пакета - після config:cache повертає null. Усе, що залежить від оточення, має проходити через config/<пакет>.php;
  • конфігурація, змінена в рантаймі (config(['invoices.x' => ...]) у boot залежно від запиту) - не потрапляє в кеш і поводиться по-різному з кешем і без;
  • несеріалізовані значення в конфігурації: замикання чи об'єкти в config/*.php ламають config:cache («Your configuration files are not serializable»). Замість замикання - ім'я класу;
  • важка робота в boot: запити до бази чи HTTP при кожному завантаженні застосунку, зокрема в консольних командах і в package:discover під час composer install (коли бази ще може не бути).

Інтеграція з командами застосунку:

public function boot(): void
{
    if ($this->app->runningInConsole()) {
        // викликаються з optimize / optimize:clear
        $this->optimizes(
            optimize: 'invoices:cache',
            clear: 'invoices:clear',
        );

        // викликається з php artisan reload (перезапуск довгоживучих процесів)
        $this->reloads('invoices:restart-workers');
    }

    // рядок у php artisan about
    AboutCommand::add('Invoices', fn () => [
        'Version' => '2.3.0',
        'PDF driver' => config('invoices.pdf.driver'),
    ]);
}

Тоді власний кеш пакета (наприклад, скомпільовані шаблони чи маніфест) створюється й очищується разом зі стандартними кешами, і розробнику не треба пам'ятати окрему команду в скрипті деплою.

Octane і довгоживучі процеси: пакет не має зберігати стан запиту в синглтонах чи статичних властивостях - у воркері Octane він «протікає» між запитами. Для даних запиту - scoped() прив'язки замість singleton().

Тест у CI пакета: прогнати набір тестів ще й після config:cache і route:cache у Testbench - так ловляться помилки, які інакше знайде користувач на продакшені.

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

Відповідь моделі може генеруватися десятки секунд, а з інструментами - ще довше. Синхронний prompt() у контролері тримає PHP-воркер увесь цей час і впирається в тайм-аути проксі та PHP.

Три підходи:

1. Стримінг (SSE) - користувач бачить текст по мірі генерації:

Route::post('/assistant', function (Request $request) {
    return SupportAssistant::make(user: $request->user())
        ->stream($request->validated('message'))
        ->then(function (StreamedAgentResponse $response) {
            // повна відповідь: зберегти, порахувати токени
        });
});

StreamableAgentResponse повертається з маршруту як потокова відповідь. Перший токен приходить за секунду-дві, але воркер зайнятий до кінця генерації.

2. Черга - запит повертається одразу, генерація йде у воркері:

ReviewClassifier::make()
    ->queue($review->body)
    ->then(fn (AgentResponse $response) => $review->update([...]))
    ->catch(fn (Throwable $e) => report($e));

return back()->with('status', 'Аналіз запущено');

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

3. Трансляція подій - генерація у черзі, а потік подій іде в браузер через WebSocket (Reverb):

SupportAssistant::make()->broadcastOnQueue($message, new PrivateChannel("chat.{$chat->id}"));

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

Як обрати:

Сценарій Підхід
чат, де користувач чекає відповідь стримінг або черга + трансляція
фонова обробка без очікування черга
багато одночасних користувачів черга + трансляція (не займати вебворкери)

Інфраструктурні деталі:

  • тайм-аути: #[Timeout] агента, timeout воркера черги й retry_after підключення мають бути узгоджені, інакше задачу візьме другий воркер, і модель відповість двічі (і двічі буде оплачено);
  • буферизація: Nginx, Cloudflare й інші проксі можуть буферизувати SSE - потрібні X-Accel-Buffering: no і відповідні налаштування;
  • окрема черга для LLM-задач з власним лімітом воркерів - щоб повільні виклики моделі не затримували листи й сповіщення;
  • повтори: при збоях провайдера краще резервний провайдер (provider: [...]), ніж повтор задачі цілком;
  • Octane / FrankenPHP - стримінг утримує воркер так само; рахуйте кількість воркерів під одночасні потоки.

Докладніше в документації: AI SDK: стримінг

Laravel MCP (laravel/mcp) дозволяє застосунку стати MCP-сервером: AI-клієнти (Claude, ChatGPT, Cursor) отримують доступ до інструментів, ресурсів і промптів вашого продукту.

php artisan make:mcp-server ShopServer
php artisan make:mcp-tool SearchProductsTool
#[Name('Shop')]
#[Version('1.0.0')]
#[Instructions('Пошук товарів і перегляд замовлень магазину.')]
class ShopServer extends Server
{
    protected array $tools = [SearchProductsTool::class, OrderStatusTool::class];
    protected array $resources = [ReturnPolicyResource::class];
    protected array $prompts = [];
}
#[IsReadOnly]
class OrderStatusTool extends Tool
{
    protected string $description = 'Статус замовлення поточного користувача за номером.';

    public function schema(JsonSchema $schema): array
    {
        return ['number' => $schema->integer()->required()];
    }

    public function handle(Request $request): Response
    {
        $data = $request->validate(['number' => 'required|integer']);

        $order = $request->user()->orders()->where('number', $data['number'])->first();

        return $order ? Response::text($order->status->label()) : Response::error('Замовлення не знайдено.');
    }
}

Реєстрація у routes/ai.php:

Mcp::web('/mcp/shop', ShopServer::class)->middleware(['auth:sanctum', 'throttle:mcp']);
Mcp::local('shop', ShopServer::class);   // для локальних агентів через artisan

Захист - вебсервер MCP це публічний API:

  • автентифікація: auth:sanctum з токеном у заголовку Authorization або OAuth 2.1 через Passport (Mcp::oauthRoutes() + auth:api) - другий варіант потрібен клієнтам на кшталт Claude.ai, що підключаються від імені користувача;
  • авторизація в кожному інструменті: $request->user() і політики. Модель передасть будь-який номер замовлення - перевірка «чи належить воно користувачу» обов'язкова;
  • shouldRegister() - приховати інструмент від користувачів без прав чи підписки: він не з'явиться в списку й не викликається;
  • анотації (#[IsReadOnly], #[IsDestructive], #[IsIdempotent]) - підказки клієнту, які дії потребують підтвердження. Це не захист - клієнт може їх ігнорувати;
  • обмеження частоти - агенти викликають інструменти в циклі значно частіше за людей;
  • мінімальні дані у відповіді: результат інструмента потрапляє в контекст моделі й далі може опинитися будь-де. Не повертати зайвих полів.

Prompt injection з вашого боку: дані, які сервер повертає (відгуки, описи товарів від продавців), можуть містити інструкції для моделі. Позначайте їх як дані, а деструктивні інструменти не робіть доступними разом з інструментами, що читають сторонній контент.

Тестування:

ShopServer::actingAs($user)
    ->tool(OrderStatusTool::class, ['number' => 1042])
    ->assertOk()
    ->assertSee('Доставлено');

Для ручної перевірки - php artisan mcp:inspector.

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

Prompt injection - модель не розрізняє «інструкції розробника» і «дані»: текст у листі, відгуку, PDF чи вебсторінці може містити «ігноруй попередні інструкції й...». Повністю цю проблему не розв'язано, тому захист будується так, ніби модель буде обдурена.

Принципи:

  • модель - недовірений компонент. Її вивід - як введення користувача: валідувати, екранувати в HTML, не підставляти в SQL, шляхи, команди;
  • мінімальні права інструментів: агент, що читає сторонній контент, не повинен мати інструментів з незворотними діями. Поєднання «читає пошту» + «надсилає листи» + «бачить приватні дані» - класичний сценарій витоку;
  • авторизація в коді інструмента від імені реального користувача, а не «що попросила модель»;
  • схвалення людиною (Approvable) для грошей, видалення, зовнішніх відправок;
  • розділення даних і інструкцій: сторонній текст - окремим блоком з явною позначкою, що це дані. Знижує, але не усуває ризик;
  • вихідні фільтри: не дозволяти моделі вставляти довільні посилання й зображення в HTML-відповідь - через них виводять дані (запит до attacker.com/?data=...).

Контроль витрат:

Кожна відповідь містить використання токенів:

$response = SupportAssistant::make()->prompt($message);

$response->usage->inputTokens;
$response->usage->outputTokens;
$response->usage->cacheReadInputTokens;

Що робити з цими даними:

  • записувати токени, модель і користувача для кожного виклику - без цього неможливо зрозуміти, хто й що коштує;
  • ліміти на користувача й тариф - лічильник у Redis чи базі, перевірка перед викликом;
  • RateLimiter на маршрутах з AI - бот без обмежень може витратити місячний бюджет за ніч;
  • #[MaxTokens] і #[MaxSteps] - верхня межа довжини відповіді й кількості викликів інструментів;
  • розмір контексту: історія розмови росте з кожним повідомленням, і кожен запит оплачує її знову. Обрізати історію чи стискати її в резюме;
  • дешевша модель для простих задач: #[UseCheapestModel] для класифікації й витягання даних, потужна - лише там, де потрібно міркування;
  • кешування: однакові запити (ембединги того самого тексту, резюме того самого документа) - зберегти результат, а не платити знову; кешування промптів у провайдера для довгого незмінного системного промпту;
  • сповіщення про аномалії: різке зростання витрат за годину - сигнал про зловживання чи цикл.

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

Докладніше в документації: AI SDK: використання токенів

Причина - збережені значення. Драйвер database при першій перевірці обчислює прапорець для скопу і записує результат у таблицю features. Далі Pennant читає збережене значення, а замикання з визначенням не викликається.

Сценарій: прапорець new-checkout визначено як Lottery::odds(1 / 10). Через тиждень команда змінює його на true для всіх - але 90% користувачів, яким раніше випало false, так і бачать стару версію.

Як оновити вже збережені значення:

Feature::activateForEveryone('new-checkout');   // усім true
Feature::deactivateForEveryone('new-checkout'); // усім false
Feature::purge('new-checkout');                 // видалити збережене - наступна перевірка обчислить заново
php artisan pennant:purge new-checkout

Продуктивність:

  • кеш у пам'яті: у межах запиту значення кешується, тож повторні перевірки не йдуть у базу;
  • цикли: перевірка прапорця для кожного з сотні користувачів - сотня запитів. Рішення - жадібне завантаження:
Feature::for($users)->load(['notifications-beta']);
  • драйвер array не зберігає нічого між запитами - для тестів (PENNANT_STORE=array) або для прапорців, які завжди обчислюються з даних;
  • власний драйвер - якщо прапорці керуються зовнішнім сервісом (LaunchDarkly, Unleash) чи конфігурацією.

Класові прапорці зручніші за замикання для великих проєктів: окремий клас з методом resolve, автоматичне визначення, впровадження залежностей, а ім'я класу - ідентифікатор, який легко знайти пошуком по коду.

Життєвий цикл прапорця:

  1. створення з власником і очікуваною датою видалення;
  2. поступовий запуск і моніторинг помилок та метрик;
  3. повний запуск - activateForEveryone;
  4. прибирання: видалити перевірки й стару гілку коду, визначення прапорця, purge збережених значень.

Чому прибирання - найважливіше: кожен активний прапорець подвоює кількість шляхів виконання. Десять прапорців - потенційно тисяча комбінацій, які ніхто не тестує. Корисні практики: список прапорців з датами в README чи задачах, нагадування в CI для прапорців, старших за N тижнів, тести для обох станів кожного прапорця.

Не плутати з конфігурацією: прапорці - тимчасовий механізм для запуску. Постійні налаштування клієнтів (тарифні можливості) краще моделювати явно - через план підписки чи налаштування тенанта.

Докладніше в документації: Pennant: очищення можливостей

Cashier - обгортка над Stripe (і окремо над Paddle) для підписок: клієнти, способи оплати, пробні періоди, зміна тарифу, рахунки.

// оформлення через Stripe Checkout
return $request->user()
    ->newSubscription('default', 'price_monthly_pro')
    ->trialDays(14)
    ->checkout(['success_url' => route('billing.done'), 'cancel_url' => route('billing')]);

// перевірка доступу
if ($user->subscribed('default')) { /* ... */ }
$user->subscription('default')->swap('price_yearly_pro');
$user->subscription('default')->cancel(); // до кінця оплаченого періоду

Чому вебхуки критичні. Оплата - асинхронний процес, що відбувається поза вашим застосунком:

  • автоматичне продовження підписки щомісяця;
  • невдале списання, повторні спроби, скасування після кількох невдач;
  • 3-D Secure (SCA): користувач підтверджує платіж у банку пізніше;
  • повернення коштів, суперечки, зміни в панелі Stripe вручну.

Про все це застосунок дізнається лише через вебхуки. Cashier реєструє маршрут /stripe/webhook і сам оновлює таблиці subscriptions і subscription_items за подіями customer.subscription.*, invoice.payment_succeeded тощо. Без налаштованих вебхуків стан у базі розходиться з реальністю: користувач перестав платити, а доступ лишився.

php artisan cashier:webhook   # створює вебхук у Stripe з потрібними подіями

Обов'язкове для продакшену:

  • перевірка підпису - STRIPE_WEBHOOK_SECRET; без неї будь-хто може надіслати «оплату пройшла»;
  • виключення з CSRF для маршруту вебхука;
  • ідемпотентність власних обробників: Stripe може надіслати подію повторно, а порядок подій не гарантований. Обробник має давати той самий результат при повторі;
  • черга для важкої роботи: відповісти Stripe швидко, а лист чи нарахування - у фонове завдання, інакше тайм-аут і повторна доставка;
  • стани incomplete і past_due: підписка створена, але платіж не підтверджено. Вирішіть, чи давати доступ у цих станах, і перевіряйте їх явно.

Stripe чи Paddle: зі Stripe ви - продавець і самі відповідаєте за податки (Stripe Tax допомагає). Paddle - «merchant of record»: він продає від свого імені й сам сплачує ПДВ у різних країнах, що спрощує продаж по світу, але бере більшу комісію.

Тестування: тестові ключі, Stripe CLI (stripe listen --forward-to) для локальних вебхуків, тести обробників на збережених прикладах подій.

Докладніше в документації: Cashier: обробка вебхуків Stripe

Pulse записує події з кожного запиту, завдання, SQL-запиту й звернення до кешу. На застосунку з тисячами запитів за хвилину це означає мільйони записів, які треба зберегти й агрегувати для панелі. Без налаштувань моніторинг починає навантажувати ту саму базу, яку мав би спостерігати.

1. Окрема база даних для Pulse:

PULSE_DB_CONNECTION=pulse

Записи й важкі агрегуючі запити панелі не конкурують з основними запитами застосунку.

2. Приймання через Redis:

PULSE_INGEST_DRIVER=redis
PULSE_REDIS_CONNECTION=pulse

За замовчуванням Pulse пише в базу після відправлення відповіді - запит користувача не чекає, але воркер PHP зайнятий. З драйвером redis записи йдуть у Redis-стрім, а окремий процес php artisan pulse:work переносить їх у базу пакетами. Підключення Redis для Pulse має бути іншим, ніж для черги.

3. Семплювання:

Recorders\UserRequests::class => [
    'sample_rate' => env('PULSE_USER_REQUESTS_SAMPLE_RATE', 0.1),
],

Записується лише ~10% подій, значення на панелі масштабуються й позначаються ~. Чим частіша подія, тим нижчу частоту можна ставити без втрати точності. Рідкісні події (винятки, повільні запити) семплювати не варто.

4. Фільтрація й групування:

  • ігнорувати шумні маршрути (/up, /livewire/*, /horizon/*) і ключі кешу;
  • групувати ключі кешу й URL з ідентифікаторами регулярними виразами - інакше user:1, user:2... стають мільйоном окремих рядків;
  • пороги «повільного» під свій застосунок (threshold), можна окремо для конкретних маршрутів.

5. Обрізання даних - Pulse видаляє записи поза вікном панелі за лотереєю під час приймання.

6. Процеси, що треба запускати й перезапускати:

  • pulse:check на кожному сервері для картки серверів;
  • pulse:work при прийманні через Redis;
  • pulse:restart під час деплою - обидві команди довгоживучі й не бачать нового коду.

7. Збої самого Pulse: якщо сховище Pulse недоступне, помилка не має ламати застосунок. Pulse за замовчуванням логує свої винятки, а обробник можна перевизначити через Pulse::handleExceptionsUsing().

Доступ: гейт viewPulse - панель показує email користувачів, SQL-запити й URL, це чутливі дані.

Докладніше в документації: Pulse: продуктивність

WebSocket-з'єднання довгоживучі: кожна відкрита вкладка - постійне з'єднання, яке тримає пам'ять і файловий дескриптор. Обмеження зазвичай не в процесорі, а в лімітах ОС, вебсервера й циклу подій.

1. Ліміт відкритих файлів. У Unix кожне з'єднання - файл. Типовий ліміт ulimit -n - 1024:

# /etc/security/limits.conf
forge  soft  nofile  10000
forge  hard  nofile  10000

Під Supervisor - minfds=10000 у supervisord.conf, бо процес успадковує ліміти від менеджера процесів.

2. Цикл подій. Reverb працює на ReactPHP, і за замовчуванням використовує stream_select, обмежений приблизно 1024 файлами. Для понад тисячі з'єднань потрібне розширення ext-uv (pecl install uv) - Reverb підхопить його автоматично.

3. Вебсервер перед Reverb. Nginx проксіює WebSocket із заголовками Upgrade і Connection "Upgrade", і має власні ліміти:

worker_rlimit_nofile 10000;
events {
    worker_connections 10000;
}

4. Горизонтальне масштабування. Один процес Reverb однопотоковий. Коли одного сервера замало:

REVERB_SCALING_ENABLED=true
  • усі сервери Reverb підключаються до спільного Redis і обмінюються повідомленнями через pub/sub: подія, отримана одним сервером, доставляється клієнтам на всіх;
  • сервери стоять за балансувальником, що підтримує WebSocket;
  • Redis для масштабування - центральна залежність: його відмова розриває обмін між серверами.

5. Деплой і перезапуск. reverb:restart коректно закриває з'єднання, і клієнти Echo перепідключаються. Але тисячі одночасних перепідключень - стрибок навантаження на Reverb і на ендпойнт авторизації каналів /broadcasting/auth (кожне приватне з'єднання - запит до застосунку). Варто перезапускати сервери по черзі.

6. Моніторинг. Reverb інтегрується з Pulse: картки з кількістю з'єднань і повідомлень. Слідкуйте за пам'яттю процесу й pulse:check на серверах Reverb.

7. Що навантажує систему крім самих з'єднань:

  • трансляція йде через чергу - при масових подіях пропускна здатність черги стає вузьким місцем;
  • розмір повідомлень - транслюйте ідентифікатори й мінімум полів, а не моделі з усіма зв'язками;
  • частота - події, що генеруються сотні разів на секунду, варто об'єднувати чи дебаунсити.

Альтернатива власному масштабуванню - керований сервіс (Pusher, Ably, Laravel Cloud) з тим самим протоколом: код застосунку не змінюється.

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

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

1. Failover - резервні транспорти:

// config/mail.php
'mailers' => [
    'failover' => [
        'transport' => 'failover',
        'mailers' => ['postmark', 'mailgun', 'sendmail'],
        'retry_after' => 60,
    ],
],
MAIL_MAILER=failover

Якщо postmark повертає помилку, лист іде через mailgun. Транспорт, що впав, позначається недоступним на retry_after секунд - наступні листи одразу йдуть через резервний. Механізм побудований на FailoverTransport із Symfony Mailer.

2. Round robin - розподіл навантаження:

'roundrobin' => [
    'transport' => 'roundrobin',
    'mailers' => ['ses', 'postmark'],
],

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

Що варто врахувати з кількома провайдерами: кожен потребує налаштованих SPF, DKIM і домену відправника, інакше резервні листи потраплять у спам. Вебхуки про відмови й скарги (bounces, complaints) теж треба приймати від усіх.

3. Різні мейлери для різних листів:

Mail::mailer('postmark')->to($user)->send(new PasswordReset($token));   // транзакційні
Mail::mailer('ses')->to($subscribers)->queue(new Newsletter($issue));   // масові

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

4. Ліміти частоти. Провайдери обмежують кількість листів за секунду чи добу. Laravel не обмежує сам - це робиться на рівні черги:

  • окрема черга mail з фіксованою кількістю воркерів;
  • middleware завдань RateLimited чи Redis::throttle() для листів у черзі;
  • $tries і backoff, щоб тимчасові помилки (429, 5xx) повторювалися з затримкою.

5. Спостереження й налагодження:

  • події MessageSending і MessageSent - для журналу надісланого чи блокування листів за умовою;
  • перегляд у браузері під час розробки: маршрут, що повертає mailable, рендерить його як сторінку;
  • на staging - драйвер log чи Mailpit/Mailtrap, а також Mail::alwaysTo() у середовищі, щоб тестові листи не потрапили справжнім клієнтам.

6. Тести: Mail::fake() і assertQueued перевіряють, що лист поставлено в чергу з правильними даними, а assertSeeInHtml - зміст листа.

Докладніше в документації: Пошта: конфігурація failover

Сповіщення з інтерфейсом ShouldQueue надсилаються у фоні. Важлива деталь реалізації: для кожного отримувача і кожного каналу Laravel ставить в чергу окреме завдання.

Сповіщення з каналами ['mail', 'database', 'slack'] для 1000 користувачів - це 3000 завдань.

Наслідки:

  • канали незалежні: якщо Slack недоступний, пошта й запис у базі все одно пройдуть. Повторюватиметься лише завдання Slack;
  • порядок не гарантований: запис у базі може з'явитися раніше чи пізніше за лист;
  • масові сповіщення створюють сплеск завдань - варто винести їх в окрему чергу.

Різні черги й підключення для каналів:

public function viaQueues(): array
{
    return [
        'mail' => 'mail',
        'slack' => 'notifications-slow',
        'database' => 'default',
    ];
}

Так повільний чи нестабільний канал не затримує швидкі. Аналогічно viaConnections() для різних підключень черги, а withDelay() - для різних затримок по каналах.

Остаточна перевірка у воркері - shouldSend:

public function shouldSend(object $notifiable, string $channel): bool
{
    return $this->invoice->fresh()->isUnpaid();
}

Між постановкою в чергу й обробкою можуть минути хвилини (при затримці - години). Нагадування про неоплачений рахунок, який уже оплатили, - типовий баг. shouldSend викликається у воркері окремо для кожного каналу, і false скасовує надсилання.

Транзакції - afterCommit:

$user->notify((new InvoicePaid($invoice))->afterCommit());

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

Збої:

  • метод failed(Throwable $e) на класі сповіщення викликається, коли завдання вичерпало спроби;
  • $tries, backoff(), retryUntil() і middleware завдань (RateLimited для API месенджерів) працюють як у звичайних завданнях;
  • події NotificationSending (можна скасувати, повернувши false), NotificationSent і NotificationFailed - для аудиту й метрик.

Ідемпотентність: при повторі після тайм-ауту лист чи SMS може піти двічі - провайдер прийняв повідомлення, але відповідь не дійшла. Для критичних каналів зберігайте ID сповіщення (він спільний для всіх каналів одного надсилання) і перевіряйте, чи його вже доставлено.

Докладніше в документації: Сповіщення: черги

Перебудова потрібна, коли змінилася структура документа (toSearchableArray), налаштування ранжування, мовна обробка чи версія пошукового рушія. Наївний шлях - scout:flush і scout:import - залишає сайт без пошуку на весь час імпорту, а на мільйонах записів це години.

Налаштування індексу як код. Фільтровані, сортовані поля й правила ранжування описують у config/scout.php:

'meilisearch' => [
    'index-settings' => [
        Post::class => [
            'filterableAttributes' => ['author_id', 'tags', 'published_at'],
            'sortableAttributes' => ['published_at'],
            'searchableAttributes' => ['title', 'body', 'tags'],
        ],
    ],
],
php artisan scout:sync-index-settings

Команду додають у деплой. Зміна налаштувань у Meilisearch запускає перебудову індексу на боці рушія - він продовжує відповідати, але на великих індексах це навантаження.

Перебудова без простою - через новий індекс:

  1. версія в імені індексу:
public function searchableAs(): string
{
    return config('scout.prefix') . 'posts_' . config('search.posts_version');
}
  1. наповнити новий індекс, поки старий обслуговує пошук: окремий процес з новою версією в конфігурації виконує scout:queue-import;
  2. дочекатися завершення індексації на боці рушія (Meilisearch обробляє задачі асинхронно - успішний імпорт у Laravel ще не означає готовий індекс);
  3. перемкнути версію в конфігурації застосунку (або атомарно поміняти індекси місцями - Meilisearch має операцію swap indexes);
  4. досинхронізувати зміни, що відбулися під час імпорту: записи, оновлені після старту, - повторно (Post::where('updated_at', '>=', $startedAt)->searchable());
  5. видалити старий індекс після перевірки.

Чому крок 5 важливий: поки йде імпорт, звичайні збереження моделей пишуть у поточний індекс (той, що повертає searchableAs). Без досинхронізації новий індекс міститиме застарілі дані для змінених за цей час записів.

Інші пастки:

  • scout:import у черзі не відновлює зв'язки з makeAllSearchableUsing - денормалізовані дані зі зв'язків варто завантажувати в toSearchableArray, усвідомлюючи N+1, або імпортувати синхронно порціями;
  • видалення: записи, видалені під час перебудови, можуть «воскреснути» в новому індексі, якщо їх прочитали до видалення - перевіряйте shouldBeSearchable і результати пошуку фільтруйте по базі;
  • тест релевантності: набір контрольних запитів з очікуваними результатами, який порівнює старий і новий індекс перед перемиканням.

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

Проблема. Локаль - це стан поточного процесу: middleware встановлює App::setLocale('uk') для запиту. Але:

  • адміністратор з англійським інтерфейсом змінює статус замовлення - і лист клієнту генерується англійською;
  • воркер черги та запланована команда взагалі не мають запиту - вони працюють з локаллю за замовчуванням з config/app.php;
  • воркер обробляє завдання різних користувачів поспіль - локаль, встановлена одним завданням через setLocale, «протікає» в наступні.

Рішення 1 - явна локаль при надсиланні:

Mail::to($customer)->locale('uk')->queue(new OrderShipped($order));
$user->notify((new InvoicePaid($invoice))->locale('pl'));

Laravel запам'ятовує локаль у самому завданні, і воркер перемикається на неї лише на час рендерингу листа, а потім повертає попередню.

Рішення 2 - бажана локаль на моделі (краще):

use Illuminate\Contracts\Translation\HasLocalePreference;

class User extends Authenticatable implements HasLocalePreference
{
    public function preferredLocale(): string
    {
        return $this->locale ?? config('app.locale');
    }
}

Тепер усі листи й сповіщення для цього користувача автоматично йдуть його мовою - незалежно від того, хто і звідки їх надіслав. Викликати locale() не потрібно.

Що ще залежить від локалі - і про що забувають:

  • дати й числа: Carbon має власну локаль; $date->translatedFormat('j F') у листі має відповідати мові отримувача;
  • URL у листах: якщо мова закодована в адресі (/uk/orders/42), посилання мають генеруватися для локалі отримувача, а не поточного запиту;
  • тема листа, що формується в envelope() через __(), теж рендериться в локалі завдання - тож перекладайте її там, а не в конструкторі (конструктор виконується в локалі відправника);
  • довільні завдання, що формують тексти (PDF, експорт), - для них перемикання не автоматичне:
use Illuminate\Support\Traits\Localizable;

class GenerateInvoicePdf implements ShouldQueue
{
    use Localizable;

    public function handle(): void
    {
        $this->withLocale($this->user->preferredLocale(), fn () => $this->render());
    }
}

Трейт Localizable (його ж використовують Mailable і відправник сповіщень) повертає попередню локаль у блоці finally - навіть після винятку, на відміну від ручного setLocale.

Тести: відправити лист користувачу з локаллю uk, коли застосунок працює з en, і перевірити assertSeeInHtml('Замовлення відправлено') - цей тест ловить більшість проблем.

Докладніше в документації: Пошта: бажані локалі користувачів

dd() і логи показують значення в одній точці. Xdebug дає повноцінне налагодження: точки зупинки в IDE, покрокове виконання, перегляд усіх змінних і стеку, умовні точки зупинки. Для складної логіки (rebase-подібні алгоритми, вкладені колекції, події) це в рази швидше за десятки dump().

Режими Xdebug 3 вмикаються через xdebug.mode:

Режим Для чого
debug покрокове налагодження з IDE
develop покращені повідомлення про помилки й var_dump
profile профілювання: файли cachegrind з часом кожної функції
coverage покриття коду для тестів
off вимкнено (на продакшені Xdebug не встановлюють взагалі)

Базове налаштування:

xdebug.mode=debug
xdebug.start_with_request=trigger      ; лише коли є тригер, інакше все сповільнюється
xdebug.client_host=host.docker.internal
xdebug.client_port=9003

start_with_request=trigger вмикає налагодження лише за наявності cookie чи параметра XDEBUG_TRIGGER (розширення браузера Xdebug Helper ставить його кнопкою). Без цього кожен запит намагається підключитися до IDE і помітно гальмує.

У Docker головна пастка - напрямок з'єднання: Xdebug сам підключається до IDE, тобто з контейнера на хост. Тому client_host - адреса хоста: host.docker.internal (на Linux потрібен extra_hosts: host.docker.internal:host-gateway). Laravel Sail робить це через змінну SAIL_XDEBUG_MODE.

Мапінг шляхів: у контейнері код лежить у /var/www/html, на хості - в іншому каталозі. У PhpStorm налаштовується сервер з іменем (PHP_IDE_CONFIG=serverName=laravel) і відповідністю шляхів, інакше точки зупинки не спрацьовують.

Налагодження консолі й тестів:

XDEBUG_TRIGGER=1 php artisan queue:work --once
XDEBUG_MODE=debug XDEBUG_TRIGGER=1 php artisan test --filter=OrderTest

Профілювання: xdebug.mode=profile записує файл на кожен запит; його відкривають у PhpStorm, KCachegrind чи Webgrind і бачать, де витрачено час. Профілювання сильно сповільнює виконання, тож абсолютні значення не відповідають продакшену - важливі пропорції. Для продакшену - вибіркові профайлери на кшталт Blackfire чи SPX.

Чому Xdebug не має бути завжди увімкненим: навіть у режимі debug без підключення він сповільнює PHP, а composer і тести стають помітно повільнішими. Зручно мати швидкий спосіб вмикати його лише на час налагодження.

Докладніше в документації: Laravel Sail: налагодження з Xdebug

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

Рівні
Junior 101 Middle 147 Senior 130

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