ACF custom field type
The acf_field extension API is available in ACF Free and PRO. A custom field
type should solve a distinct input/value contract, not merely restyle a Text or
Select field. Prefer a built-in field plus targeted filters when its raw value,
validation, and return format already fit.
Register after the field API exists
add_action( 'acf/include_field_types', static function ( $api_version ): void {
if ( ! class_exists( 'acf_field' ) ) {
return;
}
require_once __DIR__ . '/includes/class-acme-acf-field-json.php';
acf_register_field_type( 'Acme_ACF_Field_JSON' );
} );
Keep registration idempotent and feature-detect ACF. The hook receives ACF's field API version; do not confuse it with the plugin version.
In ACF 6.8.10 the base constructor calls initialize(). Set properties there
instead of replacing __construct() and accidentally skipping base hook
registration.
Define the field contract
final class Acme_ACF_Field_JSON extends acf_field {
public function initialize() {
$this->name = 'acme_json';
$this->label = __( 'JSON object', 'acme-addon' );
$this->category = 'advanced';
$this->description = __( 'Stores a validated JSON object.', 'acme-addon' );
$this->defaults = array(
'rows' => 8,
);
$this->show_in_rest = true;
}
public function render_field_settings( $field ) {
acf_render_field_setting( $field, array(
'label' => __( 'Rows', 'acme-addon' ),
'name' => 'rows',
'type' => 'number',
'min' => 3,
) );
}
public function render_field( $field ) {
$value = is_array( $field['value'] )
? wp_json_encode( $field['value'], JSON_PRETTY_PRINT )
: (string) $field['value'];
printf(
'<textarea name="%s" rows="%d">%s</textarea>',
esc_attr( $field['name'] ),
max( 3, (int) $field['rows'] ),
esc_textarea( $value )
);
}
public function validate_value( $valid, $value, $field, $input ) {
if ( true !== $valid || '' === $value || is_array( $value ) ) {
return $valid;
}
json_decode( (string) $value, true );
return JSON_ERROR_NONE === json_last_error()
? true
: __( 'Enter valid JSON.', 'acme-addon' );
}
public function update_value( $value, $post_id, $field ) {
if ( is_array( $value ) || '' === $value || null === $value ) {
return $value;
}
$decoded = json_decode( (string) $value, true );
return JSON_ERROR_NONE === json_last_error() ? $decoded : null;
}
}
Always use $field['name'] in the input. ACF rewrites it for Group, Repeater,
Flexible Content, Clone, AJAX append, and row indexes. A hardcoded input name or
DOM ID works once at top level and then silently collides in nested fields.
Separate the value stages
| Method | Stage | Appropriate work |
|---|---|---|
| validate_value() | before save acceptance | reject invalid input with true or a translated error string |
| update_value() | before raw storage | normalize to the canonical database shape |
| load_value() | after raw load | compatibility repair for historical raw values; keep it cheap |
| format_value() | template/API formatting | return the documented consumer shape; honor $escape_html if supported |
| delete_value() | after deletion | remove auxiliary rows owned only by this field type |
Keep these methods pure where possible. A formatter can run more than once and is cached by ACF per request. Do not send HTTP requests, mutate other content, or perform unbounded queries while formatting a value.
The base ACF value pipeline stores the field's raw value and hidden field-key
reference after update_value() runs. A custom type normally should not call
update_post_meta() itself. If the type owns auxiliary rows, namespace them and
delete them consistently for posts, users, terms, comments, and options—not just
post meta.
HTML escaping support
Set $this->supports['escaping_html'] = true only when format_value() returns a
safe value whenever its fourth $escape_html argument is true.
public function initialize() {
// Other properties...
$this->supports = array(
'escaping_html' => true,
'required' => true,
);
}
public function format_value( $value, $post_id, $field, $escape_html = false ) {
$value = (string) $value;
return $escape_html ? acf_esc_html( $value ) : $value;
}
Do not mark support merely because render_field() escapes admin markup. The
flag describes frontend/template formatting of stored values, including values
modified directly in the database.
Assets and dynamic inputs
Register and enqueue admin assets in input_admin_enqueue_scripts(), with unique
handles and a real plugin version. Scope CSS to the field wrapper. JavaScript
must initialize fields appended after page load by Repeaters, Flexible Content,
Clone, modals, and AJAX; use ACF's field lifecycle instead of a one-time document
ready scan. Destroy third-party widgets when rows are removed to prevent leaked
listeners and duplicate instances.
Do not enqueue frontend assets unless the field's formatted output actually needs them. A field editor control and its frontend presentation are separate features.
Verify the field type
- Create, edit, clear, and delete the value on a normal post.
- Repeat on every supported location: user, term, comment, or options.
- Test valid, invalid, empty, array, and historical raw values.
- Insert multiple field instances and nested Repeater/Group/Clone instances.
- Duplicate, reorder, append, and remove dynamic rows in the editor.
- Compare raw metadata, formatted
get_field(), and escaped reads. - Test REST only when
show_in_restis intended. - Verify behavior with ACF Free and PRO separately when both are supported.
The acf-value-storage-frontend smoke example includes a minimal custom JSON
field and verifies its update_value() and formatted read on ACF PRO 6.8.10.
Cross-references
acf-value-storage-frontendfor the base storage/reference pipeline.acf-field-group-developmentfor definitions that consume the new type.acf-pro-complex-fieldsfor nested dynamic-field behavior under PRO fields.
References
- Official custom field type guide.
- Official example field type repository.
- Official HTML escaping guide.
- Verified ACF 6.8.10 source paths:
acf.phpincludes/fields.phpincludes/fields/class-acf-field.phpincludes/acf-value-functions.phpincludes/validation.php
微信扫一扫