pdmfc/nova-action-button
Add configurable action buttons to Laravel Nova resources. Define buttons with labels, icons, colors, and links or actions, then place them on resource detail and index views to trigger custom operations, external URLs, or modal actions with minimal code.
Install
composer require pdmfc/nova-action-button
php artisan nova:publish # only if you have never built Nova assets in this app
There is no config file, no migration, no service provider registration. It's a Nova field, so Composer plus an asset build is the entire installation. Do this in a branch, not on main, until you've completed the spike below.
Where to look first — in this order
composer.json in the package, specifically the laravel/nova constraint. The last release was 2021-10-09, which predates Nova 4's frontend rework and all of Nova 5. If it pins ^3.0, you have a compatibility task, not an integration task. Decide this before writing resource code.src/ — the field class is a few hundred lines at most. Read the constructor, resolve(), and meta(). With ~36 stars and a single maintainer, the source is the authoritative documentation for the fluent method names; blog posts and forks will mislead you.First use case
The canonical day-one fit is: you already have a Nova Action reachable from the resource's dropdown, and you want a one-click button on the detail page. Pick an existing, safe, idempotent action — resend invitation, mark as reviewed, sync one record — and wire it up:
use Pdmfc\NovaActionButton\ActionButton;
public function fields(Request $request)
{
return [
// ...normal fields
ActionButton::make('Resend Invitation')
->action(ResendInvitation::class)
->icon('refresh')
->style('primary')
->onlyOnDetail(),
];
}
Then run the two assertions that gate everything else:
If both pass on your exact Nova version, proceed. If either fails, stop and evaluate native Nova inline actions before sinking more time in.
Wire the button to an Action class, never to a closure. The entire value proposition is that the button dispatches your existing Action, so the dropdown and the button stay in lockstep — same handle(), same authorization, same confirmation, same audit surface. The moment you reimplement logic in the field, you have two code paths that will drift.
ActionButton::make('Approve')
->action(ApproveSubmission::class)
->icon('check')
->style('primary'),
Placement: per-resource, scoped to a surface. Add it to the one resource's fields(). Global registration via NovaServiceProvider::fields() is almost never right here — actions are resource-specific, and a global field will surface everywhere including resources where it makes no sense. A field in fields() renders on index and detail unless you constrain it, so always be explicit:
->onlyOnDetail() // safe default
->onlyOnIndex() // the ergonomic win, but read the N+1 note below
Confirmation belongs on the Action, not the field. Define confirmText() / confirmButtonText() on the Action class itself. That way the confirmation prompt is identical whether the user clicks the button or the dropdown item, and it lives on the surface Nova actually audits. A bespoke JS confirm in the field creates two different safety stories for the same operation.
Split UX visibility from security. Do state-based hiding in the field, and put the real gate in the Action:
// Field: pure UX. Hide when the state makes the action meaningless.
ActionButton::make('Approve')
->action(ApproveSubmission::class)
->canSee(fn ($request) => $this->status === 'pending'),
// Action: the actual security boundary.
public function authorizedToSee(Request $request) { /* policy check */ }
public function authorizedToRun(Request $request, $model) { /* policy check */ }
Never let canSee be the only barrier. It's a rendering hint evaluated in PHP; the request is what matters.
Group a small set, then stop. Two or three related one-click controls on a detail page (approve / reject / escalate) is a coherent workflow. Past five or six, you've rebuilt the actions dropdown with worse discoverability — use a native action group instead.
Prove detail before index. The detail page is scoped to one record in context and refreshes cleanly. Index rows are where the real throughput gain lives — dozens of records, one click each — but that's also where per-row visibility closures, lazy-loaded relations, and wide-table refresh behavior compound. Ship detail first on a low-risk resource, measure, then extend.
Keep the chain boring. Field → Action class → handle() (implementing ShouldQueue if it does anything slow) → job/event. The button adds exactly one thing to that chain: an entry point. If the action works from the dropdown, it works here. If it doesn't, fix the Action.
Test that the field survives refactors. A fields() reshuffle silently drops the button and nobody notices until an operator complains three weeks later. Pin it:
public function test_resource_exposes_approve_button()
{
$fields = (new SubmissionResource(Submission::factory()->create()))->fields(request());
$this->assertTrue(collect($fields)->contains(
fn ($f) => $f instanceof ActionButton && $f->name === 'Approve'
));
}
Nova version is the whole ballgame. Released against Nova 3, untouched since October 2021. The PHP field class will probably still load on 4/5; the compiled JS bundle is the part that breaks, because it was built against the pre-rework Nova frontend. Treat "works on Nova 4/5" as unverified until you've actually spiked it — and set the exit criterion before the spike starts: "if the button doesn't render and dispatch correctly within one day, we stop and use native inline actions."
You are the maintainer the moment it enters composer.json. 36 stars, 0 dependents, one maintainer, one release. Vendor it via a fork (private VCS or a Composer path repository), pin an exact commit, and write down why the fork exists in your repo README. Budget a named owner and a smoke test in CI.
Field semantics leak. Nova fields are sortable, searchable, exportable, and resolvable by default — an action button is none of those things. Audit every code path that iterates fields(): CSV/spreadsheet exports, indexQuery sort handlers, any custom serialization. You do not want an empty column in an export or a nonsense sort target. Verify the field class guards against this rather than assuming it.
Silent no-ops are the worst failure mode. If the request payload shape drifts from your Nova version's contract — entirely plausible across majors — the button looks live, returns a 200 with nothing useful, and the row never refreshes. Operator trust erodes fast. During the spike, open devtools and confirm three things on click: the expected request fires, it returns 2xx, and the row/detail visibly refreshes. "Works but doesn't refresh" is a bug, not a quirk.
N+1 on per-row visibility. A canSee closure that touches a relation runs for every row on the index — 50 rows, 50 queries. Eager-load what the closure needs on the resource's indexQuery, or hoist the decision into the Action and let the button always render for permitted users.
Queued actions are your responsibility. The field just dispatches the Action. Anything non-trivial must implement ShouldQueue on the Action itself. A synchronous button over a 30-second job is a timeout waiting to become a support ticket, and this package will not save you from that.
Debugging, in order: (1) Is the field registered on the right resource and surface (onlyOnDetail vs index)? (2) Is the Action class namespaced correctly and does authorizedToSee / the policy return true for your test user? (3) Browser console clean — Vue error or 404 on the compiled asset? (4) Network tab — does the click produce the expected request and a 2xx? (5) Does the dropdown version of the same action still work? Step 5 is the diagnostic that matters most: if the dropdown works, your Action is fine and the problem is the package; if the dropdown also fails, the problem is your Action and you're debugging the wrong thing.
Extension points, ranked by safety. In order of preference: (a) configure via the Action class — confirmation, authorization, queueing; (b) configure via field fluent methods — label, icon, style, canSee; (c) wrap the field in your own small subclass inside your app so that when the 2021 asset bundle finally breaks, you patch one file you own rather than patching vendor. Prefer (c) over editing vendor/ — it makes the Nova 4/5 migration tractable instead of a rewrite.
Escape hatch. If the spike fails, the fallback is roughly two days of in-house Nova field work, or native Nova row/inline actions (materially improved in Nova 4+). Either is better than debugging a four-year-old dependency in production. This package earns its place only when the spike passes in a day and the workflow is internal, reversible, and low blast radius.
How can I help you explore Laravel packages today?