Loading
Alireza Shokrani

Digital Architect

AI Solutions Engineer

Founder @ CoreBiz ERP

GenAI & RAG Specialist

Full-Stack Systems Developer

Alireza Shokrani

Digital Architect

AI Solutions Engineer

Founder @ CoreBiz ERP

GenAI & RAG Specialist

Full-Stack Systems Developer

Solution

Eliminating Architectural Debt: Implementing SOLID Principles and Repository Patterns in Modern PHP & Laravel

September 11, 2026 Enterprise Systems Architecture
Eliminating Architectural Debt: Implementing SOLID Principles and Repository Patterns in Modern PHP & Laravel

A guide for software engineering leads on maintaining codebases that scale smoothly over five years — where loose coupling, strict interface contracts, dependency injection, and automated testing prevent enterprise software decay.

Introduction — The Five-Year Codebase Problem

Most software does not fail because it was built incorrectly. It fails because it was built without a plan for change.

The pattern is predictable:

  • Year 1: The codebase is clean. Features ship fast. The team is productive.

  • Year 2: Business requirements evolve. Controllers grow. Models gain responsibilities. Tests become brittle.

  • Year 3: New developers take weeks to understand the system. A change in billing breaks inventory. Deployments become risky.

  • Year 4: The team avoids refactoring because “it works.” Technical debt compounds.

  • Year 5: The system is unmaintainable. The only option is a rewrite — which costs more than the original build.

This is architectural debt — and it is not inevitable. It is the result of specific architectural decisions (or the absence of them) made in Year 1.

This article is about the disciplines that prevent it:

SOLID principles, Repository patterns, dependency injection, and automated testing — applied pragmatically in modern PHP and Laravel, where they actually matter.

The goal is not theoretical purity. The goal is a codebase that remains maintainable, testable, and evolvable after five years of business change.

Part 1 — What Architectural Debt Actually Is

Technical debt is a metaphor. Architectural debt is a structural condition.

The Symptoms

SymptomRoot Cause
Fragile changesTight coupling — one change breaks unrelated features
Untestable codeDependencies hard-coded, no seams for mocking
Duplicated logicNo single source of truth for business rules
Slow onboardingNo clear boundaries between concerns
Fear of refactoringNo test coverage to catch regressions
Framework lock-inBusiness logic entangled with framework code

The Root Cause: Violated Boundaries

Architectural debt accumulates when responsibilities are not separated:

Typical Laravel Controller (Year 3):
  ┌─────────────────────────────────────────────────────────┐
  │  class OrderController                                  │
  │  {                                                      │
  │      public function store(Request $request)            │
  │      {                                                  │
  │          // Validation (should be separate)             │
  │          // Business logic (should be in domain)        │
  │          // Database queries (should be in repository)  │
  │          // Email sending (should be in service)        │
  │          // Payment processing (should be in service)   │
  │          // Logging (should be cross-cutting)           │
  │          // Response formatting (should be in resource) │
  │      }                                                  │
  │  }                                                      │
  └─────────────────────────────────────────────────────────┘

This controller has seven responsibilities. It violates the Single Responsibility Principle before we even discuss SOLID.

Part 2 — SOLID Principles in Laravel: Where They Actually Matter

SOLID is not a checklist. It is a set of architectural constraints that prevent specific failure modes.

S — Single Responsibility Principle

A class should have one, and only one, reason to change.

Laravel failure mode: The “God Controller” or “God Model.”

Engineering fix: Separate concerns into distinct layers:

App/
├── Http/
│   ├── Controllers/      ← HTTP handling only
│   ├── Requests/         ← Validation only
│   └── Resources/        ← Response formatting only
├── Actions/              ← Single business operations
├── Services/             ← Multi-step business logic
├── Repositories/         ← Data access only
└── Models/               ← Eloquent entities only

Practical example:

// BEFORE: Controller does everything
class OrderController {
    public function store(Request $request) {
        $validated = $request->validate([...]);
        $order = Order::create($validated);
        Mail::to($order->customer)->send(new OrderConfirmation($order));
        return new OrderResource($order);
    }
}

// AFTER: Single responsibility per class
class OrderController {
    public function store(StoreOrderRequest $request, CreateOrderAction $action) {
        $order = $action->execute($request->validated());
        return new OrderResource($order);
    }
}

class CreateOrderAction {
    public function __construct(
        private OrderRepository $orders,
        private OrderConfirmationMailer $mailer
    ) {}
    
