深入 Laravel Actions 的 `WithAttributes` Trait:以内部属性驱动 Action 的入参收集与校验
Laravel Actions(lorisleiva/laravel-actions)除了把业务逻辑封装为可复用的 Action,还提供了一套“以内部属性代替方法参数”的入参模型:通过 WithAttributes Trait,Action 可以先在内部收集、合并、读取数据,再统一交给与控制器一致的校验管道处理。本文以 .cursor/skills/laravel-actions/references/with-attributes.md 为骨架,结合当前仓库(Coolify,lorisleiva/laravel-actions: ^2.10.2 依赖)的实际 Action 代码,逐项拆解该 Trait 的 API、与 AttributeValidator 校验钩子的协作方式、端到端用法、测试清单与常见陷阱。读完你将掌握如何为需要“先填数据、再统一校验”的 Action(尤其是控制器与 Job 混用的场景)写出稳定可测的实现。
一、应用背景:为什么 Coolify 中的 Action 需要“基于属性”的入参模型
在 Coolify 中,lorisleiva/laravel-actions 是基础设施级依赖(见 composer.json),整个 app/Actions 目录下有大量以 Lorisleiva\Actions\Concerns\AsAction 声明的 Action 类,例如 GetProxyConfiguration、StopApplication 等。它们在 handle(...) 中以方法参数的形式接收依赖对象与选项,属于典型的 argument-based 写法:
use Lorisleiva\Actions\Concerns\AsAction;
class StopApplication
{
use AsAction;
public function handle(
Application $application,
bool $previewDeployments = false,
bool $dockerCleanup = true,
bool $resetRestartCount = true,
bool $removeContainers = true
): ?string { /* ... */ }
}
这种模式在“调用方已持有全部参数”时非常干净。但当输入来自外部(HTTP 请求体、路由参数、队列载荷),并且需要先经过授权与校验再执行副作用时,把参数逐一手工搬运、再手工调用校验器会让 Action 与调用方都变得啰嗦。
WithAttributes 正是为这类场景设计的 attribute-based 入参模型:Action 内部维护一份“属性集合”,通过 fill/fillFromRequest/set 等方式收集输入,再调用 validateAttributes() 统一执行授权 + 校验,最后把经过校验的数据传给 handle(...)。从源码结构看,Coolify 的大多数 Action 走的是方法参数直传(Job/调度由调用方分发),但当某个用例需要“既可作为 HTTP 控制器、又可独立实例化填充数据”时,切换到该 Trait 就能与 references/controller.md 中描述的控制器校验管道无缝复用。
二、WithAttributes 提供的属性生命周期 API
按 with-attributes.md 的归纳,该 Trait 提供的全部方法可分为三类:写入(替换、合并、填充请求)、读取(全量、过滤、单键)、魔数属性访问。
2.1 写入类方法
setRawAttributes 用给定载荷 整体替换 全部属性——注意它不会与既有属性做任何合并:
$action->setRawAttributes([
'key' => 'value',
]);
fill 将给定数组 合并进 既有属性(类似数组 + 语义的单向覆盖,已有同名键不被覆盖与否取决于实现细节,若需要以传入值为准,请用 setRawAttributes 前先清空,或用下文 set 逐键覆盖):
$action->fill([
'key' => 'value',
]);
fillFromRequest 则是连接 HTTP 层的关键桥梁:它把 请求输入与路由参数 一起合并进属性。键冲突时请求输入优先于路由参数:
$action->fillFromRequest($request);
这一点正是该文档反复强调的语义:即使路由里出现 {title},只要请求体同样提交了 title,最终属性里取到的也是请求体的值。如果你依赖路由参数覆盖请求输入,这条语义会与直觉相悖,详见“常见陷阱”一节。
2.2 读取类方法
| 方法 | 行为 | 示例 |
|---|---|---|
all() |
返回全部属性数组 | $action->all(); |
only(...$keys) |
只返回指定键 | $action->only('title', 'body'); |
except(...$keys) |
返回排除指定键后的剩余 | $action->except('body'); |
has($key) |
判断指定键是否存在 | $action->has('title'); |
get($key, $default = null) |
按键取值,支持默认值 | $action->get('title', 'Untitled'); |
set($key, $value) |
按键设值 | $action->set('title', 'My blog post'); |
// 全量读取
$action->all();
// 白名单式读取:只取业务真正需要、且校验规则期望的键
$payload = $action->only('title', 'body');
// 黑名单式读取
$payload = $action->except('body');
// 存在性判断
if ($action->has('title')) {
// ...
}
// 带默认值的读取
$title = $action->get('title', 'Untitled');
// 显式设值
$action->set('title', 'My blog post');
2.3 以对象属性访问(魔术方法)
Trait 同时提供三个魔术方法,让属性集合表现得像普通对象属性,视觉上与 Eloquent 模型的属性访问保持一致:
// 读取:等价于 get('title')
$action->title;
// 写入:等价于 set('title', ...)
$action->title = 'My blog post';
// 存在性:等价于 has('title')
isset($action->title);
这一层抽象让调用方代码读起来更“模型化”,例如循环遍历数组时逐个 $action->{$key} = $value; 即可完成批量填充。
三、fillFromRequest 的键冲突语义与数据流
从 SKILL.md 的编排看,Coolify 中的 Action 作为控制器入口时需要“从 HTTP 输入中取得数据并交给校验”。fillFromRequest($request) 的合并顺序决定了冲突时的胜出方:
- 先收集路由参数(如
/articles/{id}中的id); - 再合并请求输入(
$request->all()); - 请求输入覆盖路由参数。
因此文档明确要求开发者不要假设“路由参数会覆盖请求体”。典型反例:路由 PUT /articles/{title} 同时请求体也带 title 字段——你期望用路由中的旧 title 做定位、请求体的新 title 做内容,但合并结果是请求体 title 直接胜出,造成定位错乱。正确的做法是:用路由参数做“身份定位”的属性(如 id)单独提取,将请求体字段交给 fill/fillFromRequest,或在校验钩子 prepareForValidation 中先把定位字段从“可写字段”里剥离。
四、validateAttributes() 与 AttributeValidator 校验管道
WithAttributes 的核心价值不止于“存数据”,而在于它把 references/controller.md 中控制器入口那套 授权 + 校验钩子整体复用到任意调用形态上。调用:
$validatedData = $action->validateAttributes();
之后,Action 内可以直接声明与控制器 Action 完全一致的钩子(即文档中列出的 AttributeValidator 钩子全集):
prepareForValidation:在真正校验前改写属性或补充规则;authorize:返回布尔值决定是否授权通过;rules:返回校验规则数组,是唯一通常必须实现的钩子;withValidator:对已构建的 Validator 做额外配置(如条件性规则);afterValidator:校验完成后的追加校验逻辑;getValidator/getValidationData:自定义 Validator 实例或参与校验的数据源;getValidationMessages/getValidationAttributes:自定义错误消息与字段显示名;getValidationRedirect/getValidationErrorBag/getValidationFailure/getAuthorizationFailure:控制失败时的重定向、错误袋与自定义失败行为。
关键点在于:这些钩子与 AsController 使用同一套实现。这意味着一个同时 use AsAction; use WithAttributes; 的类,无论从 HTTP 进来(asController + validateAttributes)还是从内部被手动填充后调用 validateAttributes(),其校验行为完全一致,无需为两条路径各写一份校验逻辑。
五、端到端示例:从 fill 到 validateAttributes() 再到 handle(...)
原文档给出了一个完整的创建文章用例,这里保留其全貌并补充注释与调用拆解:
class CreateArticle
{
use AsAction;
use WithAttributes;
public function rules(): array
{
return [
'title' => ['required', 'string', 'min:8'],
'body' => ['required', 'string'],
];
}
public function handle(array $attributes): Article
{
return Article::create($attributes);
}
}
// 1. 实例化并填充内部属性
$action = CreateArticle::make()->fill([
'title' => 'My first post',
'body' => 'Hello world',
]);
// 2. 执行授权 + 校验,拿到“安全数据”
$validated = $action->validateAttributes();
// 3. 把已验证数据交给业务核心
$article = $action->handle($validated);
整条链路可以拆成四个阶段,与 SKILL.md 强调的“业务逻辑收敛在 handle(...)、框架关注点收敛在入口适配层”一致:
| 阶段 | 手段 | 职责 |
|---|---|---|
| 实例化 | CreateArticle::make() |
拿到独立 Action 实例 |
| 收集输入 | fill() / fillFromRequest() / set() |
把外部输入放进内部属性集合 |
| 校验授权 | validateAttributes() |
触发 authorize + rules 等整套钩子,产出已验证数组 |
| 执行业务 | handle($validated) |
只处理干净数据,不感知 HTTP/队列来源 |
注意 handle(array $attributes) 在此处接收的是 已经过校验的数组,而不是裸的外部请求。这正是“校验在副作用之前”的结构性保障:任何未通过 rules() 的数据根本到不了 Article::create。
六、对调用方与测试的工程约束(Checklist)
结合原文档的清单与 Coolify 中 Action 的既有约定,落地该模式时应逐条核对:
- 属性键名显式且稳定:
fill/only/rules/handle引用的键必须一一对应,避免拼写漂移;建议用命名常量集中管理高频键。 - 校验规则与属性形态一致:
rules()描述的字段形状必须等于validateAttributes()返回的数据形状,否则会在handle处才暴露出缺键问题。 - 副作用前先校验:凡是属性来源于外部输入(请求、队列、CLI),
validateAttributes()必须在写库、调远程、发事件等副作用之前调用。 - 校验/授权钩子要有聚焦的单测:对
rules、authorize、prepareForValidation等钩子单独写断言,而不是只测整条链路——这正是 SKILL.md 中“两层测试策略”(handle业务测试 + 入口编排测试)在属性流上的体现。
一个“最小可回归”的 Pest 式单测骨架(Coolify 使用 Pest,见 tests 目录组织)大致为:
it('validates attributes before creating the article', function () {
$action = CreateArticle::make()->fill([
'title' => 'x', // 不满足 min:8
'body' => 'Hello world',
]);
expect(fn () => $action->validateAttributes())
->toThrow(ValidationException::class);
});
七、常见陷阱
原文档总结的三个坑在真实项目中几乎必然会遇到:
- 同一 Action 内混用两种入参风格且不一致。例如
handle()里既解包$attributes又读方法参数,或同一字段一部分靠fillFromRequest、一部分靠调用方手动传参。应选定一种风格贯穿整个 Action:属性来源统一走WithAttributes,业务依赖(模型、服务)仍走方法参数。 - 假设
fillFromRequest中路由参数能覆盖请求体。实际语义相反——请求输入优先。依赖路由值做身份定位时,请把定位键与可写键分离,不要赌合并顺序。 - 使用外部输入时跳过
validateAttributes()。只要属性来自请求、队列载荷或任意不可信来源,直接传入handle就会绕过rules()/authorize(),等于把校验管道架空。这在“同时支持 HTTP 与内部调用”的 Action 上尤其隐蔽,因为内部调用路径看起来“似乎不需要校验”。
八、何时选用、如何继续深入
结合 SKILL.md 的决策准则:当同一用例需要多个入口(HTTP、队列、事件、CLI)且输入需要统一授权/校验时,优先考虑在 Action 上启用属性模型;当逻辑局部、单一入口且无复用压力时,保持普通服务类或纯方法参数 Action 即可,不必为了模式而模式。
要继续深入可对照仓库内配套参考资料:
- 控制器入口与校验管道:.cursor/skills/laravel-actions/references/controller.md
- 作为 Job 分发时的属性/队列生命周期约定:.cursor/skills/laravel-actions/references/job.md
- 测试与 Fake 断言策略:.cursor/skills/laravel-actions/references/testing-fakes.md
- Action 总览与项目约定:.cursor/skills/laravel-actions/SKILL.md
- 依赖声明与 Action 类分布:composer.json、app/Actions
一句话总结:WithAttributes 让 Laravel Actions 的入参从“方法签名”走向“内部状态 + 控制器级校验管道”,而 fillFromRequest(请求优先于路由)与 validateAttributes()(副作用前统一授权校验)是这套模型的两条主约束——在设计多入口 Action 时优先把它们写进契约,再用聚焦单测钉住钩子行为,即可获得既复用校验逻辑又结构清晰的属性驱动 Action。
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 StartedRust0625
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