Skip to content

Tree

The tree field is a builder whose rows nest: each row may hold child rows under the reserved children key, built from the same row templates. The submitted structure is the authoritative order and nesting, so your handle() receives a nested array and persists parent and position straight from it — no separate move endpoint involved. Create one with TreeField::make() from lattice-php/tree and declare the templates exactly like a builder.

TreeField::make('items', 'Line items')
->maxDepth(2)
->acceptsChildrenFor(['heading'])
->templates([
RowTemplate::make('heading')->label('Heading')->schema([
TextInput::make('title', 'Title')->required(),
]),
RowTemplate::make('product')->label('Product line')->schema([
TextInput::make('product', 'Product')->required(),
TextInput::make('qty', 'Qty')->rules(['numeric']),
]),
RowTemplate::make('text')->label('Text')->schema([
Textarea::make('content', 'Content')->required(),
]),
])
->addLabel('Add block')

A row’s children live under its reserved children key and validate, cast, and prefill through the same templates as top-level rows. The field submits the full structure:

['items' => [
['type' => 'heading', 'rowId' => '9f3c…', 'title' => 'Hardware', 'children' => [
['type' => 'product', 'rowId' => 'a2b1…', 'product' => 'Switch', 'qty' => '2'],
['type' => 'text', 'rowId' => 'c4d0…', 'content' => 'Includes patch cabling.'],
]],
['type' => 'product', 'rowId' => 'e7f2…', 'product' => 'Installation', 'qty' => '1'],
]]

Validation errors surface at the nested dot path (items.0.children.1.qty), and every row keeps its stable rowId UUID through validation, exactly like the repeater and builder. Row templates must not declare their own children or rowId fields — both keys are reserved.

->maxDepth() bounds how many levels the tree accepts; the default is unlimited. Depth is enforced structurally: a row at the deepest allowed level offers no child list, and a submit that smuggles deeper rows in fails with a prohibited error on the offending children path instead of silently dropping them.

->acceptsChildrenFor([...]) restricts which row types may hold children. Without it every type accepts children.

TreeField::make('items')
->maxDepth(2)
->acceptsChildrenFor(['heading', 'product']);

Both rules are also enforced client-side: the drag-and-drop layer refuses drops that would nest a row under a type that rejects children, land a subtree deeper than maxDepth, or move a row into its own subtree.

Each row’s toolbar offers two type menus: add sub-item (shown while the row’s type accepts children and depth remains) appends a child below the row, and add item below inserts a sibling right after it. The top level additionally keeps the plain add button (labeled by ->addLabel()) for empty trees and appending at the end.

Every row carries a drag handle; dropping on the upper or lower edge of a target reorders next to it, dropping on its middle nests the dragged row (and its whole subtree) as the target’s last child. The move is a pure form-state update — nothing is persisted until the form submits. Sibling reorder buttons cover keyboard use, and ->reorderable(false) hides them.

->readOnly() (or ->disabled()) renders the same hierarchy without any structural affordances: no drag handles, no add menu, no remove or reorder buttons. Row fields keep their own read-only semantics, so a locked record can render its stored tree through the identical schema.

->minItems() / ->maxItems() bound the number of top-level rows, as on the repeater. Child lists are unbounded apart from maxDepth.