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

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

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

7 питань

Через об'єкт Request, який Laravel автоматично впроваджує в метод контролера:

public function store(Request $request)
{
    $name = $request->input('name', 'default');
    $email = $request->string('email'); // типізовані хелпери
    $active = $request->boolean('active');
    $all = $request->only(['name', 'email']);
}

Динамічний доступ $request->name теж працює, але його уникають через можливі колізії з параметрами маршруту. Для файлів - $request->file('avatar'), для перевірки наявності - $request->has('key').

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

Дані запиту приходять з різних місць, і Laravel дає окремі методи для кожного.

input() - тіло запиту й рядок запиту разом:

$name = $request->input('name');
$city = $request->input('address.city');     // вкладене поле через крапку
$tags = $request->input('tags.*.name');      // усі значення з масиву
$page = $request->input('page', 1);          // значення за замовчуванням

Для форм (application/x-www-form-urlencoded, multipart/form-data) і JSON-тіла (application/json) працює однаково. Якщо поле є і в тілі, і в рядку запиту, перевагу має тіло.

query() - лише рядок запиту (?sort=name&page=2):

$sort = $request->query('sort', 'created_at');

Корисно, коли важливо, звідки прийшло значення: наприклад, параметр фільтра не повинен підмінятися полем у тілі POST-запиту.

json() - лише JSON-тіло:

$items = $request->json('items');

Параметри маршруту - частина URL, а не вхідні дані запиту:

Route::get('/users/{user}/posts/{post}', function (User $user, Post $post) { ... });

$request->route('post');   // з будь-якого місця, наприклад у middleware

