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.
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.
| 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
| 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
| 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 →
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');
}
}
<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.
|