Питання на співбесіді: Події, Alpine і навігація
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
12 питань
Компоненти на сторінці незалежні й не знають одне про одного. Події - спосіб повідомити інших, що щось сталося, не створюючи прямої залежності.
Відправити подію з PHP:
public function save(): void
{
$post = Post::create($this->form->all());
$this->dispatch('post-created', postId: $post->id);
}
Слухати - атрибут #[On] над методом:
use Livewire\Attributes\On;
new class extends Component {
#[On('post-created')]
public function refreshList(int $postId): void
{
unset($this->posts); // скинути кеш обчислюваної властивості
}
};
Параметри події передаються в метод за назвами.
Кому дістанеться подія:
- за замовчуванням - усім компонентам на сторінці, що слухають цю назву;
- конкретному компоненту -
$this->dispatch('post-created')->to(Dashboard::class); - лише самому собі -
$this->dispatch('post-created')->to(self: true).
Події з шаблону - без власного методу-посередника:
<button wire:click="$dispatch('open-modal', { id: {{ $post->id }} })">Редагувати</button>
<button wire:click="$dispatchTo('posts', 'refresh')">Оновити список</button>
Подія від дочірнього компонента до батька можна слухати прямо на тегу:
<livewire:edit-post :$post @saved="close" />
Як це працює всередині: подія з PHP повертається в браузер разом з відповіддю, а вже там Livewire надсилає запити компонентам-слухачам. Тобто подія - це щонайменше два запити, а не виклик методу на сервері.
Можливо, подія не потрібна:
- дитина хоче викликати метод батька -
wire:click="$parent.remove({{ $id }})"; - дитина має оновлюватися зі зміною даних батька -
#[Reactive]; - обидві частини в одному компоненті - острівці.
Назви подій - у стилі kebab-case з «простором імен» (cart-updated, post-created), щоб не зіткнутися з подіями сторонніх бібліотек.
wire:navigate на посиланні перетворює звичайний перехід на SPA-навігацію: сторінка завантажується через fetch, а браузер не перезавантажується.
<nav>
<a href="/" wire:navigate>Головна</a>
<a href="/jobs" wire:navigate>Вакансії</a>
<a href="/posts" wire:navigate.hover>Блог</a>
</nav>
Що відбувається при кліку:
- Livewire перехоплює клік і запитує нову сторінку звичайним GET-запитом;
- замінює вміст
<body>і оновлює<head>(заголовок, мета-теги); - змінює адресу через History API - кнопки «Назад» і «Вперед» працюють;
- показує смугу прогресу вгорі сторінки.
Чому швидше: CSS і JavaScript не завантажуються й не виконуються заново, Alpine і Livewire не ініціалізуються з нуля. Сторінки відкриваються помітно швидше, і застосунок відчувається як SPA, хоча сервер віддає звичайний HTML.
Попереднє завантаження:
- за замовчуванням Livewire починає запит, щойно користувач натиснув кнопку миші, - до того, як він її відпустив;
.hover- починати завантаження після 60 мс наведення чи фокусу на посиланні. Ще швидше для користувача, але сервер отримує запити на сторінки, які можуть і не знадобитися.
Корисні можливості:
$this->redirect('/posts', navigate: true)- SPA-перехід після дії компонента;@persist('player')- елемент (аудіоплеєр, бічна панель) переживає перехід без перестворення;- активне посилання - атрибут
data-currentна посиланні поточної сторінки чи директиваwire:current="font-bold"; - анімація переходу -
wire:transition.navigateчерез View Transitions API браузера.
Що варто знати:
- сервер віддає повну сторінку, як звичайно, - маршрути, middleware, SEO не змінюються. Без JavaScript посилання працюють як звичайні;
- JavaScript сторінок треба адаптувати:
DOMContentLoadedспрацьовує лише на першому завантаженні - замість ньогоlivewire:navigated; - гостьові сторінки з кешуванням на CDN працюють з
wire:navigate, але залежать від того, що віддає кеш - варто перевірити, що кешований HTML не містить персональних даних.
Параметри дії передаються як аргументи виклику:
<button wire:click="addToCart({{ $product->id }}, 2)">У кошик</button>
<button wire:click="setStatus('archived')">В архів</button>
public function addToCart(int $productId, int $qty = 1): void
{
$product = Product::query()->published()->findOrFail($productId);
$this->cart->add($product, min($qty, 10));
}
Моделі й енуми як параметри. Якщо параметр типізований моделлю, Livewire знайде її за переданим ключем (як прив'язка моделей у маршрутах):
public function delete(Post $post): void
{
$this->authorize('delete', $post);
$post->delete();
}
Параметри - це введення користувача. У консолі браузера можна викликати $wire.addToCart(999, 100000). Тип перевіряє PHP, а межі, існування запису й права - лише ваш код.
Не тільки клік. Після wire: можна вказати будь-яку подію браузера:
<input wire:keydown.enter="search">
<input wire:keydown.escape="clear">
<div wire:mouseenter="loadPreview({{ $id }})">
<trix-editor wire:trix-change="setContent($event.target.value)">
Модифікатори (працюють через x-on Alpine):
| Модифікатор | Що робить |
|---|---|
.prevent |
preventDefault() |
.stop |
stopPropagation() |
.once |
спрацювати один раз |
.self |
лише якщо подія виникла на самому елементі |
.window, .document |
слухати на window / document |
.outside |
клік поза елементом |
.debounce.300ms, .throttle.500ms |
обмеження частоти |
.shift.enter, .ctrl, .escape |
клавіші й комбінації |
Модифікатори Livewire 4:
.async- виконати дію паралельно, не чекаючи черги;.renderless- без повторного рендеру;.preserve-scroll- зберегти позицію прокрутки після оновлення.
$event - об'єкт події браузера в виразі: wire:change="setColor($event.target.value)".
Магічні дії без методу: $refresh (перерендерити), $set('tab', 'settings') (встановити властивість), $toggle('open'), $dispatch(...), $parent.method().
Стан завантаження: елемент, що ініціював запит, автоматично отримує атрибут data-loading - кнопку легко стилізувати через Tailwind (data-loading:opacity-50).
Обидва способи показують чи ховають частину інтерфейсу, але працюють на різних рівнях.
@if - на сервері. Blade вирішує, чи потрапить розмітка в HTML взагалі:
@if ($showModal)
<div class="modal">...</div>
@endif
Щоб показати вікно, потрібен запит на сервер: змінити $showModal, перерендерити компонент, отримати новий HTML.
wire:show - у браузері. Елемент завжди є в DOM, а директива перемикає display: none за виразом:
<button type="button" x-on:click="$wire.showModal = true">Новий пост</button>
<div wire:show="showModal" class="modal">
<textarea wire:model="content"></textarea>
<button wire:click="save">Опублікувати</button>
</div>
Відкриття вікна - без запиту: $wire.showModal = true змінює значення в браузері, і wire:show реагує одразу. На сервер значення піде з наступною дією. Після save() на сервері можна виставити $this->showModal = false - і вікно закриється.
Коли wire:show:
- миттєве перемикання (модальні вікна, вкладки, розгортання деталей), де затримка запиту помітна;
- вміст дешевий і може бути в HTML завжди;
- потрібні анімації появи -
wire:showпоєднується зx-transitionAlpine, бо лише перемикаєdisplay.
Коли @if:
- вміст не повинен потрапити в HTML - дані, доступні лише певній ролі. Прихований
wire:showелемент усе одно є в коді сторінки й видний у DevTools; - вміст дорогий для рендеру (запити до бази в шаблоні) і потрібен рідко;
- великі блоки, які збільшують розмір відповіді на кожному рендері.
Пов'язані директиви:
wire:text="expression"- вставити текст виразу без запиту (лічильник символів, попередній перегляд);wire:cloak- сховати елемент до ініціалізації Livewire, щоб не «блимали» елементи, які мали бути приховані;- Alpine
x-show- те саме для стану, що живе лише в Alpine, без Livewire.
Пастка безпеки: wire:show - це про вигляд, а не про доступ. Кнопка «Видалити», прихована через wire:show, усе одно викликає метод, який можна викликати й без кнопки, - перевірка прав потрібна в методі.
Події Livewire - це події браузера, тож їх бачать і Alpine, і будь-який JavaScript на сторінці.
З PHP у JavaScript. Подія, відправлена з компонента, доходить у браузер:
$this->dispatch('notify', message: 'Збережено', type: 'success');
{{-- Alpine-компонент сповіщень у макеті --}}
<div x-data="{ items: [] }"
x-on:notify.window="items.push($event.detail)">
...
</div>
// звичайний JavaScript
Livewire.on('notify', ({ message, type }) => showToast(message, type));
З JavaScript у Livewire. Компонент з #[On('cart-updated')] отримає подію, відправлену будь-звідки:
<button x-on:click="$dispatch('cart-updated', { count: 3 })">...</button>
Livewire.dispatch('cart-updated', { count: 3 });
Livewire.dispatchTo('cart-badge', 'cart-updated', { count: 3 });
У скрипті компонента (Livewire 4: тег <script> у шаблоні, this - це $wire):
<script>
this.$on('post-created', () => { /* ... */ });
this.$dispatchSelf('refresh');
</script>
Події браузера на корені компонента - wire:назва-події:
<div wire:custom-event="handle">
<button x-on:click="$dispatch('custom-event')">...</button> {{-- спливе до кореня --}}
</div>
<div wire:custom-event.window="handle"> {{-- подія з будь-якого місця сторінки --}}
Безпека - головне. Слухач #[On] - це метод, який будь-хто може викликати з консолі браузера з довільними параметрами: Livewire.dispatch('order-paid', { orderId: 5 }). Тож слухачі мають перевіряти права й дані так само, як дії. Подія order-paid, що сама по собі змінює статус замовлення, - діра.
Прибирання слухачів. Livewire.on() повертає функцію для відписки. В Alpine-компонентах, що живуть із wire:navigate, слухачі треба знімати в destroy(), інакше після кожного переходу вони накопичуватимуться:
Alpine.data('cartBadge', () => ({
off: null,
init() { this.off = Livewire.on('cart-updated', ({ count }) => (this.count = count)); },
destroy() { this.off?.(); },
}));
Трансляція з сервера (Echo) - слухачі виду #[On('echo:orders,OrderShipped')] отримують події WebSocket від Laravel Reverb чи Pusher без власного JavaScript.
При SPA-переході через wire:navigate вміст <body> замінюється новим. Але деякі елементи не повинні перестворюватися: аудіо- чи відеоплеєр, що грає, чат-віджет, бічна панель з прокручуванням.
@persist позначає елемент, який Livewire переносить на нову сторінку замість заміни:
{{-- resources/views/layouts/app.blade.php --}}
<body>
<main>{{ $slot }}</main>
@persist('player')
<audio src="{{ $episode->file }}" controls></audio>
@endpersist
@livewireScripts
</body>
Якщо на новій сторінці є @persist з тією самою назвою, Livewire бере існуючий DOM-елемент зі старої сторінки й ставить на місце нового. Відтворення не переривається, зберігаються стан Alpine, обробники подій і значення полів.
Умови:
- працює лише з
wire:navigate- при звичайному переході чи оновленні сторінки все завантажується заново; - елемент з тією самою назвою має бути на обох сторінках. Найпростіше - розмістити його в спільному макеті;
- розміщувати поза Livewire-компонентами сторінки, зазвичай прямо в макеті. Вміст, який має оновлюватися з сервера, не варто «заморожувати».
Прокрутка всередині збереженого елемента (довга бічна навігація) - wire:navigate:scroll:
@persist('sidebar')
<aside class="overflow-y-auto" wire:navigate:scroll>...</aside>
@endpersist
У Livewire 3 для цього був wire:scroll - при оновленні до v4 його треба перейменувати.
Підводні камені:
- збережений елемент не оновлюється. Якщо в ньому показано «Активний пункт меню» чи лічильник, після переходу вони лишаться старими. Активне посилання в збереженій навігації позначається через
data-current/wire:current- Livewire оновлює їх при навігації; - назва має бути унікальною й стабільною - різні елементи з однією назвою на різних сторінках підмінять один одного;
- персональні дані в збереженому елементі (наприклад, після виходу з облікового запису через
wire:navigate) переживуть перехід - вихід краще робити повним перезавантаженням сторінки.
Сторонні бібліотеки (редактори тексту, вибір дати, карти, графіки) самі змінюють DOM: додають елементи, класи, атрибути. Після наступного запиту Livewire морфить DOM за новим HTML із сервера - і затирає все, що побудувала бібліотека.
wire:ignore каже Livewire не чіпати вміст елемента при оновленнях:
<div wire:ignore>
<div x-data x-init="initEditor($el)"></div>
</div>
wire:ignore.self - ігнорувати зміни лише атрибутів самого елемента, а вміст оновлювати як звичайно.
Типовий шаблон інтеграції через Alpine:
<div wire:ignore
x-data="{ value: $wire.entangle('content') }"
x-init="
const editor = new Editor($refs.editor, { content: value });
editor.on('change', () => $wire.content = editor.getHTML());
">
<div x-ref="editor"></div>
</div>
Краще без entangle - напряму через $wire:
<div wire:ignore
x-data
x-init="
const picker = flatpickr($refs.input, {
defaultDate: $wire.date,
onChange: (dates, str) => $wire.date = str,
});
">
<input x-ref="input" type="text">
</div>
Що варто врахувати:
- напрям «сервер → бібліотека»: якщо сервер змінив значення (скинули форму), бібліотека під
wire:ignoreпро це не знає. Потрібна подія ($this->dispatch('editor-reset')) чи$wire.$watch('date', ...)в Alpine; - частота запитів:
$wire.content = ...лише змінює значення в браузері, на сервер воно піде з наступною дією.$wire.set('content', value)надсилає запит одразу - для редактора тексту це запит на кожне натискання клавіші; - знищення: бібліотеки, що підписуються на
window/document, мають прибиратися вdestroy()Alpine-компонента - інакше зwire:navigateслухачі накопичуються; wire:key, якщо таких полів кілька в циклі, - щоб при зміні списку бібліотека не опинилася «прив'язаною» до чужого рядка;- власне поле з
wire:model: обгортку можна оформити Blade-компонентом зx-modelable, тоді в шаблонах вистачить<x-date-picker wire:model="date" />.
Альтернатива - бібліотеки, створені для Livewire/Alpine (Flux, Alpine-плагіни), які вже вміють жити з морфінгом.
Livewire дає два способи виконати JavaScript, і вони вирішують різні задачі.
1. #[Js] - метод, що виконується в браузері без запиту. PHP-метод повертає рядок JavaScript; при виклику з шаблону сервер не задіюється:
use Livewire\Attributes\Js;
new class extends Component {
public string $title = '';
public string $content = '';
#[Js]
public function clearForm(): string
{
return <<<'JS'
$wire.title = ''
$wire.content = ''
JS;
}
};
<button type="button" wire:click="clearForm">Очистити</button>
Очищення полів відбувається миттєво. Код JavaScript потрапляє в сторінку під час рендеру, тож на клік не потрібен мережевий запит.
2. $this->js() - виконати JavaScript після дії на сервері:
public function save(): void
{
$this->form->save();
$this->js("document.getElementById('comment-form').reset()");
$this->js('$wire.$refresh()');
}
Код ставиться в чергу й виконується, коли відповідь прийде в браузер.
3. Скрипти в шаблоні (Livewire 4): тег <script> у шаблоні однофайлового чи багатофайлового компонента виконується з this = $wire, без обгортки @script:
<script>
this.$js.focusSearch = () => this.$refs.search.focus();
</script>
<input wire:ref="search">
<button type="button" wire:click="$js.focusSearch">Пошук</button>
Такі скрипти віддаються окремими кешованими файлами.
Коли що:
- суто клієнтська дія (очистити, згорнути, сфокусуватися) -
#[Js]чи$jsу скрипті компонента; - реакція на результат серверної дії (прокрутити до нового коментаря, закрити вікно, сповіщення) -
$this->js()або подія, яку слухає JavaScript; - переважно клієнтська логіка - Alpine, а не JavaScript, згенерований у PHP.
Пастки:
- не вставляйте дані користувача в рядок JavaScript -
$this->js("alert('{$this->name}')")з ім'ям'); stealCookies(); ('виконає чужий код. Дані передавайте через подію з параметрами чиJs::from($value)для безпечного екранування; - CSP: з увімкненим
csp_safeскладні вирази JavaScript у директивах обмежені - перевірте сумісність, якщо сайт використовує суворий Content Security Policy; #[Js]-метод не має доступу до PHP-стану на момент кліку - лише до того, що є в$wireу браузері.
Livewire 4 замінив хуки commit і request з v3 на інтерсептори трьох рівнів.
| Рівень | Що це | API |
|---|---|---|
| дія | виклик одного методу (save, $refresh) |
$wire.intercept(), Livewire.interceptAction() |
| повідомлення | оновлення одного компонента (може містити кілька дій і змін властивостей) | $wire.interceptMessage(), Livewire.interceptMessage() |
| запит | HTTP-запит (може містити повідомлення кількох компонентів) | $wire.interceptRequest(), Livewire.interceptRequest() |
Кожен інтерсептор повертає функцію відписки.
Дія - найточніший рівень:
<script>
this.intercept('delete', ({ action, onSuccess, onError }) => {
if (! confirm('Видалити?')) {
action.cancel();
return;
}
onSuccess(() => showToast('Видалено'));
onError(({ preventDefault }) => {
preventDefault(); // не показувати модальне вікно помилки
showToast('Не вдалося видалити', 'error');
});
});
</script>
Колбеки: onSend, onCancel, onSuccess (з поверненим значенням методу), onError (відповідь з помилкою сервера), onFailure (мережева помилка), onFinish.
Повідомлення - доступ до корисного навантаження й етапів застосування відповіді: onSend({ payload }) (знімок, оновлення, виклики), onSuccess з вкладеними onSync, onEffect, onMorphed, onRender. Корисно, щоб ініціалізувати сторонні бібліотеки після морфінгу DOM.
Запит - глобальні речі на рівні HTTP:
Livewire.interceptRequest(({ onError }) => {
onError(({ response, preventDefault }) => {
if (response.status === 419) { // сесія чи CSRF-токен застаріли
preventDefault();
if (confirm('Сесія завершилася. Оновити сторінку?')) location.reload();
}
});
});
Також onRedirect (скасувати редирект), onDump (перехопити вивід dd()), onResponse, onStream.
Типові застосування:
- глобальна обробка помилок - 419 (сесія), 403, 500 у вигляді власного повідомлення замість типового модального вікна з HTML помилки;
- індикатори завантаження для складних випадків, де
wire:loadingзамало; - аналітика й моніторинг - час відповіді, помилки, назви дій;
- додаткові заголовки запиту на рівні застосунку.
Порядок для успішного повідомлення: onSuccess → onSync → onEffect → onMorph → onMorphed → onFinish → onRender (у наступному кадрі анімації). Код, що читає оновлений DOM, - в onMorphed, а не в onSuccess.
Що варто пам'ятати: інтерсептори - клієнтський код. Скасування дії в інтерсепторі (action.cancel()) - це UX, а не захист: дію можна викликати й без нього.
Звичайна дія Livewire повертає результат один раз, наприкінці. Для відповіді чат-бота, що генерується 20 секунд, користувач бачив би порожній екран усі 20 секунд. wire:stream дає змогу відправляти частини відповіді в браузер під час виконання дії.
new class extends Component {
public string $prompt = '';
public string $question = '';
public string $answer = '';
public function submitPrompt(): void
{
$this->question = $this->prompt;
$this->prompt = '';
$this->answer = '';
$this->js('$wire.ask()'); // друга дія - вже після того, як поле очистилося
}
public function ask(): void
{
// streamAnswer() - генератор над будь-яким клієнтом LLM зі стримінгом
foreach ($this->streamAnswer($this->question) as $chunk) {
$this->answer .= $chunk;
$this->stream(to: 'answer', content: $chunk);
}
}
};
<p wire:stream="answer">{{ $answer }}</p>
<form wire:submit="submitPrompt">
<input wire:model="prompt" placeholder="Ваше питання">
</form>
Як це працює: відповідь на запит ask() стає потоковою: кожен $this->stream() одразу відправляє шматок у браузер, і Livewire дописує його в елемент з wire:stream="answer". Коли метод завершується, приходить звичайна фінальна відповідь з повним станом компонента.
Параметри stream():
to:- назва цілі (значенняwire:streamв шаблоні);content:- текст;replace: true- замінити вміст замість дописування (для індикатора прогресу «Оброблено 40%»).
Чому дві дії (submitPrompt + ask): перша швидко повертає відповідь - поле введення очищується, питання з'являється на екрані. Друга вже стрімить відповідь.
Що враховувати в продакшені:
- процес PHP зайнятий весь час стримінгу. Двадцять секунд генерації - двадцять секунд зайнятого воркера PHP-FPM. При багатьох одночасних користувачах це вичерпує пул; доречні Octane/FrankenPHP чи винесення генерації в чергу з трансляцією результату через Reverb;
- буферизація по дорозі: проксі (Nginx
proxy_buffering, CDN), стиснення відповідей можуть накопичувати шматки й віддавати все наприкінці. Потрібно вимкнути буферизацію для цих запитів; - тайм-аути:
max_execution_time, тайм-аути проксі й балансувальника мають покривати найдовшу генерацію; - зберігати повний результат у властивості (
$this->answer .= $chunk) - інакше після фінальної відповіді та наступного рендеру текст зникне; - XSS: потоковий вміст вставляється як текст, але якщо ви рендерите відповідь як Markdown/HTML, санітизуйте її на сервері.
Альтернативи: response()->eventStream() з SSE у звичайному маршруті Laravel, якщо компонентна модель не потрібна.
З wire:navigate браузер фактично не залишає першу сторінку: замінюється вміст <body>, а JavaScript-оточення живе далі. Код, написаний для звичайних переходів, починає поводитися інакше.
1. DOMContentLoaded спрацьовує лише один раз. Ініціалізація на ньому не виконається на наступних сторінках:
// було
document.addEventListener('DOMContentLoaded', initTooltips);
// стало - спрацьовує і при першому завантаженні, і після кожного переходу
document.addEventListener('livewire:navigated', initTooltips);
2. Скрипти в <head> виконуються один раз. Нові скрипти, яких не було на попередній сторінці, - виконуються при переході й блокують перехід, поки не завантажаться.
3. Скрипти в <body> виконуються на кожній сторінці заново. Ініціалізація, що має бути одноразовою (аналітика, глобальні обробники), отримує атрибут data-navigate-once. Інакше - подвійні події аналітики й подвійні обробники.
4. Накопичення слухачів. Обробник на window чи document, доданий на кожній сторінці, після десятка переходів спрацьовує десять разів. Знімати в destroy() Alpine чи використовувати { once: true } для одноразових.
5. Застарілі ассети після деплою. Користувач не перезавантажує сторінку годинами і працює зі старим JavaScript. Атрибут data-navigate-track на тегах ассетів змушує Livewire зробити повне перезавантаження, якщо змінився рядок запиту в URL ассета. Директива @vite додає його автоматично.
6. Мерехтіння теми. Тема (dark) застосовується скриптом після завантаження - при навігації між сторінками з різними класами <html> інтерфейс «блимає». Застосовуйте її в livewire:navigating через e.detail.onSwap() - до того, як новий HTML з'явиться на екрані.
7. Аналітика. Перегляд сторінки треба відправляти на livewire:navigated, інакше аналітика бачить лише першу сторінку сесії.
8. Стан сторонніх бібліотек. Каруселі, карти, редактори з першої сторінки лишаються в пам'яті, якщо їх не знищити, - витоки пам'яті за годину роботи.
Події життєвого циклу навігації:
livewire:navigate- перехід почався (можна скасуватиpreventDefault());livewire:navigating- новий HTML отримано, перед заміною (місце дляonSwap);livewire:navigated- усе завершено (також на першому завантаженні).
Коли варто відмовитися від wire:navigate для посилання: вихід з облікового запису, перемикання мови, переходи між різними макетами чи застосунками - там надійніше звичайне перезавантаження.
Livewire 4 має вбудоване сортування перетягуванням - без сторонніх бібліотек.
<ul wire:sort="reorder">
@foreach ($this->tasks as $task)
<li wire:key="{{ $task->id }}" wire:sort:item="{{ $task->id }}">
<span wire:sort:handle>⋮⋮</span>
{{ $task->title }}
</li>
@endforeach
</ul>
Коли користувач відпускає елемент, Livewire викликає метод з ідентифікатором елемента (wire:sort:item) і новою позицією (з нуля). wire:sort:handle обмежує перетягування «ручкою».
Між кількома списками (канбан-дошка) - однакова група і ідентифікатор контейнера, що стає третім параметром:
<ul wire:sort="moveCard" wire:sort:group="cards" wire:sort:group-id="{{ $column->id }}">
Збереження порядку - ваша відповідальність, і тут легко помилитися:
public function reorder(int $taskId, int $position): void
{
$this->authorize('update', $this->project);
DB::transaction(function () use ($taskId, $position) {
$ids = $this->project->tasks()->orderBy('position')->lockForUpdate()->pluck('id');
abort_unless($ids->contains($taskId), 404); // лише задачі цього проєкту
$position = max(0, min($position, $ids->count() - 1));
$ordered = $ids->reject(fn ($id) => $id === $taskId)->values();
$ordered->splice($position, 0, [$taskId]);
foreach ($ordered as $index => $id) {
Task::whereKey($id)->update(['position' => $index]);
}
});
unset($this->tasks);
}
Що тут важливо:
- ідентифікатор і позиція - від клієнта. Без перевірки, що задача належить проєкту користувача, можна пересунути чужу задачу. Пошук через зв'язок (
$this->project->tasks()) чиfindOrFailв області видимості власника обов'язковий; - межі позиції - від'ємна чи завелика позиція не повинна ламати нумерацію;
- перенумерація в транзакції з блокуванням - два користувачі, що одночасно сортують один список, інакше отримають дублікати позицій;
- для довгих списків оновлювати лише зсунутий діапазон (
increment/decrementдля позицій між старою й новою), а не всі рядки; wire:keyна елементах обов'язковий - після відповіді сервера морфінг має зіставити елементи за ідентифікатором, а не за позицією;- для переміщення між колонками перевіряти й колонку призначення (третій параметр) - вона теж може бути чужою.
Альтернатива перенумерації - дробові чи «розріджені» позиції (крок 1000, вставка посередині між сусідами): одне оновлення замість багатьох, з періодичною перенумерацією.