Design Patterns in Laravel: The Ones You Use Daily (Whether You Know It or Not)

Every senior Laravel developer uses design patterns daily - most of them without naming them. The framework itself is built on a handful of classic patterns applied consistently and elegantly. Understanding which patterns Laravel uses internally, why it uses them, and which ones you should deliberately apply in your own code separates developers who write maintainable applications from developers who write Laravel spaghetti.

📋 Table of Contents


🏭 Patterns Laravel Uses Internally

Factory

The Factory pattern's job is to encapsulate object creation so the caller does not need to know how an object is built. Laravel uses it in two distinct ways.

Model factories for testing:

<?php
// database/factories/OrderFactory.php

declare(strict_types=1);

namespace Database\Factories;

use App\Models\Order;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;

class OrderFactory extends Factory
{
    protected $model = Order::class;

    public function definition(): array
    {
        return [
            'user_id'         => User::factory(),
            'total_cents'     => $this->faker->numberBetween(1000, 100000),
            'status'          => 'pending',
            'shipping_method' => $this->faker->randomElement(['standard', 'express']),
        ];
    }

    public function completed(): static
    {
        return $this->state(['status' => 'completed']);
    }

    public function withHighValue(): static
    {
        return $this->state(['total_cents' => $this->faker->numberBetween(100000, 1000000)]);
    }
}
<?php
// tests/Feature/Orders/OrderReportTest.php

declare(strict_types=1);

namespace Tests\Feature\Orders;

use App\Models\Order;
use Tests\TestCase;

class OrderReportTest extends TestCase
{
    public function test_high_value_orders_appear_in_report(): void
    {
        Order::factory()->count(3)->withHighValue()->completed()->create();
        Order::factory()->count(5)->create(); // normal orders

        $response = $this->getJson('/api/v1/reports/high-value-orders');

        $response->assertOk()->assertJsonCount(3, 'data');
    }
}

The factory fluent API (state() chaining) is the Builder pattern layered on top of Factory - a pattern inside a pattern.

Driver-based factory (Manager pattern): Cache::driver('redis'), Mail::mailer('ses'), Queue::connection('sqs') - these all use a Manager that calls factory methods internally to produce configured driver instances. Adding a custom driver is as simple as:

<?php
// app/Providers/AppServiceProvider.php

declare(strict_types=1);

namespace App\Providers;

use Illuminate\Support\Facades\Cache;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Cache::extend('dynamodb', function ($app) {
            return Cache::repository(new \App\Cache\DynamoDbStore(
                $app->make(\Aws\DynamoDb\DynamoDbClient::class)
            ));
        });
    }
}

Facade

Facades are widely misunderstood as static classes. They are not. A Facade is a proxy that delegates static calls to a concrete instance resolved from the service container. This distinction matters for testing and architecture.

<?php
// Illuminate/Support/Facades/Cache.php (simplified concept)

declare(strict_types=1);

namespace Illuminate\Support\Facades;

/**
 * @method static mixed get(string $key, mixed $default = null)
 * @method static bool put(string $key, mixed $value, int $seconds)
 */
class Cache extends Facade
{
    protected static function getFacadeAccessor(): string
    {
        // This string is the container binding key
        return 'cache';
    }
}

When you call Cache::get('key'), PHP's __callStatic magic is invoked, which resolves app('cache') from the container and calls ->get('key') on it. The underlying instance is Illuminate\Cache\CacheManager.

Why does this matter? Because Cache::fake() swaps the container binding for a fake implementation. If Facades were truly static classes, faking them would be impossible without monkey-patching.

You can build your own Facade for a service you want accessible globally without passing it through every constructor:

<?php
// app/Facades/PricingEngine.php

declare(strict_types=1);

namespace App\Facades;

use Illuminate\Support\Facades\Facade;

class PricingEngine extends Facade
{
    protected static function getFacadeAccessor(): string
    {
        return \App\Services\PricingEngine::class;
    }
}

Use this sparingly - Facades hide dependencies, making constructors less honest about what a class truly needs.

Pipeline

The Pipeline pattern passes an object through a series of transformations, where each stage can modify the object or short-circuit the chain. It is the foundation of Laravel's HTTP middleware system.

<?php
// app/Http/Controllers/Api/ReportController.php

declare(strict_types=1);

namespace App\Http\Controllers\Api;

use App\Pipelines\Reports\AddFilters;
use App\Pipelines\Reports\AddSorting;
use App\Pipelines\Reports\ApplyDateRange;
use App\Pipelines\Reports\FormatAsCurrency;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Pipeline\Pipeline;

