Navigation

Laravel

Domain-Driven Design (DDD) with Laravel: Building Maintainable Enterprise Applications

Learn how to implement Domain-Driven Design (DDD) in Laravel applications to build maintainable enterprise systems. This comprehensive guide covers bounded contexts, value objects, repositories, and services with practical Laravel examples and integration with popular packages.
Domain-Driven Design (DDD) with Laravel: Building Maintainable Enterprise Applications

Domain-Driven Design transforms Laravel applications from simple CRUD operations to sophisticated enterprise systems with clearly defined business domains. By aligning code structure with business realities, DDD enables teams to tackle complexity, improve communication, and build more maintainable applications that directly reflect organizational knowledge.

For decoupled architecture, see Laravel Events and Listeners: Building Decoupled Applications.

Table Of Contents

Introduction to Domain-Driven Design

In the world of software development, creating applications that accurately model complex business domains while remaining maintainable over time presents a significant challenge. As Laravel applications grow beyond simple CRUD operations to complex enterprise systems, developers often struggle with organizing code in a way that remains comprehensible and adaptable to changing business requirements.

For advanced data handling, check out Laravel Collections: Beyond Basic Array Operations.

Domain-Driven Design (DDD) addresses these challenges by providing a structured approach to software development that focuses on the core domain and domain logic. Originally introduced by Eric Evans in his 2003 book "Domain-Driven Design: Tackling Complexity in the Heart of Software," DDD offers a set of principles and patterns that help teams build software deeply aligned with business needs.

If you are building for scale, see Building Multi-Tenant Applications with Laravel: A Comprehensive Guide.

While Laravel follows the MVC (Model-View-Controller) architectural pattern by default, it's flexible enough to accommodate DDD principles without sacrificing its elegant syntax and developer-friendly features. In this article, we'll explore how to apply Domain-Driven Design concepts to Laravel applications, creating systems that are both powerful and maintainable.

Core Concepts of Domain-Driven Design

Before diving into Laravel-specific implementations, let's understand the fundamental concepts of DDD:

1. Ubiquitous Language

A shared language between developers and domain experts used consistently in code, documentation, and conversation. This eliminates translation between technical and business terminology, reducing misunderstandings.

For example, in an e-commerce application, rather than generic terms like "user" and "item," you would use domain-specific terms like "customer," "product," and "order" consistently in both code and communication.

2. Bounded Contexts

Recognition that a large domain typically contains multiple models with different responsibilities. Each bounded context has its own ubiquitous language and represents a specific business capability with clear boundaries.

In a Laravel application, bounded contexts might be implemented as separate modules or packages, each with its own models, services, and business rules.

3. Entities and Value Objects

Entities are objects defined by their identity, which persists across changes to their attributes. In Laravel, these are often represented as Eloquent models with primary keys.

Value Objects are immutable objects defined by their attributes rather than identity. They represent concepts like Money, Address, or DateRange.

For performance and query optimization, see Advanced Eloquent Techniques and Optimizations in Laravel.

// A Value Object example
class Money
{
    private float $amount;
    private string $currency;
    
    public function __construct(float $amount, string $currency)
    {
        $this->amount = $amount;
        $this->currency = $currency;
    }
    
    public function add(Money $money): Money
    {
        if ($this->currency !== $money->currency) {
            throw new InvalidArgumentException('Cannot add money with different currencies');
        }
        
        return new Money($this->amount + $money->amount, $this->currency);
    }
    
    // Other methods...
}

4. Aggregates and Aggregate Roots

An Aggregate is a cluster of associated objects treated as a unit for data changes. The Aggregate Root is the entry point to the aggregate, which ensures the consistency of changes to the objects within it.

In Laravel, an aggregate might include an Eloquent model as the aggregate root, with related models accessed only through the root.

5. Domain Services

When an operation doesn't naturally belong to a single entity or value object, it's placed in a domain service. These encapsulate domain operations that involve multiple domain objects.

6. Repositories

Repositories provide methods to obtain domain objects, abstracting the underlying persistence mechanism. In Laravel, repositories often wrap Eloquent models to provide a more domain-focused interface.

7. Domain Events

Significant occurrences within the domain that domain experts care about. These events can be used to trigger actions in other parts of the system.

