🧬 模型机制
SunAdmin 模型统一继承 App\Core\Models\BaseModel。模型层负责字段转换、关联、查询 scope、展示 accessor 等,不承担复杂业务编排。
🎯 本页目标
本页用于说明模型层应该写什么,以及 scope、get、set、cast 各自解决什么问题。新增模型、列表筛选、字段展示转换、媒体 URL 自动补全、JSON 字段处理时,都应该先看本页。
| 机制 | 解决的问题 | 适合写什么 |
|---|---|---|
scope | 复用查询条件 | 关键字搜索、状态筛选、分类筛选、时间范围 |
getXxxAttribute | 读取时追加或转换展示字段 | 状态文案、分类名称、图片完整 URL、轻量格式化 |
setXxxAttribute | 保存时归一化字段 | URL 转相对路径、数组转 JSON、金额精度处理 |
casts | 字段类型自动转换 | JSON 数组、布尔值、时间、枚举 |
| 关联方法 | 表关系表达 | belongsTo、hasMany、hasOne、belongsToMany |
🧫 BaseModel
app/Core/Models/BaseModel.php默认能力:
- 使用
SunAdminBuilder——项目自定义的查询构建器,扩展了filter()和listData()方法。 $guarded = [],默认允许批量赋值。HasLegacyDateSerialization——日期统一序列化为Y-m-d H:i:s,避免 ISO 格式和旧格式混用。HasMediaUrlAttributes——媒体字段读取时自动补全 URL,保存时自动还原为相对路径。HasScopeFilters——Dao 的filter()数组自动映射到模型 scope 方法。- 提供
scopeId()——按主键查询的便捷 scope。
📅 日期序列化
HasLegacyDateSerialization 将日期统一序列化为:
Y-m-d H:i:s这样接口返回不会出现 ISO 字符串和旧格式混用。
🎚️ scope 过滤机制
HasScopeFilters 允许 Dao 通过数组自动调用模型 scope:
$articleDao->filter([
'keyword' => '新闻',
'status' => 1,
'category_id' => 3,
]);模型中只要存在:
public function scopeKeyword(Builder $query, ?string $keyword): Builder
public function scopeStatus(Builder $query, string|int|bool|null $status): Builder
public function scopeCategoryId(Builder $query, string|int|null $categoryId): Builder就会自动调用。
过滤规则:
null、空字符串、空数组会跳过。- filter key 会转成
scope{StudlyKey}。 - 如果 value 是 list array,且 scope 参数数量大于 1,会展开传参。
- 不存在对应 scope 时忽略该 filter。
scope 示例
use Illuminate\Database\Eloquent\Builder;
public function scopeKeyword(Builder $query, ?string $keyword): Builder
{
return $query->when($keyword, function (Builder $query) use ($keyword) {
$query->where('title', 'like', "%{$keyword}%");
});
}
public function scopeStatus(Builder $query, int|string|null $status): Builder
{
return $query->when($status !== null && $status !== '', function (Builder $query) use ($status) {
$query->where('status', (int) $status);
});
}
public function scopeCreatedBetween(Builder $query, ?string $start, ?string $end): Builder
{
return $query
->when($start, fn (Builder $query) => $query->where('created_at', '>=', $start))
->when($end, fn (Builder $query) => $query->where('created_at', '<=', $end));
}Dao 中传参:
$dao->filter([
'keyword' => '配置',
'status' => 1,
'created_between' => ['2026-01-01 00:00:00', '2026-01-31 23:59:59'],
]);🔍 查询构建器
SunAdminBuilder 提供:
| 方法 | 说明 |
|---|---|
filter(array $filters) | 调用模型 scopeFilter |
autoPage() | 按 page_no/page_size 自动分页 |
listData() | 输出统一列表结构 |
listData() 返回:
{
"list": [],
"count": 0,
"page_no": 1,
"page_size": 20
}📤 Accessor 示例
文章模型:
modules/System/Domain/Models/Article/Article.php追加字段:
protected $appends = ['category_name', 'status_desc'];访问器:
public function getCategoryNameAttribute(): string
public function getStatusDescAttribute(): string代码示例
// 获取分类名称
public function getCategoryNameAttribute(): string
{
if ($this->relationLoaded('category')) {
return $this->category?->name ?? '';
}
return '';
}
// 获取状态
public function getStatusDescAttribute(): string
{
return $this->status ? '启用' : '停用';
}建议:
- 展示类字段可使用 accessor。
- 复杂统计不要塞进 accessor,避免列表 N+1 或隐式查询。
- 依赖关联的 accessor 应先判断
relationLoaded()。
📥 Mutator 示例
Mutator 也叫“修改器”或“保存器”,用于在字段写入模型时做归一化处理。它的触发时机是给模型属性赋值或批量保存时,例如:
$config->value = $value;
$config->fill(['value' => $value]);
$configDao->save($data);只要最终写入了对应属性,setXxxAttribute() 就会有机会处理这个字段。
Mutator 适合做什么
| 适合 | 不适合 |
|---|---|
| 字段类型归一化 | 多表事务 |
| 前端字段名兼容到真实字段 | 支付、上传、AI 调用等外部业务 |
| URL 转相对路径 | 权限判断 |
| 数组转 JSON / 字符串 | 复杂统计查询 |
| 布尔值、数字值规范化 | 发送通知、写日志等副作用操作 |
简单说:Mutator 只处理“这个字段入库前应该变成什么样”,不要在里面写一整段业务流程。
配置 value 保存示例
配置模型:
modules/System/Domain/Models/Config.phpConfig::setValueAttribute() 会:
- image 类型保存为相对路径。
- 富文本资源保存为相对路径。
- 数组保存为 JSON。
例如后台保存图片配置时,前端可能传:
https://example.com/storage/uploads/logo.pngMutator 会在入库前转换为更适合存储的相对值:
uploads/logo.png这样数据库不会绑定当前域名;后续换域名、换 CDN、换部署环境时,读取阶段再统一转成公开 URL。
字段别名保存示例
菜单模型:
modules/System/Domain/Models/Auth/SystemMenu.php它同时提供了 getter 和 setter,用来兼容前端字段名与数据库字段名:
public function getPidAttribute(): int
{
return (int) ($this->attributes['parent_id'] ?? 0);
}
public function setPidAttribute(int|string|null $value): void
{
$this->attributes['parent_id'] = (int) ($value ?? 0);
}含义:
- 读取
$menu->pid时,实际返回的是parent_id。 - 保存
pid时,实际写入的是parent_id。 - 前端可以使用更短的
pid字段,数据库仍保持清晰的parent_id字段。
另一个例子:
public function setIsDisableAttribute(int|string|bool|null $value): void
{
$this->attributes['is_disabled'] = (int) ($value ?? 0) === 1;
}含义:
- 前端传
is_disable = 1。 - 模型保存时转换为数据库字段
is_disabled = true。 - 避免 Controller 或 Dao 里到处写字段转换。
Mutator 注意事项
setXxxAttribute()里的Xxx对应外部属性名,例如setValueAttribute()对应value。- 如果要写入真实数据库字段,需要操作
$this->attributes['field_name']。 - Mutator 会在模型赋值时触发,不要在里面做耗时操作或有副作用的操作。
- 依赖其他字段时,要注意赋值顺序。例如
Config::setValueAttribute()会读取当前type,保存前应先保证type已经写入模型属性。 - 批量更新如果直接走查询构造器
Model::query()->update([...]),不会触发模型 Mutator;需要模型转换能力时,应通过模型实例、Dao 保存方法或显式转换后再更新。 - Mutator 只负责入库前转换;读取展示转换应放到 Accessor、Cast 或统一服务中。
🖼️ 媒体 URL 机制
数据库中存储相对路径(如 uploads/logo.png),接口返回时自动补全为完整 URL(如 https://example.com/storage/uploads/logo.png)。这样做的好处是:换域名、换 CDN、换部署环境时,数据库记录不需要改动,只需更新应用配置。
HasMediaUrlAttributes 默认处理:
protected array $fileUrlAttributes = ['avatar', 'cover', 'image', 'uri'];
protected array $fileUrlArrayAttributes = ['images'];
protected array $contentUrlAttributes = ['content'];读取时:
- 相对路径转完整 URL。
images输出始终是数组。- 富文本中的
src/href/poster转完整 URL。
保存时:
- 本站完整 URL 转相对路径。
images入库仍保存为逗号字符串,兼容旧表结构。- 富文本中的本站资源 URL 转相对路径。
📄 JSON Cast
Laravel 原生 json cast 在遇到空值或已转义的 JSON 字符串时可能报错。项目自定义了 JsonArray cast,额外兼容了 stripslashes 处理和空值兜底,确保读写都不会因为历史数据格式异常而中断。
JsonArray cast:
app/Core/Casts/JsonArray.php底层工具:
app/Core/Support/JsonField.php能力:
- 空值输出空数组。
- 兼容普通 JSON 和被转义过的 JSON。
- 保存数组时使用
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES。
📐 模型开发规范
- 查询条件优先写成 scope,供 Dao
filter()复用。 - accessor 只做轻量展示转换。
- mutator 只做字段级归一化,不写跨表业务。
- 列表查询需要关联字段时显式
with()。 - 可配置状态文案可放 Enum 或字典,不要散写多个魔法数字。
- 删除能力按业务选择软删除或硬删除;软删除模型需
use SoftDeletes。
软删除与唯一字段
软删除模型如果存在唯一字段,需要先判断这个唯一值是否允许在删除后重新使用。
- 普通管理数据允许删除后重新创建同名数据,例如账号、页面标识、字典键名、语言编码等。删除前应释放唯一字段,避免软删除记录继续占用唯一索引;新增或编辑前如果需要兼容历史软删残留,也应先释放同值的已删除记录。
- 可复用唯一字段可使用
App\Core\Models\Concerns\ReleasesUniqueKeys,在模型中引入后调用releaseUniqueKeys(['field'])。释放后的旧值会追加内部删除后缀,旧记录仍保留软删除状态,新建数据按新记录处理。 - 表单唯一校验需要与业务语义一致。软删除后允许复用的字段,
Rule::unique()应配合withoutTrashed(),避免已删除记录在 Request 阶段提前拦截。 - 支付单号、退款单号、流水号、审计编号等不可复用业务标识不能释放唯一值,也不应允许删除后复用同号;这类字段应保持全表唯一,保证幂等、对账和审计可追溯。
🧪 完整模型示例
下面示例展示一个典型模型应该包含哪些内容,按职责分为四个部分:
<?php
namespace Modules\System\Domain\Models\Article;
use App\Core\Models\BaseModel;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class Article extends BaseModel
{
// ========== 1. Trait 与基础配置 ==========
use SoftDeletes;
// 追加到接口响应的虚拟字段
protected $appends = ['category_name', 'status_desc'];
// ========== 2. 关联 ==========
public function category(): BelongsTo
{
return $this->belongsTo(ArticleCategory::class, 'category_id');
}
// ========== 3. 查询 Scope ==========
// 供 Dao filter() 自动调用,命名规则:scope{StudlyKeyName}
public function scopeKeyword(Builder $query, ?string $keyword): Builder
{
return $query->when($keyword, fn (Builder $query) => $query->where('title', 'like', "%{$keyword}%"));
}
public function scopeTitle(Builder $query, ?string $title): Builder
{
return $this->scopeKeyword($query, $title);
}
public function scopeStatus(Builder $query, string|int|bool|null $status): Builder
{
if ($status === '' || $status === null) {
return $query;
}
return $query->where('status', (bool) $status);
}
public function scopeCategoryId(Builder $query, string|int|null $categoryId): Builder
{
if ($categoryId === '' || $categoryId === null) {
return $query;
}
return $query->where('category_id', (int) $categoryId);
}
public function scopeEnabled(Builder $query): Builder
{
return $query->where('status', true);
}
// ========== 4. Accessor(读取展示转换) ==========
// 关联字段需先判断 relationLoaded(),避免 N+1 查询
public function getCategoryNameAttribute(): string
{
if ($this->relationLoaded('category')) {
return $this->category?->name ?? '';
}
return '';
}
public function getStatusDescAttribute(): string
{
return $this->status ? '启用' : '停用';
}
}