FlexPoint Docs
Engineering

Event-Driven Architecture

How domain events, listeners, and the workflow engine compose automation in the FlexPoint API.

The portal uses Laravel's event system to decouple what happened (a domain event) from what should happen next (listeners and the workflow engine).

The canonical event: LoanStatusChanged

App\Events\LoanStatusChanged is fired after a loan's lifecycle status has transitioned and been persisted. It carries everything a listener needs:

final class LoanStatusChanged
{
    use Dispatchable;

    public function __construct(
        public readonly Loan $loan,
        public readonly LoanStatus $from,
        public readonly LoanStatus $to,
        public readonly User $actor,
    ) {}
}

Two listeners, two responsibilities

LoanStatusChanged fans out to two listeners that stay deliberately separate:

ListenerResponsibility
App\Listeners\SendLoanStatusNotificationsNotifications stay a dedicated path.
App\Listeners\RunWorkflowsBridges the event into the generic workflow engine for every other automation.

RunWorkflows translates the domain event into a workflow trigger and builds the context the engine's conditions and actions read from:

public function handle(LoanStatusChanged $event): void
{
    $this->engine->dispatch(WorkflowTrigger::LoanStatusChanged, [
        'loan_id'     => $event->loan->id,
        'loan_number' => $event->loan->loan_number,
        'from_status' => $event->from->value,
        'to_status'   => $event->to->value,
        'actor_id'    => $event->actor->id,
    ]);
}

The workflow engine

App\Services\Workflow\WorkflowEngine is the generic automation layer. A WorkflowTrigger (such as LoanStatusChanged) dispatches to configured workflows whose conditions are evaluated against the event context; matching workflows run their actions (for example App\Services\Workflow\Actions\CreateConditionWorkflowAction). This is where the former app/Workflows/ engine now lives, under app/Services/Workflow/.

Conditions are a direct state change today

No ConditionResolved event (yet)

Resolving an underwriting condition is currently a direct state change, not its own domain event. App\Actions\Loans\ResolveCondition sets the condition's status to Cleared or Waived and records cleared_by / cleared_at — it does not emit a ConditionResolved event. Automation keyed off condition state therefore runs through the loan-status path, not a condition event. If a future feature needs to react to condition sign-off directly, add a domain event here.

See the product-level view of these events in Product → Events & Workflows.

On this page