Cogeze
Core

Storefront

Building a storefront plugin — the Theme and Renderer contracts, views, sections, islands.

A storefront plugin owns every public URL and every view. Data plugins own tables, admin screens and commands — nothing else.

data plugin       tables + admin + gateway commands
storefront plugin URLs + views + theme, reads data through Commander

Swapping the entire look and URL structure of a site is enabling a different storefront plugin. No data plugin changes.

Why data plugins do not render#

An earlier version let each data plugin carry its own pages. Two symptoms appeared immediately: the plugin's views had to @include the storefront's icon partial, and they could not reference any specific theme, so they reached for a shared core partial instead.

Both say the same thing — the data plugin was rendering, and it has no business knowing whether the site has a header, uses Tailwind, or what an article card looks like.

Minimum plugin#

php
namespace Plugins\StorefrontS3;

use App\Plugins\AbstractPlugin;
use App\Plugins\PluginManager;
use App\RouterHub\Support\Hub;
use Illuminate\Support\Facades\View;

class StorefrontS3Plugin extends AbstractPlugin
{
    public function key(): string
    {
        return 'storefront-s3';
    }

    public function name(): string
    {
        return 'S3 Storefront';
    }

    public function category(): string
    {
        return 'storefront';
    }

    /** Every data plugin is optional: a missing one empties a block, it does not break. */
    public function optionalDependencies(): array
    {
        return ['page' => '*', 'article' => '*', 'ecommerce' => '*'];
    }

    public function pages(): array
    {
        return [
            'article.index' => [
                'route'    => ['pattern' => '/blog'],
                'renderer' => 'blade',
                'view'     => 'storefront::article.index',
                'needs'    => [
                    'articles' => ['command' => 'article.list', 'args' => ['page' => '?page'], 'fallback' => []],
                ],
                'meta' => ['title' => 'Blog'],
                'seo'  => 'ssr',
            ],
        ];
    }

    public function boot(PluginManager $manager): void
    {
        parent::boot($manager);

        View::addNamespace('storefront', __DIR__.'/resources/views');
        app(Hub::class)->setTheme(new S3Theme);
    }
}

See Pages for the full pages() schema.

Views consume DTOs#

Commands return arrays, so views index arrays. Every access must be null-safe: hub:preview renders pages with partial fake data, and a data plugin may be disabled.

blade
{{-- storefront::article.index --}}
@php
    $items = $articles['items'] ?? [];
@endphp

@if (empty($items))
    <p class="empty">No posts yet.</p>
@else
    <div class="grid">
        @foreach ($items as $a)
            <a href="{{ hub_url('article.show', ['slug' => $a['slug'] ?? '']) }}">
                <h3>{{ $a['name'] ?? '' }}</h3>
                @if (!empty($a['summary'] ?? null))
                    <p>{{ $a['summary'] }}</p>
                @endif
            </a>
        @endforeach
    </div>
@endif

Use hub_url(), never route() — see Localised URLs.

Theme contract#

The theme supplies chrome and assets. The Hub supplies the document shell.

php
namespace App\RouterHub\Contracts;

interface Theme
{
    /** Wrap rendered content in site chrome. $data: ['content' => string, 'seo' => array]. */
    public function layout(array $data): string;

    /** CSS/JS URLs this theme needs. */
    public function assets(): array;

    /** Map of view name → replacement view name. */
    public function overrides(): array;
}
php
class S3Theme implements Theme
{
    public function layout(array $data): string
    {
        return View::make('storefront::layout', $data)->render();
    }

    public function assets(): array
    {
        return ViteAssets::urls('resources/js/public/main.ts');
    }

    public function overrides(): array
    {
        return [];
    }
}

layout() returns the <body> contents. <html>, <head>, meta tags and asset links are produced by the Hub — a theme cannot forget the canonical tag or get <html lang> wrong.

Renderer contract#

php
interface Renderer
{
    /** Identifier used by a page's `renderer` key. */
    public function key(): string;

    /** Render a view with resolved needs. */
    public function render(string $view, array $data): string;

    /** Whether this runtime produces HTML on the server. */
    public function ssr(): bool;

    /** Asset URLs this runtime needs. */
    public function assets(): array;
}

ssr() is checked at install time against a page's seo policy. A renderer that only mounts on the client cannot serve a page declaring seo: 'ssr'.

blade ships with the core. Register additional renderers in boot():

php
app(Hub::class)->registerRenderer(new AlpineRenderer);

Sections#

The page plugin is the shared section store. Any intent can pull sections by key — including pages the page plugin does not serve:

php
'sections' => ['command' => 'page.sections', 'args' => ['key' => 'blog'], 'fallback' => []],
blade
@foreach (($sections ?? []) as $section)
    @php
        $type = $section['type'] ?? '';
    @endphp
    @if ($type && view()->exists('storefront::sections.'.$type))
        @include('storefront::sections.'.$type, ['data' => $section['data'] ?? []])
    @endif
@endforeach

Editors arrange blocks with the existing page builder. No second builder is needed. If no record matches the key, the block list is empty and the page still renders.

Wrap each section in a try/catch so one broken block does not take down the page, and rethrow when app.debug is on.

Islands#

Content that must be indexed is server-rendered. Interactive parts mount into an element that already contains its SSR content:

blade
<div data-island="plan-subscribe"
     data-props='@json(['slug' => $plan['slug'], 'label' => 'Subscribe'])'></div>
tsx
// resources/js/public/islands.tsx
export const islands = {
  'plan-subscribe': () => import('./islands/PlanSubscribe'),
};

The mount script must be loaded as <script type="module">. Vite emits ES modules; a classic script tag silently does nothing — the island never mounts and no error appears. The Hub's document shell handles this.

Fallback when no storefront is enabled#

If no plugin claims /, the core serves a minimal page with HTTP 503. Missing configuration is not content, and a site should not disappear silently when a storefront is disabled.