#laravel#php#clean-code#backend#database

Pragmatic Clean Architecture in Modern Laravel: From Fat Models to High-Throughput Engines

"Laravel’s rapid development speed often masks a critical architectural failure mode in enterprise software: the erosion of domain boundaries. Eloquent’s Active"

•By huud•
0 views
~6 min read
Pragmatic Clean Architecture in Modern Laravel: From Fat Models to High-Throughput Engines

Laravel’s rapid development speed often masks a critical architectural failure mode in enterprise software: the erosion of domain boundaries. Eloquent’s Active Record pattern couples database persistence directly to domain entities. By design, it encourages controllers and listeners to mutate state, execute database queries, and trigger external side effects across arbitrary application layers.

As applications expand beyond tens of thousands of lines of code, this coupling creates severe architectural friction: schema changes break business operations, unit testing requires heavy database mocking or transactions, and memory consumption spikes under load.

Modern PHP (8.2+) provides the type system, readonly properties, and native enumerations necessary to build decoupled systems. By introducing architectural boundaries, teams can maintain Laravel's ecosystem velocity while achieving the testability, predictability, and throughput demanded by high-scale production systems.


Decoupling the Domain Layer

Enterprise business rules must remain agnostic of delivery mechanisms (HTTP, CLI, queues) and persistence layers (relational databases, document stores, third-party APIs). In Laravel, this requires demoting Eloquent models from business aggregates to infrastructure-level persistence mappers.

A pragmatic boundary strategy introduces three core primitives:

  1. Data Transfer Objects (DTOs): Strongly typed, immutable payloads that normalize and validate external inputs across system boundaries.
  2. Domain Entities: Framework-agnostic PHP classes encapsulating core business invariants, identity, and state transitions.
  3. Action Classes: Single-responsibility use cases executing specific business workflows via command-like interfaces.
php
namespace Domain\Orders\Actions;

use Domain\Orders\DTOs\CreateOrderData;
use Domain\Orders\Events\OrderCreated;
use Domain\Orders\Exceptions\InvalidOrderStateException;
use Domain\Orders\Repositories\OrderRepositoryInterface;
use Illuminate\Support\Facades\DB;

final readonly class CreateOrderAction
{
    public function __construct(
        private OrderRepositoryInterface $repository,
    ) {}

    public function execute(CreateOrderData $data): OrderResult
    {
        return DB::transaction(function () use ($data) {
            // Instantiate pure domain entity free of framework dependencies
            $order = $data->toDomainEntity();
            
            // Invariants and calculations execute strictly in memory
            $order->applyDiscount($data->couponCode);
            $order->assertCanBePlaced();

            // Persist through the abstract boundary
            $persistedOrder = $this->repository->save($order);

            // Dispatch domain events post-persistence
            OrderCreated::dispatch($persistedOrder->id);

            return new OrderResult($persistedOrder);
        });
    }
}

By depending on OrderRepositoryInterface rather than Eloquent directly, the domain expresses its persistence requirements without coupling itself to an underlying database engine or ORM schema.


Database Optimization and Indexing Mechanics

High-throughput systems frequently fail due to database access antipatterns. Two primary bottlenecks are offset pagination degradation and unindexed composite filters.

Keyset Pagination vs. Offset Scans

Standard offset pagination via paginate() relies on SQL OFFSET, which degrades linearly as the table grows:

sql
SELECT * FROM orders ORDER BY created_at DESC LIMIT 50 OFFSET 100000;

The database storage engine must traverse and discard 100,000 index tuples before returning the 50 requested rows. The operational time complexity scales as:

Toffset=O(N+K)T_{\text{offset}} = O(N + K)

where NN is the offset and KK is the page size.

Transitioning to keyset (cursor-based) pagination relies on deterministic index seeks using a composite key—typically a timestamp paired with a unique identifier as a tiebreaker to prevent data loss across identical timestamps:

Tkeyset=O(log⁡M+K)T_{\text{keyset}} = O(\log M + K)

where MM is total row cardinality. Laravel exposes this via cursorPaginate(), compiling queries to boundary constraints:

php
// Under the hood, compiles to: WHERE (created_at, id) < (?, ?) ORDER BY created_at DESC, id DESC LIMIT 51
$orders = Order::query()
    ->where('tenant_id', $tenantId)
    ->orderByDesc('created_at')
    ->orderByDesc('id')
    ->cursorPaginate(50);

Index Prefix Cardinality

