0% прочитано

Laravel Post Series: безпечна зміна порядку з lockForUpdate() і перевіркою інваріантів

У ICanUp адміністратор може додавати Post до серії, прибирати їх і змінювати порядок перетягуванням. На перший погляд це звичайне сортування масиву ID, але сервер повинен захиститися від дублікатів, пропущених Post, чужої серії, застарілих даних і конкурентних змін. Показую, навіщо тут одночасно потрібні транзакція, lockForUpdate() і перевірка інваріантів.

17 вересня 2026 р. 11 хв читанняЛаравель

Коли я додала керування Post Series у ICanUp, зміна порядку спочатку виглядала як проста задача: користувач перетягує Post, клієнтська частина надсилає новий масив ID, а сервер записує нові позиції.

Але порядок елементів є частиною стану даних. Якщо сервер сліпо довіриться масиву з браузера, один неправильний або застарілий запит може залишити серію з дублікатами, пропущеними позиціями або Post з іншої серії.

Тому для мене ця задача швидко перестала бути задачею про drag & drop. Вона стала задачею про транзакції, конкурентні зміни та інваріанти.

lockForUpdate() не перевіряє правильність даних запиту. Він допомагає контролювати конкурентні зміни. Для безпечної зміни порядку потрібні і блокування, і перевірка повного набору Post.
Laravel Post Series reorder flow with drag and drop request, transaction, lockForUpdate, invariant validation and normalized positions
Клієнтська частина пропонує новий порядок. Сервер блокує актуальний стан, перевіряє повний набір Post і лише після цього записує послідовні позиції 1..N.

Небезпечний варіант виглядає дуже привабливо

Якщо клієнт надсилає масив ID у новому порядку, найпростіший код може виглядати так:

PHP - Небезпечна зміна порядку без перевірок
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, я не хочу отримати стан, у якому перші п’ять позицій уже оновилися, а шосте оновлення впало з помилкою.

Тому зміна порядку виконується як одна операція бази даних.

PHP - Транзакція охоплює перевірку і запис
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() у транзакції.

PHP - Заблокувати актуальні Post серії
$posts = Post::query()    ->where('post_series_id', $series->id)    ->orderBy('series_position')    ->lockForUpdate()    ->get();
Сам виклик lockForUpdate() поза транзакцією не дає потрібного контракту для всієї операції. Блокування має охоплювати проміжок від читання актуального стану до завершення перевірок і запису.

Потім я порівнюю не порядок, а множину ID

На етапі перевірки мене спочатку не цікавить порядок. Мене цікавить, чи клієнт взагалі надіслав той самий набір Post, який зараз належить серії.

PHP - Перевірити повний набір 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] не повинен перетворюватися на дивний частковий порядок.

PHP - Відхилити дубльовані ID
if (    count($submittedIds)    !== count(array_unique($submittedIds))) {    throw ValidationException::withMessages([        'posts' => 'Duplicate Post IDs are not allowed.',    ]);}

Після перевірки позиції завжди нормалізуються до 1..N

Я не зберігаю позиції, які прислав браузер. Він надсилає лише впорядкований список ID.

Сервер сам обчислює позиції.

PHP - Сервер формує безперервні позиції
$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 перед додаванням.

PHP - Перевірити 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, старий стан клієнтської сторінки не повинен мати можливості повернути його в серію звичайним запитом зміни порядку.

Актуальний набір для перевірки повинен формуватися за тими самими правилами видимості даних, які використовує серверне керування серією.

Soft delete важливий не лише для показу списку. Він впливає на те, які ID сервер вважає допустимими учасниками структурної операції.

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. Це атомарна перевірка переходу з одного правильного стану колекції в інший.