Coolify Eloquent 最佳实践:关系类型提示、本地作用域与属性转换的规范落地
本文基于 Coolify 仓库中的智能体技能规则文档 eloquent.md,系统讲解其中定义的 7 条 Eloquent 编码规则:关系类型与返回值类型提示、本地作用域(Local Scopes)、全局作用域的克制使用、属性类型转换(Casts)、日期列转换、whereBelongsTo() 用法,以及禁止在查询中硬编码表名。读完后,你可以对照 laravel-best-practices 技能入口 快速审查或重构 Coolify 这类大型 Laravel 项目中的模型层代码,并在 app/Models 下找到每条规则的现成落地样本。
文档定位:这是 Coolify 的 Eloquent 规范基线
eloquent.md 位于 .agents/skills/laravel-best-practices/ 目录,是 Coolify 为编码 Agent 准备的 Laravel 最佳实践规则集的第 5 节(Eloquent Patterns)。技能入口 SKILL.md 的 Quick Reference 对它的概括是:
- 使用正确的关系类型并给出返回类型提示;
- 用本地作用域封装可复用的查询约束;
- 全局作用域要克制——并记录它们的存在;
- 属性转换放在
casts()方法中; - 转换日期列,在模板中使用 Carbon 实例;
- 用
whereBelongsTo($model)写更干净的查询; - 永不硬编码表名——用
(new Model)->getTable()或 Eloquent 查询。
SKILL.md 还强调了一条总原则 "Consistency First"(见 SKILL.md 第 13-17 行):应用任何规则之前,先看代码库里已经怎么写。Laravel 对同一问题往往提供多种合法写法,最佳选择是代码库既有的方式;这些规则只在没有既定模式时作为默认值,而不是覆盖。理解这一点后再逐条看下面的规则,才能把它们用到 Coolify 这样的存量项目上而不制造风格分裂。
规则一:使用正确的关系类型,并写返回类型提示
原文档的第一条规则要求:hasMany、belongsTo、morphMany 等关系方法应使用正确的返回类型提示(HasMany、BelongsTo 等)。类型提示让 IDE 能推断关系对象的方法签名,也让静态分析工具可以检查关系实现是否正确。
文档给出的标准写法:
public function comments(): HasMany
{
return $this->hasMany(Comment::class);
}
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
Coolify 的模型层正是这样落地的。搜索 app/Models 下带返回类型提示的关系方法,可以看到多处一致实践:
- DockerCleanupExecution::server() 声明为
public function server(): BelongsTo; - ScheduledVolumeBackup::team() 声明为
public function team(): BelongsTo; - UserChangelogRead::user() 声明为
public function user(): BelongsTo; - ScheduledTask 中
latest_log(): HasOne和executions(): HasMany也遵循同一模式,并且直接在关系里附加了排序约束:
public function latest_log(): HasOne
{
return $this->hasOne(ScheduledTaskExecution::class)->latest();
}
public function executions(): HasMany
{
// Last execution first
return $this->hasMany(ScheduledTaskExecution::class)->orderBy('created_at', 'desc');
}
从这段源码可以看出,Coolify 把"关系自带排序约束"也作为惯例使用,调用 executions 时天然得到按时间倒序的执行记录,避免每次调用方重复写 orderBy。
需要注意的边界:Coolify 也有刻意不声明关系的"伪关系"方法,例如 ScheduledTask::server() 返回 ?Server 而非 BelongsTo——因为定时任务并不直接属于服务器,而是经由 application/service 的 destination 间接推导。这类跨多跳的推导逻辑用普通方法表达比强行写关系更诚实,符合"使用正确关系类型"的本意:只有数据库层真实存在外键关系时才使用关系方法。
规则二:用本地作用域封装可复用的查询约束
原文档第二条规则:把可复用的查询约束提取为本地作用域(Local Scopes),避免重复。文档对比了错误与正确写法——错误写法是同一段"活跃用户"约束在多处散落:
// 错误:约束重复书写
$active = User::where('verified', true)->whereNotNull('activated_at')->get();
$articles = Article::whereHas('user', function ($q) {
$q->where('verified', true)->whereNotNull('activated_at');
})->get();
正确写法是抽成 scopeActive,之后两处都通过 User::active() / $q->active() 复用:
public function scopeActive(Builder $query): Builder
{
return $query->where('verified', true)->whereNotNull('activated_at');
}
// 用法
$active = User::active()->get();
$articles = Article::whereHas('user', fn ($q) => $q->active())->get();
Coolify 代码库中有多个真实样本印证了这一模式:
- ScheduledVolumeBackup 定义了
scopeForApplication(Builder $query, Application $application): Builder与scopeForService(Builder $query, Service $service): Builder,把"属于某应用/某服务的备份"这一高频过滤条件收敛到模型自身; - EnvironmentVariable::scopeWithoutBuildpackControlVariables() 封装"排除构建包托管变量"这一复合条件;
- CloudProviderToken::scopeForProvider() 按云厂商过滤 token;
- Server::scopeWithProxy() 与
scopeWhereProxyType():
public function scopeWithProxy(): Builder
{
return $this->proxy->modelScope();
}
public function scopeWhereProxyType(Builder $query, string $proxyType): Builder
{
return $query->where('proxy->type', $proxyType);
}
scopeWhereProxyType 还展示了作用域的另一价值:把 proxy->type 这种 JSON 列的访问路径封装在模型内部,调用方只需 Server::whereProxyType('traefik') 而无需知道底层是 JSON 还是关系列。作用域本质上是一个"带查询语义的模型 API",把过滤条件的演化(比如字段改名、JSON 结构变化)隔离在模型一个文件里。
规则三:全局作用域要克制使用
原文档第三条规则指出:全局作用域(Global Scopes)会静默修改模型上的每一条查询,使调试困难。应优先使用本地作用域,只把全局作用域留给真正普适的约束,例如软删除(soft deletes)或多租户(multi-tenancy)。
文档的反面教材是一个"发布状态"全局作用域:
class PublishedScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
$builder->where('published', true);
}
}
// 结果:管理后台、报表、后台任务全部悄悄跳过草稿
而正确做法是做成显式选择的本地作用域:
public function scopePublished(Builder $query): Builder
{
return $query->where('published', true);
}
Post::published()->paginate(); // 显式:只看已发布
Post::paginate(); // 管理员可见全部
核心差异在于可见性:全局作用域把过滤逻辑藏进了每条 SQL,排查"为什么查不到数据"时需要先想到作用域的存在;本地作用域则让"这条查询过滤了什么"在调用处一目了然。
Coolify 中对全局行为的引入方式恰好体现了这种克制。例如 Application 引入了 Illuminate\Database\Eloquent\SoftDeletes(use 导入位于第 19 行,且其 OpenAPI Schema 声明了可空的 deleted_at 字段,见 Application.php L112)——软删除正是文档所列举的"普适约束"典型:被软删除的应用在任何查询中默认都不可见,这是数据一致性的底线,而非某个业务视图的偏好。类似地,Coolify 的多租户隔离(按 team_id 过滤)大量通过 whereTeamId、Server::buildServers() 这类显式调用完成,而不是塞进全局作用域静默改写查询——从源码结构看,这是有意把租户过滤保留在调用点可见的位置。
另外值得对照 BaseModel:Coolify 的模型基类通过 boot() 中的 creating 事件自动补 UUID,这是模型事件(只在创建时触发,行为边界清晰),与全局作用域"改写所有查询"的性质不同,也侧面说明:模型层的自动行为应选副作用最小、最容易被开发者预期的机制。
规则四:定义属性类型转换(Casts)
原文档第四条规则:使用 casts() 方法(或遵循项目惯例的 $casts 属性)实现自动类型转换,避免业务代码里到处做 (bool)、json_decode() 手工转换:
protected function casts(): array
{
return [
'is_active' => 'boolean',
'metadata' => 'array',
'total' => 'decimal:2',
];
}
Coolify 中已有 13 个模型采用 protected function casts(): array 方法(而非旧版 $casts 属性)这一 Laravel 11 风格,说明项目已把方法式 casts 定为当前约定——按 "Consistency First" 原则,新增模型应沿用此写法。典型示例是 ScheduledTask::casts():
protected function casts(): array
{
return [
'enabled' => 'boolean',
'timeout' => 'integer',
];
}
有了这两个声明,控制器和 Livewire 组件中 $task->enabled 拿到的一定是布尔值、$task->timeout 一定是整数,序列化到 JSON(API 响应、WebSocket 推送)时类型也已保证。decimal:2、array、json、encrypted 等转换同理:把"数据库列如何映射到 PHP 值"的知识固定在模型一处,其余代码全部按 PHP 原生类型思考。
规则五:日期列必须转换,模板里用 Carbon 实例
原文档第五条规则:永远转换(cast)日期列;在 Blade 模板中使用 Carbon 实例,而不是手工解析格式化字符串。
错误写法是在模板里手写格式解析:
{{ Carbon::createFromFormat('Y-d-m H-i', $order->ordered_at)->toDateString() }}
正确写法分两步。第一步在模型声明转换:
protected function casts(): array
{
return [
'ordered_at' => 'datetime',
];
}
第二步在模板中直接使用 Carbon 实例的 API:
{{ $order->ordered_at->toDateString() }}
{{ $order->ordered_at->format('m-d') }}
这样做的好处:格式解析逻辑不存在于任何模板,时区处理由 Carbon 统一接管,日期比较(->isBefore()、->diffForHumans())免费可用。Coolify 作为带前端渲染的 PaaS,大量模型存在 created_at/updated_at/deleted_at 等时间列(它们由 Eloquent 自动按 datetime 转换),凡新增自定义时间列(例如备份时间、任务超时记录时间)都应显式写入 casts() 而不是在展示层补救。
规则六:用 whereBelongsTo() 代替手写字段外键
原文档第六条规则:查询"某关联对象下的记录"时,用 whereBelongsTo() 而不是手工指定外键列,语义更清晰、也不易写错外键名:
// 错误:手工维护外键
Post::where('user_id', $user->id)->get();
// 正确:表达关系语义
Post::whereBelongsTo($user)->get();
// 指定关系名(如 Post 通过 author 属于 User)
Post::whereBelongsTo($user, 'author')->get();
whereBelongsTo() 会按关系定义生成 whereIn 子查询,调用方不再关心外键列叫什么;模型重命名外键时,只有关系方法一处需要改动。Coolify 代码库中表达"通过关联过滤"的惯用手段还包括 whereRelation()——例如 Server::buildServers() 通过 whereRelation('settings', 'is_build_server', true) 筛选"可用作构建服务器"的记录,同样避开了直接拼 server_settings 表的外键。两者精神一致:让过滤条件以关系语义表达,而不是散落的字符串外键。
规则七:禁止在查询中硬编码表名
原文档第七条规则:DB::table()、join、raw SQL 子查询中不得使用字符串字面量表名。硬编码表名导致模型的所有使用点无法被全局搜索发现,重构(如改表名)时必须逐个翻查 raw 字符串。
文档给出的错误示例覆盖三种典型场景:
DB::table('users')->where('active', true)->get();
$query->join('companies', 'companies.id', '=', 'users.company_id');
DB::select('SELECT * FROM orders WHERE status = ?', ['pending']);
正确做法是引用模型的表名,或干脆升级成 Eloquent / 查询构造器:
DB::table((new User)->getTable())->where('active', true)->get();
// 更好——直接用 Eloquent 或查询构造器,它们天然引用模型表名
User::where('active', true)->get();
Order::where('status', 'pending')->get();
文档还给出明确的优先级结论:尽可能用 Eloquent 查询和关系,而非 DB::table();只有在 DB::table() 或 raw join 不可避免时,才必须用 (new Model)->getTable() 保持引用可追踪。
唯一例外——迁移文件:在迁移中通过 DB::table('settings') 等硬编码表名是被接受且推荐的。理由是"模型随时间演进,迁移是冻结的快照"——如果迁移引用了之后被改名甚至删除的模型,迁移就会在未来执行时崩溃。Coolify 的 database/migrations/ 目录包含数百个按时间戳命名的迁移文件(如 创建 users 表的迁移),全部属于这一"冻结快照"场景,其中的表名字符串是历史事实记录,不受本条规则约束;而 app/ 下的运行时查询则应严格避免硬编码。
在 Coolify 中应用这份规范的操作路径
结合 SKILL.md 的 "How to Apply" 指引,把本文规则用于 Coolify 的实际路径是:
- 按文件类型选章节:改模型/查询 → 本文全部 7 条;改迁移 → 只取"迁移例外"与 migrations 规则 对照;
- 先查同类文件既有模式:例如新增关系方法前,先看同目录模型(
app/Models/)当前是否统一写): BelongsTo类型提示、casts 用方法还是属性——Coolify 现有 13 个模型已使用方法式casts(): array,新模型应直接沿用; - 用搜索验证规则覆盖度:审查存量代码时可复用本文的检索模式,如
function (.*): (HasMany|BelongsTo|HasOne)找出已规范的关系、protected function casts(): array找出已转换的模型、scope[A-Z]找出现有作用域命名习惯(Coolify 中作用域普遍采用scopeFor*、scopeWhere*、scopeWithout*等语义化前缀)。
这 7 条规则的共同目标可以概括为一句话:把"数据如何被查询和转换"的知识固定到模型层一处,让调用点只表达业务意图——关系类型提示服务于静态正确性,作用域服务于约束复用,casts 服务于类型安全,whereBelongsTo/(new Model)->getTable() 服务于重构安全。这也是 SKILL.md 将本文件列为第 5 节 Eloquent Patterns、与数据库性能、安全、迁移等规则并列的原因。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00