Your application's data
This document explains how to display rows from your application's own Eloquent tables on pages whose surrounding copy an editor still controls in the CMS. It describes the three supported mechanisms (a layout that queries, a block that queries, and a route resolver), what each one gives the editor, how to supply header navigation for a section the CMS does not own, how to invalidate the page cache when your data changes, and the known limitations. It is written for the developer who builds a product catalog, a directory or a similar data-driven section on a site that runs this package.
Application data and content types
A product catalog is the standard example of data that should stay in your application's own table rather than be stored as CMS content. Its price must be sortable and filterable in SQL. Its stock level changes through ordinary application writes rather than through an editorial publishing action. Its rows are owned by your application.
A CMS content type stores all of a record's data inside a JSON blob attached to an immutable version. Two consequences follow directly:
- A value inside that blob cannot be indexed, so it cannot be sorted or filtered efficiently in the database.
- Every change writes a new version, so a stock decrement would create a permanent entry in an editorial version history.
The recommended approach is therefore to keep the data in a normal Eloquent table and render it onto CMS pages. The sections below describe the three supported mechanisms for doing that.
Choosing a mechanism
The three mechanisms differ in how much control the editor retains over where the data appears on the page.
| Mechanism | What the editor controls | Appropriate when |
|---|---|---|
| A layout that queries | The copy above and below the data, block by block. The editor cannot move the data itself. | A small, fixed number of pages display the data in a fixed arrangement, such as a storefront index, a team directory or a price list. |
| A block that queries | The position of the data among the other blocks, how many times it appears, and its settings. | The data is one element among several on a page, and the editor should be able to reposition it or place it more than once. |
| A route resolver | Nothing on the generated pages, except regions shared across the whole layout. | Your application owns an entire subtree of URLs, such as /products/*, and the pages are generated from data rather than authored. |
The mechanisms can be combined. A production storefront commonly uses a layout that queries for its index page and a route resolver for its detail pages.
Mechanism A: a layout that queries
A layout that queries is a Blade template that runs its own Eloquent query and renders the results between two editor-controlled regions.
A complete working example ships in the host application at resources/views/vendor/cms/layouts/products.blade.php. It renders App\Models\Product between two regions. Read that file alongside this section.
Building a layout that queries
Create the template at
resources/views/vendor/cms/layouts/<name>.blade.phpin your application.LayoutRegistryscans that directory after scanning its own, so creating the file registers the layout. No configuration entry and no service provider call is required.LayoutRegistry::discover()usesFinder::depth(0), so the file must sit directly in that directory. A template in a subdirectory is never found.- At the top of the template, run the query:
@php
$products = \App\Models\Product::storefront();
@endphp
Call a named scope or a static method rather than writing the query inline. The definition of which products the storefront shows then lives on the model, where it can be unit tested without rendering a page.
- In the template, place the editor regions with the
<x-cms::region>component. The admin panel finds a layout's regions by reading these tags from the template, so adding the tag is the complete procedure for adding a region.
<x-cms::region name="main" label="Above the products" />
{{-- Your query results are rendered here. --}}
<x-cms::region name="after-products" label="Below the products" />
The label attribute is never rendered on the page. The panel reads it so that the editor sees "Above the products" rather than the region key.
- In the same template, name the first region
main. Every layout in the package usesmainfor its principal region, and a block saved without a region falls back to it. If you use a different name, a page moved onto your layout appears to have lost its existing blocks, because those blocks are still stored inmainand your layout does not rendermain. - On your own markup, apply the
cms-blockclass:
<section class="cms-block cms-block--products" aria-label="{{ __('Products') }}">
The cms-block class applies the same maximum width, horizontal gutters and vertical spacing that the editor's blocks use. Without it, your markup does not align with the blocks above and below it.
- In the same template, render nothing when the query returns no rows. A page on this layout with no matching rows should display its two regions and no empty grid.
- On your model, invalidate the page cache. This step is required. The reason and the code are in Cache invalidation below.
Variables available inside a layout
The frontend controller shares the following variables into every layout. A template can read them directly; nothing needs to be passed as an attribute.
| Variable | Contents |
|---|---|
| $cmsContent | The Content record being rendered. |
| $cmsVersion | The version being rendered. The draft banner reads it to determine whether the page is a draft. |
| $cmsAttributes | The version's content attributes, including layout. |
| $seo | The version's SEO fields, for <x-cms::head>. |
| $fallbackTitle | The page title, used when the SEO title is empty. |
Serving several pages from one layout
$cmsContent allows a single layout to serve many pages. A category page can be an ordinary CMS page whose layout filters the query by that page's own slug.
Define the filter as a scope or a static method so that the database performs it:
// App\Models\Product
public static function inCategory(string $slug): Collection
{
return static::query()
->active()
->where('category_slug', $slug)
->orderBy('sort_order')
->orderBy('name')
->get();
}
@php
$products = \App\Models\Product::inCategory($cmsContent->slug);
@endphp
Do not call storefront() and then filter its result in the template. storefront() returns an Eloquent Collection rather than a query builder, so filtering it in PHP loads every product from the database and discards the products outside the category.
With this arrangement, each category is a page that an editor can create, write blocks on, give its own SEO metadata, and publish, while the product grid inside it is generated from the table.
Page titles in a layout that queries
Do not render the page title in the layout. The top region is where an editor places a hero block or a page heading block. A title rendered by the layout above that region cannot be moved, restyled or removed by the editor.
Mechanism B: a block that queries
Use a block when the data is one element among several on a page rather than the purpose of the page. An editor can position a block anywhere in the block list, reorder it, and place it more than once with different settings.
The query belongs in withView(), which runs at render time:
public function withView(array $data): array
{
return [...$data, 'products' => Product::query()
->active()
->when($data['category'] ?? null, fn ($q, $c) => $q->where('category_slug', $c))
->limit((int) ($data['limit'] ?? 6))
->get()];
}
The block's schema() method defines the settings the editor sees, such as which category to show and how many rows to display. withView() converts those settings into records. Resolve identifiers into objects in withView() rather than in the Blade template.
A block has two obligations that a layout does not:
- Implement
mediaIdsFrom()if the records reference images from the media library. The page cache uses the IDs it returns to invalidate the cached pages that display an image when that image is replaced. - Implement
isCacheable()returningfalseif the block's output differs between visitors. A block that returnsfalseprevents every page it appears on from being cached, so returnfalseonly when the output does differ between visitors, not as a precaution.
The full block contract is documented in Authoring blocks.
Mechanism C: a route resolver
A route resolver claims a set of URLs for your application. A URL claimed by a resolver has no CMS page behind it.
Registration, resolver priority and the RequestContext object are documented in Integrating another plugin. This section describes what a resolver-owned page does not receive, which determines whether a resolver is the right mechanism.
What a resolver-owned page does not receive
Resolution::external($view, $data) renders the named view and shares nothing into it. The branch of CmsFrontendController that handles it is two lines:
if ($resolution->isExternal()) {
return view($resolution->view, $resolution->data);
}
None of cmsBlocks, cmsContent, cmsVersion or cmsLayout is shared, because there is no resolved page and no version to share. The consequences on a resolver-owned page are:
<x-cms::region name="main" />renders its wrapper element and no contents:<div class="cms-region" data-cms-region="main"></div>. No page was resolved, so there are no page blocks to render.- The inline editor has no page blocks to address, so no block on the page has a pencil or a drag handle. Blocks in a shared region are the exception: they belong to a layout area rather than to the page, and remain editable in place. Their editing controls are rendered in a different color to indicate that an edit affects every page using the layout.
- Your view must construct the SEO tags. The CMS has no version to read them from.
The draft banner on a resolver-owned page
Include <x-cms::draft-banner /> with no arguments on a resolver-owned page. It has no page version to report, but it does report an unpublished shared region, which on a resolver-owned page is the only CMS content there is to publish. Without the banner, an editor can save a shared-region draft and receive no indication that it is unpublished.
Rendering shared regions on a resolver-owned page
A shared region belongs to a layout rather than to a page, so it does not require a resolved page. It requires only the layout's name, which the view can share itself:
@php
\Illuminate\Support\Facades\View::share('cmsLayout', 'cms-layout::products');
@endphp
<x-cms::region name="promo" shared />
Without the shared cmsLayout value, that tag renders an empty wrapper. With it set, the tag renders the layout area's blocks. LayoutAreas::blocksFor() returns an empty array for a layout name it does not recognize, so a misspelled name produces an empty region rather than an error.
With this in place, every resolver-owned page shows the same promotional strip, navigation block or footer content as the rest of the site, edited in one place. It does not give the page blocks of its own, because there is no page record for those blocks to belong to.
Building a complete product section
A product section with categories, subcategories, a listing page and a detail page uses all three mechanisms. There are two supported arrangements. The choice between them depends on whether an editor will create a CMS page for each category.
Arrangement 1: categories as CMS pages
| URL | Mechanism | Editor receives |
|---|---|---|
| /products | Layout that queries, on a CMS page | Blocks above and below the grid |
| /products/tools | The same layout on a child page, filtered by $cmsContent->slug | Blocks above and below, specific to that category |
| /products/tools/hand-tools | The same, one level deeper | The same |
| /products/tools/hand-tools/anvil | Route resolver | Shared regions only |
Categories and subcategories are ordinary CMS pages. They are created, nested, reordered, translated, scheduled and published in the same way as any other page, and each one can carry copy written specifically for it. Only the leaf detail pages, which may number in the thousands, are generated.
A category implemented as a CMS page requires somebody to create that page whenever a category is added. A category served by a resolver appears without any editorial action but can never contain copy written for it.
Arrangement 2: categories in your own table
Arrangement 1 requires a CMS page for every category. That is not practical when the category tree lives in your own table, is of arbitrary depth, and is maintained by merchandisers rather than editors.
In that case, a single authored page marks the boundary of the section and a resolver serves every URL below it:
| URL | Mechanism | Editor receives |
|---|---|---|
| /products | Layout that queries the top level, on a CMS page | Per-page blocks, SEO, drafts, inline editing |
| /products/apparel | Resolver, category template | Shared regions |
| /products/apparel/tops | Resolver, product listing template | Shared regions |
| /products/apparel/tops/hoodie | Resolver, detail template | Shared regions |
This arrangement uses three templates, one for each page shape, and each template declares its regions as shared. An editor fills a shared region once against the layout, and it appears on every page that uses that layout. Shared regions are the only editorial control the generated pages have. They are sufficient for content such as a shipping notice or a sizing guide.
/products is kept as a CMS page backed by one Content record and one published version. As a result it has page blocks, SEO metadata, draft support, inline editing, a sitemap entry, and a correctly computed page cache key. The generated pages below it have none of these.
Supplying navigation for the section
In Arrangement 2, /products is the only page in the section that is CMS content, so it is the only page an editor can point a mega panel or a dropdown menu item at on the Menus screen. Setting that item's Contents to "Built from a page's children" (see "Deriving contents from a page" in the package's docs/authoring.md) produces nothing, because the categories are rows in your own table rather than Content records, and the page tree below /products is empty.
Mcg\FilamentCms\Contracts\ProvidesNavigation is a second, optional interface that a RouteResolver can also implement to supply those contents. A resolver that already serves a section's URLs through resolve() implements one more method, which returns the items that belong under the page marking the section's boundary:
public function navigationItems(Content $source, int $depth, ?string $locale = null): ?array;
The arguments are:
| Argument | Contents |
|---|---|
| $source | The Content record the menu item points at; /products in this example. |
| $depth | 2 when the menu item opens as a panel of columns, 1 when it opens as a dropdown of links. It matches MenuItem::derivedDepth(). |
| $locale | The content locale being served. DerivedMenuItems passes Locales::current() when its own caller names no locale. |
Return values
null and [] have different meanings:
| Return value | Meaning | Result |
|---|---|---|
| null | The resolver does not own this source. | The CMS asks the next lower-priority resolver that implements ProvidesNavigation, then falls through to the page tree. |
| [] | The resolver owns this source and there is nothing to show. | The menu item renders as a plain link rather than as a disclosure that opens onto an empty panel. |
| A list of items | The resolver owns this source. | The items are used as the menu item's contents. |
A category table that is temporarily empty should return []. Returning null in that case hands the panel to the page tree instead of showing nothing.
Order of evaluation
The ProvidesNavigation implementations in the resolver chain are asked before the page tree, not as a fallback when the page tree is empty. DerivedMenuItems::suppliedFor() walks ResolverChain::resolvers() in order, and the first resolver that returns a non-null value supplies the contents. The CMS then does not query Content for the source's children at all.
If resolvers were asked only when the page tree was empty, publishing one ordinary CMS page under /products (a returns policy page, for example) would switch the panel from every category in your table to that single page, and nothing on screen would explain why the panel had changed. Because resolvers are asked first, a resolver that claims a source controls that source's panel regardless of what CMS pages exist below it.
The resolver chain is asked per column as well as per source. A section that a resolver owns can appear as one column of a higher-level panel, for example /products among the home page's own children. The resolver chain is consulted again for that column, at depth 1, so the column's links come from the resolver rather than rendering empty.
Item keys
Each item is an array with these keys:
| Key | Required | Contents |
|---|---|---|
| label | Yes | The link text. |
| url | Yes | The link target. |
| description | No | Text drawn with the link inside a mega panel (cms-mega__description). |
| image | No | A Media model or a media ID. |
| children | No | A list of items with the same keys, used at depth 2. |
The package fills every other key the navigation template reads (path, is_link, new_tab, classes, display and overflow). The provider's array is host code rendered into the header of every page on the site, and a key the template reads and does not find would break the header on every page.
An item missing label or url is skipped rather than rendered. The skip is logged as a warning once per provider class per request, not once per item.
A media ID in image is resolved through the same batched MediaResolver that the CMS's own page-tree path uses, so a provider returning twenty category images costs one query, not twenty.
Caps on supplied items
The caps in the navigation.derived group of config/cms.php apply to supplied items in the same way as to items drawn from the page tree:
| Key | Shipped value | Applies to |
|---|---|---|
| max_columns | 6 | Columns in a panel (depth 2) |
| max_links_per_column | 6 | Links in one column of a panel |
| max_dropdown_links | 12 | Links in a dropdown (depth 1) |
When the items exceed a cap, the contents end with an "All label" link to the source page (the cms::cms.navigation.all string, All :label), so a category table larger than the cap remains reachable rather than being truncated with no link to the remaining categories.
Invalidating the header when your data changes
The application is responsible for invalidating cached pages when the data behind navigationItems() changes. NavigationBuilder::affectsNavigation() decides whether saving a Content record should flush the pages that render navigation, and it decides by inspecting that Content row: its type, whether its navigation visibility changed, and similar fields. A change to a row in your own category table is not a Content save, so the CMS receives no event for it.
Mcg\FilamentCms\Contracts\PageCache declares flush(): void. Call it from the model whose rows feed navigationItems(), in the same way that Cache invalidation below does for product data:
protected static function booted(): void
{
$forget = fn () => app(PageCache::class)->flush();
static::saved($forget);
static::deleted($forget);
}
Call flush(), which clears the whole page cache, rather than forgetContent() for one page. The supplied navigation renders in the header of every page on the site, including pages the category change did not touch. Forgetting one page would leave every other cached page showing the old panel.
Worked example
A resolver implementing both interfaces claims a path and supplies the navigation for it:
use Mcg\FilamentCms\Contracts\ProvidesNavigation;
use Mcg\FilamentCms\Contracts\RouteResolver;
use Mcg\FilamentCms\Models\Content;
use Mcg\FilamentCms\Support\RequestContext;
use Mcg\FilamentCms\Support\Resolution;
class CatalogResolver implements ProvidesNavigation, RouteResolver
{
public function resolve(string $path, RequestContext $ctx): ?Resolution
{
// Resolve /products/{category}/{product} against the host's own
// category and product tables, as in Mechanism C above.
}
public function priority(): int
{
return 150;
}
public function navigationItems(Content $source, int $depth, ?string $locale = null): ?array
{
if ($source->path !== 'products') {
return null;
}
$categories = Category::query()
->whereNull('parent_id')
->with('children')
->orderBy('sort_order')
->get();
if ($categories->isEmpty()) {
return [];
}
return $categories->map(fn (Category $category): array => [
'label' => $category->name,
'url' => 'products/'.$category->slug,
'description' => $category->description,
'children' => $depth >= 2
? $category->children->map(fn (Category $child): array => [
'label' => $child->name,
'url' => 'products/'.$category->slug.'/'.$child->slug,
])->all()
: [],
])->all();
}
}
Without with('children'), reading $category->children inside the map() callback issues one query per category, because an unloaded HasMany relation lazy-loads on first access. navigationItems() runs on every page-cache miss that renders this menu item's header, so a per-category query here is repeated on each of those misses. The eager load keeps the method at two queries regardless of how many categories or children exist: one for the top-level categories, and one IN query against parent_id for the children of all of them. A host that needs a different ordering or a different filter at each level can instead run a second, explicit query with whereIn('parent_id', $categories->modelKeys()) and group the result in memory by parent_id. That keeps the same two-query count at the cost of a longer method.
The check of $source->path at the top of navigationItems() is the same guard a RouteResolver applies to incoming requests in resolve(), applied here to the one Content record the menu item names. The resolver answers only for the page it owns and returns null for every other source a menu might derive from.
Serving several URLs from one Content record
Do not extend Arrangement 2 downward by having the resolver return Resolution::content() with a single shared page record so that every category page gains editor blocks. The rendered-page cache key is:
prefix : site_id : locale : content_id : published_version_id : query
The key contains no path. Every URL that resolves to the same Content record therefore produces an identical cache key, and the page rendered for the first anonymous request is served for all of those URLs. The failure is deterministic. It does not appear during development because CachesCmsPages bypasses the cache for authenticated requests, so it first appears in production as visitors being served the wrong page.
Binding a custom PageCache implementation does not solve this. put() receives the cache key already computed and is never given the Request, so an implementation cannot distinguish the URLs.
Resolution::external() avoids the problem. CachesCmsPages skips external resolutions, so those pages are not cached by the CMS, and caching them is your application's responsibility.
Known limitations
A resolver-owned URL cannot carry per-page editor blocks
There is no mechanism for attaching a Content record to a path owned by your resolver and having the region components find it. If a product detail page requires copy written specifically for that product, there are two options:
- A CMS page per product. This reintroduces the problems described in Application data and content types.
- A text column on your own table, edited through your own Filament resource.
A layout used only by a resolver is still offered to editors
LayoutRegistry has no concept of a layout that is registered but not selectable. The layout must be registered, because cms:layout-areas creates shared-region records only for registered layouts and LayoutArea::isCreatable() returns false, so there is no other supported way to create those records.
Restrict the layout on the content type instead. This is the only mechanism the package provides:
class Page extends \Mcg\FilamentCms\ContentTypes\Page
{
public function availableLayouts(): ?array
{
$layouts = app(LayoutRegistry::class)->all();
unset($layouts['cms-layout::catalog-detail']);
return $layouts;
}
}
Register this class under the page key and it replaces the built-in content type. Read the registry and remove the specific entries rather than listing the permitted layouts by hand. A hand-written list omits any layout added later, and that layout is then missing from the picker with no error to indicate why.
Declaring @props in a layout hides undeclared variables
A layout that serves both a CMS page and a resolver needs props. As soon as a Blade template declares any @props, Blade stops extracting undeclared attributes into the template scope. The $seo and $fallbackTitle variables that the CMS passes then become undefined, and every page renders with the site's fallback title and no meta description. Declare seo and fallbackTitle in @props alongside your own props.
Cache invalidation
Invalidate the cache from your model:
protected static function booted(): void
{
$forget = fn () => app(PageCache::class)->flush();
static::saved($forget);
static::deleted($forget);
}
This example calls flush() rather than forgetContent() because a product is not associated with one page. Any page using the products layout renders the catalog, and the set of such pages cannot be determined from the model. If your data does map to a known set of pages, invalidate those pages specifically instead.
The complete PageCache contract, and instructions for binding your own implementation, are documented in Caching.
Practices to avoid
- Do not model the data as a content type in order to get pages without writing a layout. Prices stop being sortable, every stock change writes an immutable version, and every change to the data has to go through the draft and publish workflow. The reasoning is recorded under "E-commerce: a second package" in the roadmap.
- Do not query inside a Blade loop. A layout runs one query per request; a query inside
@foreachruns one per row. Eager-load the relationship in the scope instead. - Do not reference the
Contentmodel from your own models. Relate by identifier if a relationship is required at all. - Do not omit cache invalidation on the grounds that the pages appear correct in development. The page cache is usually disabled there.
Related documentation
- Layouts and regions: layouts, regions, and the shared-region model.
- Authoring blocks: the complete block contract.
- Integrating another plugin: resolvers, external references and events.
- Caching: what is cached and how it is invalidated.