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.

The Unsafe Version Looks Very Attractive
If the client submits IDs in the new order, the simplest code can look like this:
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.
Browser:[17, 21, 34, 42] | v Server:load current series statelock rowsvalidate the submitted setwrite positions 1..NThe 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.
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.
$posts = Post::query() ->where('post_series_id', $series->id) ->orderBy('series_position') ->lockForUpdate() ->get();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.
$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.
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.
$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:
12345...NAdd 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.
$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.
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.
vuedraggable | +-- displays the order +-- lets the user rearrange items +-- sends ordered IDs Laravel server | +-- loads current membership +-- locks the rows +-- validates invariants +-- saves positionsAfter 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.



