I was thinking about something that excited me at work this week, and I wanted to share it out loud.
Imagine you have a flow covering Sales, Supply Chain, and Pre-Sales, all connected in a monolithic project. The flow might start with a BOQ, then produce a Purchase Request, then check if inventory is available — if yes, create a Quotation; if not, send a Request for Quotation (RFQ), then collect Supplier Quotations, and eventually move to purchasing, warehousing, and delivery to the client, with many branching cases at each step.
Now imagine you want to know: where did this BOQ end up?
You’d want a Stage column on each item in the flow, so when you open a BOQ it tells you “we’re currently at Material Issue stage”, and when you open a Supplier Quotation it tells you the same.
A Live Query for this would be a nightmare given how branched and deep the flow is, so the better approach is storing the stage as a database column — and this wasn’t just the easier choice, the business requirements actually needed it.
But here’s the problem: every time the stage changes, do I have to update the entire flow at once? And in every service, do I have to embed the entire flow logic? Imagine the amount of repetition that creates.
So I asked myself: what if, from any point in the flow, I only need to know what came before me and pass it the update — nothing more?
Inside PurchaseRequestService, I know the step before me is BOQ and I have its ID.
Inside QuotationService, the step before me is Purchase Request.
Inside RequestForQuotationService, the step before me is also Purchase Request.
If every service can say “here’s what came before me” and pass along the update data, that would be elegant.
The solution came to mind from a tutorial I was studying in the morning — a video about Event-Driven Architecture (EDA) and how powerful it is in microservices. If you’re interested, it’s in Arabic, so you might want to enable subtitles or switch to Arabic, whichever works for you 🙂
https://youtube.com/playlist?list=PLpJtaIgvI9k_Kz2x91AtekDdVvqzYfMIm&si=Bvfvn4RkODE4jDUF
EDA is built on a simple concept: Producer + Broker + Consumer.
I basically needed to fire an event from any service, have it directed to the right service that needs updating, pass the data, and let that service update itself.
Losing me? Let me break it down:
When Create Purchase Request
-> Fire Event (Producer)
-> Listener (Broker)
-> Finds the right service
-> BoqService (Consumer)
-> Updates BOQ with $data
But what if we want it to keep going dynamically and propagate on its own? Simple — we return the previous step as part of the response:
When Create RFQ
-> Fire Event (Producer)
-> Listener (Broker)
-> Finds the right service
-> PurchaseRequestService (Consumer) -> {
Update current PR with $data
Return previous step (BOQ) + new $data
Fire new event with new data
}
This needs to run through a queue because doing it synchronously at runtime would be terrible for performance — and we don’t need the update to happen in the same second anyway. It essentially becomes a recurring event that keeps firing until it hits the zero point, which is the BOQ (where it returns null and stops). The actual zero point in our case is the Purchase Request, for business reasons I helped design 🙂
Let’s now look at how this translates to code.
The Event — SalesProcessEvent:
<?php
namespace Modules\Sales\Events;
use Illuminate\Queue\SerializesModels;
use Modules\Sales\Enums\SalesProcessStepEnum;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Broadcasting\InteractsWithSockets;
class SalesProcessEvent
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(
public SalesProcessStepEnum $mainStage,
public SalesProcessStepEnum $openStage,
public array $data
) {}
}
The Listener (our Broker) — SalesProcessListener:
<?php
namespace Modules\Sales\Listeners;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
use Modules\Sales\Events\SalesProcessEvent;
use Modules\Sales\Strategies\Classes\SalesProcessStrategy;
class SalesProcessListener implements ShouldQueue
{
use InteractsWithQueue;
public function __construct(
private SalesProcessStrategy $salesProcessStrategy
) {}
public function handle(SalesProcessEvent $event): void
{
$mainStage = $event->mainStage;
$openStage = $this->salesProcessStrategy
->setService($event->openStage)
->updateStage($mainStage, $event->openStage, $event->data);
if ($openStage !== null) {
SalesProcessEvent::dispatch($mainStage, $openStage['stage'], $openStage['data']);
}
}
}
Everything here depends on SalesProcessStrategy. If you're not familiar with the Strategy Pattern, the course above covers it well. The short version:
it takes fixed input, runs different operations depending on context, and can return different output. What changes is the process in the middle — and what controls that is the Strategy, which resolves the right Service based on the type.
We have $mainStage — the latest state that everyone should be updated to — and $openStage — the service we're going to open and update. We set it via setService, then call updateStage. If updateStage returns another step, the listener fires a new event for that step.
The Strategy — SalesProcessStrategy:
<?php
namespace Modules\Sales\Strategies\Classes;
use Modules\PreSales\Services\BoqService;
use Modules\Sales\Enums\SalesProcessStepEnum;
use Modules\PreSales\Services\QuotationService;
use Modules\Sales\Services\MaterialIssueService;
use Modules\Inventory\Services\GoodsReceiptService;
use Modules\Inventory\Services\MaterialReceiptService;
use Modules\SupplyChain\Services\PurchaseOrderService;
use Modules\Inventory\Services\MaterialDispatchService;
use Modules\PreSales\Services\SupplierQuotationService;
use Modules\SupplyChain\Services\PurchaseReceiptService;
use Modules\SupplyChain\Services\PurchaseRequestService;
use Modules\PreSales\Services\RequestForQuotationService;
use Modules\Sales\Strategies\Interface\SalesProccesInterface;
class SalesProcessStrategy
{
private ?SalesProccesInterface $service = null;
/**
* Set the service based on the stage.
*/
public function setService(SalesProcessStepEnum $stage): static
{
$this->service = match ($stage) {
SalesProcessStepEnum::BOQ => new BoqService(),
SalesProcessStepEnum::PURCHASE_REQUEST => new PurchaseRequestService(),
SalesProcessStepEnum::RFQ => new RequestForQuotationService(),
SalesProcessStepEnum::SUPPLIER_QUOTATION => new SupplierQuotationService(),
SalesProcessStepEnum::QUOTATION => new QuotationService(),
SalesProcessStepEnum::PURCHASE_ORDER => new PurchaseOrderService(),
SalesProcessStepEnum::PURCHASE_RECEIPT => new PurchaseReceiptService(),
SalesProcessStepEnum::MATERIAL_RECEIPT => new MaterialReceiptService(),
SalesProcessStepEnum::MATERIAL_ISSUE => new MaterialIssueService(),
SalesProcessStepEnum::MATERIAL_DISPATCH => new MaterialDispatchService(),
SalesProcessStepEnum::GOODS_RECEIPT => new GoodsReceiptService(),
default => null,
};
return $this;
}
/**
* Update the stage.
*/
public function updateStage(SalesProcessStepEnum $mainStage, SalesProcessStepEnum $openStage, array $data): mixed
{
if ($this->service === null) {
throw new \InvalidArgumentException("No service resolved for stage: {$openStage}, main stage: {$mainStage}");
}
return $this->service->updateStage($mainStage, $openStage, $data);
}
}
setService resolves the right Service based on the type and returns $this, which lets us use Method Chaining — a pattern used heavily in Laravel. Look it up, you'll love it, and it's part of the Builder Pattern.
The reason we typed the property as SalesProccesInterface is to enforce a contract. Think of it like going to a coffee shop and ordering coffee — you shouldn't get plain milk back. It might accidentally become a French coffee, but that's unexpected behavior. We prevent that by setting clear standards through an interface.
The Interface — SalesProccesInterface:
<?php
namespace Modules\Sales\Strategies\Interface;
use Modules\Sales\Enums\SalesProcessStepEnum;
interface SalesProccesInterface
{
/*
* Update the stage.
*/
public function updateStage(SalesProcessStepEnum $mainStage, SalesProcessStepEnum $openStage, array $data): mixed;
public function dispatchPreviousStage(SalesProcessStepEnum $mainStage, SalesProcessStepEnum $openStage, array $data): void;
}
BoqService:
<?php
namespace Modules\PreSales\Services;
use Modules\PreSales\Models\Boq;
use Modules\Sales\Enums\SalesProcessStepEnum;
use Modules\Inventory\Events\RegistrationItemCreatedEvent;
use Modules\Sales\Strategies\Interface\SalesProccesInterface;
class BoqService implements SalesProccesInterface
{
/**
* Update the stage.
*/
public function updateStage(SalesProcessStepEnum $mainStage, SalesProcessStepEnum $openStage, array $data): mixed
{
$boq = Boq::find($data['id']);
if ($boq && SalesProcessStepEnum::upThanCurrent($boq->stage, $mainStage)) {
$boq->update([
'stage' => $mainStage,
]);
}
return null;
}
/**
* Dispatch previous stage.
*/
public function dispatchPreviousStage(SalesProcessStepEnum $mainStage, SalesProcessStepEnum $openStage, array $data): void {}
}
We update the BOQ only if two conditions are met: the BOQ exists, and upThanCurrent confirms the new stage is actually newer and higher. This matters because the BOQ might already be at final delivery stage,
then something goes wrong — a damaged shipment — and the client requests a new Purchase Order. In that case, the BOQ should stay as-is. We'd handle the exception separately in a Log, which is a topic for another day 🙂
dispatchPreviousStage is empty here because nothing comes before BOQ in this flow.
PurchaseRequestService:
<?php
namespace Modules\SupplyChain\Services;
use Modules\PreSales\Models\Boq;
use Modules\Sales\Events\SalesProcessEvent;
use Modules\Sales\Enums\SalesProcessStepEnum;
use Modules\SupplyChain\Models\PurchaseRequest;
use Modules\SupplyChain\Enums\PurchaseRequestTypeEnum;
use Modules\Sales\Strategies\Interface\SalesProccesInterface;
class PurchaseRequestService implements SalesProccesInterface
{
/**
* Create a new PurchaseRequest.
*
* @param array $data
* @return \Modules\SupplyChain\Models\PurchaseRequest
*/
public function store(array $data): PurchaseRequest
{
$purchaseRequest = PurchaseRequest::create($data);
$this->syncPurchaseRequestItem($purchaseRequest, $data);
$this->dispatchPreviousStage(SalesProcessStepEnum::PURCHASE_REQUEST, SalesProcessStepEnum::BOQ, [
'id' => $purchaseRequest->pull_id,
]);
return $purchaseRequest->fresh();
}
/**
* Update the stage.
*/
public function updateStage(SalesProcessStepEnum $mainStage, SalesProcessStepEnum $openStage, array $data): mixed
{
$purchaseRequest = PurchaseRequest::find($data['id']);
if ($purchaseRequest && SalesProcessStepEnum::upThanCurrent($purchaseRequest->stage, $mainStage)) {
$purchaseRequest->update([
'stage' => $mainStage,
]);
}
if ($purchaseRequest->type == PurchaseRequestTypeEnum::PROJECT) {
return [
'stage' => SalesProcessStepEnum::BOQ,
'data' => [
'id' => $purchaseRequest?->pull_id ?? null,
]
];
}
return null;
}
/**
* Dispatch previous stage.
*/
public function dispatchPreviousStage(SalesProcessStepEnum $mainStage, SalesProcessStepEnum $openStage, array $data): void
{
SalesProcessEvent::dispatch($mainStage, $openStage, $data);
}
}
In store, dispatchPreviousStage fires immediately when a new record is created — it's the starting fire point. It says: my current stage is PURCHASE_REQUEST, the step before me is BOQ, here's its ID, go update it.
In updateStage, the service acts as a Consumer: it updates itself, then returns the previous step's data so the *Listener *knows there's another step to process and fires a new event for it. That's the recursion.
You might ask: why have both dispatchPreviousStage and updateStage? Because they serve different roles:
dispatchPreviousStage → fires on creation (the trigger point)
updateStage → fires as a consumer when receiving an event, updates itself, then returns the previous step to keep the chain going
This means each Service is simultaneously a Producer for the steps that precede it, and a Consumer for the events coming toward it — and it returns the previous step so the Broker can keep firing until we reach the zero point.
This is the simplified version. You could absolutely add DTOs for stronger type safety and more validation, and at that point you’d essentially have a Workflow Engine. I can’t share the full codebase since this is just a small piece inside an ERP system at my company.
Finally — I’m just your junior self-taught friend who’s trying to figure things out, so feedback is always welcome.
The idea is entirely my own with no AI involvement. I only used AI to fix spelling and grammar in this write-up — not in the workflow engine itself, not in the idea, not in anything else here.
Take care 👋

Top comments (0)