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.
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:
- Action classes for single-purpose operations
- DTOs for type-safe data transfer
- Repositories for complex query logic
- Domain events for decoupled side effects
- 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.
Security Engineering21 min read
Authentication and Authorization in Production Systems
Implement secure JWT authentication with refresh token rotation, RBAC, and OAuth 2.0 flows. Production patterns from healthcare and government 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.