Laravel's event system aligns well with this concept, allowing you to create and dispatch domain events.

Setting Up a Laravel Project for DDD

When starting a new Laravel project with DDD in mind, the standard directory structure needs some adjustments. Here's a suggested approach:

Directory Structure

laravel-ddd-project/
├── app/
│   ├── Domain/             # Domain layer
│   │   ├── Billing/        # Bounded context
│   │   │   ├── Models/     # Domain models/entities
│   │   │   ├── ValueObjects/
│   │   │   ├── Events/
│   │   │   ├── Exceptions/
│   │   │   └── Services/
│   │   └── Orders/         # Another bounded context
│   │       └── ...
│   ├── Application/        # Application layer
│   │   ├── Commands/
│   │   ├── Queries/
│   │   ├── DTOs/
│   │   └── Services/
│   ├── Infrastructure/     # Infrastructure layer
│   │   ├── Repositories/
│   │   ├── ExternalServices/
│   │   └── Persistence/
│   └── Interfaces/         # User interface layer
│       ├── Api/
│       ├── Web/
│       └── Console/
└── ...

This structure separates concerns according to DDD principles:

  • Domain: Core business logic and concepts
  • Application: Orchestration of domain objects to perform tasks
  • Infrastructure: Technical capabilities like persistence and external services
  • Interfaces: User-facing components (controllers, views, API endpoints)

Compared to the traditional Laravel structure, this organization emphasizes business domains rather than technical roles (like controllers or models).

Implementing DDD Concepts in Laravel

Let's explore practical examples of implementing DDD concepts in a Laravel application, using an e-commerce system as our example.

1. Entities and Eloquent

In Laravel, Eloquent models typically serve as entities. However, to better align with DDD principles, we can enhance them:

// app/Domain/Orders/Models/Order.php
namespace App\Domain\Orders\Models;

use App\Domain\Orders\Events\OrderShipped;
use App\Domain\Orders\ValueObjects\Address;
use App\Domain\Orders\ValueObjects\Money;
use Illuminate\Database\Eloquent\Model;

class Order extends Model
{
    protected $guarded = [];
    
    protected $casts = [
        'shipping_address' => Address::class,
        'total_amount' => Money::class,
    ];
    
    public function ship(): void
    {
        if ($this->status !== 'ready_to_ship') {
            throw new \DomainException('Order cannot be shipped from its current state');
        }
        
        $this->status = 'shipped';
        $this->shipped_at = now();
        $this->save();
        
        event(new OrderShipped($this));
    }
    
    // Domain logic methods...
}

Notice how the Order entity contains business rules and behaviors, not just data. The ship() method enforces business rules about when an order can be shipped and raises a domain event when shipping occurs.

2. Value Objects with Laravel Casts

Laravel's custom casts feature allows us to implement value objects elegantly:

// app/Domain/Orders/ValueObjects/Address.php
namespace App\Domain\Orders\ValueObjects;

use Illuminate\Contracts\Database\Eloquent\CastsAttributes;

class Address implements CastsAttributes
{
    private string $street;
    private string $city;
    private string $state;
    private string $postalCode;
    private string $country;
    
    public function __construct(string $street, string $city, string $state, string $postalCode, string $country)
    {
        $this->street = $street;
        $this->city = $city;
        $this->state = $state;
        $this->postalCode = $postalCode;
        $this->country = $country;
    }
    
    public function get($model, $key, $value, $attributes)
    {
        if (is_null($value)) {
            return null;
        }
        
        $data = json_decode($value, true);
        return new self(
            $data['street'],
            $data['city'],
            $data['state'],
            $data['postal_code'],
            $data['country']
        );
    }
    
    public function set($model, $key, $value, $attributes)
    {
        if (is_null($value)) {
            return null;
        }
        
        return json_encode([
            'street' => $value->street,
            'city' => $value->city,
            'state' => $value->state,
            'postal_code' => $value->postalCode,
            'country' => $value->country,
        ]);
    }
    
    // Methods for manipulating the address...
}

This approach allows us to work with immutable value objects in our domain model while storing them in the database as JSON.

3. Repositories

Repositories abstract data access and provide a domain-focused interface:

// app/Domain/Orders/Repositories/OrderRepositoryInterface.php
namespace App\Domain\Orders\Repositories;

