Питання на співбесіді: Форми й валідація
Питання з реальних співбесід з відповідями: 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() - публічна дія, яку можна викликати з браузера будь-коли, тож перевірки прав мають бути всередині нього.
Валідація в 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: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="{{ __('...') }}").
Об'єкт форми - окремий клас, куди виносять поля, правила валідації й логіку збереження. Компонент стає коротшим, а форму можна використати в кількох компонентах.
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, але об'єкт форми ще й тримає стан між запитами компонента.
Валідація в реальному часі - перевірка поля під час заповнення форми, а не лише при відправці.
Що для цього потрібно:
- правила на властивості через
#[Validate]- Livewire перевіряє властивість при кожному оновленні з браузера; - оновлення має доходити до сервера -
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
Як це працює під капотом:
- при виборі файлу JavaScript запитує в компонента підписаний URL для завантаження;
- файл завантажується за ним у тимчасовий каталог (
livewire-tmp/на диску за замовчуванням); - властивість
$photoотримує об'єкт тимчасового файлу (TemporaryUploadedFile); - у вашому методі файл перевіряється й переноситься на постійне місце.
Ризики й захист:
- глобальна валідація тимчасових завантажень - за замовчуванням
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_filesizePHP: частини збираються на сервері, і перерване завантаження продовжується з місця зупинки; - перевірка типу - лише через правила валідації (
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 для таких полів у циклах.
Автозбереження (як у 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()