TabletopDOCS
Authoring

Pages

How a Page class maps to a route, how view() builds its tree with the S builder, how one page composes any number of forms, tables and display blocks, and how a CRUD resource becomes a family of pages.

Every screen in a Tabletop panel is one PHP class that extends Tbtop\Admin\Pages\Page. The class says where the page lives (path()) and what is on it (view()). Everything else — title, navigation entry, gate, breadcrumbs, shell layout — is an optional hook with a working default.

There is no resource class and no controller to write. A registered page gets a route, and its view() returns a tree of nodes that the React client renders.

A minimal page

namespace App\Admin\Pages;

use Tbtop\Admin\Dsl\Node;
use Tbtop\Admin\Dsl\S;
use Tbtop\Admin\Pages\Page;

class ReportsPage extends Page
{
    public static function path(): string
    {
        return 'reports';
    }

    public function view(S $s): Node
    {
        return $s->stack([
            $s->displayText('Reports')->variant('heading'),
            $s->displayText('Nothing to show yet.')->variant('muted'),
        ]);
    }
}

path() is a Laravel route URI relative to the panel prefix, so with a panel mounted at admin this page answers /admin/reports. The Artisan scaffold writes the same shape:

php artisan make:tbtop-page Reports

The command creates app/Admin/Pages/ReportsPage.php (it appends the Page suffix) with a nav() entry in the Main group. --path= overrides the URI, --group= sets the nav group, --no-nav writes a nav() that returns null and --force overwrites an existing file. It then reports whether a panel discovers that directory or whether you must add the class to pages() yourself. Registration and discovery are covered in Register a panel.

How a page runs

view() is not called once. The package rebuilds the page from a fresh view() on the initial GET and again on every request to one of the page's endpoints — a form submit, an action click, a table fetch, a select search. Closures such as a form's onSubmit are never serialized; the server finds them again by name in the rebuilt page.

Three consequences:

  • Names must be stable. $s->form('invoice', …), $s->table('invoices') and $s->action('delete') register under those names. Keep them unique per page — reusing a name replaces the earlier entry — and do not derive them from anything that changes between requests.
  • A form or action must still exist when it is used. If view() does not build the form on the submit request, the submit answers 404.
  • Keep view() cheap. It runs on every endpoint request, not just the first render.

Page classes are resolved from the Laravel container, so constructor injection works.

Page hooks

MethodDefaultUse it to
static path(): stringrequiredRoute URI, may contain parameters: 'invoices/{invoice}/edit'
view(S $s): NoderequiredReturn the page's node tree
title(): stringheadline of the class namePage heading
subtitle(): ?stringnullMuted line under the heading
headerActions(S $s): array[]Buttons beside the title
static nav(): ?arraynull (not in nav)Place the page in the menu — see Navigation
static can(): ?stringnullGate ability required to open the page
static middleware(PanelConfig $panel): ?arraynull (panel stack)Replace the auth middleware for this page
breadcrumbs(): array|Closure|nullnull (auto)Override the breadcrumb trail
layout(): string'admin''center' for a chrome-less, centered page
static slug(): stringkebab of the class nameRoute name of the page and its endpoints
static isDiscovered(): booltrueExclude the class from directory discovery

A heading, a subtitle and a header button together:

public function title(): string
{
    return 'Invoices';
}

public function subtitle(): ?string
{
    return 'Issued and draft invoices for all customers.';
}

public function headerActions(S $s): array
{
    return [
        $s->action('new')->label('New invoice')->color('primary')->visit('/admin/invoices/new'),
    ];
}

Authorization

can() names a Gate ability. It is checked with Gate::authorize() on the page itself and on every endpoint that belongs to it, so a user without the ability gets a 403 for the page, its forms and its actions alike. The same check removes the page from that user's navigation.

public static function can(): ?string
{
    return 'manage-invoices';
}

middleware() replaces the panel's auth layer for the page and its whole endpoint cluster. Returning ['web'] makes a page public inside an authenticated panel, which is how a login screen is built. Spread $panel->authStack() to add middleware instead of replacing it:

use Tbtop\Admin\Panels\PanelConfig;

public static function middleware(PanelConfig $panel): ?array
{
    return [...$panel->authStack(), 'verified'];
}

Pair a public page with a layout() that returns 'center' to drop the sidebar and header. Any value other than 'admin' or 'center' throws.

Route parameters

A parameter in path() arrives as a plain string — there is no route-model binding. Read it with request()->route() inside view(), and from $ctx->params inside a handler, where the server derives it from the URL:

public static function path(): string
{
    return 'invoices/{invoice}/edit';
}

public function view(S $s): Node
{
    $invoice = Invoice::findOrFail(request()->route('invoice'));
    // …
}

A findOrFail() miss in view() renders a 404 inside the panel chrome. Pages with a parameter in their path never appear in the navigation.

