⌘K

Panels

Routing

Turning declared pages into real URLs — the macro, the URL shape, per-resource middleware, several zones over one set of resources, and the config path.

On this page

The registry holds no URL shell and no route: routes stay the application's, in its own group, with its own prefix and middleware. What the framework removes is the repetition — four Route::get() lines per resource, and a hand-written key→URL map beside them for the menu.

How It Works

A resource says which pages render it — and so may anything else the application registered, a dashboard included: the router reads the same catalogue the menu does, so being routable is a matter of declaring pages rather than of being a particular kind of thing.

use NyonCode\WireCore\Foundation\Routing\Contracts\ProvidesPages;
use NyonCode\WireCore\Foundation\Routing\RoutePage;
 
public static function pages(): array
{
return [
'index' => ListOrders::class,
'create' => CreateOrder::class,
'view' => ViewOrder::class,
'edit' => RoutePage::make(EditOrder::class)->permission('orders.update'),
];
}

and the application registers them inside its own group:

// routes/web.php
Route::prefix('admin')
->middleware(['auth', 'verified'])
->domain(config('app.admin_domain'))
->group(function () {
Route::wireResources();
Route::wireResource(OrderResource::class); // or one at a time
});

The prefix, the middleware and the domain are yours — these are ordinary Laravel routes registered in the group you called the macro in. A resource that declares no pages is skipped, which is how an internal or nested resource stays unrouted; naming one explicitly that declares none throws instead, because that is a mistake rather than a choice.

The URL shape

Page kind URL Route name
index {prefix} wire.{key}.index
create {prefix}/create wire.{key}.create
view {prefix}/{record} wire.{key}.view
edit {prefix}/{record}/edit wire.{key}.edit
anything else {prefix}/{kind} wire.{key}.{kind}

{prefix} is the registered key, so the menu key and the URL agree without either being repeated. {record} is a key, not a bound model: the pages resolve their own record, which is what keeps a soft-delete scope, a tenant guard or a non-Eloquent source the page's decision rather than the router's.

A page kind the router does not know sits at its own name, and may say otherwise with uri(). That URI is also what decides whether the page is about one record: a {record} in it makes the route take one — and makes the page a tab in the record's sub-navigation, whatever it is called.

'history' => RoutePage::make(OrderHistory::class)->uri('{record}/history'), // a record page
'archive' => RoutePage::make(ArchivedOrders::class), // a list page

A page never takes a path, and never loses one. Laravel keys routes by method and URI, so a second route at a path does not shadow the first — it replaces it, name and all, without a word. A page is where its menu entry points, so neither side may do that: wireResources() refuses a page whose path a route already answers, and a route registered over a page further down the route file is refused once every route is loaded — at boot, and so at route:cache too, not on the first click. The same page routed twice over itself changes nothing and passes.

A nested resource sits under one record of its parent — {parent prefix}/{parent}/{its prefix} — and inherits the parent's middleware and domain, so what guards an order guards its lines:

Page kind URL Route name
index orders/{parent}/order-lines wire.order-lines.index
edit orders/{parent}/order-lines/{record}/edit wire.order-lines.edit

Authorization, middleware and domains

RoutePage::permission() lands on the route as Laravel's own can: middleware. Nothing here re-implements an authorization check — Gate answers it, exactly as it does for actions, columns and widgets, so spatie/laravel-permission and nyoncode/laravel-permission-extended keep working unchanged. A refusal happens in the router, before the page renders or a query runs.

Per resource, ConfiguresRoutes adds the three things that belong to one resource rather than to the whole group:

public static function routeMiddleware(): array { return ['can:tenants.view']; }
public static function routeDomain(): ?string { return '{tenant}.example.com'; }
public static function routePrefix(): ?string { return 'billing/tenants'; }

The domain parameter reaches your TenantResolver like any other route parameter. Tenancy itself stays where it is — a global scope over every query, not a routing concern; see Authorization.

Zones

Several mount points over one set of resources — admin, business, production. A resource may be in one of them, in several, or in all: a zone multiplies where a page is reachable, never how many times it is registered.

A zone is the name of the route group, and nothing else:

