All pages

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:

bash
php artisan make:migration add_position_to_tasks_table
php
<?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:

php
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.

DropNeighboursNew position
Into an empty columnnone1
Above the top cardnull and 43
Below the last card4 and null5
Between two cards2 and 32.5
Into the same gap again2 and 2.52.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:

php
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:

php
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:

php
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:

php
$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:

php
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