Coolify 中的 Laravel Actions 实践:用 lorisleiva/laravel-actions 构建可复用、可测试的多入口点操作
Coolify 后端基于 Laravel,其 app/Actions 目录下的全部操作类统一采用 lorisleiva/laravel-actions 包(composer.json 中锁定 ^2.10.2)来实现。仓库内的技能文档 SKILL.md 系统化地记录了这套模式:一个 Action 类只实现一次 handle(...) 核心业务逻辑,却可以通过 AsAction trait 同时作为对象、Controller、Job、Listener、Command 五种入口点运行,并能用 fake()/mock()/spy() 等手段做第一类测试隔离。读完本文,你可以掌握 Coolify 中 Action 的完整骨架、五种入口点的接线方式、队列生命周期钩子的真实用法,以及一套“业务正确性 + 入口点接线”双层测试策略。
何时把一段逻辑写成 Action
技能文档给出的决策规则很明确(见 SKILL.md 的 “When to Use an Action”):
- 用 Action:同一用例需要多个入口点(HTTP、队列、事件、CLI),或需要一等的编排/fake 能力;
- 用普通 Service 类:逻辑是局部的、单入口点的、且不太可能以 Action 形式复用。
从 app/Actions 的实际组织看,Coolify 遵循了“按领域分子命名空间”的约定:App\Actions\Database、App\Actions\Service、App\Actions\Server、App\Actions\Proxy、App\Actions\Application 等 13 个子目录共 60 余个 Action 类,命名统一为描述性的 VerbNoun(如 StartDatabase、DeployServiceApplication、CleanupDocker)。文档同时约定:
- 业务/领域逻辑只放在
handle(...)中;传输层与框架关注点(HTTP 响应、CLI IO、队列细节)放在适配方法(asController、asJob、asListener、asCommand)中; - 所有 Action 方法优先显式参数与返回类型;
- 复杂数据结构契约优先用 PHPDoc 而非行内注释。
基础骨架与对象入口点
技能文档给出的最小骨架是:
<?php
namespace App\Actions;
use Lorisleiva\Actions\Concerns\AsAction;
class PublishArticle
{
use AsAction;
public function handle(int $articleId): bool
{
return true;
}
}
对象入口点有三种等价调用方式(详见 references/object.md):
PublishArticle::run($id); // 首选,静态 helper
PublishArticle::make()->handle($id); // 显式 make + handle
app(PublishArticle::class)->handle($id); // 容器注入(配合构造函数 DI)
trait 还额外提供了条件执行:PublishArticle::runIf($condition, ...) 与 PublishArticle::runUnless($condition, ...),分别在条件成立/不成立时才执行 handle(...)。Coolify 源码中大量使用 ::run() 同步编排:例如 RestartDatabase 内部直接 return StartDatabase::run($database);,体现了“Action 编排 Action”的组合风格。
一个真实例子是 StartDatabase:它接收八种独立数据库模型的联合类型,在 handle(...) 中先检查服务器可用性,然后按 getMorphClass() 分发到 StartPostgresql::run()、StartRedis::run() 等具体 Action;若数据库开启了公网访问,还会 StartDatabaseProxy::dispatch($database) 异步派发代理启动。注意这个文件里没有出现任何 as* 适配方法——这正是文档反复强调的“只按需扩展”:configureJob() 之外没有任何多余代码。
作为 Job:队列生命周期钩子在 Coolify 中的真实用法
文档中的 Job Action 完整模式
当 Action 需要以队列形式运行时,文档给出的项目级模式是(骨架完整保留自 SKILL.md):
<?php
namespace App\Actions\Demo;
use App\Models\Demo;
use DateTime;
use Lorisleiva\Actions\Concerns\AsAction;
use Lorisleiva\Actions\Decorators\JobDecorator;
class GetDemoData
{
use AsAction;
public int $jobTries = 3;
public int $jobMaxExceptions = 3;
public function getJobRetryUntil(): DateTime
{
return now()->addMinutes(30);
}
public function getJobBackoff(): array
{
return [60, 120];
}
public function getJobUniqueId(Demo $demo): string
{
return $demo->id;
}
public function handle(Demo $demo): void
{
// Core business logic.
}
public function asJob(JobDecorator $job, Demo $demo): void
{
// Queue-specific orchestration and retry behavior.
$this->handle($demo);
}
}
各成员的含义(“仅按需使用”):
| 成员 | 作用 |
|---|---|
$jobTries |
队列执行的最大尝试次数 |
$jobMaxExceptions |
未处理异常达到该次数后直接判失败 |
getJobRetryUntil() |
绝对重试截止时间(DateTime) |
getJobBackoff() |
每次重试的退避策略(int 或按次数组) |
getJobUniqueId(...) |
Unique Job 的去重键 |
asJob(JobDecorator $job, ...) |
访问 attempt 元数据、做队列专属分支 |
Coolify 源码中的两类真实配置
Coolify 中定义 configureJob(JobDecorator $job) 的 Action 有三个,分别展示了两种典型的队列控制写法:
- 指定队列——StartDatabase:
public function configureJob(JobDecorator $job): void
{
$job->onQueue(deployment_queue());
}
所有数据库启动任务统一进入部署队列,避免与高频检查类任务互相挤占。
- 声明式队列属性——DeployServiceApplication 直接声明
public string $jobQueue = 'high';,让服务部署任务走高优先级队列,其handle(...)内部则只关心远程docker compose up -d编排。
更完整的 Job 参考(dispatch 家族、JobDecorator 全部钩子)见 references/job.md,要点包括:
- 异步
dispatch(...)、同步dispatchSync(...)/dispatchNow(...)、响应后执行dispatchAfterResponse(...)、条件派发dispatchIf/dispatchUnless; - 链式编排:
Action::withChain([...])->dispatch(...)或Bus::chain([...])->dispatch(),配合makeJob()/makeUniqueJob()包装; JobDecorator钩子:configureJob()、getJobMiddleware()、$jobConnection、$jobQueue、$jobTries、$jobMaxExceptions、$jobBackoff/getJobBackoff()、$jobTimeout、$jobRetryUntil/getJobRetryUntil()、getJobDisplayName()、getJobTags()、getJobUniqueId()/$jobUniqueId、getJobUniqueFor()/$jobUniqueFor、getJobUniqueVia()、$jobDeleteWhenMissingModels/getJobDeleteWhenMissingModels(),以及失败回调jobFailed(?Throwable $e, ...$parameters);- 测试断言助手:
assertPushed()/assertNotPushed()/assertPushedOn(queue, times, callback),回调可接收 Action 实例、派发参数、JobDecorator实例和队列名。
一个能体现入口点切换的调用链:API 控制器 DatabasesController 中十余处 StartDatabase::dispatch($database) 走队列,而同一 Action 在 RestartDatabase 内部走 StartDatabase::run($database) 同步执行——同一个 handle(...),两种传输方式,业务逻辑零改动。
作为 Controller / Listener / Command
三种入口点在文档中的接线规则都很紧凑:
- Controller:路由直接指向类(invokable 风格),如
Route::post('/articles/{id}/publish', PublishArticle::class);需要 HTTP 适配时加asController(...)并返回响应;输入来自 HTTP 时叠加rules()或自定义 validator 钩子。 - Listener:在
EventServiceProvider中注册 Action 类为监听器,用asListener(EventName $event)收到事件后委托给handle(...)。 - Command:定义
$commandSignature与$commandDescription属性,实现asCommand(Illuminate\Console\Command $command),控制台 IO 只留在该方法内。
对应的深入参考分别为 references/controller.md、references/listener.md、references/command.md;属性注解方式见 references/with-attributes.md。
测试策略:双层验证与 AsFake 全家族
双层测试矩阵
文档要求“业务正确性”与“入口点接线”分开验证:
handle(...)直测:真实依赖 + 工厂数据,验证业务规则本身;- 入口点测试:分别针对
asController(打路由)、asJob(Queue::fake()+assertPushed*)、asListener(派发事件后断言交互)、asCommand(artisan 命令 + 输出断言)。
推荐的测试矩阵为:业务规则测试、HTTP 接线测试(下游 Action 用 shouldRun / shouldNotRun fake)、Job 接线测试(dispatch 后断言下游调用)、事件监听测试(事件触发后断言交互)、控制台测试(运行命令断言调用与输出)。最小执行单元示例:php artisan test --compact --filter=PublishArticle。
AsFake 方法族(2.x)
文档按“你想证明什么”来区分每个 fake 方法的适用场景:
mock():整体替换为 mock,适合严格期望与参数断言:
PublishArticle::mock()
->shouldReceive('handle')
->once()
->with(42)
->andReturnTrue();
partialMock():部分 mock,保留真实行为只桩掉某个昂贵/内部方法:
PublishArticle::partialMock()
->shouldReceive('fetchRemoteData')
->once()
->andReturn(['ok' => true]);
spy():间谍,不预定义期望、事后验证“是否以 X 被调用”:
$spy = PublishArticle::spy()->allows('handle')->andReturnTrue();
// 执行触发 Action 的代码…
$spy->shouldHaveReceived('handle')->with(42);
shouldRun():mock()->shouldReceive('handle')的快捷式,适合紧凑的编排断言:PublishArticle::shouldRun()->once()->with(42)->andReturnTrue();shouldNotRun():mock()->shouldNotReceive('handle')的快捷式,适合守卫分支/分支覆盖测试;allowToRun():spy + 放行handle,既让执行继续又能断言交互:
$spy = PublishArticle::allowToRun()->andReturnTrue();
// …
$spy->shouldHaveReceived('handle')->once();
isFake()/clearFake():检测类是否当前被替换、清理 fake 防止跨测试泄漏:
expect(PublishArticle::isFake())->toBeFalse();
PublishArticle::mock();
expect(PublishArticle::isFake())->toBeTrue();
PublishArticle::clearFake();
expect(PublishArticle::isFake())->toBeFalse();
实践默认值:分支测试优先 shouldRun() / shouldNotRun() 提升可读性;行为大体真实、只需调用验证时用 spy() / allowToRun();交互契约严格且要快速失败时用 mock();fake 可能泄漏时在清理阶段调 clearFake();副作用隔离原则——只 fake 被测 Action 边界,而不是 fake 一切。
Pest 风格示例与仓库中的测试现实
文档给出的 Pest 风格示例:
it('dispatches the downstream action', function () {
SendInvoiceEmail::shouldRun()->once()->withArgs(fn (int $invoiceId) => $invoiceId > 0);
FinalizeInvoice::run(123);
});
it('does not dispatch when invoice is already sent', function () {
SendInvoiceEmail::shouldNotRun();
FinalizeInvoice::run(123, alreadySent: true);
});
对照 Coolify 的测试目录可以看到落地方式:tests/Feature 下的 500 余个测试文件广泛使用 Queue::fake()、Bus::fake() 以及 Action 的派发断言来验证 Job 接线(例如 LifecycleApisTest.php 中多处 Queue::fake(),ApplicationPreviewQueueAdvancementTest.php 对预览部署队列推进做断言)。从源码结构看,Coolify 更倾向于以 Queue/Bus facade fake 验证“Action 作为 Job 是否被推入预期队列”,这与技能文档中 Job 入口点的 assertPushed* 断言体系是同一套验证思路。
排障清单与常见陷阱
技能文档收尾部分给出可直接执行的检查清单与陷阱列表,值得原样保留:
排障清单
- 确认类使用了
AsAction且命名空间匹配自动加载(可用composer show lorisleiva/laravel-actions先确认包已安装); - 以 Controller 使用时检查路由注册;
- 使用
dispatch时检查队列配置($jobQueue/configureJob/config/queue.php); - 事件到监听器的映射在
EventServiceProvider中核对; - 传输层关注点留在
as*适配方法里,不要混进handle(...)。
常见陷阱
- 把 HTTP 响应/重定向逻辑写进
handle(...)而不是asController(...); - 在多个
as*方法中重复业务规则,而不是委托给handle(...); - 以为 Listener 接线可以省略显式注册;
- 只测入口点、漏测
handle(...)直接行为; - 对一次性、单上下文逻辑过度使用 Action(无复用压力时保持普通 Service)。
小结
Coolify 的 app/Actions 目录是 SKILL.md 所述模式的完整实例:StartDatabase、DeployServiceApplication 等 Action 把远程 Docker 编排、服务器管理、数据库生命周期等业务逻辑收敛进强类型的 handle(...),再用 configureJob() / $jobQueue 声明队列归属、用 ::run() 与 ::dispatch() 在同步/异步两种传输之间自由切换;测试层则以 Queue::fake()、Bus::fake() 加上 Action fake 家族完成双层验证。如果你要在类似 Coolify 的大型 Laravel 项目中新增可复用操作,直接按“骨架 → 选入口点 → 补队列钩子 → 双层测试”的工作流推进,即可得到结构一致、可预测测试的代码。
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 StartedRust0624
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