Search Documentation

Search across all documentation pages

Your first resource

Defining resources

Define a Resource for an Eloquent model, register it, and set how its index, forms and sidebar entry behave.

A Resource connects one Eloquent model to the panel. It lists the model's fields, and Lathe builds the index table, the detail page and the create and edit forms from them.

Generate a Resource

Run the generator with the Resource name:

php artisan lathe:resource Post

This writes app/Lathe/Resources/Post.php and, when the model has none, app/Policies/PostPolicy.php. If the model does not exist yet, the command warns you. Create it before you open the Resource.

Note: Eight names are reserved and the command refuses them: Action, Dashboard, Field, Filter, Lens, Plugin, Resource and Widget.

The generated class is the smallest working Resource:

<?php

namespace App\Lathe\Resources;

use Lathe\Field\Field;
use Lathe\Http\Requests\LatheRequest;
use Lathe\Resources\Resource;
use Lathe\Resources\ResourceDefinition;

class Post extends Resource
{
    protected function configure(ResourceDefinition $definition): ResourceDefinition
    {
        return $definition->model(\App\Models\Post::class)->title('name');
    }

    public function fields(LatheRequest $request): array
    {
        return [
            Field::make('Name'),
        ];
    }
}

configure() describes the Resource: its model, its title and its settings. fields() lists what the user sees and edits. A fuller version adds a title, search columns, a relation and a date:

protected function configure(ResourceDefinition $definition): ResourceDefinition
{
    return $definition
        ->model(\App\Models\Post::class)
        ->title('title')
        ->searchable(['id', 'title']);
}

public function fields(LatheRequest $request): array
{
    return [
        Field::id()->sortable(),
        Field::text('Title')->sortable()->required(),
        Field::textarea('Excerpt')->hideFromIndex(),
        Field::belongsTo('Author'),
        Field::dateTime('Published at', 'published_at')->sortable()->nullable(),
    ];
}

See Fields for every field type and Relationships for Field::belongsTo() and its siblings.

Model

Every Resource needs a model. Lathe reads the table, the key and the timestamps from it.

return $definition->model(\App\Models\Post::class);

A Resource without a model stops the boot with an error that names the class.

Title and subtitle

The title is the text that stands for a record in search results, relation pickers and breadcrumbs. The subtitle is a second line. Pass a column name or a closure that receives the model:

return $definition
    ->model(\App\Models\User::class)
    ->title(fn ($user) => $user->first_name.' '.$user->last_name)
    ->subtitle('email');

Without a title, Lathe uses the id column.

Meta and additional information

withMeta() attaches values to a Resource instance, and meta() reads them. Call withMeta() in the constructor to set them for every request. Override the static additionalInformation() to compute values from the request. Lathe sends both with the Resource's part of every page, as resource.meta and resource.extra, so a custom page or a Resource plugin can read them:

use Lathe\Http\Requests\LatheRequest;

public function __construct(?Model $model = null)
{
    parent::__construct($model);

    $this->withMeta(['tier' => 'gold']);
}

public static function additionalInformation(LatheRequest $request): array
{
    return ['viewer' => $request->user()?->email];
}

additionalInformation() returns [] by default. In a request handler, $request->resourceInstance() gives the Resource the route names, or null. $request->newResource() gives a new instance and answers 404 when there is none.

Register Resources

Lathe finds every Resource in app/Lathe/Resources the first time the panel needs one. Your app/Providers/LatheServiceProvider.php, which lathe:install writes, does this in its registerResources() method. To register classes by hand instead, call Lathe::registerResources():

use App\Lathe\Resources\Post;
use App\Lathe\Resources\User;
use Lathe\Lathe;

protected function registerResources(): void
{
    Lathe::registerResources(User::class, Post::class);
}

To read another folder, call Lathe::discoverResources() with the directory and, if it cannot be guessed from app_path(), the namespace:

Lathe::discoverResources(app_path('Admin/Resources'), 'App\\Admin\\Resources');

On a production server, run php artisan lathe:cache when you deploy. It stores the Resource list in one file, so a request does not scan the folder. See Commands.

Two Resources cannot share a URI key. Two Resources can share a model only when all but one call ->canonical(false). The canonical Resource is the one that relation fields and search results link to.