Each page's route is named tbtop.{panel id}.{slug}, so a link can be built with route('tbtop.admin.invoice-edit-page', ['invoice' => $id]) instead of a hard-coded string. Renaming the class changes the default slug; override slug() if other code depends on it.

By default the trail is the page's nav group followed by its title, with the group linked to the first parameter-free page in the same group. A page outside the nav, or in no group, gets its title only. Override breadcrumbs() to spell the trail out, typically for a create or edit page that sits under an index:

public function breadcrumbs(): array
{
    return [
        ['label' => 'Invoices', 'url' => '/admin/invoices'],
        ['label' => $this->title()],
    ];
}

The panel can switch breadcrumbs off with ->breadcrumbs(false).

Building the tree with S

view() receives an S instance. It is the factory for everything on the page:

  • Layoutstack, row, flex, grid, section, collapsible, aside, tabs.
  • DisplaydisplayText, displayAlert, displayValue, displayKeyValue, displayImage, displayHtml, markdown, displayDivider.
  • Dataform, table, action, stat, chart, list, liveRegion, actionsRow.
  • Fields$s->text('name'), $s->select('status') and the rest of the field kinds.

How each layout and display block behaves, how grid columns and columnSpan interact, and which options are validated is covered in Layout and content blocks. Every method and option is listed in the S builder reference.

Composing a page

A page is not limited to one form or one table. view() can hold any number of forms, tables, stats, charts and display blocks, in any order, nested in grids, sections and tabs. An index can show a second table under the first. An edit page can put the record's form next to tables of related rows.

A supplier page with two stats, the supplier's form and two tables of related rows:

use App\Models\Product;
use App\Models\PurchaseOrder;
use App\Models\Supplier;
use Tbtop\Admin\Actions\ActionCtx;
use Tbtop\Admin\Actions\Effects;
use Tbtop\Admin\Dsl\Actions\FormActions;
use Tbtop\Admin\Dsl\Color;
use Tbtop\Admin\Dsl\Column;
use Tbtop\Admin\Dsl\Node;
use Tbtop\Admin\Dsl\S;

public static function path(): string
{
    return 'suppliers/{supplier}';
}

public function view(S $s): Node
{
    $supplier = Supplier::findOrFail(request()->route('supplier'));

    return $s->stack([
        $s->grid(['cols' => ['sm' => 1, 'md' => 2]], [
            $s->stat('Open orders')->value(fn () => PurchaseOrder::query()
                ->where('supplier_id', $supplier->id)->whereNull('received_at')->count()),
            $s->stat('Products')->value(fn () => Product::query()
                ->where('supplier_id', $supplier->id)->count()),
        ]),

        $s->grid(['cols' => ['sm' => 1, 'lg' => 3], 'gap' => 6], [
            $s->section(['title' => 'Supplier', 'variant' => 'card'], [
                $s->form('supplier', [
                    $s->text('name')->label('Name')->required()->maxLength(200),
                    $s->text('email')->label('Email')->rules('nullable|email'),
                    $s->actionsRow([FormActions::save($s)]),
                ])
                    ->record($supplier->only(['name', 'email']))
                    ->onSubmit(function (ActionCtx $ctx) use ($supplier): Effects {
                        $supplier->update($ctx->form);

                        return Effects::make()->notify('Saved');
                    }),
            ]),
            $s->section(['title' => 'Products', 'variant' => 'card', 'colSpan' => ['lg' => 2]], [
                $s->table('products')
                    ->query(fn () => Product::query()->where('supplier_id', $supplier->id))
                    ->columns([
                        Column::make('sku')->label('SKU')->searchable(),
                        Column::make('name')->label('Name')->sortable()->searchable(),
                        Column::make('stock')->label('Stock')->number(0)->align('right'),
                    ])
                    ->defaultSort('name', 'asc'),
            ]),
        ]),

        $s->section(['title' => 'Purchase orders'], [
            $s->table('orders')
                ->query(fn () => PurchaseOrder::query()->where('supplier_id', $supplier->id))
                ->columns([
                    Column::make('number')->label('Number')->searchable(),
                    Column::make('ordered_on')->label('Ordered')->date('Y-m-d')->sortable(),
                    Column::make('total_cents')->label('Total')->money('USD')->align('right'),
                    Column::make('status')->badge(['open' => Color::Warning, 'received' => Color::Success]),
                ])
                ->defaultSort('ordered_on', 'desc'),
        ]),
    ]);
}

