Workflows
Approval flows
Approval flows
Multi-step approval workflows route documents through sequential approvers with state tracking, notifications, and audit trails. Laravolt v7 uses state machines to model approval flows.
Why state machines
Approval flows have:
- Discrete states — draft, pending, approved, rejected
- Transitions — submit, approve, reject, revise
- Guards — only certain users can approve
- Side effects — send notifications, log changes
State machines make these rules explicit and testable.
Basic approval flow
Purchase requisition → approval → purchase order:
1. Define states and transitions
PHP
// app/States/PurchaseRequisitionState.phpnamespace App\States;use Spatie\ModelStates\State;use Spatie\ModelStates\StateConfig;abstract class PurchaseRequisitionState extends State{ abstract public function canEdit(): bool; abstract public function canSubmit(): bool; abstract public function canApprove(): bool; public static function config(): StateConfig { return parent::config() ->default(Draft::class) ->allowTransition(Draft::class, PendingApproval::class) ->allowTransition(PendingApproval::class, Approved::class) ->allowTransition(PendingApproval::class, Rejected::class) ->allowTransition(Rejected::class, Draft::class); }}class Draft extends PurchaseRequisitionState{ public function canEdit(): bool { return true; } public function canSubmit(): bool { return true; } public function canApprove(): bool { return false; }}class PendingApproval extends PurchaseRequisitionState{ public function canEdit(): bool { return false; } public function canSubmit(): bool { return false; } public function canApprove(): bool { return true; }}class Approved extends PurchaseRequisitionState{ public function canEdit(): bool { return false; } public function canSubmit(): bool { return false; } public function canApprove(): bool { return false; }}class Rejected extends PurchaseRequisitionState{ public function canEdit(): bool { return true; } public function canSubmit(): bool { return true; } public function canApprove(): bool { return false; }}2. Add state to model
PHP
// app/Models/PurchaseRequisition.phpnamespace App\Models;use App\States\PurchaseRequisitionState;use Illuminate\Database\Eloquent\Model;use Spatie\ModelStates\HasStates;class PurchaseRequisition extends Model{ use HasStates; protected $casts = [ 'state' => PurchaseRequisitionState::class, ]; public function submit() { $this->state->transitionTo(PendingApproval::class); $this->save(); // Notify approvers $this->notifyApprovers(); } public function approve(User $approver) { $this->state->transitionTo(Approved::class); $this->approved_by = $approver->id; $this->approved_at = now(); $this->save(); // Create purchase order $this->createPurchaseOrder(); // Notify requester $this->notifyRequester('approved'); } public function reject(User $approver, string $reason) { $this->state->transitionTo(Rejected::class); $this->rejected_by = $approver->id; $this->rejected_at = now(); $this->rejection_reason = $reason; $this->save(); // Notify requester $this->notifyRequester('rejected'); }}3. Controller actions
PHP
// app/Http/Controllers/PurchaseRequisitionController.phpnamespace App\Http\Controllers;use App\Models\PurchaseRequisition;use Illuminate\Http\Request;class PurchaseRequisitionController extends Controller{ public function submit(PurchaseRequisition $requisition) { $this->authorize('submit', $requisition); $requisition->submit(); return redirect() ->route('requisitions.show', $requisition) ->with('success', 'Requisition submitted for approval'); } public function approve(PurchaseRequisition $requisition) { $this->authorize('approve', $requisition); $requisition->approve(auth()->user()); return redirect() ->route('requisitions.index') ->with('success', 'Requisition approved'); } public function reject(Request $request, PurchaseRequisition $requisition) { $this->authorize('approve', $requisition); $validated = $request->validate([ 'reason' => 'required|string|min:10', ]); $requisition->reject(auth()->user(), $validated['reason']); return redirect() ->route('requisitions.index') ->with('success', 'Requisition rejected'); }}4. Authorization
PHP
// app/Policies/PurchaseRequisitionPolicy.phpnamespace App\Policies;use App\Models\PurchaseRequisition;use App\Models\User;use App\States\Draft;use App\States\PendingApproval;class PurchaseRequisitionPolicy{ public function submit(User $user, PurchaseRequisition $requisition): bool { return $requisition->created_by === $user->id && $requisition->state instanceof Draft; } public function approve(User $user, PurchaseRequisition $requisition): bool { return $user->hasRole('approver') && $requisition->state instanceof PendingApproval; }}Multi-level approval
Some workflows need sequential approvers:
PHP
// app/Models/PurchaseRequisition.phppublic function approvalSteps(){ return $this->hasMany(ApprovalStep::class)->orderBy('order');}public function currentStep(){ return $this->approvalSteps()->whereNull('approved_at')->first();}public function approve(User $approver){ $step = $this->currentStep(); if (!$step || $step->approver_id !== $approver->id) { throw new \Exception('Not authorized to approve this step'); } $step->update([ 'approved_at' => now(), 'approved_by' => $approver->id, ]); // Check if all steps are complete if ($this->approvalSteps()->whereNull('approved_at')->count() === 0) { $this->state->transitionTo(Approved::class); $this->save(); $this->createPurchaseOrder(); } else { // Notify next approver $this->notifyNextApprover(); }}Parallel approval
Multiple approvers must approve simultaneously:
PHP
// app/Models/PurchaseRequisition.phppublic function approvers(){ return $this->belongsToMany(User::class, 'requisition_approvals') ->withPivot('approved_at', 'rejected_at', 'notes') ->withTimestamps();}public function approve(User $approver){ $this->approvers()->updateExistingPivot($approver->id, [ 'approved_at' => now(), ]); // Check if all approvers have approved $pending = $this->approvers()->wherePivot('approved_at', null)->count(); if ($pending === 0) { $this->state->transitionTo(Approved::class); $this->save(); $this->createPurchaseOrder(); }}Conditional routing
Route to different approvers based on amount:
PHP
// app/Models/PurchaseRequisition.phppublic function submit(){ $this->state->transitionTo(PendingApproval::class); $this->save(); // Determine approvers based on total amount $approvers = $this->determineApprovers(); foreach ($approvers as $approver) { $this->approvers()->attach($approver->id); } $this->notifyApprovers();}protected function determineApprovers(): Collection{ $total = $this->total; if ($total < 1000) { return User::role('supervisor')->get(); } elseif ($total < 10000) { return User::role('manager')->get(); } else { return User::role('director')->get(); }}Audit trail
Track all state changes:
PHP
// app/Models/PurchaseRequisition.phpuse Spatie\Activitylog\Traits\LogsActivity;use Spatie\Activitylog\LogOptions;class PurchaseRequisition extends Model{ use LogsActivity; public function getActivitylogOptions(): LogOptions { return LogOptions::defaults() ->logOnly(['state', 'approved_by', 'rejected_by', 'rejection_reason']) ->logOnlyDirty() ->dontSubmitEmptyLogs(); } public function approve(User $approver) { activity() ->performedOn($this) ->causedBy($approver) ->withProperties(['action' => 'approved']) ->log('Requisition approved'); $this->state->transitionTo(Approved::class); $this->approved_by = $approver->id; $this->approved_at = now(); $this->save(); }}Verification commands
Test approval flows:
Bash
# Create test requisitionphp artisan tinker>>> $pr = PurchaseRequisition::factory()->create()>>> $pr->submit()>>> $pr->state // Should be PendingApproval# Test approval>>> $approver = User::role('approver')->first()>>> $pr->approve($approver)>>> $pr->state // Should be Approved# Test rejection>>> $pr2 = PurchaseRequisition::factory()->create()>>> $pr2->submit()>>> $pr2->reject($approver, 'Insufficient budget')>>> $pr2->state // Should be Rejected# Check audit trail>>> activity()->forSubject($pr)->get()Common pitfalls
Missing authorization checks
Always authorize before state transitions:
PHP
// ❌ Wrong: no authorizationpublic function approve(PurchaseRequisition $requisition){ $requisition->approve(auth()->user());}// ✅ Correct: authorize firstpublic function approve(PurchaseRequisition $requisition){ $this->authorize('approve', $requisition); $requisition->approve(auth()->user());}Invalid state transitions
State machines prevent invalid transitions:
PHP
// This will throw an exception if transition is not allowed$requisition->state->transitionTo(Approved::class);Missing notifications
Always notify relevant users:
PHP
public function approve(User $approver){ $this->state->transitionTo(Approved::class); $this->save(); // Don't forget notifications! $this->notifyRequester('approved'); $this->notifyAccountingTeam();}What to read next
- Notifications — send approval notifications
- Thunderclap — generate approval flow scaffolding
- Access control — role-based approval authorization