Skip to content

Financial Risk Assessment Engine

Difficulty: Advanced | Time: 3+ hours | Integration: External APIs, Queues, Events, Validation, Caching

Create a real-time risk assessment system integrating multiple data sources, complex business rules, and asynchronous processing with comprehensive audit trails and fallback strategies.

The Challenge: Complex Risk Assessment

Your financial services application needs to assess loan applications in real-time, combining data from multiple external services (credit bureaus, fraud detection, income verification) with internal business rules and regulatory compliance requirements.

🔍 The Problem: Scattered Risk Logic

Here's the nightmarish risk assessment code that has grown organically:

php
<?php

namespace App\Services;

use App\Models\LoanApplication;
use App\Models\User;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

class LoanAssessmentService
{
    public function assessApplication(LoanApplication $application): array
    {
        $riskScore = 0;
        $riskFactors = [];
        $warnings = [];
        
        // Credit score check (external API)
        try {
            $creditResponse = Http::timeout(10)->get('https://credit-bureau.com/api/score', [
                'ssn' => $application->applicant_ssn,
                'api_key' => config('services.credit_bureau.key')
            ]);
            
            if ($creditResponse->successful()) {
                $creditScore = $creditResponse->json()['score'];
                
                if ($creditScore < 300) {
                    return ['status' => 'rejected', 'reason' => 'Invalid credit score'];
                } elseif ($creditScore < 580) {
                    $riskScore += 40;
                    $riskFactors[] = 'Poor credit score';
                } elseif ($creditScore < 670) {
                    $riskScore += 20;
                    $riskFactors[] = 'Fair credit score';
                } elseif ($creditScore < 740) {
                    $riskScore += 10;
                } else {
                    $riskScore -= 5; // Excellent credit
                }
            } else {
                $warnings[] = 'Credit score unavailable';
                $riskScore += 25; // Penalty for missing data
            }
        } catch (\Exception $e) {
            Log::error('Credit bureau API failed', ['error' => $e->getMessage()]);
            $warnings[] = 'Credit verification failed';
            $riskScore += 30; // Higher penalty for API failure
        }
        
        // Income verification (another external service)
        try {
            $incomeResponse = Http::timeout(15)->post('https://income-verify.com/api/verify', [
                'ssn' => $application->applicant_ssn,
                'employer' => $application->employer_name,
                'stated_income' => $application->annual_income,
                'token' => config('services.income_verify.token')
            ]);
            
            if ($incomeResponse->successful()) {
                $verifiedIncome = $incomeResponse->json()['verified_income'];
                $statedIncome = $application->annual_income;
                
                $incomeDiscrepancy = abs($verifiedIncome - $statedIncome) / $statedIncome;
                
                if ($incomeDiscrepancy > 0.5) {
                    return ['status' => 'rejected', 'reason' => 'Income discrepancy too high'];
                } elseif ($incomeDiscrepancy > 0.2) {
                    $riskScore += 25;
                    $riskFactors[] = 'Significant income discrepancy';
                } elseif ($incomeDiscrepancy > 0.1) {
                    $riskScore += 10;
                    $riskFactors[] = 'Minor income discrepancy';
                }
                
                // Debt-to-income ratio
                $monthlyIncome = $verifiedIncome / 12;
                $totalDebt = $application->monthly_debt_payments + ($application->loan_amount * 0.05); // Estimate monthly payment
                $dtiRatio = $totalDebt / $monthlyIncome;
                
                if ($dtiRatio > 0.6) {
                    return ['status' => 'rejected', 'reason' => 'Debt-to-income ratio too high'];
                } elseif ($dtiRatio > 0.45) {
                    $riskScore += 30;
                    $riskFactors[] = 'High debt-to-income ratio';
                } elseif ($dtiRatio > 0.36) {
                    $riskScore += 15;
                    $riskFactors[] = 'Elevated debt-to-income ratio';
                }
            } else {
                $warnings[] = 'Income verification unavailable';
                $riskScore += 20;
            }
        } catch (\Exception $e) {
            Log::error('Income verification failed', ['error' => $e->getMessage()]);
            $warnings[] = 'Income verification failed';
            $riskScore += 25;
        }
        
        // Fraud detection (yet another external service)
        try {
            $fraudResponse = Http::timeout(5)->post('https://fraud-detection.com/api/check', [
                'applicant_data' => [
                    'ssn' => $application->applicant_ssn,
                    'email' => $application->email,
                    'phone' => $application->phone,
                    'address' => $application->address,
                    'ip_address' => $application->ip_address,
                ]
            ]);
            
            if ($fraudResponse->successful()) {
                $fraudScore = $fraudResponse->json()['risk_score'];
                
                if ($fraudScore > 0.8) {
                    return ['status' => 'rejected', 'reason' => 'High fraud risk detected'];
                } elseif ($fraudScore > 0.6) {
                    $riskScore += 35;
                    $riskFactors[] = 'Elevated fraud risk';
                } elseif ($fraudScore > 0.4) {
                    $riskScore += 15;
                    $riskFactors[] = 'Moderate fraud risk';
                }
            }
        } catch (\Exception $e) {
            // Fraud detection is less critical, just log and continue
            Log::warning('Fraud detection unavailable', ['error' => $e->getMessage()]);
        }
        
        // Internal business rules (hardcoded mess!)
        if ($application->loan_amount > 500000) {
            $riskScore += 20;
            $riskFactors[] = 'High loan amount';
        }
        
        if ($application->loan_term > 30) {
            $riskScore += 10;
            $riskFactors[] = 'Extended loan term';
        }
        
        if ($application->down_payment_percentage < 0.10) {
            $riskScore += 25;
            $riskFactors[] = 'Low down payment';
        }
        
        // Employment history
        if ($application->employment_years < 1) {
            $riskScore += 20;
            $riskFactors[] = 'Limited employment history';
        } elseif ($application->employment_years < 2) {
            $riskScore += 10;
            $riskFactors[] = 'Short employment history';
        }
        
        // Age-based risk (getting controversial!)
        $age = now()->diffInYears($application->date_of_birth);
        if ($age < 25) {
            $riskScore += 15;
            $riskFactors[] = 'Young applicant';
        } elseif ($age > 65) {
            $riskScore += 10;
            $riskFactors[] = 'Older applicant';
        }
        
        // Final decision logic (more hardcoded rules!)
        if ($riskScore >= 100) {
            $status = 'rejected';
        } elseif ($riskScore >= 75) {
            $status = 'manual_review';
        } elseif ($riskScore >= 50) {
            $status = 'conditional_approval';
        } else {
            $status = 'approved';
        }
        
        return [
            'status' => $status,
            'risk_score' => $riskScore,
            'risk_factors' => $riskFactors,
            'warnings' => $warnings,
            'recommendation' => $this->getRecommendation($status, $riskScore),
        ];
    }
    
