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:
The moved task (
id: 15) reverts to its original list (status: "To Do") and is incorrectly assignedposition: "1".The original list (
"To Do") is re-indexed, with the moved task forcing itself to the top.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, fromstatus: "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?