# Extended Acf

> Registers WordPress Advanced Custom Fields (ACF) using the vinkla/extended-acf object-oriented PHP API. Use whenever a WordPress theme or plugin task involves data modeling, defining custom fields, ACF field groups, post-type metadata, flexible content / repeater layouts, Gutenberg block fields, options-page schemas, taxonomies, or relationships — instead of writing register_field_group() arrays by hand. Covers every extended-acf field class (Text, Image, Repeater, FlexibleContent, Group, Relationship, etc.), Location rules, ConditionalLogic, bidirectional relationships, macros, and custom field classes.

- Skill: `digital-paths/extended-acf` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add digital-paths/extended-acf`
- Raw SKILL.md: https://api.skillmd.com/api/skills/digital-paths/extended-acf/raw
- Safety review: pending (external: skill-scanner PASS, skillspector PASS)
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs
- Author: digital-paths (https://skillmd.com/u/digital-paths)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/digital-paths/extended-acf

---


# Extended ACF (vinkla/extended-acf)

Use the **`vinkla/extended-acf`** Composer package to register ACF field groups in object-oriented PHP. Do not hand-write raw `register_field_group()` arrays in this project unless asked to — the OOP API is preferred because field keys are auto-generated (no collisions), and field definitions live in version control.

This skill assumes:

- WordPress + ACF (Pro recommended) is in use.
- The project already has `vinkla/extended-acf` installed, or you should add it: `composer require vinkla/extended-acf`.
- ACF Pro is installed separately (downloaded zip in `plugins/` or `mu-plugins/`).

When more detail than this file provides is needed, read `reference/UPSTREAM.md` for the pinned upstream README link and version info.

## When to use

Use this skill any time the task involves:

- "Add a custom field / field group" / "model this content in ACF"
- Building a new post type's metadata schema
- Designing a Gutenberg ACF block's fields
- Adding fields to an options page
- Building a flexible content (page-builder-style) layout
- Repeaters, groups, galleries, relational fields
- Modifying or refactoring existing extended-acf field definitions
- Setting up conditional logic, location rules, or bidirectional relationships

## When not to use

- The project uses a different ACF abstraction (Sage/Acorn `Field`, StoutLogic/acf-builder, custom JSON sync only) — defer to that.
- Pure ACF JSON sync via the dashboard, with no PHP registration — this skill is PHP-first.
- The user explicitly asks for raw `register_field_group()` arrays.

## Core registration pattern

Always register inside the `acf/include_fields` action. `register_extended_field_group()` wraps ACF's own function and assigns deterministic keys.

```php
use Extended\ACF\Fields\Image;
use Extended\ACF\Fields\Text;
use Extended\ACF\Location;

add_action('acf/include_fields', function () {
    register_extended_field_group([
        'title'    => 'About',
        'fields'   => [
            Image::make('Image'),
            Text::make('Title'),
        ],
        'location' => [
            Location::where('post_type', 'page'),
        ],
    ]);
});
```

Group settings (`menu_order`, `position`, `style`, `label_placement`, `hide_on_screen`, etc.) are the same array keys ACF itself accepts — see ACF's `register_field_group` docs.

## Field construction pattern

Every field class follows the same shape:

```php
Text::make('Label', 'optional_name')
    ->helperText('Description shown to editors.')
    ->required();
```

- The first argument is the human label.
- If the second argument is omitted, the field `name` is the label converted to `snake_case` (via `sanitize_title`, so accents and punctuation are stripped).
- Names must contain only `[a-z0-9_]`.
- **Every** field type derives a name this way, including layout-only fields (`Tab`, `Accordion`, `Message`). The field key is generated from `<parent key>_<name>`, and a duplicate key throws `InvalidArgumentException: The key [...] is not unique.` at registration. See "Naming gotchas" below.
- Most fields support `default(...)`, `required()`, `wrapper([...])`.
- Most "basic" fields also support `prepend(...)`, `append(...)`, `placeholder(...)`, `readOnly()`, `disabled()`.

## Field class index

Pick the smallest field that fits. All classes live under `Extended\ACF\Fields\`.

| Category | Classes |
| --- | --- |
| Basic | `Email`, `Number`, `Password`, `Range`, `Text`, `Textarea`, `URL` |
| Content | `File`, `Gallery`, `Image`, `Oembed`, `WYSIWYGEditor` |
| Choice | `ButtonGroup`, `Checkbox`, `RadioButton`, `Select`, `TrueFalse` |
| Relational | `Link`, `PageLink`, `PostObject`, `Relationship`, `Taxonomy`, `User` |
| Advanced | `ColorPicker`, `DatePicker`, `IconPicker`, `TimePicker`, `DateTimePicker`, `GoogleMap` |
| Layout | `Accordion`, `FlexibleContent`, `Group`, `Message`, `Repeater`, `Tab` (the Clone field has no class — see below) |

### Basic

```php
Email::make('Email')->required();