    private function getRecommendation(string $status, int $riskScore): string
    {
        // More hardcoded business logic...
        return match($status) {
            'approved' => 'Approve at standard rate',
            'conditional_approval' => 'Approve with higher interest rate',
            'manual_review' => 'Requires underwriter review',
            'rejected' => 'Decline application',
            default => 'Unknown recommendation'
        };
    }
}

🎯 Risk Assessment Requirements Analysis

Let's identify the complex business rules:

  1. Credit Score Evaluation: Multiple tiers with different risk penalties
  2. Income Verification: External service integration with discrepancy analysis
  3. Debt-to-Income Ratios: Complex financial calculations
  4. Fraud Detection: External service with risk scoring
  5. Loan Parameters: Amount, term, down payment analysis
  6. Employment History: Stability assessment
  7. Regulatory Compliance: Age-based considerations (with proper compliance)
  8. External Service Failures: Graceful degradation and fallback logic
  9. Audit Trail: Complete decision tracking for regulatory requirements

Step 1: Generate Risk Assessment Specifications

Using the Artisan command to create our risk assessment specifications:

bash
# External service specifications
php artisan make:specification Risk/CreditScoreSpecification --model=LoanApplication
php artisan make:specification Risk/IncomeVerificationSpecification --model=LoanApplication  
php artisan make:specification Risk/FraudDetectionSpecification --model=LoanApplication

# Internal business rule specifications
php artisan make:specification Risk/LoanParametersSpecification --model=LoanApplication
php artisan make:specification Risk/EmploymentHistorySpecification --model=LoanApplication
php artisan make:specification Risk/DebtToIncomeSpecification --model=LoanApplication
php artisan make:specification Risk/ComplianceSpecification --model=LoanApplication

# Service availability and fallback specifications
php artisan make:specification Risk/ServiceAvailabilitySpecification
php artisan make:specification Risk/DataCompletenessSpecification --model=LoanApplication

# Composite assessment specifications
php artisan make:specification Risk/LoanApprovalSpecification --composite
php artisan make:specification Risk/ManualReviewSpecification --composite

Step 2: External Service Specifications

CreditScoreSpecification

php
<?php

namespace App\Specifications\Risk;

use App\Models\LoanApplication;
use App\Services\CreditBureauService;
use DangerWayne\LaravelSpecifications\AbstractSpecification;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;

class CreditScoreSpecification extends AbstractSpecification
{
    private ?int $creditScore = null;
    private ?string $errorMessage = null;
    
    public function __construct(
        private readonly CreditBureauService $creditService,
        private readonly int $minimumScore = 300,
        private readonly int $cacheTtl = 3600
    ) {}
    
    public function isSatisfiedBy(mixed $candidate): bool
    {
        if (!$candidate instanceof LoanApplication) {
            return false;
        }
        
        $creditScore = $this->getCreditScore($candidate);
        
        if ($creditScore === null) {
            // Service unavailable - let other specifications handle this
            return true;
        }
        
        return $creditScore >= $this->minimumScore;
    }
    
    public function toQuery(Builder $query): Builder
    {
        // Credit scores are external data, can't filter at DB level
        return $query;
    }
    
