13 min read

Mastering Design Patterns in Laravel

A practical guide to the design patterns that actually matter in Laravel, with modern PHP 8.3+ examples and honest opinions on when to use each one.

Mastering Design Patterns in Laravel

Here's a hot take that might ruffle some feathers: most design patterns articles for Laravel are useless. They explain what each pattern is (thanks, Wikipedia), show a textbook example, and leave you wondering when you'd actually use it. The answer to "should I use the Repository pattern?" is always "it depends," and nobody tells you what it depends on.

I've been shipping Laravel applications for over nine years. In that time, I've over-engineered projects with patterns they didn't need, and I've also watched "simple" codebases collapse under their own weight because they had no structure at all. Both extremes hurt.

This guide is different. I'll walk through the patterns I actually use in production Laravel applications, with modern PHP 8.3+ code, and give you a clear opinion on when each pattern earns its place and when it's over-engineering. Every example uses constructor promotion, readonly properties, typed returns, and match expressions. If your codebase still looks like PHP 7.4, this is also a nudge to modernize.

If you've read the companion piece on SOLID principles in Laravel, you already know the theory. This post is about the practical application.

Repository Pattern

The Repository pattern puts a layer between your controllers and your Eloquent models. Instead of calling User::where(...) directly in a controller, you call $this->users->findActive(). The interface abstracts the data access, so your business logic doesn't care whether data comes from Eloquent, an API, or a CSV file.

// App\Repositories\UserRepositoryInterface.php
interface UserRepositoryInterface
{
    public function findActive(): Collection;
    public function findById(int $id): User;
    public function create(array $data): User;
}

// App\Repositories\EloquentUserRepository.php
class EloquentUserRepository implements UserRepositoryInterface
{
    public function findActive(): Collection
    {
        return User::where('active', true)
            ->orderBy('name')
            ->get();
    }

    public function findById(int $id): User
    {
        return User::findOrFail($id);
    }

    public function create(array $data): User
    {
        return User::create($data);
    }
}

Bind it in a service provider (Laravel 12 auto-discovers providers in app/Providers):

// App\Providers\AppServiceProvider.php
public function register(): void
{
    $this->app->bind(
        UserRepositoryInterface::class,
        EloquentUserRepository::class
    );
}

My opinion: The Repository pattern is worth it when you're building something that might switch data sources, or when your queries are complex enough that repeating them across controllers creates maintenance headaches. For a simple CRUD app with five models? Skip it. You're wrapping Eloquent in a class that does the same thing Eloquent already does. I use repositories in about 30% of my projects, usually when building SaaS applications where data access patterns get complex fast.

Strategy Pattern

This is the one I reach for most often. The Strategy pattern lets you swap algorithms at runtime by coding against an interface rather than a concrete class. Payment gateways are the classic example, but I've used it for notification channels, export formats, pricing calculators, and shipping providers.

// App\Contracts\PaymentGateway.php
interface PaymentGateway
{
    public function charge(int $amount, string $currency): PaymentResult;
    public function refund(string $transactionId): bool;
}

// App\Services\Payments\StripeGateway.php
class StripeGateway implements PaymentGateway
{
    public function __construct(
        private readonly StripeClient $client,
    ) {}

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

        return new PaymentResult(
            success: $intent->status === 'succeeded',
            transactionId: $intent->id,
        );
    }

    public function refund(string $transactionId): bool
    {
        // Stripe refund logic
    }
}

The controller doesn't know or care which gateway is active:

class CheckoutController extends Controller
{
    public function __construct(
        private readonly PaymentGateway $gateway,
    ) {}

    public function store(CheckoutRequest $request): JsonResponse
    {
        $result = $this->gateway->charge(
            amount: $request->integer('amount'),
            currency: 'eur',
        );

        return response()->json($result);
    }
}

My opinion: Use this pattern every time you have two or more implementations of the same behavior. It's the single most useful pattern in this list. The interface costs you 30 seconds to write and saves hours of refactoring when requirements change.