use App\Domain\Orders\Models\Order;

interface OrderRepositoryInterface
{
    public function findById(int $id): ?Order;
    public function findPendingOrders(): array;
    public function save(Order $order): void;
}

// app/Infrastructure/Repositories/EloquentOrderRepository.php
namespace App\Infrastructure\Repositories;

use App\Domain\Orders\Models\Order;
use App\Domain\Orders\Repositories\OrderRepositoryInterface;

class EloquentOrderRepository implements OrderRepositoryInterface
{
    public function findById(int $id): ?Order
    {
        return Order::find($id);
    }
    
    public function findPendingOrders(): array
    {
        return Order::where('status', 'pending')->get()->all();
    }
    
    public function save(Order $order): void
    {
        $order->save();
    }
}

Register this in your service provider:

// app/Providers/AppServiceProvider.php
public function register()
{
    $this->app->bind(
        \App\Domain\Orders\Repositories\OrderRepositoryInterface::class,
        \App\Infrastructure\Repositories\EloquentOrderRepository::class
    );
}

This approach decouples the domain layer from Eloquent, making it easier to replace the data access mechanism if needed.

4. Domain Services

Domain services encapsulate operations that don't naturally belong to a single entity:

// app/Domain/Orders/Services/OrderProcessingService.php
namespace App\Domain\Orders\Services;

use App\Domain\Orders\Models\Order;
use App\Domain\Payments\Services\PaymentServiceInterface;
use App\Domain\Inventory\Services\InventoryServiceInterface;

class OrderProcessingService
{
    private PaymentServiceInterface $paymentService;
    private InventoryServiceInterface $inventoryService;
    
    public function __construct(
        PaymentServiceInterface $paymentService,
        InventoryServiceInterface $inventoryService
    ) {
        $this->paymentService = $paymentService;
        $this->inventoryService = $inventoryService;
    }
    
    public function processOrder(Order $order): void
    {
        // Check inventory
        if (!$this->inventoryService->checkAvailability($order)) {
            throw new \DomainException('Items in order are not available');
        }
        
        // Process payment
        $paymentResult = $this->paymentService->processPayment($order);
        if (!$paymentResult->isSuccessful()) {
            throw new \DomainException('Payment failed: ' . $paymentResult->errorMessage());
        }
        
        // Update inventory
        $this->inventoryService->decreaseStock($order);
        
        // Update order status
        $order->status = 'paid';
        $order->payment_id = $paymentResult->transactionId();
        $order->save();
    }
}

This domain service coordinates actions across multiple bounded contexts (Orders, Payments, Inventory) to process an order.

5. Application Services

Application services orchestrate the use of domain objects to perform user tasks:

// app/Application/Services/OrderApplicationService.php
namespace App\Application\Services;

use App\Application\DTOs\OrderDTO;
use App\Domain\Customers\Repositories\CustomerRepositoryInterface;
use App\Domain\Orders\Models\Order;
use App\Domain\Orders\Repositories\OrderRepositoryInterface;
use App\Domain\Orders\Services\OrderProcessingService;

class OrderApplicationService
{
    private CustomerRepositoryInterface $customerRepository;
    private OrderRepositoryInterface $orderRepository;
    private OrderProcessingService $orderProcessingService;
    
    public function __construct(
        CustomerRepositoryInterface $customerRepository,
        OrderRepositoryInterface $orderRepository,
        OrderProcessingService $orderProcessingService
    ) {
        $this->customerRepository = $customerRepository;
        $this->orderRepository = $orderRepository;
        $this->orderProcessingService = $orderProcessingService;
    }
    
    public function placeOrder(OrderDTO $orderDTO): Order
    {
        $customer = $this->customerRepository->findById($orderDTO->customerId);
        if (!$customer) {
            throw new \InvalidArgumentException('Customer not found');
        }
        
        $order = new Order();
        $order->customer_id = $customer->id;
        $order->items = $orderDTO->items;
        $order->shipping_address = $orderDTO->shippingAddress;
        $order->total_amount = $orderDTO->totalAmount;
        $order->status = 'pending';
        
        $this->orderRepository->save($order);
        
        $this->orderProcessingService->processOrder($order);
        
        return $order;
    }
}

