Components

Server Tree Table

A server-rendered, Livewire-first tree table: nested rows from your query, expand and collapse without a request, row actions, and drag-and-drop or keyboard reordering that persists with one call.

php artisan blatui:add server-tree-table

Reordering sends one call when a row is dropped: reorderMethod($parentId, $ids, $movedId), where $ids are the new parent's children in their new order. Treat all three as untrusted input: check that every id belongs to the user's tenant, that the parent does, and that the parent is not the moved row or one of its descendants. Then save. The rows move in the browser straight away, and the next render puts them back if save refused.

Categories
Category Products Status
Clothing
42 Active
Men
18 Active
Shirts
11 Active
Trousers
7 Draft
Women
24 Active
Footwear
16 Active
Sneakers
9 Active
Boots
7 Archived
Accessories
5 Draft
@php($categories = [
    ['id' => 1, 'name' => 'Clothing', 'products_count' => 42, 'status' => 'Active', 'children' => [
        ['id' => 2, 'name' => 'Men', 'products_count' => 18, 'status' => 'Active', 'children' => [
            ['id' => 3, 'name' => 'Shirts', 'products_count' => 11, 'status' => 'Active'],
            ['id' => 4, 'name' => 'Trousers', 'products_count' => 7, 'status' => 'Draft'],
        ]],
        ['id' => 5, 'name' => 'Women', 'products_count' => 24, 'status' => 'Active'],
    ]],
    ['id' => 6, 'name' => 'Footwear', 'products_count' => 16, 'status' => 'Active', 'children' => [
        ['id' => 7, 'name' => 'Sneakers', 'products_count' => 9, 'status' => 'Active'],
        ['id' => 8, 'name' => 'Boots', 'products_count' => 7, 'status' => 'Archived'],
    ]],
    ['id' => 9, 'name' => 'Accessories', 'products_count' => 5, 'status' => 'Draft'],
])

{{-- Nested rows: each row's children under `children` — an array, a Collection, or a loaded relation. --}}
<x-ui.server-tree-table
    class="w-full max-w-2xl"
    caption="Categories"
    :columns="[
        ['key' => 'name', 'label' => 'Category'],
        ['key' => 'products_count', 'label' => 'Products', 'align' => 'right'],
        ['key' => 'status', 'label' => 'Status'],
    ]"
    :rows="$categories"
    :expanded="[1]"
/>

Flat Rows

Teams
Team Members
Engineering
24
Platform
9
Infrastructure
4
Product
15
Design
6
Research
2
@php($rows = [
    ['id' => 10, 'parent_id' => null, 'name' => 'Engineering', 'members' => 24],
    ['id' => 11, 'parent_id' => 10, 'name' => 'Platform', 'members' => 9],
    ['id' => 12, 'parent_id' => 11, 'name' => 'Infrastructure', 'members' => 4],
    ['id' => 13, 'parent_id' => 10, 'name' => 'Product', 'members' => 15],
    ['id' => 14, 'parent_id' => null, 'name' => 'Design', 'members' => 6],
    ['id' => 15, 'parent_id' => 14, 'name' => 'Research', 'members' => 2],
])

{{-- Flat rows, the shape one query returns: parent-key builds the tree. A row whose parent
     is not in the set becomes a root, so a filtered or paginated result still renders. --}}
<x-ui.server-tree-table
    class="w-full max-w-xl"
    caption="Teams"
    parent-key="parent_id"
    :columns="[
        ['key' => 'name', 'label' => 'Team'],
        ['key' => 'members', 'label' => 'Members', 'align' => 'right'],
    ]"
    :rows="$rows"
    expand-all
/>

Reorder

Categories
Category
Clothing
Men
Women
Kids
Footwear
Sneakers
Boots
Accessories

To move a row, focus it and press Space, move it with the arrow keys, then press Space again to drop it or Escape to cancel.

Last move:

@php($categories = [
    ['id' => 1, 'name' => 'Clothing', 'children' => [
        ['id' => 2, 'name' => 'Men'],
        ['id' => 3, 'name' => 'Women'],
        ['id' => 4, 'name' => 'Kids'],
    ]],
    ['id' => 5, 'name' => 'Footwear', 'children' => [
        ['id' => 6, 'name' => 'Sneakers'],
        ['id' => 7, 'name' => 'Boots'],
    ]],
    ['id' => 8, 'name' => 'Accessories'],
])

