laravel-domain-architecture

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Laravel 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
app/
structure starts to hurt.

适用于规模较大的项目——50+个模型、多名开发者、生命周期达数年的项目。对于简单的CRUD项目,建议保留Laravel默认结构。当标准的
app/
结构开始难以维护时,就是切换的临界点。

Project Structure

项目结构

The architecture adds a
Domain/
layer inside
app/
while keeping the standard Laravel structure for everything else. No custom autoload, no custom Application class.
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/
该架构在
app/
目录内新增
Domain/
层,其余部分保留Laravel标准结构。无需自定义自动加载,无需自定义Application类。
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 # Exception
When 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.md

Application 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.php
When the project grows:
app/Http/Controllers/Api/
├── Invoice/
│   ├── InvoiceController.php
│   └── InvoiceStatusController.php
├── Customer/
│   └── CustomerController.php
└── Payment/
    └── PaymentController.php
Same 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 prefix
When V2 is needed:
app/Http/Controllers/Api/
├── V1/
│   └── InvoiceController.php     # Move here only when V2 exists
└── V2/
    └── InvoiceController.php
Rules:
  • 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.php
    ,
    routes/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.php
    routes/api_v2.php
  • 标记废弃,而非删除——只要还有用户在使用,就保留V1运行
-> 详细实现请参考
references/application-layer.md

Naming Conventions

命名规范

TypeSuffixExample
Action
*Action
CreateInvoiceAction
Data Object
*Data
InvoiceData
Model
Invoice
QueryBuilder
*QueryBuilder
InvoiceQueryBuilder
Collection
*Collection
InvoiceLineCollection
Enum
*Status
/
*Type
InvoiceStatus
Event
*Event
InvoiceCreatedEvent
Exception
*Exception
InvalidTransitionException
Controller
*Controller
InvoiceController
HTTP Query
*IndexQuery
InvoiceIndexQuery
Resource
*Resource
InvoiceResource
Request
Store*Request
/
Update*Request
StoreInvoiceRequest
Job
*Job
SendInvoiceMailJob

类型后缀示例
Action
*Action
CreateInvoiceAction
数据对象
*Data
InvoiceData
模型
Invoice
QueryBuilder
*QueryBuilder
InvoiceQueryBuilder
集合
*Collection
InvoiceLineCollection
枚举
*Status
/
*Type
InvoiceStatus
事件
*Event
InvoiceCreatedEvent
异常
*Exception
InvalidTransitionException
控制器
*Controller
InvoiceController
HTTP查询
*IndexQuery
InvoiceIndexQuery
资源
*Resource
InvoiceResource
请求
Store*Request
/
Update*Request
StoreInvoiceRequest
任务
*Job
SendInvoiceMailJob

Core Principles

核心原则

  1. Actions — Classes with an
    execute()
    method. Represent user stories. Compose via constructor injection. Primary place for business logic.
  2. Lean Models — Only relationships, casts, custom QueryBuilders, and custom Collections. No business logic.
  3. Data Objects — Use typed
    readonly
    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.
  4. Enums with methods — Replace the state pattern in most cases. Include
    color()
    ,
    label()
    ,
    canTransitionTo()
    directly in the enum. Transitions are done via Actions.
  5. Jobs are infrastructure — Manage queues and retries. Business logic stays in Actions called from
    handle()
    .
  6. Don't fight the framework — Use standard
    app/
    , standard namespaces, standard artisan. The only addition is
    Domain/
    .

  1. Actions — 包含
    execute()
    方法的类,代表用户故事。通过构造函数注入实现组合,是业务逻辑的主要载体。
  2. 轻量模型 — 仅包含关联关系、类型转换、自定义QueryBuilders和自定义集合,不包含业务逻辑。
  3. 数据对象 — 仅当数据来自多个来源,或Action需要跨上下文复用时,才使用PHP 8.3+的 typed
    readonly
    类。对于简单的控制器->Action流程,直接传递Request或单独参数即可。
  4. 带方法的枚举 — 大多数情况下可替代状态模式。将
    color()
    label()
    canTransitionTo()
    等方法直接写在枚举中,状态转换通过Action实现。
  5. 任务属于基础设施层 — 仅用于管理队列和重试逻辑,业务逻辑应放在Action中,由任务的
    handle()
    方法调用。
  6. 不要对抗框架 — 使用标准的
    app/
    目录、标准命名空间、标准artisan命令。仅新增
    Domain/
    层。

Cross-Domain Communication

跨领域通信

Domains communicate pragmatically based on the nature of the interaction, not on rigid boundaries.
领域间的通信需根据交互性质务实处理,而非遵循僵化的边界规则。

Rules

规则

SituationApproach
Read a model/enum from another domainDirect import
Validate a precondition from another domainDirect 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 factEvent
场景实现方式
读取其他领域的模型/枚举直接引入
验证其他领域的前置条件直接调用
原子化编排多个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
    Domain/Shared/
    or use events
  • 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.php
For 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
    ProcessInvoiceAction
    that does 10 things. Prefer specific:
    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
    Shared/
    grows too large, it has code that should be in its own domain.
  • 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.md

Migration Strategy

迁移策略

No need to adopt everything at once. Recommended order:
  1. Actions — Extract business logic from controllers and models
  2. Domain folders — Group related code when directories become large
  3. Data Objects — Replace arrays with typed DTOs where justified
  4. Enums with methods — Replace conditionals with behavior in the enum
  5. Custom QueryBuilders — Extract complex scopes
  6. API Versioning — Structure when the API needs breaking changes
Domains can and should change over time. Start pragmatically, refactor as understanding grows.

无需一次性全部采用,建议按以下顺序逐步迁移:
  1. Actions — 从控制器和模型中提取业务逻辑
  2. 领域文件夹 — 当目录过于庞大时,按领域分组相关代码
  3. 数据对象 — 在合理的场景下,用类型化DTO替代数组
  4. 带方法的枚举 — 用枚举的行为替代条件判断
  5. 自定义QueryBuilders — 提取复杂的查询作用域
  6. API版本控制 — 当API需要破坏性变更时再进行版本化
领域结构可以且应该随时间演进。从务实的方式开始,随着对业务的理解加深逐步重构。

Reference Files

参考文档

  • references/domain-building-blocks.md
    — Data Objects, Actions, Models, Enums, QueryBuilders, Collections, Events, Cross-Domain Communication, Tests
  • references/application-layer.md
    — Controllers, Requests, Resources, HTTP Queries, ViewModels, Jobs, API Versioning
  • references/domain-building-blocks.md
    — 数据对象、Actions、模型、枚举、QueryBuilders、集合、事件、跨领域通信、测试
  • references/application-layer.md
    — 控制器、请求、资源、HTTP查询、ViewModel、任务、API版本控制