    public function getCreditScore(LoanApplication $application): ?int
    {
        if ($this->creditScore !== null) {
            return $this->creditScore;
        }
        
        $cacheKey = "credit_score_{$application->applicant_ssn}";
        
        $this->creditScore = Cache::remember($cacheKey, $this->cacheTtl, function () use ($application) {
            try {
                return $this->creditService->getCreditScore($application->applicant_ssn);
            } catch (\Exception $e) {
                Log::error('Credit bureau service failed', [
                    'application_id' => $application->id,
                    'error' => $e->getMessage()
                ]);
                
                $this->errorMessage = $e->getMessage();
                return null;
            }
        });
        
        return $this->creditScore;
    }
    
    public function getRiskScore(LoanApplication $application): int
    {
        $creditScore = $this->getCreditScore($application);
        
        if ($creditScore === null) {
            return 30; // Penalty for unavailable data
        }
        
        return match(true) {
            $creditScore < 580 => 40,
            $creditScore < 670 => 20, 
            $creditScore < 740 => 10,
            $creditScore >= 740 => -5,
            default => 25
        };
    }
    
    public function getRiskFactor(LoanApplication $application): ?string
    {
        $creditScore = $this->getCreditScore($application);
        
        if ($creditScore === null) {
            return 'Credit score unavailable';
        }
        
        return match(true) {
            $creditScore < 580 => 'Poor credit score',
            $creditScore < 670 => 'Fair credit score',
            $creditScore >= 740 => null, // No risk factor for excellent credit
            default => null
        };
    }
    
    public function hasServiceError(): bool
    {
        return $this->errorMessage !== null;
    }
    
    public function getErrorMessage(): ?string
    {
        return $this->errorMessage;
    }
}

IncomeVerificationSpecification

php
<?php

namespace App\Specifications\Risk;

use App\Models\LoanApplication;
use App\Services\IncomeVerificationService;
use DangerWayne\LaravelSpecifications\AbstractSpecification;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;

class IncomeVerificationSpecification extends AbstractSpecification
{
    private ?array $verificationResult = null;
    private ?string $errorMessage = null;
    
    public function __construct(
        private readonly IncomeVerificationService $incomeService,
        private readonly float $maxDiscrepancyRatio = 0.5,
        private readonly int $cacheTtl = 1800
    ) {}
    
    public function isSatisfiedBy(mixed $candidate): bool
    {
        if (!$candidate instanceof LoanApplication) {
            return false;
        }
        
        $result = $this->getVerificationResult($candidate);
        
        if ($result === null) {
            // Service unavailable - let fallback logic handle this
            return true;
        }
        
        return $this->getIncomeDiscrepancy($candidate) <= $this->maxDiscrepancyRatio;
    }
    
    public function toQuery(Builder $query): Builder
    {
        // Income verification is external, can't filter at DB level
        return $query;
    }
    
    public function getVerificationResult(LoanApplication $application): ?array
    {
        if ($this->verificationResult !== null) {
            return $this->verificationResult;
        }
        
        $cacheKey = "income_verification_{$application->id}_{$application->updated_at->timestamp}";
        
        $this->verificationResult = Cache::remember($cacheKey, $this->cacheTtl, function () use ($application) {
            try {
                return $this->incomeService->verifyIncome([
                    'ssn' => $application->applicant_ssn,
                    'employer' => $application->employer_name,
                    'stated_income' => $application->annual_income,
                ]);
            } catch (\Exception $e) {
                Log::error('Income verification service failed', [
                    'application_id' => $application->id,
                    'error' => $e->getMessage()
                ]);
                
                $this->errorMessage = $e->getMessage();
                return null;
            }
        });
        
        return $this->verificationResult;
    }
    
    public function getIncomeDiscrepancy(LoanApplication $application): float
    {
        $result = $this->getVerificationResult($application);
        
        if ($result === null) {
            return 0; // No discrepancy if we can't verify
        }
        
        $verifiedIncome = $result['verified_income'];
        $statedIncome = $application->annual_income;
        
        if ($statedIncome == 0) {
            return 1.0; // Maximum discrepancy
        }
        
        return abs($verifiedIncome - $statedIncome) / $statedIncome;
    }
    
    public function getRiskScore(LoanApplication $application): int
    {
        $result = $this->getVerificationResult($application);
        
        if ($result === null) {
            return 25; // Penalty for unavailable verification
        }
        
        $discrepancy = $this->getIncomeDiscrepancy($application);
        
        return match(true) {
            $discrepancy > 0.2 => 25,
            $discrepancy > 0.1 => 10,
            default => 0
        };
    }
    
    public function getRiskFactor(LoanApplication $application): ?string
    {
        $result = $this->getVerificationResult($application);
        
        if ($result === null) {
            return 'Income verification unavailable';
        }
        
        $discrepancy = $this->getIncomeDiscrepancy($application);
        
        return match(true) {
            $discrepancy > 0.2 => 'Significant income discrepancy',
            $discrepancy > 0.1 => 'Minor income discrepancy',
            default => null
        };
    }
    
    public function hasServiceError(): bool
    {
        return $this->errorMessage !== null;
    }
    
    public function getErrorMessage(): ?string
    {
        return $this->errorMessage;
    }
}

FraudDetectionSpecification

php
<?php

namespace App\Specifications\Risk;

