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.phpRoute::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/ordersbusiness.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/ordersResourceRoutes::urls(zone: 'business'); // only what business routesapp(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 → businessbusiness.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:
- the person's own choice — a user model implementing
HasPreferredZone, wire-panels.routes.zone_entry.primary,- 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.phpRoute::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/ordersOrderResource::url('edit', $order); // /admin/orders/7/editOrderLineResource::url('index', parameters: ['parent' => $order]); // /admin/orders/7/order-linesTaskBoard::url(parameters: ['week' => 12]); // /admin/board?week=12OrderResource::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'sparentis 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/ordersResourceRoutes::urlFor('orders', 'edit', ['record' => 7]); // /admin/orders/7/editResourceRoutes::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 InteractsWithPageUrlsResourceRoutes::all(array $only = [], array $except = []): array // every declaring key, plus `wire.home` at the rootResourceRoutes::for(string $class, array $pages = []): array // one, or throws; `$pages` routes only thoseResourceRoutes::urlFor(string $key, string $page = 'index', array $parameters = [], ?string $zone = null): ?stringResourceRoutes::urls(string $page = 'index', ?string $zone = null): arrayResourceRoutes::uriFor(string $name, string|RoutePage $page): string // the segment it sits atResourceRoutes::takesRecord(string $name, string|RoutePage $page): bool // read off that URI, not off the kindResourceRoutes::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 orderZoneDirectory::reachableBy(?Authenticatable $user): array // zone => URL, only those its `can:` lets inZoneDirectory::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 pageZone::of(?string $routeName): ?stringZone::currentPage(): ?string // its page kind — `index`, `view`, `edit`, or the resource's ownZone::pageOf(?string $routeName): ?stringZone::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
Related
- Pages — the components these routes reach
- Navigation — where a menu entry's URL comes from
- Authorization — the gates
permission()andcan:consult - Configuration — the
wire-panels.routesblock in full - The Admin Shell — a layout for the pages these routes render