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-acfinstalled, or you should add it:composer require vinkla/extended-acf. - ACF Pro is installed separately (downloaded zip in
plugins/ormu-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.
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:
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
nameis the label converted tosnake_case(viasanitize_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 throwsInvalidArgumentException: 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
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
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.
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
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
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
// 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:
// 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
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
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:
// 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'sinstructions. Supports limited Markdown:**bold**,__bold__,*italic*,_italic_,`code`,[link](https://example.com).column(int $percent)— shorthand forwrapper(['width' => $percent]). Skip this in block patterns / ACF blocks — the sidebar is narrow; keep fields at 100%.dd()/dump()— debugging only. Requirescomposer require symfony/var-dumper --dev.key('field_xxx')— custom field key. Must be prefixedfield_orlayout_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:
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\:
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:
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.
// ❌ 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
namewhenever labels are similar, punctuated, or accented ("Città"and"Citta"both sanitize tocitta;"E-mail"becomese_mail, notemail). - Always give
Tab,Accordion, andMessagefields an explicit name suffixed by type. It avoids collisions with the content field they introduce and is self-documenting:
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 therequire-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(notinstructions),default(notdefaultValue),multiple(notallowMultiple),nullable(notallowNull),maxLength(notcharacterLimit),paginated(notpagination),button(notbuttonLabel),firstDayOfWeek(notweekStartsOn),prefix/suffix(notprepend/appendon Number),acceptedFileTypes(notmimeTypes),opacity(notenableOpacity),lazyLoad(notdelay),body(notmessageon Message),prependFiles(notinsert('prepend')),format(notreturnFormat). Min/max are field-specific (minRows/maxRows,minPosts/maxPosts,minFiles/maxFiles,minLayouts/maxLayouts,minInstances/maxInstances). - v15+:
toArray()(notget()) onField,Location,ConditionalLogic. PHP 8.4 minimum.$settingsispublic protected(set). - v15.1+:
create(save: bool)onCheckbox,RadioButton, andSelect;toggle()onCheckbox.Select::save()still exists butcreate(save: true)is the documented form. v15.1.1 madetoArray()non-mutating. - v13+: namespace is
Extended\ACF\(wasWordPlate\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 fieldsexamples/with-extended-cpts.php— pairing with johnbillion/extended-cptsexamples/gutenberg-block.php+examples/block.json— ACF block registration. The currentblock.jsonuses"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
- Registered inside
add_action('acf/include_fields', fn() => ...). - Group has a
title,fields, andlocationat minimum. - Field names are valid snake_case or omitted (label is enough), and no two siblings share a sanitized name.
Tab/Accordion/Messagealways have an explicit_tab/_accordion/_messagename. - No hand-rolled
keyvalues except on both ends of a bidirectional relationship. acceptedFileTypes,min/max-prefixed sizes, and version-correct method names used.- Nested fields (
Repeater,Group,FlexibleContent,Layout) use->fields([...]). - Conditional logic and location use
where, not the deprecatedif. - 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.