35 KiB
BankFlow Project Blueprint
1. Purpose
This document is the implementation blueprint for the BankFlow challenge. It converts the approved architectural decisions into a fixed Laravel project structure, domain model, workflow design, database schema, API boundary, and test plan.
Implementation must remain consistent with:
When this blueprint and a general Laravel convention differ, this blueprint controls the project-specific architecture. The functional requirements and API contract remain the source of truth for observable behavior.
2. Fixed technical decisions
| Concern | Decision |
|---|---|
| Framework | Laravel 13 on PHP 8.4 |
| Database | PostgreSQL |
| Persistence | Eloquent ORM |
| Architecture | Layered Laravel architecture with loan-domain grouping |
| Application operations | Single-purpose Action classes |
| Service layer | No generic LoanService |
| Repository pattern | Not used; Eloquent is the only persistence implementation |
| Workflow | Ordered collection of independent Stage classes |
| Stage state | Stateless |
| Workflow definitions | Versioned database configuration per loan_type_id |
| Business-rule values | Validated JSON on configured Workflow steps |
| Stage implementation registry | Trusted code mapping in config/workflow-stages.php |
| Domain values | PHP backed Enums, except database-managed Loan types |
| Processing model | Synchronous |
| Queue | Not used |
| Transaction boundary | One database transaction for the complete processing operation |
| Concurrency control | Row lock plus database uniqueness constraints |
| Business history | Stored in loan_histories |
| Operational logging | Standard Laravel logging for unexpected failures |
| Tests | Pest unit and feature tests |
This database-managed configuration design supersedes the earlier proposal to store Workflow order in config/workflows.php and rule values in config/loan.php. Those files must not be introduced as a second source of truth.
2.1 Explicitly excluded patterns
The initial implementation must not introduce:
- a Repository layer over Eloquent;
- a generic or oversized
LoanService; - asynchronous workflow Jobs;
- Horizon, Kafka, RabbitMQ, or another broker;
- a generic Rule Engine or executable rule language;
- Clean Architecture or Hexagonal boilerplate;
- event sourcing, CQRS, or microservices.
These patterns solve problems outside the challenge scope and would add implementation and operational complexity without improving the required behavior.
3. Architectural flow
HTTP Request
↓
Form Request (transport structure only)
↓
LoanController
↓
Single-purpose Action
↓
WorkflowEngine or Eloquent Model
↓
PostgreSQL
Workflow processing follows this flow:
ProcessLoanAction
↓
Database transaction + loan row lock
↓
Loan's immutable WorkflowConfiguration
↓
WorkflowEngine
↓
Ordered stateless Stage instances
↓
Loan + LoanHistory persistence
Layer responsibilities
| Layer | Responsibility |
|---|---|
| HTTP | Parse requests, authorize access, invoke an Action, and return API Resources |
| Actions | Implement one application use case and own its transaction boundary |
| Domain | Define enums, execution results, workflow orchestration, and business stages |
| Models | Persist loans and their execution history through Eloquent |
| Configuration models | Store versioned Workflow order and rules per Loan type |
Controllers must not contain business rules. Models must not orchestrate the Workflow. Stage classes must not produce HTTP responses or write logs as business history.
4. Project structure
app/
├── Actions/
│ ├── Loan/
│ │ ├── CreateLoanAction.php
│ │ ├── GetLoanAction.php
│ │ ├── GetLoanHistoryAction.php
│ │ └── ProcessLoanAction.php
│ └── WorkflowConfiguration/
│ ├── CreateWorkflowConfigurationAction.php
│ ├── PublishWorkflowConfigurationAction.php
│ └── UpdateWorkflowConfigurationAction.php
│
├── Domain/
│ └── Loan/
│ ├── Contracts/
│ │ └── StageInterface.php
│ ├── Data/
│ │ └── ExecutionResult.php
│ ├── Enums/
│ │ ├── LoanStage.php
│ │ ├── LoanStatus.php
│ │ ├── StageResultType.php
│ │ └── WorkflowConfigurationStatus.php
│ ├── Exceptions/
│ │ ├── InvalidWorkflowConfiguration.php
│ │ └── WorkflowException.php
│ └── Workflow/
│ ├── Stages/
│ │ ├── CreditCheckStage.php
│ │ ├── FraudCheckStage.php
│ │ ├── GuarantorCheckStage.php
│ │ ├── ManagerApprovalStage.php
│ │ └── ValidationStage.php
│ ├── WorkflowConfigurationResolver.php
│ └── WorkflowEngine.php
│
├── Http/
│ ├── Controllers/
│ │ ├── Admin/
│ │ │ └── WorkflowConfigurationController.php
│ │ └── Api/
│ │ └── V1/
│ │ └── LoanController.php
│ ├── Requests/
│ │ ├── Admin/
│ │ │ ├── StoreWorkflowConfigurationRequest.php
│ │ │ └── UpdateWorkflowConfigurationRequest.php
│ │ └── StoreLoanRequest.php
│ └── Resources/
│ ├── LoanHistoryResource.php
│ ├── LoanProcessResource.php
│ └── LoanResource.php
│
├── Models/
│ ├── Loan.php
│ ├── LoanHistory.php
│ ├── LoanType.php
│ ├── StageDefinition.php
│ ├── WorkflowConfiguration.php
│ └── WorkflowConfigurationStep.php
│
└── Providers/
└── AppServiceProvider.php
config/
└── workflow-stages.php
database/
├── factories/
│ ├── LoanFactory.php
│ ├── LoanHistoryFactory.php
│ ├── LoanTypeFactory.php
│ └── WorkflowConfigurationFactory.php
├── migrations/
│ ├── *_create_loan_types_table.php
│ ├── *_create_stage_definitions_table.php
│ ├── *_create_workflow_configurations_table.php
│ ├── *_create_workflow_configuration_steps_table.php
│ ├── *_create_loans_table.php
│ └── *_create_loan_histories_table.php
└── seeders/
└── DatabaseSeeder.php
routes/
├── api.php
└── web.php
tests/
├── Feature/
│ └── Loan/
│ ├── CreateLoanTest.php
│ ├── GetLoanHistoryTest.php
│ ├── GetLoanTest.php
│ ├── HealthCheckTest.php
│ └── ProcessLoanTest.php
└── Unit/
└── Domain/
└── Loan/
├── Workflow/
│ ├── CreditCheckStageTest.php
│ ├── FraudCheckStageTest.php
│ ├── GuarantorCheckStageTest.php
│ ├── ManagerApprovalStageTest.php
│ ├── ValidationStageTest.php
│ └── WorkflowEngineTest.php
├── WorkflowConfigurationResolverTest.php
└── WorkflowConfigurationPublishingTest.php
Dockerfile
README.md
DESIGN.md
ENGINEERING_DECISIONS.md
TESTING.md
Naming rules
| Concern | Convention | Example |
|---|---|---|
| Controller | Singular resource + Controller |
LoanController |
| Action | Verb + resource + Action |
ProcessLoanAction |
| Stage | Business name + Stage |
FraudCheckStage |
| Enum | Domain concept | LoanStatus |
| Data object | Meaning of the returned data | ExecutionResult |
| Engine | Orchestrated concept + Engine |
WorkflowEngine |
Do not introduce alternate names such as WorkflowManager, LoanProcessor, or StageResult for concepts already named here.
5. Domain design
Three domain concepts must remain separate:
| Concept | Purpose | Examples |
|---|---|---|
| Loan status | Current lifecycle state of a loan | APPROVED, REJECTED |
| Workflow stage | Business operation being executed | FRAUD_CHECK, CREDIT_CHECK |
| Stage result | Outcome returned by a stage | PASS, FAIL, MANUAL_REVIEW |
5.1 LoanStatus
SUBMITTED
IN_PROGRESS
MANUAL_REVIEW
APPROVED
REJECTED
Terminal automatic-processing statuses are:
MANUAL_REVIEW
APPROVED
REJECTED
5.2 LoanStage
VALIDATION
FRAUD_CHECK
GUARANTOR_CHECK
CREDIT_CHECK
MANAGER_APPROVAL
MANUAL_REVIEW is a result and loan status, not an executable Stage.
5.3 StageResultType
PASS
FAIL
MANUAL_REVIEW
5.4 Loan types
Loan types are database records, not a PHP Enum, because administrators configure a Workflow by loan_type_id. The initial seeded codes are:
PERSONAL
BUSINESS
The code remains a stable machine identifier even when the display name changes.
5.5 WorkflowConfigurationStatus
DRAFT
PUBLISHED
ARCHIVED
Only draft configurations are editable. Published and archived configurations are immutable.
5.6 ExecutionResult
ExecutionResult is an immutable data object returned by every Stage. It is not an Eloquent model.
It contains:
| Property | Type | Purpose |
|---|---|---|
type |
StageResultType |
Stage outcome |
reason |
string |
SUCCESS or a business reason code |
The Workflow Engine maps the result to the next status and stage. Stages must not directly choose HTTP status codes.
5.7 StageInterface
All Workflow stages implement one contract:
interface StageInterface
{
/** @param array<string, mixed> $rules */
public function execute(Loan $loan, array $rules): ExecutionResult;
}
Stage requirements:
- one business responsibility per class;
- no mutable internal state;
- no database transaction management;
- no HTTP concerns;
- no direct selection of the next Stage;
- all configurable numbers read from the validated step rules supplied by the Engine;
- deterministic output for the same Loan and rule snapshot.
6. Workflow configuration
Administrators configure Workflow order and rules in the database for a specific loan_type_id. Configuration is versioned so edits cannot change the behavior of a Loan that is already being processed.
6.1 Configuration ownership
LoanType
└── WorkflowConfiguration
└── WorkflowConfigurationStep
└── StageDefinition
Each WorkflowConfiguration belongs to exactly one LoanType. A Loan stores the exact published workflow_configuration_id selected when the Loan is created.
6.2 Lifecycle
- An administrator creates a
DRAFTconfiguration for a Loan type. - The administrator selects supported Stage definitions, sets their order, and supplies Stage-specific rules.
- The system validates the complete configuration.
- Publishing occurs inside a transaction.
- The previously published configuration for that Loan type becomes
ARCHIVED. - The selected draft becomes
PUBLISHEDand immutable. - New Loans use the published configuration; existing Loans keep their assigned configuration.
Administrators never edit a published configuration in place. A change creates a new version.
6.3 Trusted Stage registry
The database stores stable Stage codes, not PHP class names or executable expressions. config/workflow-stages.php maps each trusted code to an application handler:
return [
'VALIDATION' => ValidationStage::class,
'FRAUD_CHECK' => FraudCheckStage::class,
'GUARANTOR_CHECK' => GuarantorCheckStage::class,
'CREDIT_CHECK' => CreditCheckStage::class,
'MANAGER_APPROVAL' => ManagerApprovalStage::class,
];
An administrator can select only active StageDefinition records whose codes exist in this registry. Admin input must never contain PHP, SQL, a handler class name, or an unrestricted expression.
6.4 Per-step rules
Rules are stored as JSON on workflow_configuration_steps. Every Stage handler owns the schema and validation of its rules.
Examples:
{
"rejectBelow": 500,
"manualReviewMin": 500,
"manualReviewMax": 649,
"approveFrom": 650
}
{
"activationThreshold": 500000000,
"incomeMultiplier": 20
}
Publishing must fail if:
- a required Stage is missing;
- a Stage or position is duplicated;
- a configured Stage code has no registered handler;
- a rule has the wrong type or an invalid value;
- credit ranges overlap or contain an unintended gap;
- the Workflow cannot reach a terminal outcome.
6.5 Initial configurations
Seed initial published configurations matching the challenge requirements:
PERSONAL:
VALIDATION → FRAUD_CHECK → CREDIT_CHECK → MANAGER_APPROVAL
BUSINESS:
VALIDATION → FRAUD_CHECK → GUARANTOR_CHECK
→ CREDIT_CHECK → MANAGER_APPROVAL
The initial rule values are:
| Rule | Default |
|---|---|
| Credit rejection boundary | 500 |
| Manual-review minimum | 500 |
| Manual-review maximum | 649 |
| Credit approval boundary | 650 |
| Manager activation threshold | 500000000 |
| Income multiplier | 20 |
6.6 Adding a future Stage
Adding a new Stage such as AML_CHECK requires:
- adding its
LoanStageenum value; - creating a class implementing
StageInterface; - registering its trusted code and class in
config/workflow-stages.php; - seeding or creating its
StageDefinition; - letting an administrator add it to a new draft Workflow version;
- adding Stage unit tests and Workflow integration tests.
Existing Stage classes and the Workflow Engine must not require modification.
7. Stage behavior
7.1 ValidationStage
| Field | Rule | Failure reason |
|---|---|---|
customerId |
Required and not empty | INVALID_CUSTOMER_ID |
amount |
Greater than 0 |
INVALID_AMOUNT |
phone |
Exactly 11 digits, starts with 09, digits only |
INVALID_PHONE |
loanType |
PERSONAL or BUSINESS |
INVALID_LOAN_TYPE |
monthlyIncome |
Greater than or equal to 0 |
INVALID_MONTHLY_INCOME |
creditScore |
Between 0 and 1000, inclusive |
INVALID_CREDIT_SCORE |
These are business Workflow rules. They must produce a LoanHistory entry and must not be implemented as HTTP validation that returns 422 Unprocessable Entity.
StoreLoanRequest validates only the transport contract: valid JSON shape, required keys, and values that can be safely passed into the domain. Semantic acceptance remains the responsibility of ValidationStage.
7.2 FraudCheckStage
| Condition | Result |
|---|---|
customerId starts with FRAUD |
FAIL |
customerId starts with REVIEW |
MANUAL_REVIEW |
| Otherwise | PASS |
7.3 GuarantorCheckStage
This Stage exists only in the BUSINESS Workflow.
| Condition | Result |
|---|---|
hasGuarantor is false |
FAIL |
hasGuarantor is true |
PASS |
7.4 CreditCheckStage
| Configured condition | Result |
|---|---|
Below manualReviewMin |
FAIL |
Between manualReviewMin and manualReviewMax |
MANUAL_REVIEW |
At least approveFrom |
PASS |
All boundaries come from the validated rules of the assigned CREDIT_CHECK configuration step.
7.5 ManagerApprovalStage
The Stage is skipped when:
amount <= activationThreshold
When it applies:
| Condition | Result |
|---|---|
amount > monthlyIncome × incomeMultiplier |
FAIL |
| Otherwise | PASS |
The Engine may ask a Stage whether it applies, or the Stage may return a successful no-op result. The chosen mechanism must be consistent for all conditional Stages and must not create a history record for a Stage that was not executed.
8. Workflow Engine
Responsibilities
WorkflowEngine is responsible for:
- loading the ordered steps from the Loan's assigned
WorkflowConfiguration; - resolving every
StageDefinition.codethrough the trusted handler registry; - passing each step's validated rules to its Stage handler;
- finding the current executable Stage;
- skipping Stages that do not apply;
- executing each applicable Stage once;
- recording one history entry for each executed Stage;
- stopping on
FAILorMANUAL_REVIEW; - approving the Loan after all applicable Stages pass;
- keeping
statusandcurrent_workflow_configuration_step_idconsistent.
It is not responsible for:
- parsing HTTP input;
- opening the database transaction;
- defining business thresholds;
- implementing Stage-specific rules;
- returning JSON responses.
Processing algorithm
ProcessLoanAction performs the following operation:
BEGIN TRANSACTION
↓
SELECT loan FOR UPDATE
↓
If status is terminal, return current state
↓
Load Loan's assigned published WorkflowConfiguration
↓
Set status to IN_PROGRESS
↓
For every remaining applicable configuration step:
set current_workflow_configuration_step_id
resolve trusted Stage handler
execute Stage with the step's rules
insert LoanHistory with rule snapshot
PASS → continue
FAIL → status = REJECTED; current step = null; stop
MANUAL_REVIEW → status = MANUAL_REVIEW; current step = null; stop
↓
All applicable Stages passed
↓
status = APPROVED; current step = null
COMMIT
If an unexpected exception occurs, the complete transaction rolls back. No partial history or half-updated Loan state may remain.
Synchronous execution
POST /api/v1/loans/{loanId}/process executes all remaining Stages in the request and returns the resulting terminal state. The client does not call the endpoint once per Stage.
No Stage is dispatched to a queue in this version.
9. Idempotency and concurrency
The process endpoint must be safe when called repeatedly or concurrently.
Application safeguards
- Return immediately when the Loan already has a terminal status.
- Execute only Stages without an existing history record.
- Lock the Loan row for the duration of processing.
Database safeguard
The database must enforce:
UNIQUE (loan_id, workflow_configuration_step_id)
on loan_histories.
Together, the row lock and unique constraint guarantee that:
- a configured step is not executed twice for the same Loan;
- duplicate history is not stored;
- concurrent requests cannot overwrite the final state;
- reprocessing a terminal Loan returns its existing state.
10. Database design
10.1 loan_types
| Column | Type | Constraints |
|---|---|---|
id |
bigint |
Primary key |
code |
string |
Unique, stable machine identifier |
name |
string |
Display name |
is_active |
boolean |
Required, indexed |
created_at |
timestamp |
Required |
updated_at |
timestamp |
Required |
Seed PERSONAL and BUSINESS. Deactivating a type prevents new Loans but does not affect existing Loans.
10.2 stage_definitions
| Column | Type | Constraints |
|---|---|---|
id |
bigint |
Primary key |
code |
string |
Unique; must exist in the trusted registry |
name |
string |
Admin-facing display name |
is_active |
boolean |
Required, indexed |
created_at |
timestamp |
Required |
updated_at |
timestamp |
Required |
This table controls what administrators can select. It does not store executable class names.
10.3 workflow_configurations
| Column | Type | Constraints |
|---|---|---|
id |
bigint |
Primary key |
loan_type_id |
bigint |
Foreign key to loan_types.id, indexed |
name |
string |
Admin-facing name |
version |
integer |
Positive version number |
status |
string |
Cast to WorkflowConfigurationStatus, indexed |
published_at |
timestamp, nullable |
Set only when published |
created_by |
bigint, nullable |
Foreign key to users.id |
created_at |
timestamp |
Required |
updated_at |
timestamp |
Required |
Constraints:
UNIQUE (loan_type_id, version)
UNIQUE (id, loan_type_id)
PostgreSQL should enforce at most one PUBLISHED configuration per Loan type with a partial unique index. Publishing archives the previous version in the same transaction.
10.4 workflow_configuration_steps
| Column | Type | Constraints |
|---|---|---|
id |
bigint |
Primary key |
workflow_configuration_id |
bigint |
Foreign key, indexed |
stage_definition_id |
bigint |
Foreign key, indexed |
position |
integer |
Positive execution order |
rules |
jsonb |
Stage-specific validated rules |
is_enabled |
boolean |
Required |
created_at |
timestamp |
Required |
updated_at |
timestamp |
Required |
Constraints:
UNIQUE (workflow_configuration_id, position)
UNIQUE (workflow_configuration_id, stage_definition_id)
UNIQUE (id, workflow_configuration_id)
rules is cast to an array by Eloquent. Only draft steps may be created, updated, reordered, enabled, disabled, or deleted.
10.5 loans
| Column | Type | Constraints |
|---|---|---|
id |
bigint |
Primary key |
public_id |
string |
Unique; exposed as API loanId |
customer_id |
string |
Indexed |
loan_type_id |
bigint |
Foreign key to loan_types.id, indexed |
workflow_configuration_id |
bigint |
Immutable foreign key, indexed |
current_workflow_configuration_step_id |
bigint, nullable |
Foreign key, indexed |
amount |
bigint |
Required |
phone |
string(11) |
Required |
monthly_income |
bigint |
Required |
credit_score |
integer |
Required |
has_guarantor |
boolean |
Required |
status |
string |
Required, indexed; cast to LoanStatus |
created_at |
timestamp |
Required |
updated_at |
timestamp |
Required |
workflow_configuration_id is assigned when the Loan is created and must never change. The configuration's loan_type_id must match the Loan's loan_type_id, and the current step must belong to that configuration. Enforce both invariants with composite foreign keys in PostgreSQL as well as validation in the creation and processing Actions.
FOREIGN KEY (workflow_configuration_id, loan_type_id)
REFERENCES workflow_configurations(id, loan_type_id)
FOREIGN KEY (current_workflow_configuration_step_id, workflow_configuration_id)
REFERENCES workflow_configuration_steps(id, workflow_configuration_id)
The API's loanType and currentStage values are derived from the related LoanType.code and current step's StageDefinition.code.
10.6 loan_histories
| Column | Type | Constraints |
|---|---|---|
id |
bigint |
Primary key |
loan_id |
bigint |
Foreign key to loans.id, indexed |
workflow_configuration_step_id |
bigint |
Foreign key, indexed |
stage_code |
string |
Immutable audit snapshot |
rules_snapshot |
jsonb |
Immutable rules used for execution |
result |
string |
Cast to StageResultType |
reason |
string |
Required |
executed_at |
timestamp |
Required, indexed |
created_at |
timestamp |
Required |
updated_at |
timestamp |
Required |
Constraints:
FOREIGN KEY (loan_id) REFERENCES loans(id) ON DELETE CASCADE
UNIQUE (loan_id, workflow_configuration_step_id)
INDEX (loan_id, executed_at)
The snapshots preserve the exact Stage code and rule values used for the decision even if display metadata changes later. History is ordered by executed_at, then by id as a deterministic tie-breaker.
Published or referenced configurations and steps must never be hard-deleted. Archive them so Loan execution and audit history remain reproducible.
Relationships
LoanType hasMany WorkflowConfiguration
LoanType hasMany Loan
WorkflowConfiguration belongsTo LoanType
WorkflowConfiguration hasMany WorkflowConfigurationStep
WorkflowConfiguration hasMany Loan
WorkflowConfigurationStep belongsTo WorkflowConfiguration
WorkflowConfigurationStep belongsTo StageDefinition
WorkflowConfigurationStep hasMany LoanHistory
StageDefinition hasMany WorkflowConfigurationStep
Loan belongsTo LoanType
Loan belongsTo WorkflowConfiguration
Loan belongsTo WorkflowConfigurationStep as currentStep
Loan hasMany LoanHistory
LoanHistory belongsTo Loan
LoanHistory belongsTo WorkflowConfigurationStep
All Eloquent relationships require explicit Laravel relationship return types. JSON, boolean, status, and timestamp columns require model casts.
11. Actions
CreateLoanAction
- Accepts transport-safe request data.
- Resolves the active
LoanTypeby its stable code. - Resolves that type's single published
WorkflowConfiguration. - Fails clearly when no published configuration exists.
- Generates a unique public Loan identifier.
- Stores the immutable
loan_type_idandworkflow_configuration_idassignments. - Stores the Loan with
SUBMITTEDstatus and the first configured step as its current step. - Does not execute the Workflow.
ProcessLoanAction
- Opens the database transaction.
- loads and locks the Loan row;
- returns an existing terminal Loan without changing it;
- eager-loads the assigned configuration, ordered steps, and Stage definitions;
- invokes
WorkflowEngine; - returns the final Loan state.
GetLoanAction
- Finds a Loan by
public_id. - Throws the application’s not-found exception when it does not exist.
GetLoanHistoryAction
- Finds the Loan by
public_id. - Returns its history in deterministic chronological order.
- Eager-loads required relationships and avoids N+1 queries.
CreateWorkflowConfigurationAction
- Creates the next draft version for a
loan_type_id. - Copies an existing version's steps when requested.
- Never creates a second draft version number.
UpdateWorkflowConfigurationAction
- Updates only a
DRAFTconfiguration. - Validates selected Stage definitions, positions, and Stage-specific rules.
- Replaces or updates steps atomically so a partial ordering is never exposed.
PublishWorkflowConfigurationAction
- Locks the Loan type's configurations.
- Fully validates the draft and its ordered steps.
- Archives the currently published version.
- Publishes the selected draft with
published_atandcreated_byaudit data. - Performs all status changes inside one database transaction.
12. HTTP and API design
The detailed contract is defined in api-routes.md.
Routes
POST /api/v1/loans
POST /api/v1/loans/{loanId}/process
GET /api/v1/loans/{loanId}
GET /api/v1/loans/{loanId}/history
GET /health
Controller rules
LoanController must:
- remain thin;
- use route model binding or an Action-based public-ID lookup consistently;
- invoke exactly one Action per endpoint;
- return API Resources;
- contain no Workflow or validation business rules.
HTTP validation boundary
| Failure | Handling |
|---|---|
| Malformed JSON or unusable transport structure | 400 INVALID_REQUEST |
| Semantically invalid amount, phone, score, or other loan value | Loan is created; ValidationStage later returns FAIL |
| Unknown Loan public ID | 404 LOAN_NOT_FOUND |
Do not return Laravel’s default HTML error pages or default 422 validation payload for business validation failures.
Admin configuration boundary
The admin panel operates on LoanType, WorkflowConfiguration, and WorkflowConfigurationStep through the dedicated admin Controller, Form Requests, and Actions. It must support:
- listing configurations by
loan_type_id; - creating a draft or copying an existing version;
- selecting active Stage definitions;
- ordering steps;
- editing Stage-specific rules;
- validating a draft;
- publishing a draft;
- viewing archived versions and their assigned Loans.
All admin mutations require authorization. Published and archived configurations are read-only. The public loan API must never accept a client-supplied workflow_configuration_id; the server selects the published configuration for the requested Loan type.
13. Logging and error handling
Business history and operational logs are separate concerns.
loan_histories
Stores expected business outcomes:
- successful Stage execution;
- validation failure;
- fraud rejection;
- manual-review decision;
- credit or manager rejection.
Laravel logs
Store unexpected operational failures:
- database errors;
- invalid Workflow configuration;
- unresolvable Stage classes;
- unexpected exceptions.
Operational logs should include loanId, current Stage, and exception context where available, but must not replace persisted business history.
14. Test blueprint
The project uses Pest 4.
Unit tests
Each Stage is tested independently with all boundary conditions.
ValidationStageTest
- valid application passes;
- empty customer ID fails;
- zero and negative amounts fail;
- valid and invalid phone formats;
- invalid loan type fails;
- negative monthly income fails;
- credit scores below
0and above1000fail.
FraudCheckStageTest
FRAUDprefix fails;REVIEWprefix requests manual review;- any other prefix passes.
GuarantorCheckStageTest
- missing guarantor fails;
- existing guarantor passes.
CreditCheckStageTest
- score below manual range fails;
- lower and upper manual-range boundaries request manual review;
- score at the approval boundary passes;
- overridden configuration changes behavior.
ManagerApprovalStageTest
- Stage does not apply below or at its activation threshold;
- excessive income multiple fails;
- acceptable income multiple passes;
- overridden configuration changes behavior.
WorkflowEngineTest
- runs Stages in configured order;
- stops on
FAIL; - stops on
MANUAL_REVIEW; - approves after all Stages pass;
- skips non-applicable Stages;
- records each executed Stage once;
- does not record skipped Stages.
WorkflowConfigurationResolverTest
- resolves the Loan's assigned configuration rather than the latest version;
- resolves steps in ascending position;
- maps every Stage code to a handler implementing
StageInterface; - rejects missing or inactive Stage definitions;
- rejects invalid rule schemas.
WorkflowConfigurationPublishingTest
- only drafts can be changed or published;
- publishing archives the previous version;
- only one version per Loan type is published;
- invalid Stage order and rule ranges cannot be published;
- a published configuration is immutable.
Feature tests
CreateLoanTest
- creates a Loan with the expected initial state;
- assigns the published configuration for the selected
loan_type_id; - rejects creation when the Loan type has no published configuration;
- does not accept a client-selected configuration ID;
- generates a unique public ID;
- returns
400 INVALID_REQUESTfor malformed JSON; - accepts semantically invalid business values for later Workflow validation.
ProcessLoanTest
- approves a valid personal Loan;
- approves a valid business Loan with a guarantor;
- rejects each validation failure;
- handles fraud failure and manual review;
- rejects a business Loan without a guarantor;
- handles all credit-score outcomes;
- executes conditional manager approval;
- returns
404 LOAN_NOT_FOUND; - remains idempotent after every terminal status;
- does not create duplicate history.
GetLoanTest
- returns the complete Loan representation;
- returns
404 LOAN_NOT_FOUNDfor an unknown ID.
GetLoanHistoryTest
- returns Stage history chronologically;
- returns each Stage once;
- returns
404 LOAN_NOT_FOUNDfor an unknown ID.
HealthCheckTest
- returns
200 OKwith{"status":"UP"}.
Admin Workflow configuration tests
- creates and updates a draft for a Loan type;
- copies an existing version without sharing mutable steps;
- rejects duplicate Stage positions and definitions;
- rejects invalid Stage-specific rule JSON;
- publishes atomically;
- prevents modification of published and archived versions;
- keeps in-progress Loans assigned to their original version;
- assigns newly created Loans to the newly published version;
- rejects unauthorized configuration mutations.
Persistence verification
Feature tests use the real database integration path rather than mocking Eloquent. Persistence behavior must also be verified through the Dockerized PostgreSQL environment.
15. Docker and runtime
The application must be buildable and runnable using:
docker build -t bankflow .
docker run -p 8080:8080 bankflow
Requirements:
- the service listens on port
8080; - PostgreSQL-backed data survives an application process restart;
- the trusted Stage registry is loaded at startup and published Workflow configurations are loaded from PostgreSQL;
- the health endpoint becomes available within 60 seconds;
- the production image contains only runtime requirements;
- migrations are run through an explicit, documented startup strategy.
Docker Compose may be provided for local development, but it must not replace the required docker build and docker run workflow.
16. Required documentation
| File | Purpose |
|---|---|
README.md |
Build, run, test, dependencies, and project overview |
DESIGN.md |
Architecture, Workflow model, persistence, and extensibility |
ENGINEERING_DECISIONS.md |
Decisions, alternatives, and trade-offs |
TESTING.md |
Unit and feature test strategy and coverage |
These files must describe the implemented system, not an aspirational design that differs from the code.
17. Implementation order
Implementation should proceed in small, reviewable phases:
- Project configuration and PostgreSQL connection.
- Domain Enums and
ExecutionResult. - Loan and LoanHistory migrations, models, factories, and relationships.
- Loan type, Stage definition, Workflow configuration, and step models.
- Trusted Stage registry and initial Workflow seed data.
StageInterfaceand individual stateless Stages.WorkflowConfigurationResolverand publishing validation.WorkflowEngine.- Loan Actions and transaction/concurrency behavior.
- Admin Workflow configuration Actions and HTTP boundary.
- Public Form Request, API Resources, Controller, and routes.
- Unit tests.
- Feature and persistence tests.
- Docker image and runtime verification.
- Required project documentation.
Each phase must pass its focused tests before the next phase begins.
18. Definition of done
The implementation is complete when:
- all documented routes conform to the API contract;
- all Workflow stages execute synchronously in configured order;
- business validation is recorded as Workflow history;
- terminal processing is idempotent;
- concurrent processing cannot duplicate Stage execution;
- Loan state and history remain consistent after failures;
- data persists after application restart;
- business thresholds can change without modifying Stage code;
- administrators can publish a versioned Workflow per Loan type;
- Loans already in progress retain their originally assigned configuration;
- a new Stage can be added without rewriting the Engine or existing Stages;
- all unit and feature tests pass;
- the Docker commands in the challenge work as documented;
- required documentation matches the final implementation.