Custom content types let you define your own content models — products, staff bios, meeting minutes, portfolio items, FAQs, anything — without writing code. Pick the field types, save, and the dashboard CRUD pages, public-site routes, list page, and detail page are generated automatically. Every content type also becomes a data source any design library row can bind to.
The problem
Most CMSes ship with one or two hardcoded content models — usually "Posts" and "Pages" — and force everything else into custom fields tacked onto those tables. A staff directory becomes a category of posts; a product catalog becomes posts with a price meta field; meeting minutes become posts with a date prefix in the title. The data is in the wrong shape, the admin UI is cluttered with fields that don't apply to half the records, and the public-site templates have to branch on category to render different layouts.
The Laravel-native answer — php artisan make:model, write a migration, scaffold a Livewire CRUD, register routes, build two public templates — is a half-day of plumbing every time a client asks for "a section for our team." It also forces a developer round-trip for what is usually a content shape decision, not a code decision.
The fix
Define the content type once at Dashboard → Content Types. Give it a name (e.g. Meeting Minutes), a slug, a singular label, an icon, and a list of fields. Save. Behind the scenes, ContentTypePageGenerator writes two blade files into resources/views/pages/{slug}/ and injects the matching routes into routes/web.php. The dashboard sidebar gets a CRUD link, the public site gets /{slug} and /{slug}/{record}, and a ContentTypePreset registers under the key content_type:{slug} so any design library row that takes a data source can pull from this content type.
Every content item is stored in a single shared content_items table — type_slug discriminates which definition it belongs to, and the per-type fields land in a JSON data column (ContentItem). The shape lives on ContentTypeDefinition as a fields array, so adding or removing a field is a definition edit — no migration, no schema change.
Field types
The "Type" dropdown on the new-field UI (⚡create.blade.php) offers these field types, each with a tailored editor in the per-item dashboard form and a tailored renderer on the public detail page:
| Type | Editor | Public render |
|---|---|---|
text |
Single-line input | Inline label + value pair |
richtext |
Multi-line textarea (HTML allowed) | prose block with shortcodes processed |
richtext_tiptap |
Tiptap rich text editor (toolbar, formatting) | prose block with shortcodes processed raw |
date |
Date picker | Formatted as F j, Y |
datetime |
Start/end + all-day + timezone (schedule bag) | Human-readable date / date-range line |
select |
Dropdown of declared options | Inline label + value |
image |
Media library picker | Full-width image with rounded-base |
gallery |
Multi-pick from media library | Responsive 2/3-column thumbnail grid |
toggle |
On/off switch | Stored boolean — no default render |
radio |
Radio group of declared options | Inline label + value |
checkbox |
Single boolean checkbox | Stored boolean |
checkboxes |
Multi-select checkbox group | Stored array |
oembed |
URL input (YouTube / Vimeo) | <x-dl.video> embed at 16:9 |
file |
Single file upload | Download link with paperclip icon |
files |
Multi file upload | Bulleted list of download links |
address |
International address parts (country-aware labels) | <address> block, one line per part |
hours |
Per-day open/closed/24h + time ranges | Opening-hours list (hidden when all closed) |
specs |
Label/value repeater with optional section header rows and reorder arrows | <x-dl.spec-table> grouped table; also auto-emits schema.org additionalProperty for Product / LocalBusiness schema types |
relation |
Item picker against a target type | See Content-type relations |
applies_to |
"Show On" checkboxes — one per content type that links here through a relation field | Not rendered (scopes the shared pool the linking fields' opt-out/opt-in modes draw from; see Content-type relations) |
One text-like field (text, richtext, richtext_tiptap) becomes the title: a field literally keyed title claims it outright, and otherwise the first text-like field with a value does. It populates ContentItem.title automatically on every save (via the saving hook in ContentItem::booted()) and is used as the page H1 on the detail template. Subsequent fields render under that, in declaration order.
Built-in fields — you don't declare these
Three parts of an item live on the content_items row itself rather than in the data JSON, so they never appear in the field list.
| Built-in | Where it comes from | Token |
|---|---|---|
| Title | Derived on every save from a field keyed title when the type declares one, else from the item's first non-empty text-like field in declaration order (ContentItem::deriveTitle()). There is no separate "title" input. |
{item.auto.title} |
| Excerpt | Optional per type — add it with the Excerpt quick-add chip, delete it from its own card's settings panel. A real column, so it's translatable and indexed alongside the title. | {item.excerpt} |
| Featured image | The Featured Image picker in the item form's sidebar. Every type has one, whether or not it declares an image field. | {item.featured_image} |
Naming a field title is supported, and it wins. A field keyed title outranks position in the title derivation — its value is what lands in the title column and what {item.auto.title} resolves to, wherever it sits in the grid. The same precedence applies to the localized title on /{lang}/ pages, so the translation names the same field the stored column does. The field builder badges the row the title actually comes from (Item title), and the badge follows the field when you reorder the grid.
One trap worth knowing: a field's key is derived from its label. Typing "Title" into any field's label sets that field's key to title. The key tracks the label on every keystroke, so relabelling an existing field re-keys it — and orphans whatever was stored under the old key.
Reserved field keys
A short list of keys is refused, on both the create and edit forms, by UnreservedFieldKey against ContentTypeDefinition::RESERVED_FIELD_KEYS:
auto · data · excerpt · featured_image · id · meta_title · meta_description · og_title · og_description · og_image · published_at · slug · status · translations · url — plus any key starting with rel_ or tax_.
Why the list is that short. A data field is addressed through its own namespace — {item.data.{key}} — so it can't collide with the item itself no matter what you call it. Two authoring surfaces break that isolation: a taxonomy is addressed by its bare name ({item.industry.url}), and the record's own properties are bare too ({item.url}, {item.excerpt}, {item.published_at}, {item.featured_image}, {item.id}). A key on that list would either be unreachable or shadow the real property, and which one wins isn't something the builder can show you. data is the field namespace and auto the selector namespace (see below); rel_ / tax_ are the prefixes the generated relation and taxonomy tokens use.
Why it refuses rather than re-keys. Stored content_items.data is keyed by the field name, so silently renaming on save would orphan every existing item's value for that field. Refusing at the one moment you can still pick a different name is the only non-destructive answer.
excerpt is on the list because the type-level toggle is the excerpt: a second field of that name would produce a duplicate {item.excerpt} token and an input that feeds nothing. Names that used to be discouraged for looking like built-ins — title, image, date, body, subtitle, category, tags — are deliberately not reserved. They used to resolve to a heuristic; they're now ordinary names you may use however you like.
Where the excerpt renders
The excerpt has a slot among the fields, drawn in the field builder as an ordinary field card — that it's a column rather than a fields entry is plumbing, not something the builder makes you think about. Pick it up with the move handle and click the seam you want it on. The slot is stored as excerpt_position; a type that has never moved it renders the excerpt after every field, as it always did.
Its Label, Placeholder, and help Note are set in its settings panel and stored in excerpt_settings per content type — one shared column, but wording written for Services never surfaces on Projects. Blank entries fall back to the built-in copy. Deleting the excerpt from that panel switches the column off and clears the wording with it; the quick-add chip puts it back.
The excerpt feeds listing cards and search results, and is the description fallback for SEO output (structured data, the SEO scanner) when an item has no meta description.
Naming your fields
A workable default shape for an entity-style type — a service, a location, a team member — and what each piece feeds:
| Purpose | Where it lives | Token |
|---|---|---|
| The thing's name (list card + detail H1) | name — text, required, first |
{item.auto.title} |
| Short blurb for a listing card | Built-in excerpt | {item.excerpt} |
| Longer intro on the detail page | summary — text |
{item.data.summary} |
| Main body | description — rich text |
{item.auto.body} |
| Listing card image | Built-in featured image | {item.featured_image} |
| Detail hero image (only if art-directed differently from the card) | image — image field |
{item.data.image} |
The Quick add chips on the content type editor produce exactly this shape, in this order.
namemust stay first unless something is keyedtitle. Position is what makes it the title otherwise, and a type with no text-like field at all gives every item the title "Untitled" — there's no validation against it, so the required first field is what prevents it.- For a document-style type (posts, minutes, portfolio pieces) relabel it "Title" freely. The key becomes
title, which is exactly the field the derivation prefers — it stays the item title even if you later drag another text field above it. - Prefer
{item.data.image}to the{item.auto.image}selector.auto.imagemeans "first image field" and silently re-points if you later add another image field above it. Reach for a selector when the design has to work on a type whose field names it can't know; reach fordata.when you're writing for this type.
Tokens: names and selectors
A data-source token is one of two things, and the auto. prefix is what tells them apart.
A plain {item.X} names a thing — either one of the record's own properties or something you declared. It resolves to that and nothing else, which is what leaves you free to declare a field called title, image or date.
An {item.auto.X} is a selector: "whatever this source uses for X." Picking the first image field, the first rich-text field, or the primary taxonomy is a heuristic, and heuristics live behind auto. where they announce themselves.
| Token | Resolves to |
|---|---|
{item.url} {item.excerpt} {item.featured_image} {item.published_at} {item.id} |
The record's own properties. Always there; no field key can take one (see Reserved field keys). |
{item.data.{key}} |
The field you declared under {key} — exactly that one, always. |
{item.{taxonomy}} · .slug · .url |
A taxonomy you declared, by its bare name. .url is the term's archive page — the href a category pill should carry; a bare .slug renders as a relative link and breaks. |
{item.rel_{field}.…} |
A single relation field's target record, resolved through the target type's tokens ({item.rel_client.auto.title}, .url, .data.*). Multiple relations are loop-only. |
{item.auto.title} |
The item title (the derived column, localized). |
{item.auto.subtitle} |
A field literally named subtitle, when the type has one. |
{item.auto.image} |
The first image field. |
{item.auto.body} |
The first richtext_tiptap field — the item's body. |
{item.auto.date} |
The first datetime field's start, falling back to published_at. |
{item.auto.preview_text} |
The excerpt, falling back to the body flattened to plain text and truncated. |
{item.auto.category.name} · .slug · .url |
The primary single taxonomy (one named category if there is one, else the first). |
{item.auto.tags} |
The primary multiple taxonomy, comma-joined. |
A selector that the type can't satisfy resolves to an empty string rather than an error, and the self-hiding <x-dl.*> components collapse the slot cleanly — so one shared design degrades instead of breaking on a type that has no image, no body, or no taxonomy.
{item.auto.X} works on every data source, not just content types. A source with fixed columns — products, listings, courses — has nothing to choose between, so it answers auto.X with its own X. That's what lets a single design-library row bind to either kind of source.
Installs written in the old spelling migrate with php artisan content:retokenize (--dry-run first). The selectors used to own the plain names, so {item.title}, {item.image}, {item.category.name} and friends resolve to nothing now — and a dead token renders as an empty string, not an error, so the symptom is a blank headline on a live page rather than anything that announces itself. The command sweeps runtime page blades, shared rows, header/footer partials, client-authored rows and themes, and stored content_overrides; everything the repo ships is already migrated.
Per-item detail-page flexibility
A content type's items don't have to all look the same. Beyond the shared detail template, each item gains a ladder of opt-in flexibility — tiers 1–4 build on each other, and tier 0 sits outside the ladder as the option to rule out first:
- A row on the shared template, shown for one record — an extra section for one item usually doesn't need a per-item mechanism at all. Add the row to the type's shared
⚡show.blade.phpand set its visibility to Show when… Specific record; it renders on that record's page and nowhere else, and there's still only one template to maintain. See Conditional rendering. (And if the section is really "some items have one" rather than "this item is special" — an optional field the row reads is simpler still: the row renders empty for items that leave it blank.) - Rich text — the first
richtext_tiptapfield is the item's body. - Content blocks — drop CTA / gallery / FAQ blocks into the body via
[[type:slug]]shortcodes (or auto-append), straight from the edit form's Content Blocks panel (the shared<livewire:content-blocks-manager>, same as blog/events). The first Tiptap field is the body that resolves them. See Shortcodes. - Detail layout variants — a per-item Detail Layout dropdown picks a prebuilt, field-agnostic full-page design (the variants render the type's declared fields through the shared
<x-content-type-fields>partial, so they work for any field shape). - Custom item pages — Customize this page promotes a single item to its own bespoke, fully-editable page with arbitrary design-library rows, still reading the item's fields live. Gated per type by the Allow custom item pages switch.
Tiers 3 and 4 are mutually exclusive per item (a promoted page is served by its own route and skips the layout gate) but mix freely across a type.
Sub-items (child items)
Any item can nest under a parent item of the same type via the Parent item select on the create/edit form (collapsed behind a "Nest under a parent…" link until wanted) or the list's New Sub-item row action — a wellness service with physicals, injections, and hormone-therapy sub-pages, for example. There is no per-type toggle: nesting only happens when an editor explicitly picks a parent, and is fully reversible. A sub-item is a first-class record (searchable, sitemapped, listed in the dashboard) whose page lives under its parent's URL:
- URL & page — a sub-item always materializes its own custom page at
/{type}/{parent}/{child}(created automatically on save when the type has a detail template). The two-segment wildcard show route can never serve a nested path, so a sub-item can't fall back to a shared layout — the Detail Layout card pins to Custom until the parent is cleared. - Breadcrumbs — the trail derives from the URL path, so a sub-item page reads Home / {Type} / {Parent} / {Child} automatically; a promoted or wildcard-served parent segment resolves the record's (localized) title.
- Moves follow the record — renaming a parent's slug, changing an item's parent, or deleting a parent relocates every affected page (blade + route + page-scoped overrides + sidecar) in one pass. Deleting a parent never deletes its sub-items: they re-parent to the grandparent (or top level) and their pages move up with them.
- Kept out of global lists — sub-items are excluded from the type's collection rows, facet counts, and "show all of type" relations (curate one explicitly to include it). This is the same principle as owned sub-records, but unlike owned items, sub-items keep their own live page and full record status.
- Listing a parent's sub-items — two opt-ins replace hand-authored static card grids: on the parent's own promoted page (which
@dataBinds the record),<x-dl.collection source="relation:publishedChildren" preset="content_type:{slug}">iterates that record's published sub-items in display order; on any other page (a landing or the homepage), the structuralfilter-parent="{parent-slug}"attr on a global<x-dl.collection preset="content_type:{slug}">relaxes the top-level-only scope to that one parent's sub-items.parentis structural-only — it renders no facet pill, a query-string param can never set it, and an unresolvable slug renders an empty grid rather than silently falling back to the top-level list.
Hierarchy is unrelated to owner_id (inline-created owned fragments): a sub-item is public and permanent; an owned item is hidden everywhere and cascade-deleted with its owner.
Auto-generated dashboard
Three pages live under resources/views/pages/dashboard/content/ — ⚡index.blade.php, ⚡create.blade.php, ⚡edit.blade.php. They're shared across every content type: the type slug comes in as a route parameter and the dynamic field list drives the form layout. Adding a new content type doesn't fork these files — they read the definition's fields array on every render and emit the right input per type.
The sidebar nav surfaces every content type alongside core content sections. A show_dashboard_button flag on the definition controls whether a primary "Add" button appears in the dashboard header for that type, so high-volume types (blog-like) can be one click from anywhere while low-volume types (staff bios) stay tucked into the sidebar.
Scoped editors — the content-type grant
A person who should manage one type and nothing else — HR keeping the Job Listings current, say — does not need a Manager login. Below Manager, dashboard access is the sum of per-user grants, and a content-type grant is one of them: on Dashboard → Users, pick a role below Manager (Staff is the rung meant for an employee; Standard works too) and tick the types under Content types this person can manage. That person then gets:
- a sidebar Content group listing exactly the granted types (no Pages, Docs, or Media Library entries — those pages stay Manager-only);
- the item list, add, and edit pages for those types (
dashboard/content/{typeSlug}…), including the media picker and attachment uploads an item form depends on; - the granted type's list as their landing page after login.
Everything else stays shut: other types bounce to the dashboard (and from there to wherever the person belongs), the type definitions under dashboard/content-types are Manager-only — a grant is the right to manage items, never to reshape the type — and the item form hides the links that would only bounce (edit-the-type, customize-this-item's-page, share-on-social). The review workflow still applies as it does to any below-Admin author: on a requires_review type a grant-holder submits for review rather than publishing.
How it's built: user_content_type_grants is a pivot (a deleted type takes its grants with it), read through User::canManageContentType($slug) / manageableContentTypes(), both of which short-circuit on Manager-or-higher. The item routes carry a content-scope middleware (EnsureContentTypeScope) instead of a role:manager floor — it reads {typeSlug} off the route, and on the shared media-upload / attachment endpoints (no type in the URL) admits on any grant. It is registered as Livewire persistent middleware, so a grant revoked mid-session stops the next save, not just the next page load. The live chat console is the other grant-admitted surface; see AI Chat → Who can work the chat.
Auto-generated public pages
ContentTypePageGenerator::generate() writes two blade files:
resources/views/pages/{slug}/⚡index.blade.php— a list page that paginates published items bypublished_atdesc, rendering each as a link to its detail page. The list itself is a<x-dl.section>with a heading +<x-dl.wrapper>list, so the editor can restyle it in-place using the standard page editor.resources/views/pages/{slug}/⚡show.blade.php— a detail page that resolves the item by its slug (legacy/{slug}/{id}URLs 301 to the canonical slug URL), sets aLoopContextso{item.data.field_name}tokens resolve to the current record, and renders each field via the matched*Block()builder (textBlock,richtextBlock,imageBlock,galleryBlock, etc.).
Both files are wrapped through RowBladeSurgery::wrapComponentsAsItems so they enter items-mode immediately — the editor's sidebar populates correctly on the first open.
Routes are injected into routes/web.php as paired Route::livewire() calls — Route::livewire('{slug}', 'pages::{slug}.index')->name('{slug}.index') and Route::livewire('{slug}/{record}', 'pages::{slug}.show')->name('{slug}.show'). Removing a content type tears down the directory and pulls those exact lines back out, plus any promoted custom item pages for the type.
@previewContext for the editor
Every generated ⚡show.blade.php includes a @previewContext frontmatter comment:
{{-- @previewContext model=\App\Models\ContentItem label=title value=slug routeParam=record orderBy=published_at:desc where=type_slug:{slug} --}}
This drives the page editor's "Preview as" dropdown so editors can flip between any actual content item while editing the template, scoped by where=type_slug:{slug} so the dropdown only shows records of the correct type. Without this comment the editor would render the template against an empty record and useful editing (alignment, image positions, etc.) would be impossible.
Binding design library rows to a content type
Every content type registers as a data source via ContentTypePreset under the key content_type:{slug}. Any design library row whose data source picker is shown in the editor sidebar can bind to that preset — pick the type, pick filters and ordering, and the row pulls live records on render. Tokens like {item.auto.title}, {item.published_at}, {item.data.field_name} resolve against each item in the loop.
Practical consequence: a homepage row showing "Latest meeting minutes" or a sidebar row showing "Recent products" doesn't need a custom blade template per content type. The same generic row binds to whichever content type the editor picked. Editing a content item updates every page that displays it, with response-cache invalidation handled by the standard ContentItem model lifecycle.
What lives where
| Path | Purpose |
|---|---|
app/Models/ContentTypeDefinition.php |
The type itself — name, slug, singular, icon, sort, and the fields JSON array. |
app/Models/ContentItem.php |
One row per content item across all types; type_slug discriminates and data JSON holds the per-type fields. Auto-derives title on save from a field keyed title, else the first text-like field. |
app/Support/ContentTypePageGenerator.php |
Generates and removes the public list/show blade files, injects/strips routes in routes/web.php. |
app/Support/DataSources/Presets/ContentTypePreset.php |
One preset instance per registered type, registered as content_type:{slug} so design library rows can bind. |
resources/views/pages/dashboard/content-types/ |
Define new types — ⚡index, ⚡create, ⚡edit. |
resources/views/pages/dashboard/content/ |
Shared CRUD for individual items of any type. |
resources/views/pages/{slug}/⚡index.blade.php |
Auto-generated public list page (one per registered type). |
resources/views/pages/{slug}/⚡show.blade.php |
Auto-generated public detail page (one per registered type). |
Definition fields reference
| Field | Default | Notes |
|---|---|---|
name |
required | Plural display name (e.g. Meeting Minutes). |
slug |
required | URL slug + storage discriminator. Unique on content_type_definitions. |
singular |
required | Singular form (e.g. Minute) — used in breadcrumbs and labels. |
icon |
'document' |
Heroicon name shown in the sidebar. |
sort_order |
0 |
Sidebar ordering. |
show_dashboard_button |
false |
Whether the Add button appears in the dashboard header. |
allow_custom_item_pages |
true |
Whether items of this type can be promoted to their own custom page. |
is_seeded |
false |
Marks definitions seeded by the demo data path so they can be cleanly removed. |
fields |
[] |
Array of {label, name, type, options, required} declarations. |
schema_type |
null |
schema.org type emitted as JSON-LD on each item's detail page — see below. null/'' emits nothing. |
schema_field_map |
null |
Per-role overrides of the auto-detected field mapping. Only divergent roles are stored. |
Structured data (schema.org)
A type declares one schema.org type; StructuredData::forItem() turns each item into JSON-LD on its detail page (via the partials.content-detail-head include the show template carries). Property values are auto-detected from the type's fields — by field type (address → address, hours → openingHours, featured image → image) and by name convention (phone → telephone, role → jobTitle, rating → reviewRating) — and any role can be overridden per type in Content Types → edit → Structured data.
| Schema type | Fits | Auto-fills |
|---|---|---|
Article / BlogPosting |
Posts, meeting minutes | datePublished, dateModified, publisher, mainEntityOfPage |
CreativeWork |
Portfolio pieces, projects | — |
Service |
Service pages | provider (the site) |
Product |
Shop items | additionalProperty from specs fields |
Event |
Events | startDate/endDate from a datetime field |
JobPosting |
Job listings | datePosted, hiringOrganization (the site); a text location degrades to a Place locality |
Person |
Team members | — |
Organization / LocalBusiness |
Clients; locations | LocalBusiness also emits opening hours + additionalProperty |
Review |
Testimonials | itemReviewed (the site), datePublished; a loose rating ("4.5 stars") parses, out-of-range is dropped |
Question |
FAQ entries | acceptedAnswer from the answer field |
A type that declares nothing emits nothing. There is deliberately no fallback: an unset type used to emit Article, which told search engines that every FAQ, staff bio, customer review and service was a news article — and was invisible in the UI, since "unset" and "None" both render as None in the select. Every seeded type now names its own type in SeedDemoDataJob::demoContentTypes(); a new custom type starts at None until you pick one.
Per-item state lives on ContentItem: has_custom_page (promoted to a bespoke page), detail_layout (chosen layout variant; null = the type default), and parent_id (sub-item hierarchy; null = top level).