本次迭代新增能力速览(零依赖、独立实现)
下述组件均为本次迭代补齐,延续“使用简单、配置简单、关键词尽量短”的取向;命名空间统一
Mtphp\,无任何Illuminate / Symfony / Think依赖,模板与思想借鉴主流框架但独立实现,规避版权风险。
24.1 加密 Crypt(会话 / Cookie 加密)
Mtphp\Encryption\Crypt:AES-256-CBC + HMAC-SHA256 签名,自动从 config('app.key')(即 .env 的 APP_KEY)取主密钥并派生 enc/mac 两把钥匙;会话与加密 Cookie 默认启用,对开发完全透明(失败自动回退明文,旧文件仍可读取)。
use Mtphp\Encryption\Crypt;
$c = Crypt::enc('敏感串'); // 密文(enc: 前缀)
$s = Crypt::dec($c); // 还原;被篡改会抛异常
Crypt::setKey('base64 密钥'); // 运行时注入密钥(测试 / 特殊场景)
要点:Session 落盘与 Response::eCookie() 输出均为密文,Request::cookie() 自动解密;短方法 enc() / dec()。
24.2 集合 Collection
Mtphp\Support\Collection(全局助手 collect()):链式集合操作,零依赖实现常用方法。
collect([1,2,3])->map(fn($x)=>$x*2)->filter(fn($x)=>$x>3)->toArray(); // [4,6]
collect($users)->pluck('id')->implode(','); // "1,2,3"
collect($rows)->where('type','a')->sortBy('id')->groupBy('cat');
方法覆盖:map / filter / reduce / each / first / last / pluck / contains / keys / values / sort / sortBy / implode / push / merge / unique / groupBy / chunk / pipe / sum / avg / max / min / isEmpty / toArray 等。
24.3 软删除 SoftDeletes
Mtphp\Database\SoftDeletes(trait):自动给模型加 deleted_at 全局作用域,删除只置时间、不真删。
class Post extends Model { use \Mtphp\Database\SoftDeletes; }
Post::find(1)->delete(); // 软删(置 deleted_at)
Post::withTrashed()->find(1); // 含已删
Post::onlyTrashed()->where('id',1)->get();
Post::find(1)->restore(); // 恢复
Post::find(1)->forceDelete(); // 真删
Post::baseQuery()->where(...); // 绕过作用域的裸查询
24.4 Query 增强
Mtphp\Database\Query 新增:whereNull / whereNotNull / orWhereNull、having()(含 HAVING 编译,SQLite 数值参数自动 CAST(? AS INTEGER) 避坑)、havingRaw()(原生 HAVING 片段)、bulkInsert()(批量插入)、upsert()(MySQL ON DUPLICATE KEY UPDATE;SQLite INSERT OR REPLACE)、raw()(逃生舱,直接写 SELECT / 写语句)、whereJson / orWhereJson / whereJsonIsNull / whereJsonNotNull(JSON 文档路径查询,跨库可移植,数值比较自动 CAST(... AS REAL) 修正 SQLite 亲和性)、whereFullText / orWhereFullText(MySQL MATCH...AGAINST)、whereExists / orWhereExists / whereNotExists / orWhereNotExists(EXISTS 子查询,支持闭包 / Query / 原生 SQL)、union() / unionAll()(集合操作,分支可为闭包 / Query / 原生 SQL,绑定按序合并);ModelBuilder 覆写 find($id) 接收主键链式可用。
底层修正:连接层(
Connection/ 测试用SqliteConnector)改为按 PHP 类型绑定参数(int / bool →PARAM_INT,null →PARAM_NULL),根治 SQLite / MySQL“整型值被当字符串绑定导致数值比较恒错”的亲和性陷阱(INTEGER 存储类恒小于 TEXT 存储类,故30 > '15'会被判 false)。此前having()的CAST(? AS INTEGER)与whereJson的CAST(... AS REAL)属于同一问题的局部绕行,现由底层统一解决。
DB::table('order')->whereNull('paid_at')->having('cnt','>',1)->get();
DB::table('log')->bulkInsert([ [...],[...] ]);
DB::table('kv')->upsert(['k'=>1,'v'=>2]); // 存在则更新,否则插入
DB::table('u')->raw('SELECT * FROM u WHERE id=?',[1]);
24.5 限流 Throttle 中间件
Mtphp\Http\Middleware\Throttle:固定窗口限流,默认读 config/throttle.php(max / decay)。超限返回 429 + Retry-After 并带 X-RateLimit-Limit / Remaining 头。
// 路由 / 全局中间件里加上即可
'throttle' => [new \Mtphp\Http\Middleware\Throttle(60, 60)]; // 60 次 / 60 秒
24.6 API Token 鉴权
Mtphp\Auth\Token + Mtphp\Http\Middleware\AuthApi:HMAC 签名令牌,mk($uid,$ttl) 签发、chk($token) 校验、支持吊销名单与按用户吊销;中间件校验 Authorization: Bearer <token>,失败 401,成功写入 Request::setAuthUser()。
$token = (new \Mtphp\Auth\Token())->mk(1, 3600);
(new \Mtphp\Http\Middleware\AuthApi())->handle($req, $next); // 校验 Bearer
$req->authUser(); // 取当前用户
// 路由里:'middleware' => [\Mtphp\Http\Middleware\AuthApi::class]
24.7 邮件 Mail(SMTP / Array / 门面)
Mtphp\Mail\Mail(门面)+ SmtpTransport(纯 PHP socket,零扩展,支持 TLS / AUTH LOGIN)+ ArrayTransport(测试收集);配置见 config/mail.php(driver=smtp|array、host/port/encryption/username/password/from)。
use Mtphp\Mail\Mail;
Mail::to('a@b.com')->subject('Hi')->text('hello')->send(); // 流式发送
Mail::raw('a@b.com', 'Hi', 'hello'); // 一行快速发送
Mail::to('a@b.com')->subject('S')->html('<b>x</b>')->send();// HTML 正文
// 主题含中文自动 MIME base64 编码;正文行首孤点自动转义(SMTP 安全)
无 App 上下文时 Mail::transport() 回退到 ArrayTransport,便于脱离配置做单元测试。
24.8 数据填充 Seeders + seed 命令
Mtphp\Database\Seeder(抽象基类,run() 必实现,call() 调用子填充);命令 php bin/mtphp seed(默认 App\Seeders\DatabaseSeeder,可用 --class= 指定)。示例见 app/Seeders/DatabaseSeeder.php、UserSeeder.php。
// app/Seeders/DatabaseSeeder.php
class DatabaseSeeder extends \Mtphp\Database\Seeder {
public function run(): void {
\Mtphp\Database\DB::table('user')->insert(['name'=>'演示用户','email'=>'demo@example.com']);
$this->call(\App\Seeders\UserSeeder::class);
}
}
24.9 日志增强(PSR-3 八级 / 按日切分 / 多通道)
- PSR-3 八级:
debug / info / notice / warning / error / critical / alert / emergency(数值越大越严重)。 - 按日切分:
Mtphp\Log\DailyLogger('runtime/logs/app', 'debug')→app-2025-01-31.log。 - 多通道:
Mtphp\Log\LogManager按config/log.php的channels构建并缓存;logger()->channel('daily')->error(...)切换通道。
// config/log.php
return [
'default' => 'single',
'channels' => [
'single' => ['driver'=>'single','path'=>'runtime/logs/app.log','level'=>'info'],
'daily' => ['driver'=>'daily', 'path'=>'runtime/logs/app', 'level'=>'debug'],
],
];
24.10 路由增强(命名路由 route() / resource())
Route::get($uri,$action,$mw=[],$name=null) 支持命名;route($name,$params) 生成 URL;Route::resource($name,$controller) 一键注册七条 RESTful 路由;分组支持 as(名称前缀)。
use Mtphp\Route;
Route::get('/users/{id}', 'User@show', [], 'users.show');
Route::resource('posts', 'Post@'); // index/create/store/show/edit/update/destroy + 对应命名
route('users.show', ['id'=>5]); // → /users/5
route('users.index', ['page'=>2]); // → /users?page=2
24.11 数据库短别名(关键词尽量短)
Mtphp\Database\DB 在既有 beginTransaction / commit / rollBack / transaction 之上,新增更短的入口:
DB::startTrans(); // 开事务(= beginTransaction)
DB::trans(fn()=>{...}); // 事务闭包(= transaction)
DB::commit(); // 提交
DB::rollBack(); // 回滚
24.12 HTTP 客户端重试与超时
Mtphp\Http\Client 新增 retry($attempts,$delayMs) 与 connectTimeout($sec);仅对“传输层失败”(curl 状态码 0 / 抛异常)重试,HTTP 业务码(如 500)不重试。
http('https://api.example.com')
->timeout(30)->connectTimeout(7) // 超时 / 连接超时(秒)
->retry(3, 200) // 最多 3 次,每次间隔 200ms
->get('/x');
24.13 TestResponse 测试客户端
Mtphp\Testing\TestResponse:包装一次 Http\Response,提供可读断言,让 HTTP / 控制器功能测试更直观。
use Mtphp\Testing\TestResponse;
$r = new TestResponse($response);
$r->assertOk()->assertStatus(200)
->assertJson(['ok'=>true,'data'=>['id'=>1]])
->assertSee('hello')
->assertHeader('Content-Type','application/json; charset=utf-8');
// 另有 assertNotFound / assertForbidden / assertUnauthorized
24.14 事件优先级与通配
Mtphp\Events\Dispatcher::listen($events,$listener,$priority=0):数值越大越先执行;支持通配键 * (监听全部事件)与 App\Events\* (命名空间前缀匹配)。
event()->listen(OrderPaid::class, $h, 10); // 高优先级先执行
event()->listen('*', $globalFallback); // 全局兜底
event()->listen('App\Events\*', $nsHandler); // 命名空间通配
24.15 队列失败重试退避
Mtphp\Queue\DatabaseQueue 的失败重试由固定延迟升级为指数退避:delay = retryDelay × 2^(attempts−1)(封顶 3600s),避免重试风暴;达到 max_attempts 后写入 failed_jobs。文件驱动保持“at-most-once → 失败目录兜底”的设计不变。
// config/queue.php(database 驱动)
'retry_delay' => 5, // 首次重试延迟;其后 10s → 20s → 40s … 封顶 3600s
'max_attempts' => 3, // 超过则进失败表
以上全部能力均配套零依赖单测(见
tests/,php tests/run.php一键跑全量,全绿)。
24.16 ORM 关联与模型事件增强
- 多对多
belongsToMany:新增Mtphp\Database\Relations\BelongsToMany关系类与Model::belongsToMany(),经中间表(pivot)连接两模型;复用既有with()预加载 /Model::load()懒加载机制,支持点号嵌套(如users.roles.permissions)与反向关联。默认中间表名 = 两模型「单数类名 snake」字母序拼接(如User+Role⇒role_user),外键列默认本表单数名_id/关联表单数名_id,均可在调用时显式覆盖。 - 模型事件(Model Events):
Model新增registerModelEvent($event,$cb)与observe($observer)(观察者方法名即事件名);create/update/delete接通saving/creating/created/saved、updating/updated、deleting/deleted全周期钩子;写前事件(creating/updating/saving/deleting等)显式返回false即可拦截写操作。事件按「模型类」隔离(互不影响),并提供clearModelEvents()供测试隔离。SoftDeletes的delete/restore/forceDelete同步接通deleting/deleted/restoring/restored/forceDeleting/forceDeleted。
配套零依赖单测:
tests/BelongsToManyTest.php(懒加载 / 预加载 / 反向 / 嵌套)、tests/ModelEventsTest.php(事件触发 / 观察者 / 写前拦截 / 按模型隔离 / 软删除事件),全量php tests/run.php一并绿。
24.17 关联存在性约束 + 便捷写
- 关联存在性约束
whereHas/has/doesntHave:ModelBuilder新增has($relation,$operator='>=',$count=1)、whereHas($relation,$callback,$operator,$count)、doesntHave($relation,$callback)及对应的or*变体;Model新增同名静态代理(User::has(...)等,返回可链式的ModelBuilder)。底层按关联类型生成EXISTS/NOT EXISTS子查询(belongsToMany走「中间表 + 嵌套 EXISTS」),带计数比较时生成(SELECT COUNT(*) ...) $op $n标量子查询;约束闭包作用于「关联模型构造器」,支持hasMany/hasOne/belongsTo/belongsToMany全部四种关联。 - 便捷写
firstOrCreate/updateOrCreate/firstOrNew:Model新增按属性查找的便捷静态方法,均复用已接通模型事件的create/update,返回「行数组」(含主键);firstOrNew未命中时返回未落库的属性数组。 - 隐性修复(RAW 片段反引号):
Query::compileWheres()此前会对RAW类型条件(如whereRaw、关联子查询链接、MATCH(...))整体套反引号,导致像posts.user_id = users.id这类不含括号的片段被误当成单个列名。本次改为 RAW 片段原样透传(不反引号),并新增Query::whereRaw()/orWhereRaw()(支持多占位符参数化)。
配套零依赖单测:
tests/WhereHasTest.php(hasMany / belongsTo / belongsToMany 的 EXISTS、计数、约束闭包、NOT EXISTS、orHas)、tests/FirstOrCreateTest.php(三种便捷写 + 不落库验证),全量php tests/run.php一并绿。
24.18 Query 构造器补全(核心功能缺口)
经核查,以下为 Query/ModelBuilder 真实缺失的构造器能力(非文档/别名类装饰),本次补齐:
- 列间比较
whereColumn/orWhereColumn:WHERE a op b,两侧均为列名/表达式,原样透传、不反引号、不绑定占位符(编译新增COLUMN分支)。 - 追加选择
addSelect:在现有SELECT字段基础上追加列(与select()的“替换”语义区分开),用于withCount/聚合后补列等场景。 - 原生排序
orderByRaw:支持排序表达式/聚合/随机/自定义顺序,绑定参数随主查询序列自动合并(编译层orders新增RAW分支)。 - 连接变体
leftJoin/rightJoin/crossJoin:join($table,$on,$type)的语义化封装;CROSS编译时省略ON子句(RIGHT 在 SQLite 下仅生成 SQL)。 - 抽取
pluck:抽取单列或「列→键」映射为数组。 - 条件/旁路构造
when/tap:when($cond,$cb,$default?)条件式加条件、tap($cb)旁路副作用钩子,二者均返回构造器自身以维持链式。
配套零依赖单测:
tests/QueryBuilderCompletenessTest.php(列间比较 / addSelect / orderByRaw 含绑定 / 左连接缺匹配补 NULL / 右连接与交叉连接 / pluck 列表与映射 / when / tap),全量php tests/run.php一并绿。
24.19 Query 条件构造补全(嵌套 WHERE / whereIn 子查询 / 批量删除)
- 闭包嵌套 WHERE 分组:
where()/orWhere()在首参为\Closure时自动转为嵌套分组(整体括号包裹后与外层按 AND / OR 连接),新增Query::group($callback, $boolean)助手;编译层compileWheres()早已支持['group']元组,本次补齐公开 API。 whereIn/whereNotIn子查询版:签名由「仅数组」放宽为array|Query|\Closure|string,闭包 / Query 实例 / 原生 SQL 均可作为子查询数据源(如whereIn('id', fn($q)=>$q->table('vip')->select('user_id')));编译新增IN_SUB/NOT_IN_SUB分支,与whereExists共用normalizeSub()/compileSub()(自动带表前缀、绑定按主查询顺序合并)。Model::destroy(array|int|string $ids):按主键批量删除(数组 / 单值 / 逗号串),复用已接通模型事件的删除路径;命中写前事件(deleting)返回false时整批中止(返回 0)。
配套零依赖单测:
tests/QueryConditionsTest.php(嵌套 AND 分组 / 嵌套 OR 分组 / whereIn 闭包与 Query 实例两种子查询 / whereNotIn 子查询 / destroy 数组·单值·逗号串 / 写前事件拦截),全量php tests/run.php一并绿。
24.20 FROM 派生表 / orHaving / from 别名 + 列名反引号修复
from(别名):Query::from($table)是table()的同义别名(FROM 子句的自然命名,Laravel 风格),闭包子查询内写from()更直观。- FROM 派生表
fromSub/fromRaw:以子查询(闭包 / Query 实例 / 原生 SQL)作为查询源表,须指定别名;子查询与主查询共享连接与前缀,其绑定按“先子查询、后主查询”顺序自动合并(顺带修正了compile()中$binds被whereSqlAndBindings()覆盖导致派生表绑定丢失的隐患)。fromRaw为手写 SQL 作源表的逃生舱。 orHaving:having()的“逻辑或”版本,语法同having。- 隐性修复(列名反引号):
quoteColumn()现对带点号的限定列(如o.total)逐段加反引号(`o.total);并同步修正orderBy编译分支——此前orderBy('o.amount')会把整串当作单个标识符反引号(o.amount),导致 SQLite 报no such column。where路径早已走quoteColumn,本次补齐orderBy` 的一致处理,连接 / 派生表上引用限定列不再报错。
配套零依赖单测:
tests/QueryFromSubTest.php(fromSub 闭包 / Query 实例 + where / fromRaw 含绑定 / SQL 渲染 / fromSub + orderBy 非聚合派生表 / orHaving),全量php tests/run.php一并绿。
24.21 模型作用域(本地 / 全局)+ 多态关联 + 远程一对多
- 本地作用域(scopeXxx):模型定义
scopeXxx($builder, ...$args),即可Post::published()->where(...)静态调用,或$builder->published()在构造器上调用(自动透传参数)。其余未定义静态方法也透传给ModelBuilder(如->latest())。
class Post extends Model {
public function scopePublished($q) { $q->where('published', 1); }
public function scopePopular($q, int $views) { $q->where('views', '>=', $views); }
}
Post::published()->get(); // 静态调用本地作用域
Post::popular(100)->get(); // 带参数
Post::query()->published()->where('views', '>=', 50)->get();
- 全局作用域(global scopes):
Model::addGlobalScope()注册的作用域在「每次」查询编译前自动 apply(覆盖 get / find / count / whereHas / paginate 等一切执行路径),除非调用withoutGlobalScope($scope)/withoutGlobalScopes()移除。支持三种注册形式:字符串键 + Closure、匿名 Closure(以spl_object_hash为标识)、Scope对象(以类名标识,契约见Mtphp\Database\Scopes\Scope)。
Post::addGlobalScope('adult', fn ($q) => $q->where('age', '>=', 18));
Post::addGlobalScope(fn ($q) => $q->where('active', 1)); // 匿名
Post::addGlobalScope(new PublishedScope()); // Scope 对象
Post::withoutGlobalScope('adult')->get(); // 移除单个
Post::withoutGlobalScopes()->get(); // 移除全部(如后台管理需要看到全部记录)
- 多态关联:新增
morphMany/morphOne/morphTo/morphToMany(思想借鉴主流框架,独立实现)。多个不同类型父模型共享同一子表(按类型列 + 外键列区分,如评论 / 图片 / 标签): morphMany/morphOne定义在「父」侧:Post::comments() => $this->morphMany(Comment::class, 'commentable');commentable_type默认写入本模型的「多态类型」(可被setMorphMap改写为别名,如post)。morphTo定义在「子」侧:Comment::commentable() => $this->morphTo()(默认从调用方法名推导commentable_type/commentable_id,也可显式传名);读取时凭type列经getMorphAlias()反查出真实父类。morphToMany:经多态中间表(如taggables(taggable_type, taggable_id, tag_id))连接另一模型,语义同belongsToMany的多态版。- 上述关联全部支持懒加载(
getResults)、预加载(with/load,含按类型列隔离与 morphTo 的按类型分组取回)、withCount(morphMany / morphOne)与has/whereHas/doesntHave(morphTo 无确定父表,不支持has)。 - 远程一对多
hasManyThrough:经一张中间表跨两跳取关联——Country::cities() => $this->hasManyThrough(City::class, User::class)(Country 经 User 拥有 City)。支持懒加载、预加载、以及whereHas/doesntHave(经「中间表 + 嵌套 EXISTS」判定)。
配套零依赖单测:
tests/ModelFeaturesTest.php(本地作用域静态 / 构造器调用、全局作用域自动套用 / 对象形式 / withoutGlobalScope(s)、morphMany/morphOne/morphTo 懒加载与预加载 + withCount、morph map、morphToMany、hasManyThrough 懒加载 / 预加载 / whereHas),全量php tests/run.php一并绿。
24.22 Query 便捷谓词 + ORM 计数 / 失败变体 / 批量赋值保护
补齐此前遗漏的「真实框架功能」缺口(均零依赖、纯新增,并已通过单测):
- Query 区间 / 集合谓词补全
whereNotBetween/orWhereNotBetween:值不在区间(NOT BETWEEN ? AND ?);whereBetweenColumns/orWhereBetweenColumns:区间两端为「其它列」而非绑定值(created_at BETWEEN start AND end);havingBetween/orHavingBetween:HAVING 区间过滤(聚合后过滤,与having对称)。- Query 排序 / 分页便捷方法
latest($column='created_at')/oldest($column='created_at'):按时间列降/升序,等价于orderBy($column, 'DESC'/'ASC');inRandomOrder():随机排序,跨库兼容(SQLite 用RANDOM(),其余用RAND(),经Connector::getDriver()判定);take($n)(limit 别名,保留 offset)/skip($n)(offset 别名,保留 limit)/offset($n)(同 skip)/forPage($page, $perPage=15)(按页码切片)。- Query 失败变体:
firstOrFail()取第一条,不存在抛ModelNotFoundException。 - ORM 关联计数补全
morphToMany现已支持withCount(经多态中间表按「类型列 + 外键列」分组计数,与 hasMany/hasOne/morphMany/morphOne 共用eagerLoadCounts);- 新增
Model::loadCount(array &$rows, $relations):对已「取出」的结果集追加关联计数(等价于withCount的懒加载版,withCount适用于查询时、loadCount适用于已持有数组时)。 - ORM 失败变体:
Model::firstOrFail($attributes)/updateOrFail($id, $data)/deleteOrFail($id),命中 0 行(记录不存在)时抛ModelNotFoundException(与findOrFail一致)。 - 批量赋值保护(fillable / guarded,零依赖、默认宽松向后兼容)
- 模型可声明
protected static array $fillable = [...](白名单)或protected static array $guarded = ['*'](黑名单,配合$fillable放行);未声明时遵循宽松默认——允许全部字段,绝不破坏既有create/update行为。 create/update在写入前经fillableData()过滤;对外暴露isFillable($key)、unguard()/reguard()(测试 / 导入场景临时放开)。
配套零依赖单测:
tests/QueryGapTest.php(whereNotBetween / orWhereNotBetween / whereBetweenColumns / orWhereBetweenColumns / havingBetween / orHavingBetween / latest / oldest / inRandomOrder / take / skip / offset / forPage / firstOrFail 的 SQL 渲染与结果校验)、tests/OrmExtraTest.php(morphToMany withCount + loadCount、hasMany loadCount、morphMany withCount、fillable / guarded / 默认宽松、firstOrFail / updateOrFail / deleteOrFail),全量php tests/run.php一并绿。
24.23 Query 便捷查询 + 模型便捷读 + 预加载约束闭包 + 软删判定
补齐此前遗漏的「真实框架功能」缺口(均零依赖、纯新增,并已通过单测):
- Query 便捷查询
doesntExist():exists()的反义,无匹配行返回true(等价于!exists(),语义更直白);sole():精确取「恰好一条」——0 条抛ModelNotFoundException,>1 条抛RuntimeException('sole() 期望恰好一条记录,实际找到 N 条'),恰好 1 条返回该行(用于强唯一约束场景,如按唯一键查配置);each(callable $cb, ?int $size = 100):基于chunk游标分页逐行遍历大数据集,避免一次性get()占满内存(闭包收到「数组风格」行);reorder(?string $column=null, ?string $dir='ASC'):清空既有orders,可再设新排序;reorder()无参则彻底移除排序(覆盖默认/全局作用域排序)。- 模型便捷读
Model::findMany(array $ids, array $columns=['*']):按主键批量取,自动去重,空数组直接返回[](不报错);Model::sole(array $attributes=[], array $columns=['*']):按条件精确取一条,复用Query::sole()自动抛 0/多 异常(0 条 →ModelNotFoundException,多 条 →RuntimeException)。- 预加载约束闭包(with / withCount 支持 callable) ✅ 顺带修了真 bug
Model::with(['posts' => fn ($q) => $q->where('published', 1)->orderBy('id')]):预加载时对「关联查询」追加任意 where/orderBy/limit 约束,不影响父查询;Model::withCount(['posts' => fn ($q) => $q->where('published', 1)]):计数子查询同样支持约束;- 嵌套路径约束:
with(['posts.comments' => fn ($q) => $q->where('body', 'hi')])约束自动路由到末段关联(优先匹配全路径posts.comments,回退单段名comments)。 - 真 bug 修复:
ModelBuilder::with()/withCount()原约束解析分支(foreach ($list as $value)+is_array($value) && is_callable(reset($value)))对['rel'=>fn]永远不成立(foreach 的值是闭包本身),导致with(['rel'=>fn])静默退化为「无约束预加载」。已改为foreach ($list as $key => $value)并按is_callable($value)解析(键即关系名),并在eagerLoadRelations中按全路径/单段名两级匹配应用约束;约束先经Relation::constrain()注册、getResults/getEager取数据前由applyConstraints()套用。 - 软删判定:
SoftDeletes::trashed(mixed $id): bool——按主键查该记录是否处于「已软删」状态(仅deleted_at IS NOT NULL视为 true,记录不存在或已恢复均返回 false)。
配套零依赖单测:
tests/QueryConvenienceTest.php(doesntExist / sole 的 0/1/多 三种分支 / each 全量+小批次 / reorder 清排序+重设 / findMany 去重+空数组+指定列 / Model::sole / SoftDeletes::trashed 增删恢复三态)、tests/EagerConstraintTest.php(hasMany / hasOne / belongsTo / belongsToMany / morphMany / morphOne / morphToMany / hasManyThrough 的 with 约束、withCount 约束、嵌套路径posts.comments约束),全量php tests/run.php一并绿。
24.24 校验 exists 规则 + Collection 高阶筛选
补齐此前遗漏的真实框架功能缺口(均零依赖、纯新增,已通过单测):
- Validation 数据库规则补全:
exists exists:table,column:值必须在指定表/列中存在(数据库规则,是unique的对偶)。列缺省时取字段名;空值(null /'')不参与校验(与required配合,可选字段留空即放行);无数据库上下文时静默跳过。- 例:
'email' => 'exists:users,email';也可配合unique做“更新时排除自身”之外的存在性校验。自定义规则仍走Validator::extend()与内联闭包两种扩展方式。 - Collection 高阶筛选(
Mtphp\Support\Collection,零依赖) keyBy($key | callable):以元素某列或回调返回值为索引重组集合(保留键);collect($users)->keyBy('id')得['1'=>user1, …];where($key | callable, $op = null, $value = null):按元素键/值或回调过滤;支持=!=>>=<<=like等操作符,两参形式默认==,单回调形式按返回真值保留;whereIn($key, array $values):按元素某键的值是否属于给定集合过滤;firstWhere(...):取首个满足条件的元素,无匹配返回null。- 以上均返回新集合(或
firstWhere返回元素),可继续链式。
配套零依赖单测:
tests/ValidatorTest.php::testExistsRule(存在通过/不存在失败/列缺省取字段名/空值放行/unique 反向)、tests/CollectionTest.php(keyBy / where 默认与操作符与回调 / whereIn / firstWhere),全量php tests/run.php一并绿。
24.25 缓存便捷方法(pull / add / forever / rememberForever / increment / decrement)
补齐此前遗漏的真实框架功能缺口(均零依赖、纯新增,已通过单测):
pull($key, $default):读取并删除(get + delete),常用于“一次性令牌 / 消费队列式取值”。add($key, $value, $ttl):仅当键不存在时写入(set-if-absent),已存在则不动并返回false(不覆盖),用于“首次写入独占”。forever($key, $value):写入并永不过期(ttl = null)。rememberForever($key, $callback):记忆化且永不过期。increment($key, $step = 1)/decrement($key, $step = 1):自增/自减计数(缺失视为 0),返回变化后的值,常用于访问计数、限流计数。
以上写入 CacheInterface 契约,并在 AbstractCache trait 给出通用实现(文件驱动 FileCache 与 Redis 驱动 RedisStore 共用)。注:通用版 add/increment 为「has+set」「get+set」两步(单进程安全),高并发强一致场景可由驱动覆盖为原子指令(SET NX / INCRBY)。
配套零依赖单测:
tests/FileCacheTest.php(pull / add 已存在不覆盖 / forever+rememberForever / increment+decrement)、tests/ThrottleTest.php与tests/TokenTest.php的CacheInterface测试替身同步补齐 6 方法,全量php tests/run.php一并绿。
24.26 简单分页 simplePaginate(免 COUNT 大表)
补齐此前遗漏的真实框架功能缺口(均零依赖、纯新增,已通过单测):
Query::simplePaginate($perPage = 15, $page = null):与paginate()对称,但不执行 SELECT COUNT——靠“多取 1 行”判定是否存在下一页。适合大表 / 无需精确总数的场景(避免 COUNT 全表扫描)。Model经Model::query()->simplePaginate(...)自动可用。- 返回的 Paginator 为“简单模式”:
total()/lastPage()返回-1(无意义),但hasMorePages()/nextPageUrl()/previousPageUrl()/onFirstPage()仍可正常用;links()在简单模式下只渲染“上一页 / 下一页”,不显示页码。
配套零依赖单测:
tests/PaginationTest.php(simplePaginate 首/末页、items 数、hasMorePages/nextPageUrl/previousPageUrl、total/lastPage=-1、Model 调用、toArray 与 links 简单模式),全量php tests/run.php一并绿。
24.27 缓存原子锁 lock(分布式锁)
CacheInterface::lock(string $name, int $seconds = 0, ?string $owner = null): Lock:契约新增;cache()->lock(...)(文件 / Redis 两驱动共用)。返回Mtphp\Cache\Lock对象(尚未真正占用)。Lock::get():尝试获取锁;成功返回true。底层用缓存驱动的原子add()(文件fopen('x')独占创建、RedisSET ... NX)避免“has + set”竞态;owner令牌保证只有持有者可释放;过期锁可被后续竞争者安全接管(防死锁兜底)。Lock::block(int $timeout, ?callable $callback = null):阻塞等待获取,最长$timeout秒;拿到后可(可选)执行$callback并在结束时自动释放——无回调时返回bool,有回调时返回回调结果。Lock::release()/forceRelease()/owner()/isAcquired()/getRemainingLifeTime():释放 / 强制释放 / 持有者令牌 / 是否持有 / 剩余秒数(seconds=0永不过期返回PHP_INT_MAX)。- 两驱动
add()重写为原子原语,既支撑锁也提升并发安全add本身。
$lock = cache()->lock('order:' . $id, 10); // 持锁 10 秒
if ($lock->get()) {
try {
// 临界区
} finally {
$lock->release();
}
}
// 或:阻塞等待并在拿到锁后执行回调(结束自动释放)
cache()->lock('order:' . $id, 10)->block(5, fn () => /* 临界区 */ null);
24.28 事件增强:subscribe / until / flush
Dispatcher::subscribe(string|array|object $subscriber):批注册监听器。传数组[事件类名 => 监听器]逐条listen;传含subscribe(Dispatcher $events)方法的对象/类名,由其在内部自行listen(订阅者模式)。Dispatcher::until(object $event, array $payload = []):派发并短路——按优先级依次调用监听器,返回首个非null的返回值并停止后续派发;全部为null则返回null。Dispatcher::flush():清空全部已注册监听器(便于测试隔离)。
24.29 HTTP 客户端并发 concurrent(curl_multi)
Client::concurrent(array $requests): list<ClientResponse>:用curl_multi并发执行多个请求,返回与入参同序的响应数组。每个请求支持三种形态:string(路径/URL,GET)、array(含url/method/data/headers/options局部覆盖)、Client(取其baseUrl作 GET)。- 抽出
prepare()供单请求与并发共用;并发支持注入setMultiTransport()(离线测试用),默认走curl_multi。注意:并发请求不走单请求的 retry / transport。
$resps = http('https://api.test')->withToken($t)->concurrent([
'/a',
['url' => '/b', 'method' => 'POST', 'data' => ['x' => 9]],
['url' => 'https://other.test/c'],
]);
24.30 i18n 复数 choice / trans_choice
Translator::choice(string $key, int $count, array $replace = [])与全局助手trans_choice($key, $count, $replace):按计数从多段文案中挑出匹配形态。- 段间以
|分隔,条件包在{...}或[...]中:{0}精确、{1,3}闭区间、{5,*}下界、{*,10}上界、{*}任意(catch-all)。无条件的恰两段文案按单复数降级(count==1取前段,否则取后段)。 - 选中段再做占位符替换;
:count永远被替换为当前计数。
__('apples', ['count' => 5]); // 假设文案 '{0} 没有苹果|{1} 一个苹果|[2,*] :count 个苹果' → "5 个苹果"
trans_choice('apples', 5);
24.31 Query::getBindings()(导出 toSql 绑定)
Query::getBindings(): array:导出与toSql()一一对应的绑定参数序列(等价于compile()[1]),含whereRaw()额外绑定、子查询 / union 绑定。便于调试、记录 SQL 与参数。
$q = DB::table('users')->where('age', '>', 18)->where('city', '=', 'WH');
$sql = $q->toSql(); // SELECT * FROM `users` WHERE `age` > ? AND `city` = ?
$binds = $q->getBindings(); // [18, 'WH']
24.32 Collection 集合操作:only / except / partition / zip / collapse
only(array $keys)/except(array $keys):只保留 / 剔除指定键的元素(保留原键,返回新集合)。partition(callable $cb): [Collection, Collection]:按回调把集合拆成[通过, 未通过]两段。zip(array ...$arrays): Collection:与一个或多个数组按位置一一配对(不足补null)。collapse(): Collection:折叠一层嵌套数组(子数组展平一级,重索引)。
配套零依赖单测:
tests/CacheLockTest.php(独占/释放/owner/block 回调与超时/forceRelease/过期接管/永久锁 + 条件 Redis 锁)、tests/EventsSubscribeTest.php(subscribe 数组与订阅者对象 / until 短路 / flush)、tests/HttpClientConcurrentTest.php(并发请求构建 + 同序响应 + Client 形态)、tests/TranslationChoiceTest.php(精确/区间/上界/无匹配/catch-all/单复数/:count 自动替换)、tests/QueryBindingsTest.php(条件/whereIn/whereRaw/空绑定与 SQL 对齐)、tests/CollectionTest.php(only/except/partition/zip/collapse),全量php tests/run.php一并绿。
24.33 附件上传自动压缩(ImageCompressor + UploadedFile)
框架的附件上传此前只有 UploadedFile::move() 原样搬文件、无压缩。本次补齐可选、GD 守卫的图片自动压缩,作为 UploadedFile::move() 的开关 + 参数,并通过 config/upload.php 提供全局默认。
引擎:Mtphp\Image\ImageCompressor(零依赖,GD 守卫)
compress(string $src, string $dest, array $options = []): string|false:缩放 + 重编码压缩;成功返回最终写出路径(可能因format改写扩展名),动画 GIF(format=keep)跳过时返回false,异常(GD 缺失 / 非位图 / 写出失败)抛RuntimeException。- 用
extension_loaded('gd')守卫:GD 缺失仅抛异常,不破坏框架零依赖安装(GD 是 PHP 图片处理事实标准,绝大多数环境已内置,属"软依赖")。 - 参数
options:quality(0-100,默认 82)、max_width/max_height(0=不限)、format(keep|jpeg|png|webp)、auto_rotate(默认 true)。 - 安全降级:动画 GIF(
format=keep)原样保留避免丢帧;WebP 仅在imagewebp可用时生效、否则回退 jpeg;EXIF 转正仅 jpeg 且exif_read_data可用时处理;GD 重编码天然剥离 EXIF/IPTC。
接入:UploadedFile::move() 第三参
$file = request()->file('avatar');
// 第三参:null(走全局开关) | false(强制关闭) | 数组(逐次覆盖)
$dest = $file->move('uploads/avatar', null, [
'max_width' => 1920,
'max_height' => 1920,
'quality' => 82,
'format' => 'keep',
'auto_rotate' => true,
]);
// 压缩失败不丢原文件,仅记录:
if ($file->getLastError() !== null) { /* GD 缺失 / 非图片等 */ }
- 新增
UploadedFile::getLastError(): ?string(压缩失败原因;成功/未压缩为 null)、UploadedFile::setGlobalCompressOptions(?array)(无 App 配置或测试时设置程序化默认,覆盖config)。 - 默认
config/upload.php的compress.enabled=false:全局不自动压缩,move()行为与旧版一致(向后兼容);设为true后不传第三参也自动压缩,调用方传false可强制关闭、传数组逐次覆盖。 - 非图片(PDF/zip 等):压缩会失败并保留原文件(
getLastError()非空),不会报错丢文件。
配套零依赖单测:
tests/ImageCompressTest.php(默认不压缩 / 显式压缩缩放 / false 覆盖全局 / 全局配置生效 / 动画 GIF 跳过 / 非图片穿透 / 直调 WebP / 缺文件抛异常 / 非法类型抛异常),全量php tests/run.php一并绿。