首页
/ Coolify 中的 Laravel Actions 实践:用 lorisleiva/laravel-actions 构建可复用、可测试的多入口点操作

Coolify 中的 Laravel Actions 实践:用 lorisleiva/laravel-actions 构建可复用、可测试的多入口点操作

2026-09-06 17:00:53作者:宣聪麟

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\DatabaseApp\Actions\ServiceApp\Actions\ServerApp\Actions\ProxyApp\Actions\Application 等 13 个子目录共 60 余个 Action 类,命名统一为描述性的 VerbNoun(如 StartDatabaseDeployServiceApplicationCleanupDocker)。文档同时约定:

  • 业务/领域逻辑只放在 handle(...) 中;传输层与框架关注点(HTTP 响应、CLI IO、队列细节)放在适配方法(asControllerasJobasListenerasCommand)中;
  • 所有 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 有三个,分别展示了两种典型的队列控制写法:

  1. 指定队列——StartDatabase
public function configureJob(JobDecorator $job): void
{
    $job->onQueue(deployment_queue());
}

所有数据库启动任务统一进入部署队列,避免与高频检查类任务互相挤占。

  1. 声明式队列属性——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() / $jobUniqueIdgetJobUniqueFor() / $jobUniqueForgetJobUniqueVia()$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.mdreferences/listener.mdreferences/command.md;属性注解方式见 references/with-attributes.md

测试策略:双层验证与 AsFake 全家族

双层测试矩阵

文档要求“业务正确性”与“入口点接线”分开验证:

  1. handle(...) 直测:真实依赖 + 工厂数据,验证业务规则本身;
  2. 入口点测试:分别针对 asController(打路由)、asJobQueue::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 所述模式的完整实例:StartDatabaseDeployServiceApplication 等 Action 把远程 Docker 编排、服务器管理、数据库生命周期等业务逻辑收敛进强类型的 handle(...),再用 configureJob() / $jobQueue 声明队列归属、用 ::run()::dispatch() 在同步/异步两种传输之间自由切换;测试层则以 Queue::fake()Bus::fake() 加上 Action fake 家族完成双层验证。如果你要在类似 Coolify 的大型 Laravel 项目中新增可复用操作,直接按“骨架 → 选入口点 → 补队列钩子 → 双层测试”的工作流推进,即可得到结构一致、可预测测试的代码。

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