DDD

DDD Basics

How the codebase splits business logic from HTTP concerns, what lives in each subdirectory of a domain, and where to put new code.

What this is

A stock Laravel app puts models, controllers, jobs, and business logic together under app/. This codebase doesn't. Business logic lives under src/Domain/, organised by bounded context (a self-contained slice of the business — Order, Provider, Quotation). HTTP concerns live under src/Presentation/, organised by control panel (a user-facing surface — Admin, Customer, Provider). The framework's app/ directory holds legacy Laravel components and framework glue (events, listeners, middleware, policies).

The split exists so two questions always have a clear answer: where does the code for X live? and am I allowed to touch this from over here? With everything in app/, a moderately sized codebase becomes a tangle within a year — models import each other freely, controllers grow business logic, and nobody can refactor anything without breaking three unrelated things. The domain/presentation split forces those decisions early.

New business code does not land in app/. If you find yourself running php artisan make:model, stop — see Creating DDD files below.

The shape

{
    "autoload": {
        "psr-4": {
            "Domain\\": "src/Domain/",
            "Presentation\\": "src/Presentation/",
            "App\\": "app/"
        }
    }
}

Three namespaces, three roots. Domain\Order\Actions\CreateOrder resolves to src/Domain/Order/Actions/CreateOrder.php. Presentation\Admin\Controllers\OrderController resolves to src/Presentation/Admin/Controllers/OrderController.php. Anything still in the App\ namespace is either framework glue or legacy code that hasn't been moved yet.

Layout of a single domain

Every domain follows the same skeleton:

src/Domain/{Domain}/
├── Actions/                # one class per business operation; static handle()
├── Contracts/              # interfaces — the domain's public API
├── Services/               # Contract implementations; delegate to Actions
├── DataTransferObjects/    # *Data classes for inter-layer transport
├── Enums/                  # domain-owned enums (statuses, types, kinds)
├── Exceptions/             # domain-specific exception classes
├── Jobs/                   # queued work owned by the domain
├── Models/
│   ├── Entities/           # Eloquent models
│   ├── Scopes/             # scopeFilterBy traits
│   ├── Abilities/          # per-model permission methods
│   └── Traits/             # model-only shared traits
├── Observers/              # Eloquent observers
├── Providers/              # the domain ServiceProvider — binds Contract → Service
└── Traits/                 # domain-level traits used by Services/Actions

Not every domain uses every subdirectory — small domains may have only Actions/, Contracts/, Services/, Models/Entities/, and Providers/. Create a subdirectory the first time you need it; don't pre-populate.

What belongs where

A subdirectory-by-subdirectory tour. Where another chapter goes deeper, the link is in the bullet.

Actions

One class per business operation: CreateOrder, CalculateOrderTotal, CancelOrder. final class, single public static function handle(). This is where the work happens. See actions.

Contracts

Interfaces that define the domain's public API. Anything outside the domain — another domain, the Presentation layer — talks to this domain only through a Contract. Internal-only Actions stay off the Contract. See services and contracts.

Services

The implementation behind a Contract. A Service's job is to satisfy the interface by delegating to Actions; no direct Eloquent queries inside a Service. See services and contracts.

DataTransferObjects

Typed *Data classes (CreateOrderData, FilterOrderData) that carry payloads between layers. Controllers build them from validated request data and pass them to Actions. See DTOs.

Enums

Domain-owned backed enums — statuses, types, kinds (OrderStatus, ProviderType). Each domain owns its own enums; don't put OrderStatus in Domain\Core\.

Exceptions

Domain-specific exception classes that Actions and Services throw (OrderAlreadyCancelledException). Catching them gives the HTTP layer a place to translate domain errors into responses.

Jobs

Queued work owned by the domain. Dispatch from Actions, never from controllers. See cross-module communication for when to reach for a job.

Models/Entities

Eloquent models. Skinny — relations, casts, and accessors only. Filter and query logic moves into Models/Scopes/. See Eloquent and queries.

Models/Scopes

scopeFilterBy traits that read from a Filter{Entity}Data DTO and apply the filter clauses. Keeps the model file readable when filter logic grows.

Models/Abilities

Per-model permission methods, surfaced via app/Policies/. The policy is the framework hook; the actual rules live next to the model that owns them.

Models/Traits

Model-only traits shared across entities (relations, casts, accessors). Don't put generic helpers here — only things models use.

Observers

Eloquent observers. Register them in the domain's ServiceProvider, not in EventServiceProvider.

Providers

The domain's ServiceProvider. Its main job is binding Contract → Service. Register it in bootstrap/providers.php alongside the other domain providers.

Traits

Domain-level traits used by Services and Actions (not Models). If a trait is shared by multiple Actions in the same domain, this is where it lives.

Layout of a presentation panel

A panel mirrors the domain structure for HTTP-shaped concerns:

