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.
The registry
Section titled “The registry”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
*_typecolumn; - 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) throughModel::resolveRelationUsing, with pivot names fromSupport\PivotConvention; - the reindex observers;
- custom field support, a nav item type per type, and the
ContentTypeRegisteredandTaxonomyRegisteredevents.
A taxonomy whose contentTypeHandle() names an unregistered type throws at boot.
Base classes
Section titled “Base classes”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.
Capabilities
Section titled “Capabilities”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.
The admin side
Section titled “The admin side”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.
Adding a content type
Section titled “Adding a content type”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().
Adding a taxonomy
Section titled “Adding a taxonomy”- A table, and a pivot named with
PivotConvention::tableName()andPivotConvention::key(). - A model extending
TaxonomyModelwithtaxonomyHandle(),contentTypeHandle()(what it classifies) andindexableFields(), plusimplements HierarchicalTaxonomyanduse IsHierarchicalif it nests. - 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.
Statuses and publishing
Section titled “Statuses and publishing”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.
Per-site field overrides
Section titled “Per-site field overrides”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 templates
Section titled “SEO templates”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.