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 actionphp artisan martis:action PublishPost # Destructive action (red UI, stricter authorization)php artisan martis:action ArchivePost --destructiveGenerated 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 aCollectionwith 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
| 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 ▸" submenuA 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
_actionAuthorizationas 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,IndexActionTestandDetailActionTest). Martis keepsDisabledas the default so existing lists do not change;Hidegives 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
$modelscollection is always empty — query data yourself insidehandle()
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:
- HTTP request arrives at
ActionController::execute() - Controller detects
ShouldQueueinterface on the action class - An
ExecuteActionjob is dispatched to the queue - 200
ActionEventrecords are created withstatus = "queued"(all sharing the samebatch_id) - HTTP response returns immediately:
"Action has been queued for processing." - The queue worker picks up the job and calls
handle()with all 200 models - After processing,
ActionEventrecords update tostatus = "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 continuouslyphp artisan queue:work --once # Process a single job and exitBehavior
- User selects records, fills in fields, clicks Confirm
- API responds immediately with
"Action has been queued for processing." - A
queuedentry is written tomartis_action_events - The Laravel job processes the records asynchronously
martis_action_eventsstatus updates tocompletedorfailedwhen 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.
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 callsallowActiveContent(), as on a resource form. handle()receives theUploadedFile; 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
LogicExceptionnaming 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): staticThe 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 beforehandle()runs. For post-processing on queued actions, put the logic insidehandle()itself or listen to theJobProcessedevent.
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 namephp artisan test # full suiteConfirmation 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),$itemis the record the modal runs on (a row's inline action, the detail page, a single selection), the same oneselectedModel()givesfields(): found through the index scopes and authorized to view, so a closure never sees a record the user cannot reach. It isnullfor a bulk orstandalone()run.$countis 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
nullfor the default. Any other value throws anInvalidArgumentExceptionnaming 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 aBooleanwith that label, whose value reacheshandle()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 iconAction::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 operationsDeleteSelectedPosts::make()->icon('trash')->iconColor('#dc2626') // Custom color using a theme variableApproveComment::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 touchshowOnIndex. SinceshowOnIndexdefaults totrue, 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), useonlyInline(), which also setsshowOnIndex = 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
| 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-rowcanRun()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 anUnexpectedValueExceptionnaming the action. So does a key that is no row of the batch: a list written as$rows->map(fn ($row) => ...)->all()is keyed0..n-1, not by row key, and would silently allow one row and deny another. Key it withmapWithKeys()as above. - Martis calls it once per page where the index, a lens or a relationship panel marks each row's
_actionAuthorization. A page'sprefetchIndexPage()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 onlycanRunForRows()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 overridesauthorizedToRun()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 aparent::authorizedToRun()inside it asks the batch for that one row, so do not callparentfrom the override when you want the batch to stay one call per page. canRunForRows()is onAction, not onActionContract(asopensInNewTab()below): a class that implements the contract itself keeps compiling and keeps its per-rowauthorizedToRun(), 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:
Policy::runAction($user)Policy::update($user, $model)- Permissive (returns
true)
Destructive actions fall back through:
Policy::runDestructiveAction($user)Policy::delete($user, $model)- 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) |
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 answersopenInNewTab(). - The new tab's
openeris cut before it leavesabout:blank, asrel="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 withReferrer-Policy: no-referrerto 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 onMartis\Actions\Action, not onActionContract, 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 originalandchangesstore only the diff — unchanged attributes are not storedfieldsholds the values the run resolved for the action's visible fields, with aPasswordorsensitive()field masked as******(see Action field values in the log)- For standalone actions,
actionable_type/idarenull(no model targeted) - For a pivot action, one row per selected related record with Nova's mapping:
actionableis the record whose relationship panel ran the action,targetthe related record,modelits pivot row (the pivot class, and the pivot key when the table has one), andoriginal/changeshold 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 firstActionEvent::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 daysActionEvent::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:
- The exception is caught in
ActionController - An
ActionEventrow is written withstatus = "failed"and the exception message in theexceptioncolumn then()is not called- The HTTP response returns HTTP 500 with the error message
- 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-migrationsphp artisan migrateTable 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) |
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. Theoriginalandchangescolumns 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
ShouldQueueand runs in the background - These are independent concepts. A queued action also uses
batch_idfor 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
MorphManyto that resource (yours stays, labelled as you wrote it, in its panel or tab); - no resource exposes the
ActionEventmodel (martis.action_events.resourceoff and no resource of your own); - the resource overrides
shouldAddActionsField(), Nova's hook, to returnfalse:
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 DESCby default - Shows Nova's columns (v2.0.1+), in Nova's order and with its labels, translated in
en,pt_PTandpt_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 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 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 visibleBelongsTo/MorphTo, through a resource that lets the viewerviewAnyandviewthe 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: itsviewpolicy, 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-independentmartis-view-deleted-auditgate 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'sviewAnyand the field visibility still apply on top (before v2.4.0 a deleted record skippedview, 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 (noactionable_id) has none to judge, only the resource'sviewAny. - A pivot action's event (
model_typeis the pivot): the values show when the viewer may view the parent record, but the pivot model's$hiddenattributes and the attributes of the pivot fields the viewer may not see (canSee()) on theBelongsToMany/MorphToManyfield 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
$hiddenattributes.
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
PasswordorPasswordConfirmationfield, and any field markedsensitive(), stores******(ActionEventRedactor::MASK) in place of its value; an empty value stays empty, so the log does not claim a secret was set. Inside aRepeaterrow 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 inhandle(); 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
queuedevent.
Text::make('api_key')->sensitive(); // logged as ******Password::make('new_password'); // logged as ******, with no flagNova 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=falseDisabling 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=falseWhen 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 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 withdryRun: true, and the modal shows whatdryRun()returns (the related models carry theirpivot) without runninghandle(). - Queue. An action implementing
ShouldQueueis dispatched asMartis\Actions\Jobs\ExecutePivotAction(on the action's$connection/$queuewhen 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), sohandle()receives the same models it would receive synchronously, a trashed one included. - Action event log. Every run writes one
ActionEventper selected row,completed,failedorqueued(a queued run settles its rows when the job ends), with the parent record asactionable, the related record astargetand the pivot row asmodel;original/changeshold the pivot columns the action changed.withoutActionEvents()andmartis.action_events.enabledswitch 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
MorphToManypanel 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 thebelongs-to-manyroutes called whatever parent model method{relationship}named, so a user who could view the parent and see one pivot action could run itsdelete().
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) |
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) |
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) |
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 pageCases:
| 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).