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
| Symptom | Root Cause |
|---|---|
| Fragile changes | Tight coupling — one change breaks unrelated features |
| Untestable code | Dependencies hard-coded, no seams for mocking |
| Duplicated logic | No single source of truth for business rules |
| Slow onboarding | No clear boundaries between concerns |
| Fear of refactoring | No test coverage to catch regressions |
| Framework lock-in | Business 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
| Problem | Repository Solution |
|---|---|
| Business logic depends on Eloquent | Repository abstracts data access behind an interface |
| Queries scattered across the codebase | Repository centralizes data access |
| Hard to mock in tests | Interface can be mocked — no database required |
| Database migration risk | Swap implementations without touching business logic |
| Duplicated query logic | Single source of truth for data access |
Repository Structure
App/
├── Contracts/
│ └── Repositories/
│ └── OrderRepositoryInterface.php
├── Repositories/
│ └── EloquentOrderRepository.php
└── Providers/
└── RepositoryServiceProvider.phpThe 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 When | Do 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 explicit, testable, 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
| Layer | Target Coverage | Rationale |
|---|---|---|
| Domain logic / Actions | 90%+ | Business rules must be verified |
| Repositories | 80%+ | Data access correctness |
| Controllers | 70%+ | HTTP handling + validation |
| E2E | Critical paths only | Expensive, 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
| Rule | Rationale |
|---|---|
| Controllers never query the database | Business logic belongs in Actions/Services |
| Business logic never depends on Eloquent directly | Repository interface abstracts data access |
| Every dependency is injected, not instantiated | Testability and swappability |
| Interfaces define contracts; implementations honor them | Loose coupling |
| Unit tests cover domain logic; feature tests cover integration | Fast feedback + correctness |
| New features extend interfaces; existing code is not modified | Open/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.