DEV Community

Igor Nosatov
Igor Nosatov

Posted on

From Spaghetti to Symphony: Taming Complex PHP Applications with DDD and CQRS

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()
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

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));
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

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)