Skip to content

Content types

A content type is a kind of thing with a title, a slug and a page per entry: Page, Post, catalog’s Product. A taxonomy classifies content and has pages too: PostCategory, PostTag, catalog’s ProductCategory. Both are registered types, and generic code treats them alike through the RegisteredType contract.

There are no generic contents or taxonomies tables. Each type has its own concrete table, and shared behaviour lives in PHP: abstract bases, traits, contracts and registries.

ContentTypeRegistry and TaxonomyRegistry (facades ContentTypes, Taxonomies) map a handle to a class. The handle is the type’s identity:

  • it is the morph alias, stored in every *_type column;
  • it derives the table name (ContentTypeModel::tableName() pluralises it);
  • it names the default view and pivot tables.

Handles are persisted, so renaming one is a data migration.

Types register in one of two ways: listed in config/content-management.php under content_types and taxonomies (in the module’s config/), or from another module’s provider, as CatalogServiceProvider::boot() does:

app(ContentTypeRegistry::class)->register(Product::class);

CmsServiceProvider wires everything else from the registries, in $app->booting() and again in $app->booted() so a registration in any provider’s boot() is caught:

  • the morph map;
  • content and taxonomy relations ($post->postTags, $postTag->posts) through Model::resolveRelationUsing, with pivot names from Support\PivotConvention;
  • the reindex observers;
  • custom field support, a nav item type per type, and the ContentTypeRegistered and TaxonomyRegistered events.

A taxonomy whose contentTypeHandle() names an unregistered type throws at boot.

ContentTypeModel (abstract) implements ContentType, RegisteredType, HasAuthor, HasMedia, ...
Page Hierarchical
Post Classifiable
Product (catalog)
TaxonomyModel (abstract) implements Taxonomy, RegisteredType, ...
PostCategory HierarchicalTaxonomy
PostTag
ProductCategory (catalog)

Every registered type gets these from its base, with no opt-in:

Trait Gives
HasSiteDocument siteDocuments(), getUrl(), availableOnSites(), includedOnSites(), pathHistory()
HasSiteFieldOverrides Per-site values for display fields
HasSitePathOverrides One explicit URL per site
HasSluggableTitle Title (title on content, name on taxonomies) and a stable, unique-per-type slug
ProjectsToSiteDocument The observers’ dirty gate
HasCustomFields Custom fields
BelongsToAuthor Optional author(). Its table needs author_id
HasScopedMedia Spatie media with per-site overrides stamped on custom_properties->site_id
ParsesMergeTags Merge tags in projected text
SoftDeletes Trash rather than delete, since other domains reference these rows

TaxonomyModel also has HasSeoMetadata, as a term page always has SEO.

Opt-in traits, usually paired with a contract. Services and the admin check the contract or trait, so a capability is one use away. Most bundle their fillable, casts and a static migration helper, so the model and the schema share one source:

Trait Contract Columns (helper) On
HasContentStatus status (addContentStatusColumn) Page, Post, Product
HasPublishingSchedule published_at (addPublishingScheduleColumn) Post, Product
HasSeoMetadata SeoMetadata meta_title, meta_description, index_status, override_seo (addSeoMetadataColumns) Page, Post, Product
HasContentBlocks Statik’s HasContentBlocks, HasMediaAttributes content_blocks (addContentBlocksColumn) Page, Post, Product, ProductCategory
HasLayoutChoice HasLayout layout (addLayoutColumn) Page, Post
HasDescription description (addDescriptionColumn) Post, Product, PostCategory, ProductCategory
HasPageHeading page_heading (addPageHeadingColumn) ProductCategory
HasOverviewImageMedia HasOverviewImage None, uses media Page, Post, PostCategory, ProductCategory
IsHierarchical Hierarchical parent_id Page, PostCategory, ProductCategory
HasCanonicalTerm Classifiable None, per-site rows Post, Product
HasEntityWidgets None Page, Post, Product, PostCategory, ProductCategory

Whether something is hierarchical is only ever decided by instanceof Hierarchical. There is no predicate method that could disagree.

Filament\Support\RegisteredTypeResource assembles the form and table by inspecting the model. A concrete resource can be nearly empty:

class PageResource extends RegisteredTypeResource
{
protected static ?string $model = Page::class;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-document';
}

Adopting HasContentStatus adds a status field and filter, Hierarchical a parent select, HasContentBlocks the block editor, and so on. Every type also gets the per-site override editors, an Active Links and a Redirects relation manager. Classifiable content adds Primary Terms. The edit page’s status sidebar lists where the entry is live with preview buttons.

Pages are found by convention from the resource’s namespace: Pages\List<Plural>, Create<Model>, Edit<Model>. Each subclasses ListRegisteredType, CreateRegisteredType or EditRegisteredType, setting only $resource (ListPages and ListPosts extend Filament’s ListRecords with a create action instead, as they have no tree). A hierarchical taxonomy gets a drag-reorder tree above its table by adding a Widgets\<Model>TreeWidget subclass of HierarchicalTaxonomyTreeWidget.

A worked example with a hypothetical CaseStudy, in content-management. For a type in another module, put the same files there and register from its provider, as catalog does.

1. Migration. Use the model’s helpers. The registered type tests require author_id and custom_field_set_id on every registered type’s table.

Schema::create(CaseStudy::tableName(), function (Blueprint $table) {
$table->id();
CaseStudy::addSluggableTitleColumns($table);
CaseStudy::addContentStatusColumn($table);
CaseStudy::addContentBlocksColumn($table);
CaseStudy::addSeoMetadataColumns($table);
CaseStudy::addAuthorColumn($table);
$table->foreignId('custom_field_set_id')->nullable()
->constrained('custom_field_sets')->nullOnDelete();
$table->timestamps();
$table->softDeletes();
});

