Cogeze
Core

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.

php
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
php
'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#

php
'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.
php
'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:

php
'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():

blade
<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:

php
'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#

bash
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.