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

Scaling Modern Laravel: Architecture, Database Performance, and High-Throughput Systems

"High-traffic PHP applications demand architectural discipline, memory awareness, and predictable database access patterns. Default framework conventions priori"

By huud
0 views
~7 min read
Scaling Modern Laravel: Architecture, Database Performance, and High-Throughput Systems

High-traffic PHP applications demand architectural discipline, memory awareness, and predictable database access patterns. Default framework conventions prioritize rapid prototyping over high-throughput execution. Scaling Laravel requires decoupling domain workflows from the HTTP transport layer, eliminating query bottlenecks, hardening asynchronous workers against failure, and adopting persistent memory runtimes.


1. Clean Domain Boundaries: Pragmatic ADR and Action Pipelines

Laravel controllers often degrade into multi-responsibility classes handling validation, database transactions, third-party integrations, and response formatting. This coupling impedes testability and prevents logic reuse across CLI commands, queues, and API endpoints.

Adopt an Action-Domain-Responder (ADR) structure using strictly typed Data Transfer Objects (DTOs) and single-purpose domain actions.

php
// app/DTOs/RegisterOrderDTO.php
declare(strict_types=1);

namespace App\DTOs;

readonly class RegisterOrderDTO
{
    /**
     * @param array<int, array{name: string, price: int, quantity: int}> $lineItems
     */
    public function __construct(
        public int $userId,
        public int $amountInCents,
        public string $currency,
        public array $lineItems,
    ) {}

    /**
     * @param array<string, mixed> $validated
     */
    public static function fromRequest(array $validated): self
    {
        return new self(
            userId: (int) $validated['user_id'],
            amountInCents: (int) $validated['amount'],
            currency: (string) $validated['currency'],
            lineItems: (array) $validated['items'],
        );
    }
}

Controllers validate HTTP requests, map input to an immutable DTO, and delegate execution to the domain action:

php
// app/Http/Controllers/Order/StoreOrderController.php
declare(strict_types=1);

namespace App\Http\Controllers\Order;

use App\Actions\Order\CreateOrderAction;
use App\DTOs\RegisterOrderDTO;
use App\Http\Requests\Order\StoreOrderRequest;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;

final class StoreOrderController
{
    public function __invoke(StoreOrderRequest $request, CreateOrderAction $action): JsonResponse
    {
        $dto = RegisterOrderDTO::fromRequest($request->validated());
        $order = $action->execute($dto);

        return new JsonResponse($order, Response::HTTP_CREATED);
    }
}

Domain actions execute transactional state changes and dispatch domain events:

php
// app/Actions/Order/CreateOrderAction.php
declare(strict_types=1);

namespace App\Actions\Order;

use App\DTOs\RegisterOrderDTO;
use App\Events\OrderCreatedEvent;
use App\Models\Order;
use Illuminate\Support\Facades\DB;

final class CreateOrderAction
{
    public function execute(RegisterOrderDTO $dto): Order
    {
        return DB::transaction(function () use ($dto): Order {
            $order = Order::query()->create([
                'user_id' => $dto->userId,
                'amount_cents' => $dto->amountInCents,
                'currency' => $dto->currency,
                'status' => 'pending',
            ]);

            $order->items()->createMany($dto->lineItems);

            event(new OrderCreatedEvent($order->id));

            return $order;
        });
    }
}

This pattern isolates domain logic from HTTP concerns, allowing execution across workers, CLI commands, and HTTP endpoints without duplication.


2. Database Optimization: Query Discipline and Keyset Pagination

Active Record abstractions obscure underlying query volume and execution cost. Latency spikes in production systems typically stem from unindexed lookups, unbounded result sets, and accidental N+1N+1 queries.

Enforcing Strict Query Bounds

Prevent accidental lazy loading and unfillable attributes across non-production environments in AppServiceProvider:

php
// app/Providers/AppServiceProvider.php
namespace App\Providers;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Model::shouldBeStrict(! $this->app->isProduction());
    }
}

This configuration throws runtime exceptions during automated testing and local execution when lazy loading occurs or unguarded attributes are accessed.

Offset vs. Keyset (Cursor) Pagination

