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

Питання на співбесіді: Форми й валідація

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

12 питань

wire:submit на тегу <form> перехоплює відправку форми й викликає метод компонента замість звичайного POST-запиту з перезавантаженням сторінки.

new class extends Component {
    public string $title = '';
    public string $content = '';

    public function save(): void
    {
        $validated = $this->validate([
            'title' => 'required|min:3',
            'content' => 'required',
        ]);

        auth()->user()->posts()->create($validated);

        $this->redirectRoute('posts.index');
    }
};
<form wire:submit="save">
    <input type="text" wire:model="title">
    <textarea wire:model="content"></textarea>
    <button type="submit">Опублікувати</button>
</form>

Що робить Livewire автоматично:

  • preventDefault() - модифікатор .prevent не потрібен, на відміну від інших подій;
  • блокує форму на час запиту: кнопки відправки стають неактивними, поля - доступними лише для читання. Подвійне натискання не створить два пости;
  • значення з wire:model потрапляють на сервер разом із запитом на відправку - окремі запити на кожне поле не потрібні.

Параметри методу можна передати прямо в директиві: wire:submit="save('draft')".

Чому type="submit" важливий: кнопка без типу всередині форми теж відправляє її, а кнопка «Скасувати» з wire:click усередині форми має бути type="button", інакше натискання на неї відправить форму.

Enter у полі відправляє форму, як у звичайному HTML, - це правильна поведінка для доступності, і її не варто ламати.

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

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

Валідація в Livewire побудована на звичайній валідації Laravel - ті самі правила й повідомлення.

Спосіб 1 - $this->validate() з правилами:

public function save(): void
{
    $validated = $this->validate([
        'title' => 'required|min:3|max:255',
        'email' => 'required|email',
    ]);

    Post::create($validated);
}

Якщо дані невалідні, метод кидає виняток, виконання дії зупиняється, а помилки повертаються в браузер. validate() повертає лише перевірені поля - їх безпечно передавати в create().

Спосіб 2 - атрибут #[Validate] над властивістю:

use Livewire\Attributes\Validate;

new class extends Component {
    #[Validate('required|min:3')]
    public string $title = '';

    #[Validate('required|email', as: 'електронна адреса')]
    public string $email = '';

    public function save(): void
    {
        $this->validate();   // правила беруться з атрибутів
    }
};

Правила поруч із властивістю, і поле перевіряється автоматично при кожному оновленні з браузера (якщо воно надсилається на сервер - з .live).

Показ помилок - звичайні директиви Blade:

<input wire:model="title" @error('title') aria-invalid="true" @enderror>
@error('title')
    <p class="text-sm text-red-600">{{ $message }}</p>
@enderror

Важливо: $this->validate() перед збереженням потрібен навіть з #[Validate] - атрибут перевіряє лише поля, які оновлювалися. Поле, якого користувач не торкався, без явного validate() залишиться неперевіреним.

Обмеження атрибутів: у PHP-атрибутах не можна використовувати об'єкти правил (Rule::unique(...), Password::defaults()). Для них - метод rules().

Українські повідомлення - через файли перекладів lang/uk/validation.php, як і в усьому Laravel; назву поля задає параметр as: чи метод validationAttributes().

Ручне керування помилками: $this->addError('email', 'Ця адреса вже зайнята'), $this->resetValidation('email').

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

Livewire відстежує, чи відрізняється стан у браузері від стану на сервері. Форма, в якій користувач щось змінив, але це ще не відправлено, вважається «брудною» (dirty).

wire:dirty показує елемент лише тоді, коли є незбережені зміни:

<form wire:submit="update">
    <input wire:model="title">
    <textarea wire:model="content"></textarea>

    <div wire:dirty class="text-amber-600">Є незбережені зміни</div>

    <button type="submit">Зберегти</button>
</form>

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

Варіанти:

  • wire:dirty.remove - навпаки: показувати елемент, доки змін немає («Усе збережено»);
  • wire:dirty.class="border-amber-500" - додати клас замість показу елемента;
  • wire:target="title" - стежити лише за конкретною властивістю:
