Skip to content

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.

php
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 ​

MethodDefaultDescription
model() (static)NoneReturns static::$model. You must declare protected static string $model.
slug() (static)Str::plural(Str::kebab(...)) of the class name without the Resource suffixDeclare protected static ?string $slug to override it. PostResource → posts.
label()Str::headline(...) of the class name without ResourcePostResource → "Post". Override it to translate it.
pluralLabel()Str::plural($this->label())
php
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 ​

php
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.

php
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.

php
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:

php
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:

php
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.

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):

php
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 by navigationGroup(), in the order in which each group first appears in that already sorted list, not alphabetically. A null group 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:

php
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.

php
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): ?Model must return the created model. With null, Arrel creates the record with Model::create().
  • handleRecordUpdate(Model $record, array $data): bool returns true if it has already done the update. With false, Arrel does $record->update($data).
  • handleRecordDeletion(Model $record): bool returns true if it has already done the deletion. With false, Arrel does $record->delete().
php
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):

php
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():

php
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 of TenantResolver::resolve() if one is bound in the container (see Multi-tenancy); null if not.

Next pieces ​

A Resource is built by combining: