Коли я додала керування Post Series у ICanUp, зміна порядку спочатку виглядала як проста задача: користувач перетягує Post, клієнтська частина надсилає новий масив ID, а сервер записує нові позиції.
Але порядок елементів є частиною стану даних. Якщо сервер сліпо довіриться масиву з браузера, один неправильний або застарілий запит може залишити серію з дублікатами, пропущеними позиціями або Post з іншої серії.
Тому для мене ця задача швидко перестала бути задачею про drag & drop. Вона стала задачею про транзакції, конкурентні зміни та інваріанти.

Небезпечний варіант виглядає дуже привабливо
Якщо клієнт надсилає масив ID у новому порядку, найпростіший код може виглядати так:
foreach ($orderedIds as $index => $postId) { Post::query() ->whereKey($postId) ->update([ 'series_position' => $index + 1, ]);}Цей код оновить усе, що йому передали.
Він не перевіряє, чи Post належать потрібній серії, чи немає повторених ID, чи не пропущено один із поточних Post і чи інший запит не змінює цю саму серію одночасно.
Спочатку я визначила інваріанти серії
Перед написанням зміни порядку корисніше сформулювати не алгоритм, а правила, які повинні залишатися правдивими після будь-якої операції.
Інваріант | Що він означає |
|---|---|
Один Post максимум в одній серії | Post не може одночасно належати двом PostSeries |
Без дублікатів | Один ID не може двічі з’явитися в новому порядку |
Без пропусків | Зміна порядку повинна містити весь актуальний набір Post серії |
Без чужих Post | Не можна додати ID з іншої серії через запит зміни порядку |
Позиції 1..N | Після операції немає нульової позиції, дірок або повторених позицій |
Атомарна зміна | Або оновлюється весь порядок, або не змінюється нічого |
Браузер не є джерелом істини
У клієнтській частині я використовую перетягування, тому користувач одразу бачить новий порядок.
Але цей список є лише пропозицією нового стану.
Сервер повинен порівняти його з тим, що реально зберігається в базі даних саме в момент виконання запиту.
Browser:[17, 21, 34, 42] | v Server:load current series statelock rowsvalidate the submitted setwrite positions 1..NУся структурна зміна виконується в транзакції
Якщо серія містить десять Post, я не хочу отримати стан, у якому перші п’ять позицій уже оновилися, а шосте оновлення впало з помилкою.
Тому зміна порядку виконується як одна операція бази даних.
DB::transaction(function () use ( $series, $orderedIds,): void { $posts = Post::query() ->where('post_series_id', $series->id) ->orderBy('series_position') ->lockForUpdate() ->get(); // Validate the complete set first. // Update positions only after validation succeeds.});Якщо будь-яка перевірка не проходить, я хочу відкотити всю операцію, а не залишити серію частково зміненою.
Транзакційні межі я використовую і в інших частинах Laravel. Наприклад, окремо писала про DB::afterCommit() і запуск задач черги тільки після успішної транзакції.
lockForUpdate() має сенс тільки всередині транзакції
Під час зміни порядку мені важливо не тільки прочитати актуальні Post, а й не дозволити іншій конкурентній операції непомітно змінити ті самі рядки між перевіркою та записом.
Для цього я використовую lockForUpdate() у транзакції.
$posts = Post::query() ->where('post_series_id', $series->id) ->orderBy('series_position') ->lockForUpdate() ->get();Потім я порівнюю не порядок, а множину ID
На етапі перевірки мене спочатку не цікавить порядок. Мене цікавить, чи клієнт взагалі надіслав той самий набір Post, який зараз належить серії.
$currentIds = $posts ->modelKeys(); $submittedIds = array_map( 'intval', $orderedIds,); $expected = $currentIds;$actual = $submittedIds; sort($expected);sort($actual); if ($actual !== $expected) { throw ValidationException::withMessages([Ця перевірка одразу захищає від кількох класів помилок: пропущеного Post, зайвого ID та Post, який узагалі не входить до цієї серії.
Але дублікати краще перевірити окремо, щоб причина помилки була однозначною.
Дублікати я відхиляю до зміни позицій
Запит на кшталт [17, 21, 21, 42] не повинен перетворюватися на дивний частковий порядок.
if ( count($submittedIds) !== count(array_unique($submittedIds))) { throw ValidationException::withMessages([ 'posts' => 'Duplicate Post IDs are not allowed.', ]);}Після перевірки позиції завжди нормалізуються до 1..N
Я не зберігаю позиції, які прислав браузер. Він надсилає лише впорядкований список ID.
Сервер сам обчислює позиції.
$postsById = $posts->keyBy('id'); foreach ($submittedIds as $index => $postId) { $postsById ->get($postId) ->update([ 'series_position' => $index + 1, ]);}У результаті після будь-якого правильного reorder я отримую тільки:
12345...NДодавання і видалення повинні використовувати ті самі правила
Порядок серії змінюється не тільки під час перетягування.
Він також змінюється, коли адміністратор додає Post до серії або прибирає його з неї.
Тому я не хочу мати три незалежні реалізації правил.
- Новий Post додається в кінець поточної серії.
- Видалення з серії не видаляє сам Post.
- Після видалення позиції решти Post знову нормалізуються.
- Post з іншої серії не переноситься автоматично.
- Усі структурні зміни проходять через спільну серверну логіку.
Один Post може належати максимум до однієї серії
Клієнтський список кандидатів може приховати Post, який уже належить іншій серії. Але це лише зручність інтерфейсу, а не захист даних.
Сервер повторно перевіряє membership перед додаванням.
$post = Post::query() ->lockForUpdate() ->findOrFail($postId); if ( $post->post_series_id !== null && $post->post_series_id !== $series->id) { throw ValidationException::withMessages([ 'post_id' => 'This Post already belongs to another series.', ]);}Soft delete теж впливає на набір, який можна впорядковувати
Якщо Post був soft deleted, старий стан клієнтської сторінки не повинен мати можливості повернути його в серію звичайним запитом зміни порядку.
Актуальний набір для перевірки повинен формуватися за тими самими правилами видимості даних, які використовує серверне керування серією.
vuedraggable відповідає за зручність, а не за цілісність даних
У адміністративному інтерфейсі перетягування робить зміну порядку швидкою і зрозумілою.
Але я свідомо не переношу правила предметної області в компонент перетягування.
vuedraggable | +-- displays the order +-- lets the user rearrange items +-- sends ordered IDs Laravel server | +-- loads current membership +-- locks the rows +-- validates invariants +-- saves positionsПісля помилки клієнт повинен повернути фактичний порядок
Перетягування часто оптимістично змінює порядок на екрані ще до відповіді сервера.
Якщо сервер відхилив запит через застарілий набір, дублікати або іншу конкурентну зміну, інтерфейс не повинен продовжувати показувати вигаданий стан.
Я хочу або повернути попередній порядок, або повторно завантажити актуальні дані з сервера.
Тести мають перевіряти не тільки правильний порядок
Happy path перевіряє лише найпростішу частину задачі. Найцінніші тести тут перевіряють, що неправильний запит взагалі не може пошкодити структуру серії.
Сценарій | Очікуваний результат |
|---|---|
Правильний повний набір ID | Порядок змінюється |
Повторений ID | Запит відхиляється |
Пропущений Post | Запит відхиляється |
ID з іншої серії | Запит відхиляється |
Додавання Post з іншої серії | Автоматичного перенесення немає |
Видалення з серії | Post залишається, позиції нормалізуються |
Soft-deleted Post у застарілому запиті | Не повертається в актуальний набір |
Помилка посеред операції | Транзакція не залишає частково оновлений порядок |
Цей підхід корисний далеко за межами Post Series
- Сортування пунктів меню.
- Порядок зображень у галереї.
- Порядок Content Blocks.
- Сортування категорій.
- Пріоритети елементів у черзі.
- Будь-яка колекція, де клієнт надсилає новий порядок існуючих записів.
Що я не роблю в операції зміни порядку
- Не довіряю масиву ID лише тому, що його сформував мій власний інтерфейс.
- Не оновлюю позиції до перевірки повного набору.
- Не дозволяю дублікати.
- Не приймаю неповний список як правильний reorder.
- Не дозволяю ID з іншої серії.
- Не переношу Post між серіями неявно.
- Не використовую lockForUpdate() поза транзакційною операцією як магічний захист.
- Не зберігаю позиції, надіслані клієнтом, якщо їх можна обчислити на сервері.
- Не залишаю інтерфейс у локальному оптимістичному стані після помилки сервера.
Мій короткий checklist для безпечного reorder
- Почати з інваріантів, а не з циклу update.
- Виконувати всю структурну зміну в одній транзакції.
- Завантажувати актуальні записи під lockForUpdate().
- Окремо перевіряти дублікати.
- Порівнювати submitted IDs з повним актуальним набором.
- Відхиляти пропущені та чужі ID.
- Обчислювати позиції на сервері.
- Нормалізувати їх до 1..N після add, remove і reorder.
- Перевіряти membership Post при додаванні.
- Враховувати soft delete.
- Після помилки синхронізувати інтерфейс із фактичним станом сервера.
- Покрити негативні сценарії автоматичними тестами.
lockForUpdate() захищає момент зміни. Інваріанти захищають правильність стану. Для надійного reorder потрібні обидва.
Висновок
Зміна порядку Post Series у ICanUp починалася як звичайний drag & drop, але правильне серверне рішення виявилося значно цікавішим.
Клієнт надсилає тільки бажаний порядок ID. Сервер завантажує актуальний склад серії, блокує записи в межах транзакції, перевіряє дублікати, пропуски та чужі ID і лише після цього формує позиції 1..N.
Той самий контракт використовується під час додавання і видалення Post, тому правила membership та order не дублюються в різних частинах застосунку.
Головний урок для мене: безпечний reorder - це не цикл update. Це атомарна перевірка переходу з одного правильного стану колекції в інший.