class ReportController extends Controller
{
    public function index(Request $request, Pipeline $pipeline): JsonResponse
    {
        $result = $pipeline
            ->send($request->all())
            ->through([
                ApplyDateRange::class,
                AddFilters::class,
                AddSorting::class,
                FormatAsCurrency::class,
            ])
            ->thenReturn();

        return response()->json(['data' => $result]);
    }
}
<?php
// app/Pipelines/Reports/ApplyDateRange.php

declare(strict_types=1);

namespace App\Pipelines\Reports;

use Closure;

class ApplyDateRange
{
    public function handle(array $payload, Closure $next): array
    {
        if (isset($payload['from'], $payload['to'])) {
            $payload['date_range'] = [
                'from' => now()->parse($payload['from'])->startOfDay(),
                'to'   => now()->parse($payload['to'])->endOfDay(),
            ];
        }

        return $next($payload);
    }
}

Each pipe is a small, single-purpose class. You can reorder, add, or remove stages without touching any other stage. This is OCP and SRP enforced by the pattern.

Observer

Eloquent model events implement the Observer pattern: when a model state changes, registered observers are notified automatically.

<?php
// app/Observers/OrderObserver.php

declare(strict_types=1);

namespace App\Observers;

use App\Events\OrderCompleted;
use App\Models\Order;
use Illuminate\Support\Facades\Event;

class OrderObserver
{
    public function created(Order $order): void
    {
        // Send confirmation, reserve inventory, etc.
        \App\Jobs\SendOrderConfirmationJob::dispatch($order);
    }

    public function updated(Order $order): void
    {
        if ($order->wasChanged('status') && $order->status === 'completed') {
            Event::dispatch(new OrderCompleted($order));
        }
    }

    public function deleting(Order $order): void
    {
        // Prevent deletion of completed orders
        if ($order->status === 'completed') {
            throw new \DomainException('Cannot delete a completed order.');
        }
    }
}
<?php
// app/Providers/AppServiceProvider.php

declare(strict_types=1);

namespace App\Providers;

use App\Models\Order;
use App\Observers\OrderObserver;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Order::observe(OrderObserver::class);
    }
}

Observers decouple side effects from model operations. The Order model does not know about email notifications or inventory - it just saves. The Observer reacts.

Strategy

Laravel's driver-based subsystems are Strategy in action. Cache, queue, filesystem, session, mail - each has a defined interface, and multiple concrete implementations (strategies) are swappable by configuration. When you call Storage::disk('s3')->put(...), the FilesystemManager resolves the S3 strategy. Switch to local in testing - zero code changes in your application logic.


🔨 Patterns You Should Use in Your Code

Strategy - Payment Methods

The Strategy pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable.

<?php
// app/Contracts/PaymentStrategy.php

declare(strict_types=1);

namespace App\Contracts;

use App\ValueObjects\ChargeResult;

interface PaymentStrategy
{
    public function charge(int $amountCents, string $currency): ChargeResult;

    public function supports(string $method): bool;
}
<?php
// app/Services/Payments/StripeStrategy.php

declare(strict_types=1);

namespace App\Services\Payments;

use App\Contracts\PaymentStrategy;
use App\ValueObjects\ChargeResult;
use Stripe\StripeClient;

class StripeStrategy implements PaymentStrategy
{
    public function __construct(private readonly StripeClient $client) {}

    public function charge(int $amountCents, string $currency): ChargeResult
    {
        $intent = $this->client->paymentIntents->create([
            'amount'   => $amountCents,
            'currency' => $currency,
        ]);

        return new ChargeResult($intent->id, $intent->status, $intent->status === 'succeeded');
    }

    public function supports(string $method): bool
    {
        return $method === 'stripe';
    }
}
<?php
// app/Services/Payments/PayPalStrategy.php

declare(strict_types=1);

namespace App\Services\Payments;

use App\Contracts\PaymentStrategy;
use App\ValueObjects\ChargeResult;

class PayPalStrategy implements PaymentStrategy
{
    public function charge(int $amountCents, string $currency): ChargeResult
    {
        // PayPal SDK call
        return new ChargeResult('paypal_' . uniqid(), 'completed', true);
    }

    public function supports(string $method): bool
    {
        return $method === 'paypal';
    }
}
<?php
// app/Services/PaymentContext.php

declare(strict_types=1);

namespace App\Services;