2. Model. The handle and projection map are required; add capabilities as needed.

class CaseStudy extends ContentTypeModel implements HasContentBlocksContract, HasMediaAttributes, SeoMetadata
{
use HasContentBlocks;
use HasContentStatus;
use HasSeoMetadata;
public static function contentTypeHandle(): string
{
return 'case_study';
}
public static function indexableFields(): array
{
return [
'resolved_title' => 'title',
'resolved_slug' => 'slug',
'resolved_meta_title' => 'meta_title',
'resolved_meta_description' => 'meta_description',
];
}
}

3. Register it in content_types in app-modules/content-management/config/content-management.php. The morph map, observers, nav item type, custom field support and the type’s place in the content rule, path and SEO screens all follow from that.

4. Policy. Add src/Policies/CaseStudyPolicy.php extending CapabilityPolicy, with protected string $capability = 'content.manage';. Without one, every staff role can use the resource. See authorization.

5. Resource. Filament/Resources/CaseStudyResource.php extending RegisteredTypeResource, plus CaseStudyResource/Pages/ListCaseStudies.php, CreateCaseStudy.php and EditCaseStudy.php. The module’s plugin discovers it.

6. View. themes/default/views/case_study.blade.php, receiving $caseStudy and $document. See themes.

7. Make it available. Nothing shows until a site has a content rule and rows are published. A path config is optional; the default is the slug at the root.

To give a type a listing rule (only in-stock products count, say), override applyListingConstraints() and list what it reads in listingConstraintColumns().

  1. A table, and a pivot named with PivotConvention::tableName() and PivotConvention::key().
  2. A model extending TaxonomyModel with taxonomyHandle(), contentTypeHandle() (what it classifies) and indexableFields(), plus implements HierarchicalTaxonomy and use IsHierarchical if it nests.
  3. Register it under taxonomies.

The relations appear in both directions. Optional contracts add pivot columns: ClassifiablesCanBeFeatured, ClassifiablesCanBeOrdered. TermsCanBeCanonical makes a taxonomy eligible for primary terms and term-based URLs, which suits categories but not tags.

ContentStatus is the global editorial state: Draft, Published, Archived. It is not per site.

Publish state is part of availability. SiteContentResolver only returns rows passing scopePubliclyVisible(): status published where the type has a status, and published_at null or past where it has a schedule. A type with neither is always visible. So an unpublished row has no route, is in no listing or menu and counts toward no term, on every site at once, and a draft parent collapses out of its children’s URLs on the site.

status and published_at are projection columns (publicVisibilityColumns()), so changing them reindexes the row and refreshes its terms. Nothing saves a row when its publish time arrives, so cms:publish-due runs every five minutes to catch those.

isContentIncluded() answers the rules half alone, ignoring status. Admin screens use it to set up a draft for the sites it will appear on, such as its primary terms and SEO preview.

Note that Post overrides scopePublished() to combine status and schedule; it wins over the trait’s version as a class method.

site_field_overrides holds sparse rows: one per (site, entity, field), written only where a site differs. Availability answers “should this appear”, and overrides answer “what does it say”. They share no table and no mechanism.

$page->setOverrideForSite($site, 'title', 'About our UK team');
$page->valueForSite('title', $site); // the override, else the column
$page->valueForCurrentSite('title'); // from the request's host; null off-site
$page->scoped_title; // the same, as a read-only accessor
$page->clearOverrideForSite($site, 'title');

overridableFields() is the whitelist; by default the indexableFields() sources (plus page_heading for a HasPageHeading model). Widen it for display-only fields.

  • Projected fields flow into site_documents.resolved_*, so they are safe to sort and filter by.
  • Others are read at render, for one entity at a time.

SiteFieldOverrideObserver reindexes the row. clearOverrideForSite() deletes instance by instance so the observer fires; a query-builder delete would leave the projection stale. In the admin, title, slug, page heading and description have an override link; see per-site wording.

Merge tags are parsed at projection for attributes in mergeTagAttributes(), which by default intersects content-management.merge_tag_attributes with the model’s fillable. {{this.title}} reads through overrides, {{site.name}} reads the site, and a bare {{name}} reads a global MergeTag row (MergeTagContextBuilder). Because this happens at index time, editing a global merge tag does not change stored rows until they are reindexed.

SEO is layered per field:

Tier Source Stored?
1. Row The entry’s meta_title, meta_description, index_status, only when override_seo is on Baked into resolved_meta_*
2. Template SeoTemplate for (site, source_type), holding merge-tag strings such as {{this.title}} | {{site.name}} Baked into resolved_meta_*
3. Render floor The live Site defaults, then the entry’s title, then no description, then Index Read at render by InitializeSiteDocumentSeo

Override is all or nothing, not a per-field fall through. With override_seo on, a blank row field stays blank and the template is not consulted; the floor fills it. With it off, the row’s values are ignored but kept, so the toggle loses nothing.

Seo\Services\SeoMetadataResolver::resolveDocumentLevel() handles tiers 1 and 2, and the indexer merges the result over the raw projection. Tier 3 stays live so that changing a site default does not restamp the catalog.

Editing a template rebuilds that (site, type) through SeoTemplateObserver. Templates are edited on the site’s SEO page (ManageSiteSeoTemplates), one per type, sparse. A site’s own index_status of NoIndex is a sitewide noindex at the floor. See SEO for the staff view.