Design Patterns in Real Projects

Design patterns become truly useful when developers know how to apply them in real projects. This article explains how design patterns work together in practical applications, how to choose the right pattern, and how to avoid overengineering in PHP, Laravel, Symfony, and modern software systems.

Jun 10, 2026
Design Patterns in Real Projects

Design Patterns in Real Projects

Design patterns become truly valuable when developers understand how to use them in real software projects. Learning patterns individually is important, but professional software development requires knowing when to use each pattern, how patterns work together, and when not to use them.

In real applications, patterns such as Repository, Service Layer, DTO, Factory, Strategy, Adapter, Decorator, Observer, Command, Facade, and Dependency Injection often appear together. They help developers build software that is clean, flexible, maintainable, and easier to test.

Introduction

Throughout this OOP and Design Patterns series, we discussed many important patterns and concepts. We covered Object-Oriented Programming basics, inheritance, encapsulation, polymorphism, abstraction, interfaces, static methods, namespaces, autoloading, OOP best practices, and many design patterns.

However, learning design patterns one by one is only the first step. The more important step is understanding how to apply them in real projects without overengineering the code.

This article focuses on practical usage. It explains how design patterns appear in real applications, how they support common workflows, and how developers can choose the right pattern based on the problem they are solving.

Why Design Patterns Matter in Real Projects

Design patterns matter because real projects change over time. Requirements change, new features are added, external services are replaced, business rules become more complex, and teams grow.

Without good structure, a project can quickly become difficult to maintain. Controllers become too large, services become messy, database queries are repeated, external API code spreads everywhere, and small changes create unexpected bugs.

Design patterns provide proven ways to organize code. They help developers separate responsibilities, reduce duplication, depend on abstractions, and keep code easier to extend.

Design Patterns Are Not Magic

Design patterns are not magic solutions. They do not automatically make code clean. A pattern used in the wrong place can make the code more complicated.

The goal is not to use as many patterns as possible. The goal is to solve real design problems with simple and appropriate structure.

A good developer does not ask, “Which pattern can I force here?” A good developer asks, “What problem do I have, and which design approach makes the code clearer?”

Common Real Project Problems Solved by Design Patterns

Design patterns are useful because they solve common software problems that appear repeatedly in real projects.

Common problems include:

  • Controllers becoming too large.

  • Business logic being duplicated in many places.

  • Database queries being mixed with services and controllers.

  • External API details spreading through the codebase.

  • Too many if and switch statements for different behaviors.

  • Hard-to-test classes with hidden dependencies.

  • Complex workflows that need events, jobs, or commands.

  • Large classes that do too many things.

Each design pattern can help with one or more of these problems when used correctly.

Real Project Example: User Registration

User registration is a common feature in almost every web application. A simple registration feature may start with creating a user record. Later, it may include validation, password hashing, profile creation, welcome email, email verification, audit logging, analytics, and admin notification.

Several patterns can work together in this feature:

  • DTO Pattern: Carries validated registration data.

  • Service Layer Pattern: Organizes the registration workflow.

  • Repository Pattern: Handles user data access.

  • Dependency Injection: Provides dependencies to the service.

  • Observer Pattern: Allows listeners to react after registration.

  • Command Pattern: Can represent registration as a command in larger systems.

The result is a cleaner and more maintainable registration flow.

User Registration Example in PHP

readonly class RegisterUserDto
{
    public function __construct(
        public string $name,
        public string $email,
        public string $password
    ) {
    }
}

class UserRegistrationService
{
    public function __construct(
        private UserRepositoryInterface $users,
        private PasswordHasher $passwordHasher,
        private EventDispatcherInterface $events
    ) {
    }

    public function register(RegisterUserDto $dto): User
    {
        if ($this->users->findByEmail($dto->email)) {
            throw new RuntimeException('Email already exists.');
        }

        $user = $this->users->create([
            'name' => $dto->name,
            'email' => $dto->email,
            'password' => $this->passwordHasher->hash($dto->password),
        ]);

        $this->events->dispatch(new UserRegistered($user));

        return $user;
    }
}

This example uses DTO, Service Layer, Repository, Dependency Injection, and Observer-style event dispatching. Each part has a clear responsibility.

Real Project Example: E-Commerce Checkout

Checkout is a more complex real-world feature. It may include cart validation, discount calculation, shipping cost calculation, payment processing, order creation, inventory update, invoice generation, confirmation email, and analytics tracking.

