advanced
Card reordering
Drop cards into an exact slot, inside a column or across columns, with one write per drop.
Cards can be dragged into a specific position, both within a column and when moving between
columns. This is opt-in: point the board at a sortable column with orderField().
Setup
Add a nullable decimal column to your model's table:
php artisan make:migration add_position_to_tasks_table
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('tasks', function (Blueprint $table) {
// Decimal, so a card dropped between two others gets a value between them.
// Nullable, so existing rows migrate without a default order being invented.
$table->decimal('position', 16, 8)->nullable()->index();
});
}
public function down(): void
{
Schema::table('tasks', function (Blueprint $table) {
$table->dropColumn('position');
});
}
};
An integer column will not do — midpoints need the decimals. Index it: every column of the board sorts on this field.
Then enable it:
public function kanban(Kanban $kanban): Kanban
{
return $kanban
->model(Task::class)
->statusField('status')
->orderField('position')
->columns([...]);
}
Records are then ordered by that field in every column. Without orderField(), nothing changes:
drag and drop stays column-to-column only, and cards keep the order your query returns.
How positions are assigned
A dropped card takes the midpoint between the two cards it landed between, so only the dragged row
is written. Cards dropped at the top or bottom of a column get one gap beyond their single
neighbour, and the first card of an empty column gets 1.
| Drop | Neighbours | New position |
|---|---|---|
| Into an empty column | none | 1 |
| Above the top card | null and 4 | 3 |
| Below the last card | 4 and null | 5 |
| Between two cards | 2 and 3 | 2.5 |
| Into the same gap again | 2 and 2.5 | 2.25 |
Columns are paginated, so the browser only knows the loaded head of a column. Renumbering that head
1..n would collide with the tail nobody loaded — midpoints don't.
Repeated drops into the same gap. Each drop into an identical gap halves it, and floats run out of room after roughly 50 consecutive drops between the same two cards. Everyday use never reaches that. If you script thousands of moves, renumber the column by whole gaps afterwards.
Records are sorted by the position field and then by primary key, so rows that do end up sharing a position keep a stable order instead of shuffling between renders.
Existing rows
Switching this on over a table that already has records needs nothing extra. The first time a column is reordered, its records are numbered in the order the board is currently showing them, and from then on only the dragged row is ever written. The heal runs against your configured query, so rows outside the current scope stay untouched and heal on their own first drag.
That heal keeps whatever order you already had — it does not choose one for you. If the order you
want is not the order the board happens to show (an all-null column sorts arbitrarily), seed it
once when you add the field:
Task::query()->orderBy('created_at')->get()
->each(fn ($task, $index) => $task->update(['position' => $index + 1]));
New records
The kanban's own CreateAction fills the position in for you — new cards land at the bottom of
their column:
use Asmit\AdvancedKanban\Actions\CreateAction;
use Filament\Forms\Components\TextInput;
$kanban->columnHeaderActions([
CreateAction::make()
->schema([
TextInput::make('title')->required(),
]),
]);
The column is taken from the status form field if you have one, and otherwise from the column
header the button was rendered under — so a per-column "add card" button works with no status
field in the form. Pass position in the form data to override the value.
A card created without a position is not broken either: it is healed on the next drag in its column. That heal renumbers the whole column though, and repeats every time a card is added, so setting the value up front keeps it to a single row.
Filling the position from your own create action
mutateDataUsing() holds a single closure. Calling it on the kanban's CreateAction replaces the
plugin's callback rather than stacking with it — and Filament's own CreateAction never had one.
Either way, set the position yourself:
use Asmit\AdvancedKanban\Support\RecordPosition;
CreateAction::make()
->mutateDataUsing(function (array $data): array {
$kanban = $this->getKanban();
$statusField = $kanban->getStatusField();
$orderField = $kanban->getOrderField();
// New cards land at the bottom of their column.
$data[$orderField] ??= (float) $kanban->getQuery()
->where($statusField, $data[$statusField])
->max($orderField) + RecordPosition::GAP;
return $data;
}),
??= leaves an explicit position alone, so you can still drop a card in at the top by passing
position yourself.
To send new cards to the top instead, measure from the other end:
$data[$orderField] ??= (float) $kanban->getQuery()
->where($statusField, $data[$statusField])
->min($orderField) - RecordPosition::GAP;
Records created outside the board entirely — a resource, a seeder, an import — fall back to the heal on the next drag.
Hooks
Reordering within a column uses its own hooks, so status-change side effects don't fire for a plain reorder:
public function handleRecordReorder(float $position, Model $record): void
{
$record->update(['position' => $position]);
}
public function beforeRecordReorder(float $position, Model $record): void
{
//
}
public function afterRecordReorder(float $position, Model $record): void
{
//
}
Moving a card to a different column still runs beforeRecordMove(), handleRecordMove(), and
afterRecordMove(). The new position is set on the record before handleRecordMove() is called,
so the default $record->update([...]) persists the status and the position in a single query.
What still applies
- Cards locked with
lockCardUsing()cannot be reordered. - Workflow transitions are still enforced on a cross-column drop.