use App\Models\LoanApplication;
use App\Services\FraudDetectionService;
use DangerWayne\LaravelSpecifications\AbstractSpecification;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;

class FraudDetectionSpecification extends AbstractSpecification
{
    private ?float $fraudScore = null;
    private ?string $errorMessage = null;
    
    public function __construct(
        private readonly FraudDetectionService $fraudService,
        private readonly float $maxFraudScore = 0.8,
        private readonly int $cacheTtl = 900
    ) {}
    
    public function isSatisfiedBy(mixed $candidate): bool
    {
        if (!$candidate instanceof LoanApplication) {
            return false;
        }
        
        $fraudScore = $this->getFraudScore($candidate);
        
        if ($fraudScore === null) {
            // Fraud detection is less critical - assume no fraud if service unavailable
            return true;
        }
        
        return $fraudScore <= $this->maxFraudScore;
    }
    
    public function toQuery(Builder $query): Builder
    {
        // Fraud scores are external, can't filter at DB level
        return $query;
    }
    
    public function getFraudScore(LoanApplication $application): ?float
    {
        if ($this->fraudScore !== null) {
            return $this->fraudScore;
        }
        
        $cacheKey = "fraud_score_{$application->id}";
        
        $this->fraudScore = Cache::remember($cacheKey, $this->cacheTtl, function () use ($application) {
            try {
                return $this->fraudService->checkApplication([
                    'ssn' => $application->applicant_ssn,
                    'email' => $application->email,
                    'phone' => $application->phone,
                    'address' => $application->address,
                    'ip_address' => $application->ip_address,
                ]);
            } catch (\Exception $e) {
                Log::warning('Fraud detection service unavailable', [
                    'application_id' => $application->id,
                    'error' => $e->getMessage()
                ]);
                
                $this->errorMessage = $e->getMessage();
                return null;
            }
        });
        
        return $this->fraudScore;
    }
    
    public function getRiskScore(LoanApplication $application): int
    {
        $fraudScore = $this->getFraudScore($application);
        
        if ($fraudScore === null) {
            return 0; // No penalty for unavailable fraud detection
        }
        
        return match(true) {
            $fraudScore > 0.6 => 35,
            $fraudScore > 0.4 => 15,
            default => 0
        };
    }
    
    public function getRiskFactor(LoanApplication $application): ?string
    {
        $fraudScore = $this->getFraudScore($application);
        
        if ($fraudScore === null) {
            return null; // Don't report as risk factor if service unavailable
        }
        
        return match(true) {
            $fraudScore > 0.6 => 'Elevated fraud risk',
            $fraudScore > 0.4 => 'Moderate fraud risk',
            default => null
        };
    }
    
    public function hasServiceError(): bool
    {
        return $this->errorMessage !== null;
    }
}

Step 3: Internal Business Rule Specifications

DebtToIncomeSpecification

php
<?php

namespace App\Specifications\Risk;

use App\Models\LoanApplication;
use DangerWayne\LaravelSpecifications\AbstractSpecification;
use Illuminate\Database\Eloquent\Builder;

class DebtToIncomeSpecification extends AbstractSpecification
{
    public function __construct(
        private readonly float $maxDtiRatio = 0.6,
        private readonly float $estimatedInterestRate = 0.05
    ) {}
    
    public function isSatisfiedBy(mixed $candidate): bool
    {
        if (!$candidate instanceof LoanApplication) {
            return false;
        }
        
        return $this->calculateDtiRatio($candidate) <= $this->maxDtiRatio;
    }
    
    public function toQuery(Builder $query): Builder
    {
        // This requires calculation, so we'll filter at collection level
        // Or we could add a computed column to the database
        return $query;
    }
    
    public function calculateDtiRatio(LoanApplication $application): float
    {
        $monthlyIncome = $application->annual_income / 12;
        
        if ($monthlyIncome <= 0) {
            return 1.0; // Maximum ratio if no income
        }
        
        // Estimate new loan payment
        $loanAmount = $application->loan_amount;
        $estimatedPayment = $loanAmount * ($this->estimatedInterestRate / 12) * 
            pow(1 + ($this->estimatedInterestRate / 12), $application->loan_term * 12) /
            (pow(1 + ($this->estimatedInterestRate / 12), $application->loan_term * 12) - 1);
        
        $totalDebt = $application->monthly_debt_payments + $estimatedPayment;
        
        return $totalDebt / $monthlyIncome;
    }
    
    public function getRiskScore(LoanApplication $application): int
    {
        $dtiRatio = $this->calculateDtiRatio($application);
        
        return match(true) {
            $dtiRatio > 0.45 => 30,
            $dtiRatio > 0.36 => 15,
            default => 0
        };
    }
    
    public function getRiskFactor(LoanApplication $application): ?string
    {
        $dtiRatio = $this->calculateDtiRatio($application);
        
        return match(true) {
            $dtiRatio > 0.45 => 'High debt-to-income ratio',
            $dtiRatio > 0.36 => 'Elevated debt-to-income ratio',
            default => null
        };
    }
}

LoanParametersSpecification

php
<?php

namespace App\Specifications\Risk;

