3

My application uses Laravel Livewire with Alpine.js/SortableJS for drag-and-drop reordering of tasks between status columns. The tasks have a status (string) and a position (integer) in the database.

Expected/Normal Behavior: For most drag-and-drop scenarios (reordering within the same list, or moving a task to a non-first position in a different list), the backend successfully updates the moved task's status and position, and re-indexes the tasks in both the old and new columns.

The Problem (The Only Failure Case): The update fails only when moving a task to the first position (position=1) of a different status list. The database transaction seems to encounter an issue, resulting in:

  1. The moved task (id: 15) reverts to its original list (status: "To Do") and is incorrectly assigned position: "1".

  2. The original list ("To Do") is re-indexed, with the moved task forcing itself to the top.

  3. The targeted list ("In Progress") is not updated at all.

This behaviour suggests a logic flaw in the core update method, specifically for the cross-column move to position 1.

Database State Example (Failing Scenario):

  • Moved Task: id: 15, from status: "To Do", position: "3".

  • Target Move: To status: "In Progress", position: "1".

List Task ID Status Position
BEFORE 11, 14 In Progress 1, 2
20, 19, 15, 21, 22 To Do 1, 2, 3, 4, 5
AFTER (Fails) 11, 14 In Progress 1, 2 (No Change)
15, 20, 19, 21, 22 To Do 1, 1, 2, 3, 4 (Moved back, Re-indexed)

I suspect the issue is in how the moved task's position/status is updated within the target column loop, or in the subsequent re-indexing of the original column.

The best way to isolate this is to remove the API, Blade, and non-essential Livewire logic and show a simplified scenario that still calls the core function.

📄 app/Livewire/TaskBoard.php

Showing the updateTaskOrder and reorderTasksInColumn methods.

// Only the essential Livewire component methods
class TaskBoard extends Component
{
    // ... (omitted: properties, render, other methods)

    /**
     * Handles task reordering, called by Alpine/SortableJS.
     * @param array $sortedTaskIds An array of task IDs in the new order for the target column.
     * @param string $newStatus The status (column name) the task was moved to.
     * @param int $taskId The ID of the task that was moved.
     */
    public function updateTaskOrder(array $sortedTaskIds, string $newStatus, int $taskId): void
    {        
        DB::transaction(function () use ($sortedTaskIds, $newStatus, $taskId) {
            $movedTask = Task::find($taskId);
            
            if (!$movedTask) return; // Handle task not found

            $oldStatus = $movedTask->status;
            $originalPosition = $movedTask->position;
            $newPositionForMovedTask = null;
            $statusChanged = $oldStatus !== $newStatus;
    
            // 1. Update positions for all tasks in the target column
            foreach ($sortedTaskIds as $index => $id) {
                $newPosition = $index + 1; // Positions are 1-based
                
                if ((int)$id === $taskId) {
                    $newPositionForMovedTask = $newPosition;
                    // Why does this update sometimes fail for newPosition=1?
                    $movedTask->update(['status' => $newStatus, 'position' => $newPosition]);
                } else {
                    // This update could be the issue, as it assumes $id is a non-moved task 
                    // that is *already* in $newStatus.
                    Task::where('id', $id)->where('status', $newStatus)->update(['position' => $newPosition]);
                }
            }
    
            if ($newPositionForMovedTask === null) return; // Moved task not in sorted list

            // 2. If status changed, re-order the tasks in the original column
            if ($statusChanged) {
                // Re-index remaining tasks in the column the task left
                $this->reorderTasksInColumn($oldStatus);
            }
            // ... (omitted: activity logging - non-essential)
        });

        // ... (omitted: $this->dispatch('$refresh'))
    }

    /**
     * Re-orders tasks in a specific column, closing gaps.
     * @param string $status The status/column to re-order.
     */
    private function reorderTasksInColumn(string $status): void
    {
        $tasksToReorder = Task::where('status', $status)
            ->orderBy('position', 'asc')
            ->get();

        foreach ($tasksToReorder as $index => $task) {
            $task->update(['position' => $index + 1]);
        }
    }
}

Simulated Call (Tinker Example):

Example of the initial database state is:

  • To Do: [20 (pos 1), 19 (pos 2), 15 (pos 3), 21 (pos 4), 22 (pos 5)]

  • In Progress: [11 (pos 1), 14 (pos 2)]

The failed drag-and-drop to position 1 of "In Progress" would pass this data to the Livewire method:

