0% прочитано

Laravel Post Series Reordering: Safe Ordering with lockForUpdate() and Invariant Checks

In ICanUp, an administrator can add Posts to a series, remove them, and reorder them with drag and drop. What looks like a simple array of IDs becomes a data-integrity problem once duplicates, omissions, foreign-series Posts, stale requests, and concurrent changes are possible. This article explains why a transaction, lockForUpdate(), and invariant validation are all required.

17 вересня 2026 р. 11 хв читанняLaravel

When I added Post Series management to ICanUp, reordering initially looked simple: the user drags Posts, the client sends a new array of IDs, and the server stores the new positions.

But ordering is part of the data state. If the server blindly trusts an array from the browser, one invalid or stale request can leave the series with duplicates, missing positions, or a Post that belongs to another series.

The feature therefore stopped being just a drag-and-drop task. It became a problem of transactions, concurrency, and invariants.

lockForUpdate() does not validate the request. It helps control concurrent changes. Safe reordering needs both row locking and validation of the complete Post set.
Laravel Post Series reorder flow with drag and drop request, transaction, lockForUpdate, invariant validation and normalized positions
The client proposes a new order. The server locks current state, validates the complete Post set, and only then writes continuous positions from 1 to N.

The Unsafe Version Looks Very Attractive

If the client submits IDs in the new order, the simplest code can look like this:

PHP - Unsafe reorder without validation
foreach ($orderedIds as $index => $postId) {    Post::query()        ->whereKey($postId)        ->update([            'series_position' => $index + 1,        ]);}

This updates whatever the client sends.

It does not verify that every Post belongs to the target series, that IDs are unique, that no current Post is missing, or that another request is not changing the same series concurrently.

I Started by Defining Series Invariants

Before writing the reorder algorithm, it is more useful to define the rules that must remain true after every structural operation.

Invariant

Meaning

One Post belongs to at most one series

A Post cannot be a member of two PostSeries at the same time

No duplicates

The same Post ID cannot appear twice in a submitted order

No omissions

Reordering must include the complete current set of series Posts

No foreign Posts

An ID from another series cannot enter through the reorder request

Positions are 1..N

No zero position, gaps, or duplicate positions remain

Atomic change

Either the entire order changes or nothing changes

The Browser Is Not the Source of Truth

Drag and drop makes the admin interface immediate and understandable.

But the client-side list is only a proposal for the next state.

The server must compare it with what is actually stored in the database at the moment the request executes.

The server validates the proposed order
Browser:[17, 21, 34, 42]         |        v Server:load current series statelock rowsvalidate the submitted setwrite positions 1..N

The Entire Structural Change Runs in a Transaction

If a series contains ten Posts, I do not want the first five positions to be updated while the sixth update fails.

The reorder therefore runs as one database operation.

PHP - Transaction covers validation and writes
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.});

If any validation fails, I want the whole operation rolled back instead of leaving a partially changed series.

I use transaction boundaries in other Laravel workflows too. I wrote separately about DB::afterCommit() and dispatching queue jobs only after a successful transaction.

lockForUpdate() Belongs Inside the Transaction

During reorder, I need more than an up-to-date read. I also want to prevent a competing structural operation from silently changing the same rows between validation and writing.

That is where lockForUpdate() is useful.

PHP - Lock the current series Posts
$posts = Post::query()    ->where('post_series_id', $series->id)    ->orderBy('series_position')    ->lockForUpdate()    ->get();
Calling lockForUpdate() outside the transaction does not provide the required contract for the full operation. The lock needs to span the read, invariant checks, and writes.

Then I Compare Sets Before I Compare Order

At validation time, the first question is not the order. The first question is whether the client submitted exactly the same set of Posts that currently belongs to the series.

