Pages
The page definition schema — routes, needs, argument binding, SEO, caching.
Public URLs are not declared in routes/web.php. A plugin declares intents through
pages(); the route_map table maps each intent to a URL that an administrator can change
without a deploy.
public function pages(): array
{
return [
'article.show' => [
'route' => ['pattern' => '/blog/:slug'],
'renderer' => 'blade',
'view' => 'storefront::article.show',
'needs' => [
'article' => [
'command' => 'article.findBySlug',
'args' => ['slug' => ':slug'],
'required' => true,
],
],
'seo' => 'ssr',
'seoFrom' => 'article.seo',
],
];
}
pages() must be a plain serialisable array — no closures. The installer reads it
before the plugin boots, to sync route_map and detect URL conflicts before installation.
Definition schema#
| Key | Type | Required | Default |
|---|---|---|---|
route.pattern |
string |
for routed pages | — |
route.locales |
array<string, string> |
no | [] |
renderer |
string |
no | 'blade' |
view |
string |
yes | — |
needs |
array<string, array> |
no | [] |
seo |
'ssr' |
no | — |
seoFrom |
string (dot path) |
no | 'seo' |
meta |
array |
no | [] |
priority |
int |
no | 100 |
route.pattern#
Express-style. :name becomes a route parameter.
| Pattern | Laravel URI |
|---|---|
/blog |
blog |
/blog/:slug |
blog/{slug} |
/:year/:month/:slug |
{year}/{month}/{slug} |
/ |
/ |
Omit route entirely for a page that is rendered but never routed to — see
Error pages.
priority#
Laravel matches routes in registration order. priority controls that order, lowest
first.
| Value | Use for | Example |
|---|---|---|
10 |
Root | / |
100 |
Literal segments | /blog, /blog/category/:slug |
200 |
Parameterised, may swallow siblings | /blog/:slug |
9000 |
Catch-all | /:url |
Getting this wrong produces no error. If /blog/:slug registers before
/blog/category/:slug, the category page silently disappears — category is matched as an
article slug.
Reserved segments#
A catch-all page would otherwise swallow /api, /up, /build, /storage and the
configured admin prefix. Two guards apply automatically:
- A regex constraint on the route, so reserved names never match a catch-all. Those URLs fall through to a real 404.
- An install-time error if a plugin declares a pattern starting with a reserved segment.
Only exact segment matches are blocked — /api-doc remains a valid page URL.
needs#
Each need is one Commander call. The result is passed to the view under the need's key.
| Key | Type | Default | Notes |
|---|---|---|---|
command |
string |
— | Required. |
args |
array |
[] |
See binding below. |
required |
bool |
false |
null result → 404. |
fallback |
mixed |
null |
Used when the command is unregistered, or on error for non-required needs. |
cache |
int |
0 |
TTL in seconds. 0 disables caching. |
dependsOn |
string[] |
[] |
Ordering hint for dependencies not expressed through args. |
Argument binding#
| Prefix | Source | Example |
|---|---|---|
: |
Route parameter | 'slug' => ':slug' |
? |
Query string | 'page' => '?page' |
@ |
Another need's result, dot path | 'category' => '@article.category.slug' |
| none | Literal | 'limit' => 12 |
'needs' => [
'article' => [
'command' => 'article.findBySlug',
'args' => ['slug' => ':slug'],
'required' => true,
],
'related' => [
'command' => 'article.related',
'fallback' => [],
'args' => [
'category' => '@article.category.slug',
'exclude' => ':slug',
],
],
],
Execution order is derived from @ references, then topologically sorted. Declaration order
does not matter. A cycle throws immediately — it is a declaration bug, not a data condition.
Referencing a need that does not exist binds null rather than failing the page.
required vs fallback#
This distinction decides the HTTP status, so it is worth stating precisely:
| Situation | Result |
|---|---|
required need returns null |
404 |
required need throws |
Exception propagates |
Optional need returns null |
Page renders, value is null |
| Optional need's command unregistered | Page renders with fallback |
| Optional need throws | Page renders with fallback, error logged |
When app.debug is true, every error propagates instead — a developer must see the real
failure.
A required need means this URL points at nothing. An optional need returning empty
means this block is empty. Conflating them either returns 404 for a page that exists, or
200 for one that does not.
Caching#
'categories' => ['command' => 'article.categories', 'cache' => 300, 'fallback' => []],
TTL only — there is no entity-based invalidation. The cache key covers the command name and its bound arguments, so two pages calling the same command with the same arguments share one entry.
Caching is off by default. Use it for data that rarely changes, such as category lists or menus. Do not use it for content listings: an editor who publishes an article expects to see it immediately.
SEO#
| Key | Purpose |
|---|---|
seo: 'ssr' |
Policy: this content must be server-rendered. Checked at install time. |
seoFrom |
Dot path to the SEO array inside resolved needs. |
meta |
Static fallback merged underneath. Resolved values win. |
'seoFrom' => 'article.seo',
'meta' => ['title' => 'Blog', 'description' => 'Latest posts.'],
A page declaring seo: 'ssr' whose renderer reports ssr() === false is rejected at
install time. A client-only renderer serves crawlers an empty shell, which degrades
rankings with no error anywhere.
The document shell emits <title>, description, og:*, canonical, robots and
hreflang.
Localised URLs#
One intent can serve several URLs, one per locale:
'route' => [
'pattern' => '/blog',
'locales' => ['en' => '/news', 'ja' => '/oshirase'],
],
Each locale becomes its own row in route_map and its own registered route, named
hub.{locale}.{intent}. Matching a localised route sets the application locale before
needs resolve, so commands return translated content.
In views, use hub_url() rather than route():
<a href="{{ hub_url('article.index') }}">Blog</a>
<a href="{{ hub_url('article.show', ['slug' => $a['slug']]) }}">{{ $a['name'] }}</a>
hub_url() resolves to the current locale's URL and falls back to the default. Using
route('hub.article.index') directly makes every link on a localised page point back to
the default locale.
hreflang alternates are emitted automatically for intents that have locale variants.
Error pages {#error-pages}#
An intent with no route is never routed to — it is rendered when the routing layer decides
the outcome:
'error.404' => [
'renderer' => 'blade',
'view' => 'storefront::error.404',
'meta' => ['title' => 'Not found', 'robots' => 'noindex,follow'],
],
It goes through the normal renderer and theme pipeline, so a mistyped URL still shows the
site's own chrome. Only browser requests to the storefront use it — /api/* returns the
JSON envelope and /admin/* is handled by the SPA.
Previewing without data#
php artisan hub:preview article.show --param=slug=x \
--data='{"article":{"name":"Example","content":"<p>Body</p>"}}'
Supplied keys replace the corresponding needs; the rest resolve normally. Useful for building a view before content exists, and for checking that a view is genuinely null-safe.