Filament kanban board page with drag and drop
A custom Filament 4 page that renders stage columns and moves cards between them with Alpine drag and drop and a validated Livewire method.
FilamentLayoutsfilamentkanbanlivewirealpine
Code
<?php
namespace App\Filament\Pages;
use App\Models\Candidate;
use BackedEnum;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Support\Icons\Heroicon;
class PipelineBoard extends Page
{
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedViewColumns;
protected static ?string $title = 'Pipeline';
protected string $view = 'filament.pages.pipeline-board';
/** Column key => label. The keys are the only stages a card may move to. */
public const STAGES = [
'applied' => 'Applied',
'screening' => 'Screening',
'interview' => 'Interview',
'offer' => 'Offer',
];
protected function getViewData(): array
{
return [
'stages' => self::STAGES,
'cards' => Candidate::query()
->whereIn('stage', array_keys(self::STAGES))
->orderBy('position')
->limit(300)
->get()
->groupBy('stage'),
];
}
/** Called from the browser after a drop. Never trust the client: validate the stage. */
public function moveCard(int $id, string $stage): void
{
if (! array_key_exists($stage, self::STAGES)) {
return;
}
$card = Candidate::findOrFail($id);
$this->authorize('update', $card);
if ($card->stage === $stage) {
return;
}
$card->update([
'stage' => $stage,
'position' => (Candidate::where('stage', $stage)->max('position') ?? 0) + 1,
]);
Notification::make()->title("{$card->name} moved to ".self::STAGES[$stage])->success()->send();
}
}
<x-filament-panels::page>
<div
x-data="{ dragging: null, over: null }"
style="display:grid;grid-template-columns:repeat({{ count($stages) }},minmax(14rem,1fr));gap:1rem;overflow-x:auto"
>
@foreach ($stages as $key => $label)
<section
wire:key="col-{{ $key }}"
x-on:dragover.prevent="over = '{{ $key }}'"
x-on:dragleave="over = null"
x-on:drop.prevent="if (dragging) { $wire.moveCard(dragging, '{{ $key }}'); } dragging = null; over = null"
x-bind:style="over === '{{ $key }}' ? 'outline:2px dashed #6366f1' : ''"
style="min-height:12rem;border-radius:.75rem;padding:.75rem"
class="fi-section"
>
<h3 style="font-weight:600;margin-bottom:.5rem">
{{ $label }} <span style="opacity:.6">({{ ($cards[$key] ?? collect())->count() }})</span>
</h3>
@foreach ($cards[$key] ?? [] as $card)
<article
wire:key="card-{{ $card->id }}"
draggable="true"
x-on:dragstart="dragging = {{ $card->id }}"
x-on:dragend="dragging = null"
style="cursor:grab;margin-bottom:.5rem;padding:.625rem;border-radius:.5rem;border:1px solid rgba(128,128,128,.3)"
>
<strong>{{ $card->name }}</strong>
<div style="font-size:.8rem;opacity:.7">{{ $card->job_title }}</div>
</article>
@endforeach
</section>
@endforeach
</div>
</x-filament-panels::page>
Needs: filament/filament@^4.0
How it works
What it solves
Pipelines such as hiring, sales or support are easiest to understand as columns of cards. Filament has no built-in board, but a custom page with a little Alpine is enough, and it keeps all the usual panel chrome, navigation and authorisation.
How it works
PipelineBoard is a regular Page pointing at a Blade view. getViewData() loads cards once, ordered by position, capped at 300, and groups them by stage so the view can loop over STAGES. That constant is the single source of truth for the columns.
In the view, each card is draggable and stores its id in an Alpine dragging variable on dragstart. Each column listens for dragover.prevent (required for a drop to be allowed), highlights itself, and on drop calls $wire.moveCard(id, stage), a public Livewire method.
moveCard() treats the browser as untrusted: it ignores unknown stages, authorises the update through the model policy, skips no-op moves and appends the card at the end of the target column. Livewire then re-renders from the database, so a refused move simply snaps back.
Adapting it
Add wire:key values to every loop so DOM diffing stays correct. For ordering within a column, send the sorted ids to moveCard and write positions in a transaction. For touch devices swap the native events for a sortable library.
Pitfalls
- Native drag events do not fire on most touch screens.
- Filament does not compile your own Tailwind classes for custom views unless you register a theme, so the example uses inline styles.
- Keep the card limit, a huge board makes every Livewire round trip heavy.
How to use it
- Add
stage(string) andposition(integer) columns to your model and adjustSTAGES. - Place the page class in
app/Filament/Pages/and the view inresources/views/filament/pages/. - Create an
updatepolicy on the model;moveCard()calls it. - Open the Pipeline page and drag a card to another column.
- Style cards with your own theme classes if you have a custom Filament theme.
Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.