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')- addLabel:"Add block"
- childBearingTypes:["heading"]
- columnWidth:"md"
- conditions:null
- defaultItems:0
- dependsOnAny:false
- dependsOnKeys:null
- disabled:false
- editablePrefill:false
- helperText:null
- label:"Line items"
- labelAction:null
- maxDepth:2
- maxItems:null
- minItems:null
- name:"items"
- nestedRowsKey:"children"
- prefillRefreshOn:null
- prefillResetOn:null
- readOnly:false
- reorderable:true
- required:false
- tooltip:null
- value:null
- autoComplete:null
- autoFocus:false
- columnWidth:"md"
- conditions:null
- copyable:false
- dependsOnAny:false
- dependsOnKeys:null
- disabled:false
- editablePrefill:false
- helperText:null
- label:"Title"
- labelAction:null
- name:"title"
- placeholder:null
- prefillRefreshOn:null
- prefillResetOn:null
- prefix:null
- prefixFieldName:null
- readOnly:false
- required:true
- suffix:null
- suffixFieldName:null
- tabIndex:null
- tooltip:null
- type:null
- value:null
- autoComplete:null
- autoFocus:false
- columnWidth:"md"
- conditions:null
- copyable:false
- dependsOnAny:false
- dependsOnKeys:null
- disabled:false
- editablePrefill:false
- helperText:null
- label:"Product"
- labelAction:null
- name:"product"
- placeholder:null
- prefillRefreshOn:null
- prefillResetOn:null
- prefix:null
- prefixFieldName:null
- readOnly:false
- required:true
- suffix:null
- suffixFieldName:null
- tabIndex:null
- tooltip:null
- type:null
- value:null
- autoComplete:null
- autoFocus:false
- columnWidth:"md"
- conditions:null
- copyable:false
- dependsOnAny:false
- dependsOnKeys:null
- disabled:false
- editablePrefill:false
- helperText:null
- label:"Qty"
- labelAction:null
- name:"qty"
- placeholder:null
- prefillRefreshOn:null
- prefillResetOn:null
- prefix:null
- prefixFieldName:null
- readOnly:false
- required:false
- suffix:null
- suffixFieldName:null
- tabIndex:null
- tooltip:null
- type:null
- value:null
- autoFocus:false
- columnWidth:"md"
- conditions:null
- dependsOnAny:false
- dependsOnKeys:null
- disabled:false
- editablePrefill:false
- helperText:null
- label:"Content"
- labelAction:null
- name:"content"
- placeholder:null
- prefillRefreshOn:null
- prefillResetOn:null
- readOnly:false
- required:true
- rows:null
- tabIndex:null
- tooltip:null
- value:null
Hierarchy is data
Section titled “Hierarchy is data”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.
Depth and parent rules
Section titled “Depth and parent rules”->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.
Adding rows
Section titled “Adding rows”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.
Moving rows
Section titled “Moving rows”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.
Read-only trees
Section titled “Read-only trees”->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.
Row counts
Section titled “Row counts”->minItems() / ->maxItems() bound the number of top-level rows, as on the repeater. Child lists
are unbounded apart from maxDepth.