DEV Community

Cover image for Taming the Laravel Monolith: A Deep Dive into DDD πŸ—οΈ
Prajapati Paresh
Prajapati Paresh

Posted on • Originally published at smarttechdevs.in

Taming the Laravel Monolith: A Deep Dive into DDD πŸ—οΈ

The Crisis of the MVC Monolith

When you first start building a Laravel application, the standard Model-View-Controller (MVC) architecture feels like magic. You place your models in the app/Models directory, your controllers in app/Http/Controllers, and your business logic organically spreads between the two. However, as your enterprise application scales over months or years, this default structure begins to break down. You suddenly find yourself staring at an app/Models folder containing 150 unrelated files, ranging from User and Invoice to ShippingLabel and TaxCalculation.

The core problem with the standard MVC folder structure is that it groups files by their technical type rather than their business purpose. A developer tasked with fixing a bug in the "Invoicing" system has to jump between the Models folder, the Controllers folder, the Events folder, and the Listeners folder, slowly piecing together how the system works. This cognitive overload leads to tightly coupled code, where changing a user's profile accidentally breaks the billing system.

At Smart Tech Devs, when we architect large-scale enterprise platforms, we abandon the standard MVC folder structure. Instead, we embrace Domain-Driven Design (DDD). DDD is a software engineering approach that aligns your codebase directly with the business domains it serves, making your code modular, scalable, and infinitely easier to maintain.

Understanding the Core Concepts of DDD

Before writing any code, it is crucial to understand the terminology and philosophy behind Domain-Driven Design. DDD is not just a folder structure; it is a way of thinking about your business.

1. Bounded Contexts

In a large application, the same word can mean different things depending on the context. For example, to the "Shipping" department, a Customer is just a name and a physical address. To the "Billing" department, a Customer is a credit card token and a tax ID. In DDD, we define strict "Bounded Contexts." Instead of one massive User model that knows about shipping, billing, and marketing, we isolate these concepts into separate domains.

2. Ubiquitous Language

Your code should read like a business conversation. If the business stakeholders say, "When a customer upgrades their subscription, we generate a prorated invoice," your code should explicitly contain classes like UpgradeSubscriptionAction and GenerateProratedInvoice, rather than a generic UserController@update method.

Restructuring Laravel for DDD

To implement DDD, we typically create a new src/ directory at the root of the Laravel project and update the composer.json file to autoload it. We divide this directory into distinct business Domains (e.g., Invoicing, Identity, Inventory).


// πŸ“‚ Example Folder Structure
src/
  β”œβ”€β”€ Domain/
  β”‚   β”œβ”€β”€ Invoicing/
  β”‚   β”‚   β”œβ”€β”€ Models/
  β”‚   β”‚   β”œβ”€β”€ DataTransferObjects/
  β”‚   β”‚   β”œβ”€β”€ ValueObjects/
  β”‚   β”‚   β”œβ”€β”€ Actions/
  β”‚   β”‚   └── Events/
  β”‚   └── Identity/
  β”œβ”€β”€ App/
  β”‚   β”œβ”€β”€ Http/
  β”‚   β”‚   β”œβ”€β”€ Controllers/
  β”‚   └── Console/

Step-by-Step Implementation

Step 1: Leveraging Value Objects

In standard Laravel, we often represent money as an integer or a float on a model. This leads to logic duplication when formatting or calculating totals. In DDD, we use Value Objectsβ€”immutable classes that represent a descriptive aspect of the domain with no conceptual identity.


namespace Domain\Invoicing\ValueObjects;

use InvalidArgumentException;

class Money
{
    private int $amountInCents;
    private string $currency;

    public function __construct(int $amountInCents, string $currency = 'USD')
    {
        if ($amountInCents < 0) {
            throw new InvalidArgumentException("Amount cannot be negative.");
        }

        $this->amountInCents = $amountInCents;
        $this->currency = $currency;
    }

    public function add(Money $other): self
    {
        if ($this->currency !== $other->currency) {
            throw new InvalidArgumentException("Currency mismatch.");
        }

        return new self($this->amountInCents + $other->amountInCents, $this->currency);
    }

    public function getFormatted(): string
    {
        return number_format($this->amountInCents / 100, 2) . ' ' . $this->currency;
    }
}

Step 2: Securing Boundaries with Data Transfer Objects (DTOs)

Controllers should not pass raw HTTP Request arrays into your business logic. This creates a brittle dependency on the HTTP layer. Instead, we map the request data into strongly typed Data Transfer Objects (DTOs) before passing it to the domain layer.


namespace Domain\Invoicing\DataTransferObjects;

class CreateInvoiceDTO
{
    public function __construct(
        public readonly int $customerId,
        public readonly array $lineItems,
        public readonly string $dueDate
    ) {}

    public static function fromRequest(Request $request): self
    {
        return new self(
            $request->validated('customer_id'),
            $request->validated('items'),
            $request->validated('due_date')
        );
    }
}

Step 3: Encapsulating Business Logic in Actions

With our data validated and wrapped in a DTO, we execute the actual business logic using an Action class (sometimes called an Application Service or Command). Actions have a single responsibility: execute a specific business use case.


namespace Domain\Invoicing\Actions;

use Domain\Invoicing\Models\Invoice;
use Domain\Invoicing\DataTransferObjects\CreateInvoiceDTO;
use Illuminate\Support\Facades\DB;

class CreateInvoiceAction
{
    public function execute(CreateInvoiceDTO $dto): Invoice
    {
        return DB::transaction(function () use ($dto) {
            $invoice = Invoice::create([
                'customer_id' => $dto->customerId,
                'due_date' => $dto->dueDate,
                'status' => 'draft',
            ]);

            foreach ($dto->lineItems as $item) {
                $invoice->items()->create($item);
            }

            // Fire domain event
            InvoiceCreated::dispatch($invoice);

            return $invoice;
        });
    }
}

Step 4: Thinning Out the Controller

Because the complex logic is encapsulated in the Domain layer, our Application layer (the Controller) becomes incredibly thin. Its only job is to receive the HTTP request, instantiate the DTO, call the Action, and return an HTTP response. If we ever want to trigger this exact same logic from an Artisan CLI command, we simply call the Action without touching the Controller.


namespace App\Http\Controllers\Invoicing;

use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Domain\Invoicing\Actions\CreateInvoiceAction;
use Domain\Invoicing\DataTransferObjects\CreateInvoiceDTO;
use Illuminate\Http\Request;

class InvoiceController extends Controller
{
    public function store(Request $request, CreateInvoiceAction $action): JsonResponse
    {
        $dto = CreateInvoiceDTO::fromRequest($request);
        
        $invoice = $action->execute($dto);

        return response()->json([
            'message' => 'Invoice generated successfully',
            'invoice_id' => $invoice->id
        ], 201);
    }
}

The Long-Term Engineering ROI

Migrating a Laravel application to a Domain-Driven Design architecture introduces undeniable upfront complexity. You are writing more files, defining more strict boundaries, and investing more time in architectural planning. However, for an application expected to live for years and be maintained by dozens of developers, this investment pays massive dividends.

By organizing code by business domain, new developers can onboard significantly faster. If they are tasked with fixing a billing bug, they know exactly where the Invoicing domain lives. Furthermore, DDD sets the perfect foundation for future transitions into microservices. Because your domains are already decoupled, splitting the Invoicing folder into its own standalone microservice later down the road becomes a matter of infrastructure, not a massive code rewrite.

Top comments (0)