Action Classes

Actions aren't in the Gang of Four book, but they've become the dominant pattern in the Laravel community for a reason. An Action is a single-purpose class that does one thing. It replaces the bloated service class that has 15 methods and 300 lines of code.

// App\Actions\CreateUser.php
class CreateUser
{
    public function __construct(
        private readonly UserRepositoryInterface $users,
    ) {}

    public function execute(CreateUserData $data): User
    {
        $user = $this->users->create([
            'name' => $data->name,
            'email' => $data->email,
            'password' => Hash::make($data->password),
        ]);

        event(new UserRegistered($user));

        return $user;
    }
}

Your controller becomes thin:

class RegisterController extends Controller
{
    public function store(
        RegisterRequest $request,
        CreateUser $action,
    ): RedirectResponse {
        $action->execute(
            CreateUserData::from($request->validated())
        );

        return redirect()->route('dashboard');
    }
}

My opinion: I use Action classes in every project now. They're testable in isolation, easy to call from Artisan commands and queue jobs (not just HTTP requests), and they make your controller's purpose obvious at a glance. The convention is one public method (execute or handle) per class. If you find yourself adding a second method, you need a second Action.

Data Transfer Objects (DTOs)

DTOs solve the "array of mystery" problem. Instead of passing $data arrays around and hoping the right keys exist, you create a typed object. PHP 8.0's constructor promotion and readonly properties made this pattern effortless.

// App\DTOs\CreateUserData.php
class CreateUserData
{
    public function __construct(
        public readonly string $name,
        public readonly string $email,
        public readonly string $password,
        public readonly ?string $phone = null,
    ) {}

    public static function from(array $data): self
    {
        return new self(
            name: $data['name'],
            email: $data['email'],
            password: $data['password'],
            phone: $data['phone'] ?? null,
        );
    }
}

The benefits compound across your codebase. Your IDE autocompletes every property. Type errors throw exceptions instead of silently producing bugs. And anyone reading your code knows exactly what data flows through the system without digging through validation rules.

My opinion: DTOs are worth introducing once your application passes the "trivial CRUD" stage. If you're passing the same array shape between three or more methods, wrap it in a DTO. For simple form submissions that go straight to Model::create(), they're overkill. I typically use them alongside Action classes as a pair: the DTO defines the input, and the Action processes it. The combination gives you type-safe, self-documenting code that's a pleasure to work with six months later when you've forgotten what $data['settings']['notifications']['frequency'] was supposed to contain.

Pipeline Pattern

Laravel's Pipeline is the pattern you've been using without knowing it. Every middleware in your application runs through a Pipeline. But you can use it directly for any multi-step processing where the output of one step feeds into the next.

The most practical use case I've found is query filtering. Instead of stacking if statements in a controller, each filter becomes its own class:

// App\Filters\ActiveFilter.php
class ActiveFilter
{
    public function handle(Builder $query, Closure $next): Builder
    {
        if (request()->boolean('active')) {
            $query->where('active', true);
        }

        return $next($query);
    }
}

// App\Filters\SortFilter.php
class SortFilter
{
    public function handle(Builder $query, Closure $next): Builder
    {
        if (request()->has('sort')) {
            $query->orderBy(
                request('sort'),
                request('direction', 'asc')
            );
        }

        return $next($query);
    }
}

Then in your controller:

use Illuminate\Support\Facades\Pipeline;

class ProductController extends Controller
{
    public function index(): View
    {
        $products = Pipeline::send(Product::query())
            ->through([
                ActiveFilter::class,
                SortFilter::class,
                CategoryFilter::class,
                PriceRangeFilter::class,
            ])
            ->thenReturn()
            ->paginate(20);

        return view('products.index', compact('products'));
    }
}

Adding a new filter means creating one class and adding it to the array. No touching existing code, no growing if-else chains.

