Workflows
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.php
namespace 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.php
namespace 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.php
namespace 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.php
namespace 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.php
public 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.php
public 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.php
public 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.php
use 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 requisition
php 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 authorization
public function approve(PurchaseRequisition $requisition)
{
$requisition->approve(auth()->user());
}
// ✅ Correct: authorize first
public 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