У проєкті на Laravel 13 з Livewire 4.4 спокуса виглядає так: раз компонент уміє тримати стан і сам перемальовує HTML, хай усе інтерактивне буде компонентом. Через півроку виявляється, що стрічка статей робить запит на сервер на кожен клік по тегу, а сторінка з десятком дрібних компонентів возить туди-сюди свої снапшоти.
На порталі зараз дев'ять компонентів у app/Livewire: каталог вакансій, редактор статей, редактор профілю розробника, форма вакансії для роботодавця, дві форми реєстрації, дії з вакансією, контактна форма й форма ліда. Решта сайту тримається на контролерах, Blade і невеликому перехоплювачі кліків у resources/js/app.js. Далі про критерій, за яким проходить цей розподіл.
Питання, на яке насправді відповідає Livewire
Livewire платить за кожну взаємодію мережевим запитом і повним рендером компонента на сервері. Взамін дає одну річ: стан живе в PHP, і вам не треба тримати його копію в браузері.
Тому питання перед новим компонентом одне: чи є на екрані стан, який мусить пережити клік, і чи дорого відтворювати наслідок цього стану на клієнті?
Якщо стану немає (перехід на іншу сторінку, посилання на категорію, сортування, яке зводиться до іншого URL), Livewire тут зайвий: стан уже існує, він у самому URL. Якщо стан є, але його обчислення тривіальне й дані вже в браузері (розгорнути акордеон, підсвітити вкладку, порахувати символи в полі), це робота для Alpine, без жодного запиту. Livewire виграє в третьому випадку: стан складний, а щоб показати наслідок його зміни, треба знову спитати базу.
Каталог вакансій потрапляє в третій випадок, стрічка статей у перший, онлайн-тестер регулярних виразів у другий, з поправкою.
Каталог вакансій: лічильники, які знає тільки база
Панель фільтрів у каталозі має шість груп (фреймворк, рівень, місто, формат, тип зайнятості, англійська), поріг зарплати, перемикач сортування й пагінацію по 20. Біля кожної опції стоїть кількість вакансій, яка перераховується після кожного кліку, бо фасети рахуються з урахуванням уже вибраних фільтрів. Плюс лендинг має власний пресет, який не можна зняти: на /jobs/laravel фреймворк зафіксований.
Відтворити це на клієнті означає віддати браузеру всі вакансії з усіма полями й переписати фасетний підрахунок на JavaScript. Друга реалізація тих самих правил стає другим місцем, де вони розійдуться.
Компонент тримає стан у публічних властивостях, а #[Url] синхронізує їх із query string, тому будь-який набір фільтрів лишається посиланням, яке можна надіслати:
class JobsCatalog extends Component
{
public const PER_PAGE = 20;
#[Locked]
public string $landingPath = '';
#[Url(as: 'framework', except: '')]
public string $framework = '';
#[Url(as: 'with_salary', except: false)]
public bool $withSalary = false;
public function toggle(string $group, string $value): void
{
$current = $this->list($group);
$this->{$group} = implode(',', in_array($value, $current, true)
? array_values(array_diff($current, [$value]))
: [...$current, $value]);
$this->page = 1;
}
}
#[Locked] тут не косметика. landingPath визначає пресет, а отже й те, які опції заблоковані; без атрибута його можна було б підмінити з фронтенду разом зі снапшотом.
Перший рендер серверний, з mount(), який читає initialQuery з контролера. Краулер отримує готову сторінку з результатами, а не порожній каркас, що дозаповниться після XHR. Вкладки пресетів лишаються справжніми посиланнями, і вже поверх них висить перехоплення:
<a href="{{ $tab->url() }}" wire:click.prevent="switchLanding('{{ $tab->path }}')">{{ $tab->crumb }}</a>
Метод усередині чистить фільтри, змінює лендинг і сам править історію та заголовок через $this->js(). Без JavaScript це звичайний перехід на іншу сторінку; з ним заміна списку без перезавантаження. Обидві гілки ведуть на однаковий індексований URL.
І одразу про ціну. Кожен клік по фільтру коштує запиту, повного рендеру компонента, вибірки сторінки результатів, перерахунку фасетів і ще одного підрахунку для рішення про індексацію. Тому поля з вільним введенням прив'язані тільки із затримкою: від wire:model.live.debounce.300ms у формах реєстрації до 1500 мс на тілі статті в редакторі. У Livewire 4 з'явилися islands для часткового оновлення, і якщо панель фільтрів колись почне гальмувати саме через розмір рендеру, це наступний крок. Поки дешевше не рендерити зайвого.
Редактор статей: автосейв поверх доменних правил
/write стає другим очевидним кандидатом. Автор пише Markdown, збоку живе чекліст готовності, кнопка «На редактуру» відкривається на 100%. Чернетка зберігається на сервер на кожну зміну.
Livewire знімає тут найнуднішу частину: не потрібен ані окремий ендпоїнт для автосейву, ані серіалізація форми руками. Хук updated() ловить будь-яку зміну властивості й вирішує, що з нею робити:
public function updated(string $name): void
{
if (in_array($name, ['view', 'savedAt', 'error'], true)) {
return;
}
if ($name === 'kind' && ArticleKind::tryFrom($this->kind)?->requiresOrigin() === true) {
$this->mode = 'blog';
}
$this->save();
}
Межа проходить у submit(). Компонент не вирішує, чи стаття готова до редактури, він викликає доменний метод і показує помилку, якщо той відмовився:
try {
$submission->submit(app(Clock::class)->now());
} catch (\InvalidArgumentException $e) {
$this->error = $e->getMessage();
return;
}
Правила про мінімальну довжину, обов'язкові підзаголовки й посилання на оригінал лежать в ArticleSubmission, а не у валідації компонента. Livewire-клас тут транспорт: зібрати ввід, віддати домену, показати результат. Архітектурний тест фіксує це з обох боків:
arch('the web layer never touches infrastructure directly')
->expect(['App\Http', 'App\Livewire'])
->not->toUse(['PhpUkraine\Hiring\Infrastructure', 'PhpUkraine\ContentOps\Infrastructure']);
Домен і Application, своєю чергою, не мають права згадувати Livewire взагалі. Звідси приємний побічний ефект: Livewire::test(ArticleEditor::class) ганяє сценарій редактора, а правила публікації тестуються без жодного HTTP.
Стрічки: контролер, Blade і заміна <main>
Статті, документація, компанії, питання зі співбесід, зарплати, екосистема, інструменти віддаються звичайними контролерами. Фільтрація в них зводиться до категорії й тега, тобто до іншого URL, який має бути окремою сторінкою в сайтмапі.
Щоб переходи не виглядали як перезавантаження, у resources/js/app.js лежить маленький перехоплювач кліків. Він працює тільки всередині секції (посилання з /articles/* на /articles/*), тягне сторінку фетчем і міняє <main>:
const res = await fetch(url, { headers: { 'X-Requested-With': 'soft' } });
const doc = new DOMParser().parseFromString(await res.text(), 'text/html');
const next = doc.querySelector('main');
if (!next || next.querySelector('[wire\\:id]')) { location.href = url; return; }
target.replaceWith(next);
document.title = doc.title;
Третій рядок найцікавіший. Якщо на цільовій сторінці є Livewire-компонент, soft-навігація здається й робить звичайний перехід. Підміна DOM повз Livewire залишила б компонент без ініціалізації, а гарантувати коректний порядок для чужого рантайму дорожче, ніж зрідка перезавантажити сторінку. Тому сторінка вакансії з компонентом JobActions і сам каталог до списку секцій із soft-навігацією не входять.
Кожен URL лишається справжньою сторінкою: відкритий напряму, він рендериться сервером повністю, history.pushState зберігає позицію прокрутки, popstate її повертає. Витрати: один fetch на перехід і жодного стану на сервері. Замінити це каталогом на Livewire означало б перетворити десятки індексованих сторінок на один компонент із параметрами.
Коли краще JSON-ендпоїнт
У /tools є тестер регулярних виразів, парсер cron, перевірка синтаксису й генератор хешів. Ввід змінюється на кожен символ, і результат треба показувати миттєво. Livewire дав би тут по запиту на натиск клавіші заради перемальовування всієї сторінки.
Розв'язок звичайний для такого класу задач: розбір і підсвітка живуть у JS-чанку, який підвантажується лише на сторінці інструмента, а те, що вміє тільки PHP, іде окремим викликом:
public function __invoke(Request $request, string $slug): JsonResponse
{
$class = self::TOOLS[$slug] ?? throw new NotFoundHttpException;
return response()->json(['ok' => true, 'result' => app($class)->run($request->json()->all())])
->header('Cache-Control', 'no-store');
}
Той самий критерій діє для форми відгуку на вакансію. Вона свідомо зроблена звичайним POST з $request->validate(), honeypot-полем і throttle на маршруті. Стану між кліками в ній немає, вона має працювати без JavaScript, а окремий компонент означав би Livewire на кожній сторінці вакансії заради одного сабміту.
Що з цього виходить як правило
Компонент виправданий, коли збігаються три ознаки: сервер знає щось, чого не знає браузер (лічильники, права, чернетка); стан переживає більше ніж один клік; дублювати логіку на клієнті було б другою реалізацією тих самих правил.
Помилкою він стає за двох умов: кожен стан взаємодії має власний URL, який хочеться бачити в пошуку, або взаємодія відбувається частіше, ніж кілька разів на хвилину.
Найбільше економить організаційне правило: компонент не тримає доменної логіки. Тоді питання «Livewire чи контролер» лишається питанням про транспорт, і змінити відповідь пізніше коштує рівно стільки, скільки переписати транспорт.