Architecture
Read when: adding a domain or a panel, placing a new file, making one domain talk to another, or writing an event, listener, job, console command or observer. Owns: the PSR-4 layout, the domain and presentation trees, boundaries, cross-domain calls, events and listeners, jobs and background work, console commands, observers, multi-tenancy notes, and how DDD files are created. See also: core-kernel.md · actions.md · contracts-and-services.md
TL;DR
src/Domain/{Domain}/holds business logic.src/Presentation/{Panel}/holds HTTP and views.app/holds framework glue only. Why: a domain must run the same from a request, a job, a command or a test.- Same domain: import the Action. Other domain:
app({Other}Contract::class). Nothing else crosses. - Only
Actions/andDataTransferObjects/may have sub-folders. No layer nests inside another, and no invented layer (Handlers/,Resolvers/). - A domain never reads the session, the request or a guard. It asks a
CurrentUsercontract. - Cross-domain work is a Contract call (sync), an event (decoupled) or a job (slow, retryable).
- Jobs, commands and listeners are thin: they call a Contract or an Action and hold no domain logic.
- Commands are auto-registered from
src/Domain/*/Commands/; schedule them inwithSchedule(). - Create DDD files by hand or by cloning a neighbour;
make:model,make:controller,make:requestandmake:resourcewrite intoapp/.
PSR-4
"autoload": {
"psr-4": {
"App\\": "app/",
"Domain\\": "src/Domain/",
"Presentation\\": "src/Presentation/"
}
}
Domain tree
src/Domain/{Domain}/
├── Actions/ one class per operation; may group: Actions/{Entity}/ or Actions/{SubArea}/
├── Commands/ console commands that drive only this domain
├── Contracts/ the domain's public interfaces, one per entity or sub-area
├── DataTransferObjects/ *Data classes; may group: DataTransferObjects/{SubArea}/
├── Enums/ backed enums owned by the domain
├── Exceptions/ exceptions thrown by Actions
├── Jobs/ queued work owned by the domain
├── Listeners/ only when the domain reacts to another domain's events
├── Events/ only when the domain publishes events others react to
├── Models/
│ ├── Entities/ Eloquent models
│ ├── Scopes/ {Entity}Filter traits and global Scope classes
│ ├── Abilities/ {Entity}Abilities (per-record permission questions)
│ ├── Features/ feature-flag wrappers for this domain (optional)
│ └── Traits/ model-only traits shared inside this domain
├── Observers/ registered in this domain's provider boot()
├── Providers/ {Domain}ServiceProvider with $bindings
├── Services/ Contract implementations; one-line delegations to Actions
└── Traits/ traits used by Services and Actions (not models)
Core is the one domain with extra folders (Abstracts/, Casts/, Helpers/); see
core-kernel.md. Create a folder only when it gets its first file.
A group folder may hold several small domains that share a theme, each with the full tree and its own
provider: src/Domain/Integration/{Vendor}/Contracts/..., namespace Domain\Integration\{Vendor}\....
Why: one provider and one Contract per external system, without a top-level folder for each.
Presentation tree
src/Presentation/{Panel}/
├── Controllers/{Feature}/{Feature}Controller.php
├── Requests/{Feature}/Store{Entity}Request.php
├── Resources/ JSON transformers (only where JSON is served)
├── Routes/web.php | api.php
├── Providers/ loads this panel's routes and middleware
├── Rules/ custom validation rules
├── Traits/ controller and request traits for this panel
└── Views/ only in panels that render server-side pages
A panel is one audience: Admin (back office), Api (mobile or partner JSON), Site (public pages),
Pdf (rendered documents). Each panel has a provider that calls loadRoutesFrom() on its Routes/*.php;
register it in bootstrap/providers.php next to the domain providers. A Shared folder may hold the base
controller, traits and rules several panels reuse. Controller and request shapes live in
http.md; the JSON envelope in json-api.md.
A controller belongs to exactly one panel. When two panels need the same behaviour, the logic goes into the domain and both controllers call the Contract. Why: a controller that extends or includes one from another panel couples two audiences' auth and response shapes.
What goes where
| Need | Place |
|---|---|
| A business operation | Actions/{Verb}{Entity}.php |
| The domain's API for other domains and panels | Contracts/{Entity}Contract.php + Services/{Entity}Service.php |
| A transport object between layers | DataTransferObjects/{Verb}{Entity}Data.php |
| A status, type or kind | Enums/{Entity}{Thing}.php |
| An Eloquent model | Models/Entities/{Entity}.php |
| List filtering | Models/Scopes/{Entity}Filter.php (trait) |
| "Can this record do X now?" | Models/Abilities/{Entity}Abilities.php |
| Cross-cutting role-based authorization | app/Policies/ |
| Validation | src/Presentation/{Panel}/Requests/ |
| Response shaping | src/Presentation/{Panel}/Resources/ |
| Middleware, framework adapters, package hooks | app/ |
Business logic in Presentation, or HTTP concerns (request, session, redirects, guards) inside a Domain, are critical violations.
Flat layers
Three shapes of the same mistake fork a domain's layout, so anything that walks it (a reviewer, an arch test, an audit) sees only part of the domain:
❌ src/Domain/Employee/Invitations/Contracts/InvitationContract.php a subsystem swallowing the layers
❌ src/Domain/Employee/Contracts/Invitations/InvitationContract.php a subsystem folder in a flat layer
❌ src/Domain/Employee/Models/Entities/Abilities/EmployeeAbilities.php a layer nested in another
✅ src/Domain/Employee/Contracts/InvitationContract.php
✅ src/Domain/Employee/Actions/Invitations/InviteEmployee.php Actions may group
✅ src/Domain/Employee/Models/Abilities/EmployeeAbilities.php
Classes that serve one subsystem (a strategy interface, its implementations, a value object) go together
under Actions/{SubArea}/. The class name carries the grouping in every other layer.
Existing deviations. When an older domain uses a different folder name (a singular Enum/, a
Models/Filters/), add new files next to their siblings there, and use the standard names in new
domains. Why: a rename across every reference is its own task, never a side effect.
Boundaries
A Contract is the gateway to a domain. Actions, Services and Models behind it are private.
// ✅ same domain: you own the Action
use Domain\Employee\Actions\CreateEmployee;
$employee = CreateEmployee::handle($data);
// ❌ same domain: needless indirection
$employee = app(EmployeeContract::class)->createEmployee($data);
// ✅ other domain: through its Contract
$user = app(UserContract::class)->getUser($userId);
// ❌ other domain: reaching into internals
use Domain\User\Models\Entities\User;
use Domain\User\Actions\GetUser;
$user = User::find($userId);
$user = GetUser::handle($userId);
- Type-hinting another domain's Entity because its Contract returns one is fine; querying or writing it is not. Why: the owning domain must stay the one place for its rules, caching and events.
- An operation goes on the Contract only when another domain or a panel calls it (contracts-and-services.md).
- Two domains must not call each other in a cycle. Break it by moving the shared concept to a third
domain (or
Core), by publishing an event the other side listens to, or by a join entity that owns the link. Why: cycles recurse and deadlock once both sides "keep in sync" inside transactions.
Who is acting
A class under src/Domain/ never calls auth(), Auth::user(), request() or session(). It asks a
CurrentUser contract and handles null:
$actor = app(CurrentUser::class)->admin();
Why: a job, a command and a test have no session, so code that reads it answers differently off a request,
and a global scope that finds no user silently stops filtering. null is the normal answer there, not an
edge case. Which guard answers is wired once in AppServiceProvider; the template is in
core-kernel.md.
Cross-domain communication
| Need | Use |
|---|---|
| The caller needs the result now, or it must succeed in the same transaction | Direct Contract call |
| Other domains may react; the publisher should not know who listens | Event |
| Slow work (PDF, external API, bulk write) that may fail and retry | Queued job |
| Light work after the response, no retry needed | defer() |
Dispatch jobs and events from Actions, not from controllers. Why: the same operation then queues the same work from a job, a command or a test.
Events and listeners
Use events only when a second domain must react and the publisher must not know about it. A direct Contract call is simpler and easier to follow when there is one known consumer.
namespace Domain\Employee\Events;
final class EmployeeCreated
{
use Dispatchable;
public function __construct(public readonly int $employee_id, public readonly int $user_id) {}
}
namespace Domain\User\Listeners;
final class GrantEmployeeRole
{
public function handle(EmployeeCreated $event): void
{
app(UserContract::class)->assignRole($event->user_id, 'employee');
}
}
// Domain\User\Providers\UserServiceProvider::boot()
Event::listen(EmployeeCreated::class, GrantEmployeeRole::class);
- The event lives in the publishing domain, the listener in the reacting domain.
- Events carry scalars or DTOs, not models. Why: a listener on another domain must not get a writable model of a domain it does not own.
- Register listeners in the reacting domain's provider
boot(). Why: Laravel's event discovery scansapp/Listenersonly, so a domain listener is silently never called otherwise (checked 2026-11-03). - A listener, like a controller, calls a Contract. Make it
ShouldQueuewhen the reaction is slow.
Jobs
namespace Domain\Employee\Jobs;
final class SendEmployeeWelcomeEmail implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public bool $deleteWhenMissingModels = true;
public int $tries = 3;
public function __construct(private readonly Employee $employee)
{
$this->onQueue('mail');
$this->afterCommit();
}
public function handle(EmployeeContract $employees): void
{
$employees->sendWelcomeEmail($this->employee);
}
}
- File:
src/Domain/{Domain}/Jobs/{Verb}{Noun}.php. AJobsuffix is optional; match the siblings. handle()calls one Contract method or one Action. Why: a job is a scheduling wrapper; logic inside it cannot be reused or called synchronously.- Constructor property promotion,
readonly. Pass a model (withSerializesModels) or an id, never a large array, a closure or a live client. Why: arguments are serialized; a model is re-fetched fresh when the job runs. $deleteWhenMissingModels = truewhen the row may be deleted before the job runs. Why: otherwise the job fails withModelNotFoundExceptionand lands infailed_jobsfor nothing.- Call
$this->afterCommit()in the constructor when the job may be dispatched inside a transaction, unless every queue connection setsafter_commit => true. Why: a worker can pick the job up before the commit, see no row, or act on a row that is then rolled back. - Set the queue in the constructor, not at each dispatch site. Why: one place to move it.
handle()may method-inject a Contract (jobs are container-built), or useapp().- Add
Batchablewhen the job runs insideBus::batch(). Keep batch progress on the nativeBatchobject (progress(),failedJobs,cancel()); do not mirror it in a table or the cache. Track the owner with->withOption('user_id', $id).
Console commands
namespace Domain\User\Commands;
final class DeactivateInactiveUsers extends Command
{
protected $signature = 'users:deactivate_inactive';
protected $description = 'Deactivate users who have not signed in for 90 days.';
public function handle(): void
{
$count = app(UserContract::class)->deactivateInactiveUsers();
$this->info("Deactivated {$count} users.");
}
}
- File:
src/Domain/{Domain}/Commands/{Verb}{Noun}.php,final class. - Signature
{group}:{action}, both partssnake_case; the group is the domain's plural or the integration's key (users:deactivate_inactive,employees:send_welcome_emails). Notapp:deactivate-inactive, notusers:deactivateInactive. - Body: resolve a Contract, call one method, print one line. No Eloquent, no business logic.
- Auto-register by glob, so a new file needs no wiring:
// bootstrap/app.php
->withCommands(
collect([...glob(base_path('src/Domain/*/Commands/*.php')), ...glob(base_path('src/Domain/*/*/Commands/*.php'))])
->map(fn (string $file) => str($file)->after(base_path('src').'/')->before('.php')->replace('/', '\\')->value())
->all()
)
- Registering does not schedule. Add each periodic command to
withSchedule()too, or it never runs. - Give every
withoutOverlapping()an explicit expiry longer than one run (->withoutOverlapping(60)). Why: the default lock lasts 24 hours, so a run killed mid-way (a deploy) blocks the command for a day. - Add
->onOneServer()when more than one server runs the scheduler.
Observers
src/Domain/{Domain}/Observers/{Entity}Observer.php, registered in that domain's provider boot() with
Employee::observe(EmployeeObserver::class).
- Use an observer only when every write must be covered, whoever the caller. When one code path triggers the behaviour, put it in that Action, where it is explicit and testable.
- Keep it thin: stamp a field, clear a cache key, dispatch a job.
- Never re-enter: writing back to the observed model uses
saveQuietly()/updateQuietly(). Why: a save insidesavedloops. - Do not call another domain's Contract inline; dispatch an event or a job. Why: the observer runs inside the caller's transaction, so a slow or failing cross-domain call takes the write down with it.
- Remember it fires from seeders, factories, tests and bulk imports too.
High-risk resources get one seam
When a resource is dangerous to touch (a mail provider, an external database, a directory service), exactly one domain touches it, behind one small Contract. Nothing else opens that connection or builds its requests. Keep that Contract transport-agnostic (scalars and DTOs in, DTOs out, never a model or a live handle). Why: every guard on the resource lives in one code path, and the local implementation can later be swapped for a remote client by changing one binding.
Multi-tenant apps (option)
When every row hangs off a tenant:
- Middleware resolves the tenant once per request through the tenant domain's Contract and leaves it on
the request; a base-controller trait reads it back (
$this->currentTenant()). - A stranger gets 404, a member without the permission 403. Why: a 403 on another tenant's record confirms that it exists.
- Every
Filter{Entity}Dataover tenant data declares its scoping property, and the controller sets it after spreading the request input, so a client cannot override it (dtos.md, http.md). - Laravel passes route parameters to a controller by position, not by name. If middleware consumed a
{tenant}prefix, remove it ($request->route()->forgetParameter('tenant')) so each method's own parameters line up, and never declare it in a signature. - Background work has no request: jobs and commands take the tenant id as an argument and pass it through the Contract. Why: a global scope that reads the tenant from the request is empty on a queue.
Creating DDD files
There is no generator for this layout. Copy the templates in these references, or clone a neighbouring domain that already has the same pattern.
- Check the concept does not already live in
Coreor an existing domain. - Copy the closest domain's structure into
src/Domain/{NewDomain}/. - Rename namespaces and classes (
UserContract→EmployeeContract). - Adjust DTO properties, Action bodies and the filter scope to the new schema.
- Register
{NewDomain}ServiceProviderinbootstrap/providers.php.
These write into app/, so never use them for a file under src/:
make:model make:controller make:request make:resource make:job make:command make:listener make:event
The last four stay fine for app-level work that drives no single domain. These are always correct, because their output belongs outside the domain tree:
make:migration make:seeder make:factory make:middleware make:policy make:test
Verify
# Imports of another domain's Actions, Services or Models (Core is allowed)
for d in src/Domain/*/; do
n=$(basename "$d")
grep -rnE "use Domain\\\\[A-Za-z]+\\\\(Actions|Services|Models)\\\\" "$d" | grep -vE "use Domain\\\\($n|Core)\\\\"
done
# Panels or app/ importing Actions or Services
grep -rnE "use Domain\\\\[A-Za-z]+\\\\(Actions|Services)\\\\" src/Presentation/ app/
# Sub-folders in layers that must stay flat
find src/Domain/*/{Contracts,Services,Enums,Exceptions,Jobs,Observers} -mindepth 1 -type d 2>/dev/null
# The domain reading the session
grep -rnE "auth\(|Auth::|request\(\)|session\(" src/Domain/ --include='*.php'
# Command signatures that are not {group}:{snake_case}
grep -rhoE '\$signature = .[^ .]+' src/Domain/*/Commands/*.php | sed 's/.*= .//' | grep -vE '^[a-z_]+:[a-z_]+'