use App\Models\LoanApplication;
use DangerWayne\LaravelSpecifications\AbstractSpecification;
use Illuminate\Database\Eloquent\Builder;

class LoanParametersSpecification extends AbstractSpecification
{
    public function __construct(
        private readonly int $maxLoanAmount = 1000000,
        private readonly int $maxLoanTerm = 30,
        private readonly float $minDownPaymentPercentage = 0.05
    ) {}
    
    public function isSatisfiedBy(mixed $candidate): bool
    {
        if (!$candidate instanceof LoanApplication) {
            return false;
        }
        
        return $this->isValidLoanAmount($candidate) &&
               $this->isValidLoanTerm($candidate) &&
               $this->isValidDownPayment($candidate);
    }
    
    public function toQuery(Builder $query): Builder
    {
        return $query->where('loan_amount', '<=', $this->maxLoanAmount)
                     ->where('loan_term', '<=', $this->maxLoanTerm)
                     ->where('down_payment_percentage', '>=', $this->minDownPaymentPercentage);
    }
    
    private function isValidLoanAmount(LoanApplication $application): bool
    {
        return $application->loan_amount <= $this->maxLoanAmount;
    }
    
    private function isValidLoanTerm(LoanApplication $application): bool
    {
        return $application->loan_term <= $this->maxLoanTerm;
    }
    
    private function isValidDownPayment(LoanApplication $application): bool
    {
        return $application->down_payment_percentage >= $this->minDownPaymentPercentage;
    }
    
    public function getRiskScore(LoanApplication $application): int
    {
        $riskScore = 0;
        
        if ($application->loan_amount > 500000) {
            $riskScore += 20;
        }
        
        if ($application->loan_term > 25) {
            $riskScore += 10;
        }
        
        if ($application->down_payment_percentage < 0.10) {
            $riskScore += 25;
        }
        
        return $riskScore;
    }
    
    public function getRiskFactors(LoanApplication $application): array
    {
        $factors = [];
        
        if ($application->loan_amount > 500000) {
            $factors[] = 'High loan amount';
        }
        
        if ($application->loan_term > 25) {
            $factors[] = 'Extended loan term';
        }
        
        if ($application->down_payment_percentage < 0.10) {
            $factors[] = 'Low down payment';
        }
        
        return $factors;
    }
}

Step 4: Risk Assessment Service

Create a comprehensive risk assessment service using specifications:

php
<?php

namespace App\Services;

use App\Models\LoanApplication;
use App\Models\RiskAssessment;
use App\Specifications\Risk\CreditScoreSpecification;
use App\Specifications\Risk\IncomeVerificationSpecification;
use App\Specifications\Risk\FraudDetectionSpecification;
use App\Specifications\Risk\DebtToIncomeSpecification;
use App\Specifications\Risk\LoanParametersSpecification;
use App\Specifications\Risk\EmploymentHistorySpecification;
use App\Specifications\Risk\ComplianceSpecification;
use App\Events\RiskAssessmentCompleted;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

class RiskAssessmentService
{
    public function __construct(
        private readonly CreditScoreSpecification $creditSpec,
        private readonly IncomeVerificationSpecification $incomeSpec,
        private readonly FraudDetectionSpecification $fraudSpec,
        private readonly DebtToIncomeSpecification $dtiSpec,
        private readonly LoanParametersSpecification $loanSpec,
        private readonly EmploymentHistorySpecification $employmentSpec,
        private readonly ComplianceSpecification $complianceSpec
    ) {}
    
    public function assessApplication(LoanApplication $application): RiskAssessment
    {
        return DB::transaction(function () use ($application) {
            $assessment = new RiskAssessment([
                'loan_application_id' => $application->id,
                'started_at' => now(),
                'status' => 'in_progress'
            ]);
            
            $assessment->save();
            
            try {
                $results = $this->performAssessment($application);
                
                $assessment->update([
                    'completed_at' => now(),
                    'status' => 'completed',
                    'risk_score' => $results['risk_score'],
                    'decision' => $results['decision'],
                    'risk_factors' => $results['risk_factors'],
                    'warnings' => $results['warnings'],
                    'recommendation' => $results['recommendation'],
                    'external_data' => $results['external_data'],
                ]);
                
                event(new RiskAssessmentCompleted($assessment));
                
            } catch (\Exception $e) {
                $assessment->update([
                    'completed_at' => now(),
                    'status' => 'failed',
                    'error_message' => $e->getMessage(),
                ]);
                
                Log::error('Risk assessment failed', [
                    'application_id' => $application->id,
                    'error' => $e->getMessage(),
                    'trace' => $e->getTraceAsString()
                ]);
                
                throw $e;
            }
            
            return $assessment;
        });
    }
    