Route::name('admin.')->prefix('admin')->middleware(['web','auth','can:admin'])
->group(fn () => Route::wireResources());
 
Route::name('business.')->prefix('business')->middleware(['web','auth','can:business'])
->group(fn () => Route::wireResources(only: ['orders']));
admin.wire.orders.index → admin/orders
business.wire.orders.index → business/orders

The name() call is what keeps them apart. Omit it on the second group and both zones register wire.orders.index, where the later one silently wins every lookup — which is why the config path below is the safer way to declare zones: there the zone is an array key and cannot be forgotten.

Which resources a zone contains is only / except, and nothing else — there is no second list to keep in step with the routes.

Linking inside a zone. Every URL question is "where is this key in this zone", so the zone travels with it:

ResourceRoutes::urlFor('orders', zone: 'business'); // /business/orders
ResourceRoutes::urls(zone: 'business'); // only what business routes
app(Workspace::class)->navigation(zone: 'business'); // entries linked into business

A key the zone does not route answers null and renders without a link, exactly as an unrouted resource does. When the menu should hold only what this zone can actually reach, ask for that:

app(Workspace::class)->navigation(zone: 'business', linkedOnly: true);

Opt-in rather than the rule, because the two reasons an entry has no URL are different and Workspace cannot tell them apart: one may be routed in another zone, another routed nowhere at all. And a shell with a URL scheme of its own has every entry unlinked here and still wants all of them — it is the caller who knows which case it is in. A group whose entries all drop away is absent rather than an empty heading.

The zone's own address. /business itself always answers. Unless something claims it, wireResources() puts an entry there — wire.home, or business.wire.home in a zone — that sends the person to the first page of the group they can actually open: first as the sidebar orders it (groups by their sort, then entries by theirs, hidden ones skipped), and skipping a page whose can: middleware the Gate would refuse, because a redirect into a 403 is a worse landing than none. Nothing to open is a 403; nothing registered at all is a 404. It is also the address wire:install points Fortify's home at, so signing in lands in the admin. A route the application has at that path wins, whether it comes before the group or after it: the entry is a convenience and steps aside.

The zone's landing page. Claiming the address instead is one method — an empty prefix adds no segment, so that page's index lands on the group's own path, and the entry above is then not registered:

final class BusinessOverview extends Dashboard implements ConfiguresRoutes, ProvidesPages
{
public static function pages(): array { return ['index' => ShowBusinessOverview::class]; }
 
public static function routePrefix(): ?string { return self::ROOT; }
}
business.wire.business-overview.index → business
business.wire.orders.index → business/orders

Which zone lands where is only / except, like every other membership question: give each zone its own dashboard and list it there. Two pages claiming the root of one group is refused — Laravel keys routes by URI, so the second would replace the first and take its route name with it, leaving a menu entry that looks routed and silently links nowhere.

A zone that wants a fixed destination rather than the first page writes an ordinary redirect beside the group — before it or after it — which the entry then leaves alone:

Route::redirect('business', 'business/orders');

Above the zones: where signing in lands. With several zones, / belongs to none of them, and it is where Fortify's home sends a person after signing in. Route::wireZoneEntry() routes it as wire.zones, and it decides the zone without asking whenever there is one answer, in this order:

  1. the person's own choice — a user model implementing HasPreferredZone,
  2. wire-panels.routes.zone_entry.primary,
  3. the only zone this person may enter.

A step counts only when the person may enter the zone it names — asked of the zone's can: middleware, like the entry above — so a stale preference or a primary zone behind a permission they lack falls through rather than ending in a 403. Only a real choice shows the picker view; with none configured it takes the first zone they may enter.

Route::middleware(['web', 'auth'])->group(fn () => Route::wireZoneEntry('/'));
 
// config/wire-panels.php
'routes' => [
'zone_entry' => [
'uri' => null, // or '/' — the same route, declared from config
'primary' => 'business',
'view' => 'zones', // handed $zones (key => URL) and $primary
],
],
 
// config/fortify.php
'home' => '/',
use NyonCode\WirePanels\Contracts\HasPreferredZone;
 