Composite indexes must satisfy the Equality-Sort-Range (ESR) rule. For a query filtering by tenant and status while sorting by creation date:

sql
CREATE INDEX idx_orders_tenant_status_created 
ON orders (tenant_id, status, created_at DESC, id DESC);

Placing equality columns (tenant_id, status) first allows the database engine to jump directly to the target leaf nodes, while trailing sort columns eliminate in-memory filesort operations entirely.


Resilient Queues and Asynchronous Workflows

Horizontal scaling requires delegating synchronous HTTP operations to background workers via Redis and Laravel Horizon. However, naive queue usage frequently causes silent failures and resource starvation.

The SerializesModels Anti-Pattern

The standard SerializesModels trait saves queue payload size by serializing only the model’s primary key. When the worker dequeues the job, it executes a SELECT * to rehydrate the model.

Under bursts of 10510^5 dispatched jobs, this triggers a thundering herd on read replicas. Furthermore, if a job is dispatched before the enclosing database transaction commits, the worker may dequeue the job before the row exists, raising a ModelNotFoundException.

To remediate:

  • Set $this->afterCommit = true on queued jobs.
  • Pass explicit, immutable DTOs or scalar values instead of raw Eloquent models.

Little's Law in Queue Sizing

Queue latency adheres strictly to Little’s Law:

L=λWL = \lambda W

where LL is queue depth, λ\lambda is arrival rate, and WW is processing latency. If downstream latency WW increases from 50ms50\text{ms} to 500ms500\text{ms} due to database contention, queue depth LL expands by a factor of 10 for any static arrival rate λ\lambda.

To prevent cascading backpressure, jobs must enforce concurrency limits and explicit timeouts using distributed Redis locks:

php
public function handle(): void
{
    // Restrict concurrency per tenant using a deterministic Redis lock
    Redis::funnel("process:order:{$this->tenantId}")
        ->limit(5)
        ->block(10)
        ->then(function () {
            // Process batch idempotently
        });
}

High-Performance Runtimes: Laravel Octane

Traditional PHP-FPM relies on a shared-nothing lifecycle: every request initializes the core framework, registers service providers, allocates memory, and terminates. Laravel Octane (via FrankenPHP, RoadRunner, or Swoole) boots the framework once into persistent memory, eliminating boot overhead and reducing framework latency from 25ms25\text{ms} to sub-millisecond ranges.

However, long-running processes convert minor code oversights into severe production leaks:

php
// Anti-pattern: Static state persists across distinct HTTP requests
final class ContextHolder 
{
    public static ?User $activeUser = null; 
}

Static properties and singletons bound to the application container persist across requests, creating cross-tenant data leaks and memory bloat.

To operate safely under Octane:

  1. Bind request-specific instances using $app->scoped() instead of $app->singleton().
  2. Clean custom singletons using Octane’s OperationTerminated or RequestReceived lifecycle listeners.
  3. Explicitly disconnect or cycle stale PDO connections and third-party HTTP clients.

Pragmatic Testing Strategy

Clean Architecture pays dividends in test execution speed and deterministic pipelines:

  • Pure Domain Unit Tests: Test domain entities and business rules with zero framework boot overhead. These tests run entirely in memory within microseconds.
  • Integration Tests: Test Action classes, repositories, and complex queries against a real database (PostgreSQL/MySQL within test containers) utilizing the LazilyRefreshDatabase trait to run migrations once.
  • Architecture Rules: Enforce structural boundaries statically using Pest Architecture tests, preventing infrastructure concerns from leaking into domain directories.
php
test('domain layer does not depend on infrastructure or framework')
    ->expect('Domain')
    ->not->toUse([
        'Illuminate\Http',
        'Illuminate\Database\Eloquent',
        'App\Http',
    ]);

Architectural Equilibrium

Clean Architecture in Laravel does not require wrapping every simple database operation in repositories and domain entities. Doing so for simple administrative CRUD endpoints creates needless indirection.

Adopt a pragmatic approach: utilize Command Query Responsibility Segregation (CQRS). For write operations where domain invariants, consistency, and downstream events are paramount, enforce strict hexagonal boundaries through Actions and Domain Entities. For high-throughput, read-only queries, bypass the domain layer entirely and project data directly using Eloquent or raw query builders.

Balancing domain isolation on the write path with optimized direct queries on the read path yields a maintainable, resilient system capable of scaling efficiently.

About the Author

huud

huud

@huud

About →

Systems architect and software engineer building high-performance distributed platforms.