fiberphp / orm
🏗️ FiberPHP ORM —— Active Record 模式,支持关联关系、预加载、属性转换、时间戳、查询作用域。
Requires
- php: >=8.3
- fiberphp/database: dev-master
- fiberphp/event: dev-master
- fiberphp/framework: dev-master
- fiberphp/support: dev-master
Requires (Dev)
- fakerphp/faker: ^1.24
- phpunit/phpunit: ^11.0
Suggests
- fakerphp/faker: 模型工厂(Factory)生成假数据,使用工厂时需安装
This package is auto-updated.
Last update: 2026-08-30 14:56:47 UTC
README
FiberPHP ORM —— 基于 fiberphp/database 连接池/SQL 抽象层的 Active Record 实现,支持属性处理、时间戳、全局作用域、关系映射与预加载,协程原生安全。
参考:命名风格借鉴 ThinkPHP 简短风格(Attr 获取器/修改器、Concern 名词 trait), 关系 API 保持行业惯例(hasOne/belongsTo/with 等)。
特性(P0,当前版本)
| 模块 | 能力 |
|---|---|
| Attribute | fillable/guarded 批量赋值、$casts 类型转换(json/int/bool/string)、获取器 getXxxAttr / 修改器 setXxxAttr |
| Timestamp | insert/UPDATE 时自动填充 created_at / updated_at,值回写 attributes,子类可覆盖列名 |
| Scope | base(Query) 全局作用域(多租户/SaaS 自动加条件,走 Context,天然协程隔离)+ scopeXxx() local scope |
| 关系 | hasOne / hasMany / belongsTo / belongsToMany(多对多 + Pivot)/ hasManyThrough(跨表)/ morphOne / morphMany / morphTo(多态),外键与中间表名自动推断 |
| 预加载 | with() + Relation::eagerLoad() 实现,1+1 查询解决 N+1,批量查询后按外键映射回父实例 |
| CRUD | find(id) / all() / save()(有 id→update,无→insert) / delete() / with() 链式 |
| Event | 生命周期事件:creating/created/updating/updated/saving/saved/deleting/deleted/restoring/restored,基于 fiberphp/event,before 走 dispatch 可中断,after 走 emit 异常安全 |
| Observer | ObserverInterface + Observer 基类 + Model::observe() 批量注册 + OrmProvider 从 config 自动加载 |
| SoftDelete | use SoftDelete; 即启用,delete() 变软删除,restore()/forceDelete()/trashed()/withTrashed()/onlyTrashed(),全局作用域自动过滤 |
| Collection | map/filter/pluck/each/contains/first/toArray + groupBy/keyBy/sum/avg/max/min/find/flatMap/every/pipe |
| 分页 | cursorPaginate(offset 游标,base64 编码偏移)+ keysetPaginate(keyset 游标,编码末行排序键值,级联 OR seek 翻页,并发写入不漏行不重复、性能与页码无关);均无需 COUNT,拉 perPage+1 探测 has_more,全局作用域 + with() 预加载一致生效 |
| JSON 路径 | where('meta->>theme','dark') 跨方言路径取值查询(->> 文本 / -> JSON 值)+ whereJsonContains('meta->tags','php') 数组包含查询;由 Driver jsonPath()/jsonContains() 分发:MySQL ->>/json_contains、PG #>>/@>、SQLite json_extract/json_each,支持单层/嵌套路径 |
| 模型工厂 | Model::factory()->create()/make()/createMany() 配合 fakerphp/faker 生成假数据;工厂按约定 {Model}Factory 解析或 Factory::register() 注册,属性覆盖优先、修改器/转换器生效 |
快速开始
use FiberPHP\Orm\Model;
use FiberPHP\Orm\Query;
class User extends Model
{
public const TABLE = 'users';
public const PK = 'id';
protected array $fillable = ['name', 'email', 'meta'];
protected array $casts = ['meta' => 'json'];
// 获取器(大写名字)
protected function getNameAttr($value): string
{
return strtoupper($value);
}
// 修改器(小写邮箱)
protected function setEmailAttr($value): string
{
return strtolower($value);
}
// 全局作用域:多租户只看当前租户
public static function base(Query $query): void
{
$query->where('tenant_id', \FiberPHP\Context::get('tenant_id'));
}
// local scope
public function scopeActive(Query $query): void
{
$query->where('status', 1);
}
// 关系
public function profile() { return $this->hasOne(Profile::class); }
public function posts() { return $this->hasMany(Post::class); }
}
// 查单条(触发 base 过滤,结果触发获取器)
$user = User::find(1);
echo $user->name; // 大写
// 预加载 posts(1+1 查询)
$users = User::with('posts')->active()->get();
foreach ($users as $u) {
// $u->posts 已通过 eagerLoad 写入,无额外查询
echo $u->posts->count();
}
// 插入(修改器 + created_at/updated_at 自动填充)
$u = new User();
$u->fill(['name' => 'Alice', 'email' => 'ALICE@EXAMPLE.COM']);
$u->save();
echo $u->id;
echo $u->email; // alice@example.com(修改器生效)
// 更新 + 删除
$u->name = 'Alicia';
$u->save();
$u->delete();
生命周期事件
基于 fiberphp/event 实现,事件命名规范:
orm.{model_short}.{phase}
| phase | 类型 | 触发方式 | 可中断写入 |
|---|---|---|---|
saving | before | dispatch + halt | ✅ 监听器返回 false 或抛异常 |
creating | before | dispatch + halt | ✅ |
updating | before | dispatch + halt | ✅ |
deleting | before | dispatch + halt | ✅ |
saved | after | emit(异常安全) | ❌ |
created | after | emit | ❌ |
updated | after | emit | ❌ |
deleted | after | emit | ❌ |
注册监听器
// 方式一:config/event.php 声明式
return [
'orm.user.created' => [\App\Listener\UserAuditListener::class, 'onCreated'],
'orm.user.*' => fn($data) => logger()->info('user event', $data),
];
// 方式二:运行时注册
use FiberPHP\Event\Event;
Event::on('orm.user.saving', function (array $payload) {
$model = $payload['model']; // User 实例
// 返回 false 中断写入,抛异常也会中断
if ($model->name === 'banned') {
return false;
}
});
触发时机
save():saving→creating/updating→ 写入 →created/updated→saveddelete():deleting→ 删除 →deleted
观察者(Observer)
将一组生命周期方法封装为类,比闭包监听器更易维护。
注册方式
// 方式一:config/orm.php 声明式(OrmProvider::boot() 自动加载)
return [
'observers' => [
UserObserver::class => [User::class, Admin::class],
AuditObserver::class => [User::class, Order::class],
],
];
// 方式二:运行时注册
User::observe(UserObserver::class);
观察者类
use FiberPHP\Orm\Observer\Observer;
use FiberPHP\Orm\Model;
class UserObserver extends Observer
{
public function creating(Model $model): ?bool
{
if ($model->email === 'banned@test.com') {
return false; // before 类返回 false 中断写入
}
return null;
}
public function saved(Model $model): void
{
logger()->info('user saved', ['id' => $model->id]);
}
}
触发顺序
fireModelEvent 先触发 fiberphp/event 监听器,再调用 Observer 方法。before 类中监听器或 Observer 返回 false 即中断写入。
多对多关系(belongsToMany)
// User 模型
class User extends Model
{
public function roles()
{
return $this->belongsToMany(Role::class);
// 约定:中间表 role_user,外键 user_id,关联外键 role_id
}
}
// 自定义
return $this->belongsToMany(Role::class, 'user_role', 'user_id', 'role_id');
使用
// 懒加载
$user = User::find(1);
$roles = $user->roles; // Collection<Role>
// 预加载
$users = User::with('roles')->get();
// pivot 数据访问
$role = $user->roles->first();
echo $role->pivot->user_id; // 中间表行数据
软删除(SoftDelete)
模型 use SoftDelete; 即启用,delete() 变为 UPDATE deleted_at 而非真正 DELETE。
class User extends Model
{
use SoftDelete;
// 子类可覆盖列名
public const DELETED_AT = 'deleted_at';
}
// 软删除(默认查询自动过滤 deleted_at IS NULL)
$user = User::find(1);
$user->delete();
User::find(1); // → null(已被软删除过滤)
// 包含软删除记录
User::withTrashed()->find(1); // → 找到
// 仅查软删除记录
User::onlyTrashed()->get(); // → Collection<软删除的 User>
// 恢复
$user->restore();
User::find(1); // → 找到(已恢复)
// 强制删除(真正 DELETE)
$user->forceDelete();
// 判断是否已软删除
$user->trashed(); // → bool
// 事件:deleting → deleted(软删除)、restoring → restored(恢复)
多态关系(morphOne / morphMany / morphTo)
一个模型通过 {name}_type + {name}_id 列关联多种父模型。
// Post 拥有多个 Comment(morphMany)
class Post extends Model
{
public function comments()
{
return $this->morphMany(Comment::class, 'commentable');
// 列:commentable_type='Post', commentable_id=post.id
}
}
// Comment 属于 Post 或 User 等(morphTo)
class Comment extends Model
{
public function commentable()
{
return $this->morphTo();
// 方法名 'commentable' 即 morph name
// 列:commentable_type, commentable_id
}
}
使用
// 懒加载
$post = Post::find(1);
$post->comments; // Collection<Comment>
$comment = Comment::find(1);
$comment->commentable; // Post 实例(自动解析 commentable_type → 类名)
// 预加载
Post::with('comments')->get();
Comment::with('commentable')->get(); // 按类型分组批量查询
类型解析顺序
morphTo() 通过 commentable_type 值解析类名:
morphMap()静态映射表(子类覆盖)- 同命名空间类名(
Comment→FiberPHP\Orm\Tests\Comment→Post) - 作为完整类名直接加载
跨表关联(hasManyThrough)
A → B → C:A 通过中间模型 B 关联 C 的多条记录,无需在 C 上冗余 A 的外键。
// Country → User → Post
class Country extends Model
{
public function posts()
{
return $this->hasManyThrough(Post::class, User::class);
// 约定:users.country_id → countries.id
// posts.user_id → users.id
}
}
键约定
| 参数 | 含义 | 默认推断 |
|---|---|---|
firstKey | 中间表指向当前模型的外键(users.country_id) | 当前表名单数化 + _id |
secondKey | 关联表指向中间模型的外键(posts.user_id) | 中间表名单数化 + _id |
localKey | 当前模型主键(countries.id) | static::PK |
secondLocalKey | 中间模型主键(users.id) | $through::PK |
不规则复数:默认推断用
rtrim(TABLE, 's')简单去尾 s,countries会产出countrie_id。 遇到 country→countries 等 ies 复数时,显式传firstKey:hasManyThrough(Post::class, User::class, 'country_id')。
使用
// 懒加载
$country = Country::find(1);
$country->posts; // Collection<Post>
// 预加载(1+1 查询)
Country::with('posts')->get();
中间模型作用域
跨表查询走中间模型 B::query(),其全局作用域 base() 一致生效。
如 User::base() 过滤 tenant_id=1,则 Country::find(2)->posts 即使 C 有数据,
只要关联的 B 记录被作用域过滤掉,结果即为空——多租户/软删除在跨表关联中天然生效。
游标分页(cursorPaginate)
无需 COUNT 的游标分页,适合大数据集。每次按游标解码的起始偏移量拉取 perPage + 1 条,
多出的 1 条用于探测是否还有下一页;游标为 base64 编码的 JSON 偏移量,前端原样回传即可翻页。
// 首页
$paginator = User::query()->order('id', 'asc')->cursorPaginate(15);
$paginator->items(); // 当前页模型数组(已切片至 perPage)
$paginator->collection(); // 当前页 Collection
$paginator->hasMorePages(); // bool
$paginator->nextCursor(); // 下一页游标(末页为 null)
$paginator->previousCursor(); // 上一页游标(首页为 null)
// 翻下一页(前端把 next_cursor 回传)
$next = User::query()->order('id', 'asc')->cursorPaginate(15, $cursor);
// 序列化(data 为各模型 toArray)
$paginator->toArray();
// ['data' => [...], 'per_page' => 15, 'next_cursor' => '...', 'previous_cursor' => null, 'has_more' => true]
便捷入口
// 简单分页(无排序/预加载需求)
User::cursorPaginate(15);
// 需要排序或预加载时链式调用
User::with('posts')->order('id', 'asc')->cursorPaginate(15, $cursor);
设计要点
- 全局作用域生效:分页取数前应用
base()/SoftDelete,与get()一致。 - 预加载一致:
with()在分页结果上同样避免 N+1。 - 稳定排序:游标分页依赖确定顺序,调用前应
order()指定排序列,否则翻页结果不确定。 - offset 编码:当前实现以偏移量编码游标(非 keyset),适合中等规模数据;超大并发写入场景可后续扩展为基于排序列的 keyset 游标。
Keyset(seek)游标分页
适合高并发写入场景的分页:游标编码上一页末行的排序键值,翻页用 WHERE (键) OP (末行值) seek 跳过已读行——并发插入/删除不漏行不重复,且性能与页码无关(恒定走索引 seek),不像 LIMIT offset, n 随 offset 增大变慢。
// 默认按主键升序
$page = User::keysetPaginate(perPage: 10);
// 自定义排序(多列需末列唯一以稳定排序,如按时间倒序 + id 兜底)
$page = User::query()
->keysetPaginate(10, $cursor, ['created_at' => 'desc', 'id' => 'desc']);
// 预加载一致生效
$page = User::with('posts')->keysetPaginate(10, $cursor, ['id' => 'desc']);
// 翻页:首页 cursor 传 null,后续传上一页的 next_cursor
$next = User::keysetPaginate(10, $page->nextCursor(), ['id' => 'desc']);
// 序列化(data 为模型 toArray)
$page->toArray(); // ['data'=>..., 'per_page'=>10, 'next_cursor'=>..., 'previous_cursor'=>null, 'has_more'=>bool]
设计要点
- 级联 OR seek:对排序列
[c1..cn]与末行值[v1..vn]生成(c1 OP1 v1) OR (c1=v1 AND c2 OP2 v2) OR ... OR (c1=v1 AND ... AND cn OPn vn), OPi 为>(asc)或<(desc)。通用形式,支持任意方向混合(含混合方向如created_at DESC, id ASC)。 - 游标值绑定:seek WHERE 的值经参数绑定(
?占位符),游标值虽来自库行仍走占位符防注入;列名经parseKey引用。 - 末行键值编码:
nextCursor取末行的原始库值(getAttributes,未经获取器/转换器)以保证 seek 比较与库一致。 - 首页与探测:
cursor=null视作首页(不加 seek WHERE);拉perPage+1条,多出的 1 行探测has_more,无需 COUNT。 - 仅前向:仅支持
next_cursor翻页;后向需反转排序方向,previous_cursor恒为 null。 - 排序列要求:多列排序须保证末列唯一(如
created_at, id,id 兜底)以稳定排序,避免相同键值边界漏行。 - 选型:常规场景用
cursorPaginate(offset,支持双向翻页);高并发写入、深翻页、大数据集用keysetPaginate。
JSON 路径查询(方言感知)
JSON 字段(json cast 列)可用 field->>path / field->path 语法按路径取值查询,方言差异由各数据库 Driver 的 jsonPath() 承担,不在共享 Builder 硬编码:
| 驱动 | ->>(文本,去引号,用于 = 比较) | ->(JSON 值,取子对象/数组) |
|---|---|---|
| MySQL | `meta`->>'$.theme' | `meta`->'$.theme' |
| PostgreSQL | "meta" #>> '{theme}' | "meta" #> '{theme}' |
| SQLite | json_extract("meta", '$.theme') | json_extract("meta", '$.theme') |
// 单层路径
User::query()->where('meta->>theme', 'dark')->get();
// 嵌套路径:meta->>prefs.locale 归一化为 prefs.locale → $.prefs.locale
User::query()->where('meta->>prefs.locale', 'en')->get();
// 数组下标 [n]
User::query()->where('meta->>tags.0', 'php')->get();
JSON 数组包含查询(whereJsonContains)
判断 JSON 数组(可选路径)是否包含某标量值,方言同样由驱动 jsonContains() 承担:
| 驱动 | 实现 |
|---|---|
| MySQL | json_contains(field, value[, '$.path']) |
| PostgreSQL | field @> value::jsonb(路径先 #>> 提取再转 jsonb) |
| SQLite | EXISTS (SELECT 1 FROM json_each(field[, '$.path']) WHERE "value" = json_extract(value, '$')) |
// meta.tags 数组是否含 'php'
User::query()->whereJsonContains('meta->tags', 'php')->get();
// 整字段为 JSON 数组时省略路径
User::query()->whereJsonContains('meta', 'admin')->get();
// 数字成员同样可用(SQLite 经 json_extract 取去引号标量,不依赖列存类型)
User::query()->whereJsonContains('meta->scores', 20)->get();
// OR 版本
User::query()->whereOrJsonContains('meta->tags', 'php')->get();
设计要点
- 单一入口:
Builder::parseKey()识别->/->>分支后委托buildJsonPath()→DriverInterface::jsonPath();whereJsonContains()拆分路径后经 parseKey 引用字段并委托DriverInterface::jsonContains()。路径归一化为点号分隔(a.b/tags.0/[0]),各驱动转译为本地方言。 - 文本 vs JSON 值:
->>返回去引号文本(适合=比较),->返回 JSON 值(适合取子结构);SQLite 两者均用json_extract(scalar 返回去引号值,复合类型返回 JSON 文本)。 - NULL 安全:对 NULL 的 JSON 列,
json_extract返回 NULL,不误命中。 - 作用域一致:路径查询与包含查询均经
where/whereRaw走base()全局作用域,与普通查询一致。
模型工厂(Factory + Faker)
用假数据构造并持久化模型,配合 fakerphp/faker 生成随机值(建议作为开发依赖安装:composer require --dev fakerphp/faker)。
// app/Factory/UserFactory.php
class UserFactory extends Factory
{
protected string $model = User::class;
protected function definition(): array
{
return [
'name' => $this->faker()->name(),
'email' => $this->faker()->unique()->email(),
'meta' => ['theme' => $this->faker()->randomElement(['dark', 'light'])],
];
}
}
$user = User::factory()->create(); // 构造并保存
$user = User::factory()->make(); // 仅构造不保存
$users = User::factory()->createMany(5); // 批量保存(Collection)
$users = User::factory()->makeMany(5); // 批量不保存
$user = User::factory()->create(['name' => 'bob']); // 属性覆盖(优先于 definition)
工厂解析
Model::factory() → Factory::resolveFor(static::class),解析顺序:
- 显式注册:
Factory::register(User::class, UserFactory::class)(命名空间与约定不符时用,建议在应用 Provider 中调用) - 约定:同命名空间下的
{Model}Factory类(如App\User→App\UserFactory)
设计要点
- Faker 懒加载:
$this->faker()首次调用时按$locale创建Faker\Generator并按工厂实例缓存;未安装 fakerphp/faker 时抛异常提示安装。 - 属性覆盖优先:
make()/create()的$attributes参数与definition()合并,传入值优先。 - 修改器/获取器/转换器生效:工厂经
fill()走完整setAttribute链,与手动构造一致。 - 唯一性:
$this->faker()->unique()->email()在同一工厂实例内累计去重,createMany()自动产出唯一值。 - 协程安全:工厂按单次使用设计,
new()每次返回新实例,Faker 生成器按实例缓存,避免跨协程共享有状态生成器。
协程安全设计
- 实例不缓存静态:每次
find()/query()返回全新实例,协程间零共享。 - 关系缓存绑实例:
$user->posts懒加载后写入当前实例的$relations,实例销毁(协程结束)即自动清除。 - 全局作用域走 Context:
base()从FiberPHP\Context读取 tenant_id,天然每协程独立。 - DB 连接池:底层依赖
fiberphp/database的 Workerman 协程连接池,多协程共享连接。
目录结构
orm/
├── src/
│ ├── Model.php # Active Record 基类
│ ├── Query.php # ORM 查询层(包装 database Query,加预加载/base)
│ ├── Collection.php # 模型集合(groupBy/keyBy/sum/avg/...)
│ ├── CursorPaginator.php # offset 游标分页器(继承底层,模型 Collection + toArray)
│ ├── KeysetPaginator.php # keyset(seek)分页器(编码末行排序键值,级联 OR seek,前向翻页)
│ ├── Factory.php # 模型工厂基类(make/create/createMany + Faker 懒加载 + 解析)
│ ├── Pivot.php # 中间表值对象(belongsToMany 附加)
│ ├── Concern/
│ │ ├── Attribute.php # 属性处理(fillable/casts/获取器/修改器)
│ │ ├── Relation.php # 关系入口(hasOne/hasMany/belongsTo/belongsToMany)
│ │ ├── Timestamp.php # 时间戳自动填充
│ │ ├── Scope.php # 全局/局部作用域
│ │ ├── Event.php # 生命周期事件 + Observer 调度
│ │ └── SoftDelete.php # 软删除(delete/restore/forceDelete/withTrashed)
│ ├── Relation/
│ │ ├── Relation.php # 关系抽象基类(懒加载 + 预加载契约)
│ │ ├── HasOne.php
│ │ ├── HasMany.php
│ │ ├── BelongsTo.php
│ │ ├── BelongsToMany.php # 多对多关系(pivot 表查询 + pivot 附加)
│ │ ├── HasManyThrough.php # 跨表一对多(A → B → C)
│ │ ├── MorphOne.php # 多态一对一
│ │ ├── MorphMany.php # 多态一对多
│ │ └── MorphTo.php # 多态从属(自动解析类型 → 类名)
│ ├── Observer/
│ │ ├── ObserverInterface.php # 观察者契约(8 个生命周期方法)
│ │ └── Observer.php # 观察者基类(空默认实现)
│ ├── OrmProvider.php # boot() 从 config 加载 observers
│ └── Install.php
├── config/orm.php # observers 声明(P1 启用)
├── tests/
├── composer.json
└── README.md
后续规划
P0–P3 规划项均已完成,后续按需迭代。