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

Питання на співбесіді: Відповіді й URL

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

8 питань

Контролер чи замикання маршруту може повернути майже будь-що - Laravel сам перетворить результат на об'єкт Response:

Що повертаємо Що отримує клієнт
рядок text/html з цим рядком
масив JSON
модель Eloquent чи колекція JSON (з урахуванням $hidden, $appends)
view('posts.show', [...]) HTML з шаблону
response(...) / response()->json(...) повний контроль: статус, заголовки, cookie
redirect(...) відповідь 302 з заголовком Location
API Resource JSON у форматі ресурсу
об'єкт з інтерфейсом Responsable те, що поверне його toResponse()
Route::get('/ping', fn () => 'pong');
Route::get('/settings', fn () => ['theme' => 'dark']);
Route::get('/users/{user}', fn (User $user) => $user);

return response('Created', 201)
    ->header('X-Request-Id', $requestId)
    ->cookie('seen_intro', '1', minutes: 60 * 24 * 30);

return response()->json(['message' => 'Accepted'], 202);
return response()->noContent();     // 204

Чому краще явні відповіді для API:

  • модель напряму віддає всі її атрибути, крім $hidden. Додали нову колонку в таблицю - вона автоматично з'явилася в API. API Resource описує формат явно;
  • статус-код: масив чи модель завжди повертають 200, а створення ресурсу - це 201, видалення - 204;
  • заголовки (кешування, розташування нового ресурсу) задаються лише через об'єкт відповіді.

Відповідь має бути повернута: її треба саме повернути з контролера. Виклик response(...) без return нічого не робить. Щоб перервати виконання з будь-якого місця, використовують виняток: abort(404), throw ValidationException::withMessages(...) чи власний виняток з методом render().

Middleware бачить уже готову відповідь: після $next($request) можна додати заголовки чи cookie до будь-якої відповіді, незалежно від того, що повернув контролер.

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

Основні способи:

return redirect('/dashboard');                         // за шляхом
return redirect()->route('posts.show', $post);         // за іменем маршруту (модель підставить ключ)
return to_route('posts.show', ['post' => $post]);      // коротша форма
return redirect()->action([PostController::class, 'index']);
return back();                                         // на попередню сторінку
return redirect()->away('https://partner.example.com'); // на зовнішній домен без перевірки URL

Разом з повідомленням - флеш-дані сесії, доступні лише в наступному запиті:

return redirect()->route('posts.index')
    ->with('status', 'Публікацію збережено.');
@if (session('status'))
    <div class="alert">{{ session('status') }}</div>
@endif

Повернути з введеними даними й помилками (якщо перевіряєте вручну, а не через валідацію):

return back()
    ->withInput()
    ->withErrors(['email' => 'Цю адресу вже зареєстровано.']);

У формі введене повертається через old('email'), помилки - через @error('email'). Валідація Laravel робить це сама: при помилці вона кидає виняток, що перетворюється саме на такий редирект.

Патерн Post/Redirect/Get: після успішної обробки форми (POST) - завжди редирект, а не відображення сторінки. Інакше оновлення сторінки в браузері повторно відправить форму: другий платіж, дубль коментаря.

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

  • redirect() без return - нічого не відбувається, виконання триває;
  • back() без запасного варіанта: якщо користувач прийшов за прямим посиланням, «назад» може вести на зовнішній сайт чи нікуди. back(fallback: route('home'));
  • редирект за URL з параметра запиту (redirect($request->input('next'))) - відкритий редирект, яким користуються фішери. Для повернення після входу - redirect()->intended(...), а сторонні адреси - лише зі списку дозволених;
  • away() для внутрішніх адрес - він не перевіряє URL взагалі; для своїх сторінок - route().

Коди: redirect() за замовчуванням дає 302. Постійне перенесення сторінки (SEO) - 301: redirect('/new', 301) чи Route::permanentRedirect('/old', '/new') у маршрутах.

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

Три методи для трьох ситуацій:

1. download - завантажити файл, що вже є на диску:

return response()->download(storage_path('app/reports/2026-q3.pdf'));
return response()->download($path, 'Звіт за III квартал.pdf');   // інша назва для користувача
return response()->download($path)->deleteFileAfterSend();      // тимчасовий файл