src/Presentation/{Panel}/
├── Controllers/
├── Requests/               # FormRequest validation
├── Providers/              # panel ServiceProvider — loads routes, registers middleware
├── Resources/              # API response transformers
├── Routes/
│   └── api.php             # or web.php for the Admin panel
├── Rules/                  # custom validation rules
├── Traits/
└── Views/                  # Blade — only Admin and Pdf carry views

A panel owns its routes, controllers, validation, response shapes, and (for SSR panels) views. Controllers constructor-inject the relevant domain Contract and delegate. See controllers.

Controllers belong to exactly one panel

If two panels need the same behaviour, lift the logic into a domain Service and call it from both controllers. Never extend a controller across panels and never include a controller file from a different panel.

✅ Correct

// src/Presentation/Customer/Controllers/OrderController.php
final class OrderController
{
    public function __construct(
        private readonly OrderContract $orders,
    ) {}

    public function store(StoreOrderRequest $request)
    {
        $order = $this->orders->createOrder($request->toDto());
        return new OrderResource($order);
    }
}

❌ Wrong

// src/Presentation/Provider/Controllers/OrderController.php
use Presentation\Customer\Controllers\OrderController as CustomerOrderController;

final class OrderController extends CustomerOrderController
{
    // Cross-panel inheritance — both panels are now coupled.
}

The wrong version means a change to the Customer panel's controller silently changes Provider behaviour. The fix is to put the shared logic in the domain.

Each panel registers its own ServiceProvider

Every panel under src/Presentation/{Panel}/Providers/ has a ServiceProvider that loads Routes/api.php (or web.php) and registers any panel-scoped middleware. Register it in bootstrap/providers.php alongside the domain providers.

The Domain vs Presentation split

Two rules, no exceptions:

Business logic in the Presentation layer, or HTTP concerns inside a Domain, are critical violations flagged by the reviewer agent. Keep them apart.

A controller never decides what should happen — it decides how to translate the request and hands off to the domain. A domain never knows it was invoked from HTTP — it accepts DTOs and returns Models or scalars, full stop. If you find yourself reading $request inside src/Domain/, something is in the wrong place.

Creating DDD files

There is no project-specific artisan generator. Files under src/Domain/ and src/Presentation/ are created by hand — either by copying templates from the relevant pattern chapter or by cloning the structure from a neighbouring domain that already implements the same pattern.

Don't use bare make:* for DDD components

Stock Laravel generators write to app/, which bypasses the DDD layout. Never run these for files that belong in a domain or panel:

❌ Wrong target

php artisan make:model Order            # writes to app/Models/ — wrong; entities live in src/Domain/Order/Models/Entities/
php artisan make:controller Foo         # writes to app/Http/Controllers/ — wrong; goes in src/Presentation/{Panel}/Controllers/
php artisan make:request StoreFoo       # writes to app/Http/Requests/ — wrong; goes in src/Presentation/{Panel}/Requests/
php artisan make:resource FooResource   # writes to app/Http/Resources/ — wrong; goes in src/Presentation/{Panel}/Resources/

Stock generators are still correct for app/ and database/

php artisan make:migration create_orders_table   # database/migrations/
php artisan make:job ProcessOrder                # app/Jobs/
php artisan make:listener RecordOrderTransaction # app/Listeners/
php artisan make:event OrderCreated              # app/Events/
php artisan make:notification OrderShipped       # app/Notifications/
php artisan make:command SyncOrders              # app/Console/Commands/
php artisan make:seeder OrderSeeder              # database/seeders/
php artisan make:factory OrderFactory            # database/factories/
php artisan make:policy OrderPolicy              # app/Policies/ — cross-cutting policies only; domain abilities go in Models/Abilities/

Starting a new domain — cloning workflow

  1. Pick a similar existing domain (Domain/Order/, Domain/Quotation/, Domain/Provider/).
  2. Copy its structure into src/Domain/{NewDomain}/.
  3. Rename namespaces and class names (OrderContract{NewDomain}Contract, etc.).
  4. Adjust DTO properties, Action handle() bodies, and Filter scopes to the new schema.
  5. Register the new ServiceProvider in bootstrap/providers.php.

Existing domains

Roughly forty bounded contexts live under src/Domain/ today. Before creating a new one, check whether the concept already lives in Core, UserCore, or an existing context. See the Domain Glossary for the full inventory or the DDD Map for a snapshot of each domain's adherence and legacy footprint.

Consistency first

Before applying any rule in this handbook, check what neighbouring files in the same domain already do. Established patterns vary by area. When an existing pattern works, follow it — the rules here are defaults for fresh code, not retrofits.

  • Domain boundaries — when in-domain calls hit Actions directly vs. when cross-domain calls go through Contracts.
  • Cross-module communication — direct calls, events, and queued jobs side by side.
  • DDD Map — every domain with its DDD adherence and legacy footprint.
  • Actions — the canonical final class X { public static function handle() } shape.
  • Services and contracts — how to define a Contract and bind its Service.