Їх отримують аргументами контролера (з прив'язкою до моделі), а не через input().

Інші джерела:

Метод Що повертає
$request->all() усі вхідні дані разом з файлами
$request->only(['name', 'email']) лише вказані поля
$request->except(['password']) усе, крім указаних
$request->header('X-Request-Id') заголовок
$request->cookie('theme') cookie (розшифрований)
$request->file('avatar') завантажений файл

Важливе правило: для збереження даних використовуйте не all() чи input(), а $request->validated() з Form Request - лише ті поля, які пройшли валідацію. Інакше клієнт може надіслати поля, яких ви не очікували.

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

Форма має відправляти файли як multipart/form-data:

<form method="POST" action="/profile/avatar" enctype="multipart/form-data">
    @csrf
    <input type="file" name="avatar">
</form>

Без enctype браузер відправить лише назву файлу.

Отримання файлу:

$file = $request->file('avatar');     // UploadedFile чи null
$request->hasFile('avatar');          // чи є файл у запиті
$file->isValid();                     // чи завантажився без помилок

Спершу - валідація:

$validated = $request->validate([
    'avatar' => ['required', 'image', 'mimes:jpg,png,webp', 'max:2048'],   // max у кілобайтах
    'documents.*' => ['file', 'mimes:pdf', 'max:10240'],
]);

Збереження:

$path = $request->file('avatar')->store('avatars', 's3');
// avatars/Xk9...q2.jpg - унікальна випадкова назва

$path = $request->file('avatar')->storeAs('avatars', "{$user->id}.webp", 'public');

store() сам генерує унікальну назву - це правильний варіант за замовчуванням. У базу зберігають шлях, а не сам файл.

Інформація про файл:

Метод Що повертає Чи можна довіряти
getClientOriginalName() назва з комп'ютера користувача ні - лише для відображення
getClientOriginalExtension() розширення з назви ні
extension() розширення за вмістом (MIME) так
getSize() розмір у байтах так
getMimeType() тип за вмістом так

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

  • зберігати файл під оригінальною назвою - перезапис чужих файлів, обхід шляху, shell.php;
  • перевіряти лише розширення з назви файлу;
  • забути про ліміти PHP: upload_max_filesize і post_max_size у php.ini (і client_max_body_size у Nginx). Якщо файл більший, запит приходить без файлу і без зрозумілої помилки валідації - виглядає, ніби поле порожнє;
  • завантажувати користувацькі файли в public/ поруч з кодом.

Множинне завантаження: <input type="file" name="photos[]" multiple> і $request->file('photos') повертає масив.

Докладніше в документації: Завантажені файли

Form Request перевіряється ще до виклику методу контролера - коли контейнер створює його як аргумент. Порядок кроків:

  1. authorize() - false чи заборонена відповідь політики → AuthorizationException → 403. Якщо методу немає, запит вважається дозволеним;
  2. prepareForValidation() - підготувати дані (нормалізувати, об'єднати);
  3. rules() + валідатор, разом з after() - додаткові перевірки після основних правил;
  4. при помилці - failedValidation(): редирект назад з помилками чи 422 для JSON;
  5. при успіху - passedValidation(), і лише потім виконується контролер.

after() - перевірки, яким потрібні вже провалідовані значення чи кілька полів одразу:

public function after(): array
{
    return [
        function (Validator $validator) {
            if ($this->date('ends_at') <= $this->date('starts_at')) {
                $validator->errors()->add('ends_at', 'Кінець має бути пізніше за початок.');
            }
        },
        new EnsureSlotIsFree,   // клас з __invoke(Validator $validator)
    ];
}

Налаштування поведінки - властивостями чи, у Laravel 13, атрибутами класу:

#[StopOnFirstFailure]                   // зупинитися на першому полі з помилкою
#[RedirectToRoute('checkout.show')]     // куди повертати замість back()
#[ErrorBag('checkout')]                 // окремий мішок помилок для кількох форм на сторінці
#[FailOnUnknownFields]                  // помилка на полях, яких немає в rules()
final class CheckoutRequest extends FormRequest { /* ... */ }

FailOnUnknownFields - новинка Laravel 13: якщо клієнт надіслав поле, якого немає в rules(), кожне таке поле отримує помилку prohibited. Захищає від масового присвоєння й опечаток у назвах полів на фронтенді. Глобально - FormRequest::failOnUnknownFields() у сервіс-провайдері.

Власна реакція на помилку - перевизначити failedValidation():

protected function failedValidation(Validator $validator): void
{
    throw new HttpResponseException(response()->json([
        'title' => 'Validation failed',
        'errors' => $validator->errors(),
    ], 422));
}

Для API це зазвичай краще зробити один раз у bootstrap/app.php для всіх запитів, а не в кожному класі.

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

  • authorize() не замінює політик: вона виконується до валідації, тож дані запиту ще не перевірені - звертатися до них обережно;
  • validated() і safe() повертають лише перевірені поля - саме їх передають у модель, а не all();
  • after() виконується навіть після помилок основних правил - перевірки в ньому мають враховувати, що поля можуть бути порожніми чи некоректними.

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

Усі значення з HTTP-запиту - рядки (чи масиви рядків). input('active') поверне "0", "false", "on" чи null, і if ($request->input('active')) для "false" дасть true. Laravel має методи, що одразу приводять значення до потрібного типу.

Типізовані методи:

$request->string('name')->trim();          // Stringable - зручні методи рядків
$request->integer('per_page', 15);         // int, за замовчуванням 15
$request->float('price');
$request->boolean('subscribe');            // true для "1", "true", "on", "yes"
$request->date('birthday');                // Carbon чи null
$request->date('starts_at', 'Y-m-d', 'Europe/Kyiv');
$request->enum('status', OrderStatus::class);          // case enum чи null
$request->enums('statuses', OrderStatus::class);       // масив case
$request->array('ids');
$request->collect('items');                // колекція

boolean() особливо корисний для чекбоксів: незазначений чекбокс взагалі не відправляється, і метод поверне false.

enum() повертає null для невідомого значення замість винятку - безпечно для фільтрів у рядку запиту.

Перевірки наявності:

Метод true, коли
has('name') ключ є в запиті (навіть порожній)
filled('name') ключ є і значення не порожнє
missing('name') ключа немає
isNotFilled('name') ключа немає чи значення порожнє
anyFilled(['email', 'phone']) хоча б одне заповнене

Умовна обробка без if:

$query = Order::query();

$request->whenFilled('status', fn (string $status) => $query->where('status', $status));
$request->whenHas('archived', fn () => $query->onlyTrashed());

Де це доречно, а де ні:

  • фільтри, сортування, пагінація з рядка запиту - типізовані методи з безпечними значеннями за замовчуванням ідеальні;
  • дані для збереження - через Form Request і validated(): правила boolean, date, Rule::enum() перевіряють і відхиляють неправильне значення з помилкою для користувача, а не тихо підставляють null;
  • у Form Request ті самі типізовані методи доступні через $this->safe(): $request->safe()->integer('quantity').

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

Інколи дані треба нормалізувати до валідації: прибрати пробіли з телефону, згенерувати slug з назви, привести email до нижнього регістру.

merge і mergeIfMissing:

$request->merge(['slug' => Str::slug($request->input('title'))]);
$request->mergeIfMissing(['locale' => 'uk']);   // лише якщо поля немає

У Form Request - prepareForValidation():

final class StorePostRequest extends FormRequest
{
    protected function prepareForValidation(): void
    {
        $this->merge([
            'slug' => Str::slug($this->input('slug') ?: $this->input('title')),
            'phone' => preg_replace('/\D+/', '', (string) $this->input('phone')),
        ]);
    }

    public function rules(): array
    {
        return [
            'title' => ['required', 'string', 'max:255'],
            'slug' => ['required', 'alpha_dash', Rule::unique('posts')],
            'phone' => ['nullable', 'digits_between:10,12'],
        ];
    }
}

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

Після валідації - passedValidation() - для перетворень, які не мають впливати на перевірку (наприклад, хешування).

Глобальна нормалізація вже є: middleware TrimStrings обрізає пробіли, а ConvertEmptyStringsToNull перетворює порожні рядки на null. Через це nullable правила й порожні поля працюють очікувано. Виключити поля (наприклад, пароль) можна в bootstrap/app.php.

Чому обережно:

  • merge змінює спільний об'єкт запиту. Після нього змінене значення бачать усі: middleware, що виконуються далі, слухачі, логування. Нормалізація в одному контролері раптово впливає на інший код;
  • логіка ховається: читач контролера не бачить, що phone уже не те, що надіслав клієнт;
  • не підміняйте значення, які мають бути помилкою. Якщо status невідомий, правильна відповідь - помилка валідації, а не тихе merge(['status' => 'draft']);
  • не додавайте через merge дані, що не повинні приходити від клієнта (user_id, роль), щоб «пройти валідацію». Такі поля встановлюються явно при збереженні, а не потрапляють у вхідні дані.

Альтернатива для обчислюваних полів: не змінювати запит, а додати значення при збереженні: Post::create([...$request->validated(), 'user_id' => $request->user()->id]).

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

Http - обгортка над Guzzle з розумними значеннями за замовчуванням, але саме за замовчуванням і ховаються проблеми.

Базовий виклик:

$response = Http::withToken($token)
    ->timeout(5)
    ->retry(3, 200)
    ->get('https://api.example/vacancies', ['page' => 1]);

if ($response->failed()) {
    // ...
}

$data = $response->json();

Чотири речі, без яких у прод виходити не варто:

1. Таймаут. За замовчуванням запит може висіти 30 секунд, тримаючи PHP-воркер. Чужий сервіс, що «підвис», кладе ваш застосунок, а не свій. timeout(5) і connectTimeout(2) обовʼязкові.

2. Повтори з паузою. retry(3, 200) рятує від разових збоїв, але повторювати можна лише ідемпотентні запити: повтор POST про створення платежу створить його двічі.

3. Обробка помилки. Http не кидає виняток на 4xx/5xx - повертає відповідь із failed() === true. Код, що одразу робить ->json()['id'], отримає null і піде далі, ніби все гаразд. Або перевіряйте явно, або ставте ->throw().

4. Ізоляція від збоїв. Виклик чужого API під час обробки запиту робить вашу доступність залежною від чужої. Такі виклики належать у чергу, а на повторювані збої добре лягає circuit breaker.

У тестах - Http::fake(), щоб мережі не було зовсім:

Http::fake(['api.example/*' => Http::response(['id' => 1], 200)]);
Http::assertSent(fn ($request) => $request->url() === 'https://api.example/vacancies');

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