// Goal: Move Task 15 from 'To Do' to 'In Progress', position 1.
$taskId = 15;
$newStatus = 'In Progress';
// The sorted IDs for the TARGET column, with the moved task (15) at the start:
$sortedTaskIds = [15, 11, 14]; 
// Instantiate the component (or a service class with this logic) 
$taskBoard = new TaskBoard(); 
$taskBoard->updateTaskOrder($sortedTaskIds, $newStatus, $taskId);

What is causing the position and status update for task ID 15 to fail when newPosition = 1?

0

1 Answer 1

1

What is causing the position and status update for task ID 15 to fail when newPosition = 1?

I can't certainly say from top of my head and do not have Laravel at hand right now.

However, what I can see (and you have commented that place) is that the other records are not updated.

  1. Effective
  • $movedTask->update(['status' => $newStatus, 'position' => $newPosition]);
  1. Ineffective
  • Task::where('id', $id)->where('status', $newStatus)->update(['position' => $newPosition]);

Now as written I cannot verify this specifically, but my nose tells me that there might be updates ineffective due to the transaction (database internal) or Eloquents magic (library internal).

For the effective part, this is the "moved task" that is treated specifically and the ineffective part are all other tasks albeit all belong to the same list.

Perhaps we can streamline this a bit?

We know already a little bit better and first of all two things:

  1. $taskId must be in $sortedTaskIds
  2. All $sortedTaskIds have a record.

The good news is, it works pretty much like you already did, find() supports an array with the $sortedTaskIds and we can also then check if the $taskId is in the collection it returns.

Before:

        DB::transaction(function () use ($sortedTaskIds, $newStatus, $taskId) {
            $movedTask = Task::find($taskId);
            
            if (!$movedTask) return; // Handle task not found

After:

        DB::transaction(function () use ($sortedTaskIds, $newStatus, $taskId) {
            $movedTasks = Task::find($sortedTaskIds);
            
            if (!$movedTasks->contains('id', $taskId)) return; // Nothing to move

If we assume that $movedTask->update(...) worked (but not the other one), then perhaps we can similarly "stupid" now update all tasks in the list:

            foreach ($movedTasks as $index => $movedTask) {
                $newPosition = $index + 1; // Positions are 1-based
                $movedTask->update([
                    'status' => $newStatus, 
                    'position' => $newPosition
                ]);                
            }
        });

The takeaway here is that we don't care if the status really changed or not, or if the position really changed or not. We just assign the values we know to be correct and let the data-access-layer do the rest. That's what it is for.

I would then leave the routine as this and double check if the assumptions are confirmed.

Note: I would especially not update all positions of the other table (like with the extra method you have). The reason is that the correct list has been updated and the old list is fine for the position values, ordering by position is not broken if there are "gaps". That is purely cosmetic at this point, and in case the list gets re-ordered the positions would be nicely numbered again.

And one more tip: While I can't say what specifically went wrong in your case and you likely will at the end of the day perhaps find out on your system, for the case of duplicate position values I would when the lists are fetched order by position (as you likely already do) and task ID. While that would be technically wrong, you would have a falldoor rule for the case that something goes wrong. As we already know this can happen, it is also adressing that and will then stabilize the results in case of error which helps maintaining the application and the database.

Sign up to request clarification or add additional context in comments.

3 Comments

Hi, thanks, it make a lot of sense. It ruled out a of coding issues but the code is still doing the same thing even when applying the changes. From my log file I can see it still sends the code correctly, as it does with moving it from one column to another but one to the top of a list. or what applying the action of reordering a task in the same list. But when it is moved to the first position in a different list, it then repeat this again almost immediately which send it back to the original list a position number on after the list as already been sorted and reordered. If that helps.
@ChristinaBrowne: Hmm, that is an interesting observation! So my guess on the transaction looks wrong insofar now to me that actually it's not about the database transaction, but you're doing this script execution multiple times? That could be in your scenario because when we move from one list into the other that should be one "transaction" but the update is per list, so let's say given two lists, that are current two independent actions. This may explain something, but it still then does not explain the error picture you see to me, but this could be related on the order of execution.
Okay, so not the best descriptions for Logs in my laravel.log file, but here is the log for an action when moving Task ID 12 from position: 3 in List ID: "In Progress" to position: 1 in List ID: "To Do" and then moves it back almost like a race condition. [2025-11-12 15:21:22] local.INFO: updateTaskOrder(): Livewire method invoked. Task ID: 12, New Status: In Progress [2025-11-12 15:21:22] local.INFO: updateTaskOrder(): Livewire method invoked. Task ID: 12, New Status: In Progress [2025-11-12 15:21:22] local.ERROR: The task ID 12 is not in the collection it returns.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.