Заголовок Content-Disposition: attachment - браузер пропонує зберегти файл.

2. file - показати файл у браузері (зображення, PDF):

return response()->file(storage_path('app/invoices/42.pdf'));

Content-Disposition: inline - PDF відкриється у вбудованому переглядачі.

3. streamDownload - файл, якого немає на диску, генерується на льоту:

return response()->streamDownload(function () {
    $out = fopen('php://output', 'w');
    fputcsv($out, ['id', 'email']);

    User::query()->lazyById(1000)->each(function (User $user) use ($out) {
        fputcsv($out, [$user->id, $user->email]);
    });

    fclose($out);
}, 'users.csv', ['Content-Type' => 'text/csv']);

Дані віддаються частинами - експорт мільйона рядків не займає мільйона рядків пам'яті й не створює тимчасових файлів.

З дисків Storage (зокрема S3):

return Storage::download('reports/2026-q3.pdf', 'report.pdf');
return Storage::disk('s3')->response('avatars/42.jpg');   // показати

Безпека - головне при віддачі файлів:

  • шлях ніколи не будується з введення користувача напряму: download(storage_path('app/' . $request->file)) дозволить завантажити ../../.env (обхід шляху). Файл шукають за ідентифікатором у базі, де записано шлях;
  • перевірка прав перед віддачею - політика чи Gate, як для будь-якого ресурсу;
  • приватні файли не кладуть у public/ чи публічний диск - вони мають віддаватися лише через контролер з перевіркою;
  • назва файлу від користувача - через download($path, $safeName), Laravel сам коректно закодує не-ASCII символи в заголовку.

Для великих файлів на S3 краще не проганяти їх через PHP взагалі: тимчасове підписане посилання Storage::temporaryUrl() - і браузер завантажує прямо зі сховища.

Докладніше в документації: Відповіді: завантаження файлів

Додати cookie до відповіді:

return response('Hello')->cookie('theme', 'dark', minutes: 60 * 24 * 365);

return response('Hello')->withoutCookie('theme');   // видалити

Черга cookie - коли відповіді ще немає (сервіс, middleware, слухач події):

use Illuminate\Support\Facades\Cookie;

Cookie::queue('last_seen_post', $post->id, minutes: 60);
Cookie::expire('promo_banner');

Laravel прикріпить cookie з черги до відповіді, яка буде відправлена.

Читання:

$theme = $request->cookie('theme');

Шифрування за замовчуванням. Middleware EncryptCookies шифрує й підписує всі cookie, які створює Laravel, ключем APP_KEY. Наслідки:

  • клієнт не може прочитати чи підробити значення - змінене cookie просто не розшифрується, і $request->cookie() поверне null;
  • JavaScript бачить зашифрований рядок, а не значення;
  • cookie, встановлене не Laravel (з JavaScript чи іншим сервісом), Laravel спробує розшифрувати й отримає null.

Виключення з шифрування - для cookie, які має читати фронтенд чи інший сервіс:

// bootstrap/app.php
->withMiddleware(function (Middleware $middleware): void {
    $middleware->encryptCookies(except: [
        'theme',
        'consent',
    ]);
})

Незашифровані cookie не можна вважати надійними: користувач змінює їх як завгодно, тож у них - лише налаштування інтерфейсу, ніколи не права чи ідентифікатори.

Атрибути безпеки (за замовчуванням з config/session.php): secure, httpOnly, sameSite. Для власних cookie їх можна передати в cookie():

cookie('theme', 'dark', 525600, path: '/', domain: null, secure: true, httpOnly: false, sameSite: 'lax');

Що пам'ятати:

  • зміна APP_KEY робить усі зашифровані cookie, включно з сесією, недійсними - користувачів розлогінить (допомагає APP_PREVIOUS_KEYS);
  • розмір: браузер обмежує cookie приблизно 4 КБ, а шифрування збільшує значення - великі дані в cookie не кладуть;
  • кешування сторінок: відповідь з Set-Cookie CDN зазвичай не кешує, тож cookie на кожній сторінці ламає кеш на краю мережі.

Докладніше в документації: Відповіді: cookie у відповідях