Traditional offset pagination degrades linearly as table depth increases. The database engine must scan and discard all rows preceding the designated offset:

Scan Costoffset=O(N+K)\text{Scan Cost}_{\text{offset}} = \mathcal{O}(N + K)

where NN is the offset depth and KK is the page size limit. Scanning past N=1,000,000N = 1,000,000 requires reading one million rows before returning the targeted records.

Keyset pagination eliminates offset scanning by filtering on an indexed, sequential column (such as an auto-incrementing ID or composite timestamp index):

Scan Costcursor=O(logM+K)\text{Scan Cost}_{\text{cursor}} = \mathcal{O}(\log M + K)

where MM represents total indexed rows.

php
// Keyset query utilizing sequential B-Tree index traversal
Order::query()
    ->where('id', '<', $cursorId)
    ->orderByDesc('id')
    ->limit(25)
    ->get();

Laravel provides native cursor pagination for indexed sequential columns:

php
$orders = Order::query()
    ->where('status', 'completed')
    ->orderByDesc('id')
    ->cursorPaginate(25);

3. Resilient Queue Pipelines: Idempotency and Backoff Topology

Distributed background processing requires workers capable of handling unpredictable network latency, third-party outages, and worker terminations without data corruption. Every queued job must be idempotent.

Exponential Backoff with Jitter

Fixed retry intervals risk overwhelming recovering downstream services (thundering herd problem). Combine exponential backoff with randomized jitter:

Tbackoff=min(Tmax,Tbase2attempt)+rand(0,J)T_{\text{backoff}} = \min\left(T_{\text{max}}, T_{\text{base}} \cdot 2^{\text{attempt}}\right) + \text{rand}(0, J)

Enforce backoff sequences and distributed lock acquisition directly within the job:

php
// app/Jobs/ProcessPaymentJob.php
declare(strict_types=1);

namespace App\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Cache;

class ProcessPaymentJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 5;
    public int $timeout = 60;
    public array $backoff = [2, 10, 30, 120, 300];

    public function __construct(
        public string $paymentUuid,
        public int $amountInCents,
    ) {}

    public function handle(): void
    {
        $lock = Cache::lock("lock:payment:{$this->paymentUuid}", 30);

        if (! $lock->get()) {
            $this->release(5);
            return;
        }

        try {
            // Process payment transaction idempotently against external gateway
        } finally {
            $lock->release();
        }
    }
}

Queue Partitioning via Laravel Horizon

Partition queue workers by operational Service Level Agreements (SLAs) in config/horizon.php:

php
'environments' => [
    'production' => [
        'supervisor-critical' => [
            'connection' => 'redis',
            'queue' => ['payments', 'orders'],
            'balance' => 'auto',
            'minProcesses' => 5,
            'maxProcesses' => 25,
            'tries' => 3,
        ],
        'supervisor-default' => [
            'connection' => 'redis',
            'queue' => ['notifications', 'exports', 'webhooks'],
            'balance' => 'simple',
            'minProcesses' => 2,
            'maxProcesses' => 10,
            'tries' => 2,
        ],
    ],
],

Isolating high-priority transaction queues prevents low-priority batch workloads from starving critical business events.


4. Persistent Runtimes: Laravel Octane

Standard PHP-FPM executes under a shared-nothing lifecycle: each HTTP request boots the framework, parses configuration files, and initializes service providers.

Laravel Octane maintains the application instance in memory using RoadRunner or Swoole. Bypassing repeated framework bootstrapping reduces response latency from tens of milliseconds to sub-millisecond execution times.

Preventing State and Memory Leaks

Persistent runtimes retain static variables, container singletons, and static state across request boundaries. Retaining contextual data between requests introduces security vulnerabilities and memory leaks:

php
// UNPREDICTABLE IN OCTANE: Static state persists across requests
class RequestContext
{
    public static ?User $currentUser = null;
}

Flush stateful bindings on each cycle using Octane termination listeners:

php
// app/Providers/AppServiceProvider.php
namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Laravel\Octane\Events\RequestTerminated;

class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app['events']->listen(RequestTerminated::class, function () {
            // Reset stateful singletons and custom registries
        });
    }
}

