Admin workflows

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