You need a draggable task list, a Kanban column, or a re-orderable settings panel. The livewire/sortable plugin you used to reach for is gone — it only ever supported Livewire 3. In Livewire 4, drag-and-drop is built in, ships with SortableJS, and works in twenty-five lines of Blade plus one action.
This is a tutorial for Livewire 4 specifically. If you're still on v3, start with the practical Livewire 3 to Livewire 4 migration guide — the directive names changed, and so did the handler signature.
Add a position column to your Eloquent model#
Drag-and-drop is useless without somewhere to persist the new order. Add a nullable integer column to whichever table you want to reorder, then index it for the inevitable orderBy('position') query.
// database/migrations/2026_06_18_000000_add_position_to_tasks_table.php
return new class extends Migration
{
public function up(): void
{
Schema::table('tasks', function (Blueprint $table) {
$table->unsignedInteger('position')->default(0)->index();
});
}
};
Backfill existing rows so every record starts with a stable position. A one-liner inside the same migration is enough:
DB::table('tasks')->orderBy('id')->get()->each(function ($task, $index) {
DB::table('tasks')->where('id', $task->id)->update(['position' => $index]);
});
From here on, every query that renders the sortable list must order by position — otherwise the drag will look like it worked, then snap back on the next page load.
Mark the list container with wire:sort#
In your Livewire component view, attach wire:sort to the parent element and point it at the handler method you'll write next. The handler name is the value of the directive.
<ul wire:sort="updatePosition">
{{-- items go here --}}
</ul>
That's all the parent needs. Livewire 4 wires up SortableJS on mount, listens for drop events, and dispatches them to your component. There is no JavaScript to import, no Alpine plugin to register, and no livewire/sortable package to install.
Tag each item with wire:sort:item#
Every draggable child needs wire:sort:item set to a stable identifier — usually the model primary key. Pair it with wire:key so Livewire's morphdom can survive the reorder without re-rendering everything.
<ul wire:sort="updatePosition">
@foreach ($tasks as $task)
<li wire:key="task-{{ $task->id }}"
wire:sort:item="{{ $task->id }}"
class="flex items-center gap-3 rounded-md bg-white p-3 shadow-sm">
<span class="text-zinc-500">{{ $task->title }}</span>
</li>
@endforeach
</ul>
Two things to call out. First, the sub-directive is wire:sort:item with a colon, not wire:sort.item with a dot — the dot syntax in older Livewire plugins is what most outdated tutorials show. Second, the value passed to wire:sort:item is what gets handed back to your handler, so always use the primary key, not the array index.
Persist the new order inside your handler#
Here's the bit most v3 tutorials get wrong. Livewire 4's handler signature is not an ordered array of IDs. It's ($id, $position) — the moved item's identifier and its new zero-based position. Only the dragged row changes; you have to shift the surrounding rows yourself.
// app/Livewire/TaskList.php
namespace App\Livewire;
use App\Models\Task;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Computed;
use Livewire\Component;
class TaskList extends Component
{
#[Computed]
public function tasks()
{
return Task::query()
->where('user_id', auth()->id())
->orderBy('position')
->get();
}
public function updatePosition(int $id, int $position): void
{
DB::transaction(function () use ($id, $position) {
$task = Task::where('user_id', auth()->id())->findOrFail($id);
$current = $task->position;
if ($position === $current) {
return;
}
// Shift the displaced rows up or down by one
if ($position < $current) {
Task::where('user_id', auth()->id())
->whereBetween('position', [$position, $current - 1])
->increment('position');
} else {
Task::where('user_id', auth()->id())
->whereBetween('position', [$current + 1, $position])
->decrement('position');
}
$task->update(['position' => $position]);
});
unset($this->tasks); // bust the computed cache
}
public function render()
{
return view('livewire.task-list');
}
}
Wrap the whole thing in a transaction. Two users dragging concurrently otherwise corrupt the index sequence — and authorising the scope (where('user_id', ...)) on every query keeps one tenant from rewriting another tenant's positions. If you've ever done this work for a Filament v4 relation manager with many-to-many pivots, the pattern is the same: scope by owner, update inside a transaction, bust caches.
Restrict dragging to a handle with wire:sort:handle#
Out of the box the entire item is draggable. Click the title, click the checkbox, click anywhere — drag starts. For most production UIs that's wrong. You want a grip icon that opts in.
Add wire:sort:handle to the grip element. Everything outside the handle reverts to normal click behaviour.
<ul wire:sort="updatePosition">
@foreach ($tasks as $task)
<li wire:key="task-{{ $task->id }}"
wire:sort:item="{{ $task->id }}"
class="flex items-center gap-3 rounded-md bg-white p-3 shadow-sm">
<span wire:sort:handle
class="cursor-grab text-zinc-400 hover:text-zinc-700">
<flux:icon.bars-3 class="size-4" />
</span>
<span class="flex-1">{{ $task->title }}</span>
<div wire:sort:ignore>
<flux:button size="sm" wire:click="edit({{ $task->id }})">
Edit
</flux:button>
</div>
</li>
@endforeach
</ul>
wire:sort:ignore is the second half of the same idea. Any interactive element inside a sortable item — Edit button, checkbox, delete confirmation — wrap it in wire:sort:ignore so the click reaches the button instead of being swallowed by a drag attempt.
Build a Kanban board with wire:sort:group#
For Trello-style multi-column drag, you don't need a different package. Add wire:sort:group="cards" (the group name is shared across every container) and wire:sort:group-id (a stable identifier for each column).
<div class="flex gap-4 overflow-x-auto @container">
@foreach ($this->columns as $column)
<ul wire:sort="moveCard"
wire:sort:group="cards"
wire:sort:group-id="{{ $column->id }}"
class="w-72 min-h-40 space-y-2 rounded-lg bg-zinc-100 p-3">
<h3 class="font-semibold">{{ $column->name }}</h3>
@foreach ($column->cards as $card)
<li wire:key="card-{{ $card->id }}"
wire:sort:item="{{ $card->id }}"
class="rounded-md bg-white p-3 shadow-sm">
{{ $card->title }}
</li>
@endforeach
</ul>
@endforeach
</div>
Now the handler accepts a third argument — the destination group's id — and only the receiving group's component fires.
public function moveCard(int $id, int $position, int $columnId): void
{
DB::transaction(function () use ($id, $position, $columnId) {
$card = Card::findOrFail($id);
// Recompute positions inside the destination column
Card::where('column_id', $columnId)
->where('id', '!=', $id)
->where('position', '>=', $position)
->increment('position');
$card->update([
'column_id' => $columnId,
'position' => $position,
]);
});
unset($this->columns);
}
If your board needs to flex inside a sidebar or modal as well as full-width, drop the columns into a Tailwind v4 container-query layout so the column count reflows based on the available space instead of the viewport.
Test the sort action with Pest#
A Pest feature test is the cheapest insurance against the position math drifting under you. You don't need a browser test for this — drag-and-drop is a UI concern, but updatePosition is just a public method on a Livewire component.
use App\Livewire\TaskList;
use App\Models\Task;
use App\Models\User;
use Livewire\Livewire;
it('moves a task to a new position and shifts the others', function () {
$user = User::factory()->create();
$tasks = Task::factory()
->for($user)
->count(4)
->sequence(fn ($s) => ['position' => $s->index])
->create();
$this->actingAs($user);
Livewire::test(TaskList::class)
->call('updatePosition', id: $tasks[3]->id, position: 0)
->assertOk();
expect(Task::orderBy('position')->pluck('id')->all())
->toEqual([$tasks[3]->id, $tasks[0]->id, $tasks[1]->id, $tasks[2]->id]);
});
For end-to-end coverage of the actual drag gesture, reach for Pest 4's Playwright-powered browser plugin — it can drive real mouse events against the rendered DOM without a separate Dusk install.
Gotchas and Edge Cases#
The first thing that bites people is forgetting wire:key on each item. Without it Livewire's morphdom diffs by position, the keys shuffle, and the visible order looks correct but the model that re-renders is wrong. Always set wire:key to something model-derived.
Nested components are the second trap. wire:sort works on a parent and its direct children. If you wrap each item in its own Livewire component, the directive on the outer list still fires, but the inner components don't see the reorder — drive state from the parent and pass it down with props.
The third one is position collisions during heavy traffic. If two users drag at the same instant, both shift the same range and end up with two rows on the same position number. The transaction in the example fixes this for a single application server, but on multi-node deployments add a unique constraint on (scope_id, position) and retry the handler on a unique-constraint violation.
Finally — Tailwind v4 users — cursor-grab requires the cursor variant utilities to be present in your build. If the icon doesn't change to a hand when hovering, run npm run build and confirm the class survived purge.
Wrapping Up#
Drag-and-drop in Livewire 4 is one parent attribute, one item attribute, and one handler. The handler signature is the part that catches people out — it gives you a single moved id and the destination position, so you write the shift logic yourself. Once that lands, the same pattern scales straight from a sortable list to a multi-column Kanban without changing a line of JavaScript.
If you want to keep going with Livewire 4's new surface area, Livewire 4 islands let you lazy-load expensive components — pairs cleanly with a sortable Kanban where each column hides its own slow query.
FAQ#
How do I add drag-and-drop sorting in Livewire 4?
Add wire:sort="handlerName" to the parent element and wire:sort:item="{{ $id }}" to each child. Livewire 4 ships SortableJS internally, so there's nothing else to install. Your handler method on the component receives the moved item's id and its new zero-based position, and you persist the order yourself.
Do I still need the livewire/sortable package in Livewire 4?
No. The livewire/sortable plugin is only compatible with Livewire 3 and earlier, and the official directive wire:sort replaces it entirely in Livewire 4. Remove the Composer dependency, drop the SortableJS import from your bundle, and rename your old directives — wire:sortable becomes wire:sort, wire:sortable.item becomes wire:sort:item, and so on.
How do I drag items between multiple lists in Livewire?
Add wire:sort:group="someName" to every container that should accept items from the same group, and wire:sort:group-id="{{ $id }}" to identify each one. The handler then receives a third argument — the destination group's id — and only the receiving list's handler fires when an item is dropped into a different group.
How do I persist the sort order to the database from Livewire?
Inside your handler, run a transaction that shifts the displaced rows and updates the moved row. The handler is invoked once per drop with the moved id and its new position, so you increment or decrement position on the affected range, then save the moved record. Always scope the query to the owner (user, team, project) so a drag in one tenant can't rewrite another tenant's positions.
How do I disable drag handles on specific Livewire items?
There are two layers. Use wire:sort:handle on a specific child element to make only that element initiate a drag — clicks anywhere else on the item behave normally. Use wire:sort:ignore on any interactive element inside the sortable item, like an Edit button or a checkbox, so that element's click event is not swallowed by the drag system.
Does wire:sort work with nested Livewire components?
wire:sort works on a parent element and its direct children. If each child is itself a nested Livewire component, the directive on the outer container still fires correctly, but the inner components do not receive the reorder event. Keep the sortable state in the parent and pass each child the data it needs via props, rather than each item managing its own Livewire state.