The Problem with Default Laravel Structure
Laravel's default structure is perfect for small to medium projects. But as your application grows, the app/Models, app/Http/Controllers, and app/Services folders become dumping grounds for unrelated code. A User model might handle authentication, billing, notification preferences, and team management all at once.
Domain-Driven Design (DDD) gives us a vocabulary and set of patterns to organize code around business concepts rather than technical layers.
Restructuring: From Layers to Domains
Instead of organizing by technical concern, organize by business domain:
app/
├── Domain/
│ ├── Billing/
│ │ ├── Models/
│ │ │ ├── Invoice.php
│ │ │ └── Subscription.php
│ │ ├── Actions/
│ │ │ ├── CreateInvoice.php
│ │ │ └── ProcessPayment.php
│ │ ├── Events/
│ │ │ └── PaymentReceived.php
│ │ ├── ValueObjects/
│ │ │ └── Money.php
│ │ └── Exceptions/
│ │ └── PaymentFailedException.php
│ ├── Identity/
│ │ ├── Models/
│ │ │ ├── User.php
│ │ │ └── Team.php
│ │ └── Actions/
│ │ ├── RegisterUser.php
│ │ └── InviteTeamMember.php
│ └── Catalog/
│ ├── Models/
│ └── Actions/
├── App/
│ ├── Http/Controllers/ ← thin controllers
│ ├── Console/Commands/
│ └── Providers/
Value Objects: Encapsulating Business Rules
A value object is an immutable object that represents a concept with no identity — it's defined entirely by its attributes. Money, Email, Address are classic examples.
class Money
{
public function __construct(
private readonly int $amount,
private readonly string $currency = 'USD'
) {
if ($amount < 0) {
throw new InvalidArgumentException('Amount cannot be negative');
}
}
public function add(Money $other): self
{
if ($this->currency !== $other->currency) {
throw new CurrencyMismatchException();
}
return new self($this->amount + $other->amount, $this->currency);
}
public function multiply(int $factor): self
{
return new self($this->amount * $factor, $this->currency);
}
public function formatted(): string
{
return number_format($this->amount / 100, 2) . ' ' . $this->currency;
}
public function equals(Money $other): bool
{
return $this->amount === $other->amount
&& $this->currency === $other->currency;
}
}
Now instead of passing raw integers around and hoping everyone remembers "is this cents or dollars?", you have a self-documenting, self-validating type.
Actions: Single-Purpose Business Operations
Actions (also called "use cases" or "commands") encapsulate a single business operation. They're the entry point to your domain logic.
class CreateInvoice
{
public function __construct(
private readonly InvoiceRepository $invoices,
private readonly TaxCalculator $taxCalculator,
) {}
public function execute(CreateInvoiceData $data): Invoice
{
$subtotal = $data->lineItems->sum(
fn (LineItem $item) => $item->total()->amount
);
$tax = $this->taxCalculator->calculate(
new Money($subtotal),
$data->taxRegion
);
$invoice = $this->invoices->create([
'customer_id' => $data->customerId,
'subtotal' => $subtotal,
'tax' => $tax->amount,
'total' => $subtotal + $tax->amount,
'due_date' => $data->dueDate,
'status' => InvoiceStatus::Draft,
]);
event(new InvoiceCreated($invoice));
return $invoice;
}
}
Domain Events: Decoupling Side Effects
When something important happens in your domain, fire an event. This keeps your core logic clean and lets other parts of the system react independently.
// The event — a simple data carrier
class PaymentReceived
{
public function __construct(
public readonly int $invoiceId,
public readonly Money $amount,
public readonly string $paymentMethod,
public readonly Carbon $paidAt,
) {}
}
// Listeners handle side effects
class SendPaymentConfirmation
{
public function handle(PaymentReceived $event): void
{
$invoice = Invoice::findOrFail($event->invoiceId);
Mail::to($invoice->customer->email)->send(
new PaymentConfirmationMail($invoice, $event->amount)
);
}
}
class UpdateAccountBalance
{
public function handle(PaymentReceived $event): void
{
// Update the customer's balance
}
}
class NotifyAccountingTeam
{
public function handle(PaymentReceived $event): void
{
// Send Slack notification
}
}
Data Transfer Objects
DTOs carry data between layers without behavior. They make your function signatures explicit and refactoring-friendly.
class CreateInvoiceData
{
public function __construct(
public readonly int $customerId,
public readonly Collection $lineItems,
public readonly string $taxRegion,
public readonly Carbon $dueDate,
) {}
public static function fromRequest(Request $request): self
{
return new self(
customerId: $request->integer('customer_id'),
lineItems: collect($request->input('line_items'))->map(
fn ($item) => new LineItem($item['description'], $item['quantity'], $item['unit_price'])
),
taxRegion: $request->string('tax_region'),
dueDate: Carbon::parse($request->input('due_date')),
);
}
}
When to Apply DDD (And When Not To)
- Use DDD when your business logic is complex, involves multiple rules, and changes frequently
- Skip DDD for simple CRUD applications, prototypes, or admin panels
- Start simple — you can always refactor towards DDD as complexity grows
- Don't apply every DDD pattern — pick the ones that solve your actual problems
"The goal of DDD is not to use every pattern in the book. It's to build a shared understanding of the domain between developers and domain experts, and to reflect that understanding in code."