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.

NeedUse
One-shot state change on a single row (status = 'approved')Action class + controller endpoint + policy
Two or three named states with a clear transition graphAction class per transition + model status enum
Long-running multi-actor process with assignments and audit historyCamunda / WorkflowService (post-stable)
Pure CRUD with no business rules beyond field validationGenerated 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

Plain Text
app/
Actions/
PurchaseOrder/
SubmitAction.php
ApproveAction.php
RejectAction.php
CancelAction.php
Invoice/
IssueAction.php
MarkPaidAction.php
modules/
PurchaseOrder/
Controllers/PurchaseOrderController.php
Requests/
Store.php
Update.php
ApproveRequest.php
RejectRequest.php

Rules 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 survive php artisan laravolt:clap --force regeneration.
  • Use Laravel's Bus\Dispatchable or a plain invokable; both are fine.

Anatomy of an action class

Use a single-action invokable when the action is small and self-contained:

PHP
<?php
declare(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.

PHP
// modules/PurchaseOrder/routes/web.php
use 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:

PHP
// app/Policies/PurchaseOrderPolicy.php
public 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:

PHP
Menu::add('purchase-orders.index')
->permissions('purchase-order.view');

Controller method

Keep the controller thin. It validates input, calls the action, redirects.

PHP
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:

Blade
@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>
@endcan

For multi-input actions, render a modal with a small PrelineForm bound to the action route:

Blade
{!! 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:

PHP
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:

PHP
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:

  1. Add the action class under app/Actions/{Domain}/ — not inside the module folder.
  2. Open the generated modules/{Module}/routes/web.php and append the action route(s). The generator only seeds the file once; subsequent runs do not touch it unless --force is used, in which case you can re-apply from a small routes/{module}-actions.php partial included from web.php.
  3. Add a policy method on the resource policy. Permissions live in the seeded permission list; see Demo seeders for the convention.
  4. 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 under app/Concerns/ and use it from the generated controller.
  5. Add the action FormRequest (if it takes input) under modules/{Module}/Requests/ or app/Http/Requests/{Module}/.
  6. Add the Blade trigger to the relevant generated view (show.blade.php is usually the right place).
  7. 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.

Previous
Thunderclap