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

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

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

  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()