Звичайна відповідь формується повністю, а потім відправляється. Потокова - відправляється частинами в міру готовності: користувач бачить перші дані раніше, а сервер не тримає всю відповідь у пам'яті.

stream - довільні дані частинами:

return response()->stream(function (): void {
    foreach ($this->generator->chunks() as $chunk) {
        echo $chunk;
        ob_flush();
        flush();
    }
}, 200, ['X-Accel-Buffering' => 'no']);

Простіше - передати замикання-генератор: Laravel сам відправлятиме кожне yield і скидатиме буфер:

Route::get('/answer', function (LlmClient $llm) {
    return response()->stream(function () use ($llm) {
        foreach ($llm->stream(request('prompt')) as $token) {
            yield $token;
        }
    });
});

streamJson - великий JSON, де частина даних - лінива колекція:

return response()->streamJson([
    'users' => User::query()->cursor(),
]);

Рядки читаються з бази й кодуються в JSON по одному - пам'ять не залежить від кількості записів.

eventStream - Server-Sent Events (text/event-stream):

return response()->eventStream(function () {
    while ($progress = $this->import->progress()) {
        yield new StreamedEvent(event: 'progress', data: ['percent' => $progress]);
        sleep(1);
    }
});

Браузер слухає через EventSource, а наприкінці Laravel надсилає завершальне повідомлення </stream>. Для React і Vue є готові хуки useStream і useEventStream з пакетів @laravel/stream-react / @laravel/stream-vue.

Що ламає потокову передачу:

  • буферизація на шляху: Nginx за замовчуванням буферизує відповідь від PHP-FPM - заголовок X-Accel-Buffering: no чи налаштування proxy_buffering off; так само CDN і стиснення gzip, що чекає на заповнення буфера;
  • тайм-аути: max_execution_time, тайм-аути проксі й балансувальника обривають довге з'єднання;
  • воркер зайнятий: кожен відкритий потік тримає процес PHP-FPM. Сотня користувачів, що слухають SSE, - сотня зайнятих воркерів. Для масових підписок на події краще WebSocket через Reverb;
  • сесія: блокування сесії (->block()) чи сесія, відкрита на весь час потоку, затримує інші запити того самого користувача;
  • помилка посеред потоку: статус 200 уже відправлено - про помилку доведеться повідомити в самих даних.

Коли доречно: відповіді LLM по токенах, експорт великих даних, прогрес довгої операції.

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

Основні хелпери:

url('/posts/42');                              // абсолютний URL з APP_URL чи поточного хоста
route('posts.show', $post);                    // за іменем маршруту - рекомендований спосіб
route('posts.show', ['post' => $post, 'tab' => 'comments']);  // зайві параметри стають query
route('posts.index', absolute: false);         // відносний шлях /posts
action([PostController::class, 'show'], $post);
secure_url('/checkout');                       // примусово https

Чому route(), а не рядки: змінили URL у файлі маршрутів - усі посилання оновилися. Помилка в імені маршруту - виняток одразу, а не тихе посилання в нікуди.

Модель як параметр: Laravel підставляє ключ маршруту моделі (getRouteKey()), тож якщо маршрут використовує {post:slug} чи модель перевизначає getRouteKeyName(), у URL буде slug.

Поточна й попередня адреса:

url()->current();      // без query
url()->full();         // з query
url()->previous();     // з заголовка Referer чи сесії
url()->previousPath();
request()->routeIs('posts.*');   // для активних пунктів меню

Змінити параметри поточної адреси (наприклад, сторінку чи сортування) зручніше не склеюванням рядків, а через $request->fullUrlWithQuery(['page' => 3]) чи об'єкт Uri.

Типові проблеми:

  • неправильний домен чи http замість https за балансувальником - не налаштовані довірені проксі, і Laravel не знає, що запит прийшов по HTTPS;
  • URL у листах і завданнях черги будуються з APP_URL, бо поточного запиту немає. Неправильний APP_URL - посилання на localhost у листах;
  • url()->previous() для логіки безпеки - заголовок Referer контролює клієнт;
  • підписані URL (URL::signedRoute) - для посилань, які не можна підробити: відписка, підтвердження пошти.

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

Коли однакова логіка формування відповіді повторюється в багатьох контролерах, є два інструменти, щоб винести її в одне місце.

