Concepts

Markdown

Field 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:

KeyShapeEffect
requiredflag (true / 1 / 'true' / …)Value required unless overridden by validation operators
readonlyflagNot editable in the item form
hidden_in_formflagHidden in form; skipped by the rule builder
display_namelocale map (enabled content locales)Localized label override
notelocale mapLocalized helper text
default_valuemixedApplied 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_messagelocale mapLocalized message for custom rules
layout_widthhalf | full | fill (default full)Form column width
layout_starts_new_rowflagForce new row in the field layout UI
placeholderlocale mapWhere the input supports it
conditionssee belowDynamic 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 keyLabelFields
seo_inlineSEO (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.
publishingPublishingstatus (select draft/published/archived), published_at (date), featured (boolean)
contactContactemail, phone, address, city, postal_code, country
socialSocialfacebook_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

  1. Add a *FieldPack class with a static definition() (see SeoInlineFieldPack).
  2. Register it in FieldPackRegistry::all().
  3. 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 keyCreatesRequiresRelations
seoseo — short names (title, description, …) not seo_*——
categoriescategories — name (tr), slug, description (tr)——
pagespages — title, slug, status, published_at, body (blocks: section, rich_text)seoM2O seo → seo
articlesarticles — title, slug, excerpt, body (blocks: section, rich_text), status, published_at, coverseo, categoriesM2O category, M2O seo
productsproducts — title, slug, description, price, images, statusseo, categoriesM2O 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 typeFieldsNotes
sectionheading (string, translatable)Section heading
rich_textbody (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

  1. Add a *CollectionPack class with definition() (key, label, collection, fields, requires, relations).
  2. Register it in CollectionPackRegistry::all().
  3. 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

MethodMeaning
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

TypeValuePurposeArray?Relation?Notable settings
StringstringSingle-line textnonoinput_type (string/text/integer/bigInteger/float/decimal/uuid), max_length, placeholder, icon_left/icon_right, trim, slugify, masked
AutocompleteautocompleteCombobox from static optionsnonooptions[{value,label}], allow_other, placeholder
API autocompleteapi_autocompleteRemote suggestionsnonourl (may include {{value}}), results_path, text_path, value_path, trigger (debounce|throttle), rate, placeholder, icons
NumbernumberNumeric inputnonomin, max, step, placeholder
BooleanbooleanTogglenonolabel_on, label_off (translated)
TextareatextareaMulti-line plain textnonoplaceholder, rows (default 6), max_length
WYSIWYGwysiwygRich HTML (TipTap editor)nonorows, max_length, placeholder; server strips scripts/unsafe tags on save
MarkdownmarkdownMarkdown sourcenonosame textarea-like options
CodecodeCode editornonolanguage (default javascript), line_numbers, line_wrapping, template
HashhashAuto fingerprint ID (sha256 of UUID) — not a password hashnonomasked; always nullable in rules
SlidersliderNumeric slidernonomin (0), max (100), step, default_value, show_value

Choice

TypeValuePurposeArray?Relation?Notable settings
SelectselectSingle choicenonooptions, allow_none, allow_other (UI + validation)
MultiselectmultiselectMulti choice dropdownyesnooptions, allow_other
Checkbox groupcheckbox_groupMulti checkboxesyesnooptions, allow_other
Checkbox group treecheckbox_group_treeNested multi selectyesnoTree options (+ children), value_combining (all|leaf)
Radio groupradio_groupSingle radiononooptions, allow_other
TagtagTag listyesnopresets, separator (default ,), allow_other, lowercase, alphabetize

Date, map, color

TypeValuePurposeArray?Relation?Notable settings / storage
DatedateDate / time / datetimenonodate_mode (date|time|datetime, default datetime), include_seconds. Dead use_24h removed (native inputs).
MapmapGeoJSON Point or MultiPointnonoStored 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.
ColorcolorColor pickernonoopacity, preset_colors

Files and media

TypeValuePurposeArray?Relation?Notable settings
ImageimageFile-manager image pickerconditional*noallow_multiple → list of file IDs; else single id; allowed_mime_types, crop_to_fit
FilefileSingle file (legacy)nonoallowed_mime_types
FilesfilesMultiple filesyesnoallowed_mime_types

* See callout above for allow_multiple.

Relations

Shared relation settings (where applicable):

  • related_collection_id — target collection
  • display_field — field name used as label
  • display_template — optional label template
  • filter — option list filter
  • allow_duplicates — whether the same related id may appear more than once
  • junction_fields — many_to_many only: [{ "name": "sort", "type": "number" }] mini-schema for per-link meta (string | number | boolean)
TypeValuePurposeArray?Relation?Extra
RelationrelationLegacy many-to-one alias (hidden from type picker)noyesShared relation settings
Many to onemany_to_oneLink one related itemnoyesShared relation settings
One to manyone_to_manyMany related IDsyesyes+ layout (list|table)
Many to manymany_to_manyJunction objects { related_item_id, meta } (ints accepted on write)yesyesShared relation settings + optional junction_fields
Relation treerelation_treeAlias of M2O — no tree UI yet (hidden from picker; TODO)noyesShared relation settings
Relation manyrelation_manyLegacy multi-relation (hidden from picker)yesyesShared relation settings
BlocksblocksInline page-builder blocksyesnoblock_types[]; value [{ id, type, data }]
M2Am2aMany-to-any builderyesno†allowed_collection_ids[], allow_duplicates; value [{ related_collection_id, related_item_id }]

† Not in isRelationType().

Normalized storage shapes

ShapeTypes
string / nullstring, autocomplete, api_autocomplete, textarea, wysiwyg, markdown, code, select, radio_group, date, color
number / nullnumber, slider
bool / nullboolean
string[]multiselect, checkbox_group, checkbox_group_tree, tag
GeoJSON Point / MultiPointmap (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

  • wysiwyg uses TipTap in the admin UI (toolbar: bold/italic/strike/headings/lists/blockquote/link/undo/redo). Paste from Word/HTML is accepted; WysiwygHtmlSanitizer strips scripts and non-allowlisted tags on normalize/save.
  • The blocks field itself is not translatable; nested fields may be.
  • Nested translatable fields are not flattened by ?locale= or include_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 inside block.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.conditions use the same AND / equals / empty operators as collection fields, but evaluate against sibling keys in block.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.
  • blocks is distinct from top-level m2a: blocks stores inline typed fragments; top-level m2a links out to items. You can also put an m2a field inside a block type.
  • Golden path: an Articles collection with a body blocks field — for example, a richer custom design beyond the pack default might have section → nested blocks → leaf (rich_text / media); optional related_modules (m2a) and related_articles (many_to_many) block types; conditional nested fields (e.g. show video_url when media_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
    }
  }
}