Nesting and chaining#

Use closures to nest fields inside containers and cursors to continue method chains.

The closure determines where child fields belong. The cursor determines which field or container receives a call after a field declaration.

Nest fields with a closure#

The four built-in container verbs are:

  • group
  • repeater
  • flexibleContent
  • layout

Pass a closure as the second argument:

$group->flexibleContent('sections', fn ($sections) => $sections
    ->layout('media', fn ($media) => $media
        ->image('picture'))
    ->layout('quote', fn ($quote) => $quote
        ->textarea('words')));

The resulting field paths are:

field_hero_sections
    field_hero_sections_media
        field_hero_sections_media_picture
    field_hero_sections_quote
        field_hero_sections_quote_words

There is no end() or endGroup(). Nesting ends when the closure ends.

The closure receives a field container#

A nesting closure receives an AcfChef\Contracts\FieldContainer.

The object passed to the closure is a FieldSet, not a cursor.

The closure parameter itself has no config() method:

$group->group('content', function ($content) {
    // $content is a FieldSet.
});

Configure each field by chaining from the method that added it:

$group->group('content', fn ($content) => $content
    ->text('title')->config(['required' => true])
    ->textarea('summary')->config(['rows' => 4]));

To configure the container itself, continue after its closure:

$group->group('content', fn ($content) => $content
    ->text('title'))
    ->config(['width' => 50]);

A container verb always returns its own cursor, never the cursor returned by the last child inside the closure.

Add-methods return cursors#

Every add-method returns a cursor pointing at the node it just created:

$title = $group->text('title');

A cursor has its own methods for working with that field, including config(), conditional(), orWhere() and width().

When the cursor does not declare a method, Cursor::__call() checks for it in a fixed order.

How cursor calls are resolved#

When a cursor does not declare the called method, it checks these options in order:

  1. A registered macro.
  2. Delegation to the parent container, when delegation is enabled.
  3. A registered field verb added inside the current field, but only when delegation is disabled.
  4. An exception.

This order keeps chained field declarations at the same nesting level.

On an ordinary chaining cursor, a verb adds a sibling:

$group
    ->group('content', fn ($content) => $content
        ->text('title'))
    ->text('next');

next is a sibling of content.

If the cursor applied the verb to its current field before delegating, it would add next as a child of content without warning.

Delegation keeps a chain moving#

For example:

$group
    ->text('title')
    ->textarea('summary');

text('title') returns the cursor for title.

That cursor does not declare textarea(), so it delegates the call to its parent field set. The field set recognises textarea as a registered verb and adds summary beside title.

The returned cursor now points at summary, so the chain can continue.

Calls can bubble to the field group#

Delegation is not limited to field verbs.

You can also set a location through a field chain:

$group
    ->text('title')
    ->trueFalse('featured')
    ->location('post_type', 'page');

The cursor for featured does not declare location(), so it passes the call to its field set.

The field set does not declare location() either, so it passes the call to the field group.

The field group does declare location(), so the location rule is added there. The call returns the field group.

Delegation succeeds only when an ancestor declares the method. Unknown methods remain invalid.

Macros run before delegation#

The cursor checks registered macros first when it does not declare the called method.

If a macro has the requested name, it runs before the cursor delegates to its parent container:

Chef::macro('half', function () {
    return $this->width(50);
});

$group
    ->text('title')->half()
    ->text('summary')->half();

A matching macro takes precedence over any method the call could reach through delegation.

See Macros and shorthands for how to define macros.

Found cursors do not delegate#

Cursors returned by find() and supplied to modify() have delegation disabled.

With delegation disabled, a registered verb adds a field in a different place.

On an ordinary chaining cursor, the verb delegates to the parent container and adds a sibling.

On a non-delegating cursor, the verb adds a child inside the current field:

$content = $group->find('content');

$content->text('kicker');

If content is a container, kicker is added inside it.

This limits edits to the selected field. A call on a cursor obtained for one path cannot delegate to a parent and unexpectedly add a sibling elsewhere.

See Finding and editing fields for the path APIs.

Unknown methods fail differently#

The error type depends on whether the cursor delegates.

On a normal delegating cursor, an unknown method eventually reaches verb lookup. If no verb is registered, ACF Chef raises UnknownFieldType.

If a registered verb matches within edit distance two, the exception suggests that verb:

$group->tex('title');

The error can suggest text.

On a non-delegating cursor, an unknown method raises BadMethodCallException instead. Its message says that delegation is off, making it clear why the call did not become a sibling field.

This prevents a typo on a targeted cursor from changing another part of the definition: the call fails at the selected field.

width() is a real cursor method#

width() is implemented directly on the cursor:

$group->text('title')->width(50);

It merges wrapper.width.

It preserves other wrapper settings supplied through field configuration:

$group->text('title')
    ->config([
        'wrapper' => [
            'class' => 'hero-title',
            'id' => 'primary-title',
        ],
    ])
    ->width(50);

The resulting wrapper contains all three settings:

[
    'wrapper' => [
        'class' => 'hero-title',
        'id' => 'primary-title',
        'width' => 50,
    ],
]

width() does not replace the existing wrapper.

It is the only wrapper helper in the package: settings in a config() array do not move into wrapper unless your application registered a shorthand that does that.

See Configuring fields for field configuration and merge behaviour.