This application service uses DTOs (Data Transfer Objects) to receive input and coordinates the use of domain services and repositories to place an order.

6. Controller Implementation

Controllers remain slim, focusing on HTTP concerns while delegating business logic to application services:

// app/Interfaces/Web/Controllers/OrderController.php
namespace App\Interfaces\Web\Controllers;

use App\Application\DTOs\OrderDTO;
use App\Application\Services\OrderApplicationService;
use App\Domain\Orders\ValueObjects\Address;
use App\Domain\Orders\ValueObjects\Money;
use Illuminate\Http\Request;

class OrderController extends Controller
{
    private OrderApplicationService $orderService;
    
    public function __construct(OrderApplicationService $orderService)
    {
        $this->orderService = $orderService;
    }
    
    public function store(Request $request)
    {
        $validated = $request->validate([
            'customer_id' => 'required|exists:customers,id',
            'items' => 'required|array',
            'items.*.product_id' => 'required|exists:products,id',
            'items.*.quantity' => 'required|integer|min:1',
            'shipping_address' => 'required|array',
            // Other validation rules...
        ]);
        
        $address = new Address(
            $validated['shipping_address']['street'],
            $validated['shipping_address']['city'],
            $validated['shipping_address']['state'],
            $validated['shipping_address']['postal_code'],
            $validated['shipping_address']['country']
        );
        
        $totalAmount = new Money(
            $validated['total_amount']['amount'],
            $validated['total_amount']['currency']
        );
        
        $orderDTO = new OrderDTO(
            $validated['customer_id'],
            $validated['items'],
            $address,
            $totalAmount
        );
        
        try {
            $order = $this->orderService->placeOrder($orderDTO);
            return redirect()->route('orders.show', $order)
                ->with('success', 'Order placed successfully');
        } catch (\DomainException $e) {
            return back()->withErrors(['message' => $e->getMessage()]);
        }
    }
}

The controller is responsible for:

  • Validating HTTP input
  • Converting HTTP data into domain objects
  • Calling the appropriate application service
  • Handling the HTTP response

All business logic remains in the domain and application layers.

Integrating DDD with Laravel's Features

Laravel provides many features that can be leveraged within a DDD architecture:

1. Domain Events and Laravel Events

Laravel's event system works perfectly for implementing domain events:

// app/Domain/Orders/Events/OrderShipped.php
namespace App\Domain\Orders\Events;

use App\Domain\Orders\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class OrderShipped
{
    use Dispatchable, SerializesModels;
    
    public Order $order;
    
    public function __construct(Order $order)
    {
        $this->order = $order;
    }
}

You can then create listeners for these events:

// app/Domain/Notifications/Listeners/SendShipmentNotification.php
namespace App\Domain\Notifications\Listeners;

use App\Domain\Orders\Events\OrderShipped;
use App\Domain\Notifications\Services\NotificationService;

class SendShipmentNotification
{
    private NotificationService $notificationService;
    
    public function __construct(NotificationService $notificationService)
    {
        $this->notificationService = $notificationService;
    }
    
    public function handle(OrderShipped $event)
    {
        $this->notificationService->notifyCustomer(
            $event->order->customer,
            'Your order has been shipped!',
            [
                'order_id' => $event->order->id,
                'tracking_number' => $event->order->tracking_number,
            ]
        );
    }
}

Register this in your EventServiceProvider:

protected $listen = [
    'App\Domain\Orders\Events\OrderShipped' => [
        'App\Domain\Notifications\Listeners\SendShipmentNotification',
    ],
];

This approach keeps your domain events focused on business significance while leveraging Laravel's event handling capabilities.

2. Commands and Command Bus with Laravel Jobs

Laravel's job system can be used to implement the Command pattern from DDD:

// app/Application/Commands/ProcessOrderCommand.php
namespace App\Application\Commands;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use App\Domain\Orders\Repositories\OrderRepositoryInterface;
use App\Domain\Orders\Services\OrderProcessingService;

