Getting Started

Resources

Author Laravel admin panel resources declaratively and let Flashboard assemble tables, forms, actions, and Nuxt UI-powered screens.

Resources are the declarative core of Flashboard.

Minimal resource

<?php

declare(strict_types=1);

namespace App\Flashboard;

use App\Models\Order;
use Illuminate\Database\Eloquent\Builder;
use Pepperfm\Flashboard\Contracts\Resources\Resource;
use Pepperfm\Flashboard\Core\Forms\Fields\BelongsTo;
use Pepperfm\Flashboard\Core\Forms\Fields\BelongsToMany;
use Pepperfm\Flashboard\Core\Forms\Fields\Checkbox;
use Pepperfm\Flashboard\Core\Forms\Fields\DateInput;
use Pepperfm\Flashboard\Core\Forms\Fields\FileUpload;
use Pepperfm\Flashboard\Core\Forms\Fields\RichText;
use Pepperfm\Flashboard\Core\Forms\Fields\Select;
use Pepperfm\Flashboard\Core\Forms\Fields\Textarea;
use Pepperfm\Flashboard\Core\Forms\Fields\TextInput;
use Pepperfm\Flashboard\Core\Forms\Fields\Toggle;
use Pepperfm\Flashboard\Contracts\Forms\FormContract;
use Illuminate\Database\Eloquent\Builder;
use Pepperfm\Flashboard\Core\Relations\HasMany;
use Pepperfm\Flashboard\Core\Relations\HasOne;
use Pepperfm\Flashboard\Core\Tables\Actions\DeleteAction;
use Pepperfm\Flashboard\Core\Tables\Actions\EditAction;
use Pepperfm\Flashboard\Core\Tables\Columns\BadgeColumn;
use Pepperfm\Flashboard\Core\Tables\Columns\TextColumn;
use Pepperfm\Flashboard\Contracts\Tables\TableContract;
use Pepperfm\Flashboard\Core\Tables\Filters\SelectFilter;
use Pepperfm\Flashboard\Integration\Laravel\FlashboardPanelProvider;

final class OrdersResource extends Resource
{
    public static function model(): string
    {
        return Order::class;
    }

    public static function table(TableContract $table): TableContract
    {
        return $table
            ->columns([
                TextColumn::make('id', 'ID')
                    ->sortable(),
                BadgeColumn::make('status', 'Status')
                    ->searchable()
                    ->sortable(),
            ])
            ->filters([
                SelectFilter::make('status', 'Status')->lazy(),
            ]);
    }

    public static function form(FormContract $form): FormContract
    {
        return $form
            ->columns(2)
            ->schema([
                Select::make('status', 'Status'),
                BelongsTo::make('customer_id', 'Customer')
                    ->resource(CustomersResource::class)
                    ->titleAttribute('name')
                    ->searchable(['name', 'email'])
                    ->required(),
                BelongsToMany::make('tags', 'Tags')
                    ->resource(TagsResource::class)
                    ->titleAttribute('name')
                    ->searchable(['name', 'slug'])
                    ->maxItems(8),
                TextInput::make('name', 'Name')->required(),
                TextInput::make('email', 'Email')->email(),
                Textarea::make('notes', 'Notes')->columnSpan(2),
                DateInput::make('ordered_on', 'Ordered on'),
                RichText::make('internal_summary', 'Internal summary')->fullWidth(),
                FileUpload::make('receipt', 'Receipt')
                    ->accept('application/pdf,image/*')
                    ->directory('order-receipts'),
                Checkbox::make('is_featured', 'Featured'),
                Toggle::make('is_active', 'Is active'),
            ]);
    }

    public static function actions(): array
    {
        return [
            EditAction::make(),
            DeleteAction::make(),
        ];
    }

    public static function relations(): array
    {
        return [
            HasOne::make('profile', 'Profile')
                ->resource(OrderProfilesResource::class)
                ->attachable()
                ->detachable()
                ->replaceable(),
            HasMany::make('items', 'Items')
                ->resource(OrderItemsResource::class)
                ->searchable(['name', 'sku'])
                ->perPage(10)
                ->attachable()
                ->detachable()
                ->syncable(),
        ];
    }
}