    public function execute(array $data): Order {
        $order = $this->orders->create($data);
        $this->mailer->send($order);
        return $order;
    }
}

Engineering impact: Each class has one reason to change. Business rules evolve without touching HTTP handling.

O — Open/Closed Principle

Software entities should be open for extension, but closed for modification.

Laravel failure mode: Adding a new payment method requires modifying a switch statement in the controller.

Engineering fix: Define an interface; implement new behaviors as new classes.

interface PaymentGateway {
    public function charge(Order $order): PaymentResult;
}

class StripeGateway implements PaymentGateway { ... }
class PayPalGateway implements PaymentGateway { ... }
class CryptoGateway implements PaymentGateway { ... }

// Adding a new gateway = adding a class, not modifying existing code

Engineering impact: New features extend the system without destabilizing existing code.

L — Liskov Substitution Principle

Subtypes must be substitutable for their base types.

Laravel failure mode: A CachedUserRepository that throws an exception when the cache is empty — breaking code that expects UserRepository behavior.

Engineering fix: Interfaces define contracts; implementations honor them fully.

interface UserRepository {
    public function find(int $id): ?User;  // Must return null, never throw
}

class EloquentUserRepository implements UserRepository { ... }
class CachedUserRepository implements UserRepository { ... }

Engineering impact: Any implementation can be swapped without changing the caller — enabling caching, mocking, and testing.

I — Interface Segregation Principle

Clients should not be forced to depend on interfaces they do not use.

Laravel failure mode: A single RepositoryInterface with 30 methods, where most implementations only use 5.

Engineering fix: Small, focused interfaces.

// BEFORE: Fat interface
interface RepositoryInterface {
    public function all();
    public function find($id);
    public function create(array $data);
    public function update($id, array $data);
    public function delete($id);
    public function paginate($perPage);
    public function search($query);
    // ... 20 more methods
}

// AFTER: Segregated interfaces
interface ReadableRepository {
    public function find(int $id): ?Model;
    public function findBy(array $criteria): Collection;
}

interface WritableRepository {
    public function create(array $data): Model;
    public function update(int $id, array $data): Model;
    public function delete(int $id): bool;
}

Engineering impact: Implementations only implement what they actually support.

D — Dependency Inversion Principle

High-level modules should not depend on low-level modules. Both should depend on abstractions.

Laravel failure mode: Business logic depends directly on DB::table() or Model::query().

Engineering fix: Depend on interfaces; bind implementations in the service container.

// BEFORE: Direct dependency
class OrderService {
    public function create(array $data) {
        return Order::create($data);  // Tightly coupled to Eloquent
    }
}

// AFTER: Inverted dependency
class OrderService {
    public function __construct(
        private OrderRepository $orders  // Interface, not Eloquent
    ) {}
    
    public function create(array $data) {
        return $this->orders->create($data);
    }
}

// Binding in service provider
$this->app->bind(OrderRepository::class, EloquentOrderRepository::class);

Engineering impact: Business logic becomes framework-agnostic and testable in isolation.

Part 3 — The Repository Pattern: Data Access as a Contract

The Repository pattern is the most impactful architectural decision for long-lived Laravel applications.

What the Repository Pattern Solves

ProblemRepository Solution
Business logic depends on EloquentRepository abstracts data access behind an interface
Queries scattered across the codebaseRepository centralizes data access
Hard to mock in testsInterface can be mocked — no database required
Database migration riskSwap implementations without touching business logic
Duplicated query logicSingle source of truth for data access

Repository Structure

App/
├── Contracts/
│   └── Repositories/
│       └── OrderRepositoryInterface.php
├── Repositories/
│   └── EloquentOrderRepository.php
└── Providers/
    └── RepositoryServiceProvider.php

The Interface (Contract)

namespace App\Contracts\Repositories;

interface OrderRepositoryInterface
{
    public function find(int $id): ?Order;
    public function findByCustomer(int $customerId): Collection;
    public function create(array $data): Order;
    public function update(int $id, array $data): Order;
    public function delete(int $id): bool;
    public function paginate(int $perPage = 15): LengthAwarePaginator;
}

The Implementation

namespace App\Repositories;

use App\Contracts\Repositories\OrderRepositoryInterface;

class EloquentOrderRepository implements OrderRepositoryInterface
{
    public function __construct(
        private Order $model
    ) {}
    
