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"
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:
- Data Transfer Objects (DTOs): Strongly typed, immutable payloads that normalize and validate external inputs across system boundaries.
- Domain Entities: Framework-agnostic PHP classes encapsulating core business invariants, identity, and state transitions.
- Action Classes: Single-responsibility use cases executing specific business workflows via command-like interfaces.
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:
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:
where is the offset and 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:
where is total row cardinality. Laravel exposes this via cursorPaginate(), compiling queries to boundary constraints:
// 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:
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 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 = trueon 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:
where is queue depth, is arrival rate, and is processing latency. If downstream latency increases from to due to database contention, queue depth expands by a factor of 10 for any static arrival rate .
To prevent cascading backpressure, jobs must enforce concurrency limits and explicit timeouts using distributed Redis locks:
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 to sub-millisecond ranges.
However, long-running processes convert minor code oversights into severe production leaks:
// 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:
- Bind request-specific instances using
$app->scoped()instead of$app->singleton(). - Clean custom singletons using Octane’s
OperationTerminatedorRequestReceivedlifecycle listeners. - 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
LazilyRefreshDatabasetrait to run migrations once. - Architecture Rules: Enforce structural boundaries statically using Pest Architecture tests, preventing infrastructure concerns from leaking into domain directories.
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
Systems architect and software engineer building high-performance distributed platforms.