# Actions · Martis docs

> Martis Actions allow you to run custom operations on resources. Four distinct action types are supported:

Source: https://getmartis.com/docs/core/actions

# Actions

> Last updated: 2026-04-10

## Overview

Martis Actions allow you to run custom operations on resources. Four distinct action types are supported:

| Type | Description | Selection required? |
| --- | --- | --- |
| **Standard (Bulk)** | Runs on one or more selected records | Yes (1+) |
| **Inline** | Button rendered directly on each table row | No (auto-targets row) |
| **Standalone** | Runs without any record selection | No |
| **Sole** | Requires exactly one record to be selected | Yes (exactly 1) |

Martis ships icons, menu grouping with submenus, dry-run preview, pivot actions, and custom components.

## Artisan Generator

```
# Standard action
php artisan martis:action PublishPost
 
# Destructive action (red UI, stricter authorization)
php artisan martis:action ArchivePost --destructive
```

Generated files are placed in `app/Martis/Actions/`.

## Standard (Bulk) Actions

Bulk actions run on **one or more selected records**. The user selects rows using checkboxes in the index table, then triggers the action from the Actions dropdown. The action receives the full `Collection` of selected models.

### Defining a Bulk Action

```
<?php
 
namespace App\Martis\Actions;
 
use Illuminate\Support\Collection;
use Martis\Actions\Action;
use Martis\Actions\ActionFields;
use Martis\Actions\ActionResponse;
 
class PublishPosts extends Action
{
    public ?string $name = 'Publish Posts';
 
    /**
     * Execute on all selected models.
     */
    public function handle(ActionFields $fields, Collection $models): ActionResponse|Action|null
    {
        foreach ($models as $post) {
            $post->update(['status' => 'published', 'published_at' => now()]);
        }
 
        return ActionResponse::message("{$models->count()} post(s) published successfully.");
    }
}
```

### Registering on a Resource

```
use Illuminate\Http\Request;
use Martis\Resource;
 
class PostResource extends Resource
{
    public static function model(): string
    {
        return \App\Models\Post::class;
    }
 
    public function actions(Request $request): array
    {
        return [
            PublishPosts::make(),
        ];
    }
}
```

### How it looks

- Checkbox column appears on the index table
- User selects one or more rows
- "Actions" dropdown appears in the table toolbar
- User picks an action — confirmation modal opens. It closes through Cancel, the X or Escape, not on a click outside it, so a stray click does not lose the fields typed in it (v2.16.4; the pivot action and inline create modals behave the same)
- `handle()` receives a `Collection` with all selected models

## Inline Actions

Inline actions appear **as buttons directly on each table row** — no checkbox selection required. Clicking triggers the action for that single row.

### Defining an Inline Action

```
<?php
 
namespace App\Martis\Actions;
 
use Illuminate\Support\Collection;
use Martis\Actions\Action;
use Martis\Actions\ActionFields;
use Martis\Actions\ActionResponse;
 
class ApproveComment extends Action
{
    public ?string $name = 'Approve';
 
    public function handle(ActionFields $fields, Collection $models): ActionResponse|Action|null
    {
        foreach ($models as $comment) {
            $comment->update(['approved' => true]);
        }
 
        return ActionResponse::message('Comment approved.');
    }
}
```

### Registering as Inline

Call `showInline()` when registering the action. Pair with `icon()` for best visual results.

```
public function actions(Request $request): array
{
    return [
        ApproveComment::make()
            ->showInline()
            ->icon('check-circle')
            ->withoutConfirmation(),   // optional: skip confirmation modal
 
        // Inline + visible in the dropdown too (default index behaviour)
        FlagComment::make()
            ->showInline()
            ->showOnIndex()
            ->icon('flag'),
    ];
}
```

### In relationship panels