1. Інтерфейс Responsable - об'єкт сам знає, як перетворитися на відповідь:

use Illuminate\Contracts\Support\Responsable;

final class CsvExport implements Responsable
{
    public function __construct(
        private iterable $rows,
        private string $filename,
    ) {}

    public function toResponse($request): StreamedResponse
    {
        return response()->streamDownload(function () {
            $out = fopen('php://output', 'w');
            foreach ($this->rows as $row) {
                fputcsv($out, $row);
            }
            fclose($out);
        }, $this->filename, ['Content-Type' => 'text/csv']);
    }
}
public function export(): CsvExport
{
    return new CsvExport(User::query()->cursor()->map->only('id', 'email'), 'users.csv');
}

Контролер повертає намір («ось експорт»), а не деталі HTTP. Так влаштовані API Resources, Uri, Inertia-відповіді.

Responsable з урахуванням формату запиту:

public function toResponse($request)
{
    return $request->expectsJson()
        ? response()->json(['id' => $this->order->id], 201)
        : to_route('orders.show', $this->order)->with('status', 'Замовлення створено');
}

Один контролер обслуговує і форму, і API.

2. Макроси відповідей - новий метод на фабриці response():

// AppServiceProvider::boot()
Response::macro('problem', function (string $title, int $status, array $extra = []) {
    return Response::json(
        ['type' => 'about:blank', 'title' => $title, 'status' => $status, ...$extra],
        $status,
        ['Content-Type' => 'application/problem+json'],
    );
});
return response()->problem('Недостатньо коштів', 422, ['balance' => $balance]);

Що обрати:

Responsable Макрос
де живе логіка у власному класі у сервіс-провайдері
тестування клас тестується окремо через HTTP-тест
автодоповнення в IDE повне потрібні PHPDoc чи ide-helper
для чого складні відповіді з даними короткі допоміжні методи формату

Ризики:

  • макроси глобальні й неявні: новий розробник не знайде, де визначено response()->problem(), а статичний аналіз їх не бачить без додаткових анотацій;
  • конфлікт імен з майбутніми методами фреймворку чи пакетів;
  • Responsable не повинен робити побічних дій (запис у базу, відправку листів) - він має лише формувати відповідь; інакше middleware чи тести, що створюють відповідь, викличуть побічні ефекти.

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

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

Задача: усі маршрути мають префікс {locale} чи {team}:

Route::prefix('{locale}')->group(function () {
    Route::get('/posts', [PostController::class, 'index'])->name('posts.index');
    Route::get('/posts/{post}', [PostController::class, 'show'])->name('posts.show');
});

Тепер кожен виклик route() вимагає locale: route('posts.show', ['locale' => app()->getLocale(), 'post' => $post]). Забули - виняток Missing required parameter. Посилань сотні.

URL::defaults - значення параметра за замовчуванням на весь запит:

final class SetUrlDefaults
{
    public function handle(Request $request, Closure $next): Response
    {
        URL::defaults(['locale' => $request->route('locale') ?? config('app.locale')]);

        return $next($request);
    }
}

Далі просто route('posts.show', $post) - locale підставиться сам.

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

->withMiddleware(function (Middleware $middleware): void {
    $middleware->prependToPriorityList(
        before: \Illuminate\Routing\Middleware\SubstituteBindings::class,
        prepend: \App\Http\Middleware\SetUrlDefaults::class,
    );
})

Де ще потрібні значення за замовчуванням:

  • черги, команди, листи - там немає поточного запиту й middleware не виконується. Завдання, яке генерує посилання для листа, має встановити URL::defaults саме (наприклад, з локалі отримувача) - інакше знову Missing required parameter;
  • Octane: значення за замовчуванням зберігаються в генераторі URL між запитами в тому самому воркері, тож middleware має встановлювати їх на кожен запит, а не лише коли значення змінилося;
  • тести: у тестах генерації посилань поза HTTP-запитом значення треба задати явно.

Альтернативи:

  • субдомени ({team}.app.com) - те саме з параметром домену в Route::domain('{team}.example.com');
  • локаль без префікса URL (з налаштувань користувача чи заголовка) - простіше для маршрутів, але гірше для SEO і кешування: одна адреса з різним вмістом.

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