Introduction
When we inherited a Laravel API powering a fintech application, it was a ticking time bomb. Critical bugs surfaced weekly, downtime averaged 4 hours per month, API response times were unpredictable, and the codebase was so convoluted that even simple changes took days. The technical debt was crushing development velocity.
This is the story of how we systematically refactored the Laravel 12 API, improving reliability from 94.2% to 99.8% uptime, reducing bug reports by 87%, cutting response times by 65%, and transforming an unmaintainable codebase into a model of clean architecture—all while maintaining zero downtime during the transition.
The Problem: Technical Debt Accumulation
Initial State Assessment
Code Quality Issues:
- 12,000+ lines in single controllers
- Zero test coverage
- Inconsistent error handling
- No API versioning
- Duplicate business logic everywhere
- Mixed responsibilities across layers
Reliability Metrics:
| Metric | Before Refactoring |
|---|---|
| Uptime | 94.2% |
| Mean Time to Recovery | 2.3 hours |
| Bug Reports (monthly) | 47 |
| Failed API Calls | 3.2% |
| Average Response Time | 850ms |
| Code Coverage | 0% |
| Cyclomatic Complexity | 42 (critical) |
Root Causes Identified
- Fat Controllers: Business logic, validation, and data access mixed together
- No Abstraction: Direct Eloquent usage everywhere
- Inconsistent Responses: Each endpoint had different response formats
- Poor Error Handling: Generic exception responses with no context
- No Testing: Changes broke functionality frequently
- Tight Coupling: Impossible to change one component without breaking others
Phase 1: Establishing Clean Architecture (Week 1-2)
Problem: Monolithic Controllers
// ❌ Before: 800-line controller with everything
class PaymentController extends Controller
{
public function processPayment(Request $request)
{
// Validation inline
if (!$request->amount || $request->amount <= 0) {
return response()->json(['error' => 'Invalid amount'], 400);
}
// Business logic in controller
$user = User::find($request->user_id);
if ($user->balance < $request->amount) {
return response()->json(['error' => 'Insufficient funds'], 400);
}
// Direct database manipulation
$user->balance -= $request->amount;
$user->save();
// External API call blocking request
$stripe = new \Stripe\StripeClient(env('STRIPE_SECRET'));
$payment = $stripe->charges->create([
'amount' => $request->amount * 100,
'currency' => 'usd',
]);
// Email sending blocking request
Mail::to($user)->send(new PaymentConfirmation($payment));
// Inconsistent response format
return response()->json([
'success' => true,
'payment_id' => $payment->id,
]);
}
}
Solution: Layered Architecture
// ✅ After: Clean separation of concerns
// 1. Form Request for validation
class ProcessPaymentRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('process-payment');
}
public function rules(): array
{
return [
'amount' => 'required|numeric|min:0.01|max:10000',
'currency' => 'required|in:usd,eur,gbp',
'payment_method' => 'required|string',
];
}
}
// 2. Slim controller delegating to service
class PaymentController extends Controller
{
public function __construct(
private PaymentService $paymentService
) {}
public function processPayment(ProcessPaymentRequest $request)
{
try {
$payment = $this->paymentService->processPayment(
$request->user(),
$request->validated()
);
return new PaymentResource($payment);
} catch (InsufficientFundsException $e) {
return response()->json([
'message' => 'Insufficient funds',
'error_code' => 'INSUFFICIENT_FUNDS',
], 402);
} catch (PaymentGatewayException $e) {
return response()->json([
'message' => 'Payment processing failed',
'error_code' => 'GATEWAY_ERROR',
], 502);
}
}
}
// 3. Service layer for business logic
class PaymentService
{
public function __construct(
private UserRepository $userRepository,
private PaymentRepository $paymentRepository,
private PaymentGateway $gateway
) {}
public function processPayment(User $user, array $data): Payment
{
return DB::transaction(function () use ($user, $data) {
// Business logic validation
if ($user->balance < $data['amount']) {
throw new InsufficientFundsException();
}
// Update user balance
$this->userRepository->decrementBalance($user, $data['amount']);
// Create payment record
$payment = $this->paymentRepository->create([
'user_id' => $user->id,
'amount' => $data['amount'],
'currency' => $data['currency'],
'status' => 'pending',
]);
// Process payment asynchronously
dispatch(new ProcessPaymentJob($payment, $data['payment_method']));
return $payment;
});
}
}
// 4. Repository for data access
class PaymentRepository
{
public function create(array $data): Payment
{
return Payment::create($data);
}
public function findByReference(string $reference): ?Payment
{
return Payment::where('reference', $reference)->first();
}
public function updateStatus(Payment $payment, string $status): bool
{
return $payment->update(['status' => $status]);
}
}
Result: Controller line count reduced from 800 to 45 lines, cyclomatic complexity from 42 to 3.
Phase 2: Standardized API Responses (Week 2)
Problem: Inconsistent Response Formats
Every endpoint returned different formats:
// Endpoint 1
{"success": true, "data": {...}}
// Endpoint 2
{"status": "ok", "result": {...}}
// Endpoint 3
{...} // Raw data
// Error formats varied too
{"error": "Something went wrong"}
{"message": "Failed", "code": 500}
Solution: Standardized Response Structure
// Base API response trait
namespace App\Http\Traits;
trait ApiResponse
{
protected function successResponse($data, string $message = null, int $code = 200)
{
return response()->json([
'success' => true,
'message' => $message,
'data' => $data,
'timestamp' => now()->toIso8601String(),
], $code);
}
protected function errorResponse(string $message, int $code = 400, array $errors = [])
{
return response()->json([
'success' => false,
'message' => $message,
'errors' => $errors,
'timestamp' => now()->toIso8601String(),
], $code);
}
protected function paginatedResponse($paginator)
{
return response()->json([
'success' => true,
'data' => $paginator->items(),
'meta' => [
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
],
'links' => [
'first' => $paginator->url(1),
'last' => $paginator->url($paginator->lastPage()),
'prev' => $paginator->previousPageUrl(),
'next' => $paginator->nextPageUrl(),
],
'timestamp' => now()->toIso8601String(),
]);
}
}
// Usage in controllers
class UserController extends Controller
{
use ApiResponse;
public function show(User $user)
{
return $this->successResponse(
new UserResource($user),
'User retrieved successfully'
);
}
public function index()
{
$users = User::paginate(15);
return $this->paginatedResponse($users);
}
}
Result: 100% consistent API responses, easier client integration.
Phase 3: Comprehensive Error Handling (Week 3)
Custom Exception Handler
namespace App\Exceptions;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Throwable;
class Handler extends ExceptionHandler
{
protected $dontReport = [
ValidationException::class,
AuthenticationException::class,
];
public function render($request, Throwable $e)
{
if ($request->is('api/*')) {
return $this->handleApiException($request, $e);
}
return parent::render($request, $e);
}
protected function handleApiException($request, Throwable $e)
{
$statusCode = $this->getStatusCode($e);
$message = $this->getMessage($e);
$errorCode = $this->getErrorCode($e);
// Log errors for monitoring
if ($statusCode >= 500) {
Log::error('API Error', [
'exception' => get_class($e),
'message' => $e->getMessage(),
'file' => $e->getFile(),
'line' => $e->getLine(),
'trace' => $e->getTraceAsString(),
'request' => [
'url' => $request->fullUrl(),
'method' => $request->method(),
'ip' => $request->ip(),
],
]);
}
return response()->json([
'success' => false,
'message' => $message,
'error_code' => $errorCode,
'timestamp' => now()->toIso8601String(),
], $statusCode);
}
protected function getStatusCode(Throwable $e): int
{
return match(true) {
$e instanceof ValidationException => 422,
$e instanceof AuthenticationException => 401,
$e instanceof AuthorizationException => 403,
$e instanceof ModelNotFoundException => 404,
$e instanceof ThrottleRequestsException => 429,
$e instanceof HttpException => $e->getStatusCode(),
default => 500,
};
}
}
Custom Business Exceptions
namespace App\Exceptions;
class InsufficientFundsException extends BusinessException
{
protected $message = 'Insufficient funds to complete transaction';
protected $code = 402;
protected $errorCode = 'INSUFFICIENT_FUNDS';
}
class PaymentGatewayException extends BusinessException
{
protected $message = 'Payment gateway error';
protected $code = 502;
protected $errorCode = 'GATEWAY_ERROR';
}
class ResourceNotFoundException extends BusinessException
{
protected $message = 'Resource not found';
protected $code = 404;
protected $errorCode = 'NOT_FOUND';
}
// Base business exception
abstract class BusinessException extends Exception
{
protected $errorCode;
public function getErrorCode(): string
{
return $this->errorCode;
}
}
Result: Error rate reduced by 73%, debugging time cut by 60%.
Phase 4: API Versioning Strategy (Week 3)
// Route versioning structure
Route::prefix('api/v1')->group(function () {
Route::apiResource('users', V1\UserController::class);
Route::apiResource('payments', V1\PaymentController::class);
});
Route::prefix('api/v2')->group(function () {
Route::apiResource('users', V2\UserController::class);
Route::apiResource('payments', V2\PaymentController::class);
});
// Version-specific controllers
namespace App\Http\Controllers\Api\V1;
class UserController extends Controller
{
public function index()
{
return User::paginate(15); // Old format
}
}
namespace App\Http\Controllers\Api\V2;
class UserController extends Controller
{
public function index()
{
return new UserCollection(
User::with('profile')->paginate(20) // Enhanced format
);
}
}
// Middleware for deprecation warnings
class ApiVersionMiddleware
{
public function handle(Request $request, Closure $next)
{
$version = $request->segment(2); // 'v1' or 'v2'
if ($version === 'v1') {
$response = $next($request);
$response->header('X-API-Warn', 'API v1 will be deprecated on 2025-12-31');
return $response;
}
return $next($request);
}
}
Result: Enabled backward compatibility while rolling out improvements.
Phase 5: Comprehensive Testing (Week 4-5)
Feature Tests
namespace Tests\Feature;
use Tests\TestCase;
class PaymentApiTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function user_can_process_payment_with_sufficient_funds()
{
$user = User::factory()->create(['balance' => 1000]);
$response = $this->actingAs($user, 'api')
->postJson('/api/v2/payments', [
'amount' => 100,
'currency' => 'usd',
'payment_method' => 'card',
]);
$response->assertStatus(201)
->assertJsonStructure([
'success',
'data' => ['id', 'amount', 'status'],
'timestamp',
]);
$this->assertDatabaseHas('payments', [
'user_id' => $user->id,
'amount' => 100,
]);
$this->assertEquals(900, $user->fresh()->balance);
}
/** @test */
public function payment_fails_with_insufficient_funds()
{
$user = User::factory()->create(['balance' => 50]);
$response = $this->actingAs($user, 'api')
->postJson('/api/v2/payments', [
'amount' => 100,
'currency' => 'usd',
'payment_method' => 'card',
]);
$response->assertStatus(402)
->assertJson([
'success' => false,
'error_code' => 'INSUFFICIENT_FUNDS',
]);
$this->assertEquals(50, $user->fresh()->balance);
}
}
Unit Tests for Services
namespace Tests\Unit;
class PaymentServiceTest extends TestCase
{
/** @test */
public function it_processes_payment_successfully()
{
$user = User::factory()->make(['balance' => 1000]);
$userRepo = Mockery::mock(UserRepository::class);
$paymentRepo = Mockery::mock(PaymentRepository::class);
$gateway = Mockery::mock(PaymentGateway::class);
$paymentRepo->shouldReceive('create')->once()->andReturn(new Payment());
$userRepo->shouldReceive('decrementBalance')->once();
$service = new PaymentService($userRepo, $paymentRepo, $gateway);
$payment = $service->processPayment($user, [
'amount' => 100,
'currency' => 'usd',
]);
$this->assertInstanceOf(Payment::class, $payment);
}
}
Result: Code coverage increased from 0% to 87%, bug reports dropped by 87%.
Phase 6: API Documentation (Week 5)
OpenAPI/Swagger Integration
/**
* @OA\Post(
* path="/api/v2/payments",
* tags={"Payments"},
* summary="Process a payment",
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(
* required={"amount","currency","payment_method"},
* @OA\Property(property="amount", type="number", example=100.00),
* @OA\Property(property="currency", type="string", example="usd"),
* @OA\Property(property="payment_method", type="string", example="card")
* )
* ),
* @OA\Response(
* response=201,
* description="Payment processed successfully",
* @OA\JsonContent(
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="data", type="object")
* )
* ),
* @OA\Response(response=402, description="Insufficient funds"),
* @OA\Response(response=422, description="Validation error")
* )
*/
public function processPayment(ProcessPaymentRequest $request)
{
// Implementation
}
// Generate documentation
php artisan l5-swagger:generate
Result: API integration time reduced by 70%, support tickets decreased by 45%.
Phase 7: Rate Limiting and Security (Week 6)
// Rate limiting middleware
Route::middleware(['auth:api', 'throttle:60,1'])->group(function () {
Route::apiResource('payments', PaymentController::class);
});
// Custom rate limiter
RateLimiter::for('api', function (Request $request) {
return $request->user()
? Limit::perMinute(100)->by($request->user()->id)
: Limit::perMinute(20)->by($request->ip());
});
// Request validation middleware
class ValidateApiSignature
{
public function handle(Request $request, Closure $next)
{
$signature = $request->header('X-API-Signature');
$timestamp = $request->header('X-API-Timestamp');
if (!$this->verifySignature($request, $signature, $timestamp)) {
return response()->json([
'success' => false,
'message' => 'Invalid API signature',
], 401);
}
return $next($request);
}
}
Result: Unauthorized access attempts blocked, API abuse reduced by 95%.
Final Metrics: Before vs After
| Metric | Before | After | Improvement |
|---|---|---|---|
| Uptime | 94.2% | 99.8% | +5.6% |
| MTTR | 2.3 hours | 15 minutes | 89% faster |
| Bug Reports | 47/month | 6/month | 87% reduction |
| Failed API Calls | 3.2% | 0.3% | 91% reduction |
| Response Time | 850ms | 298ms | 65% faster |
| Code Coverage | 0% | 87% | +87% |
| Cyclomatic Complexity | 42 | 4 | 90% reduction |
| Development Velocity | 2 features/sprint | 8 features/sprint | 4x faster |
Key Lessons Learned
1. Architecture Matters Most
Clean separation of concerns provided the foundation for all other improvements.
2. Start with Tests
Writing tests forced us to write testable (better) code.
3. Consistent Standards
Standardized responses and error handling eliminated countless integration issues.
4. Incremental Refactoring
We refactored one module at a time while maintaining production stability.
5. Documentation is Investment
Comprehensive API documentation paid for itself in reduced support time.
Conclusion
Improving API reliability and code quality through Laravel 12 refactoring transformed our fintech API from a liability into an asset. By implementing clean architecture, comprehensive testing, standardized responses, and proper error handling, we achieved 99.8% uptime, 87% fewer bugs, and 4x faster development velocity.
The key wasn't rewriting everything—it was systematic, strategic refactoring guided by SOLID principles and Laravel best practices. The investment in code quality paid immediate dividends in reliability, maintainability, and team productivity.
Key takeaways:
- Implement layered architecture with clear separation of concerns
- Standardize API responses and error handling
- Achieve high test coverage before major changes
- Version APIs for backward compatibility
- Document everything with OpenAPI/Swagger
- Monitor and measure continuously
- Refactor incrementally, not in one big bang
Need help refactoring your Laravel API? NeedLaravelSite specializes in API development and code quality improvement. Contact us for expert Laravel development services.
Related Resources: