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
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 landing page. /business itself routes nothing unless something
claims it, and claiming it is one method — an empty prefix adds no segment, so
that page's index lands on the group's own path:
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 destination rather than a page of its own writes an ordinary redirect beside the group:
Route::redirect('business', 'business/orders');
Where the zone comes from. Zone::current() reads it off the route being
rendered, and that is a full-page-render call:
public ?string $zone = null; public function mount(): void{ $this->zone = Zone::current();}
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.
Registering them from config instead
The macro above stays the reference path. An application that wants the convention and would rather not keep a route file for it hands the same group arguments over once:
// config/wire-panels.php'routes' => [ 'enabled' => true, 'prefix' => 'admin', 'middleware' => ['web', 'auth'], 'domain' => null, 'only' => [], 'except' => [],],
Zones are a zones key, and the array key is the zone:
'routes' => [ 'enabled' => true, 'middleware' => ['web', 'auth'], // inherited by every zone 'zones' => [ 'admin' => [ 'prefix' => 'admin', 'middleware' => ['web', 'auth', 'can:admin'], ], 'business' => [ 'prefix' => 'business', 'only' => ['orders', 'customers'], ], ], ],
Each zone inherits the values outside zones and overrides what it names. The
key becomes the route-name prefix, which is the reason to prefer this over
hand-written groups rather than merely an alternative to them: 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.
With no zones key it is one unnamed group, which is what a single-zone
application wants.
Off by default, and deliberately so: package providers boot before your own, so
these routes are matched before everything in routes/web.php. An
application with a catch-all under the same prefix wins today and would stop
winning, which is a decision to make rather than a default to inherit.
Enabling this and calling Route::wireResources() yourself would register
every page twice under one route name; that is refused rather than resolved, with
a message naming both lines you could delete.
Linking to them
Nothing needs to write a URL by hand any more. A menu entry carries the URL of its key's page, 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.
Reaching for it directly is the same call:
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
ResourceRoutes::all(array $only = [], array $except = []): array // every declaring keyResourceRoutes::for(string $class): array // one, or throwsResourceRoutes::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 kind
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 — full page renders onlyZone::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. RegistersPageRoutes
is the other half of that seam: wire-core calls it once the registries are full,
which is the only moment config-declared routes
can read a complete catalogue.
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