In the rapidly evolving landscape of 2026, “AI-first” is no longer a buzzword — it is an architectural requirement. For fintech institutions, the ability to automate credit decisions while maintaining strict compliance is the holy grail
In this deep dive, we will build a production-grade Bank Loan Approval Workflow. We won’t just move entities from “Draft” to “Approved.” We will inject a cognitive layer into the state machine using symfony/workflow and symfony/ai-bundle. Our system will automatically score loan applications and dynamically route them: high-scoring applications get instant approval, risky ones get rejected and borderline cases are routed to human underwriters.
The Architecture
We are building a Score-Driven State Machine.
Traditional workflows are linear or user-driven. Ours is agentic.
- Submission: User submits a loan application.
- AI Analysis: A dedicated AI Agent analyzes the applicant’s raw data (income, debt, history) against a “Risk Policy” prompt.
- Scoring: The AI returns a structured score (0–100) and a reasoning summary.
- Dynamic Routing: n Score > 80: Auto-Approve. n Score < 40: Auto-Reject. n 40–80: Transition to manual_review.
The Stack
- PHP 8.4: For utilizing the new Property Hooks and native HTML5 parsing if needed.
- Symfony 7.4: The LTS core.
- symfony/workflow: Managing the state lifecycle.
- symfony/ai-bundle: The integration layer for LLMs (OpenAI, Anthropic, or local models).
Project Setup and Prerequisites
First, ensure you have the Symfony CLI and PHP 8.4 installed. We will create a new skeleton project and install our dependencies.
symfony new bank_approval --webapp --version=7.4
cd bank_approval
Installing Dependencies
We need the workflow component and the AI bundle. Note that since mid-2025, symfony/ai-bundle has been the standard for AI integration.
composer require symfony/workflow symfony/ai-bundle symfony/http-client
We assume you have an OpenAI API key or similar for the AI platform configuration.
Configuration
**AI Configuration (config/packages/ai.yaml)
We will configure a “Risk Agent” specifically designed for financial analysis. We use gpt-4o (or the latest equivalent available in 2026) for its reasoning capabilities.
ai:
platform:
openai:
api_key: '%env(OPENAI_API_KEY)%'
agent:
risk_officer:
model: 'gpt-4o'
prompt:
file: '%kernel.project_dir%/tools/prompt/riskManager.txt'
Workflow Configuration (config/packages/workflow.yaml)
We define a workflow named loan_approval**.
framework:
workflows:
loan_approval:
type: 'state_machine'
audit_trail:
enabled: true
marking_store:
type: 'method'
property: 'status'
supports:
- AppEntityLoanApplication
initial_marking: draft
places:
- draft
- processing_score
- manual_review
- approved
- rejected
transitions:
submit:
from: draft
to: processing_score
auto_approve:
from: processing_score
to: approved
auto_reject:
from: processing_score
to: rejected
refer_to_underwriter:
from: processing_score
to: manual_review
underwriter_approve:
from: manual_review
to: approved
underwriter_reject:
from: manual_review
to: rejected
The Domain Layer
We need an entity that holds the data and the state. We’ll use PHP 8.4 attributes for mapping.
namespace AppEntity;
use AppRepositoryLoanApplicationRepository;
use DoctrineDBALTypesTypes;
use DoctrineORMMapping as ORM;
#[ORMEntity(repositoryClass: LoanApplicationRepository::class)]
class LoanApplication
{
#[ORMId]
#[ORMGeneratedValue]
#[ORMColumn]
private ?int $id = null;
#[ORMColumn(length: 255)]
private ?string $applicantName = null;
#[ORMColumn]
private ?int $annualIncome = null;
#[ORMColumn]
private ?int $requestedAmount = null;
#[ORMColumn]
private ?int $totalMonthlyDebt = null;
// The Workflow Marking
#[ORMColumn(length: 50)]
private string $status="draft";
// AI Scoring Results
#[ORMColumn(type: Types::INTEGER, nullable: true)]
private ?int $riskScore = null;
#[ORMColumn(type: Types::TEXT, nullable: true)]
private ?string $aiReasoning = null;
public function __construct(string $name, int $income, int $amount, int $monthlyDebt)
{
$this->applicantName = $name;
$this->annualIncome = $income;
$this->requestedAmount = $amount;
$this->totalMonthlyDebt = $monthlyDebt;
}
// Getters and Setters...
public function getId(): ?int
{
return $this->id;
}
public function getApplicantName(): ?string
{
return $this->applicantName;
}
public function setApplicantName(string $applicantName): static
{
$this->applicantName = $applicantName;
return $this;
}
public function getAnnualIncome(): ?int
{
return $this->annualIncome;
}
public function setAnnualIncome(int $annualIncome): static
{
$this->annualIncome = $annualIncome;
return $this;
}
public function getRequestedAmount(): ?int
{
return $this->requestedAmount;
}
public function setRequestedAmount(int $requestedAmount): static
{
$this->requestedAmount = $requestedAmount;
return $this;
}
public function getTotalMonthlyDebt(): ?int
{
return $this->totalMonthlyDebt;
}
public function setTotalMonthlyDebt(int $totalMonthlyDebt): static
{
$this->totalMonthlyDebt = $totalMonthlyDebt;
return $this;
}
public function getStatus(): string
{
return $this->status;
}
public function setStatus(string $status): void
{
$this->status = $status;
}
public function setAiResult(int $score, string $reasoning): void
{
$this->riskScore = $score;
$this->aiReasoning = $reasoning;
}
public function getRiskScore(): ?int
{
return $this->riskScore;
}
public function getAiReasoning(): ?string
{
return $this->aiReasoning;
}
// Calculated fields for the AI context
public function getDtiRatio(): float
{
$monthlyIncome = $this->annualIncome / 12;
if ($monthlyIncome === 0) return 100.0;
return ($this->totalMonthlyDebt / $monthlyIncome) * 100;
}
}
The AI Scoring Service
This is the core of our “Intelligent Workflow.” We will create a service that formats the entity data into a prompt, sends it to our configured risk_officer agent and parses the JSON response.
namespace AppService;
use AppEntityLoanApplication;
use SymfonyAIAgentAgentInterface;
use SymfonyAIPlatformMessageMessageBag;
use SymfonyComponentDependencyInjectionAttributeTarget;
use SymfonyAIPlatformMessageUserMessage;
use SymfonyAIPlatformMessageContentText;
readonly class LoanScorer
{
public function __construct(
#[Target('risk_officer')]
private AgentInterface $agent
) {}
/**
* @return array{score: int, reasoning: string}
*/
public function scoreApplication(LoanApplication $loan): array
{
// 1. Construct the context for the AI
$context = sprintf(
"Applicant: %s
Annual Income: $%d
Requested Amount: $%d
Monthly Debt: $%d
Calculated DTI: %.2f%%",
$loan->getApplicantName(),
$loan->getAnnualIncome(),
$loan->getRequestedAmount(),
$loan->getTotalMonthlyDebt(),
$loan->getDtiRatio()
);
// 2. Create the message
$message = new UserMessage(new Text($context));
// 3. Call the AI Agent
// In Symfony 7.4/AI Bundle, we call the agent which handles the platform communication
$response = $this->agent->call(
new MessageBag($message)
);
// 4. Parse the output
// Ideally, we would use Structured Outputs (JSON mode) supported by the bundle
$content = $response->getContent();
return $this->parseJson($content);
}
private function parseJson(string $content): array
{
// The AI might wrap the JSON in a markdown code block. Let's extract it.
if (preg_match('/```jsons*({.*?})s*```/s', $content, $matches)) {
$jsonContent = array_pop($matches);
} else {
// If no markdown block is found, assume the content is already a JSON string.
$jsonContent = $content;
}
$data = json_decode($jsonContent, true);
if (!isset($data['score']) || !is_int($data['score']) || !isset($data['reasoning']) || !is_string($data['reasoning'])) {
throw new RuntimeException('AI returned invalid or malformed JSON format: ' . $content);
}
return $data;
}
}
The Workflow Automator
Now we need the logic that connects the Scorer to the Workflow. This service triggers the transitions based on the score.
namespace AppService;
use AppEntityLoanApplication;
use DoctrineORMEntityManagerInterface;
use SymfonyComponentWorkflowWorkflowInterface;
use PsrLogLoggerInterface;
readonly class LoanAutomationService
{
public function __construct(
private WorkflowInterface $loanApprovalStateMachine,
private LoanScorer $scorer,
private EntityManagerInterface $entityManager,
private LoggerInterface $logger
) {}
public function processApplication(LoanApplication $loan): void
{
// 1. Verify we are in the correct state
if ($loan->getStatus() !== 'processing_score') {
return;
}
$this->logger->info("Starting AI scoring for Loan #{$loan->getId()}");
// 2. Get the AI Score
try {
$result = $this->scorer->scoreApplication($loan);
// Update entity with results
$loan->setAiResult($result['score'], $result['reasoning']);
$this->entityManager->flush();
$score = $result['score'];
$this->logger->info("AI Score generated: {$score}");
// 3. Determine and Apply Transition
$transition = $this->determineTransition($score);
if ($this->loanApprovalStateMachine->can($loan, $transition)) {
$this->loanApprovalStateMachine->apply($loan, $transition);
$this->entityManager->flush();
$this->logger->info("Applied transition: {$transition}");
} else {
$this->logger->error("Transition {$transition} blocked for Loan #{$loan->getId()}");
}
} catch (Exception $e) {
$this->logger->error("AI Scoring failed: " . $e->getMessage());
// Fallback: Default to manual review on error
if ($this->loanApprovalStateMachine->can($loan, 'refer_to_underwriter')) {
$this->loanApprovalStateMachine->apply($loan, 'refer_to_underwriter');
$this->entityManager->flush();
}
}
}
private function determineTransition(int $score): string
{
return match (true) {
$score >= 80 => 'auto_approve',
$score < 40 => 'auto_reject',
default => 'refer_to_underwriter',
};
}
}
Wiring it with Events
To make this seamless, we want the automation to trigger immediately after the user submits the application. We can use a Workflow Event Listener. When the loan enters the processing_score state (via the submit transition), we trigger the automation.
In a high-scale real-world app, you would dispatch a Symfony Messenger message here to handle the AI call asynchronously. For this example, we will do it synchronously to keep the code focused on logic.
namespace AppEventListenerWorkflow;
use AppEntityLoanApplication;
use AppMessageScoreLoanApplication;
use SymfonyComponentEventDispatcherAttributeAsEventListener;
use SymfonyComponentMessengerMessageBusInterface;
use SymfonyComponentWorkflowEventEvent;
readonly class LoanScoringListener
{
public function __construct(
private MessageBusInterface $bus
) {}
/**
* Listen to the 'entered' event for the 'processing_score' place.
* Event name format: workflow.[workflow_name].entered.[place_name]
*/
#[AsEventListener('workflow.loan_approval.entered.processing_score')]
public function onProcessingScore(Event $event): void
{
$subject = $event->getSubject();
if (!$subject instanceof LoanApplication) {
return;
}
// Trigger the AI Automation asynchronously
$this->bus->dispatch(new ScoreLoanApplication($subject->getId()));
}
}
The Controller
Finally, let’s build a controller to simulate the submission.
namespace AppController;
use AppDTOLoanApplicationInput;
use AppEntityLoanApplication;
use DoctrineORMEntityManagerInterface;
use SymfonyBundleFrameworkBundleControllerAbstractController;
use SymfonyComponentHttpFoundationJsonResponse;
use SymfonyComponentHttpKernelAttributeMapRequestPayload;
use SymfonyComponentRoutingAttributeRoute;
use SymfonyComponentWorkflowWorkflowInterface;
#[Route('/api/loan')]
class LoanController extends AbstractController
{
#[Route('/submit', methods: ['POST'])]
public function submit(
#[MapRequestPayload] LoanApplicationInput $data,
WorkflowInterface $loanApprovalStateMachine,
EntityManagerInterface $em
): JsonResponse
{
// 1. Create Entity from validated DTO
$loan = new LoanApplication(
$data->name,
$data->income,
$data->amount,
$data->debt
);
$em->persist($loan);
$em->flush(); // Save as draft first
// 2. Apply 'submit' transition
// This moves state to 'processing_score'
// Which triggers our Listener -> which dispatches a message
if ($loanApprovalStateMachine->can($loan, 'submit')) {
$loanApprovalStateMachine->apply($loan, 'submit');
$em->flush();
}
return $this->json([
'id' => $loan->getId(),
'status' => $loan->getStatus(),
'message' => 'Loan application submitted and is being processed.'
]);
}
}
Verification
- Configure API Key: Ensure OPENAIAPIKEY is set in .env.local.
- Start Server: symfony server:start.
- Send Request: Use curl or Postman.
Request (High Risk):
curl -X POST https://127.0.0.1:8000/api/loan/submit
-H "Content-Type: application/json"
-d '{"name": "Risk Taker", "income": 30000, "amount": 50000, "debt": 2000}'
Expected Response:
{
"id": 1,
"status": "rejected",
"risk_score": 15,
"ai_reasoning": "The applicant has a Debt-to-Income ratio exceeding 80%..."
}
Request (Low Risk):
curl -X POST https://127.0.0.1:8000/api/loan/submit
-H "Content-Type: application/json"
-d '{"name": "Safe Saver", "income": 120000, "amount": 10000, "debt": 500}'
Expected Response:
{
"id": 2,
"status": "approved",
"risk_score": 92,
"ai_reasoning": "The applicant demonstrates excellent financial health with a DTI below 10%..."
}
Conclusion
We have successfully integrated Generative AI into a deterministic business process. This pattern leverages the best of both worlds:
- Symfony Workflow: Provides the reliability, audit trails and strict state management required for banking.
- Symfony AI: Provides the nuanced decision-making capability that previously required human intervention.
This architecture scales. You can introduce new agents for fraud detection, document analysis (using OCR tools in symfony/ai-bundle), or regulatory compliance checks, all orchestrated within the same transparent workflow system.
Source Code: You can find the full implementation and follow the project’s progress on GitHub: [https://github.com/mattleads/AIBankApproval]
Let’s Connect!
If you found this helpful or have questions about the implementation, I’d love to hear from you. Let’s stay in touch and keep the conversation going across these platforms:
- LinkedIn: [https://www.linkedin.com/in/matthew-mochalkin/]
- X (Twitter): [https://x.com/MattLeads]
- Telegram: [https://t.me/MattLeads]
- GitHub: [https://github.com/mattleads]
