DEV Community

Cover image for Building Intelligent Bank Approval Workflows with Symfony 7.4 and Symfony AI
Matt Mochalkin
Matt Mochalkin

Posted on

Building Intelligent Bank Approval Workflows with Symfony 7.4 and Symfony AI

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

a

i:
    platform:
        openai:
            api_key: '%env(OPENAI_API_KEY)%'

    agent:
        risk_officer:
            model: 'gpt-4o'
            prompt:
                file: '%kernel.project_dir%/tools/prompt/riskManager.txt'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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',
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

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()));
    }
}
Enter fullscreen mode Exit fullscreen mode

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.'
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

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}'
Enter fullscreen mode Exit fullscreen mode

Expected Response:

{
  "id": 1,
  "status": "rejected",
  "risk_score": 15,
  "ai_reasoning": "The applicant has a Debt-to-Income ratio exceeding 80%..."
}
Enter fullscreen mode Exit fullscreen mode

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}'
Enter fullscreen mode Exit fullscreen mode

Expected Response:

{
  "id": 2,
  "status": "approved",
  "risk_score": 92,
  "ai_reasoning": "The applicant demonstrates excellent financial health with a DTI below 10%..."
}
Enter fullscreen mode Exit fullscreen mode

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:

Top comments (0)