Number::make('Age')->min(18)->max(65);

Password::make('Password')->required();

Range::make('Rate')->min(0)->max(100)->step(10);

Text::make('Name')->maxLength(100)->required();

Textarea::make('Biography')
    ->newLines('br')      // 'br' | 'wpautop'
    ->maxLength(2000)
    ->rows(10);

URL::make('Website')->required();
```

### Content

```php
File::make('Restaurant Menu', 'menu')
    ->acceptedFileTypes(['pdf'])
    ->library('all')      // 'all' | 'uploadedTo'
    ->minSize('400 KB')
    ->maxSize(5)          // int = MB
    ->format('array');    // 'id' | 'url' | 'array' (default)

Gallery::make('Images')
    ->acceptedFileTypes(['jpg', 'jpeg', 'png'])
    ->minHeight(500)->maxHeight(1400)
    ->minWidth(1000)->maxWidth(2000)
    ->minFiles(1)->maxFiles(6)
    ->minSize('400 KB')->maxSize(5)
    ->library('all')
    ->format('array')
    ->previewSize('medium')  // 'thumbnail' | 'medium' | 'large'
    ->prependFiles();

Image::make('Background Image')
    ->acceptedFileTypes(['jpg', 'jpeg', 'png'])
    ->minHeight(500)->maxHeight(1400)
    ->minWidth(1000)->maxWidth(2000)
    ->minSize('400 KB')->maxSize(5)
    ->library('all')
    ->format('array')
    ->previewSize('medium');

Oembed::make('Tweet')->required();

WYSIWYGEditor::make('Content')
    ->tabs('visual')       // 'all' | 'text' | 'visual' (default)
    ->toolbar(['bold', 'italic', 'link'])
    ->disableMediaUpload()
    ->lazyLoad();
```

### Choice

`choices(...)` accepts either a list (auto-snake_cased keys) or an explicit map `['key' => 'Label']`. `format` returns `'value'` by default.

**Always use machine-safe choice keys** (`[a-z0-9_]`) and put the human text in the label. Never let a sentence containing an apostrophe, quote, backslash, or accented character double as the key — see "Choice keys must be machine-safe" below.

```php
ButtonGroup::make('Color')
    ->choices(['Forest Green', 'Sky Blue'])
    ->default('forest_green')
    ->format('value');   // 'array' | 'label' | 'value' (default)

Checkbox::make('Color')
    ->choices(['Forest Green', 'Sky Blue'])
    ->layout('horizontal')   // 'vertical' | 'horizontal'
    ->create(save: true)     // v15.1+: allow custom values; save: true persists them into choices
    ->toggle();              // v15.1+: add a "toggle all" checkbox

RadioButton::make('Color')
    ->choices(['Forest Green', 'Sky Blue'])
    ->default('forest_green')
    ->create(save: true);    // v15.1+: add an "Other" choice; save: true persists custom values

Select::make('Color')
    ->choices(['Forest Green', 'Sky Blue'])
    ->multiple()
    ->nullable()
    ->stylized()             // Select2-style
    ->lazyLoad()             // AJAX load
    ->create(save: true);    // allow new options; save: true persists them into choices
// v15.1+ prefers create(save: true) over the older ->create()->save() pair (save() still exists).

TrueFalse::make('Social Media', 'display_social_media')
    ->default(false)
    ->stylized(on: 'Yes', off: 'No');
```

### Relational

```php
Link::make('Read More Link')
    ->format('array');   // 'url' | 'array' (default)

PageLink::make('Contact Link')
    ->postTypes(['contact'])
    ->postStatus(['publish'])  // draft|future|pending|private|publish
    ->taxonomies(['category:city'])
    ->disableArchives()
    ->nullable()
    ->multiple();

PostObject::make('Animal')
    ->postTypes(['animal'])
    ->postStatus(['publish'])
    ->nullable()
    ->multiple()
    ->format('object');  // 'id' | 'object' (default)

Relationship::make('Contacts')
    ->postTypes(['contact'])
    ->postStatus(['publish'])
    ->filters(['search', 'post_type', 'taxonomy'])
    ->elements(['featured_image'])
    ->minPosts(3)->maxPosts(6)
    ->format('object');