<input wire:model.live.blur="title" wire:dirty.class="border-amber-500" wire:target="title">
  • $dirty у виразах і $wire.$dirty('email') в Alpine - перевірити програмно:
<button x-bind:disabled="! $wire.$dirty()">Зберегти</button>

Кнопка «Зберегти» активна лише тоді, коли є що зберігати.

Що важливо розуміти: «чисто» означає «синхронізовано із сервером», а не «збережено в базу». Якщо поле з wire:model.live уже відправило значення на сервер, а метод збереження ще не викликано, wire:dirty уже не покаже змін. Для форм, де важливо саме збереження в базу, стан «змінено» краще вести явно (наприклад, порівнювати з початковими значеннями).

Корисне доповнення: попередження при спробі піти зі сторінки з незбереженими змінами - обробник beforeunload в Alpine, що перевіряє $wire.$dirty().

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

wire:confirm показує діалог підтвердження перед виконанням дії. Якщо користувач натиснув «Скасувати» чи Escape, запит на сервер не надсилається.

<button
    type="button"
    wire:click="delete({{ $post->id }})"
    wire:confirm="Видалити цей пост? Дію не можна скасувати."
>
    Видалити
</button>

Працює з будь-якою дією: wire:click, wire:submit тощо.

Підтвердження введенням тексту - для особливо руйнівних дій (видалення облікового запису чи проєкту). Модифікатор .prompt, очікуваний текст - після |:

<button
    wire:click="deleteAccount"
    wire:confirm.prompt="Щоб підтвердити, введіть ВИДАЛИТИ|ВИДАЛИТИ"
>
    Видалити обліковий запис
</button>

Дія виконається лише якщо введено точно ВИДАЛИТИ (з урахуванням регістру).

Що варто знати:

  • це стандартні діалоги браузера (confirm() / prompt()): виглядають по-різному в різних браузерах і не стилізуються. Для фірмового вигляду роблять власне модальне вікно (Alpine чи готові компоненти на кшталт Flux), а дію викликають з кнопки всередині нього;
  • підтвердження - це UX, а не захист. Метод delete() можна викликати напряму з консолі браузера ($wire.delete(5)), минаючи діалог. Перевірка прав і власності запису обов'язкова в самому методі:
public function delete(Post $post): void
{
    $this->authorize('delete', $post);
    $post->delete();
}
  • для масових чи незворотних операцій варто подумати про «м'яке» видалення (SoftDeletes) і можливість відновити - це надійніше за будь-які діалоги;
  • текст діалогу - звичайний рядок, тож його варто перекладати так само, як решту інтерфейсу (wire:confirm="{{ __('...') }}").

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

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

php artisan livewire:form PostForm   # app/Livewire/Forms/PostForm.php
namespace App\Livewire\Forms;

use App\Models\Post;
use Illuminate\Validation\Rule;
use Livewire\Form;

class PostForm extends Form
{
    public ?Post $post = null;

    public string $title = '';
    public string $content = '';

    protected function rules(): array
    {
        return [
            'title' => ['required', 'max:255', Rule::unique('posts')->ignore($this->post)],
            'content' => ['required', 'min:10'],
        ];
    }

    public function setPost(Post $post): void
    {
        $this->post = $post;
        $this->fill($post->only(['title', 'content']));
    }

    public function save(): Post
    {
        $this->validate();

        $post = $this->post ?? new Post(['author_id' => auth()->id()]);
        $post->fill($this->only(['title', 'content']))->save();

        return $post;
    }
}

Компоненти створення й редагування:

// створення
public PostForm $form;

public function save(): void
{
    $post = $this->form->save();
    $this->redirectRoute('posts.show', $post);
}

// редагування
public function mount(Post $post): void
{
    $this->authorize('update', $post);
    $this->form->setPost($post);
}

У шаблоні поля адресуються через назву властивості з формою:

<input wire:model="form.title">
@error('form.title') <p>{{ $message }}</p> @enderror

Корисні методи форми: $this->form->all(), only([...]), reset() (повертає значення за замовчуванням), pull() (отримати значення й одразу скинути), validate().

