Domain-Driven Design in Laravel: A Practical Guide
Why the Default Structure Breaks Down
Laravel scaffolds you into a flat structure: app/Models, app/Http/Controllers, app/Jobs. For a CRUD app with a few models, this is fine. For a domain with real complexity — tenants, billing, permissions, workflows — it falls apart fast.
Controllers end up calling Eloquent directly. Models grow to 500 lines with methods like sendWelcomeEmail() sitting next to scopeActive(). Business rules scatter across listeners, observers, and form requests with no clear home.
Domain-Driven Design doesn't solve this by adding ceremony — it solves it by giving every concept a home that matches the business domain.
The Folder Structure
Stop organizing by technical layer. Organize by domain.
app/
Domain/
Billing/
Models/
Invoice.php
Subscription.php
Actions/
CreateInvoice.php
CancelSubscription.php
Events/
InvoicePaid.php
Contracts/
BillingGateway.php
Users/
Models/
User.php
Actions/
RegisterUser.php
SuspendUser.php
DTOs/
RegisterUserData.php
App/ ← thin layer: HTTP, console, jobs
Http/
Controllers/
BillingController.php
Console/
Commands/
RenewSubscriptions.php
The Domain namespace contains pure business logic. The App namespace contains delivery mechanisms (HTTP, CLI, queues) that call into the domain.
Actions Over Fat Controllers
An Action is a single-purpose class that executes one business operation. It replaces the logic that would normally live in a controller or model.
// app/Domain/Users/Actions/RegisterUser.php
class RegisterUser
{
public function __construct(
private readonly UserRepository $users,
private readonly EventDispatcher $events,
) {}
public function execute(RegisterUserData $data): User
{
if ($this->users->existsByEmail($data->email)) {
throw new EmailAlreadyTaken($data->email);
}
$user = $this->users->create([
'name' => $data->name,
'email' => $data->email,
'password' => Hash::make($data->password),
]);
$this->events->dispatch(new UserRegistered($user));
return $user;
}
}
The controller becomes trivially thin:
class RegisterController extends Controller
{
public function __invoke(RegisterRequest $request, RegisterUser $action): JsonResponse
{
$user = $action->execute(RegisterUserData::fromRequest($request));
return response()->json(['user' => UserResource::make($user)], 201);
}
}
Data Transfer Objects
DTOs carry validated data between layers without relying on arrays. They make the shape of your data explicit and typesafe.
// app/Domain/Users/DTOs/RegisterUserData.php
class RegisterUserData
{
public function __construct(
public readonly string $name,
public readonly string $email,
public readonly string $password,
) {}
public static function fromRequest(RegisterRequest $request): self
{
return new self(
name: $request->validated('name'),
email: $request->validated('email'),
password: $request->validated('password'),
);
}
}
No more $request->all() leaking through your domain. The Action only knows about RegisterUserData, not about HTTP.
Domain Events
Domain events decouple side effects from the core action. The Action fires an event; listeners handle the consequences.
// After user registration, these run independently:
class SendWelcomeEmail implements ShouldQueue
{
public function handle(UserRegistered $event): void
{
Mail::to($event->user)->send(new WelcomeMail($event->user));
}
}
class CreateDefaultWorkspace implements ShouldQueue
{
public function handle(UserRegistered $event): void
{
Workspace::createDefault($event->user);
}
}
The RegisterUser action doesn't know about emails or workspaces. It fires UserRegistered and moves on.
When Not to Use DDD
DDD adds structure. Structure has a maintenance cost. Don't apply it to:
- Simple CRUD sections with no business logic
- Admin panels
- Internal tooling
Apply it where complexity genuinely lives: billing, permissions, multi-tenancy, complex workflows. Let the rest stay simple.
The goal is not to use DDD everywhere — it's to have a place for complex logic that isn't a 600-line controller.
Enjoyed this post?
Subscribe to the newsletter
Get future posts delivered to your inbox. No spam, unsubscribe anytime.