Skip to content

Field system (Field API)

The Field API is the way to add custom fields to content types without writing code. To add "source" to a news type, "start date" to an event or "price" to a product, you do not change the schema; you create a field definition. The form, validation, storage, display and search index derive from this definition.

This system is for Node (content) types. Business records with real SQL columns (Resource) define their fields with Doctrine; see Content model.

Concepts

Concept Description
Bundle The content type fields are attached to (post, page, portal_news)
Field definition (FieldDefinition) Bundle + name + type + label + settings
Field type (FieldTypeInterface) Behaviour: normalization, validation, index, settings schema
Widget Which Symfony form type edits that field in the form
Formatter / display How the field is drawn on the page
View mode default, teaser, card: presenting the same content in different contexts

Field definition properties

Property Meaning
name Machine name: lowercase letters, digits, underscore; starts with a letter, at most 63 characters
type Field type id (below)
label, help Label and help text
required Whether it is mandatory
cardinality Number of values: 1 single, -1 unlimited, or a fixed number
translatable Whether it can carry a different value per translation
queryable Whether it is written to the flat field index (for filtering/sorting)
fieldGroup Grouping in the form and display
weight Order
settings Type-specific settings (e.g. max_length, choices)

Fields are managed from the AACP → Structure → Fields screen. Permission: system.fields.manage.

Built-in field types

Type What it stores Setting / note
text Single-line text max_length
textarea Multi-line plain text
rich_text Rich text (with a text format) default_format (default basic_html)
integer Whole number Can be searched as int in the flat index
decimal Decimal number decimal in the flat index
boolean Yes/no
date, datetime Date, date-time datetime in the flat index
email, url Validated email / URL
select A value from a fixed list Options are value|label lines
reference The id of another entity (user, node of a certain type, resource) target is an allowed string; a raw class name is never given
image The id of an image/* asset from the media library
file The id of an asset from the media library

To see the real ids of the types: php cp-core/bin/console cp:debug.

Value lifecycle

form input ──▶ FieldValueNormalizer ──▶ FieldValidator ──▶ storage (Node::data JSON)
               (type.normalize)         (type.validate)        └─▶ flat index (if queryable)
  1. Normalization (FieldValueNormalizer): the single decision point. Only keys that a field definition supports are produced; raw request arrays are not merged as they are (mass-assignment protection).
  2. Validation (FieldValidator): works on the normalized, clean value. In multi-value fields it checks the count.
  3. Storage: values are written to the Node::data JSON column.
  4. Index: queryable fields are written to the node_field_index table with a typed value.

Forms

Adding a bundle's fields to a module's own form is one line:

$this->fieldForms->add($builder, 'portal_news', $locale);

FieldableFormBuilder::add adds the bundle's fields as a sub-form named fields; if the bundle has no fields it does nothing. The type → form type mapping is done by FieldWidgetResolver; for multi-value fields FieldsFormType wraps them in a CollectionType.

Image and file fields

image / file fields store an entity id. In the form the field-media script draws a preview, "Choose from library" and "Remove" buttons. Add them to your template's importmap list in order:

{{ importmap(['app', 'media-picker', 'field-media']) }}

media-picker must come first, field-media after. If there is no Media module, the field remains a plain id box.

Multi-value fields

Fields whose cardinality is -1 or greater than 1 become a collection; the same script adds + Add and × buttons.

Drawing fields one by one in a template

{% for child in form.fields|filter(c => c.vars.name not in ['image']) %}
    {{ form_row(child) }}
{% endfor %}

Display

Twig functions are used in front-end templates:

Function What it does
cp_field(node, 'summary') Draws a single field (HTML)
cp_field_value(node, 'summary') Gives the raw value
cp_fields(node, 'group') Draws a whole field group
cp_has_field(node, 'summary') Whether the field is filled in
cp_fields_preload(nodes) Loads the entity references in a list in bulk
cp_entity_view(node, 'teaser') Draws the entity in a view mode
{% do cp_fields_preload(items) %}
{% for item in items %}
    <article>
        <h2>{{ item.title }}</h2>
        {% if cp_has_field(item, 'summary') %}{{ cp_field(item, 'summary') }}{% endif %}
    </article>
{% endfor %}

FieldRenderer respects each field's view_capability setting (you can show a field only to users with a certain capability) and does not trigger extra content queries: values stay in the entity's field bag and references are loaded in bulk. To avoid N+1 in list templates, use cp_fields_preload.

View modes are defined in cp-core/config/view_modes.yaml (default, teaser, card). Which field is drawn with which formatter in which mode is set in the AACP → Structure → Display screen.

Creating fields at module installation

A module defines the fields for its own types at installation. FieldDefinitionSeeder::ensure() only creates a missing field; it never overwrites a field an editor has fine-tuned in AACP. Deleting a field is a separate, deliberate operation.

$this->fields->ensure([
    ['bundle' => 'portal_news', 'name' => 'source', 'type' => 'text', 'label' => 'Source'],
    ['bundle' => 'portal_news', 'name' => 'summary', 'type' => 'textarea', 'label' => 'Summary'],
]);

Writing your own field type

Mark a service with #[CpFieldType]; the type id comes from the class's static id() method and is collected into FieldTypeRegistry at build time. If a module's type cannot be loaded, only that module's types drop out; the core set is not affected.

#[CpFieldType]
final class PhoneFieldType extends AbstractFieldType
{
    public static function id(): string { return 'phone'; }
    public function label(): string { return 'field.type.phone'; }

    public function normalize(mixed $raw, FieldContext $context): mixed
    {
        $digits = preg_replace('/\D+/', '', (string) $raw);
        return $digits === '' ? null : $digits;
    }

    public function validate(mixed $value, FieldContext $context): array
    {
        return strlen((string) $value) < 10 ? ['field.phone.too_short'] : [];
    }
}

AbstractFieldType provides safe defaults (not indexable, no settings, always valid); override only the methods you need:

Method Job
normalize Turns raw input into a clean storage form (works on a single value)
validate Returns a list of error keys (valid if empty)
indexKind / indexValue How to write to the flat index (string, int, decimal, datetime)
settingsSchema / normalizeSettings The type's settings fields (appear as a form in AACP)
defaultValue Default on a new record

The number of values (cardinality) is managed by the calling code, not your type; your methods work on a single value.

Last updated: 13.10.2026