laravel-domain-architecture
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseLaravel Domain Architecture
Laravel 领域导向架构
Guide for creating and refactoring Laravel applications using a pragmatic domain-oriented architecture.
Based on patterns from "Laravel Beyond CRUD" (Brent Roose / Spatie), simplified to work with pure PHP
and native Laravel features — no mandatory external packages.
Core philosophy: group code by business meaning, not by technical property. Don't fight the framework.
使用务实的领域导向架构创建和重构Laravel应用的指南。
基于《Laravel Beyond CRUD》(Brent Roose / Spatie)中的模式,简化为纯PHP和原生Laravel特性实现——无需强制依赖外部包。
核心理念:按业务含义分组代码,而非技术属性。不要对抗框架。
Agent Instructions
Agent 操作说明
- When creating or modifying Domain layer components (Actions, Data Objects, Models, Enums, QueryBuilders, Collections, Events) -> consult
references/domain-building-blocks.md - When creating or modifying Application layer components (Controllers, Requests, Resources, Queries, Jobs, API Versioning) -> consult
references/application-layer.md - Do not load both references at once — use only the one relevant to the current task
- Always follow the naming conventions and anti-patterns listed below
- 当创建或修改领域层组件(Actions、数据对象、模型、枚举、QueryBuilders、集合、事件)时,请参考
references/domain-building-blocks.md - 当创建或修改应用层组件(控制器、请求、资源、查询、任务、API版本控制)时,请参考
references/application-layer.md - 不要同时加载两个参考文档——仅使用与当前任务相关的那一个
- 始终遵循以下列出的命名规范和反模式
When to Use
适用场景
Projects larger than average — 50+ models, multiple developers, multi-year lifespan. For simple CRUD,
stick with Laravel defaults. The tipping point is when navigating the standard structure starts to hurt.
app/适用于规模较大的项目——50+个模型、多名开发者、生命周期达数年的项目。对于简单的CRUD项目,建议保留Laravel默认结构。当标准的结构开始难以维护时,就是切换的临界点。
app/Project Structure
项目结构
The architecture adds a layer inside while keeping the standard Laravel structure
for everything else. No custom autoload, no custom Application class.
Domain/app/app/
├── Domain/ # Business logic (the "what")
│ ├── Invoice/
│ ├── Customer/
│ ├── Payment/
│ └── Shared/
├── Http/ # Standard Laravel HTTP layer
│ ├── Controllers/
│ │ ├── Api/
│ │ └── Web/
│ ├── Requests/
│ ├── Resources/
│ ├── Queries/
│ ├── ViewModels/
│ └── Middleware/
├── Jobs/
├── Listeners/
├── Notifications/
├── Providers/
└── Console/该架构在目录内新增层,其余部分保留Laravel标准结构。无需自定义自动加载,无需自定义Application类。
app/Domain/app/
├── Domain/ # 业务逻辑(「做什么」)
│ ├── Invoice/
│ ├── Customer/
│ ├── Payment/
│ └── Shared/
├── Http/ # Laravel标准HTTP层
│ ├── Controllers/
│ │ ├── Api/
│ │ └── Web/
│ ├── Requests/
│ ├── Resources/
│ ├── Queries/
│ ├── ViewModels/
│ └── Middleware/
├── Jobs/
├── Listeners/
├── Notifications/
├── Providers/
└── Console/Domain Folders Are Flat
领域文件夹采用扁平结构
Each domain folder contains all its files at the root level. The type is in the class name —
subfolders are unnecessary until a domain grows beyond ~15 files:
app/Domain/Invoice/
├── Invoice.php # Model
├── InvoiceLine.php # Model
├── InvoiceStatus.php # Enum
├── InvoiceData.php # DTO (only when justified)
├── CreateInvoiceAction.php # Action
├── MarkInvoiceAsPaidAction.php # Action
├── CancelInvoiceAction.php # Action
├── InvoiceQueryBuilder.php # Custom QueryBuilder
├── InvoiceLineCollection.php # Custom Collection
├── InvoiceCreatedEvent.php # Event
└── InvalidTransitionException.php # ExceptionWhen a domain grows large (15+ files), split into subfolders:
app/Domain/Invoice/
├── Actions/
│ ├── CreateInvoiceAction.php
│ ├── MarkInvoiceAsPaidAction.php
│ └── CancelInvoiceAction.php
├── Models/
│ ├── Invoice.php
│ └── InvoiceLine.php
├── Enums/
│ └── InvoiceStatus.php
├── Data/
│ └── InvoiceData.php
└── ...-> Detailed implementation of each domain block in
references/domain-building-blocks.md每个领域文件夹的所有文件都放在根目录。类名中包含类型标识——仅当领域内文件超过15个时,才需要子文件夹:
app/Domain/Invoice/
├── Invoice.php # 模型
├── InvoiceLine.php # 模型
├── InvoiceStatus.php # 枚举
├── InvoiceData.php # DTO(仅在必要时使用)
├── CreateInvoiceAction.php # Action
├── MarkInvoiceAsPaidAction.php # Action
├── CancelInvoiceAction.php # Action
├── InvoiceQueryBuilder.php # 自定义QueryBuilder
├── InvoiceLineCollection.php # 自定义集合
├── InvoiceCreatedEvent.php # 事件
└── InvalidTransitionException.php # 异常当领域规模扩大(15+个文件)时,可拆分为子文件夹:
app/Domain/Invoice/
├── Actions/
│ ├── CreateInvoiceAction.php
│ ├── MarkInvoiceAsPaidAction.php
│ └── CancelInvoiceAction.php
├── Models/
│ ├── Invoice.php
│ └── InvoiceLine.php
├── Enums/
│ └── InvoiceStatus.php
├── Data/
│ └── InvoiceData.php
└── ...-> 每个领域模块的详细实现请参考
references/domain-building-blocks.mdApplication Layer
应用层
The application layer uses standard Laravel structure. Group by domain within each folder
only when folders grow too large:
app/Http/Controllers/Api/
├── InvoiceController.php # Fine when there are few controllers
├── CustomerController.php
└── PaymentController.phpWhen the project grows:
app/Http/Controllers/Api/
├── Invoice/
│ ├── InvoiceController.php
│ └── InvoiceStatusController.php
├── Customer/
│ └── CustomerController.php
└── Payment/
└── PaymentController.phpSame principle applies to Requests, Resources, and Queries.
应用层采用Laravel标准结构。仅当文件夹规模过大时,才在内部按领域分组:
app/Http/Controllers/Api/
├── InvoiceController.php # 控制器数量较少时的合理结构
├── CustomerController.php
└── PaymentController.php当项目规模扩大后:
app/Http/Controllers/Api/
├── Invoice/
│ ├── InvoiceController.php
│ └── InvoiceStatusController.php
├── Customer/
│ └── CustomerController.php
└── Payment/
└── PaymentController.php同样的原则适用于Requests、Resources和Queries。
API Versioning
API版本控制
Only create version folders when the first breaking change happens. While there's
one version, no prefix needed:
app/Http/Controllers/Api/InvoiceController.php # Single version — no prefixWhen V2 is needed:
app/Http/Controllers/Api/
├── V1/
│ └── InvoiceController.php # Move here only when V2 exists
└── V2/
└── InvoiceController.phpRules:
- Domain layer is shared — Actions, Models, Enums are the same across V1 and V2
- Only version what changes — If V2 only changes Invoices, don't copy Customers
- Routes per version — ,
routes/api_v1.phproutes/api_v2.php - Deprecate, don't delete — Keep V1 running as long as there are consumers
-> Detailed implementation in
references/application-layer.md仅当首次出现破坏性变更时,才创建版本文件夹。当只有一个版本时,无需前缀:
app/Http/Controllers/Api/InvoiceController.php # 单版本——无需前缀当需要V2版本时:
app/Http/Controllers/Api/
├── V1/
│ └── InvoiceController.php # 仅当V2存在时,才迁移到此处
└── V2/
└── InvoiceController.php规则:
- 领域层共享——Actions、模型、枚举在V1和V2中保持一致
- 仅对变更内容做版本控制——如果V2仅修改了Invoice相关逻辑,无需复制Customer相关代码
- 按版本配置路由——、
routes/api_v1.phproutes/api_v2.php - 标记废弃,而非删除——只要还有用户在使用,就保留V1运行
-> 详细实现请参考
references/application-layer.mdNaming Conventions
命名规范
| Type | Suffix | Example |
|---|---|---|
| Action | | |
| Data Object | | |
| Model | — | |
| QueryBuilder | | |
| Collection | | |
| Enum | | |
| Event | | |
| Exception | | |
| Controller | | |
| HTTP Query | | |
| Resource | | |
| Request | | |
| Job | | |
| 类型 | 后缀 | 示例 |
|---|---|---|
| Action | | |
| 数据对象 | | |
| 模型 | — | |
| QueryBuilder | | |
| 集合 | | |
| 枚举 | | |
| 事件 | | |
| 异常 | | |
| 控制器 | | |
| HTTP查询 | | |
| 资源 | | |
| 请求 | | |
| 任务 | | |
Core Principles
核心原则
- Actions — Classes with an method. Represent user stories. Compose via constructor injection. Primary place for business logic.
execute() - Lean Models — Only relationships, casts, custom QueryBuilders, and custom Collections. No business logic.
- Data Objects — Use typed classes (pure PHP 8.3+) only when data comes from multiple sources or the Action is reused across contexts. For simple controller -> action flows, passing the Request or individual parameters is fine.
readonly - Enums with methods — Replace the state pattern in most cases. Include ,
color(),label()directly in the enum. Transitions are done via Actions.canTransitionTo() - Jobs are infrastructure — Manage queues and retries. Business logic stays in Actions called from .
handle() - Don't fight the framework — Use standard , standard namespaces, standard artisan. The only addition is
app/.Domain/
- Actions — 包含方法的类,代表用户故事。通过构造函数注入实现组合,是业务逻辑的主要载体。
execute() - 轻量模型 — 仅包含关联关系、类型转换、自定义QueryBuilders和自定义集合,不包含业务逻辑。
- 数据对象 — 仅当数据来自多个来源,或Action需要跨上下文复用时,才使用PHP 8.3+的 typed 类。对于简单的控制器->Action流程,直接传递Request或单独参数即可。
readonly - 带方法的枚举 — 大多数情况下可替代状态模式。将、
color()、label()等方法直接写在枚举中,状态转换通过Action实现。canTransitionTo() - 任务属于基础设施层 — 仅用于管理队列和重试逻辑,业务逻辑应放在Action中,由任务的方法调用。
handle() - 不要对抗框架 — 使用标准的目录、标准命名空间、标准artisan命令。仅新增
app/层。Domain/
Cross-Domain Communication
跨领域通信
Domains communicate pragmatically based on the nature of the interaction, not on rigid boundaries.
领域间的通信需根据交互性质务实处理,而非遵循僵化的边界规则。
Rules
规则
| Situation | Approach |
|---|---|
| Read a model/enum from another domain | Direct import |
| Validate a precondition from another domain | Direct call |
| Orchestrate actions atomically (transaction) | Direct call |
| Side effect (notify, log, sync) | Event |
| Async reaction (can fail without affecting the flow) | Event |
| Multiple domains react to the same fact | Event |
| 场景 | 实现方式 |
|---|---|
| 读取其他领域的模型/枚举 | 直接引入 |
| 验证其他领域的前置条件 | 直接调用 |
| 原子化编排多个Action(事务) | 直接调用 |
| 副作用操作(通知、日志、同步) | 事件 |
| 异步响应(失败不影响主流程) | 事件 |
| 多个领域响应同一事实 | 事件 |
Direct Import (models, enums, preconditions)
直接引入(模型、枚举、前置条件)
php
namespace App\Domain\Invoice;
use App\Domain\Customer\Customer;
class CreateInvoiceAction
{
public function execute(InvoiceData $data, Customer $customer): Invoice
{
if (! $customer->isActive()) {
throw new \App\Domain\Customer\InactiveCustomerException();
}
return Invoice::create([
'customer_id' => $customer->id,
'number' => $data->number,
]);
}
}php
namespace App\Domain\Invoice;
use App\Domain\Customer\Customer;
class CreateInvoiceAction
{
public function execute(InvoiceData $data, Customer $customer): Invoice
{
if (! $customer->isActive()) {
throw new \App\Domain\Customer\InactiveCustomerException();
}
return Invoice::create([
'customer_id' => $customer->id,
'number' => $data->number,
]);
}
}Orchestration (needs the result, must be atomic)
编排操作(需要返回结果,必须原子化)
When multiple domains must succeed together, the app layer orchestrates:
php
class CheckoutController
{
public function store(CheckoutRequest $request)
{
return DB::transaction(function () use ($request) {
$invoice = app(CreateInvoiceAction::class)->execute($invoiceData);
$payment = app(ProcessPaymentAction::class)->execute($invoice, $paymentData);
return new CheckoutResource($invoice, $payment);
});
}
}当多个领域操作必须同时成功时,由应用层负责编排:
php
class CheckoutController
{
public function store(CheckoutRequest $request)
{
return DB::transaction(function () use ($request) {
$invoice = app(CreateInvoiceAction::class)->execute($invoiceData);
$payment = app(ProcessPaymentAction::class)->execute($invoice, $paymentData);
return new CheckoutResource($invoice, $payment);
});
}
}Events (side effects, reactions)
事件(副作用、响应操作)
php
class CreateInvoiceAction
{
public function execute(InvoiceData $data): Invoice
{
$invoice = Invoice::create([...]);
event(new InvoiceCreatedEvent($invoice));
return $invoice;
}
}
// Another domain reacts via listener — no direct coupling
class ReconcilePaymentListener
{
public function handle(InvoiceCreatedEvent $event): void
{
app(ReconcilePaymentAction::class)->execute($event->invoice);
}
}php
class CreateInvoiceAction
{
public function execute(InvoiceData $data): Invoice
{
$invoice = Invoice::create([...]);
event(new InvoiceCreatedEvent($invoice));
return $invoice;
}
}
// 其他领域通过监听器响应——无直接耦合
class ReconcilePaymentListener
{
public function handle(InvoiceCreatedEvent $event): void
{
app(ReconcilePaymentAction::class)->execute($event->invoice);
}
}What's Prohibited
禁止操作
- Circular dependencies — If A calls B and B calls A, extract to or use events
Domain/Shared/ - Side effects disguised as direct calls — If the caller doesn't need the result, it should be an event
- 循环依赖 — 如果A调用B且B调用A,需将公共逻辑提取到或使用事件
Domain/Shared/ - 伪装成直接调用的副作用 — 如果调用方不需要返回结果,应使用事件实现
Domain Service Providers
领域服务提供者
Each domain can have its own ServiceProvider when it needs to register
bindings, event listeners, or specific configurations:
app/Providers/
├── InvoiceServiceProvider.php
└── CustomerServiceProvider.phpFor simple domains (no custom bindings), don't create a ServiceProvider — Laravel's auto-discovery
and auto-injection already handle it.
当领域需要注册绑定、事件监听器或特定配置时,可为每个领域创建独立的ServiceProvider:
app/Providers/
├── InvoiceServiceProvider.php
└── CustomerServiceProvider.php对于简单的领域(无需自定义绑定),无需创建ServiceProvider——Laravel的自动发现和自动注入已能满足需求。
Anti-patterns
反模式
- Logic in Models — Models don't calculate, don't send emails, don't validate business rules. That goes in Actions.
- Overly generic Actions — that does 10 things. Prefer specific:
ProcessInvoiceAction,CreateInvoiceAction.MarkInvoiceAsPaidAction - Repository pattern — Eloquent already is the repository. Use custom QueryBuilders instead.
- Interfaces for everything — Only create interfaces when there are multiple real implementations (e.g., payment gateways).
- Giant Domain/Shared — If grows too large, it has code that should be in its own domain.
Shared/ - DTOs for everything — Don't create a DTO when the controller just passes 2-3 fields to an Action.
- Fighting the framework — Custom Application class, custom autoload, custom directory structure that breaks artisan. If it requires a hack, rethink the approach.
- 模型中包含逻辑 — 模型不应包含计算、发送邮件、验证业务规则的逻辑,这些都应放在Actions中。
- 过于通用的Actions — 比如包含10种功能的,建议拆分为更具体的类:
ProcessInvoiceAction、CreateInvoiceAction。MarkInvoiceAsPaidAction - 仓库模式 — Eloquent本身已具备仓库的功能,建议使用自定义QueryBuilders替代。
- 为所有类创建接口 — 仅当存在多个实际实现(如支付网关)时,才创建接口。
- 过于庞大的Domain/Shared — 如果目录过大,说明其中的代码应拆分为独立领域。
Shared/ - 为所有场景创建DTO — 当控制器仅向Action传递2-3个字段时,无需创建DTO。
- 对抗框架 — 自定义Application类、自定义自动加载、自定义会破坏artisan命令的目录结构。如果需要hack才能实现,应重新考虑方案。
Testing Strategy
测试策略
- Actions: The most important tests. Pattern: setup -> execute -> assert
- Models: Test custom QueryBuilders and Collections in isolation
- Enums: Test behavior methods and transition rules
- Data Objects: Minimal testing — the type system does the heavy lifting
-> Complete examples in
references/domain-building-blocks.md- Actions — 最重要的测试对象,测试模式:准备环境 -> 执行 -> 断言结果
- 模型 — 单独测试自定义QueryBuilders和集合
- 枚举 — 测试行为方法和状态转换规则
- 数据对象 — 最少化测试——类型系统已完成大部分校验工作
-> 完整示例请参考
references/domain-building-blocks.mdMigration Strategy
迁移策略
No need to adopt everything at once. Recommended order:
- Actions — Extract business logic from controllers and models
- Domain folders — Group related code when directories become large
- Data Objects — Replace arrays with typed DTOs where justified
- Enums with methods — Replace conditionals with behavior in the enum
- Custom QueryBuilders — Extract complex scopes
- API Versioning — Structure when the API needs breaking changes
Domains can and should change over time. Start pragmatically, refactor as understanding grows.
无需一次性全部采用,建议按以下顺序逐步迁移:
- Actions — 从控制器和模型中提取业务逻辑
- 领域文件夹 — 当目录过于庞大时,按领域分组相关代码
- 数据对象 — 在合理的场景下,用类型化DTO替代数组
- 带方法的枚举 — 用枚举的行为替代条件判断
- 自定义QueryBuilders — 提取复杂的查询作用域
- API版本控制 — 当API需要破坏性变更时再进行版本化
领域结构可以且应该随时间演进。从务实的方式开始,随着对业务的理解加深逐步重构。
Reference Files
参考文档
- — Data Objects, Actions, Models, Enums, QueryBuilders, Collections, Events, Cross-Domain Communication, Tests
references/domain-building-blocks.md - — Controllers, Requests, Resources, HTTP Queries, ViewModels, Jobs, API Versioning
references/application-layer.md
- — 数据对象、Actions、模型、枚举、QueryBuilders、集合、事件、跨领域通信、测试
references/domain-building-blocks.md - — 控制器、请求、资源、HTTP查询、ViewModel、任务、API版本控制
references/application-layer.md