use App\Contracts\PaymentStrategy;
use App\ValueObjects\ChargeResult;
use InvalidArgumentException;

class PaymentContext
{
    /** @param PaymentStrategy[] $strategies */
    public function __construct(private readonly array $strategies) {}

    public function charge(string $method, int $amountCents, string $currency): ChargeResult
    {
        $strategy = $this->findStrategy($method);

        return $strategy->charge($amountCents, $currency);
    }

    private function findStrategy(string $method): PaymentStrategy
    {
        foreach ($this->strategies as $strategy) {
            if ($strategy->supports($method)) {
                return $strategy;
            }
        }

        throw new InvalidArgumentException("No payment strategy found for method: {$method}");
    }
}
<?php
// app/Providers/PaymentServiceProvider.php

declare(strict_types=1);

namespace App\Providers;

use App\Services\PaymentContext;
use App\Services\Payments\PayPalStrategy;
use App\Services\Payments\StripeStrategy;
use Illuminate\Support\ServiceProvider;
use Stripe\StripeClient;

class PaymentServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->singleton(PaymentContext::class, function (): PaymentContext {
            return new PaymentContext([
                new StripeStrategy(new StripeClient(config('services.stripe.secret'))),
                new PayPalStrategy(),
            ]);
        });
    }
}

Decorator - Response Transformation

The Decorator pattern adds behaviour to an object dynamically by wrapping it, without changing the wrapped object's class.

<?php
// app/Contracts/QueryExecutor.php

declare(strict_types=1);

namespace App\Contracts;

use Illuminate\Database\Eloquent\Collection;

interface QueryExecutor
{
    public function execute(array $filters): Collection;
}
<?php
// app/Repositories/ProductQueryExecutor.php

declare(strict_types=1);

namespace App\Repositories;

use App\Contracts\QueryExecutor;
use App\Models\Product;
use Illuminate\Database\Eloquent\Collection;

class ProductQueryExecutor implements QueryExecutor
{
    public function execute(array $filters): Collection
    {
        return Product::query()
            ->when($filters['category'] ?? null, fn ($q, $cat) => $q->where('category_id', $cat))
            ->get();
    }
}
<?php
// app/Decorators/CachedQueryExecutor.php

declare(strict_types=1);

namespace App\Decorators;

use App\Contracts\QueryExecutor;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Cache;

class CachedQueryExecutor implements QueryExecutor
{
    public function __construct(
        private readonly QueryExecutor $inner,
        private readonly int $ttlSeconds = 300,
    ) {}

    public function execute(array $filters): Collection
    {
        $key = 'query_' . md5(serialize($filters));

        return Cache::remember($key, $this->ttlSeconds, fn () => $this->inner->execute($filters));
    }
}
<?php
// app/Providers/AppServiceProvider.php

declare(strict_types=1);

namespace App\Providers;

use App\Contracts\QueryExecutor;
use App\Decorators\CachedQueryExecutor;
use App\Repositories\ProductQueryExecutor;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind(QueryExecutor::class, function (): CachedQueryExecutor {
            return new CachedQueryExecutor(
                new ProductQueryExecutor(),
                ttlSeconds: 300,
            );
        });
    }
}

In tests you bind ProductQueryExecutor directly, bypassing the cache. In production the cache wraps it transparently. Neither class was modified.

Builder - Complex Object Construction

The Builder pattern separates the construction of a complex object from its representation, so the same construction process can produce different results.

<?php
// app/Builders/InvoiceBuilder.php

declare(strict_types=1);

namespace App\Builders;

use App\DataTransferObjects\InvoiceData;
use App\DataTransferObjects\InvoiceLineItem;
use App\Models\Order;
use App\Models\User;
use Carbon\Carbon;

class InvoiceBuilder
{
    private User    $customer;
    private array   $lineItems  = [];
    private ?string $notes      = null;
    private Carbon  $issueDate;
    private int     $dueDays    = 14;
    private bool    $vatExempt  = false;

    public function __construct()
    {
        $this->issueDate = now();
    }

    public function for(User $customer): static
    {
        $this->customer = $customer;

        return $this;
    }

    public function fromOrder(Order $order): static
    {
        foreach ($order->items as $item) {
            $this->lineItems[] = new InvoiceLineItem(
                description: $item->product->name,
                quantity:    $item->quantity,
                unitPrice:   $item->price_cents,
            );
        }

        return $this;
    }

    public function withNote(string $notes): static
    {
        $this->notes = $notes;

        return $this;
    }

