Board
The board package renders a Trello-style kanban board over an Eloquent (or custom) data source: configurable columns, drag-and-drop card moving with a persisted manual order, a quick-add input per column, per-card click/context-menu actions, and a toolbar that reuses the table package’s filter classes and search. Each column pages independently over a signed endpoint, so a board with thousands of cards only ever loads what is on screen.
Installation
Section titled “Installation”composer require lattice-php/boardThat is the whole integration: the package ships its React renderer as source, and the
lattice() Vite plugin compiles it into your app’s bundle via virtual:lattice/plugins
(see Component packages). The PHP classes are picked up by
Lattice’s discovery and TypeScript generation automatically.
No-build apps use the precompiled module the package also ships:
run php artisan lattice:assets after installation.
Board::make('sprint') ->columns([ BoardColumn::make('backlog')->label('Backlog')->color('gray')->data(), BoardColumn::make('doing')->label('In Progress')->color('blue')->data(), BoardColumn::make('done')->label('Done')->color('green')->data(), ]) ->schema([ Stack::make()->gap(Gap::ExtraSmall)->schema([ Text::make('')->dataKey('text', 'title'), Text::make('')->dataKey('text', 'assignee'), ]), ]) ->result(BoardResult::make([ new BoardColumnCards('backlog', [ ['id' => 1, 'title' => 'Design onboarding flow', 'assignee' => 'Mina'], ], 4, true, 1), new BoardColumnCards('doing', [ ['id' => 2, 'title' => 'Wire up billing webhook', 'assignee' => 'Theo'], ], 1, false, 1), new BoardColumnCards('done', [ ['id' => 3, 'title' => 'Migrate avatars to S3', 'assignee' => 'Priya'], ], 1, false, 1), ]));- cardAction:null
- columns:[{"color":{"dark":null,"kind":"named","value":"gray"},"icon":null,"key":"backlog","label":"Backlog"},{"color":{"dark":null,"kind":"named","value":"blue"},"icon":null,"key":"doing","label":"In Progress"},{"color":{"dark":null,"kind":"named","value":"green"},"icon":null,"key":"done","label":"Done"}]
- createAction:null
- endpoint:null
- filters:[]
- moveAction:null
- perColumn:25
- query:{"q":"","tf":[]}
- queryKey:null
- result:{"columns":[{"cards":[{"assignee":"Mina","id":1,"title":"Design onboarding flow"}],"hasMore":true,"key":"backlog","offset":1,"total":4},{"cards":[{"assignee":"Theo","id":2,"title":"Wire up billing webhook"}],"hasMore":false,"key":"doing","offset":1,"total":1},{"cards":[{"assignee":"Priya","id":3,"title":"Migrate avatars to S3"}],"hasMore":false,"key":"done","offset":1,"total":1}],"indicators":[]}
- searchable:false
- syncQuery:false
- align:null
- direction:null
- float:null
- gap:"xs"
- height:null
- justify:null
- sticky:false
- width:null
- align:null
- color:null
- copyable:false
- dataBindings:{"text":"title"}
- size:"md"
- text:""
- align:null
- color:null
- copyable:false
- dataBindings:{"text":"assignee"}
- size:"md"
- text:""
That example builds a board by hand — every card, count, and column set directly. In an
application you register a BoardDefinition instead and let Board::use() populate all of it
from a real data source, discovered and endpoint-backed like any Lattice definition.
Defining a board
Section titled “Defining a board”EloquentBoardDefinition is the main path: back a board with a model, a status column, and a
manual-order column.
use Lattice\Board\AsBoard;use Lattice\Board\BoardColumn;use Lattice\Board\EloquentBoardDefinition;use Lattice\Ui\Components\Stack;use Lattice\Ui\Components\Text;use Lattice\Ui\Enums\Gap;
#[AsBoard('tasks')]final class TaskBoard extends EloquentBoardDefinition{ public function model(): string { return Task::class; }
public function columns(): array { return [ BoardColumn::make('todo')->label('To Do')->color('gray'), BoardColumn::make('doing')->label('In Progress')->color('blue'), BoardColumn::make('done')->label('Done')->color('green'), ]; }
// The single card template — serialized once, materialized per card client-side. public function card(): array { return [ Stack::make()->gap(Gap::ExtraSmall)->schema([ Text::make('')->dataKey('text', 'title'), Text::make('')->dataKey('text', 'assignee'), ]), ]; }}Board::use(TaskBoard::class);columnField() (default 'status') and positionField() (default 'position') name the columns
the built-in EloquentBoardSource groups and orders cards by. Override query(Builder $query) to
scope the underlying query (tenancy, soft deletes, eager loads) — it runs before both the per-column
card query and the totals query, so a scope always applies to counts too. perColumn() (default 25)
sets the initial page size and the load-more page size per column.
Any other backing store implements the two-method Lattice\Board\Contracts\BoardSource contract
(query(BoardQuery): BoardResult), or use Lattice\Board\Sources\CallbackBoardSource as a closure
escape hatch.
Moving cards
Section titled “Moving cards”moveAction() wires a registered Lattice action to receive { cardId, columnKey, position } on every drop:
Board::use(TaskBoard::class)->moveAction(MoveTaskAction::class);The client drags cards within and across columns, applies the move optimistically, and rolls back if the action rejects it. Keyboard focus moves with the arrow keys within and across columns; keyboard-driven moves are a planned follow-up.
Planning a move with BoardMovePlanner
Section titled “Planning a move with BoardMovePlanner”Every moveAction handler re-implements the same splice-and-resequence algorithm.
BoardMovePlanner owns it as a pure function: give it the current placements of the board — id,
column key, position per card — plus the wire payload, and it returns only the placements that
must change, with contiguous zero-based positions in both the source and destination columns. It
returns null when the move is structurally impossible: unknown card, or a destination column
that is not one of the board’s declared columns. A move that changes nothing returns an empty
plan. The planner never writes anything — persistence, locking, and domain rules stay with your
application:
use Illuminate\Support\Facades\DB;use Lattice\Actions\ActionDefinition;use Lattice\Actions\ActionResult;use Lattice\Board\BoardColumn;use Lattice\Board\BoardRegistry;use Lattice\Board\Support\BoardMovePlanner;use Lattice\Board\Support\CardPlacement;
final class MoveTaskAction extends ActionDefinition{ public function __construct(private readonly BoardRegistry $boards) {}
public function handle(Request $request): ActionResult { $payload = $request->validate([ 'cardId' => ['required', 'string'], 'columnKey' => ['required', 'string'], 'position' => ['required', 'integer', 'min:0'], ]);
$board = $this->boards->resolve($this->contextString('board')); $columnKeys = array_map(fn (BoardColumn $column): string => $column->key(), $board->columns());
$card = Task::query()->find($payload['cardId']);
if (! $card) { return ActionResult::failure('The task cannot be moved there.'); }
$tasks = Task::query()->whereIn('status', [$card->status, $payload['columnKey']])->get();
$plan = BoardMovePlanner::plan( $tasks->map(fn (Task $task): CardPlacement => new CardPlacement($task->id, $task->status, $task->position)), $columnKeys, $payload['cardId'], $payload['columnKey'], $payload['position'], );
if ($plan === null) { return ActionResult::failure('The task cannot be moved there.'); }
DB::transaction(function () use ($plan): void { foreach ($plan as $placement) { Task::query()->whereKey($placement->id)->update([ 'status' => $placement->columnKey, 'position' => $placement->position, ]); } });
return ActionResult::success($payload); }}$this->boards->resolve($this->contextString('board')) recovers the board key Board::use()
sealed into the action’s context, so a move handler shared by multiple boards can look up the
right column set. Ids compare by string identity — the wire payload’s string "5" matches an
integer primary key 5 — while the emitted placements keep your rows’ original id types.
Quick-add
Section titled “Quick-add”createAction() wires the inline “+ card” input at the end of each column to a registered action
receiving { column, title }:
Board::use(TaskBoard::class)->createAction(CreateTaskAction::class);final class CreateTaskAction extends ActionDefinition{ public function handle(Request $request): ActionResult { $payload = $request->validate([ 'column' => ['required', 'string'], 'title' => ['required', 'string', 'max:255'], ]);
$position = (int) (Task::query()->where('status', $payload['column'])->max('position') ?? -1) + 1; $task = Task::query()->create(['title' => $payload['title'], 'status' => $payload['column'], 'position' => $position]);
return ActionResult::success(['cardId' => (string) $task->id])->toast('Task added.'); }}After a successful create, the client refetches that column’s first page rather than optimistically
inserting the new card — the server’s cardData()/cardActions() decoration and its own ordering
stay authoritative.
Card click and per-card actions
Section titled “Card click and per-card actions”A whole-card click is one component-wide action, configurable as either a registered action or a plain URL:
Board::use(TaskBoard::class)->cardAction(OpenTaskAction::class); // { cardId, columnKey }final class ScopedTaskBoard extends EloquentBoardDefinition{ public function cardUrl(array $card): string { return '/tasks/'.$card['id']; }}cardActions($card) decorates each card with its own context-menu actions, serialized with the
board key sealed into their context:
final class TaskBoard extends EloquentBoardDefinition{ public function cardActions(array $card): array { return [ Action::use(DeleteTaskAction::class, ['card_id' => $card['id']]), ]; }}Filters and search
Section titled “Filters and search”A board reuses lattice-php/table’s filter classes and wire format for its toolbar. Declare
searchable() fields for full-text q search across the source, and filters() for dedicated
filter controls:
use Lattice\EloquentOptions;use Lattice\Table\Filters\SelectFilter;
final class TaskBoard extends EloquentBoardDefinition{ public function searchable(): array { return ['title', 'assignee']; }
public function filters(): array { return [ SelectFilter::make('assignee') ->label('Assignee') ->optionsFrom(EloquentOptions::make(Task::class)->label('assignee')->value('assignee')) ->searchable(), ]; }}The toolbar renders only when the board declares searchable() fields or filters() — it stays
hidden entirely otherwise. Search and filter changes refetch every column’s first page; the current
q/tf state is preserved across load-more requests, so paging further into a filtered column
keeps the same filter applied. A searchable SelectFilter’s options are resolved through the same
sub-request seam the table endpoint uses, scoped to the board’s own declared filters.
Syncing search and filters to the URL
Section titled “Syncing search and filters to the URL”A board can restore its search and dedicated filters from the page URL on load, and keep the URL
updated as the user searches or filters — the same opt-in mechanism a table uses. Override
syncsQueryToUrl() on the definition:
final class TaskBoard extends EloquentBoardDefinition{ public function syncsQueryToUrl(): bool { return true; }}Only q and tf[...] are restored — a board’s column/offset/limit load-more state is
ephemeral, so the initial render always paints the first perColumn() of every column regardless of
what’s in the URL. As with tables, restoring is tolerant: an unknown tf key is dropped instead of
rejected, and the URL is updated via history.replaceState only, so filtering never creates a
browser history entry.
When a page has more than one synced table or board, give every one but one a urlQueryKey() so
each owns a distinct, bracketed slice of the URL instead of colliding on the unprefixed params — see
Syncing filters to the URL for the full
scoping rules.
Configuration
Section titled “Configuration”'boards' => [ 'endpoint' => 'lattice/boards/{board}', 'middleware' => ['web', 'auth'],],The definition is discovered like any Lattice definition (#[AsBoard] + Lattice’s discovery
paths), and the serialized board carries a sealed reference — the same
signing machinery Lattice tables use — that the package’s
lattice/boards/{board} endpoint verifies before resolving the definition again with the identical
context. authorize() on the definition gates both the initial render and every fetch.
Per-column paging
Section titled “Per-column paging”The initial render fetches each column’s first perColumn() cards plus a server-computed total.
“Load more” requests one column at a time (?column=<key>&offset=<n>), fetching one extra row to
derive hasMore without a second count query. Positions are absolute, since loading is a strict
per-column prefix — a drop’s index is already the final position.
Translations
Section titled “Translations”The component’s strings ship with inline English defaults. With
laravel-i18next enabled, the plugin’s board
namespace is loaded automatically and serves the bundled en/de translations (override them
like any Laravel package translation — see Internationalization).
Bundle size
Section titled “Bundle size”The breakdown below measures the package’s built plugin — React and the framework runtime are external, so this is exactly what the board package adds on top of an app that already ships Lattice. It is regenerated on every docs build.
8.5 KB gzipped · 27.1 KB raw JavaScript
| Dependency | Raw | Gzip | Share |
|---|---|---|---|
| @lattice-php/board | 22.7 KB | 7.7 KB | 91.4% |
| Bundler runtime | 4.5 KB | 0.7 KB | 8.6% |
Emitted JavaScript files
| File | Raw | Gzip |
|---|---|---|
plugin.js | 27.1 KB | 8.5 KB |