class ProcessOrderCommand implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
    
    private int $orderId;
    
    public function __construct(int $orderId)
    {
        $this->orderId = $orderId;
    }
    
    public function handle(
        OrderRepositoryInterface $orderRepository,
        OrderProcessingService $orderProcessingService
    ) {
        $order = $orderRepository->findById($this->orderId);
        
        if (!$order) {
            throw new \InvalidArgumentException("Order not found: {$this->orderId}");
        }
        
        $orderProcessingService->processOrder($order);
    }
}

This command can be dispatched like any Laravel job:

ProcessOrderCommand::dispatch($orderId);

3. Query Objects with Laravel's Query Builder

For complex queries, you can create dedicated query objects that use Laravel's query builder:

// app/Application/Queries/FindOrdersForCustomerQuery.php
namespace App\Application\Queries;

use App\Domain\Orders\Models\Order;
use Illuminate\Support\Collection;

class FindOrdersForCustomerQuery
{
    public function execute(int $customerId, array $filters = []): Collection
    {
        $query = Order::query()->where('customer_id', $customerId);
        
        if (isset($filters['status'])) {
            $query->where('status', $filters['status']);
        }
        
        if (isset($filters['date_from'])) {
            $query->where('created_at', '>=', $filters['date_from']);
        }
        
        if (isset($filters['date_to'])) {
            $query->where('created_at', '<=', $filters['date_to']);
        }
        
        if (isset($filters['sort_by'])) {
            $direction = $filters['sort_direction'] ?? 'asc';
            $query->orderBy($filters['sort_by'], $direction);
        } else {
            $query->latest();
        }
        
        return $query->get();
    }
}

This separates query logic from your controllers and application services, making it easier to maintain complex data retrieval operations.

Common Challenges and Solutions

Implementing DDD in Laravel comes with challenges. Here are some common issues and solutions:

1. Balancing Eloquent Magic with DDD Purity

Challenge: Eloquent's active record pattern can blur domain boundaries and encourage anemic domain models.

Solution: Use Eloquent as an implementation detail within your repositories, not as your domain entities. Consider creating separate domain entities that are hydrated from Eloquent models, especially for complex domains.

For simpler cases, extend Eloquent with domain behavior as shown earlier, being careful to maintain a clear distinction between domain logic and persistence concerns.

2. Managing Transaction Boundaries

Challenge: In DDD, transaction boundaries should align with aggregate boundaries, but Laravel's transaction handling typically occurs at the application level.

Solution: Use a Unit of Work pattern or repository methods that handle transactions:

// app/Infrastructure/Persistence/UnitOfWork.php
namespace App\Infrastructure\Persistence;

use Illuminate\Database\ConnectionInterface;

class UnitOfWork
{
    private ConnectionInterface $connection;
    
    public function __construct(ConnectionInterface $connection)
    {
        $this->connection = $connection;
    }
    
    public function execute(callable $callback)
    {
        return $this->connection->transaction(function () use ($callback) {
            return $callback();
        });
    }
}

Then use it in your application services:

public function placeOrder(OrderDTO $orderDTO): Order
{
    return $this->unitOfWork->execute(function () use ($orderDTO) {
        // All the order placement logic
        // ...
        
        return $order;
    });
}

3. Handling Cross-Bounded Context Communication

Challenge: Communication between bounded contexts can become complex and introduce tight coupling.

Solution: Use domain events for asynchronous communication and well-defined interfaces for synchronous communication:

// When a user registers (in the User bounded context)
event(new UserRegistered($user));

// In the Marketing bounded context
class SendWelcomeEmailListener
{
    public function handle(UserRegistered $event)
    {
        // Send marketing welcome email
    }
}

For synchronous communication, define clear interfaces:

// In the Orders bounded context
interface InventoryServiceInterface
{
    public function checkAvailability(Order $order): bool;
    public function decreaseStock(Order $order): void;
}

// In the Inventory bounded context
class InventoryService implements InventoryServiceInterface
{
    // Implementation
}

4. Managing Complex Value Objects with Eloquent

Challenge: Storing and retrieving complex value objects can be cumbersome with Eloquent.

Solution: As shown earlier, use Laravel's custom casts feature or create dedicated serialization methods:

// In your Eloquent model
protected $casts = [
    'shipping_address' => AddressCast::class,
    'total_amount' => MoneyCast::class,
];

For collections of value objects, consider JSON columns with appropriate serialization/deserialization:

