Plugin de Kanban para Filament v5 (PHP 8.2+ / Laravel 11-12).
Inspirado em mokhosh/filament-kanban, reescrito do zero para a API do Filament 5.
O plugin permite adicionar páginas Kanban a qualquer Resource do Filament v5.
O board é totalmente Livewire-driven e suporta:
Não faz parte do v1:
{
"name": "alessandro-nuunes/filament-kanban",
"description": "Kanban board pages for Filament v5 panels.",
"type": "library",
"license": "MIT",
"require": {
"php": "^8.2|^8.3|^8.4",
"filament/filament": "^5.0",
"spatie/laravel-package-tools": "^1.16",
"illuminate/contracts": "^11.0|^12.0"
}
}
Namespace PHP: AlessandroNuunes\FilamentKanban
packages/filament-kanban/
├── composer.json
├── README.md
├── CHANGELOG.md
├── LICENSE.md
├── pint.json
├── docs/
│ └── BLUEPRINT.md ← este arquivo
│ └── PLUGIN-DOCUMENTATION.md ← docs para usuário final
├── config/
│ └── filament-kanban.php
├── resources/
│ ├── lang/
│ │ ├── en/default.php
│ │ └── pt_BR/default.php
│ └── views/
│ ├── kanban-board.blade.php ← wrapper + colunas
│ ├── kanban-column.blade.php ← header + cards da coluna
│ ├── kanban-card.blade.php ← card individual
│ ├── kanban-empty.blade.php ← empty state por coluna
│ └── kanban-scripts.blade.php ← JS Alpine para drag
└── src/
├── FilamentKanbanPlugin.php
├── FilamentKanbanServiceProvider.php
├── Concerns/
│ ├── InteractsWithKanbanStatuses.php ← trait para usar no Enum
│ └── HasKanbanBoard.php ← trait com lógica para a Page
├── Contracts/
│ └── HasKanbanStatuses.php ← interface para o Enum
├── Exceptions/
│ └── KanbanTransitionNotAllowedException.php
├── Pages/
│ └── KanbanBoard.php ← classe base que a app estende
├── Support/
│ ├── ConfigHelper.php
│ ├── KanbanRecordRepository.php
│ └── KanbanSortManager.php
└── Console/
└── Commands/
├── InstallCommand.php
└── MakeKanbanCommand.php ← gera page na app
KanbanBoarduse AlessandroNuunes\FilamentKanban\Pages\KanbanBoard;
class TicketsKanban extends KanbanBoard
{
// obrigatório
protected static string $model = Task::class;
// atributo do model que armazena o status
protected static string $recordStatusAttribute = 'status';
// atributo usado como título no card
protected static string $recordTitleAttribute = 'title';
// enum ou array manual de statuses (ver seção 4.2)
protected static ?string $statusEnum = TaskStatus::class;
// navegação
protected static ?string $navigationLabel = 'Kanban';
protected static ?string $navigationIcon = 'heroicon-o-view-columns';
protected static ?string $navigationGroup = null;
protected static ?int $navigationSort = null;
// modal de edição
public bool $disableEditModal = false;
protected string $editModalWidth = '2xl';
protected bool $editModalSlideOver = false;
protected string $editModalSaveButtonLabel = 'Save';
protected string $editModalCancelButtonLabel = 'Cancel';
}
Opção A – Enum com interface (recomendado):
use AlessandroNuunes\FilamentKanban\Contracts\HasKanbanStatuses;
use AlessandroNuunes\FilamentKanban\Concerns\InteractsWithKanbanStatuses;
enum TaskStatus: string implements HasKanbanStatuses
{
use InteractsWithKanbanStatuses;
case Pending = 'pending';
case InProgress = 'in_progress';
case Completed = 'completed';
case Cancelled = 'cancelled';
// opcional – filtrar quais aparecem no board
public static function kanbanCases(): array
{
return [self::Pending, self::InProgress, self::Completed];
}
// opcional – título customizado por case
public function getKanbanTitle(): string
{
return $this->getLabel(); // usa HasLabel se implementado
}
// opcional – cor da coluna
public function getKanbanColor(): string
{
return $this->getColor(); // usa HasColor se implementado
}
}
Opção B – array manual (quando não existe Enum):
protected function statuses(): Collection
{
return collect([
['id' => 'todo', 'title' => 'To Do', 'color' => 'gray'],
['id' => 'in_progress', 'title' => 'In Progress', 'color' => 'blue'],
['id' => 'done', 'title' => 'Done', 'color' => 'green'],
]);
}
// filtrar/customizar os records carregados
protected function records(): Collection
// permite ou bloqueia a movimentação de um card
protected function canMoveRecord(Model $record, string $from, string $to): bool
// callback pós-status-change (update, log, evento, etc.)
protected function onStatusChanged(
int|string $recordId,
string $newStatus,
array $fromOrderedIds,
array $toOrderedIds,
): void
// callback pós-sort dentro da mesma coluna
protected function onSortChanged(
int|string $recordId,
string $status,
array $orderedIds,
): void
// customizar dados antes de serem passados para a view do card
protected function mutateRecordDataForCard(array $data, Model $record): array
// schema do modal de edição inline
protected function getEditModalFormSchema(int|string|null $recordId): array
// lógica de salvar via modal de edição
protected function editRecord(int|string $recordId, array $data, array $state): void
// quais transições são permitidas (null = todas)
protected function allowedTransitions(): ?array
// ex.: ['pending' => ['in_progress', 'cancelled'], 'in_progress' => ['completed', 'awaiting']]
// TaskResource.php
public static function getPages(): array
{
return [
'index' => Pages\ListTasks::route('/'),
'kanban' => Pages\TicketsKanban::route('/kanban'),
'view' => Pages\ViewTask::route('/{record}'),
];
}
use AlessandroNuunes\FilamentKanban\FilamentKanbanPlugin;
public function panel(Panel $panel): Panel
{
return $panel->plugins([
FilamentKanbanPlugin::make(),
]);
}
KanbanBoard (Page Livewire)Filament\Resources\Pages\Page.HasKanbanBoard que centraliza:
loadStatuses() — resolve via Enum ou statuses().loadRecords() — chama records() e agrupa por status.moveRecord($id, $status, $fromOrderedIds, $toOrderedIds) — Livewire action.sortRecord($id, $status, $orderedIds) — Livewire action.openEditModal($id) / saveEditModal($data) — Livewire actions.$groupedRecords, $statuses, $editingRecordId, $editModalData.KanbanRecordRepositoryResponsável por queries:
getRecordsGroupedByStatus(Builder $query, Collection $statuses): arrayupdateStatus(Model $record, string $status, array $payload): voidupdateOrder(string $model, array $orderedIds, string $orderColumn): voidKanbanSortManagershouldPersistSorting() retorna true.10, 20, 30... para espaçamento entre records.KanbanTransitionNotAllowedExceptioncanMoveRecord() retorna false ou transição não está em allowedTransitions().┌──────────────────────────────────────────────────────┐
│ [Pendente (3)] [Em Andamento (1)] [Concluído (5)] │
│ ───────────── ───────────────── ─────────────── │
│ [ card ] [ card ] [ card ] │
│ [ card ] [ card ] │
│ [ card ] ↕ drop zone ... │
└──────────────────────────────────────────────────────┘
┌──────────────────────────────┐
│ [drag handle] Título do │
│ record │
│ │
│ [badge tipo] [badge prazo] │
└──────────────────────────────┘
Campos exibidos no card configuráveis via mutateRecordDataForCard().
Filament\Actions\Action).getEditModalFormSchema().editRecord() com os dados.$editingRecordId.// na classe do board
protected static string $boardView = 'filament-kanban::kanban-board';
protected static string $columnView = 'filament-kanban::kanban-column';
protected static string $cardView = 'filament-kanban::kanban-card';
protected static string $emptyView = 'filament-kanban::kanban-empty';
protected static string $scriptsView = 'filament-kanban::kanban-scripts';
Publicar todas as views:
php artisan vendor:publish --tag="filament-kanban-views"
| Camada | Onde | Descrição |
|---|---|---|
| Visibilidade da página | canViewAny() no Resource |
Quem pode ver o Kanban na navegação |
| Filtragem de records | records() |
Scoped query por usuário/role |
| Movimentação de card | canMoveRecord() |
Por record e target status |
| Transições permitidas | allowedTransitions() |
Mapa de status-origem → destinos válidos |
| Policy Laravel | opcional | Verificada antes de persistir o update |
Configurável em config/filament-kanban.php:
'authorization_mode' => 'record', // 'record' | 'policy'
record: chama canMoveRecord() na Page.policy: chama $policy->update($user, $record) antes de persistir.protected function allowedTransitions(): ?array
{
return [
'pending' => ['in_progress', 'cancelled'],
'in_progress' => ['awaiting_mei', 'completed', 'pending'],
'awaiting_mei'=> ['in_progress', 'completed'],
// null ou ausente = sem restrição para aquele status
];
}
created_at DESC ou scope customizado em records().// na classe do board
protected function getSortColumn(): ?string
{
return 'sort_order';
}
protected function shouldPersistSorting(): bool
{
return true;
}
Quando configurado:
onSortChanged() chama KanbanSortManager::updateOrder().sort_order dos records em $orderedIds com incremento 10, 20, 30....// antes de persistir
KanbanRecordStatusChanging::class
// payload: $userId, $recordId, $fromStatus, $toStatus
// após persistir
KanbanRecordStatusChanged::class
// payload: $userId, $recordId, $fromStatus, $toStatus, $fromOrderedIds, $toOrderedIds
// após reordenação
KanbanRecordSortingChanged::class
// payload: $userId, $recordId, $status, $orderedIds
Event::listen(KanbanRecordStatusChanged::class, function ($event) {
// ex.: criar timeline, enviar notificação, etc.
});
| PHP | Laravel | Filament |
|---|---|---|
| 8.2 | 11 | 5.x latest |
| 8.3 | 11 | 5.x latest |
| 8.3 | 12 | 5.x latest |
| 8.4 | 12 | 5.x latest |
// board renderiza com statuses
it('renders the kanban board with statuses', function () {
$user = User::factory()->superAdmin()->create();
livewire(TicketsKanban::class)
->actingAs($user)
->assertSeeHtml('pending')
->assertSeeHtml('in_progress');
});
// card aparece na coluna correta
it('shows records in the correct status column', function () {
$task = Task::factory()->create(['status' => 'pending']);
livewire(TicketsKanban::class)
->assertSee($task->title);
});
// mover card atualiza status
it('updates status when a card is moved', function () {
$task = Task::factory()->create(['status' => 'pending']);
livewire(TicketsKanban::class)
->call('moveRecord', $task->id, 'in_progress', [], [])
->assertNotified();
expect($task->fresh()->status->value)->toBe('in_progress');
});
// transição não permitida bloqueia
it('blocks a forbidden status transition', function () {
$task = Task::factory()->create(['status' => 'completed']);
livewire(TicketsKanban::class)
->call('moveRecord', $task->id, 'pending', [], [])
->assertNotified(); // erro
expect($task->fresh()->status->value)->toBe('completed');
});
// usuário sem acesso não vê o record
it('scopes records to the authenticated user', function () {
$otherTask = Task::factory()->create();
$myTask = Task::factory()->myTasks()->create();
livewire(TicketsKanban::class)
->assertSee($myTask->title)
->assertDontSee($otherTask->title);
});
filament-kanban:installphp artisan filament-kanban:install
Publica:
config/filament-kanban.phplang/vendor/filament-kanban/make:filament-kanbanphp artisan make:filament-kanban TicketsKanban --resource=TaskResource
Gera:
app/Filament/{Panel}/Resources/Tasks/Pages/TicketsKanban.phpConteúdo gerado:
<?php
namespace App\Filament\Admin\Resources\Tasks\Pages;
use App\Filament\Admin\Resources\Tasks\TaskResource;
use AlessandroNuunes\FilamentKanban\Pages\KanbanBoard;
use App\Models\Task;
use App\Enums\TaskStatus;
class TicketsKanban extends KanbanBoard
{
protected static string $resource = TaskResource::class;
protected static string $model = Task::class;
protected static string $recordStatusAttribute = 'status';
protected static string $recordTitleAttribute = 'protocol';
protected static ?string $statusEnum = TaskStatus::class;
protected function onStatusChanged(
int|string $recordId,
string $newStatus,
array $fromOrderedIds,
array $toOrderedIds,
): void {
Task::find($recordId)?->update(['status' => $newStatus]);
}
}
KanbanBoard base pageonStatusChanged hookmake:filament-kanbanallowedTransitions() com validaçãocanMoveRecord() por record| Arquivo | Conteúdo |
|---|---|
README.md |
Quick start, install, uso básico |
docs/BLUEPRINT.md |
Este arquivo |
docs/PLUGIN-DOCUMENTATION.md |
Docs completo para usuário final |
docs/AUTHORIZATION.md |
Layers de autorização e exemplos |
docs/CUSTOMIZATION.md |
Views, cards, modal, statuses |
docs/SORTING.md |
Sorting com e sem coluna persistida |
docs/EVENTS.md |
Eventos e listeners |
docs/TESTING.md |
Como testar boards na app |
docs/UPGRADING.md |
Guia de upgrade entre versões |
CHANGELOG.md |
Histórico de versões |
Contexto: painel Admin, resource TaskResource, model Task, enum TaskStatus.
Colunas do board:
pending) — warningin_progress) — infoawaiting_mei) — graycompleted) — successCancelado (cancelled) e Reaberto (reopened) ficam fora do board (ver kanbanCases()).
Fluxo de transições permitidas:
pending → in_progress, cancelled
in_progress → awaiting_mei, completed, pending
awaiting_mei → in_progress, completed
Integração com TaskResource:
public static function getPages(): array
{
return [
'index' => Pages\ListTasks::route('/'),
'kanban' => Pages\TicketsKanban::route('/kanban'),
'view' => Pages\ViewTask::route('/{record}'),
];
}
Link de navegação (sub-nav entre List e Kanban):
// na ListTasks e TicketsKanban
public function getSubNavigation(): array
{
return [
TaskResource::getUrl('index') => 'Lista',
TaskResource::getUrl('kanban') => 'Kanban',
];
}
Lógica de movimentação com side-effects (timeline, notificação):
protected function onStatusChanged(
int|string $recordId,
string $newStatus,
array $fromOrderedIds,
array $toOrderedIds,
): void {
$task = Task::find($recordId);
if (!$task) return;
$payload = ['status' => $newStatus];
if ($newStatus === TaskStatus::Completed->value) {
$payload['completed_at'] = now();
}
if ($newStatus === TaskStatus::InProgress->value && $task->attendant_id === null) {
$payload['attendant_id'] = auth()->id();
$payload['assigned_at'] = now();
}
$task->update($payload);
}
How can I help you explore Laravel packages today?