Skip to content
MCG Filament CMS Demo

Content types

This page explains how to write a content type: 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.

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 blocks it accepts. 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 layout_area, unless cms.layout_areas.enabled is false. To create another type, in a terminal at the site's root, run the generator with the class name, adding --tree for a type whose content nests under a parent:

Shell

php artisan cms:make-content-type CaseStudy
php artisan cms:make-content-type Chapter --tree

The command writes app/Cms/ContentTypes/{Class}.php. It derives the key, label and plural label from the class name with Str::snake(), Str::headline() and Str::plural(Str::headline()), so CaseStudy becomes key case_study, label Case Study and plural label Case Studies. --tree adds an isTree() method that returns true.

Registration and precedence

A generated class has no effect until it is registered. Two keys in config/cms.php register types: register names classes explicitly, and discover scans a namespace. The shipped discover value already includes App\Cms\ContentTypes, so a class written by the generator is registered without further configuration.

config/cms.php PHP

'content_types' => [
    'discover' => ['App\\Cms\\ContentTypes' => null],   // null derives app/Cms/ContentTypes
    'register' => [App\Cms\ContentTypes\CaseStudy::class],
],
  • discover maps a namespace to a directory. Every non-abstract ContentType subclass found in the directory is registered. A null directory is derived from the namespace, so App\Cms\ContentTypes resolves to app/Cms/ContentTypes.
  • register is an explicit list of classes, for types that live outside a discovered directory.

Blocks are registered by the same mechanism.

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.

Example

The example shows the methods most often overridden. Every method other than the four abstract ones has a default on the base class, so a minimal type declares only key(), label(), pluralLabel() and attributeSchema().

app/Cms/ContentTypes/CaseStudy.php PHP

<?php

declare(strict_types=1);

namespace App\Cms\ContentTypes;

use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Textarea;
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),
            Textarea::make('summary')->rows(3),
            DatePicker::make('completed_at'),
        ];
    }

    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

The table lists every method on Mcg\FilamentCms\Contracts\ContentType, its default on the base class, and when to override it. Four methods are abstract and must be implemented.

ContentType methods, their defaults, and when to override them
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 the Tree screen and the Parent select on the edit form.
routePattern() null When URLs follow a pattern instead of the tree.
indexRoute() null To declare where the type's listing page lives. Reported by the cms_list_content_types MCP tool as index_route. Renders nothing.
isRoutable() true When the type can be embedded in other content but has no URL of its own.
pathPrefix() cms.snippets.path_prefix (/_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.
structuredData() [] To supply the properties that schema.org type requires.
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 mutually exclusive strategies. The choice depends on whether routePattern() returns a value.

  • 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. Renaming a parent changes the path of every descendant, and the package fires one ContentPathChanged event per changed URL, so each old URL receives its own 301 redirect.
  • Pattern (routePattern() returns a non-null string). {slug} in the pattern is replaced with the row's slug, so '/blog/{slug}' places a post at /blog/ followed by its slug. No extra routes are registered: 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.

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, because ContentTree::forType() limits the query to one type and then starts the tree from rows whose parent_id is null. 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 on Known traps.

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. Such rows still have a path, because the path column is NOT NULL and uniquely indexed, and PathResolver places them under the reserved pathPrefix() so they cannot collide with the path of a routable page.

When homepageEligible() returns true, the edit form shows the Use as homepage toggle. Promoting a page 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.

Editing a page

Every content type uses the same edit screen. The actions shown on it depend on the editor's abilities, not on the type.

The 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, and 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 it, not 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 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: 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.

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. First, the global search query must not use getEloquentQuery(), which limits the query to the content type of the current screen; getGlobalSearchEloquentQuery() is overridden to search every type the user may view. Second, each result's URL must name its own contentType, because 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 (the tree page). A flat type's navigation item opens the table (the index page), because a flat type has no hierarchy and 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 sets the dragged page's parent, renumbers the sort value of every sibling, and 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. Every sibling is renumbered because changing only the moved row would accumulate equal values, and rows with equal values are returned in an order the database does not define.

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.

Capability toggles

Each row below is a method on the content type that returns true by default. The table states the effect of overriding it to return false, for that type only.

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 cms_preview_content 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 cms_list_content_types MCP tool as uses_soft_deletes. 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. To keep a type out of search results, return false from 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.

Restricting blocks

allowedBlocks() limits the block picker to the block keys it returns:

PHP

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:

PHP

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 own defaults(), on the create form only, and only while the form is untouched. A key naming a block that allowedBlocks() excludes is skipped, so the two lists cannot contradict each other on screen.
  • requiredBlocks() is enforced in VersionManager, 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 throws BlockValidationException, 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.

Extending the panel

Two methods change what the panel shows for one type without subclassing its resource. tableColumns() replaces the columns on the list screen, and extendEditForm() receives the assembled edit form schema and returns a new one.

PHP

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.

PHP

public function layout(): string
{
    return 'components.layouts.marketing';
}

availableLayouts() controls which layouts an editor may choose from. null offers every registered layout. An empty array offers none and fixes the type to layout(). A component => label map offers the listed layouts. Writing a layout is covered on Layouts.

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:

PHP

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:

schema.org types for common kinds of content
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:

PHP

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:

  1. Derived: title, description, address, dates, author, image and @type, from values the CMS holds.
  2. Declared: the organization from Site Settings, and the Page type from page settings.
  3. Typed: ContentType::structuredData().
  4. Overridden: the Custom JSON-LD field (seo.structured_data).

To disable structured data for the whole site, set cms.structured_data.enabled to false.