PHP - Validate the complete Post set
$currentIds = $posts    ->modelKeys(); $submittedIds = array_map(    'intval',    $orderedIds,); $expected = $currentIds;$actual = $submittedIds; sort($expected);sort($actual); if ($actual !== $expected) {    throw ValidationException::withMessages([

This check protects against several failure modes at once: a missing Post, an extra ID, or a Post that does not belong to this series.

I still prefer a separate duplicate check so the failure reason remains explicit.

Duplicates Are Rejected Before Any Position Changes

A request such as [17, 21, 21, 42] should not become an ambiguous partial order.

PHP - Reject duplicate IDs
if (    count($submittedIds)    !== count(array_unique($submittedIds))) {    throw ValidationException::withMessages([        'posts' => 'Duplicate Post IDs are not allowed.',    ]);}

Positions Are Normalized to 1..N After Validation

I do not store position values supplied by the browser. The client sends only the ordered list of IDs.

The server derives positions itself.

PHP - Server creates continuous positions
$postsById = $posts->keyBy('id'); foreach ($submittedIds as $index => $postId) {    $postsById        ->get($postId)        ->update([            'series_position' => $index + 1,        ]);}

After every valid reorder, the only acceptable sequence is:

Continuous position sequence
12345...N

Add and Remove Need the Same Rules

The series order changes during more than drag and drop.

It also changes when an administrator adds a Post or removes one from the series.

I do not want three separate implementations of the same domain rules.

  • A newly added Post goes to the end of the current series.
  • Removing a Post from a series does not delete the Post itself.
  • After removal, remaining positions are normalized again.
  • A Post already assigned to another series is not moved implicitly.
  • All structural operations go through shared server-side logic.

One Post Can Belong to at Most One Series

The admin picker can hide Posts that already belong to another series. That is useful UX, not a data-integrity guarantee.

The server validates membership again before adding the Post.

PHP - Validate membership on the server
$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 Deletes Also Affect the Reorder Set

If a Post has been soft deleted, a stale client page must not be able to reintroduce it into the active series through an old reorder payload.

The set used for validation must follow the same soft-delete contract as the server-side series management itself.

Soft delete is not only a display concern. It changes which IDs the server considers valid participants in the structural operation.

vuedraggable Owns the UX, Not Data Integrity

Drag and drop makes the admin experience fast and intuitive.

I deliberately keep domain rules out of the draggable component.

Client and server have different responsibilities
vuedraggable    |    +-- displays the order    +-- lets the user rearrange items    +-- sends ordered IDs Laravel server    |    +-- loads current membership    +-- locks the rows    +-- validates invariants    +-- saves positions

After an Error, the Client Must Return to Server State

Drag-and-drop interfaces often update locally before the server responds.

If the server rejects the request because the series changed, duplicate IDs appeared, or another structural operation won the race, the UI should not keep showing an invented local state.

It should restore the previous order or reload the actual order from the server.

Tests Need More Than the Successful Reorder

The happy path verifies only the easiest part. The most valuable tests prove that invalid input cannot corrupt series structure.

Scenario

Expected result

Correct complete ID set

Order changes

Duplicate ID

Request is rejected

Missing Post

Request is rejected

ID from another series

Request is rejected

Adding a Post from another series

No implicit transfer

Removing from the series

Post remains, positions normalize

Soft-deleted Post in a stale request

It does not return to the active set

Failure during the operation

Transaction does not leave a partial order

The Pattern Applies Far Beyond Post Series

  • Menu item ordering.
  • Gallery image ordering.
  • Content Block ordering.
  • Category ordering.
  • Priority queues.
  • Any collection where the client submits a new order for existing records.

What I Do Not Do in a Reorder Operation

  • Do not trust an ID array merely because my own admin interface created it.
  • Do not update positions before validating the complete set.
  • Do not accept duplicates.
  • Do not accept an incomplete list as a valid reorder.
  • Do not accept IDs from another series.
  • Do not move Posts between series implicitly.
  • Do not treat lockForUpdate() outside a transaction as a magic concurrency fix.
  • Do not store client-supplied position numbers when the server can derive them.
  • Do not leave the UI in an optimistic local state after the server rejects the operation.

My Short Checklist for Safe Reordering

  • Start with invariants, not the update loop.
  • Run the structural change in one transaction.
  • Load current records under lockForUpdate().
  • Check duplicates separately.
  • Compare submitted IDs with the complete current set.
  • Reject missing and foreign IDs.
  • Derive positions on the server.
  • Normalize to 1..N after add, remove, and reorder.
  • Validate Post membership when adding.
  • Respect soft deletes.
  • Synchronize the UI with actual server state after an error.
  • Cover negative scenarios with automated tests.

lockForUpdate() protects the moment of change. Invariants protect the correctness of state. Reliable reordering needs both.

Conclusion

Post Series ordering in ICanUp started as an ordinary drag-and-drop feature, but the correct server-side solution was much more interesting.

The client submits only the desired sequence of IDs. The server loads the current membership, locks the rows inside a transaction, validates duplicates, omissions, and foreign IDs, and only then derives positions from 1..N.

The same contract is reused for adding and removing Posts, so membership and ordering rules are not duplicated across separate workflows.

My main lesson is that safe reordering is not an update loop. It is an atomic transition from one valid collection state to another.