Що варто знати:

  • ключі помилок мають префікс назви властивості: form.title, а не title - і в @error, і в тестах (assertHasErrors('form.title'));
  • хуки updated... працюють і всередині об'єкта форми;
  • авторизація лишається в компоненті чи в методі форми - об'єкт форми не робить дії безпечними сам по собі;
  • reset() повертає типізовані властивості без значення за замовчуванням у неініціалізований стан - полям варто задавати значення за замовчуванням.

Аналог у звичайному Laravel - Form Request, але об'єкт форми ще й тримає стан між запитами компонента.

Докладніше в документації: Форми: об'єкти форм

Валідація в реальному часі - перевірка поля під час заповнення форми, а не лише при відправці.

Що для цього потрібно:

  1. правила на властивості через #[Validate] - Livewire перевіряє властивість при кожному оновленні з браузера;
  2. оновлення має доходити до сервера - wire:model.live чи wire:model.live.blur.
#[Validate('required|email|unique:users,email')]
public string $email = '';
<input type="email" wire:model.live.blur="email">
@error('email') <p>{{ $message }}</p> @enderror

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

Чому не просто .live: перевірка на кожне натискання клавіші показує «Некоректна адреса», поки людина ще друкує, і створює запит на кожну паузу. Перевірка при виході з поля (.blur) - зручніша і дешевша. Виняток - поля, де миттєвий відгук корисний (сила пароля, доступність імені користувача).

Чому rules() не спрацьовує при введенні. Метод rules() застосовується лише при виклику $this->validate(). При оновленні властивості Livewire перевіряє тільки ті, що мають атрибут #[Validate]. Щоб і правила з rules() (наприклад, з об'єктами Rule::unique(), яких атрибут не дозволяє) перевірялися при введенні, додайте порожній атрибут:

#[Validate]
public string $email = '';

protected function rules(): array
{
    return ['email' => ['required', 'email', Rule::unique('users')->ignore(auth()->id())]];
}

Вимкнути автоматичну перевірку для поля з атрибутом - #[Validate('required', onUpdate: false)].

Перевірити одне поле вручну: $this->validateOnly('email') у хуку updated.

Помилки в JavaScript - магічна властивість $errors:

<p wire:show="$errors.has('email')" wire:text="$errors.first('email')"></p>

Підсумкова перевірка обов'язкова: валідація в реальному часі - зручність. Перед збереженням $this->validate() все одно викликається, бо частину полів користувач міг не чіпати, а запит можна відправити й в обхід інтерфейсу.

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

Запит Livewire - не звичайний запит сторінки, а AJAX. HTTP-редирект у відповідь на нього браузер не виконає як перехід. Тому Livewire передає «команду перейти» в JavaScript, а той змінює сторінку.

Методи компонента:

public function save(): void
{
    $post = $this->form->save();

    session()->flash('status', 'Пост опубліковано');

    $this->redirectRoute('posts.show', ['post' => $post]);
}
  • $this->redirect('/posts') - за адресою;
  • $this->redirectRoute('posts.show', [...]) - за назвою маршруту;
  • $this->redirectAction([PostController::class, 'index']) - на дію контролера;
  • $this->redirectIntended('/dashboard') - туди, куди користувач ішов до входу.

Повернути redirect()->route(...) з методу теж можна - Livewire розпізнає звичайну відповідь-редирект Laravel.

SPA-перехід замість повного завантаження:

$this->redirect('/posts', navigate: true);
$this->redirectRoute('posts.index', navigate: true);

Сторінка підвантажується як при wire:navigate: без перезавантаження скриптів і стилів, швидше.

Флеш-повідомлення працюють, як у звичайному Laravel, - session()->flash(), а на сторінці призначення:

@if (session('status'))
    <div class="alert">{{ session('status') }}</div>
@endif

Повідомлення без редиректу. Якщо після збереження користувач лишається на тій самій сторінці, флеш-повідомлення з'явиться лише при наступному запиті - не одразу. Тут краще:

  • подія в браузер, яку слухає компонент сповіщень ($this->dispatch('notify', message: 'Збережено'));
  • властивість компонента з текстом повідомлення.