    public function find(int $id): ?Order
    {
        return $this->model->find($id);
    }
    
    public function findByCustomer(int $customerId): Collection
    {
        return $this->model->where('customer_id', $customerId)
            ->orderBy('created_at', 'desc')
            ->get();
    }
    
    public function create(array $data): Order
    {
        return $this->model->create($data);
    }
    
    public function paginate(int $perPage = 15): LengthAwarePaginator
    {
        return $this->model->paginate($perPage);
    }
}

The Binding

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use App\Contracts\Repositories\OrderRepositoryInterface;
use App\Repositories\EloquentOrderRepository;

class RepositoryServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind(
            OrderRepositoryInterface::class,
            EloquentOrderRepository::class
        );
    }
}

When to Use Repositories (and When Not To)

Use Repository WhenDo NOT Use Repository When
✅ Business logic must be testable without database❌ The app is a simple CRUD interface
✅ Multiple data sources (Eloquent + external API)❌ Eloquent alone is sufficient
✅ Complex queries used across multiple places❌ Each query is used only once
✅ Long-term maintainability matters❌ Prototype or throwaway project
✅ Team has multiple developers❌ Single developer, small scope

Repositories add a layer. That layer must earn its cost. In large, long-lived applications, it does.

Part 4 — Dependency Injection: The Glue of Clean Architecture

Laravel’s service container is the most underused feature in production applications. It is not magic — it is dependency management.

Constructor Injection

The standard pattern for injectable dependencies:

class CreateOrderAction
{
    public function __construct(
        private OrderRepositoryInterface $orders,
        private PaymentGateway $payment,
        private OrderConfirmationMailer $mailer
    ) {}
    
    public function execute(array $data): Order
    {
        $order = $this->orders->create($data);
        $this->payment->charge($order);
        $this->mailer->send($order);
        return $order;
    }
}

Engineering impact: Dependencies are explicittestable, and swappable.

Service Provider Binding

// app/Providers/AppServiceProvider.php
public function register(): void
{
    $this->app->bind(OrderRepositoryInterface::class, EloquentOrderRepository::class);
    $this->app->bind(PaymentGateway::class, StripeGateway::class);
    $this->app->singleton(OrderConfirmationMailer::class);
}

Contextual Binding

Different implementations for different consumers:

$this->app->when(AdminOrderService::class)
    ->needs(OrderRepositoryInterface::class)
    ->give(AdminOrderRepository::class);

$this->app->when(CustomerOrderService::class)
    ->needs(OrderRepositoryInterface::class)
    ->give(CustomerOrderRepository::class);

Engineering impact: The same interface resolves to different implementations based on context — without changing business logic.

Part 5 — PHPUnit Testing Strategy: The Safety Net

Tests are not documentation. They are the safety net that enables refactoring.

The Testing Pyramid in Laravel

        ┌─────────────┐
        │   E2E /     │  ← Few: full HTTP + DB + external
        │   Feature   │
        ├─────────────┤
        │ Integration │  ← Some: repository + DB
        ├─────────────┤
        │    Unit     │  ← Many: actions, services, domain logic
        └─────────────┘

Engineering principle: The majority of tests should be unit tests — fast, isolated, and focused on business logic.

Unit Testing Actions (No Database)

class CreateOrderActionTest extends TestCase
{
    public function test_it_creates_order_and_sends_confirmation(): void
    {
        // Arrange
        $order = new Order(['id' => 1]);
        
        $orders = $this->createMock(OrderRepositoryInterface::class);
        $orders->expects($this->once())
            ->method('create')
            ->willReturn($order);
        
        $payment = $this->createMock(PaymentGateway::class);
        $payment->expects($this->once())
            ->method('charge')
            ->with($order);
        
        $mailer = $this->createMock(OrderConfirmationMailer::class);
        $mailer->expects($this->once())
            ->method('send')
            ->with($order);
        
        $action = new CreateOrderAction($orders, $payment, $mailer);
        
        // Act
        $result = $action->execute(['customer_id' => 42]);
        
        // Assert
        $this->assertSame($order, $result);
    }
}

Why this matters: No database. No HTTP. No external services. The test runs in milliseconds and verifies business logic in isolation.

Feature Testing Controllers

class OrderControllerTest extends TestCase
{
    use RefreshDatabase;
    