final class AdminPanelProvider extends FlashboardPanelProvider
{
    public function register(): void
    {
        $this->panelConfig()
            ->discover();
    }
}

Any *Resource class placed in app/Flashboard is picked up automatically by provider discover(). Use ->resource(OrdersResource::class) only when you want explicit registration.

In table definitions, column-level searchable() adds the column to the global resource table search, and column-level sortable() renders a clickable server-side sort header. This is separate from SelectFilter::searchable(), which only changes the option picker for that filter.

Discovery variants

<?php

public function register(): void
{
    $this->panelConfig()
        ->discoverResources()
        ->except(
            App\Flashboard\Support\DraftResource::class,
            'Support/IgnoredResource.php',
        );
}

Use:

  • discover() to scan both resources and pages
  • discoverResources() to scan only resources
  • discoverPages() to scan only pages
  • except() to exclude helper or draft classes from auto-registration
  • withoutDiscovery() to opt out completely and register resources explicitly

Available resource surfaces

  • table() for list and index behavior
  • form() for create and edit behavior
  • detail() for read-only detail screens
  • infolist() as a concept-aligned alias for detail()
  • actions() for resource-owned actions, including table row actions
  • relations() for nested relation managers and legacy read-only relation payloads
  • pages() for resource-owned page declarations

Table row actions are configured from Resource::actions(), not from the table builder. Use EditAction::make() and DeleteAction::make() when the index table should expose per-record controls; delete is explicit opt-in and remains policy-aware.

Actions and pages participate in the same package-owned resource surface model as table(), form(), and infolist(). That keeps custom resource pages and resource-level actions from drifting into an ad hoc subsystem.

Form relation fields

BelongsTo belongs in form() when the create/edit screen should write one local foreign key:

<?php

BelongsTo::make('customer_id', 'Customer')
    ->resource(CustomersResource::class)
    ->titleAttribute('name')
    ->searchable(['name', 'email'])
    ->required();

The field renders as a lazy searchable select, submits only the scalar FK value, and persists through the normal forceFill() form save path. The third make() argument can override the relationship name, but Flashboard infers common FK names such as customer_id -> customer. Explicit resource() wins over auto-discovery; otherwise a related resource can be inferred from a single registered resource whose model() matches the Eloquent related model.

BelongsToMany belongs in form() when the create/edit screen should write pivot membership for the current record:

<?php

BelongsToMany::make('tags', 'Tags')
    ->resource(TagsResource::class)
    ->titleAttribute('name')
    ->searchable(['name', 'slug'])
    ->modifyQueryUsing(static fn (Builder $query): Builder => $query->where('archived', false))
    ->maxItems(8);

The field renders as a lazy multi-select, submits an array of related record keys, and syncs the Eloquent BelongsToMany relation after the parent record is saved in the same transaction. Omitted fields are left untouched; an explicit empty array detaches all selected records. Pivot attributes and related-record create/update/delete flows stay outside this field.

Keep these separate from Resource::relations(). BelongsTo writes the current record's scalar FK from the form, BelongsToMany syncs the current record's pivot membership, and HasOne / HasMany are inverse relation managers because they mutate related records' FK values from the parent resource context.

Inverse relation managers

Use HasOne and HasMany in relations() when a parent resource should display and manage related records without embedding those controls into the normal form field schema.

<?php

use Pepperfm\Flashboard\Core\Relations\HasMany;
use Pepperfm\Flashboard\Core\Relations\HasOne;

public static function relations(): array
{
    return [
        HasOne::make('profile', 'Profile')
            ->resource(OrderProfilesResource::class)
            ->attachable()
            ->detachable()
            ->replaceable(),
        HasMany::make('items', 'Items')
            ->resource(OrderItemsResource::class)
            ->searchable(['name', 'sku'])
            ->perPage(10)
            ->modifyRecordsQueryUsing(static fn (Builder $query): Builder => $query->with('product'))
            ->modifyAttachOptionsQueryUsing(static fn (Builder $query): Builder => $query->where('archived', false))
            ->attachable()
            ->detachable()
            ->syncable(),
    ];
}

make(string $key, ?string $label = null, ?string $relationship = null) follows the typed-node label convention. The relationship name is inferred from $key by default; pass the third argument or call relationship() when the Eloquent method differs. Flashboard resolves Eloquent HasOne / HasMany metadata through the Laravel integration layer, infers a related resource when exactly one registered resource matches the related model, and lets resource() override inference.

