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)
- 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). - Validation (
FieldValidator): works on the normalized, clean value. In multi-value fields it checks the count. - Storage: values are written to the
Node::dataJSON column. - Index:
queryablefields are written to thenode_field_indextable 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.