Documentation menu
HomeDocsCore ConceptsActions

Actions

Last updated: 2026-04-10

Overview

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

TypeDescriptionSelection required?
Standard (Bulk)Runs on one or more selected recordsYes (1+)
InlineButton rendered directly on each table rowNo (auto-targets row)
StandaloneRuns without any record selectionNo
SoleRequires exactly one record to be selectedYes (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). 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

MethodEffect
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);
ModeRow buttonMenu itemDetail page
Disabled (default)greyed outgreyed outgreyed out
Hideleft outleft outleft out
KeepSlotinvisible placeholder that keeps its place, so the next icons line up from row to rowleft outleft 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).

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 fieldIn the modalValidatedWhat handle() receives
hidden by canSee() / canSeeWhen()nonoits default(), or nothing
readonly()yes, read-onlynoits default(), or nothing
computed()yesnonothing
any other fieldyesyesthe 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.

This holds for a resource action and a pivot action, 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 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 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 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).

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

Scenariothen() 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 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)')

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

MethodIndex dropdownDetail dropdownPer-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).

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

MethodBehaviour
(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), 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):

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

ResponseEffect
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)
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)
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
ActionResponse::modal($component, $data)Shows the component registered under $component (see 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): 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() field masked as ****** (see Action field values in the log)
  • For standalone actions, actionable_type/id are null (no model targeted)
  • For a pivot action, 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

ColumnTypeDescription
idbigintAuto-increment primary key
batch_iduuidGroups per-model records from a single bulk execution
user_idint|nullID of the user who triggered the action
namestringAction display name (e.g. "Publish Posts")
actionable_typestring|nullPolymorphic model class (e.g. App\Models\Post); for a pivot action, the class of the record whose panel ran it
actionable_idint|nullID of the target model; for a pivot action, that record's ID
target_typestring|nullTarget model class; for a pivot action, the related record's class
target_idint|nullTarget model ID; for a pivot action, the related record's ID
model_typestring|nullSource model class; for a pivot action, the pivot class
model_idint|nullSource model ID; for a pivot action, the pivot row's key (null when the pivot table has none)
fieldsjsonThe action's field values as the run resolved them: the visible fields only, secrets masked (see Action field values in the log)
statusstringcompleted, failed, or queued
exceptiontextError message on failure (empty string on success)
originaljsonChanged attributes with their values before the action (diff only)
changesjsonChanged attributes with their new values after the action (diff only)
created_attimestampWhen the action was executed
updated_attimestampLast 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.

ScenarioRecords selectedActionEvent rowsbatch_id
Inline action on 1 record11Single UUID
Bulk action on 5 records55Same UUID shared by all 5
Standalone action (no records)01 (null model fields)Single UUID
Queued action on 10 records1010 (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

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

FieldIndexShows
IDyesThe event id
NameyesThe action name, through __() as Nova's Nova::__($value)
Initiated ByyesThe 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
TargetyesThe 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 over it never relabels the target (v2.11.0)
StatusyesNova'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, ChangesnoThe diff as a key/value table, only on an event that holds one (Nova adds them only when set). Values stay redacted, see below
ExceptionnoThe exception message of a failed run
Happened Atyescreated_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:

SurfaceBehaviour
GET /api/resources/action-events and /{id}403
Sidebar, MenuItem::resource(ActionEventResource::class)Hidden
Command palette, Recent activityEmpty
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 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(), 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

MethodScopeEffect
config('martis.action_events.enabled', false)GlobalDisables all action logging
->withoutActionEvents()Per actionDisables logging for one action
config('martis.action_events.resource', false)GlobalHides 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 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.

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.
  • 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) 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.

MethodPathDescription
GET/api/resources/{resource}/actionsList available actions (filter with ?context=index|detail|inline)
GET/api/resources/{resource}/actions/{action}/fieldsGet 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)
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}/actionsList the pivot actions of a relationship panel (see Pivot Actions)
GET/api/resources/{resource}/{id}/{belongs-to-many|morph-to-many}/{relationship}/actions/{action}/fieldsGet 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)
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

ComponentDescription
ActionDropdownToolbar dropdown with group/submenu rendering and icon support
ActionModalConfirmation dialog with field rendering, response handling, and dry-run support
Inline buttonsPer-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:

CaseEffect
RedirectAfter::DetailNavigate to /resources/{resource}/{id} (default).
RedirectAfter::EditNavigate to /resources/{resource}/{id}/edit.
RedirectAfter::IndexBack to the resource index.
RedirectAfter::StayKeep 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).