Use modifyRecordsQueryUsing(fn (Builder $query): Builder => ...) to customize displayed/current related records, modifyAttachOptionsQueryUsing(fn (Builder $query): Builder => ...) to customize attach/replace/sync candidate options, or modifyQueryUsing(fn (Builder $query): Builder => ...) when the same modifier should apply to both. These callbacks are server-only, run after related resource query extensions, must return an Eloquent Builder, and are not included in runtime payloads.

Relation managers render on detail screens by default. Use showOnEdit() when the manager should also appear below an edit form. Legacy RelationDefinition output remains read-only and keeps the old badge-style payload.

Mutation modes are opt-in:

  • attachable() moves one related record by setting its FK to the parent key
  • detachable() clears a related FK only when the FK is nullable or otherwise safe
  • replaceable() on HasOne detaches the current related record and attaches the replacement in one transaction
  • syncable() on HasMany explicitly moves selected records and detaches omitted current records; it never deletes records

Relation records, attach options, and mutation actions are served through protected nested resource routes. Related resource query extensions, relation query modifiers, and policy checks are applied before records or options are exposed, and selected records are re-resolved server-side for every mutation. Related create actions open the related resource create form with a server-resolved parent context; submitted FK values are overwritten by the server before persistence.

Normal relation-manager reads and mutations do not log. HTTP-boundary failures may emit sanitized WARN/ERROR context such as resource class, relation key, action, failure category, exception class, and coarse SQL state only. Search terms, selected IDs, labels, titles, model attributes, and request payloads should not be logged.

Form authoring guidance

  • prefer $form->schema([...]) for simple CRUD resources
  • introduce Section, Tabs, and Tab nodes inside schema() when the form has meaningful operator-facing grouping
  • use purpose-built fields such as TextInput, Textarea, NumberInput, DateInput, FileUpload, RichText, PasswordInput, BelongsTo, BelongsToMany, Select, Checkbox, and Toggle
  • pass the label as the optional second argument to keyed typed nodes, for example TextInput::make('name', 'Name'); keep ->label() for later overrides
  • use columns(), gap(), columnSpan(), and fullWidth() to place multiple fields on one row without extra layout nodes
  • keep legacy array definitions only as a migration bridge

Configuration styles

Flashboard currently supports both:

  • typed schema nodes such as TextColumn::make('status', 'Status')
  • concept-aligned nodes such as TextInput, BelongsTo, BelongsToMany, DateInput, FileUpload, RichText, Section, Tabs, Tab, and SelectFilter
  • legacy compatibility arrays such as ['key' => 'status', 'label' => 'Status']

Typed nodes are the preferred public API going forward.

Common overrides

  • routeBasePath()
  • navigationLabel()
  • navigationGroup()
  • navigationIcon()
  • formRules()
  • mutateFormDataBeforeSave()
  • afterSave()
  • policy()

Resources can customize their sidebar icon with navigationIcon():

public static function navigationIcon(): string
{
    return 'lucide:annoyed';
}

Icon names use the Nuxt UI/Iconify format without the leading i-: collection:name. Browse available names on Icones. Flashboard passes the value to Nuxt UI with i- added under the hood and supports the lucide and heroicons collections, for example lucide:annoyed or heroicons:cube.

Escape hatches

  • queryExtensions()
  • payloadExtensions()
  • actionExtensions()
  • runtimeHooks()

Use these when the declarative path is not enough.

Generate with prompts

php artisan flashboard:make-resource

The command requires only the model class. Resource class, primary field, secondary field, navigation group, and detail screen prompts can be accepted as-is. By default, the resource class is inferred from the model, the generated table/form uses id, and the detail screen is disabled. Edit/delete table actions and create/edit form scaffolding are generated by default. Generated resources default to typed table, form, and infolist definitions. The generator chooses purpose-built form fields for common names: password-like fields become PasswordInput with a generated hashing mutation and are omitted from generated table/detail output, date-like fields become DateInput, upload-like fields become FileUpload with a default storage directory, body/content fields become RichText, and notes/description fields become Textarea.