{{-- Drag a row by its handle, or focus it and press Space, then the arrow keys. With
     `reparent`, Right/Left (or dropping onto the middle of a row) moves it under another
     parent. Under Livewire, reorder-method receives ($parentId, $ids, $movedId) once per
     drop; here the tree-reorder event shows the same payload. --}}
<div x-data="{ last: null }" x-on:tree-reorder="last = $event.detail" class="w-full max-w-xl space-y-3">
    <x-ui.server-tree-table
        caption="Categories"
        :columns="[['key' => 'name', 'label' => 'Category']]"
        :rows="$categories"
        :expanded="[1, 5]"
        reorderable
        reparent
    />
    <p class="text-muted-foreground text-sm">
        Last move: <code x-text="last ? JSON.stringify({ parent: last.parent, ids: last.ids, moved: last.moved }) : 'none yet'"></code>
    </p>
</div>

Row Actions

Category Products Actions
Clothing
42
Men
18
Women
24
Footwear
16
@php($categories = [
    ['id' => 1, 'name' => 'Clothing', 'products_count' => 42, 'children' => [
        ['id' => 2, 'name' => 'Men', 'products_count' => 18],
        ['id' => 3, 'name' => 'Women', 'products_count' => 24],
    ]],
    ['id' => 4, 'name' => 'Footwear', 'products_count' => 16],
])

{{-- The same actions as server-table: each one a native wire:click carrying the row's key. --}}
<x-ui.server-tree-table
    class="w-full max-w-2xl"
    variant="card"
    responsive="stack"
    :columns="[
        ['key' => 'name', 'label' => 'Category'],
        ['key' => 'products_count', 'label' => 'Products', 'align' => 'right'],
    ]"
    :rows="$categories"
    :expanded="[1]"
    actions-mode="dropdown"
    :actions="[
        ['label' => 'Add subcategory', 'icon' => 'plus', 'method' => 'createChild'],
        ['label' => 'Edit', 'icon' => 'pencil', 'method' => 'edit'],
        ['label' => 'Delete', 'icon' => 'trash-2', 'method' => 'delete', 'variant' => 'destructive', 'confirm' => 'Delete this category?'],
    ]"
/>

Using with Livewire

wire:model

The examples above are the frontend (Blade + Alpine) usage. Inside a Livewire component, bind wire:model for two-way state — same component, no wrappers. Full Livewire guide →

app/Livewire/Demo.php
use Livewire\Component;

class Demo extends Component
{
    public array $expanded = [];

    #[\Livewire\Attributes\Computed]
    public function categories()
    {
        // Flat rows, one query: the component builds the tree from parent_id.
        return \App\Models\Category::query()
            ->withCount('products')
            ->orderBy('position')
            ->get();
    }

    public function reorderCategories(?int $parentId, array $ids, int $movedId): void
    {
        $this->authorize('reorder', \App\Models\Category::class);

        $scope = \App\Models\Category::query()->where('company_id', auth()->user()->company_id);
        $ids = array_map('intval', $ids);

        // Every id, and the parent, must be ours.
        abort_unless($scope->clone()->whereKey($ids)->count() === count($ids), 403);
        abort_unless($parentId === null || $scope->clone()->whereKey($parentId)->exists(), 403);

        // No cycles: the new parent may not be the moved row or anything under it.
        for ($p = $parentId; $p !== null; $p = $scope->clone()->whereKey($p)->value('parent_id')) {
            abort_if($p === $movedId, 422);
        }

        \Illuminate\Support\Facades\DB::transaction(function () use ($scope, $ids, $parentId) {
            foreach ($ids as $position => $id) {
                $scope->clone()->whereKey($id)->update(['parent_id' => $parentId, 'position' => $position]);
            }
        });
    }