Several design patterns can support this workflow:

  • Service Layer: Coordinates the checkout process.

  • Strategy Pattern: Handles payment methods, shipping rules, or discount rules.

  • Factory Pattern: Creates the correct payment or shipping strategy.

  • Repository Pattern: Stores orders and retrieves products.

  • Adapter Pattern: Connects to external payment gateways.

  • Observer Pattern: Triggers side effects after order placement.

  • Command Pattern: Queues slow tasks such as email or invoice generation.

This combination allows checkout logic to stay organized instead of becoming one massive controller method.

Checkout Service Example

class CheckoutService
{
    public function __construct(
        private CartValidator $cartValidator,
        private DiscountStrategy $discountStrategy,
        private PaymentGatewayInterface $paymentGateway,
        private OrderRepositoryInterface $orders,
        private InventoryService $inventory,
        private EventDispatcherInterface $events
    ) {
    }

    public function checkout(User $user, Cart $cart): Order
    {
        $this->cartValidator->validate($cart);

        $discount = $this->discountStrategy->calculate($cart);

        $order = $this->orders->createFromCart($user, $cart, $discount);

        $this->paymentGateway->charge($order);
        $this->inventory->decreaseStock($cart);

        $this->events->dispatch(new OrderPlaced($order));

        return $order;
    }
}

This service coordinates the main workflow. Payment, discount, inventory, and event handling are separated into their own components.

Real Project Example: Payment Gateway Integration

Payment gateway integration is a common area where design patterns are very useful. Different providers such as Stripe, PayPal, bank transfer, or local payment systems may have different APIs and response formats.

Useful patterns include:

  • Adapter Pattern: Converts provider-specific APIs into a common internal interface.

  • Strategy Pattern: Allows choosing different payment methods.

  • Factory Pattern: Creates the correct payment strategy or adapter.

  • DTO Pattern: Standardizes payment request and response data.

  • Dependency Injection: Injects the selected payment implementation.

This keeps payment provider details away from the core application logic.

Payment Interface and DTO Example

readonly class PaymentRequestDto
{
    public function __construct(
        public float $amount,
        public string $currency,
        public string $customerEmail
    ) {
    }
}

readonly class PaymentResultDto
{
    public function __construct(
        public bool $successful,
        public ?string $transactionId,
        public ?string $message = null
    ) {
    }
}

interface PaymentGatewayInterface
{
    public function charge(PaymentRequestDto $request): PaymentResultDto;
}

The application now depends on a stable internal interface and DTOs instead of depending directly on provider-specific request and response formats.

Payment Adapter Example

class StripePaymentAdapter implements PaymentGatewayInterface
{
    public function __construct(
        private StripeClient $client
    ) {
    }

    public function charge(PaymentRequestDto $request): PaymentResultDto
    {
        $response = $this->client->createCharge([
            'amount' => $request->amount,
            'currency' => $request->currency,
            'email' => $request->customerEmail,
        ]);

        return new PaymentResultDto(
            successful: $response['status'] === 'paid',
            transactionId: $response['id'] ?? null,
            message: $response['message'] ?? null
        );
    }
}

This adapter isolates Stripe-specific logic. If the application later adds another payment provider, a new adapter can implement the same interface.

Real Project Example: Notification System

A notification system may send messages through email, SMS, push notifications, Telegram, Slack, or WhatsApp. Each channel may have a different API.

Useful patterns include:

  • Strategy Pattern: Selects the notification channel.

  • Adapter Pattern: Integrates external providers.

  • Factory Pattern: Creates the correct sender.

  • Decorator Pattern: Adds logging, retry, or rate limiting.

  • Command Pattern: Queues notification sending.

  • Observer Pattern: Sends notifications after events.

This allows the application to add new channels without rewriting the notification workflow.

Notification Strategy Example

interface NotificationSender
{
    public function send(string $recipient, string $message): bool;
}

class EmailNotificationSender implements NotificationSender
{
    public function send(string $recipient, string $message): bool
    {
        // Send email
        return true;
    }
}

class SmsNotificationSender implements NotificationSender
{
    public function send(string $recipient, string $message): bool
    {
        // Send SMS
        return true;
    }
}

The application can use NotificationSender without knowing the concrete channel.

Decorator in Real Notification System

Logging can be added without changing the original sender:

class LoggedNotificationSender implements NotificationSender
{
    public function __construct(
        private NotificationSender $sender,
        private LoggerInterface $logger
    ) {
    }

    public function send(string $recipient, string $message): bool
    {
        $this->logger->info('Sending notification', [
            'recipient' => $recipient,
        ]);

        return $this->sender->send($recipient, $message);
    }
}

This is a practical use of the Decorator Pattern in real projects.

Real Project Example: Report Generation

Report generation often includes data retrieval, filtering, formatting, exporting, storage, and notification. It may support multiple formats such as PDF, Excel, CSV, and JSON.

Useful patterns include:

  • Repository Pattern: Retrieves report data.

  • Service Layer: Coordinates report generation.

  • Strategy Pattern: Selects export format.

  • Factory Pattern: Creates the correct exporter.

  • Command Pattern: Queues long-running report generation.

  • Facade Pattern: Provides a simple interface for complex report workflows.

This structure helps keep report generation manageable as new formats and filters are added.

Export Strategy Example

interface ReportExporter
{
    public function export(array $data): string;
}

class PdfReportExporter implements ReportExporter
{
    public function export(array $data): string
    {
        return 'PDF content';
    }
}

class CsvReportExporter implements ReportExporter
{
    public function export(array $data): string
    {
        return 'CSV content';
    }
}

Each export format is implemented as a separate strategy. The report service can use any exporter through the same interface.

Real Project Example: File Import System

File import systems often need validation, parsing, mapping, duplicate checking, database saving, error reporting, and background processing.

Useful patterns include:

  • Strategy Pattern: Selects parser type such as CSV, Excel, or JSON.

  • DTO Pattern: Represents imported rows.

  • Repository Pattern: Saves imported data.

  • Service Layer: Coordinates import workflow.

  • Command Pattern: Runs import as a queued job.

  • Observer Pattern: Triggers notifications after import completion.

This prevents file import logic from becoming one large class.

File Parser Strategy Example

interface FileParser
{
    public function parse(string $filePath): array;
}

class CsvFileParser implements FileParser
{
    public function parse(string $filePath): array
    {
        // Parse CSV file
        return [];
    }
}

class JsonFileParser implements FileParser
{
    public function parse(string $filePath): array
    {
        // Parse JSON file
        return [];
    }
}

The import service can use a parser interface and does not need to know the details of each file format.

Real Project Example: Multi-Tenant Application

Multi-tenant applications often serve multiple customers or organizations from the same application. They may need tenant-specific settings, storage paths, payment providers, branding, permissions, and data filters.

Useful patterns include:

  • Strategy Pattern: Selects tenant-specific behavior.

  • Factory Pattern: Creates tenant-specific services.

  • Repository Pattern: Applies tenant data filtering.

  • Decorator Pattern: Adds tenant scoping or logging.

  • Dependency Injection: Provides tenant-aware services.

Design patterns help keep tenant logic organized and prevent tenant conditions from spreading everywhere.

Choosing the Right Pattern

Choosing the right pattern starts with understanding the problem. Do not start by choosing a pattern. Start by identifying what is making the code difficult.

Useful questions include:

  • Is object creation becoming complex? Consider Factory, Abstract Factory, Builder, or Prototype.

  • Are interfaces incompatible? Consider Adapter.

  • Do you need to add behavior without modifying a class? Consider Decorator.

  • Is a subsystem too complex to use directly? Consider Facade.

  • Are there multiple algorithms or behaviors? Consider Strategy.

  • Should multiple listeners react to an event? Consider Observer.

  • Should actions be queued, logged, or undone? Consider Command.

  • Is data access logic scattered? Consider Repository.

  • Is business logic inside controllers? Consider Service Layer.

  • Are raw arrays unclear? Consider DTO.

The pattern should match the problem.

How Patterns Work Together

In real projects, patterns are rarely used alone. They often work together naturally.

For example, a CheckoutService may use a PaymentGatewayInterface injected through Dependency Injection. The payment gateway may be an Adapter around Stripe. The selected payment method may be chosen using Strategy. The correct strategy may be created by a Factory. The final OrderPlaced event may notify Observers. A SendInvoiceCommand may be queued after the order is placed.

This does not mean the project is overengineered if each pattern solves a real problem. The key is that each class has a clear responsibility and the code remains understandable.

Example: Patterns Working Together

class CheckoutService
{
    public function __construct(
        private OrderRepositoryInterface $orders,
        private PaymentGatewayInterface $paymentGateway,
        private DiscountStrategy $discountStrategy,
        private EventDispatcherInterface $events
    ) {
    }

    public function checkout(CreateOrderDto $dto): Order
    {
        $discount = $this->discountStrategy->calculate($dto);

        $order = $this->orders->create($dto, $discount);

        $this->paymentGateway->charge(
            new PaymentRequestDto(
                amount: $order->total,
                currency: $order->currency,
                customerEmail: $order->customerEmail
            )
        );

        $this->events->dispatch(new OrderPlaced($order));

        return $order;
    }
}