// In your Eloquent model
public function getOrderLinesAttribute($value)
{
    $data = json_decode($value, true);
    return array_map(function ($line) {
        return new OrderLine(
            $line['product_id'],
            $line['quantity'],
            new Money($line['price_amount'], $line['price_currency'])
        );
    }, $data);
}

public function setOrderLinesAttribute($orderLines)
{
    $this->attributes['order_lines'] = json_encode(array_map(function (OrderLine $line) {
        return [
            'product_id' => $line->productId(),
            'quantity' => $line->quantity(),
            'price_amount' => $line->price()->amount(),
            'price_currency' => $line->price()->currency(),
        ];
    }, $orderLines));
}

Integrating with Laravel Packages

Many Laravel packages can be integrated into a DDD architecture:

1. Laravel Sanctum for Authentication

Keep authentication in the infrastructure layer, using Sanctum for the mechanism while defining your own domain interfaces:

// app/Domain/Authentication/AuthenticationServiceInterface.php
namespace App\Domain\Authentication;

use App\Domain\Users\Models\User;

interface AuthenticationServiceInterface
{
    public function authenticate(string $email, string $password): ?User;
    public function logout(User $user): void;
}

// app/Infrastructure/Authentication/SanctumAuthenticationService.php
namespace App\Infrastructure\Authentication;

use App\Domain\Authentication\AuthenticationServiceInterface;
use App\Domain\Users\Models\User;
use Illuminate\Support\Facades\Auth;

class SanctumAuthenticationService implements AuthenticationServiceInterface
{
    public function authenticate(string $email, string $password): ?User
    {
        if (Auth::attempt(['email' => $email, 'password' => $password])) {
            return Auth::user();
        }
        
        return null;
    }
    
    public function logout(User $user): void
    {
        $user->tokens()->delete();
    }
}

This approach allows you to change the authentication mechanism without affecting your domain logic.

2. Laravel Nova or Filament for Admin Interfaces

Admin panels can be treated as a separate bounded context that consumes your domain model:

// app/Interfaces/Admin/Resources/OrderResource.php
namespace App\Interfaces\Admin\Resources;

use App\Domain\Orders\Models\Order;
use Laravel\Nova\Fields\ID;
use Laravel\Nova\Fields\Text;
use Laravel\Nova\Fields\BelongsTo;
use Laravel\Nova\Resource;

class OrderResource extends Resource
{
    public static $model = Order::class;
    
    public function fields(Request $request)
    {
        return [
            ID::make()->sortable(),
            Text::make('Status'),
            BelongsTo::make('Customer'),
            // Other fields...
        ];
    }
    
    public function actions(Request $request)
    {
        return [
            new ShipOrder,
            new CancelOrder,
        ];
    }
}

The admin panel interacts with your domain through the same interfaces as your main application, ensuring consistency.

3. Laravel Livewire for Interactive UIs

Livewire components can be treated as part of your presentation layer, communicating with your application services:

// app/Interfaces/Web/Livewire/OrdersList.php
namespace App\Interfaces\Web\Livewire;

use App\Application\Queries\FindOrdersForCustomerQuery;
use Livewire\Component;

class OrdersList extends Component
{
    public $status = null;
    public $dateFrom = null;
    public $dateTo = null;
    
    public function render(FindOrdersForCustomerQuery $query)
    {
        $filters = [
            'status' => $this->status,
            'date_from' => $this->dateFrom,
            'date_to' => $this->dateTo,
        ];
        
        $orders = $query->execute(auth()->id(), $filters);
        
        return view('livewire.orders-list', [
            'orders' => $orders,
        ]);
    }
}

This maintains the separation of concerns while leveraging Livewire's interactivity.

Testing DDD Laravel Applications

A well-structured DDD application is highly testable. Here are approaches for different layers:

1. Domain Layer Tests

Test domain objects, value objects, and domain services in isolation:

// tests/Unit/Domain/Orders/Models/OrderTest.php
namespace Tests\Unit\Domain\Orders\Models;

use App\Domain\Orders\Models\Order;
use App\Domain\Orders\Events\OrderShipped;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Tests\TestCase;

class OrderTest extends TestCase
{
    use RefreshDatabase;
    
