首页
/ 深入 Laravel Actions 的 `WithAttributes` Trait:以内部属性驱动 Action 的入参收集与校验

深入 Laravel Actions 的 `WithAttributes` Trait:以内部属性驱动 Action 的入参收集与校验

2026-09-07 09:53:42作者:沈韬淼Beryl

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 类,例如 GetProxyConfigurationStopApplication 等。它们在 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) 的合并顺序决定了冲突时的胜出方:

  1. 先收集路由参数(如 /articles/{id} 中的 id);
  2. 再合并请求输入($request->all());
  3. 请求输入覆盖路由参数

因此文档明确要求开发者不要假设“路由参数会覆盖请求体”。典型反例:路由 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(),其校验行为完全一致,无需为两条路径各写一份校验逻辑。

五、端到端示例:从 fillvalidateAttributes() 再到 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() 必须在写库、调远程、发事件等副作用之前调用。
  • 校验/授权钩子要有聚焦的单测:对 rulesauthorizeprepareForValidation 等钩子单独写断言,而不是只测整条链路——这正是 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);
});

七、常见陷阱

原文档总结的三个坑在真实项目中几乎必然会遇到:

  1. 同一 Action 内混用两种入参风格且不一致。例如 handle() 里既解包 $attributes 又读方法参数,或同一字段一部分靠 fillFromRequest、一部分靠调用方手动传参。应选定一种风格贯穿整个 Action:属性来源统一走 WithAttributes,业务依赖(模型、服务)仍走方法参数。
  2. 假设 fillFromRequest 中路由参数能覆盖请求体。实际语义相反——请求输入优先。依赖路由值做身份定位时,请把定位键与可写键分离,不要赌合并顺序。
  3. 使用外部输入时跳过 validateAttributes()。只要属性来自请求、队列载荷或任意不可信来源,直接传入 handle 就会绕过 rules()/authorize(),等于把校验管道架空。这在“同时支持 HTTP 与内部调用”的 Action 上尤其隐蔽,因为内部调用路径看起来“似乎不需要校验”。

八、何时选用、如何继续深入

结合 SKILL.md 的决策准则:当同一用例需要多个入口(HTTP、队列、事件、CLI)且输入需要统一授权/校验时,优先考虑在 Action 上启用属性模型;当逻辑局部、单一入口且无复用压力时,保持普通服务类或纯方法参数 Action 即可,不必为了模式而模式。

要继续深入可对照仓库内配套参考资料:

一句话总结:WithAttributes 让 Laravel Actions 的入参从“方法签名”走向“内部状态 + 控制器级校验管道”,而 fillFromRequest(请求优先于路由)与 validateAttributes()(副作用前统一授权校验)是这套模型的两条主约束——在设计多入口 Action 时优先把它们写进契约,再用聚焦单测钉住钩子行为,即可获得既复用校验逻辑又结构清晰的属性驱动 Action。

登录后查看全文
热门项目推荐
相关项目推荐