My opinion: Pipelines shine when you have three or more sequential processing steps. For two filters, inline when() clauses on the query builder are simpler. But the moment you hit four, five, or six filters, the Pipeline approach pays for itself in readability and maintainability. I use this heavily in API endpoints where users can filter and sort results.

Observer Pattern

Observers let you hook into Eloquent model lifecycle events (creating, created, updating, deleted, etc.) without cluttering the model itself. Since Laravel 10, you can register observers directly on the model with the ObservedBy attribute:

use App\Observers\OrderObserver;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;

#[ObservedBy(OrderObserver::class)]
class Order extends Model
{
    // Clean model, no event logic here
}

The observer handles the side effects:

class OrderObserver
{
    public function created(Order $order): void
    {
        Notification::send(
            $order->customer,
            new OrderConfirmation($order)
        );
    }

    public function updated(Order $order): void
    {
        if ($order->wasChanged('status') && $order->status === 'shipped') {
            Notification::send(
                $order->customer,
                new OrderShipped($order)
            );
        }
    }
}

No more registering observers manually in service providers. The attribute makes the relationship explicit right on the model.

My opinion: Observers are great for side effects that should always happen when a model changes, like sending notifications, clearing cache, or logging audit trails. But be careful. I've seen projects where observers fire off three database queries and two API calls every time a model saves, and nobody realizes it because the logic is hidden. If the side effect is conditional or complex, use an event/listener pair instead. Observers should be simple and predictable.

Singleton Pattern

The Singleton ensures you get the same instance every time you resolve a class from the container. Laravel's service container makes this trivial:

// In AppServiceProvider
public function register(): void
{
    $this->app->singleton(
        AnalyticsService::class,
        fn () => new AnalyticsService(
            apiKey: config('services.analytics.key'),
        )
    );
}

Every time you inject or resolve AnalyticsService, you get the exact same object. This matters when the class holds state (like an API connection) or when construction is expensive (like parsing a large config file). Laravel already uses singletons internally for the database connection, cache store, and logger.

My opinion: You rarely need to create your own singletons. The framework handles the common cases. I've used custom singletons maybe five times in nine years, usually for API clients where maintaining a persistent connection improves performance. If you're reaching for singleton() often, you might be over-managing object lifecycles that the container handles fine with regular bindings.

Factory Pattern

The Factory pattern creates objects without exposing the instantiation logic. In Laravel, you'll see this most in Eloquent model factories for testing, but the pattern is useful any time you need to create different objects based on a condition.

class NotificationFactory
{
    public static function create(string $channel): NotificationChannel
    {
        return match ($channel) {
            'email' => app(EmailNotification::class),
            'sms' => app(SmsNotification::class),
            'slack' => app(SlackNotification::class),
            default => throw new InvalidArgumentException(
                "Unsupported channel: {$channel}"
            ),
        };
    }
}

Notice the match expression instead of switch. It's stricter (no fallthrough bugs), more concise, and returns a value directly. If you're still using switch in PHP 8.0+, you're writing more code than you need to.

My opinion: Factories are useful when creation logic involves decisions. If you always create the same class, just inject it directly. The Factory earns its place when the "which class?" question depends on runtime data, like a user's notification preferences or a configuration flag.

Builder Pattern (Query Builder)

You use this pattern every time you write an Eloquent query. Laravel's query builder is a textbook implementation of the Builder pattern: it constructs a complex SQL object through a fluent, step-by-step interface.

$topCustomers = DB::table('orders')
    ->select('customer_id', DB::raw('SUM(total) as lifetime_value'))
    ->where('status', 'completed')
    ->where('created_at', '>=', now()->subYear())
    ->groupBy('customer_id')
    ->having('lifetime_value', '>', 1000)
    ->orderByDesc('lifetime_value')
    ->limit(50)
    ->get();

You can apply the same principle to your own classes. Any time you're constructing a complex object with many optional parameters, consider a fluent builder interface instead of a constructor with 12 arguments. For tips on building efficient queries, check out the query optimization guide.