Taxonomy::make('Cinemas')
    ->taxonomy('cinema')
    ->appearance('checkbox')  // checkbox | multi_select | radio | select
    ->create(false)
    ->load(true)
    ->save(true)
    ->format('id');           // 'object' | 'id' (default)

User::make('User')
    ->roles(['administrator', 'editor'])
    ->format('array');        // 'id' | 'object' | 'array' (default)
```

### Advanced

```php
ColorPicker::make('Text Color')
    ->default('#4a9cff')
    ->opacity()
    ->format('string')   // 'array' | 'string' (default)
    ->palette(['#111111', '#222222', '#333333'])
    ->disableColorWheel();

DatePicker::make('Birthday')
    ->displayFormat('d/m/Y')
    ->format('d/m/Y')
    ->defaultNow();

IconPicker::make('Icon')
    ->format('string')
    ->tabs(['dashicons']);  // dashicons | media_library | url

TimePicker::make('Start Time', 'time')
    ->displayFormat('H:i')
    ->format('H:i');

DateTimePicker::make('Event Date', 'date')
    ->displayFormat('d-m-Y H:i')
    ->format('d-m-Y H:i')
    ->firstDayOfWeek(1)        // 0 = Sunday, 1 = Monday
    ->defaultNow();
// Or: ->weekStartsOnMonday() / ->weekStartsOnSunday()

GoogleMap::make('Address', 'address')
    ->center(57.456286, 18.377716)
    ->zoom(14);
```

### Layout

```php
// Accordion: fields placed after it become its children until an endpoint.
Accordion::make('Address')->open()->multiExpand();
Accordion::make('Endpoint')->endpoint()->multiExpand();

// FlexibleContent: page-builder-style layouts.
FlexibleContent::make('Blocks')
    ->button('Add Component')
    ->layouts([
        Layout::make('Image')
            ->layout('block')
            ->fields([
                Text::make('Description'),
            ]),
    ])
    ->minLayouts(1)
    ->maxLayouts(10);

// Group: nested sub-fields.
Group::make('Hero')
    ->fields([
        Text::make('Title'),
        Image::make('Background Image'),
    ])
    ->layout('row');

// Message: editor-facing notice, not stored.
Message::make('Heading')
    ->body('Up to 1.21 gigawatts.')
    ->escapeHtml();

// Repeater: list of sub-field rows.
Repeater::make('Employees')
    ->fields([
        Text::make('Name'),
        Image::make('Profile Picture'),
    ])
    ->minRows(2)->maxRows(10)
    ->collapsed('name')
    ->button('Add employee')
    ->paginated(10)
    ->layout('table');  // 'block' | 'row' | 'table'

// Tab: groups subsequent fields into a tab. Use endpoint() to start a new tab group.
// Always pass an explicit name: a tab's key is derived from its label like any
// other field, so Tab::make('Consents') + Checkbox::make('Consents') collide.
Tab::make('General', 'general_tab');
Tab::make('Consents', 'consents_tab')
    ->placement('top')   // 'top' | 'left'
    ->selected()
    ->endpoint();
```

### Clone (no class)

The clone field has no dedicated class. Define a field in its own file and `require` it:

```php
// fields/email.php
use Extended\ACF\Fields\Email;
return Email::make('Email')->required();

// employee.php
register_extended_field_group([
    'fields' => [
        require __DIR__ . '/fields/email.php',
    ],
]);
```

## Location rules

```php
use Extended\ACF\Location;

Location::where('post_type', 'post')
        ->and('post_type', '!=', 'post');
// Operators: ==, !=
```

If only two arguments are given, the operator defaults to `==`. The renamed-from-`if`-to-`where` change happened in v12.

## Conditional logic

```php
use Extended\ACF\ConditionalLogic;

Select::make('Type')->choices([
    'document' => 'Document',
    'link'     => 'Link',
    'embed'    => 'Embed',
]);

File::make('Document', 'file')->conditionalLogic([
    ConditionalLogic::where('type', '==', 'document'),
]);

// AND
Textarea::make('Embed Code')->conditionalLogic([
    ConditionalLogic::where('type', '!=', 'document')
                    ->and('type', '!=', 'link'),
]);

// OR — multiple ConditionalLogic entries are OR'd
Text::make('Title')->conditionalLogic([
    ConditionalLogic::where('type', '!=', 'document'),
    ConditionalLogic::where('type', '!=', 'link'),
]);

