How to Build an AI-Driven Loan Approval Workflow With Symfony 7.4 and Symfony AI

Written by mattleads | Published 2026/02/16
Tech Story Tags: ai-risk-scoring-system | symfony-7.4 | symfony-ai | software-architecture | php-8.4-state-machine | enterprise-ai-integration | agentic-workflow-design | automated-loan-underwriting

TLDRThis tutorial demonstrates how to integrate generative AI into a deterministic Symfony Workflow to automate bank loan approvals. Using PHP 8.4, Symfony 7.4, and symfony/ai-bundle, the system scores loan applications with an LLM and dynamically routes them to approval, rejection, or manual review. The result is a scalable, audit-ready, AI-first architecture for fintech automation.via the TL;DR App

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.

  1. Submission: User submits a loan application.
  2. AI Analysis: A dedicated AI Agent analyzes the applicant’s raw data (income, debt, history) against a “Risk Policy” prompt.
  3. Scoring: The AI returns a structured score (0–100) and a reasoning summary.
  4. Dynamic Routing:
    Score > 80:Auto-Approve.
    Score < 40:Auto-Reject.
    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:
                - App\Entity\LoanApplication
            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 App\Entity;

use App\Repository\LoanApplicationRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: LoanApplicationRepository::class)]
class LoanApplication
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    private ?string $applicantName = null;

    #[ORM\Column]
    private ?int $annualIncome = null;

    #[ORM\Column]
    private ?int $requestedAmount = null;

    #[ORM\Column]
    private ?int $totalMonthlyDebt = null;

    // The Workflow Marking
    #[ORM\Column(length: 50)]
    private string $status = 'draft';

    // AI Scoring Results
    #[ORM\Column(type: Types::INTEGER, nullable: true)]
    private ?int $riskScore = null;

    #[ORM\Column(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 App\Service;

use App\Entity\LoanApplication;
use Symfony\AI\Agent\AgentInterface;
use Symfony\AI\Platform\Message\MessageBag;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\AI\Platform\Message\UserMessage;
use Symfony\AI\Platform\Message\Content\Text;

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('/```json\s*(\{.*?\})\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 App\Service;

use App\Entity\LoanApplication;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Workflow\WorkflowInterface;
use Psr\Log\LoggerInterface;

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 App\EventListener\Workflow;

use App\Entity\LoanApplication;
use App\Message\ScoreLoanApplication;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Workflow\Event\Event;

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 App\Controller;

use App\DTO\LoanApplicationInput;
use App\Entity\LoanApplication;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Workflow\WorkflowInterface;

#[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

  1. Configure API Key: Ensure OPENAI_API_KEY is set in .env.local.
  2. Start Server: symfony server:start.
  3. 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:

  1. Symfony Workflow: Provides the reliability, audit trails and strict state management required for banking.
  2. 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:


Written by mattleads | Hi, friends, being AI enthusiast, I'm an MBA, CEO and CPO who loves building products. I share my insights here.)
Published by HackerNoon on 2026/02/16