Building Scalable Laravel Applications
When building modern web applications that need to serve thousands of users and handle complex business logic, scalability is a must. After building well-architected Laravel applications in production, I've gathered patterns and practices that transform simple Laravel apps into robust, maintainable systems. Today, I'll walk you through these battle-tested strategies that seamlessly scale from MVP to enterprise.
#The Foundation: Action-Based Architecture
The most transformative pattern I've encountered is the Action pattern—a simple concept that revolutionizes how you organize business logic. Instead of bloated controllers doing everything, actions encapsulate single business operations.
1abstract class BaseAction
2{
3 public static function instance(): static
4 {
5 return new static();
6 }
7
8 abstract public function handle(/* parameters */);
9}
10
11class CreateSubscriptionAction extends BaseAction
12{
13 public function handle(User $user, Plan $plan, array $paymentData): Subscription
14 {
15 DB::beginTransaction();
16 try {
17 $subscription = $this->createSubscription($user, $plan);
18 $this->processPayment($subscription, $paymentData);
19 $this->sendWelcomeNotification($user);
20 $this->logSubscriptionEvent($subscription);
21
22 DB::commit();
23 return $subscription;
24 } catch (Exception $e) {
25 DB::rollBack();
26 throw $e;
27 }
28 }
29
30 private function createSubscription(User $user, Plan $plan): Subscription
31 {
32 return $user->subscriptions()->create([
33 'plan_id' => $plan->id,
34 'status' => 'active',
35 'expires_at' => now()->addDays($plan->duration_days),
36 ]);
37 }
38
39 private function processPayment(Subscription $subscription, array $paymentData): void
40 {
41 // Payment processing logic
42 }
43
44 private function sendWelcomeNotification(User $user): void
45 {
46 Mail::to($user)->queue(new SubscriptionWelcome($user));
47 }
48
49 private function logSubscriptionEvent(Subscription $subscription): void
50 {
51 activity()->log('Subscription created', $subscription);
52 }
53}
Why does this work so brilliantly? Single responsibility. Each action has one job, making it easily testable, reusable across controllers and commands, and maintainable as your application grows.
The genius here is that actions serve as the single source of truth for business operations across your entire application stack.
#Query Optimization: The Pipeline Filter Pattern
Dynamic query building is where most Laravel applications start to feel sluggish. The pipeline filter pattern solves this elegantly by transforming query building into composable, testable components:
1abstract class Filter
2{
3 public function handle(Builder $builder, Closure $next)
4 {
5 if (!request()->has($this->filterName())) {
6 return $next($builder);
7 }
8 return $this->applyFilter($next($builder));
9 }
10
11 abstract protected function applyFilter(Builder $builder): Builder;
12
13 protected function filterName(): string
14 {
15 return Str::snake(class_basename($this));
16 }
17}
18
19class StatusFilter extends Filter
20{
21 protected function applyFilter(Builder $builder): Builder
22 {
23 $status = request($this->filterName());
24
25 return match($status) {
26 'active' => $builder->where('status', 'active'),
27 'inactive' => $builder->where('status', 'inactive'),
28 'pending' => $builder->where('status', 'pending'),
29 default => $builder,
30 };
31 }
32}
33
34class DateRangeFilter extends Filter
35{
36 protected function applyFilter(Builder $builder): Builder
37 {
38 return $builder
39 ->when(request('start_date'), fn($q) =>
40 $q->whereDate('created_at', '>=', request('start_date')))
41 ->when(request('end_date'), fn($q) =>
42 $q->whereDate('created_at', '<=', request('end_date')));
43 }
44}
45
46// Model trait
47trait HasFiltered
48{
49 protected array $filter_pipes = [
50 SearchFilter::class,
51 StatusFilter::class,
52 DateRangeFilter::class,
53 ];
54
55 public function scopeWhereFilterByQueryParams(Builder $query): Builder
56 {
57 return Pipeline::send($query)
58 ->through($this->getFilterPipes())
59 ->thenReturn();
60 }
61
62 protected function getFilterPipes(): array
63 {
64 return $this->filter_pipes ?? [];
65 }
66}
67
68// Clean controller usage
69class SubscriptionController extends Controller
70{
71 public function index()
72 {
73 $subscriptions = Subscription::with(['user', 'plan'])
74 ->whereFilterByQueryParams()
75 ->paginate();
76
77 return SubscriptionResource::collection($subscriptions);
78 }
79}
This approach provides several benefits:
- Composable filters: Each filter is independent and testable
- Performance optimization: Only active filters are applied
- Maintainable logic: Adding new filters requires no controller changes
#Type Safety Through Modern PHP
Laravel applications benefit tremendously from PHP 8.1+'s type system. The combination of enums, readonly properties, and DTOs creates compile-time safety that prevents entire categories of runtime errors.
1enum SubscriptionStatus: string
2{
3 case ACTIVE = 'active';
4 case CANCELLED = 'cancelled';
5 case EXPIRED = 'expired';
6 case PENDING = 'pending';
7
8 public static function all(): array
9 {
10 return array_column(self::cases(), 'value');
11 }
12
13 public function getLabel(): string
14 {
15 return match($this) {
16 self::ACTIVE => 'Active Subscription',
17 self::CANCELLED => 'Cancelled',
18 self::EXPIRED => 'Expired',
19 self::PENDING => 'Pending Activation',
20 };
21 }
22
23 public function canRenew(): bool
24 {
25 return in_array($this, [self::EXPIRED, self::CANCELLED]);
26 }
27}
28
29// DTO for type-safe data transfer
30class SubscriptionDataDto extends BaseDto
31{
32 public function __construct(
33 public readonly int $user_id,
34 public readonly int $plan_id,
35 public readonly SubscriptionStatus $status,
36 public readonly ?Carbon $expires_at = null,
37 public readonly array $metadata = [],
38 ) {
39 if ($this->status === SubscriptionStatus::ACTIVE && !$this->expires_at) {
40 throw new InvalidArgumentException('Active subscriptions must have expiration date');
41 }
42 }
43
44 public static function fromArray(array $data): static
45 {
46 return new self(
47 user_id: (int) $data['user_id'],
48 plan_id: (int) $data['plan_id'],
49 status: SubscriptionStatus::from($data['status']),
50 expires_at: isset($data['expires_at']) ? Carbon::parse($data['expires_at']) : null,
51 metadata: $data['metadata'] ?? [],
52 );
53 }
54
55 public function isExpiringSoon(int $days = 7): bool
56 {
57 return $this->expires_at &&
58 $this->expires_at->diffInDays(now()) <= $days;
59 }
60}
The power of this approach lies in prevention rather than detection. Type errors are caught at development time, not in production.
#Event-Driven Decoupling
One of the most elegant patterns for scaling Laravel applications is event-driven architecture. This approach decouples your core business logic from side effects, making your system more maintainable and testable.
1// Events
2class SubscriptionCreated
3{
4 use Dispatchable, SerializesModels;
5
6 public function __construct(public readonly Subscription $subscription) {}
7}
8
9class SubscriptionExpired
10{
11 use Dispatchable, SerializesModels;
12
13 public function __construct(public readonly Subscription $subscription) {}
14}
15
16// Listeners
17class SendWelcomeEmailListener implements ShouldQueue
18{
19 use InteractsWithQueue;
20
21 public function handle(SubscriptionCreated $event): void
22 {
23 $subscription = $event->subscription;
24
25 Mail::to($subscription->user)
26 ->queue(new SubscriptionWelcomeEmail($subscription));
27 }
28}
29
30class UpdateUserPermissionsListener
31{
32 public function handle(SubscriptionCreated $event): void
33 {
34 $user = $event->subscription->user;
35 $plan = $event->subscription->plan;
36
37 $user->syncPermissions($plan->permissions);
38 }
39}
40
41class LogSubscriptionMetricsListener
42{
43 public function handle(SubscriptionCreated $event): void
44 {
45 UpdateCompanyMetricsJob::dispatch('subscription_created');
46 }
47}
48
49// Event registration in AppServiceProvider
50public function boot(): void
51{
52 $eventsToListeners = [
53 SubscriptionCreated::class => [
54 SendWelcomeEmailListener::class,
55 UpdateUserPermissionsListener::class,
56 LogSubscriptionMetricsListener::class,
57 ],
58 SubscriptionExpired::class => [
59 RevokeUserPermissionsListener::class,
60 SendRenewalEmailListener::class,
61 ],
62 ];
63
64 foreach ($eventsToListeners as $event => $listeners) {
65 foreach ($listeners as $listener) {
66 Event::listen($event, $listener);
67 }
68 }
69}
70
71// Clean action usage
72class CreateSubscriptionAction extends BaseAction
73{
74 public function handle(SubscriptionDataDto $data): Subscription
75 {
76 $subscription = Subscription::create($data->toArray());
77
78 // Single line - all side effects handled by listeners
79 SubscriptionCreated::dispatch($subscription);
80
81 return $subscription;
82 }
83}
This pattern ensures that your core business logic remains focused while side effects are handled asynchronously and independently.
#Authorization Architecture That Scales
Complex applications require sophisticated authorization. Laravel's Gate system combined with helper functions creates a centralized, maintainable authorization layer:
1// Service Provider
2class AuthServiceProvider extends ServiceProvider
3{
4 public function boot(): void
5 {
6 Gate::define('manage-subscriptions', function (User $user): bool {
7 return $user->hasPermission('manage_subscriptions') || $user->isAdmin();
8 });
9
10 Gate::define('view-subscription', function (User $user, Subscription $subscription): bool {
11 return $user->id === $subscription->user_id ||
12 $user->hasPermission('view_all_subscriptions');
13 });
14
15 Gate::define('cancel-subscription', function (User $user, Subscription $subscription): bool {
16 if ($user->id !== $subscription->user_id) {
17 return false;
18 }
19
20 return $subscription->status === SubscriptionStatus::ACTIVE;
21 });
22 }
23}
24
25// Global helper functions
26function canManageSubscriptions(): bool
27{
28 return Gate::check('manage-subscriptions');
29}
30
31function canViewSubscription(Subscription $subscription): bool
32{
33 return Gate::check('view-subscription', $subscription);
34}
35
36function canCancelSubscription(Subscription $subscription): bool
37{
38 return Gate::check('cancel-subscription', $subscription);
39}
40
41// Form request authorization
42class UpdateSubscriptionRequest extends FormRequest
43{
44 public function authorize(): bool
45 {
46 return canManageSubscriptions() ||
47 (auth()->id() === $this->route('subscription')->user_id);
48 }
49
50 public function rules(): array
51 {
52 return [
53 'status' => ['required', Rule::in(SubscriptionStatus::all())],
54 'expires_at' => ['nullable', 'date', 'after:today'],
55 ];
56 }
57}
58
59// Middleware for route-level protection
60class SubscriptionAccessMiddleware
61{
62 public function handle(Request $request, Closure $next): Response
63 {
64 $subscription = $request->route('subscription');
65
66 if (!canViewSubscription($subscription)) {
67 abort(403, 'Access denied to this subscription');
68 }
69
70 return $next($request);
71 }
72}
This centralized approach prevents authorization logic from being scattered across controllers and makes security policies easy to audit and modify.
#Performance-First Queue Management
Background job processing is crucial for scalable applications. Implementing robust queue patterns ensures your application remains responsive under load:
1class ProcessSubscriptionPaymentJob implements ShouldQueue
2{
3 use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
4
5 public $tries = 3;
6 public $backoff = [60, 300, 900]; // Exponential backoff
7 public $timeout = 120;
8
9 public function __construct(
10 private readonly Subscription $subscription,
11 private readonly array $paymentData
12 ) {}
13
14 public function handle(): void
15 {
16 try {
17 $payment = PaymentService::charge(
18 $this->subscription->user,
19 $this->subscription->plan->price,
20 $this->paymentData
21 );
22
23 $this->subscription->update([
24 'status' => SubscriptionStatus::ACTIVE,
25 'last_payment_at' => now(),
26 ]);
27
28 Log::info('Subscription payment processed', [
29 'subscription_id' => $this->subscription->id,
30 'payment_id' => $payment->id,
31 ]);
32
33 } catch (PaymentFailedException $e) {
34 Log::error('Subscription payment failed', [
35 'subscription_id' => $this->subscription->id,
36 'error' => $e->getMessage(),
37 'attempt' => $this->attempts(),
38 ]);
39
40 throw $e; // Trigger retry
41 }
42 }
43
44 public function failed(Throwable $exception): void
45 {
46 $this->subscription->update([
47 'status' => SubscriptionStatus::CANCELLED,
48 'cancelled_reason' => 'Payment failed after retries',
49 ]);
50
51 Mail::to($this->subscription->user)
52 ->send(new PaymentFailedNotification($this->subscription));
53 }
54}
55
56// Batch processing for efficiency
57class SendRenewalRemindersJob implements ShouldQueue
58{
59 public function handle(): void
60 {
61 $expiringSubscriptions = Subscription::where('status', SubscriptionStatus::ACTIVE)
62 ->whereBetween('expires_at', [now()->addDays(7), now()->addDays(8)])
63 ->with('user')
64 ->get();
65
66 $jobs = $expiringSubscriptions->map(function ($subscription) {
67 return new SendRenewalReminderJob($subscription);
68 });
69
70 Bus::batch($jobs)
71 ->name('Renewal Reminders - ' . now()->format('Y-m-d'))
72 ->allowFailures()
73 ->onQueue('emails')
74 ->dispatch();
75 }
76}
77
78// Unique jobs to prevent duplicates
79class UpdateSubscriptionMetricsJob implements ShouldQueue, ShouldBeUnique
80{
81 use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
82
83 public $uniqueFor = 3600; // 1 hour
84
85 public function uniqueId(): string
86 {
87 return 'subscription-metrics-update';
88 }
89
90 public function handle(): void
91 {
92 $metrics = [
93 'total_active' => Subscription::where('status', SubscriptionStatus::ACTIVE)->count(),
94 'total_revenue' => Subscription::sum('total_paid'),
95 'churn_rate' => $this->calculateChurnRate(),
96 ];
97
98 cache()->put('subscription_metrics', $metrics, now()->addHour());
99 }
100
101 private function calculateChurnRate(): float
102 {
103 $totalCancelled = Subscription::where('status', SubscriptionStatus::CANCELLED)
104 ->whereMonth('cancelled_at', now()->month)
105 ->count();
106
107 $totalActive = Subscription::where('status', SubscriptionStatus::ACTIVE)->count();
108
109 return $totalActive > 0 ? ($totalCancelled / $totalActive) * 100 : 0;
110 }
111}
This queue architecture provides automatic retries, failure handling, and prevents duplicate processing while maintaining high throughput.
#API Design for Long-Term Maintenance
Consistent API responses and resource transformation patterns make your API a joy to consume and maintain:
1class SubscriptionResource extends JsonResource
2{
3 public function toArray(Request $request): array
4 {
5 return [
6 'id' => $this->id,
7 'status' => $this->status->value,
8 'status_label' => $this->status->getLabel(),
9 'plan' => new PlanResource($this->whenLoaded('plan')),
10 'user' => new UserResource($this->whenLoaded('user')),
11 'expires_at' => $this->expires_at?->toISOString(),
12 'created_at' => $this->created_at->toISOString(),
13 'updated_at' => $this->updated_at->toISOString(),
14
15 // Computed properties
16 'is_active' => $this->status === SubscriptionStatus::ACTIVE,
17 'is_expiring_soon' => $this->expires_at &&
18 $this->expires_at->diffInDays(now()) <= 7,
19 'days_remaining' => $this->expires_at?->diffInDays(now()),
20
21 // Conditional fields
22 'cancellation_reason' => $this->when(
23 $this->status === SubscriptionStatus::CANCELLED,
24 $this->cancellation_reason
25 ),
26
27 // Actions available to current user
28 'can_cancel' => $this->when(
29 auth()->check(),
30 fn() => canCancelSubscription($this->resource)
31 ),
32 'can_renew' => $this->when(
33 auth()->check(),
34 fn() => $this->status->canRenew()
35 ),
36 ];
37 }
38}
39
40// Consistent API response trait
41trait ApiResponses
42{
43 protected function successResponse($data = null, string $message = 'Success', int $code = 200): JsonResponse
44 {
45 return response()->json([
46 'success' => true,
47 'message' => $message,
48 'data' => $data,
49 'timestamp' => now()->toISOString(),
50 ], $code);
51 }
52
53 protected function errorResponse(string $message, int $code = 400, array $errors = []): JsonResponse
54 {
55 return response()->json([
56 'success' => false,
57 'message' => $message,
58 'errors' => $errors,
59 'timestamp' => now()->toISOString(),
60 ], $code);
61 }
62
63 protected function paginatedResponse($data, string $message = 'Success'): JsonResponse
64 {
65 return response()->json([
66 'success' => true,
67 'message' => $message,
68 'data' => $data->items(),
69 'pagination' => [
70 'current_page' => $data->currentPage(),
71 'per_page' => $data->perPage(),
72 'total' => $data->total(),
73 'last_page' => $data->lastPage(),
74 'has_more' => $data->hasMorePages(),
75 ],
76 'timestamp' => now()->toISOString(),
77 ]);
78 }
79}
80
81// Clean controller implementation
82class SubscriptionController extends Controller
83{
84 use ApiResponses;
85
86 public function index(IndexSubscriptionRequest $request)
87 {
88 $subscriptions = Subscription::with(['user', 'plan'])
89 ->whereFilterByQueryParams()
90 ->paginate();
91
92 return $this->paginatedResponse(
93 SubscriptionResource::collection($subscriptions)
94 );
95 }
96
97 public function store(StoreSubscriptionRequest $request, CreateSubscriptionAction $action)
98 {
99 try {
100 $dto = SubscriptionDataDto::fromArray($request->validated());
101 $subscription = $action->handle($dto);
102
103 return $this->successResponse(
104 new SubscriptionResource($subscription->load(['user', 'plan'])),
105 'Subscription created successfully',
106 201
107 );
108 } catch (PaymentFailedException $e) {
109 return $this->errorResponse('Payment failed: ' . $e->getMessage(), 402);
110 }
111 }
112}
This resource pattern provides consistency, security, and flexibility while making API responses self-documenting.
#Smart Caching Strategies
Intelligent caching can transform application performance. Here's a modern approach that doesn't rely on deprecated cache tags:
1trait HasModelCache
2{
3 public static function bootHasModelCache()
4 {
5 static::saved(function ($model) {
6 $model->clearModelCache();
7 });
8
9 static::deleted(function ($model) {
10 $model->clearModelCache();
11 });
12 }
13
14 public function cacheKey(string $suffix = ''): string
15 {
16 return sprintf('%s:%s%s',
17 class_basename($this),
18 $this->getKey(),
19 $suffix ? ":{$suffix}" : ''
20 );
21 }
22
23 public function rememberForever(string $key, callable $callback)
24 {
25 return cache()->rememberForever($this->cacheKey($key), $callback);
26 }
27
28 public function remember(string $key, int $ttl, callable $callback)
29 {
30 return cache()->remember($this->cacheKey($key), $ttl, $callback);
31 }
32
33 protected function getRelatedCacheKeys(): array
34 {
35 return [
36 class_basename($this) . ':*',
37 $this->cacheKey() . ':*',
38 ];
39 }
40
41 public function clearModelCache(): void
42 {
43 // Clear specific model caches
44 $keys = $this->getRelatedCacheKeys();
45
46 foreach ($keys as $pattern) {
47 $this->clearCacheByPattern($pattern);
48 }
49
50 // Clear class-level caches
51 $this->clearClassCache();
52 }
53
54 private function clearCacheByPattern(string $pattern): void
55 {
56 if (method_exists(cache()->getStore(), 'flush')) {
57 // For cache stores that support pattern-based clearing
58 $cacheKeys = cache()->getStore()->getAllKeys();
59 $keysToDelete = array_filter($cacheKeys, function ($key) use ($pattern) {
60 return fnmatch($pattern, $key);
61 });
62
63 foreach ($keysToDelete as $key) {
64 cache()->forget($key);
65 }
66 }
67 }
68
69 private function clearClassCache(): void
70 {
71 // Clear common class-level caches
72 cache()->forget(class_basename($this) . ':count');
73 cache()->forget(class_basename($this) . ':metrics');
74 }
75}
76
77// Usage in models
78class Subscription extends Model
79{
80 use HasModelCache;
81
82 public function getActiveSubscriptionsCount(): int
83 {
84 return $this->rememberForever('active_count', function () {
85 return self::where('status', SubscriptionStatus::ACTIVE)->count();
86 });
87 }
88
89 public function getRevenueMetrics(): array
90 {
91 return cache()->remember('subscription:revenue_metrics', 3600, function () {
92 return [
93 'total_revenue' => self::sum('total_paid'),
94 'monthly_revenue' => self::whereMonth('created_at', now()->month)->sum('total_paid'),
95 'average_subscription_value' => self::avg('total_paid'),
96 ];
97 });
98 }
99}
100
101// Centralized cache management service
102class CacheService
103{
104 public static function clearSubscriptionCaches(): void
105 {
106 $patterns = [
107 'subscription:*',
108 'Subscription:*',
109 'user_subscriptions:*',
110 'popular_plans',
111 'subscription_metrics',
112 ];
113
114 foreach ($patterns as $pattern) {
115 self::forgetByPattern($pattern);
116 }
117 }
118
119 public static function clearUserCaches(int $userId): void
120 {
121 $patterns = [
122 "user:{$userId}:*",
123 "User:{$userId}:*",
124 "user_subscriptions:{$userId}",
125 ];
126
127 foreach ($patterns as $pattern) {
128 self::forgetByPattern($pattern);
129 }
130 }
131
132 private static function forgetByPattern(string $pattern): void
133 {
134 if (str_contains($pattern, '*')) {
135 // Handle wildcard patterns
136 if (config('cache.default') === 'redis') {
137 // Redis supports pattern deletion
138 $keys = Redis::keys($pattern);
139 if (!empty($keys)) {
140 Redis::del($keys);
141 }
142 }
143 } else {
144 // Direct key deletion
145 cache()->forget($pattern);
146 }
147 }
148}
149
150// Query result caching with automatic invalidation
151class SubscriptionService
152{
153 public function getPopularPlans(int $limit = 10): Collection
154 {
155 return cache()->remember('popular_plans', 1800, function () use ($limit) {
156 return Plan::withCount('subscriptions')
157 ->orderBy('subscriptions_count', 'desc')
158 ->limit($limit)
159 ->get();
160 });
161 }
162
163 public function getUserSubscriptionHistory(User $user): Collection
164 {
165 return cache()->remember("user_subscriptions:{$user->id}", 600, function () use ($user) {
166 return $user->subscriptions()
167 ->with('plan')
168 ->orderBy('created_at', 'desc')
169 ->get();
170 });
171 }
172
173 public function getSubscriptionMetrics(): array
174 {
175 return cache()->remember('subscription_metrics', 3600, function () {
176 return [
177 'total_active' => Subscription::where('status', SubscriptionStatus::ACTIVE)->count(),
178 'total_revenue' => Subscription::sum('total_paid'),
179 'monthly_growth' => $this->calculateMonthlyGrowth(),
180 'churn_rate' => $this->calculateChurnRate(),
181 ];
182 });
183 }
184
185 private function calculateMonthlyGrowth(): float
186 {
187 $thisMonth = Subscription::whereMonth('created_at', now()->month)->count();
188 $lastMonth = Subscription::whereMonth('created_at', now()->subMonth()->month)->count();
189
190 return $lastMonth > 0 ? (($thisMonth - $lastMonth) / $lastMonth) * 100 : 0;
191 }
192
193 private function calculateChurnRate(): float
194 {
195 $totalCancelled = Subscription::where('status', SubscriptionStatus::CANCELLED)
196 ->whereMonth('cancelled_at', now()->month)
197 ->count();
198
199 $totalActive = Subscription::where('status', SubscriptionStatus::ACTIVE)->count();
200
201 return $totalActive > 0 ? ($totalCancelled / $totalActive) * 100 : 0;
202 }
203}
This modern caching strategy provides organized cache management without relying on deprecated cache tags, while still maintaining automatic invalidation and performance benefits.
#Testing Patterns for Confidence
Comprehensive testing patterns ensure your scalable architecture remains reliable as it grows:
1// Feature test for subscription creation
2class CreateSubscriptionTest extends TestCase
3{
4 use RefreshDatabase;
5
6 public function test_user_can_create_subscription_with_valid_payment()
7 {
8 Event::fake([SubscriptionCreated::class]);
9 Queue::fake();
10
11 $user = User::factory()->create();
12 $plan = Plan::factory()->create(['price' => 2999]);
13
14 $response = $this->actingAs($user)
15 ->postJson(route('subscriptions.store'), [
16 'plan_id' => $plan->id,
17 'payment_method' => 'credit_card',
18 'payment_token' => 'valid_token_123',
19 ]);
20
21 $response->assertStatus(201)
22 ->assertJsonStructure([
23 'success',
24 'message',
25 'data' => [
26 'id',
27 'status',
28 'plan',
29 'expires_at',
30 'can_cancel',
31 ]
32 ]);
33
34 $this->assertDatabaseHas('subscriptions', [
35 'user_id' => $user->id,
36 'plan_id' => $plan->id,
37 'status' => SubscriptionStatus::ACTIVE->value,
38 ]);
39
40 Event::assertDispatched(SubscriptionCreated::class, function ($event) use ($user) {
41 return $event->subscription->user_id === $user->id;
42 });
43 }
44
45 public function test_subscription_creation_fails_with_invalid_payment()
46 {
47 $user = User::factory()->create();
48 $plan = Plan::factory()->create();
49
50 $response = $this->actingAs($user)
51 ->postJson(route('subscriptions.store'), [
52 'plan_id' => $plan->id,
53 'payment_method' => 'credit_card',
54 'payment_token' => 'invalid_token',
55 ]);
56
57 $response->assertStatus(402)
58 ->assertJson([
59 'success' => false,
60 'message' => 'Payment failed: Invalid payment token',
61 ]);
62
63 $this->assertDatabaseMissing('subscriptions', [
64 'user_id' => $user->id,
65 'plan_id' => $plan->id,
66 ]);
67 }
68}
69
70// Unit test for business logic
71class CreateSubscriptionActionTest extends TestCase
72{
73 use RefreshDatabase;
74
75 public function test_creates_subscription_with_correct_expiration()
76 {
77 $user = User::factory()->create();
78 $plan = Plan::factory()->create(['duration_days' => 30]);
79
80 $dto = new SubscriptionDataDto(
81 user_id: $user->id,
82 plan_id: $plan->id,
83 status: SubscriptionStatus::ACTIVE,
84 expires_at: now()->addDays(30),
85 );
86
87 $action = CreateSubscriptionAction::instance();
88 $subscription = $action->handle($dto);
89
90 $this->assertInstanceOf(Subscription::class, $subscription);
91 $this->assertEquals($user->id, $subscription->user_id);
92 $this->assertEquals($plan->id, $subscription->plan_id);
93 $this->assertEquals(SubscriptionStatus::ACTIVE, $subscription->status);
94 $this->assertTrue($subscription->expires_at->isSameDay(now()->addDays(30)));
95 }
96}
97
98// Test factory with states
99class SubscriptionFactory extends Factory
100{
101 public function definition(): array
102 {
103 return [
104 'user_id' => User::factory(),
105 'plan_id' => Plan::factory(),
106 'status' => SubscriptionStatus::ACTIVE->value,
107 'expires_at' => now()->addDays(30),
108 'created_at' => now(),
109 ];
110 }
111
112 public function expired(): static
113 {
114 return $this->state([
115 'status' => SubscriptionStatus::EXPIRED->value,
116 'expires_at' => now()->subDays(1),
117 ]);
118 }
119
120 public function cancelled(): static
121 {
122 return $this->state([
123 'status' => SubscriptionStatus::CANCELLED->value,
124 'cancelled_at' => now(),
125 'cancellation_reason' => 'User requested cancellation',
126 ]);
127 }
128
129 public function expiringSoon(): static
130 {
131 return $this->state([
132 'expires_at' => now()->addDays(3),
133 ]);
134 }
135}
136
137// Architecture testing with Pest
138arch('Actions should extend BaseAction')
139 ->expect('App\Actions')
140 ->toExtend('App\Actions\Contracts\BaseAction');
141
142arch('DTOs should extend BaseDto')
143 ->expect('App\DTO')
144 ->toExtend('App\DTO\BaseDto');
145
146arch('No debugging functions in production code')
147 ->expect(['dd', 'dump', 'var_dump'])
148 ->not->toBeUsed();
These testing patterns provide confidence in your architecture while making it easy to verify that changes don't break existing functionality.
#Real-World Implementation Patterns
#Progressive Enhancement Strategy
When implementing these patterns in existing applications, consider a progressive approach:
1// Phase 1: Start with Actions for new features
2class ModernFeatureController extends Controller
3{
4 public function store(Request $request, CreateFeatureAction $action)
5 {
6 // New features use actions immediately
7 $feature = $action->handle($request->validated());
8 return new FeatureResource($feature);
9 }
10}
11
12// Phase 2: Gradually refactor existing controllers
13class LegacyFeatureController extends Controller
14{
15 public function update(Request $request, Feature $feature)
16 {
17 // Gradually extract to actions
18 $action = UpdateFeatureAction::instance();
19 $updatedFeature = $action->handle($feature, $request->validated());
20
21 return new FeatureResource($updatedFeature);
22 }
23}
24
25// Phase 3: Implement consistent patterns across the application
26trait HasModernPatterns
27{
28 protected function executeAction(string $actionClass, ...$parameters)
29 {
30 return app($actionClass)->handle(...$parameters);
31 }
32}
#Environment-Specific Optimizations
1// config/cache.php - Environment-aware cache configuration
2return [
3 'subscription_metrics_ttl' => env('APP_ENV') === 'production' ? 3600 : 60,
4 'user_data_ttl' => env('APP_ENV') === 'production' ? 1800 : 30,
5 'query_cache_enabled' => env('QUERY_CACHE_ENABLED', env('APP_ENV') === 'production'),
6];
7
8// Conditional performance optimizations
9class PerformanceOptimizedService
10{
11 public function getData(): array
12 {
13 if (app()->environment('production')) {
14 return cache()->remember('expensive_data', 3600, fn() => $this->calculateExpensiveData());
15 }
16
17 // Skip caching in development for easier debugging
18 return $this->calculateExpensiveData();
19 }
20}
#Monitoring and Observability
1// Performance monitoring helper
2function monitorPerformance(string $operation, callable $callback)
3{
4 $startTime = microtime(true);
5 $startMemory = memory_get_usage(true);
6
7 $result = $callback();
8
9 $executionTime = (microtime(true) - $startTime) * 1000;
10 $memoryUsed = memory_get_usage(true) - $startMemory;
11
12 Log::info("Performance: {$operation}", [
13 'execution_time_ms' => round($executionTime, 2),
14 'memory_used_mb' => round($memoryUsed / 1024 / 1024, 2),
15 ]);
16
17 return $result;
18}
19
20// Usage in actions
21class ExpensiveCalculationAction extends BaseAction
22{
23 public function handle(array $data): array
24 {
25 return monitorPerformance('expensive_calculation', function () use ($data) {
26 // Complex calculation logic
27 return $this->performCalculation($data);
28 });
29 }
30}
#Key Strengths and Benefits
#Developer Experience Excellence
Type Safety Everywhere: Enums, DTOs, and strict typing prevent entire categories of bugs before they reach production.
Predictable Patterns: Once developers learn the action pattern, they can navigate any part of the application with confidence.
Testing Confidence: Every business operation is easily testable in isolation.
#Performance Optimization
Intelligent Caching: Model-aware cache invalidation ensures data freshness while maximizing performance.
Queue-Based Architecture: Background processing keeps the application responsive under heavy load.
Optimized Queries: Pipeline filters ensure only necessary query constraints are applied.
#Scalability and Maintenance
Separation of Concerns: Each pattern addresses a specific architectural concern without overlap.
Event-Driven Design: Loose coupling between components makes the system easy to extend and modify.
Consistent Standards: Patterns provide guidelines that prevent architectural drift as teams grow.
#Final Thoughts
Building scalable Laravel applications isn't about using every advanced feature—it's about choosing the right patterns for your specific needs and implementing them consistently. The patterns discussed here have been battle-tested in production environments serving thousands of users and processing millions of requests.
The key insight is that great architecture emerges from simple, well-implemented patterns rather than complex, over-engineered solutions. Start with the patterns that solve your immediate pain points: perhaps Actions for business logic organization, or Pipeline Filters for complex queries. Then gradually incorporate other patterns as your application's complexity grows.
What makes these Laravel patterns exceptional is their respect for both developer productivity and application performance, proving that you don't have to sacrifice one for the other. Whether you're building your first scalable application or refactoring an existing monolith, these architectural decisions provide a solid foundation for sustainable growth.
The difference between applications that scale gracefully and those that become maintenance nightmares lies in these foundational choices. Choose patterns that grow with your team and your user base, and your future self will thank you.
Ready to implement these patterns in your Laravel application? Start small, be consistent, and let these battle-tested approaches guide your architectural decisions toward a more maintainable and scalable codebase.