What keeps the pieces independent:

  • Each piece has its own name and endpoint. A form submits to {path}/forms/{name}, a table loads rows from {path}/tables/{name}, an action posts to {path}/actions/{name}. The server rebuilds view() and finds the piece by that name, so products and orders run their own queries, and submitting supplier validates only that form's fields.
  • Tables keep separate state. Sort, search, filters and page number are stored in the URL under the table's name (t[products][…], t[orders][…]), so two tables on one page do not overwrite each other, and a reload or a shared link restores both.
  • Effects can target another table. Effects::make()->refreshTable('orders') refetches that table from any action or form submit on the page. Without a name, refreshTable() refreshes the table the action sits in, or every table on the page when the action is outside a table.
  • Names are unique per kind. A second $s->table('orders') replaces the first, and the same holds for forms, actions, stats and charts.
  • Spans go on a wrapper. Forms and tables have no colSpan of their own. Put them in a section or stack and set colSpan/colStart on that, as the Products section does. See Grid columns and columnSpan.

Tabs work the same way: each tab of $s->tabs() can hold its own form or table.

See it in the demo: the posts index has two tables on one page, plus forms inside its create and edit actions, and the dashboard mixes stats, charts and display blocks.

CRUD as a family of pages

A resource is a few ordinary pages that share one field definition. For an Invoice:

Pagepath()What it holds
InvoicesIndexPageinvoicesA table with row and bulk actions and a "New" button. The only one in the nav.
InvoiceCreatePageinvoices/newA form whose onSubmit creates the record and redirects to its edit page
InvoiceEditPageinvoices/{invoice}/editThe same form, pre-filled, plus delete

Put the fields in a trait so create and edit cannot drift apart. Usually the only difference is the unique rule, which must ignore the record being edited:

trait InvoiceFormFields
{
    protected function invoiceFields(S $s, ?int $ignoreId = null): array
    {
        $number = $s->text('number')->label('Number')->required()->unique('invoices');

        return [
            $s->section(['title' => 'Invoice'], [
                $ignoreId === null ? $number : $number->ignore($ignoreId),
                $s->date('due_on')->label('Due on')->rules('nullable|date'),
                $s->textarea('notes')->label('Notes'),
            ]),
        ];
    }
}

The create page redirects by returning a string from onSubmit:

public function view(S $s): Node
{
    return $s->stack([
        $s->form('invoice', [
            ...$this->invoiceFields($s),
            FormActions::saveCancel($s, '/admin/invoices', saveLabel: 'Create'),
        ])
            ->record(['number' => '', 'due_on' => null, 'notes' => null])
            ->onSubmit(function (ActionCtx $ctx): string {
                $invoice = Invoice::create($ctx->form);

                return "/admin/invoices/{$invoice->id}/edit";
            }),
    ]);
}

The edit page pre-fills from the model, saves in place and offers a delete that leaves the page:

public function view(S $s): Node
{
    $invoice = Invoice::findOrFail(request()->route('invoice'));

    return $s->stack([
        $s->form('invoice', [
            ...$this->invoiceFields($s, $invoice->id),
            FormActions::saveCancel($s, '/admin/invoices', extra: [
                $s->action('delete')->label('Delete')->color('danger')
                    ->confirm('Delete invoice?', 'This cannot be undone.')
                    ->handle(function (ActionCtx $ctx): Effects {
                        Invoice::whereKey($ctx->params['invoice'])->delete();

                        return Effects::make()->notify('Invoice deleted')->redirect('/admin/invoices');
                    }),
            ]),
        ])
            ->record([
                'number' => $invoice->number,
                'due_on' => $invoice->due_on?->format('Y-m-d'),
                'notes' => $invoice->notes,
            ])
            ->onSubmit(function (ActionCtx $ctx): Effects {
                Invoice::findOrFail($ctx->params['invoice'])->update($ctx->form);

                return Effects::make()->notify('Saved');
            }),
    ]);
}

How the form, its record and its submit behave is covered in Forms; buttons and effects in Actions. For small records you can skip the separate pages: CreateAction and EditAction open the same kind of form in a modal from the index table.

See it in the demo: the posts index and create pages are built this way.

Gotchas

  • nav() returning null only hides the menu entry. The route stays registered and reachable. Use can() to restrict access.
  • Overlapping parameterized paths. invoices/{invoice} and invoices/new both match /admin/invoices/new; routes match in registration order, so list the static page first in pages(). With discovery, discovered pages register after any pages() entries, in class-name order; listing the static page in pages() works in a discovering panel too, because duplicates are removed. A panel that uses discovery rejects duplicate slugs and identical paths outright.
  • The page index hides new pages. Once php artisan tbtop:cache-pages has written bootstrap/cache/tbtop-pages.php, discovery reads that index instead of scanning, in any environment. A page added after that stays invisible (404, missing from the menu) until you rerun the command, then route:cache. A deleted page triggers a rescan automatically. See Deploy.
  • Imports. Node, S and Color live in Tbtop\Admin\Dsl; ActionCtx and Effects in Tbtop\Admin\Actions; FormActions in Tbtop\Admin\Dsl\Actions.

On this page