Пастки:

  • код після редиректу виконується: $this->redirect() лише записує намір, а не зупиняє метод. Якщо після нього є ще логіка, вона спрацює - за потреби додайте return;
  • редирект на зовнішній домен з navigate: true не працює як SPA-перехід - для зовнішніх адрес звичайний redirect();
  • не редиректити за адресою з введення користувача без перевірки - відкритий редирект використовують для фішингу.

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

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

reset() повертає властивості до значень, оголошених у класі:

public string $body = '';
public ?int $rating = null;

public function addComment(): void
{
    $this->validate();
    $this->post->comments()->create($this->only(['body', 'rating']));

    $this->reset('body', 'rating');   // лише ці поля
    // $this->reset();                // усі публічні властивості
}

Обережно з reset() без аргументів: він скидає всі публічні властивості компонента, включно з тими, що встановлено в mount() (наприклад, $post), - і компонент «губить» свій контекст. Безпечніше явно перелічувати поля або тримати поля форми в об'єкті форми й скидати лише його: $this->form->reset().

pull() - отримати значення і одразу скинути:

$this->post->comments()->create($this->pull(['body', 'rating']));

resetExcept([...]) - скинути все, крім указаного.

Помилки валідації зберігаються окремо від значень:

$this->resetValidation();         // усі помилки
$this->resetValidation('body');   // помилку одного поля
$this->resetErrorBag();           // те саме, що resetValidation()

Типовий випадок - кнопка «Скасувати» в модальному вікні: скинути і поля, і помилки, щоб при наступному відкритті форма була чистою.

Що варто знати:

  • типізована властивість без значення за замовчуванням (public string $title;) після reset() стає неініціалізованою, і звернення до неї в шаблоні кине помилку. Задавайте значення за замовчуванням;
  • reset() не викликає хуків updated - це зміна на сервері, а не з браузера;
  • поля з wire:model без .live очищаються в інтерфейсі, бо після відповіді сервера Livewire морфить DOM з новими значеннями;
  • для полів, якими керує сторонній JavaScript (редактор тексту, вибір дати під wire:ignore), скидання властивості не очистить сам віджет - йому треба надіслати подію.

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

Компонент з трейтом WithFileUploads приймає файли через звичайний wire:model:

use Livewire\WithFileUploads;

new class extends Component {
    use WithFileUploads;

    #[Validate('image|max:2048')]   // 2 МБ
    public $photo;

    public function save(): void
    {
        $this->validate();
        $path = $this->photo->store(path: 'avatars', options: 'public');
        auth()->user()->update(['avatar_path' => $path]);
    }
};
<input type="file" wire:model="photo">
@if ($photo) <img src="{{ $photo->temporaryUrl() }}"> @endif

Як це працює під капотом:

  1. при виборі файлу JavaScript запитує в компонента підписаний URL для завантаження;
  2. файл завантажується за ним у тимчасовий каталог (livewire-tmp/ на диску за замовчуванням);
  3. властивість $photo отримує об'єкт тимчасового файлу (TemporaryUploadedFile);
  4. у вашому методі файл перевіряється й переноситься на постійне місце.

Ризики й захист:

  • глобальна валідація тимчасових завантажень - за замовчуванням file|max:12288 (12 МБ). Тобто ще до вашої валідації сервер приймає будь-який файл до 12 МБ. Звузьте в config/livewire.php (temporary_file_upload.rules), якщо застосунку не потрібні великі файли;
  • обмеження частоти - ендпойнт завантаження за замовчуванням має throttle-middleware; його можна змінити (temporary_file_upload.middleware). Але в Livewire 4 файли понад 1 МБ на локальному диску йдуть частинами (chunked upload) - один файл стає десятками швидких запитів. Надто жорсткий ліміт на кшталт throttle:60,1 ламає великі завантаження;
  • розмір файлу обмежує ваше правило max:, а не upload_max_filesize PHP: частини збираються на сервері, і перерване завантаження продовжується з місця зупинки;
  • перевірка типу - лише через правила валідації (image, mimes:pdf), які перевіряють вміст, а не розширення. Ніколи не зберігайте з оригінальною назвою від користувача без перевірки - використовуйте згенеровану (store() робить саме так);
  • попередній перегляд (temporaryUrl()) працює лише для зображень і через підписаний URL - чужі файли так не подивитися;
  • очищення - на локальному диску Livewire сам видаляє старі тимчасові файли; для S3 треба налаштувати правило життєвого циклу (php artisan livewire:configure-s3-upload-cleanup, файли старші 24 годин);
  • публічність - зберігайте в приватний диск усе, що не призначене для всіх, і віддавайте через підписані URL.