    private function performAssessment(LoanApplication $application): array
    {
        $riskScore = 0;
        $riskFactors = [];
        $warnings = [];
        $externalData = [];
        
        // Run all specifications and collect results
        $specifications = [
            'credit' => $this->creditSpec,
            'income' => $this->incomeSpec,
            'fraud' => $this->fraudSpec,
            'dti' => $this->dtiSpec,
            'loan_params' => $this->loanSpec,
            'employment' => $this->employmentSpec,
            'compliance' => $this->complianceSpec,
        ];
        
        foreach ($specifications as $name => $spec) {
            $specResult = $this->evaluateSpecification($spec, $application, $name);
            
            $riskScore += $specResult['risk_score'];
            $riskFactors = array_merge($riskFactors, $specResult['risk_factors']);
            $warnings = array_merge($warnings, $specResult['warnings']);
            
            if (!empty($specResult['external_data'])) {
                $externalData[$name] = $specResult['external_data'];
            }
            
            // Hard rejections
            if ($specResult['hard_rejection']) {
                return [
                    'decision' => 'rejected',
                    'risk_score' => 100,
                    'risk_factors' => [$specResult['rejection_reason']],
                    'warnings' => $warnings,
                    'recommendation' => 'Application does not meet minimum requirements',
                    'external_data' => $externalData,
                ];
            }
        }
        
        // Make final decision based on risk score
        $decision = $this->determineDecision($riskScore);
        $recommendation = $this->getRecommendation($decision, $riskScore);
        
        return [
            'risk_score' => $riskScore,
            'decision' => $decision,
            'risk_factors' => array_unique($riskFactors),
            'warnings' => array_unique($warnings),
            'recommendation' => $recommendation,
            'external_data' => $externalData,
        ];
    }
    
    private function evaluateSpecification($spec, LoanApplication $application, string $name): array
    {
        $result = [
            'risk_score' => 0,
            'risk_factors' => [],
            'warnings' => [],
            'external_data' => [],
            'hard_rejection' => false,
            'rejection_reason' => null,
        ];
        
        try {
            // Check if specification passes
            if (!$spec->isSatisfiedBy($application)) {
                // Handle hard rejections
                if (method_exists($spec, 'isHardRejection') && $spec->isHardRejection($application)) {
                    $result['hard_rejection'] = true;
                    $result['rejection_reason'] = $spec->getRejectionReason($application);
                    return $result;
                }
            }
            
            // Get risk score if specification supports it
            if (method_exists($spec, 'getRiskScore')) {
                $result['risk_score'] = $spec->getRiskScore($application);
            }
            
            // Get risk factors if specification supports it
            if (method_exists($spec, 'getRiskFactor')) {
                $factor = $spec->getRiskFactor($application);
                if ($factor) {
                    $result['risk_factors'][] = $factor;
                }
            } elseif (method_exists($spec, 'getRiskFactors')) {
                $result['risk_factors'] = $spec->getRiskFactors($application);
            }
            
            // Check for service errors
            if (method_exists($spec, 'hasServiceError') && $spec->hasServiceError()) {
                $result['warnings'][] = $spec->getErrorMessage();
            }
            
            // Collect external data for audit trail
            if (method_exists($spec, 'getExternalData')) {
                $result['external_data'] = $spec->getExternalData($application);
            }
            
        } catch (\Exception $e) {
            Log::error("Specification evaluation failed: {$name}", [
                'application_id' => $application->id,
                'error' => $e->getMessage()
            ]);
            
            $result['warnings'][] = "Failed to evaluate {$name} criteria";
            $result['risk_score'] = 20; // Penalty for evaluation failure
        }
        
        return $result;
    }
    
    private function determineDecision(int $riskScore): string
    {
        return match(true) {
            $riskScore >= 100 => 'rejected',
            $riskScore >= 75 => 'manual_review',
            $riskScore >= 50 => 'conditional_approval',
            default => 'approved'
        };
    }
    
    private function getRecommendation(string $decision, int $riskScore): string
    {
        return match($decision) {
            'approved' => 'Approve at standard rate',
            'conditional_approval' => 'Approve with adjusted terms or higher interest rate',
            'manual_review' => 'Requires underwriter review and additional documentation',
            'rejected' => 'Decline application - risk criteria not met',
            default => 'Unable to determine recommendation'
        };
    }
}

Step 5: Asynchronous Processing with Jobs

Create background jobs for handling risk assessments:

php
<?php

namespace App\Jobs;

use App\Models\LoanApplication;
use App\Services\RiskAssessmentService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;

class ProcessRiskAssessmentJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
    
    public int $timeout = 300; // 5 minutes
    public int $tries = 3;
    public int $backoff = 60; // Wait 60 seconds between retries
    
    public function __construct(
        private readonly LoanApplication $application
    ) {}
    
    public function handle(RiskAssessmentService $assessmentService): void
    {
        Log::info('Starting risk assessment', [
            'application_id' => $this->application->id,
            'job_id' => $this->job->uuid()
        ]);
        
        try {
            $assessment = $assessmentService->assessApplication($this->application);
            
            Log::info('Risk assessment completed', [
                'application_id' => $this->application->id,
                'assessment_id' => $assessment->id,
                'decision' => $assessment->decision,
                'risk_score' => $assessment->risk_score
            ]);
            
        } catch (\Exception $e) {
            Log::error('Risk assessment job failed', [
                'application_id' => $this->application->id,
                'error' => $e->getMessage(),
                'attempt' => $this->attempts()
            ]);
            
            // Re-throw to trigger retry mechanism
            throw $e;
        }
    }
    
    public function failed(\Throwable $exception): void
    {
        Log::error('Risk assessment job permanently failed', [
            'application_id' => $this->application->id,
            'error' => $exception->getMessage(),
            'attempts' => $this->attempts()
        ]);
        
        // Update application status to indicate assessment failure
        $this->application->update(['status' => 'assessment_failed']);
        
        // Could also send notification to admin team
    }
}