An inline action also appears on each row of every `HasMany`, `HasManyThrough` and `MorphMany` panel that lists the resource (v2.0, as in Nova), laid out as on the index: an action without a `group()` is an icon button, the grouped ones sit in the "..." menu. Each is disabled where the record may not run it, by the same predicate the run enforces (`canRun()`, then, unless the action is `standalone()`, the `runAction` policy, or `runDestructiveAction` for a destructive action; see [Authorization](https://getmartis.com/docs/core/actions#authorization)). Actions that cannot run stay visible and disabled, as on the index.

The panel runs the action on that one record through the same modal, and sends the relationship with it (`viaResource`, `viaResourceId`, `viaRelationship`, as Nova does). The run then finds its records as that panel lists them: the relationship's own rows, a global scope the relationship removes (`->withoutGlobalScope(ArchivedScope::class)`) staying removed, so a row the panel shows runs even when the resource's own index hides it; narrowed by the resource's `scopes()` and `indexQuery()`; with the trashed ones when the panel offers its trashed filter (`softDeletes()` and `canViewTrashed()`). A record the panel does not list (another parent's, one those hooks hide) answers 404, and so does a relationship the parent does not declare, for a `standalone()` action too, which then runs on no record. An `ActionResponse::openCreate()` / `openDetail()` / `openUpdate()` answer opens its drawer over the page, as on the index.

Each panel row carries an `_actionAuthorization` map of its inline actions (the index maps every action); a standalone one is checked by its `canRun()` alone. `BelongsToMany` / `MorphToMany` panels keep their pivot actions instead.

### Visibility control

| Method | Effect |
| --- | --- |
| `showInline()` | Also show as a per-row button |
| `onlyInline()` | Show **only** as per-row button (hidden from dropdown) |
| `exceptInline()` | Never show as per-row button |

### Buttons and the "..." menu (v2.17.0)

A row draws its inline actions as icon buttons, then a "..." menu. An action goes to the menu when it has a `group()` (a submenu, a dot nesting a further level) or when it calls `inRowMenu()` (a flat item, no submenu). Flat items come first, then the group submenus. The same layout applies in the relationship panels.

```
RebuildMachine::make()->showInline()->inRowMenu();               // flat item in "..."
StartMachine::make()->showInline()->inRowMenu()->group('Power'); // "Power ▸" submenu
```

A resource can cap the buttons instead of marking each action: with `protected static ?int $maxInlineButtons = 2;` the first two ungrouped inline actions stay buttons and the rest go to the menu as flat items. The count is taken on the declared actions, before an inapplicable one is hidden, so the same actions are buttons on every row. A negative value throws an `InvalidArgumentException` naming the resource.

### An action a record cannot run (v2.17.0)

By default an action the record cannot run (its `canRun()` or run policy says no) is drawn greyed out. `whenInapplicable()` changes that, per action:

```
use Martis\Enums\InapplicableAction;
 
SwitchOn::make()->showInline()->whenInapplicable(InapplicableAction::Hide)
    ->canRun(fn ($request, $item) => ! $item->live);
MoveUp::make()->showInline()->whenInapplicable(InapplicableAction::KeepSlot)
    ->canRun(fn ($request, $item) => $item->position > 1);
```

| Mode | Row button | Menu item | Detail page |
| --- | --- | --- | --- |
| `Disabled` (default) | greyed out | greyed out | greyed out |
| `Hide` | left out | left out | left out |
| `KeepSlot` | invisible placeholder that keeps its place, so the next icons line up from row to row | left out | left out |

- A group submenu whose actions are all left out is not drawn, nor is the "..." button when its menu would be empty.
- The detail page now grays out or leaves out the actions its record cannot run, from the same verdict: the record's detail payload carries `_actionAuthorization` as an index row does. Before v2.17.0 the detail page offered every action and the run refused the record.
- The run refuses an inapplicable record whatever the mode: this only changes what is drawn.
- Nova hides a row action the row cannot run and shows it disabled in the detail menu (its public `nova-dusk-suite`, `IndexActionTest` and `DetailActionTest`). Martis keeps `Disabled` as the default so existing lists do not change; `Hide` gives Nova's row behaviour, and the detail page grays out as Nova does.

### How it looks

- Each row shows a small icon button (no checkbox needed)
- Clicking the button immediately targets that row's model
- Tooltip shows the action name on hover
- Confirmation modal opens (unless `withoutConfirmation()` is set)

## Standalone Actions

Standalone actions run **without any record selection**. They appear in the Actions dropdown but do not require the user to select rows first. Useful for resource-wide operations like export-all or import.

### Defining a Standalone Action

```
<?php
 
namespace App\Martis\Actions;
 
use Illuminate\Support\Collection;
use Martis\Actions\Action;
use Martis\Actions\ActionFields;
use Martis\Actions\ActionResponse;
 
class ExportAllPosts extends Action
{
    public ?string $name = 'Export All Posts';
 
    public function handle(ActionFields $fields, Collection $models): ActionResponse|Action|null
    {
        // $models is empty for standalone actions — query directly
        $csv = \App\Services\PostExporter::exportAll();
 
        return ActionResponse::download('posts.csv', $csv->url());
    }
}
```

### Registering as Standalone

```
public function actions(Request $request): array
{
    return [
        ExportAllPosts::make()
            ->standalone()
            ->icon('file-arrow-down')
            ->onlyOnIndex(),
    ];
}
```

### How it looks

- Action appears in the dropdown regardless of row selection
- No "select rows first" requirement
- `$models` collection is always empty — query data yourself inside `handle()`

## Destructive Actions

Destructive actions extend `DestructiveAction` instead of `Action`. The UI renders with **red styling** (border and header accent) and the confirmation button is labeled destructively. Laravel authorization policies check `runDestructiveAction()` or fall back to `delete()`.

### Defining a Destructive Action

```
<?php
 
namespace App\Martis\Actions;
 
use Illuminate\Support\Collection;
use Martis\Actions\ActionFields;
use Martis\Actions\ActionResponse;
use Martis\Actions\DestructiveAction;
 
class DeleteSelectedPosts extends DestructiveAction
{
    public ?string $name = 'Delete Posts';
 
    public function handle(ActionFields $fields, Collection $models): ActionResponse|Action|null
    {
        $count = $models->count();
 
        foreach ($models as $post) {
            $post->delete();
        }
 
        return ActionResponse::message("{$count} post(s) deleted.");
    }
}
```

### Registering a Destructive Action

```
public function actions(Request $request): array
{
    return [
        DeleteSelectedPosts::make()
            ->icon('trash')
            ->confirmText('This will permanently delete the selected posts.')
            ->confirmButtonText('Delete')
            ->cancelButtonText('Keep'),
    ];
}
```

## Queued (Batch) Actions

For heavy operations (sending emails, generating reports, processing large datasets), implement `ShouldQueue` on the action class. The action dispatches as a real Laravel job and returns immediately to the user with a "queued" toast.

**When to use queued actions:**

- Converting 200 records to PDF — synchronous would block the HTTP response for minutes
- Sending bulk emails — external API calls add up quickly
- Processing large datasets — CSV/Excel exports of thousands of records
- Any operation that takes more than a few seconds per record

### Defining a Queued Action

```
<?php
 
namespace App\Martis\Actions;
 
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Collection;
use Martis\Actions\Action;
use Martis\Actions\ActionFields;
use Martis\Actions\ActionResponse;
 
class SendNewsletterToSubscribers extends Action implements ShouldQueue
{
    use Queueable;
 
    public ?string $name = 'Send Newsletter';
 
    // Optional: target a specific queue
    public string $queue = 'emails';
    public string $connection = 'redis';
 
    public function handle(ActionFields $fields, Collection $models): ActionResponse|Action|null
    {
        foreach ($models as $subscriber) {
            \Mail::to($subscriber->email)->send(
                new \App\Mail\Newsletter($fields->subject, $fields->body)
            );
        }
 
        return ActionResponse::message('Newsletter sent to all selected subscribers.');
    }
 
    /**
     * Fields collected from the user before queuing.
     */
    public function fields(\Illuminate\Http\Request $request): array
    {
        return [
            \Martis\Fields\Text::make('subject', 'Subject')->required(),
            \Martis\Fields\Textarea::make('body', 'Body')->required(),
        ];
    }
}
```

### Registering a Queued Action

```
public function actions(Request $request): array
{
    return [
        SendNewsletterToSubscribers::make()
            ->icon('envelope')
            ->confirmText('This will send an email to all selected subscribers.'),
    ];
}
```

### Real-World Example: Convert Records to PDF

If you need to convert 200 records to PDF, this is a **queued action**:

```
<?php
 
namespace App\Martis\Actions;
 
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Martis\Actions\Action;
use Martis\Actions\ActionFields;
use Martis\Actions\ActionResponse;
use Martis\Fields\Select;
use Martis\Fields\Text;
 
class GenerateReportPdf extends Action implements ShouldQueue
{
    public ?string $name = 'Generate PDF Report';
 
    public string $connection = 'redis';
    public string $queue = 'default';
 
    public function handle(ActionFields $fields, Collection $models): ActionResponse|Action|null
    {
        $paperSize = $fields->paper_size ?? 'a4';
        $title = $fields->report_title ?? 'Report';
 
        foreach ($models as $model) {
            // Use any PDF library (DomPDF, Snappy, Browsershot, etc.)
            // Pdf::loadView('reports.record', ['record' => $model])
            //     ->setPaper($paperSize)
            //     ->save(storage_path("app/reports/{$model->getKey()}.pdf"));
        }
 
        return ActionResponse::message(
            $models->count() . ' PDF report(s) generated successfully.'
        );
    }
 
    public function fields(Request $request): array
    {
        return [
            Text::make('report_title', 'Report Title')
                ->default('Monthly Report')
                ->nullable(),
            Select::make('paper_size', 'Paper Size')
                ->options(['a4' => 'A4', 'letter' => 'Letter', 'legal' => 'Legal'])
                ->default('a4'),
        ];
    }
}
```

**What happens when the user selects 200 records and runs this action:**

1. HTTP request arrives at `ActionController::execute()`
2. Controller detects `ShouldQueue` interface on the action class
3. An `ExecuteAction` job is dispatched to the queue
4. 200 `ActionEvent` records are created with `status = "queued"` (all sharing the same `batch_id`)
5. HTTP response returns immediately: `"Action has been queued for processing."`
6. The queue worker picks up the job and calls `handle()` with all 200 models
7. After processing, `ActionEvent` records update to `status = "completed"` (or `"failed"`)

**The job payload is encrypted when the fields carry a secret (v2.4.0).** The values the run resolved travel in the job, and the queue driver stores the serialized job (a `jobs` row, a Redis key, an SQS message). When a `Password` or `sensitive()` field of the action, or a `sensitive` attribute of a Repeater row, has a non-empty value, `ExecuteAction` and `ExecutePivotAction` are dispatched with `$shouldBeEncrypted = true` (the property Laravel reads like `ShouldBeEncrypted`): the payload is encrypted with `APP_KEY` and the worker decrypts it, so the secret is not in plain text in the queue store. An action with no secret keeps a plain payload, and the worker needs no key for it. The workers and the app must share `APP_KEY`. The audit log already masks these values (see [Hidden values](https://getmartis.com/docs/core/actions#hidden-values-in-original-and-changes-v201)).

**Testing locally:**

```
php artisan queue:work          # Run the worker continuously
php artisan queue:work --once   # Process a single job and exit
```

### Behavior

- User selects records, fills in fields, clicks Confirm
- API responds immediately with `"Action has been queued for processing."`
- A `queued` entry is written to `martis_action_events`
- The Laravel job processes the records asynchronously
- `martis_action_events` status updates to `completed` or `failed` when done

## Action Fields

Fields let you collect user input inside the confirmation modal before the action runs. Any Martis field type can be used.

```
use Illuminate\Http\Request;
use Martis\Fields\Boolean;
use Martis\Fields\Select;
use Martis\Fields\Text;
use Martis\Fields\Textarea;
 
public function fields(Request $request): array
{
    return [
        Text::make('reason', 'Reason')
            ->required()
            ->help('Explain why this action is being taken.'),
 
        Select::make('format', 'Export Format')
            ->options([
                'csv'  => 'CSV',
                'json' => 'JSON',
                'xlsx' => 'Excel',
            ])
            ->default('csv'),
 
        Textarea::make('message', 'Message'),
 
        Boolean::make('notify', 'Notify users')->default(false),
    ];
}
```

Access field values in `handle()`:

```
public function handle(ActionFields $fields, Collection $models): ActionResponse|Action|null
{
    $reason  = $fields->reason;         // dynamic property access
    $format  = $fields->get('format');  // explicit get()
    $all     = $fields->all();          // full associative array
}
```

### Fields the request cannot set

As Nova resolves an Action's fields, the run takes a value from the request only for the fields the user may set (v1.38.0+). A field the request cannot set is not validated, and `handle()` receives its `default()` whatever the request sends, or no value at all when it has no default:

| Action field | In the modal | Validated | What `handle()` receives |
| --- | --- | --- | --- |
| hidden by `canSee()` / `canSeeWhen()` | no | no | its `default()`, or nothing |
| `readonly()` | yes, read-only | no | its `default()`, or nothing |
| `computed()` | yes | no | nothing |
| any other field | yes | yes | the value the request sends |

```
public function fields(Request $request): array
{
    return [
        Textarea::make('message', 'Message')->required(),
        // Admins pick the channel; everyone else runs with `email`.
        Select::make('channel', 'Channel')
            ->options(['email' => 'Email', 'sms' => 'SMS'])
            ->default('email')
            ->canSee(fn (Request $request) => $request->user()?->isAdmin() ?? false),
        // Shown, never set by the user.
        Text::make('reference', 'Reference')->readonly()->default(fn () => 'REF-'.now()->format('Ymd')),
    ];
}
```

A user who cannot see `channel` runs the action with `channel` = `email` even when the request says `sms`, and `reference` always holds the server's default.

The rows of a `Repeater` among the fields follow the Repeater's row rules, every row being a new one (an Action stores nothing): a readonly or hidden row field holds its `default()` or nothing, a computed one nothing, and an immutable one the value the row sends. See [Repeater → Readonly, computed, hidden and immutable row fields](https://getmartis.com/docs/core/repeater#readonly-computed-hidden-and-immutable-row-fields).

This holds for a resource action and a [pivot action](https://getmartis.com/docs/core/actions#pivot-actions), synchronous, dry run or queued (the job receives the same values). A key of `fields` that names no field of the Action reaches `handle()` as sent: a [custom component](https://getmartis.com/docs/core/actions#custom-component-martis-extension) posts its own values that way, so check such a value like any other input.

Before v1.38.0 the fields endpoints listed every field of the Action, the run validated every field (a required field the user could not see failed the run) and `handle()` received every value the request sent, a hidden, readonly or computed field and the fields of a Repeater's rows included.

### Relation fields

`BelongsTo`, `MorphTo` and `Tag` fields work in `fields()` as on a resource form. Their pickers load options from the Action itself, `GET /api/resources/{resource}/actions/{action}/relatable/{attribute}`, which reads the Action's declaration of the field: its related resource, `relatableQueryUsing()` and `withoutTrashed()` apply, even when the resource declares a field under the same attribute. The usual [relatable scoping](https://getmartis.com/docs/core/relationships#relatable-scoping-precedence) runs first: the target resource's `relatableQuery()`, then the `relatable{PluralModelName}()` hook of the resource the Action runs on.

```
use Illuminate\Database\Eloquent\Builder;
use Martis\Fields\BelongsTo;
use Martis\Fields\Tag;
 
public function fields(Request $request): array
{
    return [
        BelongsTo::make('assignee', 'Assignee', UserResource::class)
            ->relatableQueryUsing(fn (Request $request, Builder $query) => $query->where('active', true)),
 
        Tag::make('labels', 'Labels')->relatedResource('labels'),
    ];
}
```

The endpoint is gated like running the Action (403 without `viewAny` on the resource, or when the Action's `canSee()` denies), then like every picker (403 without `viewAny` on the related resource), and answers 404 for an attribute the Action does not declare as a `BelongsTo`, `MorphTo` or `Tag`, and for one the user cannot see (the field's `canSee()`, v1.38.0+). The scope decides what the picker lists; `handle()` receives the submitted value under the field's attribute (`$fields->assignee_id` for the `BelongsTo` above) and should check it like any other input.

The modal of a [pivot action](https://getmartis.com/docs/core/actions#pivot-actions) asks the relationship panel instead, `GET /api/resources/{resource}/{id}/{belongs-to-many|morph-to-many}/{relationship}/actions/{action}/relatable/{attribute}`: it finds the action where the panel's fields endpoint finds it (the field's `->actions()`, then the resource's `->pivotAction()` ones), behind the same gates, and reads the action's declaration of the field with the parent resource as the source of the relatable hooks.

A picker in a row of a `Repeater` among the Action's fields adds the row to either request (`?repeater={attribute}&repeatable={type}`), and the field is read from that row type's `fields()` (v1.38.0+, see [Repeater → Relation pickers and remote selects in rows](https://getmartis.com/docs/core/repeater#relation-pickers-and-remote-selects-in-rows)).

> Before v1.38.0 these pickers asked the page's resource for the attribute: one only the Action declares answered 404 and the picker opened empty, and one the resource also declares listed the resource's options instead of the Action's.

### Default values, help text and tooltips in the modal

Each field opens on its `default()` (v2.16.0+), as Nova fills an action's fields from their defaults. A closure default is resolved on the server for the current request. A field without a default opens empty, and every opening of the modal starts again from the defaults, never from the last run's values. The run still falls back to the default of a field the request cannot set (see [Fields the request cannot set](https://getmartis.com/docs/core/actions#fields-the-request-cannot-set)), so the modal now shows the value the run would use.

The modal renders each field in the same wrapper as a resource form: the label, the required asterisk, the `tooltip()` next to the label and the `help()` text under the input, which is linked to the input with `aria-describedby` (v2.16.0+). The resource, lens, standalone and pivot action modals behave the same.

> Before v2.16.0 the modal opened every field empty and showed neither its help text nor its tooltip.

### The record the action runs on

When the modal opens for one record, `fields()` can read that record with `$this->selectedModel()` (v2.16.0+), to build a default, a help text or an option list from it:

```
use Illuminate\Http\Request;
use Martis\Fields\Text;
 
public function fields(Request $request): array
{
    $product = $this->selectedModel();
 
    return [
        Text::make('url', 'Terminal URL')
            ->default($product?->terminal_url)
            ->help($product ? "Now: {$product->terminal_url}" : null),
    ];
}
```

`selectedModel()` returns a record when the modal opens from the detail page, from a row's inline button, from a relationship panel's row, or from the index or a lens with exactly one row selected. It returns `null` for a standalone action, a bulk selection, and any request that names no record, so `fields()` must handle `null`.

The modal names the record in the fields request, `GET /api/resources/{resource}/actions/{action}/fields?resourceId={id}` (on the lens and pivot routes too, and with the `viaResource`, `viaResourceId` and `viaRelationship` of a relationship panel). The server looks the record up exactly as the run does: through the index scope (`scopes()` and `indexQuery()`), the lens's query or the panel's relationship, then the action's `canRun()` and the policy's `runAction()` for that record. A record that is outside that scope, or that the run would refuse, answers 404, so the endpoint never discloses a record the action cannot run on. The 403 answers (no `viewAny`, or `canSee()` denies) are unchanged. For a [pivot action](https://getmartis.com/docs/core/actions#pivot-actions), `selectedModel()` is the related record, with its `pivot`, as the run hands it to `handle()`.

A run that names exactly one record (and resolves it) sets the same record before it reads `fields()`, so the rules and defaults the run applies are the ones the modal showed. A run that names several records sees `null`, even when only one of them resolves. The pickers of the modal's relation fields send the same `resourceId` to their [options endpoint](https://getmartis.com/docs/core/actions#relation-fields), so a `relatableQueryUsing()` closure can read `$this->selectedModel()` too, behind the same lookup and the same 404.

`selectedModel()` is meant for `fields()`. A [queued action](https://getmartis.com/docs/core/actions#queued-batch-actions) rebuilds the action in the job, where `selectedModel()` is `null`: `handle()` receives the records in `$models`.

Nova reads the record with `$request->findModel()` in `fields()`. In Martis it is a method of the Action because a second parameter on `fields()` would change `ActionContract::fields()` for every Action an application declares. `selectedModel()` is on the `Action` base class, not on `ActionContract`.

### File fields (v2.17.0)

A `File` (or `Image`) field among an action's fields works as on a resource form. When a field holds a file, the modal posts the run as `multipart/form-data`, with the same encoding as the resource forms: the fields under `fields[...]`, the records as `resources[i]`, booleans as `1` / `0`, lists and maps as JSON (decoded again on the server). Without a file the run stays JSON.

```
public function fields(Request $request): array
{
    return [
        Text::make('title')->rules(['required']),
        File::make('file')->acceptedTypes(['mp4', 'png', 'svg'])->rules(['required']),
    ];
}
 
public function handle(ActionFields $fields, Collection $models): ActionResponse|Action|null
{
    $path = $fields->get('file')->store('materials', 'public'); // an Illuminate\Http\UploadedFile
 
    // ...
}
```

- The upload is validated with the field's own rules (`acceptedTypes()`, `maxSize()`, `rules()`), and active content is refused unless the field calls `allowActiveContent()`, as on a resource form.
- `handle()` receives the `UploadedFile`; the action stores it where it wants. Nothing is stored for you.
- A [queued action](https://getmartis.com/docs/core/actions#queued-batch-actions) cannot take an upload: the temporary file is deleted when the request ends and does not serialize into the job. A queued run that receives one throws a `LogicException` naming the action and the field, as Nova does not support File fields on a queued action either. Store the file in a synchronous action, or upload it elsewhere and pass its path.

## Post-processing with then()

The `then()` callback runs **after `handle()` completes** on a synchronous (non-queued) action. Use it to run code that should happen outside the action itself: sending notifications, invalidating caches, triggering follow-up jobs, or recording external audit entries beyond the built-in `martis_action_events` log.

### Signature

```
->then(Closure $callback): static
```

The closure receives a `Collection` wrapping the value returned by `handle()`:

```
->then(function (Illuminate\Support\Collection $responses) {
    // $responses->first() is the ActionResponse (or null) returned by handle()
})
```

### Concrete example — notify after bulk publish

```
<?php
 
namespace App\Martis\Actions;
 
use App\Models\User;
use App\Notifications\PostsPublishedNotification;
use Illuminate\Support\Collection;
use Martis\Actions\Action;
use Martis\Actions\ActionFields;
use Martis\Actions\ActionResponse;
 
class PublishPosts extends Action
{
    public ?string $name = 'Publish Posts';
 
    public function handle(ActionFields $fields, Collection $models): ActionResponse|Action|null
    {
        foreach ($models as $post) {
            $post->update(['status' => 'published', 'published_at' => now()]);
        }
 
        return ActionResponse::message("{$models->count()} post(s) published.");
    }
}
```

Register the action with a `then()` callback on the resource:

```
public function actions(Request $request): array
{
    return [
        PublishPosts::make()
            ->then(function (Collection $responses) {
                // Notify the site admin after the bulk publish
                $admin = User::where('role', 'admin')->first();
                $admin?->notify(new PostsPublishedNotification);
 
                // Or: invalidate a cache tag
                \Cache::tags(['posts'])->flush();
            }),
    ];
}
```

### When then() runs vs. when it does NOT

| Scenario | `then()` called? |
| --- | --- |
| Synchronous action — `handle()` succeeds | ✅ Yes |
| Synchronous action — `handle()` throws an exception | ❌ No |
| Queued action (implements `ShouldQueue`) | ❌ No — skipped for queued actions |
| Standalone action (no models) | ✅ Yes |

> **Queued actions:** `then()` is not called for queued actions because the HTTP response returns before `handle()` runs. For post-processing on queued actions, put the logic inside `handle()` itself or listen to the `JobProcessed` event.

### How to test

**Unit test — trigger the callback manually:**

```
use App\Martis\Actions\PublishPosts;
use App\Models\Post;
use Illuminate\Support\Collection;
use Martis\Actions\ActionFields;
 
it('calls the then callback after publish', function () {
    $notified = false;
    $post = Post::factory()->create(['status' => 'draft']);
 
    $action = PublishPosts::make()
        ->then(function (Collection $responses) use (&$notified) {
            $notified = true;
        });
 
    $models = collect([$post]);
    $fields = new ActionFields([]);
 
    // Run handle()
    $result = $action->handle($fields, $models);
 
    // Invoke the callback exactly as ActionController does
    $cb = $action->getThenCallback();
    $cb(collect([$result]));
 
    expect($notified)->toBeTrue();
    expect($post->fresh()->status)->toBe('published');
});
```

**Feature/HTTP test — verify the callback fires via the full request cycle:**

```
use App\Models\Post;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Notification;
 
it('flushes cache after bulk publish', function () {
    Cache::shouldReceive('tags')->with(['posts'])->andReturnSelf();
    Cache::shouldReceive('flush')->once();
 
    $user  = User::factory()->admin()->create();
    $posts = Post::factory()->count(3)->create(['status' => 'draft']);
 
    $this->actingAs($user)
        ->postJson('/martis/api/resources/posts/actions/publish-posts', [
            'resources' => $posts->pluck('id')->all(),
            'fields'    => [],
        ])
        ->assertOk();
});
```

Run the tests:

```
php artisan test --filter PublishPosts   # filter by class name
php artisan test                         # full suite
```

## Confirmation Modal

```
PublishPosts::make()
    ->confirmText('Are you sure you want to publish these posts?')
    ->confirmButtonText('Yes, publish')
    ->cancelButtonText('Cancel')
    ->size(\Martis\Enums\ModalSize::Large)  // Small | Medium | Large | ExtraLarge | TwoExtraLarge … SevenExtraLarge
    ->fullscreen()                           // overrides size
    ->withoutConfirmation()                  // skip modal entirely (runs immediately)
```

An action without `confirmText()` shows "Are you sure you want to run this action?" (translated) in its confirmation modal, Nova's default, so the modal never opens with a blank body (v2.15.0). The sentence shows whenever the action asks for confirmation, fields or not; an action with `withoutConfirmation()` that still opens the modal for its fields or for Preview shows none. An action with `withoutConfirmation()` runs only once its fields have loaded, and opens the modal instead when it has some (v2.15.0: it used to run before they loaded, without them). When a run without a modal fails, the error toast shows and the action can be run again.

### A text that names the record (v2.17.0)

`confirmText()`, `confirmButtonText()` and `cancelButtonText()` also take a closure, resolved each time the modal opens:

```
DeleteMaterial::make()
    ->confirmText(fn (Request $request, ?Model $item, int $count) => $item
        ? __('Delete «:title» for good? The file, the cover and the subtitles are removed.', ['title' => $item->title])
        : trans_choice('Delete :count item for good?|Delete :count items for good?', $count, ['count' => $count]))
    ->confirmButtonText(fn (Request $request, ?Model $item) => $item ? __('Delete «:title»', ['title' => $item->title]) : null),
```

- `$item` is the record the modal runs on (a row's inline action, the detail page, a single selection), the same one `selectedModel()` gives `fields()`: found through the index scopes and authorized to view, so a closure never sees a record the user cannot reach. It is `null` for a bulk or `standalone()` run.
- `$count` is the number of selected records (0 for a standalone action). The client sends it for the text only: it is never used to authorize or run.
- The closure returns the text, or `null` for the default. Any other value throws an `InvalidArgumentException` naming the action and the option.
- The texts ride on the fields request the modal already makes when it opens: no extra request, and nothing is computed per row of the index. Until it answers, a placeholder stands in for the text and the run button waits.
- The text is plain text: a title holding markup shows literally.
- A record-aware checkbox ("Also delete the Portuguese version") needs no confirmation API: `fields()` reads `$this->selectedModel()` and returns a `Boolean` with that label, whose value reaches `handle()` as any field's.

## Icons

Set any [Phosphor icon](https://phosphoricons.com/) name. Icons appear in the dropdown, inline buttons, and the action modal header.

```
PublishPosts::make()->icon('rocket-launch')
DeleteSelectedPosts::make()->icon('trash')
ExportAllPosts::make()->icon('file-arrow-down')
SendNewsletterToSubscribers::make()->icon('envelope')
ApproveComment::make()->icon('check-circle')
```

### Hiding the icon

Use `withoutIcon()` to show the label only — no icon is rendered anywhere.

```
// Menu item shows text only, no icon
Action::using('Export CSV', fn ($f, $m) => ...)->withoutIcon()
```

### Icon color

Use `iconColor()` to apply a custom CSS color to the icon. Accepts any valid CSS color value, including `var()` references.

```
// Red icon for dangerous operations
DeleteSelectedPosts::make()->icon('trash')->iconColor('#dc2626')
 
// Custom color using a theme variable
ApproveComment::make()->icon('check-circle')->iconColor('var(--martis-success)')
```

## Menu Grouping

Group actions into dropdown submenus using `group()`. Use dot-notation for nested submenus.

```
public function actions(Request $request): array
{
    return [
        // Top-level (no group)
        PublishPosts::make()->icon('rocket-launch'),
 
        // "Export" submenu
        ExportAllPosts::make()->icon('file-arrow-down')->group('Export')->standalone(),
        Action::using('Export as PDF', fn ($f, $m) => ActionResponse::message('PDF started.'))
            ->icon('file-pdf')
            ->group('Export'),
 
        // Nested: "Notifications > Email"
        SendNewsletterToSubscribers::make()->group('Notifications.Email'),
    ];
}
```

## Visibility Options

| Method | Index dropdown | Detail dropdown | Per-row button |
| --- | --- | --- | --- |
| `showOnIndex()` | ✅ | — | — |
| `showOnDetail()` | — | ✅ | — |
| `showInline()` | — | — | ✅ |
| `onlyOnIndex()` | ✅ | ❌ | ❌ |
| `onlyOnDetail()` | ❌ | ✅ | ❌ |
| `onlyInline()` | ❌ | ❌ | ✅ |
| `exceptOnIndex()` | ❌ | — | — |
| `exceptOnDetail()` | — | ❌ | — |
| `exceptInline()` | — | — | ❌ |

Legend: ✅ turns the surface **on**, ❌ turns it **off**, and `—` leaves it **unchanged** (keeps its current/default value).

Defaults: `showOnIndex = true`, `showOnDetail = true`, `showInline = false`.

> **`showInline()` is additive.** It only turns the per-row button on — it does **not** touch `showOnIndex`. Since `showOnIndex` defaults to `true`, a single action with `->showInline()` appears in **both** the index dropdown **and** as a per-row button. To render it **only** as a per-row button (hidden from the dropdown), use `onlyInline()`, which also sets `showOnIndex = false`. You never need to register the action twice.

### Actions run only from your own components

An action with every surface off, `->exceptOnIndex()->exceptOnDetail()->exceptInline()`, is listed in no menu, button or inline icon, nor in the command palette (a standalone action too: the index and lens toolbars list a standalone action only when it is shown on the index, v2.16.0+, as Nova lists the index's actions), and the action lists `GET /api/resources/{resource}/actions?context=index|detail|inline` leave it out. It still runs through its endpoints with every check of a visible action: `viewAny` on the resource, `canSee()`, `canRun()`, the policy's `runAction()`, the record scope and the action events. This is the supported way to give a custom component (a field `Display`, a Tool, a card) an action that keeps the permissions and the audit log in one place. Run it with `useRunAction()` from `@martis/runtime` (see [Overrides → Running an action from a component](https://getmartis.com/docs/customization/overrides#running-an-action-from-a-component-v2160)).

```
public function actions(Request $request): array
{
    return [
        UpdateTerminalLinks::make()->exceptOnIndex()->exceptOnDetail()->exceptInline(),
    ];
}
```

As in Nova, where the run checks `canSee()` and not where the action is shown, hiding an action from the menus is not an authorization rule: a user who passes `canSee()` and `canRun()` can run it through the API. The resource schema (`/schema`) keeps listing it for those users, as Nova's action list does.

## Execution Modes

| Method | Behaviour |
| --- | --- |
| *(default)* | Runs on 1+ selected models |
| `standalone()` | Runs with no models — selection not required |
| `sole()` | Requires exactly 1 selected model |

`Action::executionMode(): ActionExecutionMode` returns the resolved mode (`Default`, `Standalone`, `Sole`) — useful when consumer code or tests need to branch on the configured mode without re-checking each setter. The matching boolean shortcuts `isStandalone()` and `isSole()` are also available.

## Authorization

### Layer 1 — canSee()

Controls whether the action appears in the UI at all. Evaluated when listing actions.

```
PublishPosts::make()->canSee(fn (Request $request) => $request->user()->isAdmin());
```

### Layer 2 — canRun()

Controls whether a specific model can be acted on. Evaluated per model at execution, and for each listed row, where a row button or menu item the check refuses is disabled.

```
PublishPosts::make()->canRun(fn (Request $request, $model) => $model->status === 'draft');
```

### Deciding a page in one call — canRunForRows() (v2.20.0)

A `canRun()` runs once per listed row. When it needs a query (an `EXISTS`, a lookup in another table), a page of 100 rows runs it 100 times. `canRunForRows()` is the batched alternative: it receives the whole page and answers for every row in one call.

```
use Illuminate\Database\Eloquent\Collection;
 
RevokeGrant::make()->showInline()->canRunForRows(function (Request $request, Collection $rows): array {
    $granted = Grant::whereIn('user_id', $rows->modelKeys())->pluck('user_id')->flip();
 
    return $rows->mapWithKeys(fn ($user) => [$user->getKey() => $granted->has($user->getKey())])->all();
});
```

- It returns `[row key => bool]`. Return an entry for every row. A row the array omits falls back to the action's per-row `canRun()` when there is one, and is **denied** when there is none, on the page and on the run alike (the rule fails closed). Keys are compared as strings, so an int key and its string form, or a uuid, match. A value that is not an array throws an `UnexpectedValueException` naming the action. So does a key that is no row of the batch: a list written as `$rows->map(fn ($row) => ...)->all()` is keyed `0..n-1`, not by row key, and would silently allow one row and deny another. Key it with `mapWithKeys()` as above.
- Martis calls it **once per page** where the index, a lens or a relationship panel marks each row's `_actionAuthorization`. A page's `prefetchIndexPage()` has already run (see [Avoiding per-row queries on index pages](https://getmartis.com/docs/core/resources#avoiding-per-row-queries-on-index-pages-v2200)), so the rule can read what it loaded.
- **The run is refused for what the page hides.** The run, the action's fields request and a pivot run decide with the same rule: a bulk run calls it once with every selected record and refuses the whole run (404) when it denies any of them, as it does for `canRun()`. The policy checks (`runAction` / `runDestructiveAction`) still apply on top. When only `canRunForRows()` is set, a check of a single record (`authorizedToRun()`) asks it with that one row, so no route skips the rule.
- **An overridden `authorizedToRun()` still counts.** A subclass that overrides `authorizedToRun()` next to a batched rule is asked for each row too, and a row runs only when **both** allow it (deny if either denies), on the page, the detail page, the run, the fields request and a pivot run. The override is asked per row, and a `parent::authorizedToRun()` inside it asks the batch for that one row, so do not call `parent` from the override when you want the batch to stay one call per page.
- `canRunForRows()` is on `Action`, not on `ActionContract` (as `opensInNewTab()` below): a class that implements the contract itself keeps compiling and keeps its per-row `authorizedToRun()`, with no batched rule.
- With both set, the batch answers for the rows it names and `canRun()` for the others, on the page and on the run alike.
- Without `canRunForRows()` nothing changes: `canRun()` runs per row, on the page and on the run.

### Layer 3 — Policy integration

The controller resolves authorization via a fallback chain on the resource's policy. It applies to each record, after `canRun()`, except for a `standalone()` action; each row of the index and of a relationship panel is marked with the same result, so an action the menu enables is one the run accepts. Both must allow the run: unlike Nova 5, where a `canRun()` replaces the policy, a `canRun()` grants nothing the policy refuses (see [Differentials](https://getmartis.com/docs/reference/differentials)):

**Normal actions** fall back through:

1. `Policy::runAction($user)`
2. `Policy::update($user, $model)`
3. Permissive (returns `true`)

**Destructive actions** fall back through:

1. `Policy::runDestructiveAction($user)`
2. `Policy::delete($user, $model)`
3. Permissive (returns `true`)

### Example Policy

```
namespace App\Policies;
 
use App\Models\Post;
use App\Models\User;
 
class PostPolicy
{
    public function runAction(User $user): bool
    {
        return $user->hasRole('editor');
    }
 
    public function runDestructiveAction(User $user): bool
    {
        return $user->hasRole('admin');
    }
}
```

### Binding the policy to a resource

```
class PostResource extends Resource
{
    protected static string $policy = \App\Policies\PostPolicy::class;
 
    // ...
}
```

## Action Responses

| Response | Effect |
| --- | --- |
| `ActionResponse::message('Done')` | Green success toast, then the page refreshes |
| `ActionResponse::danger('Failed')` | Red error toast, then the page refreshes |
| `ActionResponse::redirect($url)` | Full-page browser redirect to an `http(s)` URL or a path (see [Response URLs](https://getmartis.com/docs/core/actions#response-urls)) |
| `ActionResponse::visit($path, $params)` | SPA navigation (no reload) to `$path` below the Martis base path, with `$params` added to its query string (`visit('/resources/users', ['view' => 'open'])`); a query or `#fragment` the path already has is kept (`visit('/resources/users?view=open#top', ['page' => 2])` goes to `/resources/users?view=open&page=2#top`) |
| `ActionResponse::openInNewTab($url)` | Opens the URL in a new tab (an `http(s)` URL or a path). Declare `opensInNewTab()` on the action so the tab opens reliably (see [Opening a new tab](https://getmartis.com/docs/core/actions#opening-a-new-tab)) |
| `ActionResponse::download($filename, $url)` | Downloads the file as `$filename` (an `http(s)` URL or a path) |
| `ActionResponse::emit($event, $data, $message)` | Emits `$event` with `$data` on `martisEventBus`, then the success toast (`$message` when given, else the generic one; `->silent()` shows none). See [Client-side events](https://getmartis.com/docs/core/actions#client-side-events) |
| `ActionResponse::modal($component, $data)` | Shows the component registered under `$component` (see [Custom modal responses](https://getmartis.com/docs/core/actions#custom-modal-responses)) |
| `ActionResponse::openCreate()` / `openDetail()` / `openUpdate()` | Opens the matching drawer |

Every successful run fires `martis:action-executed` on `martisEventBus`, and dashboard metrics refetch on it, as Nova's do (v2.3.0). Pivot actions handle every response above but the three drawers, which fall back to the success toast.

### Response URLs

`redirect()`, `openInNewTab()` and `download()` hand their URL to the browser, and a `javascript:` URL there would run script in the panel with the viewer's session. They take an `http(s)` URL or a path (`/exports/posts.csv`, `//cdn.example.com/file`, `exports/posts.csv`) and, since v2.4.0, throw an `InvalidArgumentException` for any other scheme (`javascript:`, `data:`, `vbscript:`, `file:`, `mailto:`, `blob:`, ...), however it is spelled (leading spaces, tabs inside the scheme). The panel refuses the same URLs on its side too (a console error, nothing navigates), as `visit()` already refuses a path outside the app. A URL built from record data is therefore safe to pass; a `data:` download is not supported, serve the file from a route or a signed URL instead.

### Opening a new tab

A browser lets a page open a tab only while it handles a click. The answer of an action arrives after its request, no longer tied to the click that ran it, so a tab opened then is blocked: always by Safari (desktop and iOS), and by the other browsers when the run takes more than a few seconds. Declare it on the action and the panel opens a blank tab on the click itself (Run Action, or the click on an action that skips the modal), then sends it to the URL of the answer (v2.15.0):

```
OpenInStripe::make()
    ->opensInNewTab()
    ->withoutConfirmation();
 
// in handle():
return ActionResponse::openInNewTab("https://dashboard.stripe.com/customers/{$customer->stripe_id}");
```

- Any other answer (`message()`, `danger()`, ...), a failed run or a refused URL closes the blank tab, so declare it only on an action whose run answers `openInNewTab()`.
- The new tab's `opener` is cut before it leaves `about:blank`, as `rel="noopener"` would, so the page cannot reach back into the panel. The panel's origin is sent as the referrer (the browser's default policy sends the origin only to another site); serve the panel with `Referrer-Policy: no-referrer` to send none.
- A run that never answers (the request hangs, or the panel tab is reloaded meanwhile) leaves the blank tab open.
- When the browser blocks the tab anyway (a popup blocker set to block everything), a warning toast says so and carries a link to the page: following a link is a fresh click, which the browser lets through. An action without `opensInNewTab()` opens its tab the same way when the answer arrives, and gets the same warning when that late tab is blocked, instead of failing silently.
- `opensInNewTab()` is on `Martis\Actions\Action`, not on `ActionContract`, so a class that implements the contract itself keeps compiling.

**A link needs no action.** When the page to open is known when the row renders (a customer's page in Stripe, an invoice PDF on a CDN) and the click does no server work, show it as a computed `Url` field instead: it renders a plain link (`target="_blank" rel="noopener noreferrer"`), so no request runs and no browser blocks it, and `canSee()` decides who gets it.

```
Url::make('stripe', 'Stripe')
    ->computed(fn (Customer $customer): ?string => $customer->stripe_id !== null
        ? "https://dashboard.stripe.com/customers/{$customer->stripe_id}"
        : null)
    ->displayText('Open in Stripe')
    ->exceptOnForms()
    ->canSee(fn (Request $request): bool => $request->user()->can('viewBilling')),
```

### Custom modal responses

`ActionResponse::modal($component, $data)` shows a React component you register in your extension bundle, with `$data` as its `data` prop, as Nova renders a modal response (v2.3.0). The component draws its own dialog and calls `onClose`; the page the action ran from refreshes when it closes. Use it to show a value once, such as a generated password or a new API token. Draw the dialog in the modal shell the built-in modals use (`martis-modal-scrim`, `martis-modal-surface`, see [components.md](https://getmartis.com/docs/customization/components)): the host renders the component after the page, and the scrim is what lays it over the screen:

```
public function handle(ActionFields $fields, Collection $models): ActionResponse
{
    $token = Str::random(40);
    $models->first()->forceFill(['api_token' => hash('sha256', $token)])->save();
 
    return ActionResponse::modal('api-token-issued', ['token' => $token]);
}
```

```
// A module your extension entry (resources/js/martis-extensions/index.ts) imports.
import { componentRegistry, type ActionResponseModalProps } from '@martis/runtime'
 
function ApiTokenIssued({ data, onClose }: ActionResponseModalProps) {
  return (
    <div className="martis-modal-scrim">
      <div role="dialog" aria-modal="true" aria-labelledby="api-token-issued-title" className="martis-modal-surface">
        <div className="martis-modal-head">
          <h3 id="api-token-issued-title" className="martis-modal-head-title">New API token</h3>
        </div>
        <div className="martis-modal-body">
          <p>Copy the token now: it is shown once.</p>
          <code>{String(data.token)}</code>
        </div>
        <div className="martis-modal-foot">
          <button type="button" className="martis-btn-primary" onClick={onClose}>Done</button>
        </div>
      </div>
    </div>
  )
}
 
componentRegistry.register('api-token-issued', ApiTokenIssued)
```

`data` is always an object: an action that passes no `$data` gives `{}`. A key with no registered component logs a console warning and refreshes the page. A component that throws while it renders logs `[martis] action response component "<key>" threw` with the error, closes, and the page refreshes; the rest of the panel stays. A second modal answer closes the one still open (its page refreshes) before it shows.

### Client-side events

`ActionResponse::emit($event, $data)` emits `$event` on the Martis event bus with `$data` as its payload, as Nova emits on `Nova.$emit`. Listen from your extension:

```
import { useEffect } from 'react'
import { martisEventBus } from '@martis/runtime'
 
export function ReportsCounter() {
  useEffect(() => {
    const onRefresh = (payload: Record<string, unknown>) => console.log('Reports changed', payload)
    martisEventBus.on('reports:refresh', onRefresh)
    return () => martisEventBus.off('reports:refresh', onRefresh)
  }, [])
 
  return null
}
```

The SPA shows the generic success toast after the event. Pass `$message` as the third argument to show your own text instead, or chain `silent()` when the listener gives the feedback itself. The event is emitted and the table refreshes in every case.

```
return ActionResponse::emit('reports:refresh', ['id' => 7], 'Report queued');
return ActionResponse::emit('reports:copy', ['id' => 7])->silent();
```

`silent()` returns a modified copy and throws `InvalidArgumentException` on a response that is not an `emit()`. A call without `$message` or `silent()` sends the same payload as before.

## Full Execution Flow

This section traces exactly what happens from the moment the user clicks "Run Action" to the moment the response is shown — including how the audit log is populated.

### Step-by-step: synchronous action (non-queued)

```
User clicks "Run Action" in the browser
         │
         ▼
POST /martis/api/resources/{resource}/actions/{action}
     Body: { "resources": [1, 2, 3], "fields": { ... } }
         │
         ▼
ActionController::execute()
  1. Resolve resource class from URI key
  2. Find action by URI key (uriKey())
  3. Check canSee() — 403 if unauthorized
  4. Load Eloquent models by the IDs in "resources", as the index lists
     them: through the resource's scopes() and indexQuery() (trashed
     records included when the resource soft-deletes). With viaResource /
     viaResourceId / viaRelationship, as that panel lists them: the
     relationship's rows (the global scopes it removes stay removed), the
     resource's scopes() and indexQuery() by key, trashed records when the
     panel offers its trashed filter.
 
  4. Load Eloquent models by the IDs in "resources", through the
     resource's scopes() and indexQuery(), grouped so an orWhere() in
     them cannot widen the selection (trashed records included when the resource
     soft-deletes) and, with viaResource / viaResourceId /
     viaRelationship, only among the records that relationship reaches.
     The action runs on the IDs that resolve, as in Nova; 404 when none
     does, rather than running on nothing, and 422 when "resources" is
     empty. A standalone() action loads no model (the relationship it
     names is still checked).
  5. Check canRun() and the policy per model: 404 if any is refused
  6. Validate the fields the request may set against their rules
     (a hidden, readonly or computed field is not validated and gets its
     default(); see "Fields the request cannot set")
  7. Capture model attribute snapshots (state BEFORE execution)
         │
         ▼
  8. Call action->handle($fields, $models)
         │
         ▼
  9. Refresh each model (reload from DB to capture changes)
 10. Call logActionEvent() — insert ActionEvent rows:
     - One row per model, all sharing the same batch_id UUID
     - status = "completed"
     - original = changed attributes with values BEFORE
     - changes  = changed attributes with values AFTER
         │
         ▼
 11. Call then() callback (if registered)
         │
         ▼
 12. Return ActionResponse as JSON to the browser
     → UI shows toast / redirects / etc.
```

### Step-by-step: queued action (implements ShouldQueue)

```
Same steps 1-7 as above
         │
         ▼
  8. Dispatch ExecuteAction job to the queue
  9. Call logActionEvent() — insert ActionEvent rows with status = "queued"
         │
         ▼
 10. Return immediately: "Action has been queued for processing."
     (then() is NOT called for queued actions)
 
--- later, in the queue worker ---
 
ExecuteAction::handle()
  1. Re-instantiate the action from its class name
  2. Re-load the models by the IDs the request resolved, without global
     scopes, as Laravel restores a queued job's models (the request already
     scoped them; a trashed record or one only the panel's relationship
     reaches is handled as in a synchronous run)
  3. Capture model snapshots
  4. Call action->handle($fields, $models)
  5. Refresh models
  6. Update ActionEvent rows — status = "completed" or "failed"
```

### What lands in martis_action_events

Every action execution writes one `ActionEvent` row per model. Example for "Publish Posts" on posts [1, 2, 3]:

```
id  batch_id (UUID)          user_id  name           actionable_type  id  status     original                  changes
─   ───────────────────────  ───────  ─────────────  ───────────────  ──  ─────────  ────────────────────────  ──────────────────────────
1   550e8400-…-446655440000  7        Publish Posts  App\Models\Post  1   completed  {"status":"draft"}        {"status":"published"}
2   550e8400-…-446655440000  7        Publish Posts  App\Models\Post  2   completed  {"status":"draft"}        {"status":"published"}
3   550e8400-…-446655440000  7        Publish Posts  App\Models\Post  3   completed  {"status":"draft"}        {"status":"published","published_at":"2026-04-10T12:00:00"}
```

Key points:

- All 3 rows share the same `batch_id` — they came from one action run
- `original` and `changes` store **only the diff** — unchanged attributes are not stored
- `fields` holds the values the run resolved for the action's visible fields, with a `Password` or [`sensitive()`](https://getmartis.com/docs/core/fields#sensitive-fields) field masked as `******` (see [Action field values in the log](https://getmartis.com/docs/core/actions#action-field-values-in-the-log))
- For standalone actions, `actionable_type/id` are `null` (no model targeted)
- For a [pivot action](https://getmartis.com/docs/core/actions#pivot-actions), one row per selected related record with Nova's mapping: `actionable` is the record whose relationship panel ran the action, `target` the related record, `model` its pivot row (the pivot class, and the pivot key when the table has one), and `original` / `changes` hold the pivot columns the action changed (v1.38.0+)

### Querying the audit log

```
use Martis\Models\ActionEvent;
 
// All events for a specific batch (one action run)
ActionEvent::forBatch($batchId)->get();
 
// All "Publish Posts" executions, newest first
ActionEvent::forAction("Publish Posts")->latest()->paginate(25);
 
// What did user #7 do today?
ActionEvent::forUser(7)->whereDate("created_at", today())->get();
 
// Failed actions in the last 7 days
ActionEvent::where("status", "failed")
    ->where("created_at", ">=", now()->subDays(7))
    ->get();
 
// History for a specific post (Post model uses the Actionable trait)
$post = Post::find(1);
$post->actions()->latest()->get();
```

### Error handling in the log

If `handle()` throws an exception:

1. The exception is caught in `ActionController`
2. An `ActionEvent` row is written with `status = "failed"` and the exception message in the `exception` column
3. `then()` is **not** called
4. The HTTP response returns HTTP 500 with the error message
5. The UI shows a red error toast

For queued actions, if `handle()` throws inside the job, Laravel retries/fails the job normally and updates the `ActionEvent` row to `status = "failed"`.

## Action Events (Audit Log)

Every action execution is logged to the `martis_action_events` table automatically. Martis ships with a built-in **ActionEvent model**, a publishable **migration**, an **Actionable trait** for querying history, and a **built-in Resource** for browsing the audit log in the admin panel.

### Setup

Publish and run the migration to create the `martis_action_events` table:

```
php artisan vendor:publish --tag=martis-migrations
php artisan migrate
```

### Table Schema

| Column | Type | Description |
| --- | --- | --- |
| `id` | `bigint` | Auto-increment primary key |
| `batch_id` | `uuid` | Groups per-model records from a single bulk execution |
| `user_id` | `int\|null` | ID of the user who triggered the action |
| `name` | `string` | Action display name (e.g. "Publish Posts") |
| `actionable_type` | `string\|null` | Polymorphic model class (e.g. `App\Models\Post`); for a pivot action, the class of the record whose panel ran it |
| `actionable_id` | `int\|null` | ID of the target model; for a pivot action, that record's ID |
| `target_type` | `string\|null` | Target model class; for a pivot action, the related record's class |
| `target_id` | `int\|null` | Target model ID; for a pivot action, the related record's ID |
| `model_type` | `string\|null` | Source model class; for a pivot action, the pivot class |
| `model_id` | `int\|null` | Source model ID; for a pivot action, the pivot row's key (`null` when the pivot table has none) |
| `fields` | `json` | The action's field values as the run resolved them: the visible fields only, secrets masked (see [Action field values in the log](https://getmartis.com/docs/core/actions#action-field-values-in-the-log)) |
| `status` | `string` | `completed`, `failed`, or `queued` |
| `exception` | `text` | Error message on failure (empty string on success) |
| `original` | `json` | Changed attributes with their values **before** the action (diff only) |
| `changes` | `json` | Changed attributes with their new values **after** the action (diff only) |
| `created_at` | `timestamp` | When the action was executed |
| `updated_at` | `timestamp` | Last update time |

> **Per-model logging:** Bulk actions create one event per model, all sharing the same `batch_id`. The `original` and `changes` columns capture only the attributes that actually changed — not the full model snapshot.

### Understanding batch_id

The `batch_id` is a UUID generated **for every action execution**, not just for queued/batch actions. It serves as a **grouping identifier** that links all `ActionEvent` records created from a single action run.

**Why does every action have a batch_id?**

When a user selects 5 posts and runs "Publish Posts", Martis creates **5 separate `ActionEvent` records** — one per model. All 5 records share the same `batch_id` UUID, allowing you to:

- Query all models affected by a single execution: `ActionEvent::forBatch($uuid)->get()`
- Count how many records were processed in one run
- Reconstruct the full audit trail of a bulk operation

The name "batch" refers to **"a batch of models processed in one execution"**, not to "batch processing" or "queued actions". Even a single-record inline action gets a `batch_id` — it just happens to be the only record with that UUID.

| Scenario | Records selected | ActionEvent rows | batch_id |
| --- | --- | --- | --- |
| Inline action on 1 record | 1 | 1 | Single UUID |
| Bulk action on 5 records | 5 | 5 | Same UUID shared by all 5 |
| Standalone action (no records) | 0 | 1 (null model fields) | Single UUID |
| Queued action on 10 records | 10 | 10 (status=`queued`) | Same UUID shared by all 10 |

**Important distinction:**

- `batch_id` = grouping UUID for audit log (present on ALL actions)
- Queued action = action that implements `ShouldQueue` and runs in the background
- These are independent concepts. A queued action also uses `batch_id` for the same grouping purpose.

### ActionEvent Model

The `Martis\Models\ActionEvent` Eloquent model provides typed access to the audit log:

```
use Martis\Models\ActionEvent;
 
// Query all events
$events = ActionEvent::query()->latest()->paginate(25);
 
// Filter by action name
$publishes = ActionEvent::query()->forAction('Publish Posts')->get();
 
// Filter by user
$myActions = ActionEvent::query()->forUser(auth()->id())->get();
 
// Filter by batch (all records from a single execution)
$batch = ActionEvent::query()->forBatch($batchId)->get();
 
// Access relationships
$event = ActionEvent::find(1);
$event->user;          // The User model who triggered it
$event->actionable;    // The model the action ran on (polymorphic)
$event->target;        // The target model (polymorphic)
```

#### Available Scopes

| Scope | Usage | Description |
| --- | --- | --- |
| `forBatch(string $batchId)` | `->forBatch($uuid)` | Filter by batch UUID |
| `forAction(string $name)` | `->forAction('Publish Posts')` | Filter by action name |
| `forUser(int\|string $userId)` | `->forUser(1)` | Filter by user ID |

### Actionable Trait

Add the `Actionable` trait to any Eloquent model to query its action history:

```
use Illuminate\Database\Eloquent\Model;
use Martis\Concerns\Actionable;
 
class Post extends Model
{
    use Actionable;
}
 
// Query action history for a specific post
$post = Post::find(1);
 
// All actions run on this post (newest first)
$post->actions()->latest()->get();
 
// Count publish actions
$post->actions()->where('name', 'Publish Posts')->count();
 
// Get failed actions
$post->actions()->where('status', 'failed')->get();
```

#### The Action Events panel (v2.0.1+)

As in Nova, the detail page of a model that uses the `Actionable` trait ends with a collapsable **Action Events** panel: a `MorphMany` of `actions()` through the resource that exposes the `ActionEvent` model (the built-in `ActionEventResource`, or your own). Martis adds it after `fieldsForDetail()` (see `Resource::resolveDetailFields()`), unless:

- the detail fields already declare a `MorphMany` to that resource (yours stays, labelled as you wrote it, in its panel or tab);
- no resource exposes the `ActionEvent` model (`martis.action_events.resource` off and no resource of your own);
- the resource overrides `shouldAddActionsField()`, Nova's hook, to return `false`:

```
use Illuminate\Http\Request;
 
class PostResource extends Resource
{
    protected function shouldAddActionsField(Request $request, array $fields): bool
    {
        return false;
    }
}
```

`actionEventsField()` builds the panel, labelled **Action Events** as in Nova (`Nova::__('Action Events')`), translated (`martis::action_events.label`); override it to change its label or options. The panel follows the action event resource's `viewAny`, as every relationship panel does (see [Relationships → Panels follow the related resource's `viewAny`](https://getmartis.com/docs/core/relationships#panels-follow-the-related-resources-viewany-v201)): while the audit log is closed (below), no user sees it and its route answers `403`.

### Built-in ActionEvent Resource

Martis automatically registers an `ActionEventResource` in the admin panel, providing a read-only interface for browsing the audit log. This resource:

- Appears in the sidebar as **"Action Events"** with a clipboard icon, for the users allowed to read the log (see below)
- Is **read-only** (no create, update, or delete)
- Sorts by `created_at DESC` by default
- Shows Nova's columns (v2.0.1+), in Nova's order and with its labels, translated in `en`, `pt_PT` and `pt_BR` (`martis::action_events`)

#### Columns and detail fields (v2.0.1+)

The resource mirrors Nova 5's `Laravel\Nova\Actions\ActionResource`:

| Field | Index | Shows |
| --- | --- | --- |
| **ID** | yes | The event id |
| **Name** | yes | The action name, through `__()` as Nova's `Nova::__($value)` |
| **Initiated By** | yes | The name of the user who ran the action, else their email, else the stored `user_id` when the user is gone (Nova prints "Nova User" there). The user comes from the Martis guard's user model (`GuardCatalog::martisUserModel()`, the model of `MARTIS_GUARD`'s provider), eager-loaded per page |
| **Target** | yes | The target record as `Project: Apollo`: its resource's singular label and title, linked to its detail page (with the peek card) when the viewer may `viewAny` and `view` it. As in Nova, a record the viewer may not view shows its title unlinked. A record that is gone, or a model no resource exposes, reads `Project: 12` (label, or class basename, and id), unlinked. When several resources expose the model, the first routable one gives the label and the link, so a [headless resource](https://getmartis.com/docs/core/resources#a-headless-resource-over-a-model-that-has-a-page) over it never relabels the target (v2.11.0) |
| **Status** | yes | Nova's labels: **Waiting** (`queued`), **Running**, **Finished** (`completed`, `finished`), **Failed**, plus **Denied** for an authorization denial. Waiting and Running show a spinner, Failed and Denied the error mark. Another value reads capitalised |
| **Original**, **Changes** | no | The diff as a key/value table, only on an event that holds one (Nova adds them only when set). Values stay redacted, see below |
| **Exception** | no | The exception message of a failed run |
| **Happened At** | yes | `created_at` |

The batch id and the raw `actionable_*` columns are not shown any more (Nova does not show them); they stay in the table and on the `ActionEvent` model. `ActionEventResource::statusLabel()`, `initiatorName()` and `targetValue()` are public, for a custom audit resource that wants the same display.

#### Who can read the audit log (v2.0.1+)

The audit log is **closed by default**: it records what every user changed, including values of fields other users cannot see. Open it with the `view-martis-action-events` gate, from your `MartisServiceProvider` (or any service provider):

```
use Illuminate\Support\Facades\Gate;
 
Gate::define('view-martis-action-events', fn ($user) => $user->is_admin);
```

The package registers a deny-by-default definition, as it does for `manage-martis-cache` and `martis-invite`; yours replaces it whatever the boot order. A policy for the `Martis\Models\ActionEvent` model decides instead when it defines `viewAny` / `view` (`Gate::policy(ActionEvent::class, ActionEventPolicy::class)`, or `App\Martis\Policies\ActionEventPolicy` by auto-discovery), as Nova's action log follows the `ActionEvent` policy. An ability the policy does not define falls back to the gate.

Without access:

| Surface | Behaviour |
| --- | --- |
| `GET /api/resources/action-events` and `/{id}` | `403` |
| Sidebar, `MenuItem::resource(ActionEventResource::class)` | Hidden |
| Command palette, *Recent activity* | Empty |
| A relationship panel listing the log (the automatic Action Events panel of an `Actionable` model, or a `MorphMany::make('Actions', 'actions', ActionEventResource::class)` of your own) | Not on the detail page; its route answers `403`, as for every panel whose related resource denies `viewAny` |

The denied gate check runs with every navigation build, so the [authorization-denial audit](https://getmartis.com/docs/auth/authorization#audit-log-of-denied-authorizations) skips it like the `viewAny` cascade (unless `MARTIS_AUDIT_AUTHZ_DENIALS_INCLUDE_VIEWANY=true`).

#### Hidden values in original and changes (v2.0.1+)

`original` and `changes` store the raw attributes an action changed, whatever the record's resource shows. The detail page shows a value only when the viewer could read that attribute on the record's own detail page; any other value reads `******` (`ActionEventRedactor::MASK`), so the log still tells which attributes changed:

- **The record has a resource.** A key keeps its value when it is the attribute of a detail field the viewer may see (`canSee()`, `canSeeForModel()`), or the foreign key / morph type of a visible `BelongsTo` / `MorphTo`, through a resource that lets the viewer `viewAny` and `view` the record. Attributes no field shows (`password`, `remember_token`, internal columns) are masked. A viewer who may not view the record sees every value masked, and so does one whose global scopes hide it (another tenant's record). A hard-deleted record cannot be judged at all: its `view` policy, its global scopes (a tenant fence) and its owner are gone with the row, and the diff holds only the attributes the action changed, so a policy built on them would fail open or closed by accident. Every value of its events is masked (v2.4.0) unless the record-independent `martis-view-deleted-audit` gate opens that resource's deleted records for the viewer: `Gate::define('martis-view-deleted-audit', fn ($user, string $resourceClass, string $uriKey) => $user->is_auditor)`. The gate receives the user, the resource class and its uri key and nothing about the record; the package registers it deny-by-default, and a gate that raises denies. When it allows, the resource's `viewAny` and the field visibility still apply on top (before v2.4.0 a deleted record skipped `view`, and an intermediate v2.4.0 build judged it on a model hydrated from the event). A tenant fence that lives in a global scope therefore stays closed after the hard delete: the other tenant's events are masked for everybody the gate does not name. An event that names no record (no `actionable_id`) has none to judge, only the resource's `viewAny`.
- **A pivot action's event** (`model_type` is the pivot): the values show when the viewer may view the parent record, but the pivot model's `$hidden` attributes and the attributes of the pivot fields the viewer may not see (`canSee()`) on the `BelongsToMany` / `MorphToMany` field that lists the row. When the parent's detail page declares such a field but the viewer may see none of them, every value is masked.
- **No resource exposes the model** (a standalone action, a role change, a custom writer): the values show, but the model's `$hidden` attributes.

A custom audit resource applies the same rule from its fields:

```
use Martis\Actions\ActionEventRedactor;
 
KeyValue::make('changes')->resolveUsing(
    fn ($value, $event, $attribute, $request) => ActionEventRedactor::redact($event, $value, $request ?? request()),
);
```

The stored row keeps every other value: code that reads `ActionEvent` directly gets them. Only the model's `$hidden` attributes never reach it (below).

#### $hidden attributes are stored masked (v2.0.1+)

When an action changes an attribute its model hides (`$hidden`: a password hash, a token), the event stores `******` for it in `original` and `changes`, keeping the key; a pivot action does the same with the pivot model's `$hidden` columns. The mask reads the instance's `getHidden()`, as Nova's Sidekick reads `$model->getHidden()`, so a runtime `makeVisible()` of a `$hidden` attribute on a model the action changes stores that value in clear: read the secret without it (`$model->api_token` needs no `makeVisible()`), or call `makeVisible()` on a copy (`clone $model`). This applies to synchronous and queued actions and to pivot actions, and to rows written from v2.0.1 on (older rows keep their values, still masked on read). Nova does the same: its action events store their diffs through `Orchestra\Sidekick\Eloquent\model_state()`, which replaces each `$hidden` attribute with a value serialised as `******`. A custom writer masks its own diffs with `ActionEventRedactor::maskHiddenAttributes($values, $model)`.

#### Action field values in the log (v2.4.0+)

The `fields` column used to store the raw `fields` input of the request: every value the user typed in the modal, a `Password` field included (a "Set new password" or "Rotate API key" action left the secret in plain text in the database and its backups), the values of fields the user cannot see, and any key that names no field. An event now stores the values the run resolved for the fields the user may see, as `handle()` receives them:

- a `Password` or `PasswordConfirmation` field, and any field marked [`sensitive()`](https://getmartis.com/docs/core/fields#sensitive-fields), stores `******` (`ActionEventRedactor::MASK`) in place of its value; an empty value stays empty, so the log does not claim a secret was set. Inside a `Repeater` row the fields its repeatables mark sensitive are masked the same way, nested Repeaters included;
- a field the user cannot see (`canSee()`) and a key that names no field of the action (the extra values a custom component posts) are left out. The action still receives them in `handle()`; only the log drops them;
- a pivot action's event stores its fields the same way, queued or not, and so do a failed run and a queued run's `queued` event.

```
Text::make('api_key')->sensitive();   // logged as ******
Password::make('new_password');       // logged as ******, with no flag
```

Nova stores action fields as submitted. The mask applies to rows written from v2.4.0 on; older rows keep what they stored (the built-in `ActionEventResource` never shows the column). A custom writer applies the same rule with `ActionEventRedactor::loggableFields($fields, $values, $request)`. The values a queued action hands its job (`ExecuteAction`, `ExecutePivotAction`) are not part of the log: the job payload carries them as the queue driver stores it.

#### $visible attributes only (v2.3.0+)

When a model (or a pivot model) declares `$visible`, its action events store only those attributes in `original` and `changes`; the others are left out, as Nova's do (`Orchestra\Sidekick\Eloquent\model_state()` builds its diffs from the model's `attributesToArray()`). `$hidden` attributes inside `$visible` are still stored as `******`. Only the class's `$visible` counts, not a runtime `makeVisible()`. Rows written before v2.3.0 keep what they stored.

#### Hide from Navigation

To hide the ActionEvent resource from the sidebar, set the config option:

```
// config/martis.php
'action_events' => [
    'enabled'  => true,
    'resource' => false,    // Hide from sidebar
],
```

Or set the environment variable:

```
MARTIS_ACTION_EVENTS_RESOURCE=false
```

### Disabling Action Logging

#### Globally

Disable all action event logging via configuration:

```
// config/martis.php
'action_events' => [
    'enabled'  => false,    // No events recorded at all
    'resource' => true,
],
```

Or via environment variable:

```
MARTIS_ACTION_EVENTS_ENABLED=false
```

When disabled globally, no `martis_action_events` rows are created regardless of per-action settings.

#### Per Action

Disable logging for a specific action using `withoutActionEvents()`:

```
PublishPosts::make()->withoutActionEvents()
```

This is useful for high-frequency or low-value actions that would clutter the audit log.

#### Summary

| Method | Scope | Effect |
| --- | --- | --- |
| `config('martis.action_events.enabled', false)` | Global | Disables all action logging |
| `->withoutActionEvents()` | Per action | Disables logging for one action |
| `config('martis.action_events.resource', false)` | Global | Hides ActionEvent from sidebar |

## Closure Actions

For simple one-off actions, use `Action::using()` without creating a dedicated class.

```
Action::using('Quick Approve', function (ActionFields $fields, Collection $models) {
    $models->each->update(['approved' => true]);
    return ActionResponse::message("{$models->count()} record(s) approved.");
})
->icon('check-circle')
->showInline()
->withoutConfirmation()
```

> Closure actions are **not queueable**. For heavy work, create a dedicated class implementing `ShouldQueue`.

## Dry-Run Preview (Martis Extension)

Show users what the action *would* do before committing:

```
class BulkArchivePosts extends Action
{
    public ?string $name = 'Archive Posts';
 
    public function handle(ActionFields $fields, Collection $models): ActionResponse|Action|null
    {
        foreach ($models as $post) {
            $post->update(['status' => 'archived']);
        }
        return ActionResponse::message("{$models->count()} post(s) archived.");
    }
 
    public function dryRun(ActionFields $fields, Collection $models): array
    {
        return [
            'preview' => "Would archive {$models->count()} post(s).",
            'affected_ids' => $models->pluck('id')->all(),
        ];
    }
}
```

Enable dry-run when registering:

```
BulkArchivePosts::make()->withDryRun()
```

The UI shows a **Preview** button alongside the Confirm button, in the resource action modal and in the [pivot action](https://getmartis.com/docs/core/actions#pivot-actions) modal. Clicking Preview calls `dryRun()` and shows the result in the modal without running `handle()`: the `preview` text first, then every other key the array returns. The modal stays open, so the user can run the action or cancel; changing a field clears the preview. A dry-run action always opens its modal, even with no fields and no confirmation, so Preview is on offer before anything runs.

Before v1.38.0 the resource modal took the preview answer for a finished run (a success toast, the modal closed and the list refreshed, though nothing had run), and an action with no fields and no confirmation ran before Preview could be offered.

## Custom Component (Martis Extension)

Replace the default fields form inside the action modal with a custom React component.

```
MyCustomAction::make()->component('my-custom-component', ['param' => 'value'])
```

Register the component in the frontend registry. It takes over the whole action UI and receives `CustomActionComponentProps`: `action`, `resource`, `selectedIds`, `componentProps` (the array passed to `->component()`), `onFieldsChange(fields)`, `onExecute(extraFields?)`, `onClose()` and `isExecuting`. The run posts the declared fields' `default()` values (v2.16.0+) merged under what `onFieldsChange()` and `onExecute()` send. The run's answer is handled as any other action's: a `modal()` answer closes your component and shows its own.

## Pivot Actions

A pivot action runs on records attached through a `BelongsToMany` or `MorphToMany` relationship, from that relationship's panel on the detail page: select rows, then pick the action from the panel's dropdown. `handle()` receives the related models loaded through the relationship, so each one carries its pivot row, including the pivot fields the relationship field declares. The rows resolve as the panel lists them (v2.0): the relationship's own, the global scopes it removes staying removed, narrowed by the related resource's `scopes()` and `indexQuery()`, with the trashed ones when the panel offers its trashed filter; a row the panel does not list answers 404:

```
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Martis\Actions\Action;
use Martis\Actions\ActionFields;
use Martis\Actions\ActionResponse;
use Martis\Fields\Select;
 
class SetTagPriority extends Action
{
    public function handle(ActionFields $fields, Collection $models): ActionResponse
    {
        foreach ($models as $tag) {
            $tag->pivot->priority = $fields->get('priority');
            $tag->pivot->save();
        }
 
        return ActionResponse::message('Priority updated.');
    }
 
    public function fields(Request $request): array
    {
        return [
            Select::make('priority', 'Priority')
                ->options(['low' => 'Low', 'normal' => 'Normal', 'high' => 'High'])
                ->required(),
        ];
    }
}
```

Declare it on the relationship field to show it on that relationship's panel only, as Nova's `->actions()` does:

```
MorphToMany::make('Tags', 'tags', TagResource::class)
    ->fields(fn () => [
        Select::make('priority', 'Priority')
            ->options(['low' => 'Low', 'normal' => 'Normal', 'high' => 'High']),
    ])
    ->actions(fn (Request $request) => [
        SetTagPriority::make(),
    ])
```

`BelongsToMany` takes the same `->actions()`. The closure receives the current request, and an action declared there does not need `->pivotAction()`.

A resource action flagged `->pivotAction()` shows on **every** `BelongsToMany` and `MorphToMany` panel of the resource instead, after the actions the field declares. When a field action and a resource pivot action share a URI key, the field's action wins on that panel.

```
public function actions(Request $request): array
{
    return [
        RemoveTag::make()
            ->pivotAction()
            ->referToPivotAs('tag assignment')
            ->icon('tag'),
    ];
}
```

`referToPivotAs()` labels the panel's dropdown (**Actions** by default); actions with different labels get one dropdown each. `canSee()`, `canRun()`, `sole()`, `standalone()` and the validation of the action's `fields()` apply as they do on the resource, and so do the rules for the [fields the request cannot set](https://getmartis.com/docs/core/actions#fields-the-request-cannot-set).

Running a pivot action also needs the policy a resource action checks, asked of the record whose relationship panel runs it (v1.38.0+): `runAction` on its resource's policy, falling back to `update`, or for a `DestructiveAction`, `runDestructiveAction`, falling back to `delete`. A user who may only view the parent record cannot run a pivot action on its rows; before v1.38.0 only `canSee()` and `canRun()` gated one.

A pivot action runs like a resource action (v1.38.0+):

- **Dry run.** `withDryRun()` adds a Preview button to the pivot action modal; it posts the same body with `dryRun: true`, and the modal shows what `dryRun()` returns (the related models carry their `pivot`) without running `handle()`.
- **Queue.** An action implementing `ShouldQueue` is dispatched as `Martis\Actions\Jobs\ExecutePivotAction` (on the action's `$connection` / `$queue` when it declares them). The job reloads the selected rows through the parent's relationship, with the pivot columns the field declares and without global scopes (the request already resolved them as the panel lists them), so `handle()` receives the same models it would receive synchronously, a trashed one included.
- **Action event log.** Every run writes one `ActionEvent` per selected row, `completed`, `failed` or `queued` (a queued run settles its rows when the job ends), with the parent record as `actionable`, the related record as `target` and the pivot row as `model`; `original` / `changes` hold the pivot columns the action changed. `withoutActionEvents()` and `martis.action_events.enabled` switch it off, as on the resource. See [What lands in martis_action_events](https://getmartis.com/docs/core/actions#what-lands-in-martis_action_events).
- **Errors.** A failed run answers with the same generic message as a resource action; the exception text goes to the log and the action event, never to the response.

Before v1.38.0 a pivot action always ran synchronously: `ShouldQueue` and `withDryRun()` were ignored, no action event was written, and a failed run returned the exception message to the client.

The pivot action routes (see the [API Reference](https://getmartis.com/docs/core/actions#api-reference)) resolve `{relationship}` only to a relationship field the resource declares with the route's type (`BelongsToMany` on `belongs-to-many`, `MorphToMany` on `morph-to-many`, nested layouts included). Any other name answers 404 without calling a model method.

> Before v1.38.0 a `MorphToMany` panel requested pivot action endpoints that did not exist (a 404 on every render), so its pivot actions never showed or ran; the `->actions()` closure of both fields was stored and never read; and the `belongs-to-many` routes called whatever parent model method `{relationship}` named, so a user who could view the parent and see one pivot action could run its `delete()`.

## API Reference

Every route below answers 403 when the user may not `viewAny` the resource, as running an action does: a resource the user cannot list does not disclose the actions it declares nor their field definitions (labels, defaults and the option lists a `Select` is filled with). The lens routes (`/api/resources/{resource}/lenses/{lens}/actions...`) follow the same rule, and the pivot routes check `viewAny` on the parent resource.

| Method | Path | Description |
| --- | --- | --- |
| `GET` | `/api/resources/{resource}/actions` | List available actions (filter with `?context=index\|detail\|inline`) |
| `GET` | `/api/resources/{resource}/actions/{action}/fields` | Get action fields (without the ones the user cannot see) |
| `GET` | `/api/resources/{resource}/actions/{action}/relatable/{attribute}` | Options of a `BelongsTo` / `MorphTo` / `Tag` the action declares (see [Relation fields](https://getmartis.com/docs/core/actions#relation-fields)) |
| `POST` | `/api/resources/{resource}/actions/{action}` | Execute action (bulk) |
| `POST` | `/api/resources/{resource}/{id}/actions/{action}` | Execute action (single record) |
| `GET` | `/api/resources/{resource}/{id}/{belongs-to-many\|morph-to-many}/{relationship}/actions` | List the pivot actions of a relationship panel (see [Pivot Actions](https://getmartis.com/docs/core/actions#pivot-actions)) |
| `GET` | `/api/resources/{resource}/{id}/{belongs-to-many\|morph-to-many}/{relationship}/actions/{action}/fields` | Get pivot action fields (without the ones the user cannot see) |
| `GET` | `/api/resources/{resource}/{id}/{belongs-to-many\|morph-to-many}/{relationship}/actions/{action}/relatable/{attribute}` | Options of a `BelongsTo` / `MorphTo` / `Tag` the pivot action declares (see [Relation fields](https://getmartis.com/docs/core/actions#relation-fields)) |
| `POST` | `/api/resources/{resource}/{id}/{belongs-to-many\|morph-to-many}/{relationship}/actions/{action}` | Execute a pivot action on attached records (`resources` holds related ids) |

### Execute request body

```
{
  "resources": [1, 2, 3],
  "fields": {
    "subject": "Hello",
    "message": "World"
  },
  "dryRun": false
}
```

### Execute response

```
{
  "data": {
    "type": "message",
    "data": {
      "message": "3 posts published successfully."
    }
  }
}
```

## Frontend Components

| Component | Description |
| --- | --- |
| `ActionDropdown` | Toolbar dropdown with group/submenu rendering and icon support |
| `ActionModal` | Confirmation dialog with field rendering, response handling, and dry-run support |
| Inline buttons | Per-row icon buttons in the DataTable (no checkbox required) |

All components respect the Martis theme system and work in both light and dark themes.

## RedirectAfter enum (Override redirects)

Used with `Override::redirectAfter()` (override system) to control where the user navigates after a create/update action within an override:

```
use Martis\RedirectAfter;
 
Override::for(PostResource::class)
    ->onCreate('app:create-post-wizard')
    ->redirectAfter(RedirectAfter::Detail);   // jump to detail page
```

Cases:

| Case | Effect |
| --- | --- |
| `RedirectAfter::Detail` | Navigate to `/resources/{resource}/{id}` (default). |
| `RedirectAfter::Edit` | Navigate to `/resources/{resource}/{id}/edit`. |
| `RedirectAfter::Index` | Back to the resource index. |
| `RedirectAfter::Stay` | Keep the user on the current page (drawer scenarios). |

For custom URLs with dynamic placeholders, pass a string directly:

```
->redirectAfter('/resources/{resource}/{id}/preview')
```

Placeholders: `{id}` (record id), `{resource}` (resource uriKey).

Soft-gates & badges

Default row actions