Пряме завантаження в S3 (LIVEWIRE_TEMPORARY_FILE_UPLOAD_DISK=s3) знімає навантаження з сервера застосунку: файл іде з браузера одразу в бакет. Але частина правил валідації потребує доступу до файлу - бакет має це дозволяти.

Пастка з назвою: upload - зарезервоване слово Livewire. Метод чи властивість з такою назвою зламають завантаження.

Тести: UploadedFile::fake()->image('avatar.jpg') з ->set('photo', $file) і Storage::fake().

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

Є три рівні складності - від простої обгортки до окремого Livewire-компонента.

1. Blade-компонент-обгортка над справжнім <input> - найчастіший випадок (мітка, поле, помилка в одному місці):

{{-- resources/views/components/input-text.blade.php --}}
@props(['name', 'label'])

<label>
    {{ $label }}
    <input type="text" name="{{ $name }}" {{ $attributes }}>
</label>
@error($name) <p class="text-red-600">{{ $message }}</p> @enderror
<x-input-text name="title" label="Заголовок" wire:model.live.blur="title" />

Секрет - {{ $attributes }}: wire:model з усіма модифікаторами переходить на справжнє поле. Для зручності з $attributes->wire('model') можна прочитати назву властивості й використати її для @error без окремого name.

2. Поле без нативного <input> на Alpine (лічильник, перемикач, вибір кольору) - x-modelable:

{{-- resources/views/components/input-counter.blade.php --}}
<div x-data="{ count: 0 }" x-modelable="count" {{ $attributes }}>
    <button type="button" x-on:click="count--">-</button>
    <span x-text="count"></span>
    <button type="button" x-on:click="count++">+</button>
</div>
<x-input-counter wire:model="quantity" />
<x-input-counter x-model="quantity" />   {{-- працює і в чистому Alpine --}}

x-modelable каже Alpine, яку змінну пов'язати з wire:model чи x-model на цьому елементі. Стан живе в браузері, сервер отримує значення разом з дією.

3. Окремий Livewire-компонент - #[Modelable], коли полю потрібна серверна логіка (пошук по базі для автодоповнення, завантаження варіантів):

use Livewire\Attributes\Modelable;

new class extends Component {
    #[Modelable]
    public ?int $value = null;

    public string $search = '';

    #[Computed]
    public function options() { /* пошук у базі за $search */ }
};
<livewire:user-picker wire:model="assigneeId" />

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

Пастки: забутий {{ $attributes }} (і wire:model мовчки не працює); type="button" на кнопках усередині форми; wire:key для таких полів у циклах.

Докладніше в документації: Атрибут Modelable

Автозбереження (як у Google Docs чи налаштуваннях) робиться хуком updated: поле відправляється на сервер при виході з нього й одразу записується в базу.

new class extends Component {
    public Post $post;

    #[Validate('required|max:255')]
    public string $title = '';

    #[Validate('required')]
    public string $content = '';

    public function mount(Post $post): void
    {
        $this->authorize('update', $post);
        $this->post = $post;
        $this->fill($post->only(['title', 'content']));
    }

    public function updated(string $property): void
    {
        if (! in_array($property, ['title', 'content'], true)) {
            return;
        }

        $this->authorize('update', $this->post);
        $this->post->update([$property => $this->{$property}]);
    }
};
<input wire:model.live.blur="title">
<textarea wire:model.live.blur="content"></textarea>
<span wire:dirty>Не збережено</span>

