Panels
Resources
One entity bound to the surfaces it exposes — the contracts it implements, how it is named, how it is registered, and what the registry can answer without building any of them.
On this page
A resource binds one entity to the surfaces it exposes — its list, its form, its read-only view — so those live in one declaration instead of being wired by hand into each Livewire component that happens to show them.
How It Works
A table, a form and an infolist are already independent primitives, and they stay
that way: nothing about a resource changes how they work. What a resource adds is
an owner above them — one class that answers "this is the Order entity, this
is how it lists, this is how it is edited" — and a registry that can answer
"which resources exist" and "which one owns App\Models\Order" without building
any of those surfaces.
That split is why a resource is several small contracts rather than one large one:
| Contract | Answers |
|---|---|
DescribesResource |
what the entity is: key, model, singular and plural label |
ProvidesResourceTable |
how it lists |
ProvidesResourceForm |
how it is created and edited |
ProvidesResourceInfolist |
how one record is shown read-only |
A resource implements the ones it has. A read-only audit log implements identity and a table and nothing else, and a page that needs a form cannot be handed it by mistake — the type says so.
DescribesResource is static and the surfaces are instance methods, and
the reason is mechanical rather than stylistic. A menu asks for a label, and the
registry routes a model to its owner, before anything has been instantiated; so
metadata cannot require an instance. The surfaces compose a builder that the
caller owns and has already wired to its host — exactly as RelationManager and
any WithTable component do — so those receive the instance and hand it back.
Basic Usage
use App\Models\Order;use NyonCode\WireTable\Columns\TextColumn;use NyonCode\WireCore\Core\Resources\Concerns\DescribesRecords;use NyonCode\WireCore\Core\Resources\Contracts\DescribesResource;use NyonCode\WirePanels\Resources\Contracts\ProvidesResourceTable;use NyonCode\WireTable\Table; final class OrderResource implements DescribesResource, ProvidesResourceTable { use DescribesRecords; public static function modelClass(): ?string { return Order::class; } public function table(Table $table): Table { return $table->columns([ TextColumn::make('number'), TextColumn::make('customer.name'), ]); }}
DescribesRecords supplies the other three answers from the model class, so the
declaration above is complete: key orders, label Order, plural Orders.
Naming
The key is not cosmetic. It is the config handle, the introspection name and the route segment a page will use, so it has to survive a label change and a namespace move — which is why it is derived from the model, not from the resource class or the label:
App\Models\OrderLine → key 'order-lines' · label 'Order Line' · plural 'Order Lines'App\Models\Person → key 'people' · label 'Person' · plural 'People'
Pluralisation is Laravel's inflector, so irregular nouns are right without being spelled out. Override just the answer that is wrong:
public static function pluralLabel(): string{ return 'Line items';}
A resource with no model — one backed by a DataSource rather than Eloquent —
returns null from modelClass() and derives its names from its own class name
instead, with a trailing Resource dropped. It is registered and listed like any
other; it simply cannot be found by model.
Registration
Resources are declared in config:
// config/wire-core.php'resources' => [ App\Resources\OrderResource::class, App\Resources\CustomerResource::class,],
This is a registry, not a panel: it holds class names and answers two questions about them. It owns no routing, no URL shell and no navigation tree.
To add one at runtime — which is what an attribute-discovery scanner would do — resolve the registry and register directly:
use NyonCode\WireCore\Core\Resources\ResourceRegistry; app(ResourceRegistry::class)->register(OrderResource::class);
Registering the same class twice is a no-op, because config merging and a provider booted twice both do it. Two different classes claiming one key throws instead: the second would silently take over routing for the first.
Discovering Them
A folder of resources can be registered as a folder:
// config/wire-core.php'discover' => [ 'resources' => ['App\\Resources' => app_path('Resources')], 'dashboards' => ['App\\Dashboards' => app_path('Dashboards')],],
Namespace to directory, the way PSR-4 maps them. At boot, every concrete class
under the directory that implements DescribesResource (for dashboards,
extends Dashboard) is registered — after the listed ones, so a class both
listed and discovered registers once and a listed order wins. What else the
folder holds — an abstract base, a trait, a helper — is passed over, because a
folder of resources is allowed to hold what they are built from. A class that
does not autoload is not found: discovery goes through the autoloader, so it
sees what the application can load.
It is one directory listing and one class_exists() per file on every boot,
which is why it is off until a directory is named.
Reading The Registry
$registry = app(ResourceRegistry::class); $registry->all(); // ['orders' => OrderResource::class, …]$registry->find('orders'); // OrderResource::class | null$registry->has('orders'); // bool$registry->forModel(Order::class); // OrderResource::class | null
Every one of these answers from the static contract alone, so building a menu
from all() never composes a table.
Extended Example
An order resource with all three surfaces — the list a page renders, the form create and edit share, and the read-only view:
use App\Models\Order;use NyonCode\WireCore\Infolists\Infolist;use NyonCode\WireCore\Infolists\Components\TextEntry;use NyonCode\WireForms\Components\Select;use NyonCode\WireForms\Components\TextInput;use NyonCode\WireForms\Forms\Form;use NyonCode\WireTable\Columns\MoneyColumn;use NyonCode\WireTable\Columns\TextColumn;use NyonCode\WireCore\Core\Resources\Concerns\DescribesRecords;use NyonCode\WireCore\Core\Resources\Contracts\DescribesResource;use NyonCode\WireForms\Contracts\ProvidesResourceForm;use NyonCode\WireCore\Infolists\Contracts\ProvidesResourceInfolist;use NyonCode\WirePanels\Resources\Contracts\ProvidesResourceTable;use NyonCode\WireTable\Table; final class OrderResource implements DescribesResource, ProvidesResourceTable, ProvidesResourceForm, ProvidesResourceInfolist{ use DescribesRecords; public static function modelClass(): ?string { return Order::class; } public function table(Table $table): Table { return $table ->columns([ TextColumn::make('number')->searchable(), TextColumn::make('customer.name')->label('Customer'), MoneyColumn::make('total', 'Kč'), ]) ->defaultSort('number', 'desc'); } public function form(Form $form): Form { return $form->schema([ TextInput::make('number')->required(), Select::make('customer_id') ->relationship('customer', 'name') ->required(), ]); } public function infolist(Infolist $infolist): Infolist { return $infolist->schema([ TextEntry::make('number'), TextEntry::make('customer.name')->label('Customer'), TextEntry::make('total')->money('Kč'), ]); }}
One form() serves both creating and editing on purpose — a create form and an
edit form that drift apart is the failure this shape prevents. Where they must
genuinely differ, the page passes a form the resource then shapes, rather than
the resource declaring two.
Persistence stays the form's: Form already owns the save lifecycle, and a
resource over a non-Eloquent source writes through Form::using().
Nested Resources
Some records only make sense inside another — an order's lines, a project's tasks. A resource says so by naming its parent and the parent model's relationship that holds it:
use NyonCode\WirePanels\Resources\Contracts\NestedResource; final class OrderLineResource implements DescribesResource, NestedResource, ProvidesPages, ProvidesResourceForm, ProvidesResourceTable{ use DescribesRecords; public static function parentResource(): string { return OrderResource::class; } public static function parentRelationship(): string { return 'lines'; // Order::lines(), a hasMany } public static function pages(): array { return ['index' => ListOrderLines::class, 'create' => CreateOrderLine::class, 'edit' => EditOrderLine::class]; } // table(), form() — as any resource}
Everything goes through the parent record. The pages are routed under it —
orders/{parent}/order-lines, …/create, …/{record}/edit — and read the
{parent} key from the route. The list shows that order's lines only; a record
page looks its line up through $order->lines(), so the key of another order's
line is a 404, not somebody else's line; and a create page binds its form to
$order->lines()->make(), so the new line is filed under the order with its key
already set. The pages' URLs carry the parent, so New, the redirect after a
save and the record's tabs all stay inside it.
The trail and the tabs follow. A line's page leads Orders › ORD-17 › Order
lines › Edit Order line, the parent record named by its name, title,
number, label or subject, else by its label and key. And the parent
record's pages gain a tab to the list — the parent declares nothing for it; the
nested resource naming it is enough, and the tab is left out for someone the
list's route would refuse.
The limits are the URL's. A route carries one {parent}, so a nested
resource's parent is not nested itself — that is refused when the routes are
registered. Creating needs a relationship that can make a child that knows its
parent — hasMany, morphMany and their one-to-one kin; a belongsToMany
refuses with a message naming the way out. A nested resource has no URL without
a parent, so it has no place in a menu either: leave ProvidesNavigation off it.
Introspection
describe-resource reports what an application's resources declare — identity,
which surfaces each has, and its navigation entry:
describe-resource # every registered resourcedescribe-resource orders # one, by keydescribe-resource App\Resources\OrderResource # or by class
Surfaces are reported as declared or not, not as their contents: composing them
would cost exactly what the static half exists to avoid, and describe-table and
describe-form already answer that for the pages that render them.
DescribesResource API
| Method | Returns | Purpose |
|---|---|---|
static key(): string |
string |
Stable identifier, unique per registry |
static modelClass(): ?string |
class-string|null |
The Eloquent model owned, or null for a non-Eloquent source |
static label(): string |
string |
Singular human name |
static pluralLabel(): string |
string |
Plural human name |
Surface Contract API
| Contract | Method | Ships in |
|---|---|---|
ProvidesResourceTable |
table(Table $table): Table |
wire-panels |
ProvidesResourceForm |
form(Form $form): Form |
wire-forms |
ProvidesResourceInfolist |
infolist(Infolist $infolist): Infolist |
wire-core |
ProvidesRelationManagers |
relationManagers(): array |
wire-panels |
ManagesTrashedRecords |
(marker) | wire-panels |
NestedResource |
static parentResource(): string, static parentRelationship(): string |
wire-panels |
ProvidesNavigation |
static navigation(): NavigationItem |
wire-core |
ProvidesBreadcrumbs |
breadcrumbs(): array |
wire-core |
ProvidesPages |
static pages(): array |
wire-core |
ConfiguresRoutes |
static routeMiddleware(): array, static routeDomain(): ?string, static routePrefix(): ?string |
wire-core |
GloballySearchable |
static globallySearchableAttributes(): array, static toGlobalSearchResult(object): GlobalSearchResult |
wire-core |
ResourceRegistry API
| Method | Returns | Purpose |
|---|---|---|
register(string $resource): void |
void |
Add a resource class; throws when it is not a resource, or when its key is taken by a different class |
registerMany(mixed $resources): void |
void |
Register whatever config held. A missing or malformed entry is skipped rather than fatal — a published config file with a stray value must not take the application down at boot |
all(): array |
array<string, class-string> |
Every registered resource, keyed by key |
registeredClasses(): array |
array<string, class-string> |
The RegistrySource promise the menu, the router and the palette read. The same answer as all(), deliberately a separate method, so all() can grow other callers without becoming that promise |
find(string $key): ?string |
class-string|null |
The resource with this key |
has(string $key): bool |
bool |
Whether a key is registered |
forModel(string $model): ?string |
class-string|null |
The resource owning a model class |
Related
- Pages — the components that render these surfaces
- Navigation — putting a resource in the menu
- Routing — giving its pages URLs
- Modules — declaring a whole business area's resources at once
- Global Search — the command palette over every registered resource
- Relation Managers — a relation-scoped table, the owner this pattern generalises
- Configuration — where
resourcesis declared