Skip to content

Resources and pages

A panel shows two kinds of content: resources – lists, forms and detail pages of a model – and pages with content of their own.

public function resources(): array
{
return [InvoiceResource::class, CustomerResource::class, ...Access::resources()];
}

Every resource gets its routes (/admin/invoices, …/create, …/{uuid}, …/{uuid}/edit), a navigation entry and its permissions in the role editor. The slug of a resource must be unique within the panel.

The same resource class can be registered in several panels; it is instantiated per panel and builds its URLs from the panel. Code that needs “the resource of a model in this panel” asks the panel:

$panel = service('panels')->current();
$panel->resourceForModel(CustomerModel::class); // first resource of the model, or null
$panel->resourceByClass(CustomerResource::class); // InvalidArgumentException if not registered
$panel->resource('customers'); // by slug, or null

Selects, relationship columns and counts use resourceForModel() to apply the permissions and policies of the related records: registering a resource for a model makes its records follow the rules of that resource everywhere in the panel.

use Anvil\Access\Access;
public function resources(): array { return [...$own, ...Access::resources()]; }
public function pages(): array { return [Dashboard::class, ...Access::pages()]; }

Access::resources() adds the user and role administration (and API clients when the API is used), Access::pages() the inbox, the profile and the workflow settings. Leave them out in panels that should not offer them, e.g. a portal gets only Access::pages() for the profile and the inbox. Packages offer the same, e.g. Sync::resources() and Sync::pages().

public function pages(): array
{
return [Dashboard::class, ReportsPage::class];
}

Defaults to [Dashboard::class]. A page renders its own content inside the panel layout:

Terminal window
php spark make:anvil-page Reports --suffix # App\Pages\ReportsPage + app/Views/pages/reports.php
use Anvil\Page\Page;
final class ReportsPage extends Page
{
public function title(): string { return 'Reports'; }
public function subtitle(): ?string { return 'Revenue of the current year'; }
public function navigationIcon(): string { return 'bar-chart'; }
public function navigationGroup(): ?string { return 'Reports'; }
public function content(): string
{
return view('reports/index', ['totals' => model(InvoiceModel::class)->totals()]);
}
}
Method Default Purpose
title() – (required) heading, browser title and breadcrumb
content() – (required) HTML of the page body; escape every value
subtitle() null line below the heading
slug() class name in kebab case without Page (ReportsPage → reports) URI segment, /admin/reports
permission() <panel>.page.<slug> permission to open the page
breadcrumbs() [url() => title()] the way after the dashboard, url => label; pages with sub views add their levels, see below
url() the URL of the page

The navigation methods (navigationLabel(), navigationIcon(), navigationGroup(), navigationSort(), navigationBadge(), navigationBadgeColor(), shouldRegisterNavigation()) are described in Navigation.

A page can show one entry of its own list, e.g. the settings of one workflow at /admin/workflows?workflow=order-approval. Build it like the detail page of a record: the entry as title, the page as subtitle, and a breadcrumb with both levels.

public function title(): string
{
return $this->selected()?->title() ?? 'Workflows';
}
public function navigationLabel(): string
{
return 'Workflows'; // the navigation keeps the name of the page
}
public function subtitle(): ?string
{
return $this->selected() === null ? null : 'Workflows';
}
public function breadcrumbs(): array
{
$selected = $this->selected();
return $selected === null
? [$this->url() => 'Workflows']
: [$this->url() => 'Workflows', $this->url() . '?workflow=' . rawurlencode($selected->key()) => $selected->title()];
}

Check the query value against the entries the user may see; an unknown value shows the list. No own “back” links: the breadcrumb leads back.

The permission of a page is e.g. admin.page.reports; it is listed in the role editor. Users without it don’t see the navigation entry and get a “no access” page (403, audit event access.denied). Return null from permission() only for pages without sensitive data: every user of the panel may open them.

The content is rendered on every request: load data in content(), not in the constructor, and query through resources ($resource->query()) so that tenant and scopeQuery() apply.

Pages that need interactive parts – buttons that run code, forms, lists that reload – implement Anvil\Page\HasComponents:

use Anvil\Endpoint\Component;
use Anvil\Page\HasComponents;
final class SettingsPage extends Page implements HasComponents
{
public function component(string $kind): ?Component
{
return $kind === 'settings' ? new SettingsComponent() : null;
}
}

Components are addressed as <panel>:<page slug>:<kind> and go through the component endpoint with signed tokens and the permission of each action. The permission of the page applies to its components as well: a token issued before the permission was revoked opens nothing. Examples: Anvil\Access\ProfilePage, and Components for writing components.

Dashboard is the start page at /admin, see Dashboard. A panel without a dashboard has no start page; keep it in pages().