Правила з #[Validate] перевіряються до updated: невалідне значення не дійде до збереження.

Пастки:

1. Назва властивості - від клієнта. Варіант із документації, де updated($name, $value) робить update([$name => $value]), небезпечний без білого списку: клієнт може оновити будь-яку публічну властивість, і її назва стане назвою колонки в update(). Масове призначення з $fillable частково рятує, але явний перелік полів надійніший.

2. Авторизація на кожне збереження. Перевірка в mount() виконується один раз; права могли змінитися, а запит - бути підробленим.

3. Частково збережений стан. Поля зберігаються по одному. Якщо бізнес-правило охоплює кілька полів («дата завершення після дати початку»), окреме збереження першого поля може записати неузгоджені дані. Для таких полів - звичайна кнопка «Зберегти» з валідацією всього разом.

4. Навантаження й побічні ефекти. Кожне поле - запит і UPDATE, а з ним спостерігачі моделі, події, індексація пошуку, журнал змін. Для «важких» моделей краще зберігати чернетку окремо чи з затримкою.

5. Конкурентне редагування. Дві вкладки чи два редактори перезаписують зміни одне одного без попередження. Потрібна перевірка версії (updated_at) чи блокування запису.

6. Невидимий зворотний зв'язок. Користувач має бачити «Збережено» / «Помилка збереження» - інакше він не знає, чи можна закривати сторінку. wire:dirty, wire:loading і повідомлення про помилку обов'язкові.

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

Коли правила залежать від стану компонента чи потребують об'єктів правил Laravel, атрибутів #[Validate] замало - потрібен метод rules().

use Illuminate\Validation\Rule;

new class extends Component {
    public ?Product $product = null;

    public string $sku = '';
    public string $type = 'physical';
    public ?float $weight = null;

    /** @var list<array{name: string, qty: int}> */
    public array $items = [];

    protected function rules(): array
    {
        return [
            // унікальність, крім поточного запису при редагуванні
            'sku' => ['required', Rule::unique('products', 'sku')->ignore($this->product)],

            'type' => ['required', Rule::in(['physical', 'digital'])],

            // умовне правило: вага обов'язкова лише для фізичних товарів
            'weight' => [Rule::requiredIf($this->type === 'physical'), 'nullable', 'numeric', 'min:0'],

            // масив рядків
            'items' => ['array', 'min:1', 'max:50'],
            'items.*.name' => ['required', 'string', 'max:100'],
            'items.*.qty' => ['required', 'integer', 'min:1'],
        ];
    }

    protected function validationAttributes(): array
    {
        return ['items.*.qty' => 'кількість'];
    }
};

Шаблон з динамічними рядками:

@foreach ($items as $index => $item)
    <div wire:key="item-{{ $index }}">
        <input wire:model="items.{{ $index }}.name">
        @error("items.{$index}.name") <p>{{ $message }}</p> @enderror
    </div>
@endforeach
<button type="button" wire:click="addItem">Додати рядок</button>
public function addItem(): void
{
    if (count($this->items) < 50) {
        $this->items[] = ['name' => '', 'qty' => 1];
    }
}

Додавання через метод на сервері, а не через $set з браузера, дає змогу одразу обмежити кількість рядків.

Що варто знати:

  • rules() не перевіряється при введенні - лише в $this->validate(). Для перевірки в реальному часі - порожній #[Validate] над властивістю;
  • межі масиву обов'язкові (max:50): масив приходить з браузера, і без обмеження хтось надішле сто тисяч елементів;
  • ключ wire:key для рядків - бажано стабільний ідентифікатор, а не індекс, якщо рядки можна видаляти з середини списку;
  • помилки адресуються з індексом - items.3.qty;
  • messages() і validationAttributes() - власні тексти й назви полів; назви rules, messages, validationAttributes зарезервовані - не використовуйте їх для властивостей;
  • власні правила (php artisan make:rule) працюють так само, як у контролерах;
  • валідатор напряму: $this->withValidator(fn ($validator) => $validator->after(...)) - для перевірок, що охоплюють кілька полів.

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