Ever looked at a codebase and thought "I need a map, a compass, and maybe a therapist"? We've all been there. That moment when your UserController
has grown to 500 lines, handles everything from authentication to sending birthday emails, and makes you question your career choices.
What if I told you there's a way to write code so clean, so organized, that future-you will actually thank present-you? Enter Domain-Driven Design (DDD) and Command Query Responsibility Segregation (CQRS) – not just fancy acronyms, but your secret weapons against the chaos.
Let's dive into a real-world example that'll make you fall in love with architecture again.
The Grand Architecture: Four Layers of Zen
Think of our application like a well-organized house. Each floor has its purpose, and nobody's doing laundry in the kitchen (hopefully).
Layer 1: The Domain – Where Business Logic Lives Its Best Life
This is your application's soul. Pure, untainted business logic that doesn't care whether you're using Laravel, Symfony, or coding with carrier pigeons.
class User {
private function __construct(
private UserId $id,
private UserName $name,
private Email $email,
private HashedPassword $password,
private UserRole $role,
private \DateTimeInterface $createdAt,
private ?\DateTimeInterface $updatedAt = null
) {}
public static function create(
UserName $name,
Email $email,
HashedPassword $password,
UserRole $role
): self {
return new self(
UserId::generate(),
$name,
$email,
$password,
$role,
new \DateTimeImmutable()
);
}
}
Notice something beautiful here? This User
doesn't know about databases, HTTP requests, or Laravel. It's just being a user, doing user things. Chef's kiss.
Value Objects: The Unsung Heroes
Remember the last time you accidentally passed an email where you meant to pass a name? Value Objects are here to save you from those 2 AM debugging sessions:
readonly class Password {
public function __construct(private string $value) {
if (strlen($value) < 8) {
throw new \InvalidArgumentException('Password must be at least 8 characters');
}
}
public function hash(): HashedPassword {
return new HashedPassword(password_hash($this->value, PASSWORD_DEFAULT));
}
}
Now you literally cannot create an invalid password. The code won't even compile. It's like having a bouncer for your data types.
Layer 2: Application – The Master Orchestrator
This layer is like a symphony conductor, making sure every instrument plays at the right time. It orchestrates business scenarios through Commands and Handlers.
Commands: Speaking in Business Language
readonly class RegisterUserCommand {
public function __construct(
public UserName $name,
public Email $email,
public Password $password,
public UserRole $role
) {}
}
Look at that! No arrays, no random string parameters. Just pure, expressive intent. When you see RegisterUserCommand
, you know exactly what's about to happen.
Handlers: Where the Magic Happens
class RegisterUserHandler {
public function handle(RegisterUserCommand $command): User {
// Business rule: No duplicate emails
if ($this->userRepository->existsByEmail($command->email)) {
throw new EmailAlreadyExistsException();
}
// Create the user (domain logic)
$user = User::create(
$command->name,
$command->email,
$command->password->hash(),
$command->role
);
// Persist it
$this->userRepository->save($user);
// Tell the world what happened
$this->eventDispatcher->dispatch(
new UserRegistered($user, new \DateTimeImmutable())
);
return $user;
}
}
This handler reads like a story: "Check if email exists, create user, save user, announce the good news." No guesswork, no surprises.
Layer 3: Infrastructure – The Dirty Work Department
This is where we get our hands dirty with databases, APIs, and all the messy real-world stuff. But here's the kicker – it's completely replaceable.
class EloquentUserRepository implements UserRepositoryInterface {
public function save(User $user): void {
// Translate domain entity to database row
$eloquentUser = EloquentUser::updateOrCreate(
['id' => $user->id()->value()],
[
'name' => $user->name()->value(),
'email' => $user->email()->value(),
'password' => $user->password()->value(),
]
);
$eloquentUser->assignRole($user->role()->value);
}
private function toDomainEntity(EloquentUser $eloquentUser): User {
// Translate database row back to domain entity
return new User(
new UserId($eloquentUser->id),
new UserName($eloquentUser->name),
new Email($eloquentUser->email),
new HashedPassword($eloquentUser->password),
UserRole::from($eloquentUser->roles->first()?->name ?? 'user'),
$eloquentUser->created_at,
$eloquentUser->updated_at
);
}
}
See what happened? We're translating between our pure domain objects and Eloquent models. Tomorrow, if you decide to switch to MongoDB, you just write a new repository. Your business logic stays untouched.
Layer 4: Presentation – The People Pleaser
This layer speaks HTTP, handles JSON, and deals with all the web-specific drama:
class AuthController extends Controller {
public function register(RegisterUserRequest $request): JsonResponse {
try {
$data = $request->validated();
// Build the command from HTTP data
$command = new RegisterUserCommand(
new UserName($data['name']),
new Email($data['email']),
new Password($data['password']),
UserRole::from($data['role'])
);
// Execute business logic
$user = $this->registerHandler->handle($command);
$token = $this->tokenService->generate($user);
// Return HTTP response
return response()->json([
'success' => true,
'token' => $token,
'user' => [
'id' => $user->id()->value(),
'name' => $user->name()->value(),
'email' => $user->email()->value(),
'role' => $user->role()->value
]
], 201);
} catch (EmailAlreadyExistsException $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage()
], 422);
}
}
}
The controller is now thin, focused, and honest about its job: translating between HTTP and your application.
Why This Architecture Will Change Your Life
1. Testing Becomes Almost Fun (Almost)
Remember struggling to test controllers that talked directly to the database? Those days are over:
// Mock the repository, test the business logic
$mockRepository = $this->createMock(UserRepositoryInterface::class);
$mockRepository->expects($this->once())->method('existsByEmail')->willReturn(false);
$handler = new RegisterUserHandler($mockRepository, $eventDispatcher);
$user = $handler->handle($command);
$this->assertEquals('John Doe', $user->name()->value());
Pure bliss. No database setup, no HTTP mocking. Just pure logic testing.
2. Framework Independence (Finally!)
Your business logic doesn't care if Laravel goes out of fashion or if you need to migrate to Symfony. The domain layer is framework-agnostic:
// This works regardless of your framework choice
$user = User::create($name, $email, $password, $role);
3. Type Safety That Actually Saves You
With Value Objects, this literally won't compile:
// Compiler error: Cannot pass string where Email expected
$user = User::create("John", "not-an-email-object", $password, $role);
Your IDE catches bugs before your users do. Revolutionary, right?
4. Error Handling That Makes Sense
Gone are the days of cryptic exception messages:
try {
$user = $handler->handle($command);
} catch (EmailAlreadyExistsException $e) {
// Crystal clear what went wrong
} catch (UserNotFoundException $e) {
// This one too
}
No more detective work to figure out why something failed.
5. Events: The Social Network of Your Code
Components can react to what happens without tight coupling:
class UserRegistered {
public function __construct(
private User $user,
private \DateTimeInterface $occurredAt
) {}
}
Want to send a welcome email? Listen to UserRegistered
. Need analytics? Same event. Your registration code doesn't need to know or care about these features.
Real Talk: When to Use This Architecture
Do Use It When:
- Your business logic is complex
- You have a team of more than 3 developers
- The project will live longer than 6 months
- You value your sanity and sleep
Don't Use It When:
- Building a simple CRUD app
- Working on a weekend hackathon project
- Your business logic fits in one file
- You enjoy suffering (just kidding!)
Getting Started Without Losing Your Mind
Start Small
Don't rewrite everything at once. Pick one feature, implement it with this architecture, and see how it feels. I guarantee you'll want to do more.
Invest in Tests Early
This architecture makes testing so much easier. Write tests as you go – future-you will send thank-you cards.
Document Your Boundaries
Know which code belongs where. Draw diagrams if needed. Your teammates will appreciate the clarity.
Embrace the Learning Curve
Yes, it feels like more work initially. But remember: you're not just writing code for today. You're writing code for the next developer who has to maintain it (which might be you in 6 months).
The Bottom Line
This isn't just about writing "enterprise" code or impressing your colleagues (though it will). It's about treating your craft with respect. It's about building software that can evolve, that can be understood, that doesn't require archaeological expeditions to modify.
Sure, you could keep throwing everything into controllers and hope for the best. But where's the fun in that? Where's the pride in craftsmanship?
Take the leap. Your future self – and your team – will thank you for it.
Now go forth and architect something beautiful.
Detail code you can watch here https://github.com/Igor-Nosatov/Laravel_DDD_and_CQRS
Top comments (0)