Step 6: Event-Driven Architecture

Create events and listeners for risk assessment workflow:

php
<?php

namespace App\Events;

use App\Models\RiskAssessment;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class RiskAssessmentCompleted implements ShouldBroadcast
{
    use Dispatchable, InteractsWithSockets, SerializesModels;
    
    public function __construct(
        public readonly RiskAssessment $assessment
    ) {}
    
    public function broadcastOn(): array
    {
        return [
            new PresenceChannel('loan-application.' . $this->assessment->loan_application_id),
            new Channel('risk-assessments'),
        ];
    }
    
    public function broadcastWith(): array
    {
        return [
            'assessment_id' => $this->assessment->id,
            'application_id' => $this->assessment->loan_application_id,
            'decision' => $this->assessment->decision,
            'risk_score' => $this->assessment->risk_score,
            'completed_at' => $this->assessment->completed_at,
        ];
    }
}
php
<?php

namespace App\Listeners;

use App\Events\RiskAssessmentCompleted;
use App\Notifications\RiskAssessmentResultNotification;
use App\Jobs\UpdateCreditModelJob;
use App\Models\User;

class ProcessRiskAssessmentResult
{
    public function handle(RiskAssessmentCompleted $event): void
    {
        $assessment = $event->assessment;
        $application = $assessment->loanApplication;
        
        // Update application status
        $application->update([
            'status' => match($assessment->decision) {
                'approved' => 'approved',
                'conditional_approval' => 'conditionally_approved',
                'manual_review' => 'pending_review',
                'rejected' => 'rejected',
                default => 'unknown'
            }
        ]);
        
        // Notify the applicant
        if ($application->user) {
            $application->user->notify(new RiskAssessmentResultNotification($assessment));
        }
        
        // Notify underwriters for manual review cases
        if ($assessment->decision === 'manual_review') {
            $underwriters = User::role('underwriter')->get();
            foreach ($underwriters as $underwriter) {
                $underwriter->notify(new ManualReviewRequiredNotification($assessment));
            }
        }
        
        // Update machine learning models with new data
        UpdateCreditModelJob::dispatch($assessment);
    }
}

Step 7: Comprehensive Testing

Integration Testing

php
<?php

namespace Tests\Feature;

use App\Models\LoanApplication;
use App\Models\User;
use App\Services\RiskAssessmentService;
use App\Services\CreditBureauService;
use App\Services\IncomeVerificationService;
use App\Services\FraudDetectionService;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;

class RiskAssessmentIntegrationTest extends TestCase
{
    use RefreshDatabase;
    
    protected function setUp(): void
    {
        parent::setUp();
        
        // Mock external services
        $this->mockCreditBureauService();
        $this->mockIncomeVerificationService();
        $this->mockFraudDetectionService();
    }
    
    /** @test */
    public function complete_risk_assessment_with_all_services_available()
    {
        // Arrange
        $application = LoanApplication::factory()->create([
            'loan_amount' => 250000,
            'loan_term' => 30,
            'annual_income' => 80000,
            'monthly_debt_payments' => 1500,
            'down_payment_percentage' => 0.20,
            'employment_years' => 5,
        ]);
        
        // Act
        $service = app(RiskAssessmentService::class);
        $assessment = $service->assessApplication($application);
        
        // Assert
        $this->assertEquals('completed', $assessment->status);
        $this->assertNotNull($assessment->risk_score);
        $this->assertContains($assessment->decision, ['approved', 'conditional_approval', 'manual_review', 'rejected']);
        $this->assertNotNull($assessment->recommendation);
        
        // Check that external data was collected
        $this->assertArrayHasKey('credit', $assessment->external_data);
        $this->assertArrayHasKey('income', $assessment->external_data);
        $this->assertArrayHasKey('fraud', $assessment->external_data);
    }
    
    /** @test */
    public function handles_external_service_failures_gracefully()
    {
        // Arrange - Mock service failures
        Http::fake([
            'credit-bureau.com/api/*' => Http::response(null, 500),
            'income-verify.com/api/*' => Http::response(null, 503),
            'fraud-detection.com/api/*' => Http::response(null, 404),
        ]);
        
        $application = LoanApplication::factory()->create();
        
        // Act
        $service = app(RiskAssessmentService::class);
        $assessment = $service->assessApplication($application);
        
        // Assert
        $this->assertEquals('completed', $assessment->status);
        $this->assertNotEmpty($assessment->warnings);
        $this->assertGreaterThan(0, $assessment->risk_score); // Should have penalties for missing data
        
        // Should still make a decision even with service failures
        $this->assertNotNull($assessment->decision);
    }
    