The sidebar entry

Each Resource gets a sidebar entry from its class name. Change the entry with the definition or with attributes. Each setting below has both forms.

use Lathe\Attributes\Resource\Group;
use Lathe\Attributes\Resource\Icon;
use Lathe\Attributes\Resource\NavigationLabel;

#[Group('Content')]
#[Icon('FileText')]
#[NavigationLabel('Blog posts')]
class Post extends Resource
{
    // ...
}
  • Group puts Resources with the same name under one heading.
  • Icon takes the name of a Lucide icon component, as a string.
  • NavigationLabel replaces the label, which is the plural of the class name.
  • DisplayInNavigation(false) hides the entry. The routes stay, so users can still open the Resource by its URL.

To replace the whole entry, override the static menu() method. It returns a MenuItem, or null to keep the default:

use Lathe\Http\Requests\LatheRequest;
use Lathe\Menu\MenuItem;

public static function menu(LatheRequest $request): ?MenuItem
{
    return MenuItem::resource(static::class)->badge('New', 'info');
}

The index table

Style

The table style is Default or Tight. Vertical column borders are off by default.

use Lathe\Attributes\Resource\ShowColumnBorders;
use Lathe\Attributes\Resource\TableStyle;
use Lathe\Enums\TableStyle as TableStyleEnum;

#[TableStyle(TableStyleEnum::Tight)]
#[ShowColumnBorders]
class Post extends Resource
{
    // ...
}

Click action

A click on a row opens the detail page. Change that with ClickAction, or ->clickAction() in the definition:

use Lathe\Enums\ClickAction;

return $definition
    ->model(\App\Models\Task::class)
    ->clickAction(ClickAction::Preview);
Case What a click does
Detail Opens the detail page. This is the default.
Edit Opens the edit form.
Select Ticks the row checkbox.
Preview Opens a preview modal on the index page.
Ignore Does nothing.

For Preview, override fieldsForPreview() to choose the fields in the modal. The same pattern works for fieldsForIndex(), fieldsForDetail(), fieldsForCreate(), fieldsForUpdate() and fieldsForPeeking(): each one takes a LatheRequest and returns a list of fields.

Pagination

perPage() sets the page size choices. The first value is the default, and each value is from 1 to the limit in config/lathe.php (100 by default).

return $definition->model(\App\Models\Post::class)->perPage([25, 50, 100]);

paginationType() takes PaginationType::Links (page numbers), Simple (previous and next) or LoadMore. To set the page size of a relation panel, call perPageOptions() on the relation field. See Relationships.

searchable() names the columns the search box looks in. A plain string matches the start of the value. Pass a SearchableColumn object for another strategy, such as SearchableRelation to search a related model's column:

use Lathe\Search\SearchableRelation;

return $definition
    ->model(\App\Models\Order::class)
    ->searchable(['number', new SearchableRelation('customer', 'name')])
    ->globallySearchable();

globallySearchable() also puts the Resource in the panel's global search. debounce() sets how many seconds the box waits after the last key before it queries. The default is 0.5.

To read the columns back, for example in a test, call Resource::searchableColumns(). It returns what the definition declared:

$columns = Post::make()->searchableColumns();
// ['id', 'title']

Polling

polling() reloads the index every given number of seconds. showPollingToggle() adds a switch so users can turn it off.

return $definition->model(\App\Models\Order::class)->polling(10)->showPollingToggle();

Soft deletes, saved tabs and borders

  • Lathe detects the SoftDeletes trait on the model and adds a filter for trashed rows. Call softDeletes(false) to turn that off.
  • savedTabs() lets users save their current filters and columns as named tabs.
  • borderless() removes the dividers between fields on forms and detail pages.

Eager loading

List the relations your fields read in with(), so Lathe loads them with the rows instead of one query per row:

return $definition->model(\App\Models\Post::class)->with(['author', 'categories']);

Default values for new records

Resource::defaultAttributes() returns the attributes a new model starts with, before any field fills it. A field's own default wins over it for the same key.

use Lathe\Http\Requests\LatheRequest;

public static function defaultAttributes(LatheRequest $request): array
{
    return [
        'status' => 'draft',
        'author_id' => $request->user()?->getKey(),
    ];
}

