Concepts
MarkdownField types
Each collection field has a type from App\Enums\FieldTypeEnum. The type drives validation, normalization, EAV storage shape, and which React input renders in the item form.
Sources of truth
Enum: app/Enums/FieldTypeEnum.php. Settings validation: ValidatesCollectionFieldSettings. UI catalog: resources/js/lib/collection-field-types.ts. Item inputs: dynamic-item-fields.tsx.
Changing field types
Changing a field's type after it has stored values can lead to data loss or validation errors. See Schema changes for current behaviour, safe migration paths, and planned fixes.
Common settings
Available on (almost) every field via collections_fields.settings JSON:
| Key | Shape | Effect |
|---|---|---|
required | flag (true / 1 / 'true' / …) | Value required unless overridden by validation operators |
readonly | flag | Not editable in the item form |
hidden_in_form | flag | Hidden in form; skipped by the rule builder |
display_name | locale map (enabled content locales) | Localized label override |
note | locale map | Localized helper text |
default_value | mixed | Applied on create when empty (not for hash / files / relations / blocks / m2a in UI) |
validation_rules | [{ operator, value? }] | Extra operators: required, unique, min_length, max_length, min, max, regex, contains, not_contains, equals, not_equals |
validation_message | locale map | Localized message for custom rules |
layout_width | half | full | fill (default full) | Form column width |
layout_starts_new_row | flag | Force new row in the field layout UI |
placeholder | locale map | Where the input supports it |
conditions | see below | Dynamic hide / readonly / required |
Flags are normalized with a truthy check (boolean / 1 / '1' / 'true' / 'on'). Localized string maps are validated against the project’s enabled content locales (not hard-coded en/it) — see Project settings.
Field packs
Deterministic field bundles you can apply in one shot onto an existing collection from the Fields UI (Add field pack…) or via AI (ManageCollections apply_field_pack). Existing field names are skipped; missing ones are created through the same settings pipeline as HTTP create field.
| Pack key | Label | Fields |
|---|---|---|
seo_inline | SEO (inline) | seo_title (string, translatable), seo_description (textarea, translatable), seo_keywords (string, translatable), seo_alternate (string, translatable), seo_canonical (string), seo_robots (select), seo_noindex (boolean), seo_og_image / seo_facebook_image / seo_twitter_image (image). Prefer the SEO collection pack + M2O for Articles/Pages/Products. |
publishing | Publishing | status (select draft/published/archived), published_at (date), featured (boolean) |
contact | Contact | email, phone, address, city, postal_code, country |
social | Social | facebook_url, instagram_url, linkedin_url, twitter_url, youtube_url |
HTTP: POST /collections/{collection}/field-packs/{pack} (can-edit-collections). Source: app/Support/Collections/FieldPacks/.
How to add a field pack
- Add a
*FieldPackclass with a staticdefinition()(seeSeoInlineFieldPack). - Register it in
FieldPackRegistry::all(). - Ship docs + a Pest apply/skip test.
Collection packs
Starter collections. Prefer these for SEO entity / Articles / Pages / Products / Categories. Apply from the collections list (Create from pack…) or AI (apply_collection_pack). Missing dependencies are auto-created. Existing collection slugs are reused; missing fields/relations are added.
| Pack key | Creates | Requires | Relations |
|---|---|---|---|
seo | seo — short names (title, description, …) not seo_* | — | — |
categories | categories — name (tr), slug, description (tr) | — | — |
pages | pages — title, slug, status, published_at, body (blocks: section, rich_text) | seo | M2O seo → seo |
articles | articles — title, slug, excerpt, body (blocks: section, rich_text), status, published_at, cover | seo, categories | M2O category, M2O seo |
products | products — title, slug, description, price, images, status | seo, categories | M2O category, M2O seo |
HTTP: POST /collections/packs/{pack} (can-create-collections), optional body name / slug. Source: app/Support/Collections/CollectionPacks/.
Pack block schema (articles, pages)
Both articles.body and pages.body use MinimalBlocksBody (app/Support/Collections/CollectionPacks/MinimalBlocksBody.php) with two block types:
| Block type | Fields | Notes |
|---|---|---|
section | heading (string, translatable) | Section heading |
rich_text | body (wysiwyg, translatable) | TipTap WYSIWYG editor |
Example Public CMS API response (articles.body with ?locale=it):
[
{
"id": "...",
"type": "section",
"data": { "heading": { "en": "Intro", "it": "Introduzione" } }
},
{
"id": "...",
"type": "rich_text",
"data": { "body": { "en": "<p>Hello</p>", "it": "<p>Ciao</p>" } }
}
]
Note: The ?locale= query parameter and include_all_translations only apply to top-level translatable fields. Nested block fields always return their per-locale maps ({ "en": "...", "it": "..." }). Frontends should pick the locale from each block's field data using the same fallback order as the server (see Locale fallback, or Live Preview — Locale resolution for active-locale resolution).
How to add a collection pack
- Add a
*CollectionPackclass withdefinition()(key,label,collection,fields,requires,relations). - Register it in
CollectionPackRegistry::all(). - Ship docs + Pest (deps + relations + idempotent apply).
Model note: SEO is a collection; Articles/Pages/Products link via many_to_one. Use seo_inline only when you need denormalized seo_* fields on an existing collection.
Field conditions
Optional per-field rules evaluated against other field values on the item form (client) and for required / readonly on the server. Full operator table + examples: Field conditions.
{
"logic": "or",
"rules": [
{ "field": "kind", "operator": "equals", "value": "custom" },
{ "field": "status", "operator": "in", "value": "draft, review" }
],
"hidden": false,
"readonly": false,
"required": true
}
Operators: equals, not_equals, contains, empty, not_empty, gt / gte / lt / lte, in / not_in. Logic is flat AND or OR (no nested groups). When rules match, any of hidden / readonly / required present in the object override the base settings for that request.
Collection form layout
Presentation metadata on collections.form_layout (JSON), not a field type. Fields stay leaves; layout groups them into optional tabs + collapsible sections:
{
"version": 1,
"tabs": [{ "id": "main", "label": { "en": "Main" } }],
"sections": [
{
"id": "details",
"tab_id": "main",
"label": { "en": "Details" },
"collapsible": true,
"collapsed": false,
"field_ids": [1, 2]
}
]
}
layout_width / layout_starts_new_row still apply inside each section. Unplaced fields render in an “Other” group. Endpoint: PUT /collections/{collection}/form-layout.
Storage helpers on the enum
| Method | Meaning |
|---|---|
isArrayStorage() | Value is stored as multiple EAV rows (position 0..n-1) or as a JSON array assembled from those rows |
isRelationType() | Points at another collection’s items |
isMultipleRelationType() | Relation that stores many related item IDs |
supportsTranslatable() | Whether the field may be marked per-locale (collections_fields.translatable) |
Translatable is not universal
hash, blocks, and all relation types (many_to_one, many_to_many, one_to_many, m2a, relation_tree, legacy relation / relation_many) do not support translatable. The admin UI hides the toggle; create/update (and the AI tool) force translatable=false for those types. For blocks, localization lives inside nested block fields instead. Text, media, selects, boolean/number/date/color/map/slider, and similar scalars still can.
Image multiple
image is not in FieldTypeEnum::isArrayStorage(), but CollectionField::usesArrayStorage() returns true when settings.allow_multiple is enabled. Treat image-with-multiple as array storage at runtime.
Array storage types
These return true from isArrayStorage():
multiselect, checkbox_group, checkbox_group_tree, tag, files, one_to_many, many_to_many, blocks, m2a, relation_many.
Relation types
These return true from isRelationType():
relation, many_to_one, one_to_many, many_to_many, relation_tree, relation_many.
Multiple relation IDs (isMultipleRelationType()): one_to_many, many_to_many, relation_many.
M2A exception
m2a uses array storage of { related_collection_id, related_item_id } blocks but is not classified as isRelationType() in the enum.
Every field type
Text and scalars
| Type | Value | Purpose | Array? | Relation? | Notable settings |
|---|---|---|---|---|---|
| String | string | Single-line text | no | no | input_type (string/text/integer/bigInteger/float/decimal/uuid), max_length, placeholder, icon_left/icon_right, trim, slugify, masked |
| Autocomplete | autocomplete | Combobox from static options | no | no | options[{value,label}], allow_other, placeholder |
| API autocomplete | api_autocomplete | Remote suggestions | no | no | url (may include {{value}}), results_path, text_path, value_path, trigger (debounce|throttle), rate, placeholder, icons |
| Number | number | Numeric input | no | no | min, max, step, placeholder |
| Boolean | boolean | Toggle | no | no | label_on, label_off (translated) |
| Textarea | textarea | Multi-line plain text | no | no | placeholder, rows (default 6), max_length |
| WYSIWYG | wysiwyg | Rich HTML (TipTap editor) | no | no | rows, max_length, placeholder; server strips scripts/unsafe tags on save |
| Markdown | markdown | Markdown source | no | no | same textarea-like options |
| Code | code | Code editor | no | no | language (default javascript), line_numbers, line_wrapping, template |
| Hash | hash | Auto fingerprint ID (sha256 of UUID) — not a password hash | no | no | masked; always nullable in rules |
| Slider | slider | Numeric slider | no | no | min (0), max (100), step, default_value, show_value |
Choice
| Type | Value | Purpose | Array? | Relation? | Notable settings |
|---|---|---|---|---|---|
| Select | select | Single choice | no | no | options, allow_none, allow_other (UI + validation) |
| Multiselect | multiselect | Multi choice dropdown | yes | no | options, allow_other |
| Checkbox group | checkbox_group | Multi checkboxes | yes | no | options, allow_other |
| Checkbox group tree | checkbox_group_tree | Nested multi select | yes | no | Tree options (+ children), value_combining (all|leaf) |
| Radio group | radio_group | Single radio | no | no | options, allow_other |
| Tag | tag | Tag list | yes | no | presets, separator (default ,), allow_other, lowercase, alphabetize |
Date, map, color
| Type | Value | Purpose | Array? | Relation? | Notable settings / storage |
|---|---|---|---|---|---|
| Date | date | Date / time / datetime | no | no | date_mode (date|time|datetime, default datetime), include_seconds. Dead use_24h removed (native inputs). |
| Map | map | GeoJSON Point or MultiPoint | no | no | Stored as GeoJSON (Point / MultiPoint, coordinates [lng, lat]); legacy { lat, lng } accepted on write and converted. Settings: geometry_mode (point|multipoint, default point), default_lat, default_lng, default_zoom. Leaflet OSM picker in admin; respects readonly. No LineString/Polygon yet. |
| Color | color | Color picker | no | no | opacity, preset_colors |
Files and media
| Type | Value | Purpose | Array? | Relation? | Notable settings |
|---|---|---|---|---|---|
| Image | image | File-manager image picker | conditional* | no | allow_multiple → list of file IDs; else single id; allowed_mime_types, crop_to_fit |
| File | file | Single file (legacy) | no | no | allowed_mime_types |
| Files | files | Multiple files | yes | no | allowed_mime_types |
* See callout above for allow_multiple.
Relations
Shared relation settings (where applicable):
related_collection_id— target collectiondisplay_field— field name used as labeldisplay_template— optional label templatefilter— option list filterallow_duplicates— whether the same related id may appear more than oncejunction_fields— many_to_many only:[{ "name": "sort", "type": "number" }]mini-schema for per-linkmeta(string|number|boolean)
| Type | Value | Purpose | Array? | Relation? | Extra |
|---|---|---|---|---|---|
| Relation | relation | Legacy many-to-one alias (hidden from type picker) | no | yes | Shared relation settings |
| Many to one | many_to_one | Link one related item | no | yes | Shared relation settings |
| One to many | one_to_many | Many related IDs | yes | yes | + layout (list|table) |
| Many to many | many_to_many | Junction objects { related_item_id, meta } (ints accepted on write) | yes | yes | Shared relation settings + optional junction_fields |
| Relation tree | relation_tree | Alias of M2O — no tree UI yet (hidden from picker; TODO) | no | yes | Shared relation settings |
| Relation many | relation_many | Legacy multi-relation (hidden from picker) | yes | yes | Shared relation settings |
| Blocks | blocks | Inline page-builder blocks | yes | no | block_types[]; value [{ id, type, data }] |
| M2A | m2a | Many-to-any builder | yes | no† | allowed_collection_ids[], allow_duplicates; value [{ related_collection_id, related_item_id }] |
† Not in isRelationType().
Normalized storage shapes
| Shape | Types |
|---|---|
| string / null | string, autocomplete, api_autocomplete, textarea, wysiwyg, markdown, code, select, radio_group, date, color |
| number / null | number, slider |
| bool / null | boolean |
| string[] | multiselect, checkbox_group, checkbox_group_tree, tag |
| GeoJSON Point / MultiPoint | map (legacy { lat, lng } still accepted on write) |
| int (file id) | file; image (single) |
| int[] (file ids) | files; image (multiple) |
| int (item id) | relation, many_to_one, relation_tree |
| int[] (item ids) | one_to_many, relation_many |
{ related_item_id, meta }[] | many_to_many (breaking vs bare ints; ints still accepted on write) |
block[] { id, type, data } | blocks |
| M2A block[] | m2a |
| string (hash) | hash |
Blocks field
blocks defines its schema inline on the field itself instead of pointing to another collection like m2a.
Settings
{
"max_blocks_depth": 3,
"block_types": [
{
"key": "rich_text",
"label": "Rich text",
"fields": [
{
"name": "title",
"type": "string",
"translatable": true,
"settings": {}
},
{
"name": "body",
"type": "wysiwyg",
"translatable": true,
"settings": {}
}
]
},
{
"key": "media",
"label": "Media",
"fields": [
{ "name": "image", "type": "image", "settings": {} },
{
"name": "caption",
"type": "string",
"translatable": true,
"settings": {}
}
]
}
]
}
max_blocks_depth is per field (default 3, absolute ceiling 5). Nested blocks fields inherit the root field’s max; deeper blocks types are stripped on save.
Value shape
[
{
"id": "11111111-1111-1111-1111-111111111111",
"type": "rich_text",
"data": {
"title": { "en": "Hello", "it": "Ciao" },
"body": { "en": "<p>Body</p>" }
}
}
]
Notes
wysiwyguses TipTap in the admin UI (toolbar: bold/italic/strike/headings/lists/blockquote/link/undo/redo). Paste from Word/HTML is accepted;WysiwygHtmlSanitizerstrips scripts and non-allowlisted tags on normalize/save.- The
blocksfield itself is not translatable; nested fields may be. - Nested translatable fields are not flattened by
?locale=orinclude_all_translations— they always return per-locale maps in API responses. Frontends must pick the locale per block themselves (see Pack block schema above or Locale fallback for the resolution order). - Nested file fields (
image,file,files) expand on the public API just like top-level file fields (including inside nested blocks). - Nested relations (
m2a,many_to_many,one_to_many) are JSON-embedded insideblock.data— same link shapes as top-level (m2a{ related_collection_id, related_item_id }, m2m{ related_item_id, meta }). There is no junction table for nested paths; SQL filter/query on nested m2m is out of scope. - Nested field
settings.conditionsuse the same AND / equals / empty operators as collection fields, but evaluate against sibling keys inblock.data, not top-level item fields. - Builder UX: drag-and-drop, duplicate, delete, collapse/expand, text summary in the header, move up/down. No live frontend/site preview.
blocksis distinct from top-levelm2a:blocksstores inline typed fragments; top-levelm2alinks out to items. You can also put anm2afield inside a block type.- Golden path: an Articles collection with a body
blocksfield — for example, a richer custom design beyond the pack default might havesection→ nestedblocks→ leaf (rich_text/media); optionalrelated_modules(m2a) andrelated_articles(many_to_many) block types; conditional nested fields (e.g. showvideo_urlwhenmedia_kind === video).
Nested blocks example
{
"max_blocks_depth": 3,
"block_types": [
{
"key": "rich_text",
"label": "Rich text",
"fields": [
{
"name": "title",
"type": "string",
"translatable": true,
"settings": {}
},
{
"name": "body",
"type": "wysiwyg",
"translatable": true,
"settings": {}
}
]
},
{
"key": "section",
"label": "Section",
"fields": [
{
"name": "heading",
"type": "string",
"translatable": true,
"settings": {}
},
{
"name": "blocks",
"type": "blocks",
"translatable": false,
"settings": {
"block_types": [
{
"key": "rich_text",
"label": "Rich text",
"fields": [
{
"name": "body",
"type": "wysiwyg",
"translatable": true,
"settings": {}
}
]
},
{
"key": "media",
"label": "Media",
"fields": [{ "name": "image", "type": "image", "settings": {} }]
}
]
}
}
]
},
{
"key": "related_modules",
"label": "Related modules",
"fields": [
{
"name": "modules",
"type": "m2a",
"settings": { "allowed_collection_ids": [2] }
}
]
},
{
"key": "related_articles",
"label": "Related articles",
"fields": [
{
"name": "articles",
"type": "many_to_many",
"settings": { "related_collection_id": 1, "display_field": "title" }
}
]
}
]
}
Nested conditions example
Inside a block type, conditions reference sibling field names:
{
"name": "video_url",
"type": "string",
"settings": {
"conditions": {
"logic": "and",
"rules": [
{ "field": "media_kind", "operator": "equals", "value": "video" }
],
"required": true
}
}
}
Related pages
- Collections data model
- Collection items
- AI assistant model — tools that create/edit fields and items