    /** @test */
    public function rejects_applications_with_hard_rejection_criteria()
    {
        // Arrange - Application with very poor credit
        Http::fake([
            'credit-bureau.com/api/*' => Http::response(['score' => 250], 200),
        ]);
        
        $application = LoanApplication::factory()->create();
        
        // Act
        $service = app(RiskAssessmentService::class);
        $assessment = $service->assessApplication($application);
        
        // Assert
        $this->assertEquals('rejected', $assessment->decision);
        $this->assertEquals(100, $assessment->risk_score);
        $this->assertContains('Invalid credit score', $assessment->risk_factors);
    }
    
    /** @test */
    public function triggers_manual_review_for_borderline_cases()
    {
        // Arrange - Borderline application
        Http::fake([
            'credit-bureau.com/api/*' => Http::response(['score' => 650], 200),
            'income-verify.com/api/*' => Http::response(['verified_income' => 70000], 200),
            'fraud-detection.com/api/*' => Http::response(['risk_score' => 0.5], 200),
        ]);
        
        $application = LoanApplication::factory()->create([
            'annual_income' => 75000, // Slight income discrepancy
            'monthly_debt_payments' => 2500, // High debt
            'down_payment_percentage' => 0.05, // Low down payment
        ]);
        
        // Act
        $service = app(RiskAssessmentService::class);
        $assessment = $service->assessApplication($application);
        
        // Assert
        $this->assertEquals('manual_review', $assessment->decision);
        $this->assertGreaterThanOrEqual(75, $assessment->risk_score);
        $this->assertLessThan(100, $assessment->risk_score);
        $this->assertStringContains('underwriter review', $assessment->recommendation);
    }
    
    private function mockCreditBureauService(): void
    {
        Http::fake([
            'credit-bureau.com/api/*' => Http::response(['score' => 720], 200)
        ]);
    }
    
    private function mockIncomeVerificationService(): void
    {
        Http::fake([
            'income-verify.com/api/*' => Http::response(['verified_income' => 80000], 200)
        ]);
    }
    
    private function mockFraudDetectionService(): void
    {
        Http::fake([
            'fraud-detection.com/api/*' => Http::response(['risk_score' => 0.2], 200)
        ]);
    }
}

Specification Unit Tests

php
<?php

namespace Tests\Unit\Specifications;

use App\Models\LoanApplication;
use App\Specifications\Risk\DebtToIncomeSpecification;
use App\Specifications\Risk\LoanParametersSpecification;
use Tests\TestCase;

class RiskSpecificationTest extends TestCase
{
    /** @test */
    public function debt_to_income_specification_calculates_ratio_correctly()
    {
        // Arrange
        $spec = new DebtToIncomeSpecification(0.6);
        
        $application = LoanApplication::factory()->make([
            'annual_income' => 60000,
            'monthly_debt_payments' => 1000,
            'loan_amount' => 200000,
            'loan_term' => 30,
        ]);
        
        // Act
        $dtiRatio = $spec->calculateDtiRatio($application);
        
        // Assert
        $this->assertGreaterThan(0, $dtiRatio);
        $this->assertLessThan(1, $dtiRatio);
        
        // With $5000 monthly income and ~$1800 estimated loan payment + $1000 existing debt
        // DTI should be around 0.56 (2800/5000)
        $this->assertGreaterThan(0.5, $dtiRatio);
        $this->assertLessThan(0.6, $dtiRatio);
    }
    
    /** @test */
    public function loan_parameters_specification_validates_loan_terms()
    {
        // Arrange
        $spec = new LoanParametersSpecification(1000000, 30, 0.05);
        
        $validApplication = LoanApplication::factory()->make([
            'loan_amount' => 500000,
            'loan_term' => 25,
            'down_payment_percentage' => 0.20,
        ]);
        
        $invalidApplication = LoanApplication::factory()->make([
            'loan_amount' => 1200000, // Too high
            'loan_term' => 35, // Too long
            'down_payment_percentage' => 0.02, // Too low
        ]);
        
        // Act & Assert
        $this->assertTrue($spec->isSatisfiedBy($validApplication));
        $this->assertFalse($spec->isSatisfiedBy($invalidApplication));
        
        $riskFactors = $spec->getRiskFactors($invalidApplication);
        $this->assertNotEmpty($riskFactors);
        $this->assertContains('High loan amount', $riskFactors);
        $this->assertContains('Extended loan term', $riskFactors);
        $this->assertContains('Low down payment', $riskFactors);
    }
}

Key Learnings

This advanced example demonstrates:

External Service Integration: Graceful handling of API failures and timeouts
Complex Business Logic: Multi-layered risk assessment with fallback strategies
Asynchronous Processing: Background jobs for time-intensive assessments
Event-Driven Architecture: Real-time updates and workflow automation
Comprehensive Caching: Performance optimization for external service calls
Audit Trail: Complete decision tracking for regulatory compliance
Error Handling: Robust failure recovery and partial data handling

Next Steps

With your financial risk assessment system:

  • Add machine learning models for dynamic risk scoring
  • Implement A/B testing for different risk thresholds
  • Create regulatory reporting with audit trail data
  • Add real-time monitoring for service availability and performance

Master the final challenge: Complete the series with Distributed Workflow Engine.

View Workflow Engine Example →

Released under the MIT License.