Content type reference
This document explains how to write a content type for the Filament CMS package: generating the class, registering it, the methods it can override, how its URLs are built, the capability toggles, block restrictions, how the type behaves in the admin panel, frontend rendering and structured data. It is written for the developer who adds a kind of content to a site.
A content type describes one kind of content. It declares the fields the content has, whether the content nests under a parent, how its URL is built, and which parts of the CMS apply to it. The type's key is stored in the content.type column of every row it owns. One Filament resource, ContentResource, serves every registered type, so registering a type adds a navigation item and a URL space to the panel without adding a resource class.
The package registers up to three built-in types: Page; Snippet, unless cms.snippets.enabled is false; and LayoutArea, unless cms.layout_areas.enabled is false.
Generating a content type
- In a terminal at the site's root, run the generator with the class name:
php artisan cms:make-content-type CaseStudy
For a type whose content nests under a parent, add --tree:
php artisan cms:make-content-type Chapter --tree
When the name argument is omitted, the command asks for it and then asks "Should these nest under one another?", which sets the same option as --tree.
The command writes app/Cms/ContentTypes/{Class}.php from stubs/content-type.stub. It derives the key, label and plural label from the class name with Str::snake(), Str::headline() and Str::plural(Str::headline()). CaseStudy therefore becomes key case_study, label Case Study and plural label Case Studies. --tree adds an isTree() method that returns true, so content of the type nests under a parent.
The generated class is registered automatically when cms.content_types.discover includes the App\Cms\ContentTypes namespace, which is the shipped default. Otherwise, add the class to cms.content_types.register.
Registration and precedence
A content type is registered in one of two ways, both configured in config/cms.php:
'content_types' => [
'discover' => ['App\\Cms\\ContentTypes' => null],
'register' => [App\Cms\ContentTypes\CaseStudy::class],
],
discovermaps a namespace to a directory. Every non-abstractContentTypesubclass found in the directory is registered. Anulldirectory is derived from the namespace, soApp\Cms\ContentTypesresolves toapp/Cms/ContentTypes.registeris an explicit list of classes, for types that live outside a discovered directory.
Blocks are registered by the same mechanism, described in Block reference.
FilamentCmsServiceProvider::registerContentTypes() registers types in this order: the built-in types, then the register list, then each discover entry. When two types declare the same key(), the one registered later replaces the earlier one. A type whose key() returns page therefore replaces the built-in Page type completely, without a config flag and without modifying the built-in class.
Example
<?php
declare(strict_types=1);
namespace App\Cms\ContentTypes;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\TextInput;
use Mcg\FilamentCms\Contracts\ContentType;
class CaseStudy extends ContentType
{
public function key(): string { return 'case_study'; }
public function label(): string { return 'Case Study'; }
public function pluralLabel(): string { return 'Case Studies'; }
public function icon(): string { return 'heroicon-o-briefcase'; }
/* navigationGroup() is not overridden: the default, 'Content Manager', is
the group that holds Pages and Snippets. Returning a different string
creates a second sidebar group instead of moving the item. */
public function navigationSort(): int { return 20; }
/** URLs follow /case-studies/acme instead of a position in the page tree. */
public function routePattern(): ?string { return '/case-studies/{slug}'; }
/* Reported to MCP clients only. Nothing renders this path; the index is an
ordinary page. */
public function indexRoute(): ?string { return '/case-studies'; }
public function attributeSchema(): array
{
return [
TextInput::make('title')->required()->maxLength(200),
TextInput::make('client')->maxLength(120),
DatePicker::make('completed_at'),
// Subtitle and Summary are added for every routable type; see
// "Standard fields".
];
}
public function searchableAttributes(): array
{
return ['title', 'client', 'summary'];
}
public function allowedBlocks(): ?array
{
return ['rich_text', 'image', 'image_grid', 'stats', 'testimonial', 'cta'];
}
public function defaultBlocks(): array { return ['rich_text']; }
public function layout(): string { return 'cms-layout::article'; }
public function sitemapPriority(): float { return 0.8; }
}
Every method
All methods are defined on Mcg\FilamentCms\Contracts\ContentType. Four are abstract and must be implemented; every other method has the default shown.
| Method | Default | When to override |
|---|---|---|
| key() | Abstract | Always. The value is stored in content.type. Renaming the key leaves existing rows with a type that no longer exists. |
| label() | Abstract | Always. |
| pluralLabel() | Abstract | Always. Used as the navigation label. |
| attributeSchema() | Abstract | Always. Returns the Filament fields shown on the Settings tab. |
| icon() | heroicon-o-document | To change the navigation icon. |
| navigationGroup() | 'Content Manager' | To place the type in a different sidebar group. Returning a new string creates a new group rather than moving the item into an existing one. |
| navigationSort() | 0 | To change the type's position in the sidebar. Types are sorted by this value, then by label. |
| isTree() | false | When content nests under a parent. Controls two things: the Tree screen and the Parent select on the edit form. A type whose rows are parented to content of another type gets an empty tree; see pitfalls.md. |
| routePattern() | null | When URLs follow a pattern instead of the tree. |
| indexRoute() | null | To declare where the type's listing page lives. The value is reported by the cmslistcontenttypes MCP tool as indexroute and renders nothing. Build the listing as an ordinary page with a block that queries the type. |
| isRoutable() | true | When the type can be embedded in other content but has no URL of its own. |
| pathPrefix() | cms.snippets.pathprefix (/snippets) | When an unroutable type needs its own reserved path space instead of sharing the snippet prefix. |
| homepageEligible() | false | When an instance may be the site homepage. |
| slugIsEditable() | true | When the slug is an identifier rather than an address, and renaming it would break a reference. |
| isCreatable() | true | When rows are derived from another source, and a row created by hand would not render anywhere. |
| allowedBlocks() | null (all registered blocks) | To restrict the block picker. |
| requiredBlocks() | [] | When every version must contain the listed blocks. Enforced in VersionManager. |
| defaultBlocks() | [] | When new content should open with the listed blocks already placed. |
| usesVersioning() | true | See Capability toggles. |
| usesSeo() | true | See Capability toggles. |
| usesScheduling() | true | See Capability toggles. |
| usesPreview() | true | See Capability toggles. |
| usesMedia() | true | See Capability toggles. |
| usesSoftDeletes() | true | See Capability toggles. |
| isSearchable() | true | See Capability toggles. |
| searchableAttributes() | [] | To name the attribute keys whose values are added to the search index. |
| includeInSitemap() | true | To exclude the type from the sitemap. |
| includeInFeed() | The value of includeInSitemap() | When the type belongs in the sitemap but not the RSS feed, or the reverse. |
| sitemapPriority() | 0.5 | To change the sitemap priority. |
| sitemapChangefreq() | weekly | To change the sitemap change frequency. |
| structuredDataType() | 'WebPage' | To name the schema.org type of instances. See Structured data. |
| structuredData() | [] | To supply the properties that schema.org type requires. See Structured data. |
| layout() | cms-layout::default | To use a different layout component. |
| availableLayouts() | null (every registered layout) | To offer the editor a shortlist of layouts, or [] to fix the type to layout(). |
| view() | null, which uses cms::content.default | To use a custom content view. |
| extendListPage() | No operation | To customize the list page. |
| extendCreateForm() | Returns the schema unchanged | To add components to the create form. |
| extendEditForm() | Returns the schema unchanged | To add components to the edit form. |
| tableColumns() | null (the default columns) | To replace the table columns. |
| tableFilters() | null (the default filters) | To replace the table filters. |
Routing
A content type builds its URLs with one of two strategies. The strategies are mutually exclusive, and the choice depends on whether routePattern() returns a value.
| Strategy | Selected when | How the path is built |
|---|---|---|
| Tree-derived | isTree() returns true and routePattern() returns null | The path is built by walking the parent chain. A page branding under design under services has the path /services/design/branding. |
| Pattern | routePattern() returns a non-null string | {slug} in the pattern is replaced with the row's slug. |
Under the tree-derived strategy, renaming a parent changes the path of every descendant, and the package fires one ContentPathChanged event per changed URL. Each old URL therefore receives its own 301 redirect.
A pattern is declared like this:
public function routePattern(): ?string
{
return '/blog/{slug}';
}
No extra routes are registered for a pattern. PathResolver writes the resolved pattern into the row's path column when the row is saved, so the catch-all frontend route resolves the row with the same single indexed lookup it uses for every other row.
The two effects of isTree()
isTree() controls two things in the panel: the Tree screen, and whether the Parent select appears on the edit form. Both are controlled by the one method, although a type can need one without the other:
- A type can need a parent recorded without needing a Tree screen.
- A type whose rows are parented to content of a *different* type gets a Tree screen that is always empty.
ContentTree::forType()limits the query to one type and then starts the tree from rows whoseparent_idis null, and none of this type's rows have a null parent.
Setting isTree() to false removes the Tree screen and the Parent select together, so nothing in the panel can set parent_id afterwards. Before turning it off, check what reads parent_id: Breadcrumbs walks it across types, and the page_list block lists pages by it. The full explanation is in Pitfalls.
Unroutable types
When isRoutable() returns false, content of the type can be authored and embedded in other content, but it has no public URL. The frontend resolver skips it, so a visitor sees it only through another page that references it. Snippets are the built-in example.
Rows of an unroutable type still have a path, because the path column is NOT NULL and uniquely indexed. PathResolver places them under the reserved pathPrefix() so that they cannot collide with the path of a routable page.
Homepage eligibility
When homepageEligible() returns true, the edit form shows the Use as homepage toggle. Promoting a page to homepage demotes the previous homepage and moves it back to its slug-derived path. That move creates a redirect for the previous homepage, because / now serves the new homepage.
Capability toggles
Each toggle is a method that returns true by default. Overriding it to return false has this effect:
| Toggle set to false | Effect |
|---|---|
| usesVersioning() | Hides the Version history relation manager. |
| usesSeo() | Removes the SEO tab from the editor. |
| usesScheduling() | Removes the publish and unpublish date-time fields. |
| usesPreview() | Removes the Preview header action, and the cmspreviewcontent MCP tool refuses the type. |
| usesMedia() | No effect in the package. No code reads this method. Media blocks remain available unless allowedBlocks() also excludes them. |
| usesSoftDeletes() | Reported by the cmslistcontenttypes MCP tool as usessoftdeletes. No other code reads it, so deletion behavior does not change. |
| isSearchable() | No effect in the package. No code reads this method. Site search covers every routable type; see search.md. To keep a type out of search results, make it unroutable with isRoutable(). |
| includeInSitemap() | Excludes the type from the sitemap. |
| includeInFeed() | Excludes the type from the RSS feed. The default is the value of includeInSitemap(), because a type excluded from the sitemap is rarely one to publish to feed readers. Override it where the two differ, for example a landing page that belongs in the sitemap and not in the feed. |
These toggles apply to every instance of a type. To exclude one page, every routable type has a Where this page appears section in Page settings. It holds three toggles that keep a single published page out of the navigation, out of page lists, or out of sitemap.xml, without changing its URL. See Layouts and regions.
Restricting blocks
public function allowedBlocks(): ?array
{
return ['rich_text', 'image', 'cta'];
}
The block picker offers the intersection of this list and each block's own contexts(). The list cannot be overridden for a single record. A type narrows its block set to make its records uniform, and a per-record override available to any editor would make the list advisory. If one record needs another block, add the block's key to the list; it then becomes available on every record of the type.
To allow a block only for some editors, keep the block in allowedBlocks() and implement Block::authorize() on the block, which decides who may place it.
defaultBlocks() names the block keys that new content starts with, and requiredBlocks() names the block keys every version must contain:
public function defaultBlocks(): array
{
return ['page_heading', 'rich_text', 'cta'];
}
public function requiredBlocks(): array
{
return ['page_heading'];
}
Both methods had no effect before v0.35.7. They were part of the contract from the beginning and were reported by the cms_list_content_types MCP tool, but no other code read them. A type could declare default blocks and every new record still opened empty, and it could declare a required heading that an editor could delete and save.
Since v0.35.7 they behave as follows:
defaultBlocks()places the listed blocks, each with its owndefaults(), on the create form only, and only while the form is untouched. A key naming a block thatallowedBlocks()excludes is skipped, so the two lists cannot contradict each other on screen.requiredBlocks()is enforced inVersionManager, not in the panel. Versions are written from three places (the panel editor, the MCP tools and the delivery API), and a rule enforced in only one of them would not apply to the other two. Saving a version without a required block throwsBlockValidationException, naming every missing block. The panel also hides the delete button on the last remaining copy of a required block, so the editor usually meets the rule when deleting rather than as a failed save.
A hidden instance of a required block counts as present. Hiding a block is a temporary editorial change that keeps the block in the version. If a hidden block did not count, the only way to preview a page without its heading would be to delete the heading.
Existing types are not affected. Both methods return an empty array by default, so behavior changes only for a type that overrides them.
Required blocks and existing records
Adding a key to requiredBlocks() applies to records that already exist. Every record of the type that lacks the block fails its next save, including a save that changes only the title or an SEO field, until the block is added. Since version 0.79.4 the package handles this in three places:
| Where | What happens |
|---|---|
| The create screen | A new record opens with its defaultBlocks(), followed by every required block the defaults do not include |
| The edit screen | When a record opens without a required block, the block is appended to the end of the page with its own defaults, and a persistent notice names it: "Required blocks added to the end of the page". Nothing is stored until the editor saves |
| MCP and the delivery API | The save is refused. The error names every missing block and says to add it with cmsappendblock. Blocks are not added to a write the agent did not send |
RequiredBlocks::missingFrom() and RequiredBlocks::appendMissing() hold the rule that all three apply. A required block placed inside a container counts as present, as does a hidden one.
Before making a block required on a type with records already created over MCP or the API, add the block to those records with cms_append_block, or open and save each one in the panel. Until then, an agent editing one of them meets the refusal on its first write.
How a type behaves in the panel
This section applies to every registered type. A type that switches a capability off changes what an editor sees on these screens.
Editing a page
The edit screen header has one primary button, Save & publish, and a More actions menu holding the other actions in this order: Save, Publish, Preview, View live page, Add a child page, Duplicate page, Unpublish and Delete. Each action checks the user's abilities, so an editor who may save but not publish sees Save and neither of the publishing actions.
Save & publish requires both the save and the publish ability. It re-reads the record between saving and publishing. If it published the record instance the action started with, it would publish the previous draft and discard the edit just saved.
Preview opens a slide-over instead of a browser tab. Opening a tab per preview leaves tabs that are out of date as soon as the editor saves again. usesPreview() removes the action.
Add a child page opens the create form with a ?parent= query parameter. CreateContent::afterFill() reads the parameter. It is read there and not in mount(), because fillForm() replaces the form state with the form's defaults and would discard a value set earlier. The parent is validated against the same content type, so an edited URL cannot place a page under a parent of another type.
Duplicate page copies the blocks, attributes, SEO fields and tags into a new unpublished draft placed directly after the original, then opens it. The copy's slug is {slug}-copy, or {slug}-copy-2, {slug}-copy-3 and so on when that slug is taken, and its title is the original's title followed by (copy). The copy does not receive the published version, the schedule or the homepage status, so it cannot go live at the moment it is created. Version history is not copied either: the copy starts at version 1, and the original's history stays with the original.
ContentDuplicator::duplicate() performs the copy. Other code that needs a copy of a page can call it instead of reproducing these rules.
The settings section
The edit form's collapsed settings section is headed with the type's label, for example "Case Study settings". It shows the type's own fields from attributeSchema(), then the standard fields, then the fields below. Each field is shown only when something reads it for that type:
| Field | Shown when |
|---|---|
| Subtitle, Summary | The type is routable. These come from standardAttributeSchema(); see Standard fields |
| Slug | Always. Read-only when slugIsEditable() returns false |
| Template | More than one layout is available; see availableLayouts() |
| Custom classes | The type is routable. The classes are written onto the <body> element when the content is displayed, so an unroutable type, which has no page of its own, does not show the field |
| Language | Multilingual content is enabled with more than one locale |
| Parent, Sort | isTree() returns true |
| Tags | The type is routable and cms.tags.enabled is true |
| Use as homepage | homepageEligible() returns true |
| Navigation label, Navigation description, Navigation image, Hide from navigation | The type is routable and its key is in cms.navigation.types, which is ['page'] by default. NavigationBuilder and derived menus read only those types. Add a type's key to the setting to put its content in tree-built navigation |
| Hide from page lists | The type is routable |
| Hide from sitemap.xml | The type is routable and includeInSitemap() returns true |
| Publish at, Unpublish at | usesScheduling() returns true |
Before version 0.79.3, the navigation fields, Hide from sitemap.xml and Custom classes were shown for types where nothing read them. Values saved in them then are kept in each version and have the same effect as before.
Standard fields
Every routable type has two fields in addition to the ones its attributeSchema() returns:
| Field | Attribute | Read by |
|---|---|---|
| Subtitle | subtitle | Layouts that show a lead under the title, such as article |
| Summary | summary | Page list, Related and tag archives, which show it under each link, and the RSS feed, which uses it as an item's description when the page has no SEO description |
A list reads summary, and subtitle when no summary is set, which keeps the list text of content written before version 0.79.3, when lists read only the subtitle.
ContentType::standardAttributeSchema() returns the two fields, and editorAttributeSchema() appends each one whose name attributeSchema() does not already use. To change a standard field, declare a field with the same name in attributeSchema(); to remove both, override standardAttributeSchema() to return []. Unroutable types, such as snippets and layout areas, get neither. cms_list_content_types reports the standard fields in each type's attributes.
Media fields
A field in attributeSchema() that stores a file from the media library is declared with MediaOptions::picker(), and its name must follow the media naming convention: media_id, any name ending in _media_id, or one of the list names images and logos.
public function attributeSchema(): array
{
return [
TextInput::make('title')->required(),
MediaOptions::picker('audio_media_id')->label('Audio'),
MediaOptions::picker('cover_media_id')->label('Cover'),
MediaOptions::picker('images')->multiple()->label('Gallery'),
];
}
The value is stored in the version's attributes column, not in media_refs, which VersionManager::save() computes from blocks only. MediaReferences::attributeMediaIds() finds media ids in attributes by field name, at any depth, so a picker inside a Repeater is found as well. The media library's IN USE badge, its Usage tab, the Delete action, the bulk delete, the Unused only filter and the cms_delete_media MCP tool all read those references.
A media field with a name outside the convention, such as MediaOptions::picker('photo'), is not found. The file it holds shows no IN USE badge and can be deleted from the media library while the record still points at it. The versions already written then hold the id of a file that no longer exists, and they cannot be corrected because versions are immutable. Before version 0.81.3, MediaReferences did not read attributes at all, so this applied to every content type field. See Media for the rules the scan applies.
Finding a page
Content appears in the panel's global search across every registered type: a search started from the Pages screen also returns snippets. The search matches titles, slugs and paths, so a page can be found by its name or by its URL.
Two implementation details apply when extending this search:
- The global search query must not use
getEloquentQuery(), which limits the query to the content type of the current screen.ContentResource::getGlobalSearchEloquentQuery()is overridden to search every type the user may view. - Each result's URL must name its own
contentType.ContentResource::getUrl()defaults that parameter to the active type, and without it a snippet result would open on the Pages screen.
Results are filtered by the content.view ability for each type. On an installation that grants view on pages and withholds it on snippets, the search box does not return snippet titles.
The list table's own search matches the title and the slug. Both searches previously matched the slug only, so a page titled "Pitfalls" at /docs/known-traps could not be found by typing its title.
Arranging pages
A nesting content type has two views of the same list, each linked from the other's header: the table, and the Tree.
A nesting type's navigation item opens the Tree. For a nesting type the navigation item links to the tree page; for a flat type it links to the index page (the table). A flat type has no hierarchy, so the table shows all of its structure. For a nesting type, the hierarchy is what the editor arranges, and the table does not show it: the table can sort by path, which places children under their parents because of alphabetical order and shows nothing about depth. The table stays one click away in the Tree's header, and search, filters and bulk actions are available only on the table.
The Tree shows the hierarchy and rearranges it by drag and drop. A drop does three things:
- It sets the dragged page's parent.
- It renumbers the
sortvalue of every sibling. Changing only the moved row would accumulate equal values over time, and rows with equal values are returned in an order the database does not define. - It calls
PathResolver::sync(), which recomputes the path, updates the paths of descendants, and fires the event that creates a redirect for every URL that changed.
A page cannot be dropped onto itself or onto one of its own descendants. Either drop would make the path recursive and remove the branch, with its children, from the tree.
Both views order rows by sort, then created_at, then id. ContentTree::ordering() is the single definition of that order; the table's defaultSort and the Tree both call it, so the two views always show the same order. The table previously sorted by path, which discarded the sort order, the only ordering an editor controls.
Customizing the admin
public function tableColumns(): ?array
{
return [
TextColumn::make('title')
->getStateUsing(fn ($record) => $record->attributes['title'] ?? $record->slug),
TextColumn::make('published_at')->dateTime(),
];
}
public function extendEditForm(Schema $schema): Schema
{
return $schema->components([
...$schema->getComponents(),
Section::make('Internal')->schema([Textarea::make('editor_notes')]),
]);
}
tableFilters() replaces the default filter set in the same way that tableColumns() replaces the default columns. extendListPage(ListRecords $page): void receives the list page instance during mount().
Frontend rendering
layout() returns the name of an anonymous Blade component, not a view path. The default is cms-layout::default. view() returns a view path, or null to use cms::content.default, which renders the blocks through the block dispatcher inside the layout.
public function layout(): string
{
return 'components.layouts.marketing';
}
availableLayouts() controls which layouts an editor may choose from:
| Return value | Effect |
|---|---|
| null | Offers every registered layout. |
| [] | Offers none, and fixes the type to layout(). |
| A component => label map | Offers the listed layouts. |
Writing a layout is covered in Layouts, regions and page width.
Structured data
Every routable instance outputs a JSON-LD graph in its <head> describing the publisher, the site and the page. The CMS derives most of the graph from values it already holds, so a new content type produces valid markup without extra code. The CMS cannot infer what kind of thing the content is, so the type declares it:
public function structuredDataType(): string
{
return 'BlogPosting';
}
The returned string is used unchanged as the page node's @type, so it must be a real schema.org type. Common values:
| Content | Type |
|---|---|
| An ordinary page | WebPage (the default) |
| A blog post or news article | BlogPosting, NewsArticle |
| A job listing | JobPosting |
| A case study, white paper or report | CreativeWork, Report |
| An event | Event |
| A product | Product |
| A recipe | Recipe |
| A person's profile | ProfilePage |
An editor can override the type for one page with the Page type select in the SEO tab. The select offers the list in StructuredData::PAGE_TYPES: WebPage, AboutPage, ContactPage, CollectionPage, FAQPage, ProfilePage and CheckoutPage. Its empty option reads "Decided by the content type". A type returned by structuredDataType() that is not in that list is still used whenever the editor leaves the select empty, which is the usual case.
Supplying the properties that type requires
Each schema.org type has required properties. A JobPosting without datePosted and hiringOrganization, or a Product without offers, is reported as invalid by a search engine; it is not ignored. Few of those properties correspond to a CMS field, so the content type supplies them:
public function structuredData(Content $content, array $attributes): array
{
return [
'title' => $content->title(),
'hiringOrganization' => ['@id' => StructuredData::organizationId()],
'datePosted' => $content->published_at?->toIso8601String(),
'employmentType' => $attributes['employment_type'] ?? null,
'jobLocation' => [
'@type' => 'Place',
'address' => ['@type' => 'PostalAddress', 'addressLocality' => 'Marlton'],
],
];
}
$attributes is the published version's attribute array, so every field on the Settings tab is available. The returned array is merged over the derived properties key by key. A key the graph already sets, such as name, description or image, is replaced, not appended to.
Null values are removed from the result. This allows employmentType in the example to be listed unconditionally, without a separate condition for each optional attribute. Empty arrays are removed in the same way.
If structuredData() throws an exception, the package treats it as if it returned an empty array. The method runs while the <head> of a public page renders, so an exception costs the page its extra properties and not its 200 response. tests/Feature/StructuredDataTest.php covers this case.
The per-page Custom JSON-LD field in the SEO tab is merged after structuredData() and takes precedence, so one instance can differ from what the type declares for every instance.
Mcg\FilamentCms\Support\StructuredData builds the graph in four layers, each merged over the previous one key by key:
- Derived: title, description, address, dates, author, image and
@type, from values the CMS holds. - Declared: the organization from Site Settings, and the Page type from page settings.
- Typed:
ContentType::structuredData(). - Overridden: the Custom JSON-LD field (
seo.structured_data).
The class docblock describes each layer in detail.
To disable structured data for the whole site, set cms.structured_data.enabled to false.