This example combines Service Layer, Repository, Strategy, Adapter-style PaymentGateway, DTO, Dependency Injection, and Observer. The code remains understandable because each dependency has a clear role.

Design Patterns in Laravel Projects

Laravel provides many tools that make design patterns easier to apply. Its service container supports Dependency Injection. Events and listeners support Observer-style architecture. Jobs support Command-style background work. Eloquent can work with Repository Pattern when needed. Service classes and action classes can organize business logic.

Common Laravel pattern usage includes:

  • Service classes for business workflows.

  • Form Requests for validation.

  • DTOs for structured service input.

  • Repositories for complex data access.

  • Events and listeners for side effects.

  • Jobs for queued commands.

  • Adapters for external APIs.

  • Strategies for payment, shipping, discounts, or exports.

Laravel does not force every pattern, so developers should use them based on project complexity.

Design Patterns in Symfony Projects

Symfony strongly supports service-oriented architecture and Dependency Injection. Many Symfony applications naturally use services, repositories, event subscribers, command handlers, DTOs, adapters, and strategies.

Common Symfony pattern usage includes:

  • Services for application logic.

  • Doctrine repositories for data access.

  • Event dispatcher for Observer-style behavior.

  • Messenger component for Command and CQRS-style workflows.

  • DTOs for forms, APIs, and commands.

  • Adapters for external services.

  • Tagged services for strategy collections.

Symfony's structure makes it suitable for large projects where patterns help keep code organized.

Design Patterns and Clean Architecture

Design patterns support clean architecture by helping separate concerns. Clean architecture focuses on keeping business rules independent from frameworks, databases, user interfaces, and external services.

Patterns can support this goal:

  • Repository interfaces protect business logic from database details.

  • Adapters isolate external services.

  • DTOs define data transfer boundaries.

  • Services or use cases organize application workflows.

  • Dependency Injection provides implementations from outside.

  • Commands represent application actions.

  • Events and observers separate side effects.

However, clean architecture does not require using every pattern. It requires clear boundaries and good dependency direction.

Design Patterns and SOLID Principles

Design patterns and SOLID principles are closely related. Many patterns help apply SOLID principles in practical code.

For example:

  • Strategy Pattern supports the Open Closed Principle.

  • Dependency Injection supports the Dependency Inversion Principle.

  • Service Layer helps support Single Responsibility Principle.

  • Adapter Pattern supports interface-based design.

  • Decorator Pattern extends behavior without modifying existing classes.

  • Repository Pattern separates persistence concerns.

Understanding SOLID helps developers use design patterns more wisely.

Overengineering with Design Patterns

Overengineering happens when developers add too much structure for a problem that does not need it. This can make code harder to read and slower to develop.

For example, a simple contact form may not need DTOs, repositories, factories, strategies, commands, and events. A simple controller and service may be enough.

Patterns should be added when they solve a real problem. If a pattern only adds extra files without improving clarity, it may not be necessary.

Signs You Are Overusing Design Patterns

Design patterns may be overused when:

  • Simple features require too many classes.

  • Developers cannot easily trace the flow of execution.

  • Most classes only forward calls without adding value.

  • Interfaces are created for every class without multiple implementations or testing need.

  • The code looks more abstract but not more understandable.

  • Adding a small feature requires editing many unnecessary layers.

Good architecture should simplify change, not make every change heavier.

Underengineering: The Opposite Problem

Underengineering happens when there is not enough structure. Everything is placed in controllers, models, or helper files. This may feel fast at first, but it becomes expensive later.

Signs of underengineering include:

  • Very large controllers.

  • Repeated database queries.

  • Duplicated business logic.

  • External API calls spread everywhere.

  • Long if and switch statements for behavior selection.

  • Hard-to-test classes.

  • Changes in one feature breaking unrelated features.

The goal is balance. Use enough structure to keep the project maintainable, but not so much that simple work becomes complicated.

Practical Rule: Start Simple, Refactor Toward Patterns

A practical approach is to start simple and refactor toward patterns when the need becomes clear.

For example:

  • If a controller becomes too large, move logic to a service.

  • If queries are repeated, create a repository method.

  • If raw arrays become confusing, introduce a DTO.

  • If many if statements choose behavior, introduce Strategy.

  • If external API code spreads, introduce an Adapter.

  • If side effects grow after an action, introduce events and observers.

  • If tasks need to run later, introduce commands or jobs.

This approach keeps architecture practical and avoids unnecessary complexity.

Folder Structure Example

A real PHP or Laravel project may organize patterns using folders such as:

app/
  DTO/
    CreateOrderDto.php
    PaymentRequestDto.php

  Services/
    CheckoutService.php
    UserRegistrationService.php

  Repositories/
    OrderRepositoryInterface.php
    EloquentOrderRepository.php

  Strategies/
    Discounts/
      DiscountStrategy.php
      PremiumCustomerDiscountStrategy.php
      SeasonalDiscountStrategy.php

  Adapters/
    Payments/
      StripePaymentAdapter.php
      PayPalPaymentAdapter.php

  Events/
    OrderPlaced.php

  Listeners/
    SendOrderConfirmation.php
    UpdateAnalytics.php

  Jobs/
    GenerateInvoiceJob.php

This is only an example. The best folder structure depends on the framework, team style, and project size.

Testing Design Patterns in Real Projects

Design patterns often make testing easier. Services can be tested with fake repositories. Strategies can be tested independently. Adapters can be mocked. Commands can be tested as single actions. DTOs make input data predictable.

For example, testing a discount strategy is simple because the class has one focused responsibility:

class PremiumCustomerDiscountStrategyTest
{
    public function test_it_calculates_premium_discount(): void
    {
        $strategy = new PremiumCustomerDiscountStrategy();

        $discount = $strategy->calculate(200);

        assert($discount === 30.0);
    }
}

Small focused classes are usually easier to test than large mixed classes.

Design Patterns and Team Collaboration

Design patterns also improve communication between developers. When a developer says “this should be an adapter,” other developers understand that the class should translate an external interface into an internal interface.

When a developer says “use a strategy here,” the team understands that there are multiple interchangeable behaviors.

Shared pattern vocabulary makes code reviews, architecture discussions, and onboarding easier.

Common Mistakes in Real Projects

One common mistake is using patterns because they look professional, not because they solve a real problem.

Another mistake is mixing responsibilities even while using patterns. For example, a repository should not contain business workflow logic, and a DTO should not contain complex business rules.

A third mistake is creating interfaces for everything without a reason. Interfaces are useful when there are multiple implementations, testing needs, or architectural boundaries.

A fourth mistake is ignoring framework conventions. Laravel, Symfony, and other frameworks already provide useful structures. Patterns should work with the framework, not against it.

Best Practices for Using Design Patterns in Real Projects

To use design patterns effectively, developers should focus on clarity and responsibility.

Useful best practices include:

  • Start with the problem, not the pattern.

  • Use patterns only when they improve clarity or flexibility.

  • Keep each class focused on one responsibility.

  • Use interfaces where they provide real value.

  • Use Dependency Injection instead of hidden service locators.

  • Keep controllers thin and move business logic to services or use cases.

  • Use repositories for meaningful data access logic.

  • Use DTOs when data structures need clarity.

  • Use events for independent side effects.

  • Avoid overengineering simple features.

These practices help design patterns support the project instead of making it harder.

Practical Checklist Before Applying a Pattern

Before applying a design pattern, ask these questions:

  • What specific problem am I solving?

  • Will this pattern make the code easier to understand?

  • Will it reduce duplication or coupling?

  • Will it make future changes safer?

  • Will it make testing easier?

  • Is the feature complex enough to justify the pattern?

  • Can another developer understand the flow easily?

  • Am I following the framework conventions?

If the answer is yes to several of these questions, the pattern may be a good choice.

Recommended Learning Path for Real Projects

For beginners and intermediate developers, a practical learning path is:

  1. Learn OOP basics: classes, objects, encapsulation, inheritance, polymorphism, and abstraction.

  2. Learn interfaces and Dependency Injection.

  3. Learn MVC and Service Layer Pattern.

  4. Learn Repository Pattern and DTO Pattern.

  5. Learn Factory and Strategy for flexible behavior.

  6. Learn Adapter for external integrations.

  7. Learn Observer and Command for events, jobs, and workflows.

  8. Learn Decorator and Facade for structural organization.

  9. Practice by refactoring real features, not only reading theory.

This learning path helps developers move from basic OOP to real project architecture step by step.

Conclusion

Design patterns are most useful when they solve real problems in real projects. They help developers organize business logic, manage dependencies, integrate external systems, structure data transfer, handle events, queue actions, and keep applications maintainable as they grow.

Patterns such as Service Layer, Repository, DTO, Factory, Strategy, Adapter, Decorator, Facade, Observer, Command, and Dependency Injection often work together in modern PHP, Laravel, Symfony, and enterprise applications.

However, design patterns should be used thoughtfully. Overusing patterns can create unnecessary complexity, while ignoring them can lead to messy and fragile code. The best approach is to start simple, identify real design problems, and refactor toward the right pattern when it improves clarity, flexibility, and maintainability.