    public function render()
    {
        return view('livewire.demo');
    }
}
resources/views/livewire/demo.blade.php
<x-ui.server-tree-table
    :rows="$this->categories"
    parent-key="parent_id"
    :columns="[
        ['key' => 'name', 'label' => 'Category'],
        ['key' => 'products_count', 'label' => 'Products', 'align' => 'right'],
    ]"
    expanded-model="expanded"
    reorder-method="reorderCategories"
    reparent
    :actions="[
        ['label' => 'Edit', 'icon' => 'pencil', 'method' => 'edit'],
    ]"
/>

Expanding a branch never makes a request; expanded-model only records which branches are open, and rides along with the next one. Reordering makes exactly one request per drop.

API Reference

Props, slots and exposed methods for <x-ui.server-tree-table>.

Props

Prop Type Default Description
columns* array [] The column definitions, in the same shape as server-table: key, label, sortable, align, class, width. The first column carries the indent and the expand button unless treeColumn says otherwise.
rows* array|Collection|Paginator [] The rows, rendered server-side. Either nested, with each row's children under childrenKey, or flat with parentKey. A paginator paginates the roots. Every row is rendered whether its branch is open or not, so expanding costs no request.
rowKey string 'id' The primary-key path read from each row (via data_get). Used for wire:key, for the open state, and as the ids sent to reorderMethod.
childrenKey string 'children' Where a nested row keeps its children: an array, a Collection, or an eager-loaded relation.
parentKey string Pass flat rows, the shape one query returns, and name the column that points at the parent (e.g. parent_id). A row whose parent is not in the set is drawn as a root, so a search result or one page of a paginator still renders.
treeColumn string The column key that carries the indent, the drag handle and the expand button. Defaults to the first column.
expanded array [] Keys of the rows that start open.
expandAll bool false Show every row regardless of what is open. Server-driven, so :expand-all="$search !== ''" reveals matches inside closed branches while a search is active and gives the tree back when it is cleared.
expandedModel string A Livewire array property to keep the open keys in. Opening a branch still makes no request; the keys ride along with the next one. Use it when the server should know, for example to persist the open branches or to keep them across a page change.
reorderable bool false Show drag handles and allow keyboard reordering. Rows move in the browser and a tree-reorder event reports each completed move.
reorderMethod string The Livewire method called once per completed move with ($parentId, $ids, $movedId): the new parent (null for the root level), that parent's children in their new order, and the row that moved. Implies reorderable. Treat all three as untrusted input.
reparent bool false Allow a row to move under a different parent. By pointer, drop onto the middle of a row. By keyboard, press Right to nest under the row above or Left to move out a level. Without it, a row only moves among its siblings.
sort string The current sort key, for aria-sort and the active header. Sorting happens in your query, per level. Reordering by hand only makes sense while sorted by position, so pass reorder-method only then.
direction string 'asc' The current sort direction, asc or desc.
sortMethod string 'sortBy' The Livewire method a sortable header calls with the column key.
actions array [] Declarative row actions, in the same shape as server-table's: each renders a native wire:click carrying the row's real key.
actionsView string A Blade view included per row, with $row and $depth in scope, for fully custom action markup.
actionsMode string 'inline' Buttons in the row, or an overflow menu.
inline dropdown
cellViews array [] Map of column key to a Blade view, included with $value, $row and $depth in scope.
searchable bool false Render a search input bound to searchModel with wire:model.live.debounce.300ms.
searchModel string 'search' The Livewire property the search input binds to.
perPageOptions array [] Page sizes for a select bound to perPageModel. Empty renders no select.
responsive string 'scroll' scroll keeps a table that scrolls sideways on narrow screens. stack turns each row into a card below md, indented by its depth.
scroll stack
variant string 'default' A plain bordered table, or one on a card surface.
default card
caption string Accessible table caption (screen-reader only unless captionVisible).
emptyText string 'No results.' Shown when there are no rows.

* Required.

Slots

Slot Description
toolbar An open slot on the toolbar row, after the search input, for filters and bulk actions bound to your own Livewire properties.

Events

Events the component listens for (in) and dispatches (out). Both directions bubble, so .window works even when the component is teleported.

Event Direction Detail
tree-reorder Out { parent, ids, moved, from } A move was completed, by pointer or keyboard. The detail carries the new parent, its children in their new order, the moved key and the parent it came from. Integer keys arrive as numbers. Dispatched with or without reorderMethod, so it works without Livewire too.