Питання на співбесіді: Відповіді й 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-CookieCDN зазвичай не кешує, тож 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) - для посилань, які не можна підробити: відписка, підтвердження пошти.
Коли однакова логіка формування відповіді повторюється в багатьох контролерах, є два інструменти, щоб винести її в одне місце.
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 і кешування: одна адреса з різним вмістом.