How it works#

ACF Chef has two phases: describe and compile.

Knowing which phase you are in explains why definitions can be rearranged, why some configuration is deferred and why references can point at fields declared later.

Describe the field group#

During the describe phase, field declarations build a tree of lightweight ACF Chef nodes.

Each node records:

  • The field verb
  • Its name
  • Array configuration
  • Deferred configuration closures
  • Conditional rules
  • Any child fields or layouts

No extended-acf field object exists yet.

$group = FieldGroup::make('hero')
    ->trueFalse('has_image')
    ->group('content', fn ($content) => $content
        ->text('title')
        ->image('picture')->conditional('has_image', '==', true));

At this point, ACF Chef has a description of the group. It has not constructed or serialised any extended-acf fields.

Because the description is still a tree, you can clone it, address fields by path, modify it and compose recipes into it. A deferred config() closure is stored rather than executed.

Compile the field group#

Compilation starts when ACF Chef needs output, such as when you call toArray() or register the group.

At the group level, compilation proceeds in this order:

  1. Normalise the group key into a stem, removing a leading group_ or field_.
  2. Snapshot the current per-run state so a nested compilation cannot corrupt the outer one.
  3. Isolate extended-acf’s process-global key store.
  4. Build every node into a fresh extended-acf field object.
  5. Serialise each top-level field with the group stem.
  6. Leave key isolation and restore the previous key store.
  7. Rewrite every emitted key in the finished array.
  8. Rewrite references to those keys.

The two rewrite passes recurse through both sub_fields and flexible-content layouts.

The first pass replaces field and layout keys with the values produced by the active key strategy. The second updates settings that refer to those keys:

  • A repeater’s collapsed field
  • Every conditional rule’s field

References are rewritten only after every field has been built and serialised. A conditional rule can therefore point at a field declared later in the group.

How each field is built#

Each node follows the same sequence:

  1. Resolve its verb to an extended-acf field class.
  2. Compute the compiled name and label.
  3. Construct the field with make($label, $name).
  4. Apply ordinary array settings through withSettings(), with ACF Chef’s eight reserved settings removed.
  5. Apply an explicit key supplied through configuration.
  6. Compute the path-based key and record the key mapping. Duplicate-key detection happens here.
  7. Build the node’s children and attach them through fields() or layouts().
  8. Apply the collapsed setting.
  9. Run deferred config() closures.
  10. Resolve and attach conditional rules.

You normally do not need to think about each of these steps. Their order matters when one kind of configuration depends on another.

Deferred configuration sees the complete field#

A deferred config() closure runs after array settings, children and collapsed have already been applied.

$group->repeater('items', fn ($items) => $items
    ->text('title'))
    ->config(['collapsed' => 'title'])
    ->config(fn ($field) => $field->required());

The closure receives the real extended-acf field object in its assembled state. It can inspect or override configuration that was applied earlier, including array settings, attached children and the collapsed field.

The closure still runs before conditional rules are attached.

Conditional rules behave the same in both APIs#

You can declare a condition on the cursor:

$group->text('caption')
    ->conditional('has_image', '==', true);

You can also declare it inside deferred configuration:

$group->text('caption')->config(fn ($field) => $field
    ->conditional('has_image', '==', true));

Both forms record the same kind of rule.

Deferred closures finish before conditions are resolved and attached, so a condition has the same behaviour whichever form you use. Target lookup, boolean normalisation and final key rewriting all happen through the same compilation path.

Deliberate keys are not rewritten#

The compiler records its generated-key mapping before deferred closures run.

A key set later inside a deferred closure is therefore not one of the keys the rewrite pass emitted:

$group->text('title')->config(fn ($field) => $field
    ->key('field_legacy_title'));

The rewrite pass leaves that key alone.

The same user-facing rule applies to an explicit key supplied in array configuration:

$group->text('title')->config([
    'key' => 'field_legacy_title',
]);

Explicit keys are deliberate compatibility choices and remain exactly as supplied.

Compilation is repeatable#

extended-acf records generated keys in process-global state. Without isolation, compiling the same definition twice in one request can raise its duplicate-key error even when the group contains no collision.

ACF Chef snapshots that store, installs an empty one for the current compilation and restores the original in a finally block. Nested compilations receive their own state without disturbing the compilation around them.

Duplicate detection is not removed. It moves to the keys ACF Chef actually emits. If two nodes produce the same final key, ACF Chef raises DuplicateFieldKey and identifies the node path that collided.

Compiled field objects are not cached either. extended-acf’s Field::toArray() mutates the field’s settings, so each compilation builds fresh objects rather than attempting to reuse serialised ones.

$first = $group->toArray();
$second = $group->toArray();

assert($first === $second);

The definition remains unchanged, and repeated compilation produces the same array.