Resources
A Resource is a PHP class that describes an Eloquent model: which fields its form has, which columns its table has, which filters, which actions and which relations are managed from its panel. Arrel turns that description into a /schema endpoint that the frontend renders dynamically; you do not generate any Vue file.
final class PostResource extends Resource
{
protected static string $model = Post::class;
public function fields(): array { /* ... */ }
public function columns(): array { /* ... */ }
}fields() and columns() are abstract: every Resource must implement them. actions(), filters() and relations() have a default implementation that returns an empty array, and you add them only when you need them.
Identity
| Method | Default | Description |
|---|---|---|
model() (static) | None | Returns static::$model. You must declare protected static string $model. |
slug() (static) | Str::plural(Str::kebab(...)) of the class name without the Resource suffix | Declare protected static ?string $slug to override it. PostResource → posts. |
label() | Str::headline(...) of the class name without Resource | PostResource → "Post". Override it to translate it. |
pluralLabel() | Str::plural($this->label()) |
final class PostResource extends Resource
{
protected static string $model = Post::class;
protected static ?string $slug = 'articles';
public function label(): string
{
return __('posts.model_label');
}
}The base query
public function getEloquentQuery(): Builder
{
$model = static::model();
return $model::query();
}Override it to apply any global scope to the listing and to the record search (visibility by role, soft deletes, relations eager loaded with with()...). It is a normal method of the class: you can call $this->user() or $this->tenant() (see below) from here.
public function defaultSort(): ?string
{
return '-published_at'; // the `-` prefix reverses the order
}Authorization
Arrel has no authorization DSL of its own: the five check methods delegate directly to the model's Laravel Policy, when one is registered.
public function canViewAny(): bool
{
return $this->authorize('viewAny', static::model());
}
public function canView(Model $record): bool { /* 'view' */ }
public function canCreate(): bool { /* 'create' */ }
public function canEdit(Model $record): bool { /* 'update' */ }
public function canDelete(Model $record): bool { /* 'delete' */ }The exact behaviour:
protected function authorize(string $ability, Model|string $arg): bool
{
if (Gate::getPolicyFor($arg) === null) {
return true;
}
return Gate::forUser($this->user())->allows($ability, $arg);
}With no Policy registered for the model, everything is allowed for any authenticated user. When there is one, Arrel delegates to it: there is nothing new to learn, the same viewAny/view/create/update/delete methods you already use in the rest of the application. Note that canEdit() checks the update ability (not edit), the name Laravel uses by convention in its Policies.
Override any of the five methods if you need logic different from your Policy for the panel context:
public function canDelete(Model $record): bool
{
return $this->user()->can('posts.delete')
&& ! $record->isProtected();
}canViewAny() closes the whole Resource. If it returns false, every endpoint of the Resource answers 403 (listing, schema, creation, editing, deletion, actions, relations and field options), not just the listing. That is why a Resource that must be restricted only needs a Policy with viewAny, or a canViewAny() of its own. See Security and authorization.
Navigation
Four methods control how the Resource appears outside its own form/table. They all return null by default (no icon, no group, no explicit order, no per-record title):
public function icon(): ?string
{
return 'document-text';
}
public function navigationGroup(): ?string
{
return 'school';
}
public function navigationSort(): ?int
{
return 10;
}
public function recordTitleAttribute(): ?string
{
return 'title';
}They are normal instance methods, like label()/pluralLabel(), not static properties: the icon or the position in the navigation is presentation, not identity.
icon()must be one of the names in the curated subset of Heroicons (see Actions → Icons); a name that is not there renders nothing.navigationSort()sorts Resources ascending; one that declares none is sorted after all those that do, keeping its original registration order relative to the other unsorted ones. They are then grouped bynavigationGroup(), in the order in which each group first appears in that already sorted list, not alphabetically. Anullgroup is its own bucket (Resources without a group are neither mixed in nor discarded).recordTitleAttribute()names a field or column that is shown in the header of the edit/view page of a specific record (e.g. "Edit Post: Installation guide") when the loaded record has a value for it. If it is not defined, the header keeps the generic "Edit {label}".
Lifecycle hooks
The hooks wrap the requests to create, update and delete a record:
public function mutateDataBeforeCreate(array $data): array;
public function handleRecordCreation(array $data): ?Model;
public function afterCreate(Model $record): void;
public function mutateDataBeforeUpdate(array $data, Model $record): array;
public function handleRecordUpdate(Model $record, array $data): bool;
public function afterUpdate(Model $record): void;
public function handleRecordDeletion(Model $record): bool;
public function mutateDataBeforeFill(array $data, Model $record): array;mutateDataBeforeCreate()/mutateDataBeforeUpdate() run after validation and before the record is written, and return the array that is actually persisted. afterCreate()/afterUpdate() run once saved (and, in the case of afterCreate(), reloaded), so $record already has its primary key.
public function mutateDataBeforeCreate(array $data): array
{
if (isset($data['category'])) {
$data['category'] = strtolower($data['category']);
}
return $data;
}
public function afterCreate(Model $record): void
{
if (! $record instanceof Post || $record->tags()->exists()) {
return;
}
$tag = Tag::firstOrCreate(['name' => 'general']);
$record->tags()->attach($tag->id);
}Replacing how the record is written
handleRecordCreation(), handleRecordUpdate() and handleRecordDeletion() replace the default writing (Model::create(), update() and delete()) when the model cannot be saved that way: a domain service, a transaction of your own, a model that is deleted with a business rule.
handleRecordCreation(array $data): ?Modelmust return the created model. Withnull, Arrel creates the record withModel::create().handleRecordUpdate(Model $record, array $data): boolreturnstrueif it has already done the update. Withfalse, Arrel does$record->update($data).handleRecordDeletion(Model $record): boolreturnstrueif it has already done the deletion. Withfalse, Arrel does$record->delete().
public function handleRecordCreation(array $data): ?Model
{
return app(CreatePost::class)->handle($data);
}A RuntimeException thrown during deletion is answered with a 422 and its message.
Filling the edit form
mutateDataBeforeFill() receives the data sent to a record's edit form and returns the data that is actually shown there. It is useful to compute values that are not a column (for example, the text of a Placeholder field):
public function mutateDataBeforeFill(array $data, Model $record): array
{
$data['summary'] = "{$record->comments()->count()} comments";
return $data;
}All the hooks have a default implementation that does nothing; you only override them when you need them.
Detail page
When a Resource has a ViewAction, the record is shown on a read-only page. By default, that page reuses the Resource's columns(). To organize it in sections, declare infolist():
public function infolist(): array
{
return [
InfolistSection::make('Post')
->columns(2)
->schema([
Column::make('title'),
Column::make('status')->as('badge'),
]),
InfolistSection::make('Dates')
->schema([
Column::make('published_at')->as('date')->displayFormat('d/m/Y'),
]),
];
}Each entry is a Column, with the same as(), value() and color(). InfolistSection::make($label) accepts columns(int) for the grid. With an empty infolist() (the default), the view uses columns().
Available helpers
Inside any method of the Resource you have access to:
$this->user(): ?Authenticatable: the current authenticated user.$this->tenant(): mixed: the result ofTenantResolver::resolve()if one is bound in the container (see Multi-tenancy);nullif not.
Next pieces
A Resource is built by combining:
- Fields: what the form contains.
- Tables and columns: what is shown in the listing.
- Filters: how the listing is filtered.
- Actions: what can be done with each record.
- Relation managers: how relations are managed from the same panel.