// Cross-group reference
Text::make('Sub Title')->conditionalLogic([
    ConditionalLogic::where(
        group:    'other-group',
        name:     'enable_highlight',
        operator: '==',
        value:    'on',
    ),
]);
```

Operators: `==`, `!=`, `>`, `<`, `==pattern`, `==contains`, `==empty`, `!=empty`.

## Bidirectional relationships

Both sides must define a custom key and reference each other:

```php
// On "Project" post type
Relationship::make('Related Testimonial')
    ->postTypes(['testimonial'])
    ->key('field_related_testimonial')
    ->bidirectional('field_related_project');

// On "Testimonial" post type
Relationship::make('Related Project')
    ->postTypes(['project'])
    ->key('field_related_project')
    ->bidirectional('field_related_testimonial');
```

This is the **only** case where you should set custom field keys manually. Everywhere else, let `register_extended_field_group` generate them.

## Non-standard methods

- `helperText('...')` — replaces ACF's `instructions`. Supports limited Markdown: `**bold**`, `__bold__`, `*italic*`, `_italic_`, `` `code` ``, `[link](https://example.com)`.
- `column(int $percent)` — shorthand for `wrapper(['width' => $percent])`. Skip this in block patterns / ACF blocks — the sidebar is narrow; keep fields at 100%.
- `dd()` / `dump()` — debugging only. Requires `composer require symfony/var-dumper --dev`.
- `key('field_xxx')` — custom field key. Must be prefixed `field_` or `layout_` and only contain `[a-z0-9_]`. Avoid except for bidirectional relationships.
- `withSettings(['key' => 'value'])` — merge arbitrary ACF setting keys onto the field. Useful for ACF features the package doesn't expose directly.

## Macros (runtime field extensions)

Register on the base `Field` class during `acf/init`:

```php
use Extended\ACF\Fields\Field;

add_action('acf/init', function () {
    Field::macro('translatable', function (Field $field): static {
        return $field->withSettings(['translatable' => true]);
    });
});

// Then any field can use it:
Text::make('Title')->translatable();
```

For type-restricted macros, check `instanceof` inside the closure and throw `BadMethodCallException` on mismatch.

## Custom field classes

Extend `Extended\ACF\Fields\Field` and import setting traits from `Extended\ACF\Fields\Settings\`:

```php
namespace App\Fields;

use Extended\ACF\Fields\Field;
use Extended\ACF\Fields\Settings\HelperText;
use Extended\ACF\Fields\Settings\Required;

class OpenStreetMap extends Field
{
    use HelperText;
    use Required;

    protected $type = 'open_street_map';

    public function latitude(float $latitude): static
    {
        $this->settings['latitude'] = $latitude;
        return $this;
    }
}
```

Common traits: `HelperText`, `Required`, `FileTypes`, `MaxLength`, `Affixable` (prepend/append), `Immutable` (readOnly/disabled), `Fields` (sub-fields).

## Gotchas

### Choice keys must be machine-safe

**Symptom:** a required `Checkbox`, `RadioButton`, or `Select` inside an **ACF Extended (ACFE) front-end form** fails validation with the generic required-value message ("X value is required" / "Il valore X è richiesto") even though the user selected an option. Server-side `acf_validate_value` reports the field as valid, yet the form still rejects it.

**Cause:** when the ACFE form has `kses: true`, ACFE runs `wp_kses_post_deep()` followed by `wp_slash()` on `$_POST` before validating. A submitted value such as `l'Informativa` becomes `l'Informativa`. ACFE's checkbox `validate_front_value()` then does:

```php
if (!empty(array_diff($value, array_keys($field['choices'])))) { return false; }
```

The slashed submitted value no longer matches the unslashed choice key, so the field is rejected, and it surfaces as the misleading required-message rather than a "not in choices" error.

**Rule:** in `choices()`, always use slug keys (`[a-z0-9_]`) and put the human text in the label. Never let a label containing an apostrophe, quote, backslash, or accented character double as the choice key.

```php
// ❌ key contains an apostrophe → breaks under ACFE kses
Checkbox::make('Consents', 'consents')->choices([
    "Ho letto e accetto l'Informativa sulla Privacy" => "Ho letto e accetto l'Informativa sulla Privacy",
]);

// ✅ slug key, human text as label
Checkbox::make('Consents', 'consents')->choices([
    'privacy' => "Ho letto e accetto l'Informativa sulla Privacy",
    'termini' => "Ho letto e accetto i termini di servizio",
]);
```

The list form `choices(['Some Label'])` runs each entry through `Key::sanitize()` (`sanitize_title` plus `-` → `_`), which strips apostrophes and accents, so it is usually safe. The dangerous pattern is the explicit map form where the human sentence is repeated as the key. Prefer explicit slug keys either way: it also keeps stored meta clean and stable if the wording changes.

### Naming: auto-generated names and label-only fields

extended-acf derives a field's `name` from its label when the second `make()` argument is omitted, then generates the field key from `<parent key>_<name>`. Two fields in the same group (or the same `Repeater` / `Group` / `Layout`) whose labels reduce to the **same** sanitized name produce the same key, and `Key::generate()` throws `InvalidArgumentException: The key [...] is not unique.`

This applies to **every** field type, including layout-only fields. ACF blanks the `name` of `Tab`, `Accordion`, and `Message` fields when it loads them, but extended-acf has already derived the key from the label by then, so `Tab::make('Consents')` next to `Checkbox::make('Consents')` still collides.

Rules:

- Pass an explicit `name` whenever labels are similar, punctuated, or accented (`"Città"` and `"Citta"` both sanitize to `citta`; `"E-mail"` becomes `e_mail`, not `email`).
- Always give `Tab`, `Accordion`, and `Message` fields an explicit name suffixed by type. It avoids collisions with the content field they introduce and is self-documenting:

```php
Tab::make('Consents', 'consents_tab');
Checkbox::make('Consents', 'consents')->choices([...]);

Accordion::make('Address', 'address_accordion');
Message::make('Address help', 'address_help_message')->body('...');
```

- Reusing one field instance under several parents is safe from v15.1.1: `toArray()` no longer mutates the instance's settings. On older versions, build a fresh instance per parent (or use the `require`-a-file clone pattern above).

## Version-sensitive method names

The package has renamed methods across major versions. When working with an existing codebase, check `composer.json` for the installed major version before writing code:

- v14+: `helperText` (not `instructions`), `default` (not `defaultValue`), `multiple` (not `allowMultiple`), `nullable` (not `allowNull`), `maxLength` (not `characterLimit`), `paginated` (not `pagination`), `button` (not `buttonLabel`), `firstDayOfWeek` (not `weekStartsOn`), `prefix`/`suffix` (not `prepend`/`append` on Number), `acceptedFileTypes` (not `mimeTypes`), `opacity` (not `enableOpacity`), `lazyLoad` (not `delay`), `body` (not `message` on Message), `prependFiles` (not `insert('prepend')`), `format` (not `returnFormat`). Min/max are field-specific (`minRows`/`maxRows`, `minPosts`/`maxPosts`, `minFiles`/`maxFiles`, `minLayouts`/`maxLayouts`, `minInstances`/`maxInstances`).
- v15+: `toArray()` (not `get()`) on `Field`, `Location`, `ConditionalLogic`. PHP 8.4 minimum. `$settings` is `public protected(set)`.
- v15.1+: `create(save: bool)` on `Checkbox`, `RadioButton`, and `Select`; `toggle()` on `Checkbox`. `Select::save()` still exists but `create(save: true)` is the documented form. v15.1.1 made `toArray()` non-mutating.
- v13+: namespace is `Extended\ACF\` (was `WordPlate\Acf\`).

When in doubt, prefer the v14+ names — they're the current API.

## Examples directory in the upstream repo

The package ships runnable examples in `examples/`:

- `examples/custom-post-type.php` — registering a CPT alongside fields
- `examples/with-extended-cpts.php` — pairing with johnbillion/extended-cpts
- `examples/gutenberg-block.php` + `examples/block.json` — ACF block registration. The current `block.json` uses `"acf": { "hideFieldsInSidebar": true, "renderTemplate": "template.php" }` (the older `"mode": "edit"` + `"supports": { "mode": false }` pattern was dropped).
- `examples/options-page.php` — options-page schema

If the user is doing one of these specifically, fetch that example from the pinned commit URL in `reference/UPSTREAM.md` before writing code.

## Quick checklist before finishing a field group

1. Registered inside `add_action('acf/include_fields', fn() => ...)`.
2. Group has a `title`, `fields`, and `location` at minimum.
3. Field names are valid snake_case or omitted (label is enough), and no two siblings share a sanitized name. `Tab` / `Accordion` / `Message` always have an explicit `_tab` / `_accordion` / `_message` name.
4. No hand-rolled `key` values except on both ends of a bidirectional relationship.
5. `acceptedFileTypes`, `min/max`-prefixed sizes, and version-correct method names used.
6. Nested fields (`Repeater`, `Group`, `FlexibleContent`, `Layout`) use `->fields([...])`.
7. Conditional logic and location use `where`, not the deprecated `if`.
8. Every `choices()` map uses slug keys (`[a-z0-9_]`); human text lives only in the label.

## Reference

`reference/UPSTREAM.md` contains the pinned upstream commit SHA, the link to the verbatim upstream README at that commit, and the procedure the maintainer uses to refresh this skill when the package updates.