Choosing the Right Pattern

After years of experimenting, here's the cheat sheet I keep in my head for when each pattern earns its place:

Use Repository when you have complex queries repeated across multiple controllers, or when you might need to swap data sources. Skip it for simple CRUD.

Use Strategy whenever you have interchangeable implementations. If you write an interface, you're already halfway there. This is my most-used pattern.

Use Actions for any business logic that doesn't belong in a controller. This is your default extraction pattern. When in doubt, extract into an Action.

Use DTOs when you're passing data between layers and want type safety. Pair them with Actions for maximum benefit.

Use Pipeline for sequential processing with three or more steps. Query filtering is the killer use case, but content processing and data import pipelines work great too.

Use Observer for simple, always-fire model side effects. Keep them lightweight and avoid side effects that make debugging harder.

Use Singleton sparingly. Let the container handle it unless you have a specific reason like maintaining API connections.

Use Factory when object creation depends on runtime conditions like user preferences or config values.

The biggest mistake I see is applying every pattern to every project. A five-page marketing site doesn't need repositories, DTOs, and a Pipeline. A SaaS with 50 models and complex business rules probably needs all of them. Match the pattern to the problem, not the other way around.

FAQ

Should I use the Repository pattern with Eloquent?

It depends on your project's complexity. For straightforward CRUD applications, repositories add an unnecessary layer over Eloquent's already clean API. For applications with complex queries, multiple data sources, or strict testing requirements where you want to mock data access, repositories provide real value. My rule of thumb: if you're writing the same where clause in three places, extract it into a repository method.

What's the difference between a Service class and an Action class?

A Service class typically groups related methods (like UserService with create, update, delete, deactivate). An Action class has one public method that does one thing (CreateUser, DeactivateUser). Actions follow the Single Responsibility Principle more strictly and are easier to test. I prefer Actions because they prevent the "god service" problem where a class grows to 500 lines.

How do I decide between Observers and Event/Listener pairs?

Use Observers when the side effect is tightly coupled to a model's lifecycle and should always fire (audit logging, cache invalidation). Use Event/Listener pairs when the side effect is conditional, when multiple unrelated systems need to react, or when you want to queue the processing. Observers are simpler but less flexible.

Can I combine multiple design patterns in one feature?

Yes, and you should when it makes sense. A typical flow in my projects looks like this: Controller receives request and creates a DTO, passes DTO to an Action class, the Action uses a Repository for data access, and the Repository implementation uses the Strategy pattern for conditional logic. Each layer has a single concern, and the patterns work together naturally.

Do design patterns affect application performance?

The overhead is negligible. An interface resolution in Laravel's container takes microseconds. The performance cost of adding a Repository or Strategy layer is far less than a single unoptimized database query. If you want to convert between data formats while working with these patterns, a JSON to PHP array converter can speed up your development workflow.

Wrapping Up

Design patterns aren't decorations you add to make code look professional. They're solutions to specific structural problems that emerge as applications grow. The patterns that have earned permanent spots in my toolkit are Strategy (for swappable implementations), Actions (for business logic extraction), and Pipeline (for multi-step processing). The rest come and go depending on the project's needs.

Start with the simplest approach that works. Write your logic in the controller first. When you feel pain, like duplicated queries, growing controllers, or tangled conditional logic, that's when you reach for a pattern. Not before. The goal is clean, maintainable code that your future self (or your team) can understand at a glance. Every pattern here serves that goal when applied to the right problem.

If you're building a Laravel application and want help structuring it for long-term maintainability, let's talk about it.

Share: X/Twitter | LinkedIn |
Hafiz Riaz

About Hafiz

Senior Full-Stack Developer with 9+ years building web apps and SaaS platforms. I specialize in Laravel and Vue.js, and I write about the real decisions behind shipping production software.

View My Work →

Get web development tips via email

Join 50+ developers • No spam • Unsubscribe anytime