Domain-Driven Design in Laravel: Lessons Learned
48 bounded contexts, single-action classes, and event sourcing in production. A practical guide to DDD patterns in a Laravel codebase.
When Laravel's default structure breaks down
Most Laravel projects start the same way. Scaffold some models, add controllers, write migrations. It works. Then the app grows, and suddenly you have a 400-line controller handling user registration, profile management, email verification, and subscription billing. All in one class. All touching the same User model.
We've hit this wall enough times to recognize it early. The codebase becomes hard to change, hard to test, and hard for new developers to navigate. Every change in one area risks breaking another, because everything is coupled through shared models and god-controllers.
Domain-Driven Design offered us a way to structure Laravel projects so different parts of the business logic are isolated from each other. Not perfectly isolated — pragmatism matters — but enough to keep things manageable. We've now applied these patterns across several production systems, most extensively in a core banking platform with 48 domain modules. Here's what we learned.
Bounded contexts: organizing 48 domain modules
The DDD concept that helped us most was bounded contexts. In practice, a bounded context is the boundary where a word like "Account" means one specific thing and everyone agrees on what it is.
Take "Account" as an example. In a banking context, an account is a financial ledger with a balance, transactions, and a currency. In an identity context, an account is login credentials with an email and password. In a compliance context, an account is a KYC profile with identity documents and a risk score. Same word, three completely different data structures, three different sets of business rules.
In our Laravel projects, we map bounded contexts to top-level directories under app/Domain:
app/
Domain/
Banking/
Models/
Events/
Actions/
Projections/
Identity/
Models/
Events/
Actions/
Exchange/
Lending/
Treasury/
Compliance/
...
In the banking platform, this structure scales to 48 domains, from core financial operations (Account, Exchange, Lending, Treasury) to digital assets (CrossChain, Custodian, DeFi) to AI and automation (AgentProtocol, Governance). Each domain has its own models, events, actions, and projectors. Domains communicate through domain events, not by directly calling each other's code.
To manage dependencies at this scale, each domain has a manifest file that declares its dependencies, interfaces, events, and commands. The module system resolves these manifests at boot time and ensures domains load in the correct order. You can enable or disable domains dynamically — php artisan module:disable exchange — without losing data or breaking other modules. This matters when different deployments need different feature sets.
Start with fewer contexts than you think you need. Our early attempts had too many fine-grained boundaries, domains with two or three models each. That's a sign you've over-decomposed. We consolidated several times before arriving at boundaries that felt natural. The right number of contexts is the one where each context has enough internal complexity to justify its own namespace.
Single-action classes over service classes
Early on, we followed the typical Laravel pattern: AccountService, TransactionService, UserService. These classes grew large and difficult to test because they handled multiple operations with shared state.
We switched to single-action classes, one class per operation:
app/Domain/Banking/Actions/
OpenAccount.php
PostTransaction.php
SuspendAccount.php
CalculateFee.php
CloseAccount.php
Each class has a single handle method, clear dependencies injected via the constructor, and one job to do. The advantages are practical: each action is independently testable, easy to find (the class name tells you what it does), and small enough to review in a pull request without scrolling.
The downside is more files. Our Banking domain alone has 30+ action files. But we'd rather have 30 small, focused files than 5 large, tangled ones. When a new developer asks "where does account suspension happen?" the answer is immediately obvious: SuspendAccount.php.
One pattern we adopted: actions can dispatch other actions but can't call domain logic directly in other contexts. If the Banking domain needs to trigger a Compliance check, it emits a TransactionPosted event that the Compliance domain listens for. This prevents the coupling that service classes encourage.
Event sourcing with Spatie and domain-specific event tables
We use the Spatie Event Sourcing package as our foundation, extended with domain-specific customizations. The key architectural decision was separating event storage per domain: instead of one stored_events table for the entire application, each domain stores events in its own table: exchange_events, lending_events, wallet_events.
This has several practical benefits:
- Performance: queries against a domain's events don't scan the entire event store
- Backup flexibility: high-volume domains can have different backup schedules
- Retention policies: compliance events might be retained for 7 years while analytics events are purged quarterly
- Debugging: when something goes wrong in the Exchange domain, you're looking at exchange events, not wading through millions of unrelated records
Each domain defines its own aggregate roots, events, and projectors. An aggregate root enforces business rules before emitting events. The Account aggregate checks balances before allowing withdrawals, validates currencies, and ensures the account is in an active state. Projectors listen for events and materialize read-optimized views.
We don't event-source everything. Updating a user's display name? Plain Eloquent update. Changing a notification preference? Same. Event sourcing is reserved for operations where audit trails, temporal queries, or replay debugging provide genuine business value. Over-applying event sourcing to trivial operations was one of our earlier mistakes.
Saga and workflow patterns for complex operations
Some operations span multiple bounded contexts and need coordination with compensation logic — if step 3 fails, undo steps 1 and 2. This is the saga pattern, and it comes up constantly in financial software.
A cross-currency transfer, for example, involves the Account domain (debit source), the Exchange domain (convert currency), the Account domain again (credit destination), and the Compliance domain (post-facto screening). If the exchange fails, the debit needs to be reversed. If compliance flags the transaction after execution, the entire chain needs to be unwound.
We implement sagas using Laravel Workflow (Waterline), which provides a clean way to define multi-step processes with automatic compensation. Each step defines both its forward action and its compensating action. The workflow engine handles execution order, retry logic, and rollback sequencing.
The alternative (scattering try/catch blocks across service methods with manual rollback logic) is what we did before sagas, and it was a maintenance disaster. Rollback paths were inconsistent, edge cases were handled differently depending on which developer wrote the code, and testing the failure scenarios was nearly impossible. Explicit saga definitions solve all three problems.
Static analysis at PHPStan level 8
With 48 domains and hundreds of classes, manual code review can't catch everything. We run PHPStan at level 8 — the strictest setting — across the entire codebase.
Level 8 enforces strict type checking, catches impossible type comparisons, validates return types, and flags dead code. In a codebase this size, the static analysis catches problems that tests don't: interface contract violations, incorrect generics usage, unreachable branches, and type coercion bugs that would only surface in edge cases at runtime.
Getting to level 8 was painful. We started at level 5 and incrementally tightened over several months, fixing thousands of type annotations along the way. But now, a PHPStan failure in CI is a hard gate. Nothing merges without passing. The initial investment paid for itself within weeks by catching bugs before they reached testing.
Combined with Pest PHP running 6,500+ tests across 925 test files, the quality gate gives us confidence to refactor aggressively. When you know the type system and the test suite will catch regressions, you're not afraid to move code between domains or restructure boundaries.
What we would do differently
Naming conventions. Establish them on day one. We didn't, and ended up with CreateAccount alongside AccountCreation alongside OpenAccount in the same codebase. Months of inconsistency before we settled on imperative verb + noun for actions (OpenAccount) and past-tense for events (AccountOpened). Should have been in the contributing guide from commit one.
Domain events for cross-context communication worked beautifully, until they didn't. Asynchronous propagation means there's a window where one domain's read model is stale relative to another's write. Usually fine. But for operations requiring immediate consistency (balance checks during transfers), we had to add synchronous pathways that partially violate boundary isolation. We call these "consistency exceptions" and document them explicitly. Pragmatic? Yes. Purist DDD? Not exactly.
The manifest system, on the other hand, was worth every hour. We initially thought it was over-engineering. Then a client deployment needed 12 of the 48 modules and we configured it in minutes instead of surgically removing code. The ability to disable modules, resolve dependency graphs automatically, and catch circular dependencies at boot time has saved us more than once.
DDD in Laravel is absolutely worth it for applications with complex business rules and multiple stakeholders. For a simple CRUD application, it's overkill. Standard Laravel with models and controllers is fine. The boundary is somewhere around the point where you start having arguments about where to put logic. When two developers disagree about whether something belongs in the Account model or the Transaction service, it probably belongs in its own context.
We applied the same DDD patterns when building a B2B marketplace for solar panel recycling, which proved the approach transfers well beyond fintech. If you're considering DDD for a growing Laravel codebase, we're happy to discuss what worked and what we'd skip.
Working on something similar?
We bring the same engineering approach to client projects. Tell us about yours.