Domain-Driven Design in Laravel: The Right Way
What is Domain-Driven Design?
Domain-Driven Design (DDD) is a software development approach that focuses on modeling software based on the business domain rather than technical implementation details. It emphasizes understanding the business, using consistent language between developers and domain experts, and organizing code around business concepts.
Introduction
DDD is an attempt to organize code around business domains instead of technical layers.
Laravel’s default structure organizes by technical concerns:
app/
├── Http/Controllers/
├── Models/
└── Services/This works fine initially, but as applications grow, it becomes painful to manage. Finding all code related to “Feedback” means hunting across Controllers, Models, Services, Jobs, Events, etc.
DDD groups everything by business domain:
app/Domain/
├── Feedback/ # Everything feedback-related
├── Branch/ # Everything branch-related
└── Client/ # Everything client-relatedThis article shows the practical approach used in production Laravel applications.
Important: DDD in Laravel doesn’t replace everything. It works alongside Laravel’s conventions:
- Routes still live in
routes/ - Migrations still live in
database/migrations/ - Tests still live in
tests/
You organize business logic by domain, but keep Laravel’s infrastructure structure intact.
The Correct Approach: Laravel-Native DDD
Here’s the structure that works:
app/
├── Domain/
│ ├── Feedback/
│ │ ├── Models/
│ │ │ └── Feedback.php
│ │ ├── Controllers/
│ │ │ └── FeedbackController.php
│ │ ├── Actions/
│ │ │ └── CreateFeedbackAction.php
│ │ ├── Services/
│ │ │ └── FeedbackTransformer.php
│ │ ├── Events/
│ │ │ ├── FeedbackCreated.php
│ │ │ └── FeedbackClosed.php
│ │ ├── Enums/
│ │ │ ├── FeedbackStatus.php
│ │ │ └── FeedbackType.php
│ │ ├── QueryBuilders/
│ │ │ └── FeedbackQueryBuilder.php
│ │ ├── Collections/
│ │ │ └── FeedbackCollection.php
│ │ ├── Contracts/
│ │ │ └── FeedbackStoreContract.php
│ │ └── Observers/
│ │ └── FeedbackObserver.php
│ ├── Branch/
│ ├── Client/
│ └── Business/
│
└── Support/ # Shared utilitiesroutes/
├── web.php # Or keep everything in api.php
├── api.php
├── feedback.php # Domain-specific routes
├── branch.php
└── client.phpdatabase/
└── migrations/ # Standard Laravel structuretests/
├── Feature/
│ ├── Feedback/
│ ├── Branch/
│ └── Client/
└── Unit/
Key Principles
1. Stay Within App\ Namespace
// ✅ Correct - Works with Laravel
namespace App\Domain\Feedback\Models;
use Illuminate\Database\Eloquent\Model;
class Feedback extends Model
{
// Domain logic lives here
}2. Models = Data + Relationships + Custom Query Builders
namespace App\Domain\Feedback\Models;
class Feedback extends Model
{
// Casts for type safety
protected $casts = [
'status' => FeedbackStatus::class,
'type' => FeedbackType::class,
'closed_at' => 'datetime',
];
// Relationships
public function client(): BelongsTo
{
return $this->belongsTo(Client::class);
}
// Use custom query builder
public function newEloquentBuilder($query): FeedbackQueryBuilder
{
return new FeedbackQueryBuilder($query);
}
}3. Actions = Business Workflows
Actions handle multi-step operations, validation, transactions, and events:
namespace App\Domain\Client\Actions;
class ClientFeedbackAction implements FeedbackStoreContract
{
public function store(FeedbackStoreRequestContract $request): JsonResponse
{
// 1. Start transaction
// 2. Resolve business logic (e.g., get business_id from branch/area)
// 3. Create feedback
// 4. Broadcast event
// 5. Commit transaction
// 6. Return response
}
public function close(Feedback $feedback): JsonResponse
{
// 1. Validate (already closed? belongs to user?)
// 2. Update status and timestamp
// 3. Broadcast event
// 4. Return response
}
}4. Services = Cross-Model Business Logic
namespace App\Domain\Feedback\Services;
class FeedbackTransformer
{
public function transformForClient(Feedback $feedback): array
{
// Transform data for API responses
// Calculate derived values (e.g., unread counts)
}
}5. Enums = Type-Safe Constants with Behavior
namespace App\Domain\Feedback\Enums;
enum FeedbackStatus: int
{
case PENDING = 1;
case ACTIVE = 2;
case CLOSED = 4;
public function label(): string { }
public function canTransitionTo(self $newStatus): bool { }
}6. Events = Domain Events for Broadcasting
namespace App\Domain\Feedback\Events;
class FeedbackCreated implements ShouldBroadcast
{
public function __construct(public Feedback $feedback) { }
public function broadcastOn(): array
{
return [new PrivateChannel("business.{$this->feedback->business_id}")];
}
}7. Collections = Data Transformations
namespace App\Domain\Feedback\Collections;
class FeedbackCollection extends Collection
{
public function transformForClientFrontend(): array
{
// Map collection items to API format
}
}8. Contracts = Interfaces for Swappable Implementations
namespace App\Domain\Feedback\Contracts;
interface FeedbackStoreContract
{
public function store(FeedbackStoreRequestContract $request): JsonResponse;
}9. QueryBuilders = Complex Query Logic
namespace App\Domain\Feedback\QueryBuilders;
class FeedbackQueryBuilder extends BaseQueryBuilder
{
//
}What This Approach Gets Right
✅ Leverages Laravel’s Strengths
- Uses Eloquent relationships and query scopes
- Uses Laravel’s validation (FormRequests)
- Uses Laravel’s events and broadcasting
- Uses Laravel’s service container
- Uses artisan make commands
✅ Maintains Separation of Concerns
- Models: Data + relationships + casts
- QueryBuilders: Complex queries with authorization logic
- Actions: Business workflows (create, update, close, etc.)
- Services: Cross-model transformations and calculations
- Events: Domain events for broadcasting
- Enums: Type-safe constants with behavior
- Collections: Data transformations for APIs
✅ Team-Friendly
- Familiar to Laravel developers
- IDE autocomplete works
- Clear file organization
- Easy to onboard new developers
Scalable
- Easy to extract micro-services later
- Clear domain boundaries
- Testable architecture
Common Pitfalls to Avoid
1. Over-Modularization
The Problem: Creating separate modules with their own service providers, routes, and migrations.
Why It Fails: Too much assembly required. Simple changes touch 5+ files across modules.
When It Makes Sense: Large teams (15+) working on completely separate features.
2. Abandoning App\ Namespace
The Problem: Moving everything to Domain\, Support\, Application\ namespaces.
Why It Fails: Breaks Laravel’s auto-discovery, IDE tools, and team expectations.
Solution: Use App\Domain\ instead of bare Domain\.
Conclusion
The key to successful DDD in Laravel is working with the framework, not against it:
- Keep the
App\namespace - Use Eloquent models as rich domain objects
- Leverage Laravel’s features
- Add DDD patterns only where they add value
- Start simple, add complexity when needed
This approach gives you:
- Clean architecture
- Maintainable code
- Happy developers
- Productive teams
Remember: The best architecture is the one your team can understand and maintain. Don’t sacrifice Laravel’s productivity for architectural purity.