    public function test_order_can_be_shipped_when_in_ready_state()
    {
        Event::fake();
        
        $order = Order::factory()->create(['status' => 'ready_to_ship']);
        
        $order->ship();
        
        $this->assertEquals('shipped', $order->status);
        $this->assertNotNull($order->shipped_at);
        Event::assertDispatched(OrderShipped::class);
    }
    
    public function test_order_cannot_be_shipped_when_not_ready()
    {
        $order = Order::factory()->create(['status' => 'pending']);
        
        $this->expectException(\DomainException::class);
        
        $order->ship();
    }
}

2. Application Layer Tests

Test application services with mocked dependencies:

// tests/Unit/Application/Services/OrderApplicationServiceTest.php
namespace Tests\Unit\Application\Services;

use App\Application\DTOs\OrderDTO;
use App\Application\Services\OrderApplicationService;
use App\Domain\Customers\Models\Customer;
use App\Domain\Customers\Repositories\CustomerRepositoryInterface;
use App\Domain\Orders\Models\Order;
use App\Domain\Orders\Repositories\OrderRepositoryInterface;
use App\Domain\Orders\Services\OrderProcessingService;
use App\Domain\Orders\ValueObjects\Address;
use App\Domain\Orders\ValueObjects\Money;
use Mockery;
use Tests\TestCase;

class OrderApplicationServiceTest extends TestCase
{
    public function test_place_order_creates_and_processes_order()
    {
        // Arrange
        $customer = new Customer(['id' => 1, 'name' => 'Test Customer']);
        
        $customerRepo = Mockery::mock(CustomerRepositoryInterface::class);
        $customerRepo->shouldReceive('findById')->with(1)->andReturn($customer);
        
        $orderRepo = Mockery::mock(OrderRepositoryInterface::class);
        $orderRepo->shouldReceive('save')->once();
        
        $processingService = Mockery::mock(OrderProcessingService::class);
        $processingService->shouldReceive('processOrder')->once();
        
        $service = new OrderApplicationService(
            $customerRepo,
            $orderRepo,
            $processingService
        );
        
        $dto = new OrderDTO(
            1,
            [['product_id' => 1, 'quantity' => 2]],
            new Address('123 Main St', 'Anytown', 'CA', '12345', 'USA'),
            new Money(100.00, 'USD')
        );
        
        // Act
        $result = $service->placeOrder($dto);
        
        // Assert
        $this->assertInstanceOf(Order::class, $result);
        $this->assertEquals(1, $result->customer_id);
        $this->assertEquals('pending', $result->status);
    }
}

3. Infrastructure Layer Tests

Test repositories and external service integrations:

// tests/Unit/Infrastructure/Repositories/EloquentOrderRepositoryTest.php
namespace Tests\Unit\Infrastructure\Repositories;

use App\Domain\Orders\Models\Order;
use App\Infrastructure\Repositories\EloquentOrderRepository;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class EloquentOrderRepositoryTest extends TestCase
{
    use RefreshDatabase;
    
    public function test_find_by_id_returns_order()
    {
        // Arrange
        $order = Order::factory()->create();
        $repository = new EloquentOrderRepository();
        
        // Act
        $result = $repository->findById($order->id);
        
        // Assert
        $this->assertInstanceOf(Order::class, $result);
        $this->assertEquals($order->id, $result->id);
    }
    
    public function test_find_pending_orders_returns_only_pending_orders()
    {
        // Arrange
        Order::factory()->create(['status' => 'pending']);
        Order::factory()->create(['status' => 'pending']);
        Order::factory()->create(['status' => 'shipped']);
        
        $repository = new EloquentOrderRepository();
        
        // Act
        $result = $repository->findPendingOrders();
        
        // Assert
        $this->assertCount(2, $result);
        $this->assertEquals('pending', $result[0]->status);
        $this->assertEquals('pending', $result[1]->status);
    }
}

4. Interface Layer Tests

Use Laravel's testing tools for controllers, API endpoints, and Livewire components:

// tests/Feature/Interfaces/Web/Controllers/OrderControllerTest.php
namespace Tests\Feature\Interfaces\Web\Controllers;