    public function test_it_creates_order_via_api(): void
    {
        $this->postJson('/api/orders', [
            'customer_id' => 42,
            'items' => [['sku' => 'ABC', 'qty' => 2]]
        ])
        ->assertStatus(201)
        ->assertJsonStructure(['id', 'customer_id', 'total']);
        
        $this->assertDatabaseHas('orders', ['customer_id' => 42]);
    }
}

Repository Testing

class EloquentOrderRepositoryTest extends TestCase
{
    use RefreshDatabase;
    
    public function test_it_finds_orders_by_customer(): void
    {
        Order::factory()->count(3)->create(['customer_id' => 42]);
        Order::factory()->create(['customer_id' => 99]);
        
        $repo = new EloquentOrderRepository(new Order());
        $results = $repo->findByCustomer(42);
        
        $this->assertCount(3, $results);
    }
}

Test Coverage Targets

LayerTarget CoverageRationale
Domain logic / Actions90%+Business rules must be verified
Repositories80%+Data access correctness
Controllers70%+HTTP handling + validation
E2ECritical paths onlyExpensive, slow, brittle

Engineering principle: Coverage is a signal, not a goal. 100% coverage with meaningless assertions is worse than 80% coverage with meaningful ones.

Part 6 — The Five-Year Architecture

Combining all disciplines into a codebase that survives business evolution:

┌─────────────────────────────────────────────────────────────────────────────┐
│                         HTTP LAYER                                          │
│  Controllers (thin) │ Form Requests (validation) │ Resources (response)     │
├─────────────────────────────────────────────────────────────────────────────┤
│                         APPLICATION LAYER                                   │
│  Actions (single operations) │ Services (multi-step workflows)              │
├─────────────────────────────────────────────────────────────────────────────┤
│                         DOMAIN LAYER                                        │
│  Interfaces (contracts) │ Value Objects │ Domain Events                     │
├─────────────────────────────────────────────────────────────────────────────┤
│                         INFRASTRUCTURE LAYER                                │
│  Repositories (data access) │ External Services │ Mailers │ Payment         │
├─────────────────────────────────────────────────────────────────────────────┤
│                         FRAMEWORK LAYER                                     │
│  Laravel │ Eloquent │ Service Container │ Queue │ Cache                     │
└─────────────────────────────────────────────────────────────────────────────┘

Architectural Rules

RuleRationale
Controllers never query the databaseBusiness logic belongs in Actions/Services
Business logic never depends on Eloquent directlyRepository interface abstracts data access
Every dependency is injected, not instantiatedTestability and swappability
Interfaces define contracts; implementations honor themLoose coupling
Unit tests cover domain logic; feature tests cover integrationFast feedback + correctness
New features extend interfaces; existing code is not modifiedOpen/Closed principle

Part 7 — When to Apply This Architecture (and When Not To)

Apply SOLID + Repository + DI + Testing when:

  • ✅ The application is expected to live 3+ years

  • ✅ Multiple developers work on the codebase

  • ✅ Business rules are complex and evolving

  • ✅ Testing is a requirement (not optional)

  • ✅ Long-term maintainability matters more than initial speed

  • ✅ The domain has clear bounded contexts

Do NOT apply when:

  • ❌ The project is a prototype or MVP with uncertain future

  • ❌ The team is one developer working on a small scope

  • ❌ The application is simple CRUD with minimal business logic

  • ❌ Speed to market outweighs long-term structure

  • ❌ The team lacks experience with these patterns (adds risk)

Architecture is an investment. It pays returns in maintainability. It costs in initial complexity. The question is whether the codebase will live long enough to collect.

Conclusion — Preventing Decay Is an Architectural Decision

Enterprise software does not decay because developers are careless. It decays because no architectural boundaries were established to prevent it.

SOLID principles, Repository patterns, dependency injection, and automated testing are not academic exercises. They are the specific disciplines that prevent:

  • Fragile changes → loose coupling

  • Untestable code → dependency injection

  • Duplicated logic → single responsibility

  • Framework lock-in → interface contracts

  • Fear of refactoring → test coverage

The five-year codebase is not a fantasy. It is the result of decisions made in Year 1 — and honored in Years 2 through 5.

Code is not written for the computer. It is written for the developer who will change it next year.

Author’s Note

This article reflects architectural patterns developed while building CoreBiz ERP — a modular enterprise platform designed for long-term maintainability. For collaboration on enterprise PHP/Laravel architecture, reach out via the contact page.

Tags:
Write a comment