    public function netTerms(int $days): static
    {
        $this->dueDays = $days;

        return $this;
    }

    public function vatExempt(): static
    {
        $this->vatExempt = true;

        return $this;
    }

    public function build(): InvoiceData
    {
        return new InvoiceData(
            customer:   $this->customer,
            lineItems:  $this->lineItems,
            issueDate:  $this->issueDate,
            dueDate:    $this->issueDate->copy()->addDays($this->dueDays),
            notes:      $this->notes,
            vatExempt:  $this->vatExempt,
        );
    }
}
<?php
// Usage in an action

declare(strict_types=1);

$invoice = (new InvoiceBuilder())
    ->for($user)
    ->fromOrder($order)
    ->withNote('Thank you for your business.')
    ->netTerms(30)
    ->vatExempt()
    ->build();

Eloquent's query builder follows exactly this pattern: User::where()->with()->orderBy()->paginate(). The Builder accumulates constraints and only executes SQL when you call a terminal method.

Null Object - Eliminating Defensive Null Checks

The Null Object pattern provides a default object that does nothing instead of a null reference that causes TypeError or forces defensive checks everywhere.

<?php
// app/Models/UserProfile.php

declare(strict_types=1);

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class UserProfile extends Model
{
    protected $fillable = ['bio', 'avatar_url', 'website'];
}
<?php
// Without Null Object - defensive checks everywhere:

if ($user->profile !== null && $user->profile->avatar_url !== null) {
    $avatar = $user->profile->avatar_url;
} else {
    $avatar = '/images/default-avatar.png';
}
<?php
// app/Models/NullUserProfile.php

declare(strict_types=1);

namespace App\Models;

class NullUserProfile extends UserProfile
{
    public string $bio        = '';
    public string $avatar_url = '/images/default-avatar.png';
    public string $website    = '';

    public function save(array $options = []): bool
    {
        // Null objects must not persist
        return true;
    }
}
<?php
// app/Models/User.php (relevant excerpt)

declare(strict_types=1);

namespace App\Models;

use Illuminate\Foundation\Auth\User as Authenticatable;

class User extends Authenticatable
{
    public function profile(): \Illuminate\Database\Eloquent\Relations\HasOne
    {
        return $this->hasOne(UserProfile::class);
    }

    public function getProfileAttribute(): UserProfile
    {
        return $this->relations['profile'] ?? new NullUserProfile();
    }
}
<?php
// Now clean code everywhere - no defensive checks:

$avatar = $user->profile->avatar_url; // Always safe, returns default if no profile
$bio    = $user->profile->bio;        // Empty string, not null

🚫 Patterns Worth Not Overusing

Repository pattern as Eloquent wrapper. The most cargo-culted pattern in Laravel. When your UserRepository is just User::find($id), User::create($data), User::where('email', $email)->first() - you are adding a layer of indirection with zero benefit. Eloquent is already your persistence abstraction. Repository makes sense when you have genuine domain complexity, multiple data sources, or need in-memory test doubles. Without those pressures, it is ceremony.

Singleton with global mutable state. PHP-FPM creates a new process per request, so singletons are scoped to a single request. In Octane (persistent processes), a singleton that accumulates state across requests is a concurrency bug waiting to happen. Singletons should be stateless services, not state bags.

Event-listener chains for sequential logic. Firing an event from a listener which fires another event from its listener creates an invisible call chain that is impossible to trace in a debugger and almost impossible to test in isolation. Use an orchestrating Action or a Pipeline for sequential operations. Events are for broadcasting a fact - "something happened" - not for chaining logic.

Abstract Factory for one product. Building an AbstractFactory with createButton(), createInput(), createModal() for a system that will have exactly one UI theme is over-engineering. Start with concrete classes. Introduce the abstraction when the second variant appears.


  • Laravel itself is a design-pattern textbook: Factory, Facade, Pipeline, Observer, and Strategy are baked into the framework
  • Facades are not static classes - they are container-resolved proxies, which is why Facade::fake() works
  • Strategy and Decorator are the two most practically useful patterns for Laravel application code
  • The Null Object pattern eliminates entire categories of null-check bugs and makes code more readable
  • Builder pattern shines for constructing complex data objects with optional fields and variant configurations
  • Repository as a thin Eloquent wrapper is ceremony, not architecture - add it only when the complexity justifies it
  • Singletons in Octane must be stateless; events are for broadcasting facts, not chaining business logic

Follow me on LinkedIn for more Laravel tips!

Comments (0)
Leave a comment

© 2026 All rights reserved.