Admin workflows
Business actions
Business actions
Most Laravolt v7 admin modules start as plain CRUD: a table, a create form, an edit form, a delete button. Real systems quickly grow operations that are not create/update/delete on a single row — submit, approve, reject, issue, receive, cancel, resend, retry. Treat these as business actions with their own files, routes, policies, and tests.
This page documents the v7 convention for non-CRUD operations on top of generated Thunderclap modules. The full workflow engine (Camunda + WorkflowService) is a separate, post-stable integration; this page covers the lightweight pattern that ships with v7 stable.
When to use what
Use the simpler tool first.
| Need | Use |
|---|---|
One-shot state change on a single row (status = 'approved') | Action class + controller endpoint + policy |
| Two or three named states with a clear transition graph | Action class per transition + model status enum |
| Long-running multi-actor process with assignments and audit history | Camunda / WorkflowService (post-stable) |
| Pure CRUD with no business rules beyond field validation | Generated controller — no action class needed |
If you find yourself adding if ($status === 'draft') branches inside the generated update() method, that is the signal to extract a business action.
Folder convention
app/ Actions/ PurchaseOrder/ SubmitAction.php ApproveAction.php RejectAction.php CancelAction.php Invoice/ IssueAction.php MarkPaidAction.phpmodules/ PurchaseOrder/ Controllers/PurchaseOrderController.php Requests/ Store.php Update.php ApproveRequest.php RejectRequest.phpRules of thumb:
- One action class per verb, named
{Verb}Action.php. - Group by business domain (
PurchaseOrder,Invoice), not by HTTP verb. - Actions live under
app/Actions/, not inside the generated module folder, so they survivephp artisan laravolt:clap --forceregeneration. - Use Laravel's
Bus\Dispatchableor a plain invokable; both are fine.
Anatomy of an action class
Use a single-action invokable when the action is small and self-contained:
<?phpdeclare(strict_types=1);namespace App\Actions\PurchaseOrder;use App\Models\PurchaseOrder;use App\Models\User;use Illuminate\Support\Facades\DB;final class ApproveAction{ public function __invoke(PurchaseOrder $purchaseOrder, User $approver, ?string $note = null): PurchaseOrder { return DB::transaction(function () use ($purchaseOrder, $approver, $note) { abort_unless($purchaseOrder->status === 'submitted', 422, 'Only submitted POs can be approved.'); $purchaseOrder->forceFill([ 'status' => 'approved', 'approved_at' => now(), 'approved_by' => $approver->id, 'approval_note' => $note, ])->save(); // Optional: dispatch a domain event for listeners event(new \App\Events\PurchaseOrderApproved($purchaseOrder, $approver)); return $purchaseOrder->refresh(); }); }}Promote to a class-with-methods (handle(), authorize(), rules()) when an action grows side effects or shared helpers. Avoid global helpers for business rules — keep them on the action.
Routing and policy authorization
Wire actions explicitly. Do not rely on menu visibility for security; route middleware and controller authorize() are the boundary.
// modules/PurchaseOrder/routes/web.phpuse App\Actions\PurchaseOrder\ApproveAction;use Modules\PurchaseOrder\Controllers\PurchaseOrderController;Route::middleware(['web', 'auth'])->group(function () { Route::resource('purchase-orders', PurchaseOrderController::class); Route::post('purchase-orders/{purchaseOrder}/approve', [PurchaseOrderController::class, 'approve']) ->name('purchase-orders.approve') ->middleware('can:approve,purchaseOrder'); Route::post('purchase-orders/{purchaseOrder}/reject', [PurchaseOrderController::class, 'reject']) ->name('purchase-orders.reject') ->middleware('can:reject,purchaseOrder');});Add the corresponding policy methods next to the existing view/create/update/delete set:
// app/Policies/PurchaseOrderPolicy.phppublic function approve(User $user, PurchaseOrder $po): bool{ return $user->can('purchase-order.approve') && $po->status === 'submitted' && $po->created_by !== $user->id; // four-eyes rule}Two-layer authorization is intentional:
- Permission gate (
purchase-order.approve) decides who can ever perform the action. - Policy decides whether this specific record is in the right state for this user right now.
Menu entries should mirror permission gates, not policy preconditions:
Menu::add('purchase-orders.index') ->permissions('purchase-order.view');Controller method
Keep the controller thin. It validates input, calls the action, redirects.
public function approve( ApprovePurchaseOrderRequest $request, PurchaseOrder $purchaseOrder, ApproveAction $approve,): RedirectResponse { $this->authorize('approve', $purchaseOrder); $approve($purchaseOrder, $request->user(), $request->validated('note')); return to_route('purchase-orders.show', $purchaseOrder) ->withSuccess(__('Purchase order approved.'));}ApprovePurchaseOrderRequest is a normal FormRequest — same convention as Store/Update generated by Thunderclap. Use it for action-specific input (notes, attachments, reasons) and skip it for zero-input actions.
Blade trigger
Render action buttons with the existing Preline volt components. Keep the trigger explicit so the audit trail is obvious in the markup:
@can('approve', $purchaseOrder) <form method="POST" action="{{ route('purchase-orders.approve', $purchaseOrder) }}"> @csrf <x-volt-button type="submit" variant="primary" icon="check"> {{ __('Approve') }} </x-volt-button> </form>@endcanFor multi-input actions, render a modal with a small PrelineForm bound to the action route:
{!! form()->post(route('purchase-orders.reject', $purchaseOrder)) !!} {!! form()->textarea('reason')->label(__('Reason'))->required() !!} {!! form()->submit(__('Reject'))->variant('danger') !!}{!! form()->close() !!}Testing actions
Action classes are pure PHP — test them without HTTP:
it('approves a submitted purchase order', function () { $po = PurchaseOrder::factory()->submitted()->create(); $approver = User::factory()->withPermission('purchase-order.approve')->create(); app(ApproveAction::class)($po, $approver, note: 'Looks good'); expect($po->refresh()) ->status->toBe('approved') ->approved_by->toBe($approver->id) ->approval_note->toBe('Looks good');});it('refuses to approve a draft purchase order', function () { $po = PurchaseOrder::factory()->draft()->create(); $approver = User::factory()->withPermission('purchase-order.approve')->create(); expect(fn () => app(ApproveAction::class)($po, $approver)) ->toThrow(\Symfony\Component\HttpKernel\Exception\HttpException::class);});Add a feature test for the route to lock in the permission + policy combination:
it('blocks unauthorized users from the approve route', function () { $po = PurchaseOrder::factory()->submitted()->create(); $user = User::factory()->create(); // no permission actingAs($user) ->post(route('purchase-orders.approve', $po)) ->assertForbidden();});Adding actions on top of Thunderclap
Thunderclap generates a CRUD controller, routes, requests, model, table view, and tests. To add a custom action on top of the generated module without losing it on the next regeneration:
- Add the action class under
app/Actions/{Domain}/— not inside the module folder. - Open the generated
modules/{Module}/routes/web.phpand append the action route(s). The generator only seeds the file once; subsequent runs do not touch it unless--forceis used, in which case you can re-apply from a smallroutes/{module}-actions.phppartial included fromweb.php. - Add a policy method on the resource policy. Permissions live in the seeded permission list; see Demo seeders for the convention.
- Add the controller method in
modules/{Module}/Controllers/{Module}Controller.php. If you intend to re-run the generator, move custom methods to a trait underapp/Concerns/anduseit from the generated controller. - Add the action FormRequest (if it takes input) under
modules/{Module}/Requests/orapp/Http/Requests/{Module}/. - Add the Blade trigger to the relevant generated view (
show.blade.phpis usually the right place). - Add a Pest test for the action class and a feature test for the route.
Treat the generator as a starting point. Regeneration with --force is a tool for stub upgrades, not a daily operation; protect anything that grew custom logic.
When to graduate to the workflow engine
Move from action classes to WorkflowService / Camunda when:
- There are more than three or four named states and the transition graph is non-trivial.
- The process has task assignments per role (manager review → finance review → CEO sign-off).
- Long-running state must survive deployments and be observable from a dashboard outside Laravel.
- Audit history and SLA timers are first-class requirements.
Until then, action classes + model status + policy is the recommended v7 stable pattern. Defer the workflow engine to a later phase rather than ship half-integrated.
What to read next
- Admin workflows overview — the loop that actions plug into.
- Thunderclap — generate the CRUD baseline before adding actions.
- Access control — permissions and policy methods.
- Workflow & automation overview — the engine-backed path for complex processes.