Backend Design

Laravel at Scale: Enterprise Patterns Beyond MVC

Build enterprise Laravel applications with repository pattern, service layer, and DDD principles. Production patterns from government and healthcare systems.

Khalid Aboubakr
18 min read
LaravelPhpEnterprisePatternsArchitectureRepository PatternService LayerDdd

Introduction

Laravel's MVC structure works well for simple applications, but enterprise systems demand more. Fat controllers, model callbacks everywhere, and business logic scattered across the codebase become maintenance nightmares.

This article presents patterns I've used to build maintainable Laravel applications in government and healthcare sectors.

Beyond Fat Controllers

The Action Pattern

Single-purpose action classes keep code focused and testable:

<?php namespace App\Actions\Orders; use App\Models\Order; use App\Models\User; use App\DTOs\CreateOrderDTO; use App\Events\OrderCreated; use Illuminate\Support\Facades\DB; class CreateOrderAction { public function __construct( private InventoryService $inventory, private PaymentService $payment, private NotificationService $notifications ) {} public function execute(User $user, CreateOrderDTO $dto): Order { return DB::transaction(function () use ($user, $dto) { // Reserve inventory $this->inventory->reserve($dto->items); // Create order $order = Order::create([ 'user_id' => $user->id, 'status' => OrderStatus::PENDING, 'total_amount' => $dto->calculateTotal(), 'shipping_address' => $dto->shippingAddress, ]); // Create order items foreach ($dto->items as $item) { $order->items()->create([ 'product_id' => $item->productId, 'quantity' => $item->quantity, 'unit_price' => $item->unitPrice, ]); } // Process payment $payment = $this->payment->charge( $user, $dto->calculateTotal(), $dto->paymentMethod ); $order->update([ 'payment_id' => $payment->id, 'status' => OrderStatus::CONFIRMED, ]); // Dispatch event for side effects event(new OrderCreated($order)); return $order->fresh(['items', 'user']); }); } } // Controller becomes thin class OrderController extends Controller { public function store( CreateOrderRequest $request, CreateOrderAction $action ): JsonResponse { $order = $action->execute( $request->user(), CreateOrderDTO::fromRequest($request) ); return response()->json( new OrderResource($order), Response::HTTP_CREATED ); } }

Data Transfer Objects

DTOs provide type safety and validation in one place:

<?php namespace App\DTOs; use Illuminate\Http\Request; use Spatie\LaravelData\Data; use Spatie\LaravelData\Attributes\Validation\Required; use Spatie\LaravelData\Attributes\Validation\Min; class CreateOrderDTO extends Data { public function __construct( #[Required] public array $items, #[Required] public AddressDTO $shippingAddress, #[Required] public string $paymentMethod, public ?string $couponCode = null, ) {} public static function fromRequest(Request $request): self { return new self( items: collect($request->items)->map( fn ($item) => OrderItemDTO::from($item) )->all(), shippingAddress: AddressDTO::from($request->shipping_address), paymentMethod: $request->payment_method, couponCode: $request->coupon_code, ); } public function calculateTotal(): Money { $subtotal = collect($this->items)->sum( fn (OrderItemDTO $item) => $item->unitPrice * $item->quantity ); return Money::of($subtotal, 'USD'); } }

Repository Pattern Done Right

When Repositories Add Value

<?php namespace App\Repositories; use App\Models\Patient; use App\DTOs\PatientSearchCriteria; use Illuminate\Contracts\Pagination\LengthAwarePaginator; interface PatientRepositoryInterface { public function findById(string $id): ?Patient; public function findByMedicalRecordNumber(string $mrn): ?Patient; public function search(PatientSearchCriteria $criteria): LengthAwarePaginator; public function save(Patient $patient): Patient; } class EloquentPatientRepository implements PatientRepositoryInterface { public function findById(string $id): ?Patient { return Patient::with(['allergies', 'medications'])->find($id); } public function findByMedicalRecordNumber(string $mrn): ?Patient { return Patient::where('medical_record_number', $mrn) ->with(['allergies', 'medications']) ->first(); } public function search(PatientSearchCriteria $criteria): LengthAwarePaginator { $query = Patient::query(); if ($criteria->name) { $query->where(function ($q) use ($criteria) { $q->where('first_name', 'ILIKE', "%{$criteria->name}%") ->orWhere('last_name', 'ILIKE', "%{$criteria->name}%"); }); } if ($criteria->dateOfBirth) { $query->whereDate('date_of_birth', $criteria->dateOfBirth); } if ($criteria->insuranceProvider) { $query->whereHas('insurancePolicies', function ($q) use ($criteria) { $q->where('provider', $criteria->insuranceProvider) ->where('status', 'active'); }); } return $query ->orderBy($criteria->sortBy, $criteria->sortDirection) ->paginate($criteria->perPage); } }

Domain Events

Decouple side effects from core logic:

<?php namespace App\Events; use App\Models\Order; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; class OrderCreated { use Dispatchable, SerializesModels; public function __construct( public readonly Order $order ) {} } // Listeners handle side effects independently class SendOrderConfirmationEmail { public function handle(OrderCreated $event): void { Mail::to($event->order->user->email) ->queue(new OrderConfirmationMail($event->order)); } } class UpdateInventory { public function handle(OrderCreated $event): void { foreach ($event->order->items as $item) { InventoryAdjustment::create([ 'product_id' => $item->product_id, 'quantity' => -$item->quantity, 'reason' => "Order #{$event->order->id}", ]); } } } class NotifyWarehouse { public function handle(OrderCreated $event): void { if ($event->order->requiresShipping()) { dispatch(new PrepareShipmentJob($event->order)); } } }

Service Layer

For complex business operations spanning multiple models:

<?php namespace App\Services; class SubscriptionService { public function __construct( private PaymentGateway $paymentGateway, private SubscriptionRepository $subscriptions, private UserRepository $users ) {} public function upgrade(User $user, Plan $newPlan): Subscription { $currentSubscription = $user->activeSubscription; if (!$currentSubscription) { throw new NoActiveSubscriptionException(); } if (!$newPlan->isUpgradeFrom($currentSubscription->plan)) { throw new InvalidPlanUpgradeException(); } return DB::transaction(function () use ($user, $currentSubscription, $newPlan) { // Calculate prorated amount $proratedAmount = $this->calculateProratedAmount( $currentSubscription, $newPlan ); // Charge the difference $payment = $this->paymentGateway->charge( $user->defaultPaymentMethod, $proratedAmount ); // Update subscription $currentSubscription->update([ 'plan_id' => $newPlan->id, 'price' => $newPlan->price, 'upgraded_at' => now(), ]); // Record the upgrade SubscriptionUpgrade::create([ 'subscription_id' => $currentSubscription->id, 'from_plan_id' => $currentSubscription->plan_id, 'to_plan_id' => $newPlan->id, 'payment_id' => $payment->id, 'prorated_amount' => $proratedAmount, ]); event(new SubscriptionUpgraded($currentSubscription)); return $currentSubscription->fresh(); }); } }

Conclusion

Enterprise Laravel applications benefit from:

  1. Action classes for single-purpose operations
  2. DTOs for type-safe data transfer
  3. Repositories for complex query logic
  4. Domain events for decoupled side effects
  5. Service layer for cross-cutting business logic

These patterns add structure without over-engineering. Start simple and introduce them as complexity grows.

Related Articles

Backend Design19 min read

API Design: Choosing Between REST, GraphQL, and gRPC

Compare REST, GraphQL, and gRPC APIs with performance benchmarks and use cases. Learn which API style fits your project based on real production experience.

Backend Design20 min read

Database Design Patterns for Scale

Scale databases with sharding, replication, and partitioning. Covers PostgreSQL, MySQL, and MongoDB scaling patterns with real performance numbers from production systems.

Software Architecture22 min read

Domain-Driven Design: Bounded Contexts in Practice

Learn how to implement bounded contexts in Domain-Driven Design. Practical guide covering context mapping, aggregates, domain events, and real examples from enterprise projects.

Security Engineering18 min read

API Security Hardening: A Practitioner's Guide

Secure your APIs with rate limiting, input validation, and CORS configuration. Production-tested checklist covering authentication, encryption, and error handling.