Run Octane workers with bounded request lifecycles to mitigate memory fragmentation:

bash
php artisan octane:start --server=roadrunner --workers=8 --max-requests=10000

The --max-requests flag periodically recycles worker processes, clearing residual memory buildup without dropping active connections.


5. Pipeline Pattern for Complex Business Workflows

Multi-stage domain workflows—such as checkout validation, discount application, regional taxation, and fraud evaluation—accumulate structural bloat when structured as procedural scripts. The Laravel Pipeline provides composable, isolated execution chains.

php
// app/Pipelines/Order/ProcessOrderPipeline.php
declare(strict_types=1);

namespace App\Pipelines\Order;

use App\DTOs\OrderContext;
use Illuminate\Pipeline\Pipeline;

final class ProcessOrderPipeline
{
    public function __construct(private Pipeline $pipeline) {}

    public function process(OrderContext $context): OrderContext
    {
        $pipes = [
            Pipes\ValidateInventory::class,
            Pipes\ApplyDiscountVouchers::class,
            Pipes\CalculateRegionalTax::class,
            Pipes\EvaluateFraudRisk::class,
        ];

        return $this->pipeline
            ->send($context)
            ->through($pipes)
            ->then(fn (OrderContext $context): OrderContext => $context);
    }
}

Each pipe encapsulates a single transformation or invariant check:

php
// app/Pipelines/Order/Pipes/ValidateInventory.php
declare(strict_types=1);

namespace App\Pipelines\Order\Pipes;

use App\DTOs\OrderContext;
use Closure;
use DomainException;

final class ValidateInventory
{
    public function handle(OrderContext $context, Closure $next): mixed
    {
        if (! $context->hasSufficientStock()) {
            throw new DomainException('Insufficient stock for order line items.');
        }

        return $next($context);
    }
}

6. Integration Testing Against Real Data Stores

Unit testing query builder logic using mocks validates syntax rather than database behavior. Test domain actions and queries against real database instances inside transactions using RefreshDatabase.

php
// tests/Feature/Order/CreateOrderActionTest.php
declare(strict_types=1);

namespace Tests\Feature\Order;

use App\Actions\Order\CreateOrderAction;
use App\DTOs\RegisterOrderDTO;
use App\Events\OrderCreatedEvent;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Tests\TestCase;

final class CreateOrderActionTest extends TestCase
{
    use RefreshDatabase;

    public function test_creates_order_and_dispatches_event(): void
    {
        Event::fake([OrderCreatedEvent::class]);

        $user = User::factory()->create();

        $dto = new RegisterOrderDTO(
            userId: $user->id,
            amountInCents: 5000,
            currency: 'USD',
            lineItems: [['name' => 'Widget', 'price' => 5000, 'quantity' => 1]],
        );

        $action = $this->app->make(CreateOrderAction::class);
        $order = $action->execute($dto);

        $this->assertDatabaseHas('orders', [
            'id' => $order->id,
            'user_id' => $user->id,
            'amount_cents' => 5000,
            'status' => 'pending',
        ]);

        Event::assertDispatched(OrderCreatedEvent::class);
    }
}

Execute parallelized test runs across available hardware threads:

bash
php artisan test --parallel --recreate-databases

Architectural Comparison Matrix

Layer Standard Laravel Pattern Scaled High-Throughput Pattern Primary Trade-Off
Domain Logic Fat Controllers & Eloquent Observers ADR, DTOs, and Dedicated Actions Higher file count, clear boundaries
Data Access Offset paginate() Keyset cursorPaginate() Eliminates arbitrary page jumping
Runtime PHP-FPM Request-Bound Laravel Octane (RoadRunner/Swoole) Requires strict memory management
Queue Workers Shared Single Queue Worker Segmented Horizon Workers + Jitter Requires dedicated Redis topology
Workflows Monolithic Service Classes Composable Pipeline Execution Additional class orchestration

Scaling Laravel requires moving past generic Active Record patterns toward explicit domain actions, constant-time database access, isolated queue topologies, and memory-conscious execution runtimes. Applying these patterns preserves maintainability while sustaining enterprise traffic loads.

About the Author

huud

huud

@huud

About →

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