How I Built a Multi-Department Workflow Routing System in Laravel with Inertia.js
Source: Dev.to
The Problem I Was Trying to Solve
When I started building VMMS — a voucher management system — the biggest challenge was the workflow routing.
A voucher request doesn’t go to just one office; it passes through multiple departments. At any point a department can:
- Complete its step and pass the request forward
- Reject the entire request
- Flag it for missing documents and pause processing
I needed a system that could handle all of that cleanly.
Configurable Processing Flow
Each voucher type has a configurable processing flow stored in the transaction_flows table:
Schema::create('transaction_flows', function (Blueprint $table) {
$table->id();
$table->foreignId('voucher_id')->constrained()->cascadeOnDelete();
$table->string('department');
$table->integer('order_number');
$table->timestamps();
});
When a client submits a request, the system reads this table to determine the sequence of departments.
Audit Trails
Every time a department processes a request, an audit record is created in the audit_trails table:
Schema::create('audit_trails', function (Blueprint $table) {
$table->id();
$table->foreignId('transaction_id')->constrained()->cascadeOnDelete();
$table->string('processing_offices');
$table->timestamp('process_initiate')->nullable();
$table->timestamp('process_accomplished')->nullable();
$table->date('deadline')->nullable();
$table->timestamps();
});
process_initiateis set when a department starts processing.process_accomplishedis set when they finish.
Determining the Current Step
The logic to find the active department (or the next one) looks like this:
$activeAudit = $auditTrails->first(
fn($a) => $a->process_initiate && !$a->process_accomplished
);
if ($activeAudit) {
return $activeAudit->processing_offices;
}
// If no active audit, find the next step
$doneOrders = $auditTrails
->filter(fn($a) => $a->process_accomplished)
->map(fn($a) => $flow->firstWhere('department', $a->processing_offices)?->order_number ?? 0);
$lastDone = $doneOrders->max() ?? 0;
$nextStep = $flow->firstWhere('order_number', $lastDone + 1);
return $nextStep?->department ?? 'Completed';
Edge Cases Handled
- Skipped department – the code looks for the next order number.
- Missing documents – the request is paused until the required documents are provided.
- Final department completed – returns
'Completed'.
Vue 3 Stepper Component
On the front‑end I built a stepper component to visualise the progress:
const stepStatus = (req, step, stepIndex) => {
const audits = req.audit_trails ?? [];
const accomplishedCount = audits.filter(
a => a.process_accomplished !== null
).length;
if (stepIndex whereNull('resume_transaction')
->pluck('transaction_id')
->toArray();
When resume_transaction is filled, the workflow resumes automatically.
Key Takeaways
- Model the flow separately from the transaction – keeping
transaction_flowsdistinct fromvoucher_transactionsmakes it easy to modify routing logic. - Audit trails are essential – they provide a reliable source of truth for the current state and history.
- Plan for edge cases – skipped steps, document pauses, and final completions need explicit handling.
Demo & Availability
- Live demo: 🔴
- Purchase VMMS: Available on Gumroad –
Feel free to ask any questions in the comments!