migears / domain
Minimalist Domain — pure data containers with zero mapping and self-validation
Requires
- php: ^8.1
- migears/validator: ^2.0
Requires (Dev)
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^10
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Minimalist Domain layer — pure data containers with zero mapping.
Background: miGears is the open-source successor of TinyGears, a self-developed PHP framework. It was renamed and open-sourced recently because the name TinyGears is already taken in the open-source community.
Philosophy
- No Getter/Setter — properties are
public readonly - No Hydrator/Mapper — direct
new XxxDomain(...$row)construction - No base class inheritance — use the
DataAccesstrait - Field names match database columns 1:1 — no camelCase conversion
- No persistence logic — Domain knows nothing about SQL or DAO
Boundaries
In scope
- The
DataAccesstrait:fromArray()(array → Domain) andtoArray()(Domain → array), binding column names to properties 1:1 with no camelCase conversion (PSR-4 rootMiGears\Domain). - The
Validatabletrait: per-class rules viavalidationRules(),validate()/isValid()/validateArray()/isValidArray(), and per-classcustomValidators(). - Domain objects as plain
public readonlydata carriers, plus the recommended static lazy-relation accessor pattern (setItemLoader()), which keeps the Domain free of DAO/Manager references.
Not in scope (by design)
- Persistence: the Domain knows nothing about SQL or DAO — generating statements belongs to
migears/sql, executing them and converting rows belongs tomigears/dao. - The validation rule set itself:
Validatableonly declares rules and delegates to the sharedValidator; the built-in validators and rule execution belong tomigears/validator, and turning the returned error codes into text belongs tomigears/i18n. - Hydration, mapping and scalar conversion: there is no hydrator/mapper and no casting layer; PDO (PHP 8.1+) already delivers native
int/float/string/null, sofromArray()binds by parameter name only. - Wiring the lazy loader: calling
setItemLoader()from a Manager constructor ismigears/manager's job — there is no lifecycle hook here.
Installation
composer require migears/domain
Requires: PHP 8.1+, migears/validator.
Quick Start
Define a Domain
use MiGears\Domain\DataAccess; class UserDomain { use DataAccess; public function __construct( public readonly int $id, public readonly string $user_name, public readonly string $email, public readonly int $age, public readonly string $created_at, ) {} }
Array → Domain
// From a database row $row = ['id' => 1, 'user_name' => 'Alice', 'email' => 'a@b.com', 'age' => 25, 'created_at' => '2024-01-01']; $user = UserDomain::fromArray($row); echo $user->user_name; // "Alice"
fromArray() uses PHP 8.x named arguments via ...$row array spreading, so array keys must match constructor parameter names exactly.
Domain → Array
$row = $user->toArray(); // ['id' => 1, 'user_name' => 'Alice', 'email' => 'a@b.com', 'age' => 25, 'created_at' => '2024-01-01']
toArray() uses get_object_vars($this), returning all properties as an associative array.
Round Trip
$domain = UserDomain::fromArray($row); $back = $domain->toArray(); // $back === $row ✅ (same key order)
=== also requires an identical key order: get_object_vars() returns properties in
declaration order, so a SELECT * whose column order differs from the constructor's
parameter order makes === false while == stays true. Either list the columns
explicitly in the query, or compare with ==.
Type Contract
fromArray() binds values via PHP 8.x strict typed named arguments, so no type coercion happens here. Every value must already be of the type declared by the constructor parameter:
- an
int $idparameter requires a genuine PHPint, not the string'1' - a missing key, an extra key, or a type mismatch throws a native
\Error/\TypeError
Native typing is not something the Domain — or the DAO — has to produce; PDO
already does it. Since PHP 8.1 a result set carries real PHP int / float for
numeric columns, under both emulated and native prepares and on every bundled
driver. The value chain is therefore:
PDO (PHP 8.1+) → native PHP types (int, float, string, null)
↓
Domain::fromArray() only binds the row by parameter name (no hydration, no casting)
This is why neither the Domain nor the DAO needs a hydrator or scalar-conversion logic.
Three things to watch for:
DECIMALcolumns staystring(precision-preserving) — declare themstringTINYINT(1)columns areint— declare themint, notbool- never enable
PDO::ATTR_STRINGIFY_FETCHES: it restores the old string-everything behaviour and breaks everyintproperty
A mismatch between a constructor type and its column fails loudly with a
native \TypeError rather than converting silently — that is intentional: it
surfaces schema drift on the spot instead of coercing '2024-01-01' into 2024.
Naming Convention
| Layer | Convention | Example |
|---|---|---|
| Database column | snake_case | user_name |
| Domain property | snake_case (matches column) | $user_name |
| Constructor param | snake_case (matches column) | string $user_name |
No camelCase ↔ snake_case conversion anywhere. What you see in the database is what you see in code.
Architecture
Domain is the middle layer of miGears' three-layer data architecture:
Service Layer (business logic)
↓ calls
DAO Layer (receives/returns Domain objects) → migears/dao
↓ internally calls
SQL Layer (SQL + params → arrays) → migears/sql
↓
PDO / MySQL
- Domain knows nothing about SQL or DAO
- DAO uses
fromArray()to convert SQL results into Domain objects - DAO uses
toArray()to convert Domain objects back to arrays for SQL
Related Data (Lazy Loading)
Domain objects stay pure: they never hold a DAO, a Manager, or any other module
reference. When business code still wants $order->items(), the recommended
practice is a static loader injected before construction — the Domain declares
the accessor, the Manager supplies the implementation. All dependency knowledge
therefore stays in the Manager, and the Domain remains independently testable.
Manager side
final class OrderManager { public function __construct(private OrderItemDao $itemDao) { OrderDomain::setItemLoader( fn(OrderDomain $order) => $this->itemDao->getByOrderId($order->id) ); } }
There is no lifecycle hook here and nothing calls a boot() for you: the wiring builds
each Manager once, before anything is served, so the constructor is the place
(see migears/manager).
Domain side
use Closure; use MiGears\Domain\DataAccess; use RuntimeException; class OrderDomain { use DataAccess; /** @var null|Closure(self): list<OrderItemDomain> */ private static ?Closure $itemLoader = null; public function __construct( public readonly int $id, public readonly string $title, ) {} public static function setItemLoader(?callable $loader): void { static::$itemLoader = $loader === null ? null : Closure::fromCallable($loader); } /** @return list<OrderItemDomain> */ public function items(): array { if (static::$itemLoader === null) { throw new RuntimeException('OrderDomain::setItemLoader() was not called'); } return (static::$itemLoader)($this); } }
$order = OrderDomain::fromArray($row); $order->items(); // loaded through the Manager's callable $order->toArray(); // ['id' => ..., 'title' => ...] — loader not included
Why not store the loader on the instance?
Because any instance property leaks into persistence. toArray() is
get_object_vars($this), so a stored callable — even a private one — ends up
in the array the DAO hands to SQL:
toArray() → ['id' => 7, 'title' => 'Order A', 'itemsLoader' => Closure]
SQL → ERROR: table orders has no column named itemsLoader
Static storage keeps fromArray() and toArray() untouched.
Rules
- Declare the slot as
?Closure. A property typedcallableis a fatal error (Property ... cannot have type callable); acceptcallablein the setter and normalise withClosure::fromCallable(). - Accept
?callableand treatnullas reset. Static state outlives a single test, sotearDown()should callsetItemLoader(null). - Throw when the loader was never injected. Returning
[]silently would make "no related records" and "not configured" indistinguishable. - Use
static::rather thanself::so the accessor stays overridable. - A subclass that declares no slot of its own inherits its parent's loader; to get an independent one it must declare its own slot and setter.
- This is deliberate global state — the only place this package recommends it. Inject once, from a single place: the Manager's constructor.
When to use it
Use it for related records and child collections that you do not want to load eagerly and do not want the Domain to know how to fetch. Do not use it for plain column reads — those already live on the object — and do not use it when callers need different loaders for the same class; that is a sign the caller should ask the Manager instead.
Self-Validation with Validatable
Domain objects can validate their own data using the Validatable trait. Validation rules are defined in the domain class itself, and errors are returned as structured error codes + params (i18n-ready).
Depends on migears/validator.
Migrating —
ValidatablerequirestoArray(). The trait declarestoArray(): arrayabstract, becausevalidate()andisValid()read the instance through it. A class that usesValidatablemust therefore supplytoArray()— either throughDataAccess, as below, or by implementing it itself. This is a deliberate breaking change: a class that usedValidatablewithout atoArray()of its own used to load, and failed withCall to undefined method ...::toArray()only whenvalidate()first ran; a class that used only the staticvalidateArray()/isValidArray()never failed at all. Both now fail at class declaration.
use MiGears\Domain\DataAccess; use MiGears\Domain\Validatable; class UserDomain { use DataAccess; use Validatable; public function __construct( public readonly string $username, public readonly string $email, public readonly int $age = 0, ) {} protected static function validationRules(): array { return [ 'username' => ['required' => true, 'minLength' => 3, 'maxLength' => 20], 'email' => ['required' => true, 'email' => true], 'age' => ['integer' => true, 'min' => 0, 'max' => 150], ]; } }
Validate an instance
$user = new UserDomain('ab', 'invalid', -1); $errors = $user->validate(); // [ // 'username' => ['rule' => 'minLength', 'params' => ['min' => 3]], // 'email' => ['rule' => 'email', 'params' => []], // 'age' => ['rule' => 'min', 'params' => ['min' => 0]], // ] $user->isValid(); // false
Validate before construction
$errors = UserDomain::validateArray($_POST); if ($errors === []) { $user = UserDomain::fromArray($_POST); }
Custom validation rules
Rules that are not part of the built-in set can be added by overriding customValidators(). Each entry is a validator class-string (the rule alias is derived from the class name); the domain class's shared validator is pre-registered with these on first use, scoped to that class only.
use MiGears\Validator\ValidatorInterface; final class StrongPasswordValidator implements ValidatorInterface { public function validate(mixed $value): bool { /* ... */ } public function getErrorCode(): string { return 'strongPassword'; } public function getErrorParams(): array { return []; } } class UserDomain { use DataAccess; use Validatable; // ...constructor & validationRules()... protected static function customValidators(): array { return [StrongPasswordValidator::class]; // `strongPassword` is now available in validationRules() } }
Custom rules registered for one domain class never leak into others.
Error format
Errors use structured codes instead of hardcoded messages, ready for i18n:
['field' => ['rule' => 'minLength', 'params' => ['min' => 3]]]
Pair with migears/i18n to translate:
$message = $translator->translate( "validation.{$error['rule']}", ['field' => $fieldLabel, ...$error['params']] );
Placeholders follow the %name% convention, so the template behind the example
above would read Too short, at least %min%.
Why a Trait Instead of a Base Class?
- No inheritance constraint — Domain classes can extend whatever they need
- Zero overhead — trait methods are inlined into the class
- Maximum readability —
DataAccessis two methods, each a single line of logic
License
MIT
migears/domain
极简 Domain 层 — 纯数据容器,零映射。
设计哲学
- 不用 Getter/Setter — 属性全部
public readonly - 不用 Hydrator/Mapper — 直接
new XxxDomain(...$row)构造 - 不用基类继承 — 使用
DataAccesstrait - 字段名与数据库列名完全一致 — 不做驼峰/下划线互转
- 不含持久化逻辑 — Domain 不知道 SQL 和 DAO 的存在
边界
范围内
DataAccesstrait:fromArray()(数组 → Domain)与toArray()(Domain → 数组),列名与属性名 1:1 绑定、不做驼峰转换;PSR-4 根为MiGears\Domain。Validatabletrait:按类声明规则(validationRules())、validate()/isValid()/validateArray()/isValidArray(),以及按类的customValidators()。- Domain 对象作为纯
public readonly数据载体,以及推荐的静态懒加载关联访问器模式(setItemLoader()),让 Domain 不持有 DAO/Manager 引用。
范围外(刻意不做)
- 持久化:Domain 不知道 SQL 和 DAO 的存在 —— 生成语句属于
migears/sql,执行语句与转换结果行属于migears/dao。 - 验证规则集合本身:
Validatable只声明规则并委托给共享的Validator;内置验证器与规则执行属于migears/validator,把返回的错误码翻译成文案属于migears/i18n。 - Hydration、映射与标量转换:本包没有 hydrator/mapper,也没有强制转换层;PDO(PHP 8.1+)已给出原生
int/float/string/null,fromArray()只按参数名绑定。 - 懒加载的 wiring:在 Manager 构造函数里调用
setItemLoader()是migears/manager的职责 —— 这里没有生命周期钩子。
安装
composer require migears/domain
要求:PHP 8.1+、migears/validator。
快速开始
定义 Domain
use MiGears\Domain\DataAccess; class UserDomain { use DataAccess; public function __construct( public readonly int $id, public readonly string $user_name, public readonly string $email, public readonly int $age, public readonly string $created_at, ) {} }
数组 → Domain
// 从数据库行构造 $row = ['id' => 1, 'user_name' => 'Alice', 'email' => 'a@b.com', 'age' => 25, 'created_at' => '2024-01-01']; $user = UserDomain::fromArray($row); echo $user->user_name; // "Alice"
fromArray() 通过 ...$row 展开关联数组,利用 PHP 8.x 命名参数特性,数组键名必须与构造函数参数名完全匹配。
Domain → 数组
$row = $user->toArray(); // ['id' => 1, 'user_name' => 'Alice', 'email' => 'a@b.com', 'age' => 25, 'created_at' => '2024-01-01']
toArray() 使用 get_object_vars($this),返回所有属性组成的关联数组。
往返转换
$domain = UserDomain::fromArray($row); $back = $domain->toArray(); // $back === $row ✅ (键序一致时)
=== 还要求键序完全一致:get_object_vars() 按属性声明序返回,因此当 SELECT * 的
列序与构造参数序不同时,=== 为 false 而 == 仍为 true。可在查询中显式列出列序,
或改用 == 比较。
类型契约
fromArray() 通过 PHP 8.x 强类型命名参数绑定值,因此这里不做任何类型转换。每个值必须已经是构造参数所声明的类型:
int $id参数需要真正的 PHPint,而不是字符串'1'- 缺少键、多出键或类型不匹配都会抛出原生
\Error/\TypeError
原生类型并不是 Domain(或 DAO)需要产出的东西 — PDO 已经给出了。PHP 8.1
起,结果集对数字列即返回真正的 PHP int / float,模拟预处理与原生预处理
皆然,各内置驱动一致。因此取值链路是:
PDO(PHP 8.1+)→ 原生 PHP 类型(int、float、string、null)
↓
Domain::fromArray() 仅按参数名绑定(不 hydration、不强制转换)
这正是为什么 Domain 与 DAO 都不需要 hydrator 和标量转换逻辑。
有三点需要注意:
DECIMAL列保持string(为保留精度)— 请声明为stringTINYINT(1)列是int— 请声明为int,而非bool- 切勿开启
PDO::ATTR_STRINGIFY_FETCHES:它会恢复「一切皆字符串」的旧行为, 使每个int属性都报错
构造参数类型与列类型不一致时会大声失败(原生 \TypeError),而不是静默
转换 — 这是刻意设计:当场暴露 schema 漂移,而非把 '2024-01-01' 强转成 2024。
命名规范
| 层级 | 规范 | 示例 |
|---|---|---|
| 数据库列名 | 下划线 | user_name |
| Domain 属性 | 下划线(与列名一致) | $user_name |
| 构造函数参数 | 下划线(与列名一致) | string $user_name |
全程不做驼峰/下划线互转。数据库里是什么,代码里就是什么。
架构
Domain 是 miGears 三层数据架构的中间层:
Service 层(业务逻辑)
↓ 调用
DAO 层(接收/返回 Domain 对象)→ migears/dao
↓ 内部调用
SQL 层(SQL + 参数 → 数组)→ migears/sql
↓
PDO / MySQL
- Domain 不知道 SQL 和 DAO 的存在
- DAO 用
fromArray()把 SQL 结果转为 Domain 对象 - DAO 用
toArray()把 Domain 对象转回数组供 SQL 使用
关联数据(懒加载)
Domain 对象保持纯净:不持有 DAO、Manager 或任何其他模块引用。当业务代码仍希望写成
$order->items() 时,推荐的做法是在构造之前注入静态 loader——Domain 只声明访问器,
实现由 Manager 提供。依赖知识因此全部留在 Manager 中,Domain 依旧可独立测试。
Manager 侧
final class OrderManager { public function __construct(private OrderItemDao $itemDao) { OrderDomain::setItemLoader( fn(OrderDomain $order) => $this->itemDao->getByOrderId($order->id) ); } }
这里没有生命周期钩子,也没有任何东西会替你调用 boot():wiring 在对外提供服务之前
把每个 Manager 建一次,所以构造函数就是它该在的地方(见 migears/manager)。
Domain 侧
use Closure; use MiGears\Domain\DataAccess; use RuntimeException; class OrderDomain { use DataAccess; /** @var null|Closure(self): list<OrderItemDomain> */ private static ?Closure $itemLoader = null; public function __construct( public readonly int $id, public readonly string $title, ) {} public static function setItemLoader(?callable $loader): void { static::$itemLoader = $loader === null ? null : Closure::fromCallable($loader); } /** @return list<OrderItemDomain> */ public function items(): array { if (static::$itemLoader === null) { throw new RuntimeException('OrderDomain::setItemLoader() was not called'); } return (static::$itemLoader)($this); } }
$order = OrderDomain::fromArray($row); $order->items(); // 经 Manager 注入的 callable 加载 $order->toArray(); // ['id' => ..., 'title' => ...] —— 不含 loader
为什么不把 loader 存在实例上
因为任何实例属性都会污染持久化。toArray() 的实现是 get_object_vars($this),
所以存下来的 callable——即便是 private 的——会进入 DAO 交给 SQL 的数组:
toArray() → ['id' => 7, 'title' => 'Order A', 'itemsLoader' => Closure]
SQL → ERROR: table orders has no column named itemsLoader
静态存储则让 fromArray() 和 toArray() 完全不受影响。
约定
- 成员声明为
?Closure。属性类型写callable会致命错误(Property ... cannot have type callable);setter 收callable,用Closure::fromCallable()归一化。 - setter 收
?callable,把null视为复位。静态状态会跨测试存活,因此tearDown()应调用setItemLoader(null)。 - loader 从未注入时应当抛异常。静默返回
[]会让「没有关联数据」与「忘了配置」无法区分。 - 用
static::而非self::,让访问器保持可覆盖。 - 未自行声明槽位的子类会继承父类的 loader;若需独立的 loader,子类必须自己声明槽位与 setter。
- 这是刻意的全局状态,也是本包唯一推荐使用它的地方。请只在一处注入:Manager 的构造函数。
适用场景
适用于不想预加载、也不想让 Domain 知道如何取数的关联记录与子集合。纯字段读取不必使用—— 它们本就在对象上;而如果不同调用方需要同一类的不同 loader,则说明应由调用方去问 Manager。
Validatable 自验证
Domain 对象可以使用 Validatable trait 自验证数据。验证规则定义在 domain 类自身,错误以结构化的错误码 + 参数形式返回(i18n 就绪)。
依赖 migears/validator。
迁移提示 —
Validatable要求toArray()。 该 trait 将toArray(): array声明为 abstract,因为validate()与isValid()通过它读取实例状态。因此使用Validatable的类必须提供toArray()—— 或用DataAccess(见下例),或自行实现。 这是有意的破坏性变更:过去不带自身toArray()而使用Validatable的类可以加载, 只在首次运行validate()时才抛Call to undefined method ...::toArray();而只用静态validateArray()/isValidArray()的类则完全不会失败。现在两者都在类声明处失败。
use MiGears\Domain\DataAccess; use MiGears\Domain\Validatable; class UserDomain { use DataAccess; use Validatable; public function __construct( public readonly string $username, public readonly string $email, public readonly int $age = 0, ) {} protected static function validationRules(): array { return [ 'username' => ['required' => true, 'minLength' => 3, 'maxLength' => 20], 'email' => ['required' => true, 'email' => true], 'age' => ['integer' => true, 'min' => 0, 'max' => 150], ]; } }
验证实例
$user = new UserDomain('ab', 'invalid', -1); $errors = $user->validate(); // [ // 'username' => ['rule' => 'minLength', 'params' => ['min' => 3]], // 'email' => ['rule' => 'email', 'params' => []], // 'age' => ['rule' => 'min', 'params' => ['min' => 0]], // ] $user->isValid(); // false
构造前验证
$errors = UserDomain::validateArray($_POST); if ($errors === []) { $user = UserDomain::fromArray($_POST); }
自定义验证规则
不在内置集合里的规则,可通过覆盖 customValidators() 添加。每个条目是一个验证器类名(规则别名由类名推导);domain 类在首次使用时把自定义规则预注册到共享的验证器实例上,且仅作用于本类。
use MiGears\Validator\ValidatorInterface; final class StrongPasswordValidator implements ValidatorInterface { public function validate(mixed $value): bool { /* ... */ } public function getErrorCode(): string { return 'strongPassword'; } public function getErrorParams(): array { return []; } } class UserDomain { use DataAccess; use Validatable; // ...构造器与 validationRules()... protected static function customValidators(): array { return [StrongPasswordValidator::class]; // `strongPassword` 现在可以在 validationRules() 中使用 } }
为一个 domain 类注册的自定义规则不会泄漏到其它类。
错误格式
错误使用结构化代码而非硬编码消息,i18n 就绪:
['字段名' => ['rule' => 'minLength', 'params' => ['min' => 3]]]
配合 migears/i18n 翻译:
$message = $translator->translate( "validation.{$error['rule']}", ['field' => $fieldLabel, ...$error['params']] );
占位符遵循 migears/i18n 的 %name% 约定,例如上面示例对应的消息模板可写作
太短了,至少 %min% 个字符。
为什么用 Trait 而不是基类?
- 不受继承约束 — Domain 类可以继承任何需要的父类
- 零开销 — trait 方法会被内联到类中
- 最大可读性 —
DataAccess只有两个方法,各一行逻辑
许可证
MIT