Senior: питання на співбесіді з теми «Форми й валідація»
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
4 питання
Компонент з трейтом 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()