Recipes#
A recipe is a reusable field definition that composes directly into an existing container.
Use one when the same block of fields belongs in several field groups or at several depths, especially when each host may need small changes afterwards.
A recipe implements one method:
use AcfChef\Contracts\FieldContainer;
use AcfChef\Contracts\Recipe;
final class Heading implements Recipe
{
public function compose(FieldContainer $set): void
{
$set
->text('heading')
->textarea('standfirst');
}
}
compose() returns nothing. Whatever the recipe produces, it adds directly to the FieldContainer it receives.
Compose a recipe#
Pass a recipe to use():
$heading = new Heading();
$group->use($heading);
use() is available on:
- A field group
- A field set
- A container cursor
The recipe therefore works at any depth:
$heading = new Heading();
$hero = FieldGroup::make('hero');
$hero->use($heading);
$promo = FieldGroup::make('promo');
$promo->group('intro', fn ($intro) => $intro
->use($heading));
$promo->repeater('cards', fn ($cards) => $cards
->use($heading));
$promo->flexibleContent('sections', fn ($sections) => $sections
->layout('feature', fn ($feature) => $feature
->use($heading)));
The recipe does not know or care which kind of container received it.
Pass a recipe directly to a container#
A container verb accepts a Recipe anywhere it accepts its nesting closure:
$group->group('intro', $heading);
That is the reusable equivalent of:
$group->group('intro', fn ($intro) => $intro
->text('heading')
->textarea('standfirst'));
The same applies to repeaters, flexible content and layouts.
Recipes compose into the live field tree#
A recipe does not return a detached array or temporary field group.
It adds fresh nodes directly to the host container.
That means everything it declares is immediately available to the rest of the describe-phase API:
$group->use($heading);
$group->modify('heading', [
'label' => 'Hero Headline',
]);
This is the main reason to use a recipe instead of building a throwaway field group and extracting fields from it.
Compose the shared definition first, then change only what this host needs.
Each host gets its own fields#
Passing the same recipe object to several containers produces separate field nodes in each host:
$heading = new Heading();
$hero = FieldGroup::make('hero');
$hero->use($heading);
$promo = FieldGroup::make('promo');
$promo->use($heading);
$hero->modify('heading', [
'label' => 'Hero Headline',
]);
The edit affects the hero field only. The promo group has its own heading node.
The recipe object you pass to use() is not itself composed. ACF Chef clones it first, so the instance you hold remains untouched and can be reused.
Recipe cloning is shallow#
The recipe clone is an ordinary PHP clone.
That means scalar state belongs to each composition independently:
final class NumberedFields implements Recipe
{
private int $number = 0;
public function compose(FieldContainer $set): void
{
$this->number++;
$set->text('item_' . $this->number);
}
}
Each use starts from the state of the original recipe object rather than mutating that original.
Object-valued properties are different. A shallow clone copies the reference, so mutable objects stored on a recipe are shared between its copies unless the recipe implements its own cloning behaviour.
Do not use a recipe expecting arbitrary nested object state to be isolated automatically.
The field nodes produced by each composition are still independent; this caveat concerns state stored on the recipe object itself.
Closures are lightweight recipes#
use() also accepts a closure:
use AcfChef\Contracts\FieldContainer;
$heading = function (FieldContainer $set): void {
$set
->text('heading')
->textarea('standfirst');
};
$group->use($heading);
The closure receives the container itself — the same FieldSet that a nesting closure receives, not a cursor.
Its return value is discarded. Add fields to the supplied container rather than returning anything from it.
A closure can also be passed directly to a container verb:
$group->group('intro', $heading);
Use a closure for a small local partial. Use a Recipe class when the definition needs configuration, composition of its own or a reusable extension point.
Configure recipes through their constructor#
Constructor arguments are the natural way to parameterise a recipe:
final class TextField implements Recipe
{
private const SETTINGS = [
'required' => true,
];
public function __construct(
private readonly string $name,
private readonly array $config = [],
) {
}
public function compose(FieldContainer $set): void
{
$set->text($this->name)->config([
...self::SETTINGS,
...$this->config,
]);
}
}
Reuse the same recipe shape with different inputs:
$group
->use(new TextField('heading'))
->use(new TextField('kicker', [
'required' => false,
'maxlength' => 80,
]));
Shared defaults can stay in the recipe while each instance supplies the values that differ.
Recipes can use other recipes#
A recipe can compose another recipe through the same use() API:
final class Intro implements Recipe
{
public function compose(FieldContainer $set): void
{
$set->use(new TextField('heading'));
$set->textarea('standfirst');
}
}
Composition does not need to stop at one level. Small recipes can form larger ones while every field still lands in the host's live node tree.
Use a base recipe for a shared structure#
For a family of field groups with the same skeleton, an abstract recipe can own the structure and leave protected hooks for the parts that differ:
abstract class ContentSection implements Recipe
{
final public function compose(FieldContainer $set): void
{
$set
->tab('Content')
->group('content', function ($content) {
$this->fields($content);
});
$this->finish($set);
}
abstract protected function fields(FieldContainer $set): void;
protected function finish(FieldContainer $set): void
{
}
}
A concrete recipe fills the shared container:
final class HeroContent extends ContentSection
{
protected function fields(FieldContainer $set): void
{
$set
->text('title')
->textarea('summary');
}
protected function finish(FieldContainer $set): void
{
$set->modify('content.title', [
'required' => true,
]);
}
}
Keep path-addressed finishing work last.
modify() resolves against the node tree immediately, so the field it targets must already have been added. Building the common skeleton first, filling its hooks second and applying path edits in finish() gives those edits a complete tree to work against.
See Finding and editing fields for path resolution and Nesting and chaining for the container API recipes receive.