Lathe shows these values in the create form and applies them when a record is stored.

Replicate a record

Users can copy a record from its detail page. replicateExcept() lists the attributes the copy leaves empty. Lathe also leaves out every file column, so a copy never points at the original's files.

public static function replicateExcept(): array
{
    return ['slug', 'published_at'];
}

Lifecycle hooks

Override these static methods to run code around a write. Each receives the LatheRequest and the model:

  • beforeCreate() and afterCreate()
  • beforeUpdate() and afterUpdate()
  • beforeDelete() and afterDelete()
  • beforeRestore() and afterRestore()
  • beforeForceDelete() and afterForceDelete()
use Illuminate\Database\Eloquent\Model;

public static function afterCreate(LatheRequest $request, Model $model): void
{
    // Runs after the record is saved.
}

The before hooks run inside the write's database transaction. To add validation after the field rules pass, see Validation.

Redirects

After a create or an update, the panel opens the record's detail page. After a delete, it opens the index. Override these static methods to send the user elsewhere:

use Lathe\Resources\Resource;

public static function redirectAfterCreate(LatheRequest $request, Resource $resource): string
{
    return static::uriKey();
}

redirectAfterUpdate() takes the same arguments. redirectAfterDelete() takes only the request.

Conflicts

When two users edit one record, the second save could overwrite the first. Lathe refuses a save if the record changed since the form loaded, and tells the user. This is on by default. Turn it off with ->trafficCop(false) or the TrafficCop attribute.

Query hooks

Each hook is a static method that receives the request and a query builder, and returns the builder. Use them to hide rows from users.

Method Narrows
indexQuery() The index rows, counts and exports.
detailQuery() The rows a detail page, delete or restore can reach.
editQuery() The rows the edit form and update can reach.
searchQuery() Global search. Defaults to indexQuery().
widgetQuery() The rows a widget reads. Defaults to indexQuery().
relatableQuery() The records a relation picker offers.
replicateQuery() The records a copy can start from.
scoutQuery() A Laravel Scout search. It receives the Scout builder.
use Illuminate\Database\Eloquent\Builder;

public static function indexQuery(LatheRequest $request, Builder $query): Builder
{
    return $query->where('published', true);
}

Because searchQuery() and widgetQuery() follow indexQuery(), a Resource that hides rows on the index hides them in search and in widgets too. Override either one to widen it.

Authorization

Policies decide what each user may do with a Resource. A missing policy denies access. See Policies.

Attributes and the builder

Every setting on this page can be written as a PHP attribute or as a call on the definition. The definition starts from the class attributes, and your calls in configure() replace them for the same setting.

use Lathe\Attributes\Resource\TableStyle;
use Lathe\Enums\TableStyle as TableStyleEnum;

#[TableStyle(TableStyleEnum::Tight)]
class Post extends Resource
{
    protected function configure(ResourceDefinition $definition): ResourceDefinition
    {
        return $definition
            ->model(\App\Models\Post::class)
            ->title('title')
            ->searchable(['id', 'title']);
    }
}

Here the table style comes from the attribute and the rest from configure(). Pick one style for each Resource. Use configure() when you need a closure or a condition, because an attribute argument must be a constant. Attributes are not inherited from a parent Resource class.

These definition methods have no section above:

Method Effect
parent(string $parent, string $relationship) Makes the Resource a child of a relation, and sets its breadcrumbs.
canonical(bool) Whether relation fields and search link to this Resource for its model.
canSee(Closure) Hides the Resource from requests the closure refuses.
defaultGroup(string) The row grouping applied when the URL names none.
globalSearchResults(int) The most rows this Resource adds to global search.
scoutSearchResults(int) The most keys a Scout search reads.
useScout(?bool) Forces Scout on or off. null detects it.
maxPrimaryKeySize(int) The largest integer key the key search accepts.
pickerResults(int) The page size of relation pickers.

What you see

After you run php artisan lathe:resource Post, add the fields and open /lathe. The sidebar has a Posts entry in the group and with the icon you set. It opens an index table with the columns from fields(), a search box on the columns you named, and a Create button. A click on a row does what you chose in clickAction().