class User extends Authenticatable implements HasPreferredZone
{
public function preferredZone(): ?string
{
return $this->preferred_zone;
}
}

Choosing again stays one link away: ?choose skips the landing and shows the picker to anybody, primary zone or not — what a zone switcher's "all zones" entry points at, route('wire.zones', ['choose' => 1]). The zones are read off the router, not off config, so zones declared in a route file count the same as wire-core.routes.groups entries; ZoneDirectory::reachableBy($user) is the same list a switcher draws.

Where the zone comes from. Zone::current() reads it off the route being rendered. On a page Route::wireResources() registered that holds on a Livewire round trip too: every such route carries RememberPage, which Livewire re-runs as persistent middleware against the page's original request, so Zone::current(), currentKey() and currentPage() answer for the page on the second render as on the first. Anywhere else — a component on a route of your own, a layout partial on a hand-written route — it is a full-page-render call:

public ?string $zone = null;
 
public function mount(): void
{
$this->zone = Zone::current();
}

There Route::currentRouteName() answers livewire.update during a round trip, so a component that asks again mid-update gets nothing — and a palette that searches on every keystroke would link out of its zone while looking perfectly fine. Read it once, keep it in a public property, and let Livewire carry it. The command palette already does exactly this, so a palette in a zoned layout needs no configuration.

Describing the groups in config

The panel's pages are one route group, panel, and every package's routes are placed the same way — from the route file or from config, through the same code.

In the route file, inside any group Laravel can build around it:

// routes/web.php
Route::middleware(['web', 'auth', 'can:admin'])->prefix('admin')
->group(fn () => Route::wire('panel', zone: 'admin'));

zone: is the route-name prefix a zone is, written for you. Route::wireResources() is the same call without it — Route::wire('panel', only: [...], except: [...]).

In config, the same groups described rather than written out, registered by the framework's one route file:

// config/wire-core.php
'routes' => [
'defaults' => [], // under every entry
'groups' => [
'admin' => ['uses' => 'panel', 'prefix' => 'admin', 'middleware' => ['web', 'auth', 'can:admin']],
'business' => ['uses' => 'panel', 'prefix' => 'business', 'only' => ['orders', 'customers']],
'zones' => ['uri' => '/'], // the address above them
],
],

An entry's key is the group it places unless uses names one; then the key is the zone, which is the reason to prefer this over hand-written groups: in a route file ->name('business.') is a line someone omits, and omitting it makes one zone take over the other's links silently. An array key cannot be omitted and cannot repeat.

Key What it is
prefix, domain, middleware, as, where, namespace, scope_bindings Laravel's group attributes, as Route::group() takes them
without_middleware Laravel's excluded_middleware
can Adds can:… to the group
zone, tenant, only, except The panel's own options — tenant is Tenancy
routes Changes to single routes by key: ['orders' => ['can' => 'orders.export']] — middleware, without_middleware, can, where
uses, enabled The group this entry places; false skips it, as does 'enabled' => false

An entry that names no middleware is guarded. The panel ships ['web', 'auth'] as its own starting point — defaults goes over it, the entry over both — because what it registers are create, edit and delete screens, and a group without auth serves them to anybody with the URL while nothing looks wrong.

The traps. Groups from config are registered before routes/web.php is read, so they are matched before everything in it: an application with a catch-all under the same prefix places that group in its route file instead, to decide the order. The same page at two addresses under one route name — config and a route file both placing the unnamed panel — is refused rather than resolved; placing it over itself changes nothing and is allowed. And a page that only exists inside a company is skipped in a group without {tenant}, so an application without tenancy never gets an address that could only answer 404.

Linking to them

Nothing needs to write a URL by hand any more. From code, ask the class:

OrderResource::url(); // /admin/orders
OrderResource::url('edit', $order); // /admin/orders/7/edit
OrderLineResource::url('index', parameters: ['parent' => $order]); // /admin/orders/7/order-lines
TaskBoard::url(parameters: ['week' => 12]); // /admin/board?week=12
OrderResource::url(zone: 'business'); // /business/orders

url() comes with DescribesRecords, which a resource already uses, and with Pages\Page; any other class that knows its key — a dashboard — adds it with use InteractsWithPageUrls;. It is the key-based call below with the class's own key filled in, so it answers exactly what that does, null included, and a renamed key cannot leave a link pointing at the old one.

Three things decide what it builds:

  • A model is reduced to its key, the record and any parameter alike. {record} and {parent} are keys the page resolves for itself, and a model whose route key is a slug would otherwise produce a URL the page finds nothing at.
  • A parameter the route does not name becomes the query string, which is how a page of your own takes its options.
  • The zone defaults to the one being rendered, read off the route name — on a Livewire round trip too, for any page Route::wireResources() registered (see where the zone comes from). A component on a route of your own keeps the zone it read on mount and passes it — url(zone: $this->zone). A nested resource's parent is not guessed: pass it.

A menu entry carries the URL of its key's page already, and a search result carries the URL of its record's:

$item->getUrl(); // /admin/orders — filled by Workspace, null when unrouted
$result->url; // /admin/orders/7 — from the key and the record key

Both come from ResolvesPageUrls, which wire-panels answers and wire-core answers with null when no package owns routing. Null is a real answer: a menu entry without an href still renders, and a resource that declares no pages is deliberately unlinked. An entry or a result that names its own URL always wins — an external link, or an application with a shell URL scheme of its own.

Code that holds a key rather than a class reaches the same answer directly:

ResourceRoutes::urlFor('orders'); // /admin/orders
ResourceRoutes::urlFor('orders', 'edit', ['record' => 7]); // /admin/orders/7/edit
ResourceRoutes::urls(); // ['orders' => '/admin/orders', …]

A full-page Livewire component needs a layout, and the framework does not supply one — set livewire.component_layout to your own.

Routing API

OrderResource::url(string $page = 'index', Model|int|string|null $record = null, array $parameters = [], ?string $zone = null): ?string // any class using InteractsWithPageUrls
ResourceRoutes::all(array $only = [], array $except = []): array // every declaring key, plus `wire.home` at the root
ResourceRoutes::for(string $class, array $pages = []): array // one, or throws; `$pages` routes only those
ResourceRoutes::urlFor(string $key, string $page = 'index', array $parameters = [], ?string $zone = null): ?string
ResourceRoutes::urls(string $page = 'index', ?string $zone = null): array
ResourceRoutes::uriFor(string $name, string|RoutePage $page): string // the segment it sits at
ResourceRoutes::takesRecord(string $name, string|RoutePage $page): bool // read off that URI, not off the kind
ResourceRoutes::zoneEntry(string $uri = '/'): Route // `wire.zones`; the macro is Route::wireZoneEntry()
Route::wire(string $group, mixed ...$options): array // any package's route group, keyed; `panel` takes zone:, tenant:, only:, except:
ZoneDirectory::all(): array // zone => its shortest route, in registration order
ZoneDirectory::reachableBy(?Authenticatable $user): array // zone => URL, only those its `can:` lets in
ZoneDirectory::landingFor(?Authenticatable $user): ?string // preference, primary, the only one — or null

And the two readings of a page route's name, which every caller of either gets from one anchored pattern — livewire.update contains wire., so a substring search reports a zone on exactly the request that has none:

Zone::current(): ?string // the zone of the page being rendered — on its round trips too, for a wireResources page
Zone::of(?string $routeName): ?string
Zone::currentPage(): ?string // its page kind — `index`, `view`, `edit`, or the resource's own
Zone::pageOf(?string $routeName): ?string
Zone::prefix(?string $zone): string

urlFor() answers null twice over: when nothing routes the key, and when the route needs a parameter this call did not give — a resource on a {tenant} domain, say. Both render as "no link" rather than taking a menu down.

From wire-core, reach it through ResolvesPageUrls instead, which wire-panels answers and which answers null when no package owns routing.

AuthorizesUrls is the question after "where": may this person open that URL. wire-panels answers it with RouteAccess, which puts the route's can: middleware (a page's permission(), a zone's group) to the Gate. Without a package that routes, the answer is yes. A tour asks it before leading somebody to another page, so a step never ends in a 403.

app(AuthorizesUrls::class)->allowsUrl(string $url, ?Authenticatable $user): bool