use App\Domain\Customers\Models\Customer;
use App\Domain\Products\Models\Product;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class OrderControllerTest extends TestCase
{
    use RefreshDatabase;
    
    public function test_store_creates_new_order()
    {
        // Arrange
        $customer = Customer::factory()->create();
        $product = Product::factory()->create(['price' => 50.00]);
        
        $user = $customer->user;
        $this->actingAs($user);
        
        // Act
        $response = $this->post('/orders', [
            'customer_id' => $customer->id,
            'items' => [
                ['product_id' => $product->id, 'quantity' => 2],
            ],
            'shipping_address' => [
                'street' => '123 Main St',
                'city' => 'Anytown',
                'state' => 'CA',
                'postal_code' => '12345',
                'country' => 'USA',
            ],
            'total_amount' => [
                'amount' => 100.00,
                'currency' => 'USD',
            ],
        ]);
        
        // Assert
        $response->assertRedirect(route('orders.show', 1));
        $response->assertSessionHas('success');
        
        $this->assertDatabaseHas('orders', [
            'customer_id' => $customer->id,
            'status' => 'pending',
        ]);
    }
}

A well-designed testing strategy ensures that your domain logic is thoroughly tested in isolation, while integration tests verify that all layers work together correctly.

Real-World Examples and Use Cases

DDD is particularly valuable for complex business domains. Here are some real-world examples where DDD with Laravel shines:

1. E-commerce Platform

An e-commerce platform involves multiple complex domains such as product catalog, inventory management, order processing, payment processing, and customer management. Using DDD, each of these can be modeled as separate bounded contexts with clear interfaces between them.

For example, the inventory context might provide services to check stock levels, while the order context consumes these services without needing to understand the internal complexities of inventory management.

2. Financial Systems

Financial applications require precise business rules and often involve complex calculations, workflows, and regulatory requirements. DDD's focus on capturing domain knowledge and explicit business rules makes it ideal for financial systems.

For instance, a lending application might have bounded contexts for loan applications, risk assessment, payment processing, and customer management, each with its own specific business rules and domain experts.

3. Healthcare Systems

Healthcare systems involve complex relationships between patients, practitioners, appointments, medical records, billing, and insurance. Each of these domains has its own terminology, rules, and processes.

Using DDD, you can model each healthcare domain separately while ensuring they can communicate effectively, resulting in a system that accurately reflects the complexities of healthcare operations.

Comparing DDD with Traditional Laravel Development

Let's compare traditional Laravel development with a DDD approach:

Traditional Laravel Approach

In a traditional Laravel application:

  • Organization: Code is organized by technical role (controllers, models, views)
  • Models: Typically anemic, focused on data access with minimal business logic
  • Business Logic: Often placed in controllers or service classes without clear boundaries
  • Domain Concepts: Implicit rather than explicitly modeled

This approach works well for simpler applications but can become unwieldy as complexity grows.

DDD Laravel Approach

In a Laravel application using DDD:

  • Organization: Code is organized by business domain and then by technical role within each domain
  • Models: Rich with business logic and behavior
  • Business Logic: Clearly separated into domain, application, and infrastructure layers
  • Domain Concepts: Explicitly modeled with a ubiquitous language

This approach requires more initial investment but pays dividends for complex applications through improved maintainability and alignment with business needs.

Conclusion

Domain-Driven Design offers a powerful approach to managing complexity in Laravel applications, particularly for enterprise-scale systems with complex business domains. By organizing code around business concepts and creating clear boundaries between different parts of the system, DDD enables teams to build more maintainable and business-aligned applications.

While implementing DDD with Laravel requires some adjustments to the default framework structure, the flexibility of Laravel makes it well-suited for DDD principles. By leveraging Laravel's features within a DDD architecture, you can create applications that are both powerful and maintainable.

For teams working on complex applications, the investment in learning and applying DDD principles can lead to significant benefits in terms of code quality, maintainability, and alignment with business objectives. As your Laravel applications grow in complexity, consider whether Domain-Driven Design might be the right approach for structuring your code and capturing your business domain.


Related Posts

If you're already working with Laravel clean architecture patterns, DDD is a natural next step that builds on those foundations to create even more robust, business-focused applications. By combining DDD principles with Laravel's elegant syntax and powerful features, you can build enterprise-grade applications that are both sophisticated and maintainable.

Share this article

Add Comment

No comments yet. Be the first to comment!

More from Laravel