首页
/ Coolify 中的 Laravel Actions:用 `AsAction` 统一多入口服务逻辑的工程实践

Coolify 中的 Laravel Actions:用 `AsAction` 统一多入口服务逻辑的工程实践

2026-09-07 10:27:46作者:贡沫苏Truman

本指南以 Coolify 仓库内置的开发技能文档(.cursor/skills/laravel-actions/SKILL.md)为骨架,结合仓库内 App\Actions 命名空间下的真实实现,系统讲解基于 lorisleiva/laravel-actions 的 Action 化开发:从单一 handle(...) 业务核心出发,按需装配对象、控制器、队列任务、事件监听器、Artisan 命令五种入口,并用 AsFake 进行可预测的测试。阅读后你将掌握 Coolify 团队推荐的 Action 类结构、入口适配模式、任务重试/幂等控制以及完整的两层测试策略。

一、Coolify 为什么采用 Action 模式

Coolify 是一个自托管的 PaaS 平台,一个"用例"经常同时暴露在多个入口:

  • HTTP 入口:用户在前端点击"重启数据库",走的是 Livewire/控制器;
  • 队列入口:部署、代理重启等重任务需要异步执行并支持重试;
  • 事件入口:状态变更(如 ApplicationStatusChanged)需要联动后续动作;
  • CLI 入口:运维命令与定时调度通过 Artisan 触发。

若为每个入口各自写一个控制器/Job/监听器,业务规则会被复制多份、极易漂移。lorisleiva/laravel-actions 的核心思路是:业务逻辑只写一次在 handle(...) 里,框架相关的传输/适配逻辑放在 asControllerasJobasListenerasCommand 等适配方法中,一个类即可同时作为对象、控制器、队列任务、监听器或命令使用。

Coolify 在 composer.json 中声明依赖 "lorisleiva/laravel-actions": "^2.10.2",并在 app/Actions 下按领域组织了大量 Action 类(ApplicationDatabaseServerDockerService 等子目录),是该模式在大型 Laravel 项目中的典型落地。

何时该用 Action,何时该用普通 Service

场景 建议
同一用例需要多种入口(HTTP + 队列 + 事件 + CLI) 用 Action
需要一等公民级的编排与 fake 能力(dispatchshouldRunassertDispatched 等) 用 Action
逻辑局部、单入口、几乎不会复用 保持普通 Service 类

判定标准一句话:存在"复用压力"或"多入口压力"时才 Action 化,否则不要为用而用。

二、基础 Action 骨架与项目约定

确认包已安装

composer show lorisleiva/laravel-actions

应输出 ^2.10.2 版本信息(与 composer.json 声明一致)。

最小骨架

<?php

namespace App\Actions;

use Lorisleiva\Actions\Concerns\AsAction;

class PublishArticle
{
    use AsAction;

    public function handle(int $articleId): bool
    {
        return true;
    }
}

Coolify 项目内的通用约定

  • 命名空间:Action 类放在 App\Actions,除非已有领域子命名空间(如 App\Actions\DatabaseApp\Actions\Application);
  • 命名:使用描述性的 动词+名词VerbNoun),例如 RestartDatabaseStopApplicationCleanupPreviewDeploymentGetContainersStatus
  • 职责边界:领域/业务逻辑留在 handle(...);框架与传输关注点放在适配方法(asController/asJob/asListener/asCommand);
  • 类型偏好:所有 Action 方法优先声明显式参数类型与返回类型;
  • 文档偏好:复杂数据结构(如数组 shape)用 PHPDoc,而非内联注释。

开发流程(Quick Workflow)

  1. composer show lorisleiva/laravel-actions 确认包可用;
  2. 新建/编辑 Action 类,使用 Lorisleiva\Actions\Concerns\AsAction
  3. 先实现 handle(...)(核心业务逻辑);
  4. 仅在需要对应入口时才补适配方法:asController(+ 路由/可调用控制器)、asJob(+ dispatch)、asListener(+ 事件监听注册)、asCommand(+ 命令签名/描述);
  5. 为所选入口补充测试;
  6. 需要隔离编排时使用 Action fake(MyAction::fake())与断言(MyAction::assertDispatched())。

三、作为普通对象运行(Object Entrypoint)

对象的三种调用方式是所有入口的地基,因为它们最终都汇聚到 handle(...)

方法 说明 等价写法
Action::run(...) 从容器解析并立即执行(推荐,可读性最好) Action::make()->handle(...)
Action::make() 仅从容器解析实例 app(Action::class)
app(Action::class)->handle(...) 手动注入调用 ——
Action::runIf($cond, ...) 条件为真才执行 if ($cond) Action::run(...)
Action::runUnless($cond, ...) 条件为假才执行 if (! $cond) Action::run(...)

依赖注入写法适合在 Service 内协作:

final class ArticleService
{
    public function __construct(
        private PublishArticle $publishArticle
    ) {}

    public function publish(int $articleId): bool
    {
        return $this->publishArticle->handle($articleId);
    }
}

仓库佐证:Action 之间的对象级编排

Coolify 的 RestartDatabase 是教科书式的对象入口编排——它接收数据库模型,先做前置检查,再用 ::run() 依次调用另外两个 Action:

public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database)
{
    $server = $database->destination->server;
    if (! $server->isFunctional()) {
        return 'Server is not functional';
    }
    StopDatabase::run($database, dockerCleanup: false);

    return StartDatabase::run($database);
}

从中可读出几条值得借鉴的实践:

  • 窄化依赖handle 参数使用数据库模型的联合类型,而不是宽泛的对象;
  • 显式前置守卫:服务器不可用(isFunctional())时直接短路返回,业务分支清晰;
  • 通过 ::run() 组合:编排层只声明"先停后启",具体逻辑下沉到 StopDatabase / StartDatabase,天然形成可独立测试、可独立复用的 Action 链。

四、作为队列任务运行(Job Entrypoint)

异步执行是 Coolify 最重度的入口之一(部署、清理、代理操作都走队列)。lorisleiva/laravel-actions 通过 AsJob 把 Action 变成标准 Laravel job。

分发辅助方法

方法 语义
Action::dispatch(...) 异步入队
Action::dispatchIf($cond, ...) 条件满足才异步入队
Action::dispatchUnless($cond, ...) 条件不满足才异步入队
Action::dispatchSync(...) 同步执行(dispatchNow 为其别名)
Action::dispatchAfterResponse(...) HTTP 响应发送后再同步执行

包装与链式任务

  • makeJob(...):创建 JobDecorator 包装,配合 dispatch(...) 辅助函数或任务链使用;
  • makeUniqueJob(...):创建 UniqueJobDecorator 包装(实现 ShouldBeUnique 时通常自动,但可强制);
  • withChain([...]):在当前任务成功后追加后续任务。
$chain = [
    OptimizeTeamReport::makeJob($team),
    SendTeamReportEmail::makeJob($team),
];

CreateNewTeamReport::withChain($chain)->dispatch($team);

等价于 Bus::chain([...]),且可用 Bus::assertChained([...]) 断言整条链:

use Illuminate\Support\Facades\Bus;

Bus::fake();

Bus::assertChained([
    CreateNewTeamReport::makeJob($team),
    OptimizeTeamReport::makeJob($team),
    SendTeamReportEmail::makeJob($team),
]);

JobDecorator 提供的任务生命周期钩子(全部与 handle 分离)

asJob 在任务被分发时调用;若未实现则回退到 handle(...)。因此只有在需要队列专属编排时才写:

public function asJob(Team $team): void
{
    $this->handle($team, true); // 队列场景强制全量报表
}

configureJob(JobDecorator $job) 可统一配置连接/队列/中间件/延迟:

public function configureJob(JobDecorator $job): void
{
    $job->onConnection('my_connection')
        ->onQueue('my_queue')
        ->through(['my_middleware'])
        ->chain(['my_chain'])
        ->delay(60);
}

任务属性:重试、超时、幂等(属性写法)

属性 作用 示例
public string $jobConnection 队列连接 'my_connection'
public string $jobQueue 队列名 'high'
public int $jobTries 最大尝试次数 10
public int $jobMaxExceptions 失败前最大未捕获异常数 3
public int $jobBackoff 重试延迟秒数(标量版) 60
public int $jobTimeout 超时秒数 60 * 30
public int $jobRetryUntil 重试截止时间戳(属性版) 1610191764
public string $jobUniqueId 静态幂等键 'some_static_key'
public int $jobUniqueFor 幂等锁时长(秒) 3600
public bool $jobDeleteWhenMissingModels 模型缺失时是否直接删除任务 true

任务方法:重试、幂等、失败处理(方法写法)

方法写法可做动态/按参数决策,优先级高于对应属性。

public function getJobRetryUntil(): DateTime
{
    return now()->addMinutes(30);      // 绝对重试截止时间
}

public function getJobBackoff(): array
{
    return [30, 60, 120];              // 逐次递增的延迟策略
}

其余常用钩子:

  • getJobDisplayName():自定义队列显示名;
  • getJobTags(Team $team): array:返回队列标签,如 ['report', 'team:'.$team->id]
  • getJobUniqueId(Team $team): int:基于参数的幂等键(如 $team->id);
  • getJobUniqueFor(Team $team): int:幂等锁时长,可按参数分支;
  • getJobUniqueVia():幂等锁使用的缓存驱动,如 Cache::driver('redis')
  • getJobDeleteWhenMissingModels(): bool:模型缺失处理;
  • getJobMiddleware(array $parameters): array:为队列任务追加中间件(如 [new RateLimited('reports')]);
  • jobFailed(?Throwable $e, ...$parameters): void:失败兜底,用于通知用户、上报错误、执行补偿。

仓库佐证:Coolify 用 $jobQueue 做高优队列

Coolify 的大量 Action 声明了 public string $jobQueue = 'high';,把关键操作送入专用高优队列,例如 CleanupPreviewDeploymentStopApplicationStopDatabaseProxyGetContainersStatus 以及 app/Actions/Server 下的一批服务器运维 Action。这印证了 SKILL 中"Job Action 常定义额外队列生命周期方法与任务属性,用于重试、幂等与时机控制"的项目约定。

队列断言

use Illuminate\Support\Facades\Queue;

Queue::fake();

SendTeamReportEmail::assertPushed();
SendTeamReportEmail::assertPushed(3);
SendTeamReportEmail::assertPushed($callback);          // 回调拿到 Action 实例、参数、JobDecorator、队列名
SendTeamReportEmail::assertNotPushed();
SendTeamReportEmail::assertPushedOn('reports');
SendTeamReportEmail::assertPushedOn('reports', 3, $callback);

五、作为 HTTP 控制器运行(Controller Entrypoint)

Action 可直接注册为可调用(invokable)控制器,Laravel 因此把它当作 action@__invoke 解析。

Route::post('/articles/{id}/publish', PublishArticle::class);

__invokeAsController trait 提供(等价于 $action->handle(...))。若要自定义,需要把 trait 方法别名化后再覆写:

class MyAction
{
    use AsAction {
        __invoke as protected invokeFromLaravelActions;
    }

    public function __invoke()
    {
        // 自定义行为...
    }
}

请求适配方法

asController 在 Action 作为控制器被调用时执行,负责从 Request/路由参数取数据、调用 handle(...) 并返回响应;缺省时回退到 handle(...)

public function asController(User $user, Request $request): Response
{
    $article = $this->handle(
        $user,
        $request->get('title'),
        $request->get('body')
    );

    return redirect()->route('articles.show', [$article]);
}

响应可进一步按内容协商拆分:

  • jsonResponse($result, Request $request):请求期望 JSON 时在 asController 之后调用,例如返回 new ArticleResource($article)
  • htmlResponse($result, Request $request):请求期望 HTML 时调用,例如 redirect()->route(...)

控制器中间件与就地路由

public function getControllerMiddleware(): array
{
    return ['auth', MyCustomMiddleware::class];
}

public static function routes(Router $router)
{
    $router->get('author/{author}/articles', static::class);
}

使用就地 routes() 时,需要在某个 ServiceProvider 中注册路由发现:

use Lorisleiva\Actions\Facades\Actions;

Actions::registerRoutes();
Actions::registerRoutes('app/MyCustomActionsFolder');
Actions::registerRoutes([
    'app/Authentication',
    'app/Billing',
    'app/TeamManagement',
]);

校验与授权钩子(ActionRequest

控制器入口把 Laravel FormRequest 的能力搬进了 Action,全部钩子及其职责如下:

钩子 作用
prepareForValidation(ActionRequest $request) 在授权与校验之前执行,可 $request->merge(...) 预填数据
authorize(ActionRequest $request): bool | Response 授权逻辑;可返回 Response::allow() / Response::deny('...')
rules(): array 校验规则
withValidator(Validator $v, ActionRequest $request) 通过 $validator->after(...) 追加自定义校验
afterValidator(Validator $v, ActionRequest $request) withValidator 的另一种写法
getValidator(Factory $f, ActionRequest $request) 完全自定义 Validator,绕开默认 rules 管线
getValidationData(ActionRequest $request) 定义参与校验的数据(默认 $request->all()
getValidationMessages(): array 自定义错误消息
getValidationAttributes(): array 请求字段的人性化名称
getValidationRedirect(UrlGenerator $url) 校验失败时的自定义重定向
getValidationErrorBag(): string 自定义错误 bag 名(默认 default
getValidationFailure() 覆盖校验失败行为(如抛自定义异常)
getAuthorizationFailure() 覆盖授权失败行为

典型组合示例:

public function authorize(ActionRequest $request): bool
{
    return $request->user()->role === 'author';
}

public function rules(): array
{
    return [
        'title' => ['required', 'min:8'],
        'body' => ['required', IsValidMarkdown::class],
    ];
}

public function withValidator(Validator $validator, ActionRequest $request): void
{
    $validator->after(function (Validator $validator) use ($request) {
        if (! Hash::check($request->get('current_password'), $request->user()->password)) {
            $validator->errors()->add('current_password', 'Wrong password.');
        }
    });
}

核心铁律:HTTP 响应/重定向/校验逻辑一律放在 asController 及其响应适配方法中,严禁塞进 handle(...)

六、作为事件监听器运行(Listener Entrypoint)

Action 也可直接充当事件监听器。事件对象的数据在 asListener(Event $event) 中解包并映射成 handle(...) 参数;缺省时回退到 handle(...)(事件属性需与参数天然匹配)。

class SendOfferToNearbyDrivers
{
    use AsAction;

    public function handle(Address $source, Address $destination): void
    {
        // ...
    }

    public function asListener(TaxiRequested $event): void
    {
        $this->handle($event->source, $event->destination);
    }
}

监听映射在事件服务提供者中显式注册(Coolify 的对应文件为 app/Providers/EventServiceProvider.php):

protected $listen = [
    TaxiRequested::class => [
        SendOfferToNearbyDrivers::class,
    ],
];

在 Coolify 中,这类模式与 app/Events 下的领域事件(如 ApplicationStatusChangedServerReachabilityChangedScheduledTaskDone)天然配套:事件触发、监听器 Action 接收领域对象后执行副作用。

监听器测试

use Illuminate\Support\Facades\Event;

Event::fake();

TaxiRequested::dispatch($source, $destination);

Event::assertDispatched(TaxiRequested::class);

七、作为 Artisan 命令运行(Command Entrypoint)

Action 可通过属性或方法声明命令签名/描述/帮助/隐藏,再用 asCommand 处理控制台 I/O。

use Illuminate\Console\Command;

class UpdateUserRole
{
    use AsAction;

    public string $commandSignature = 'users:update-role {user_id} {role}';

    public function handle(User $user, string $newRole): void
    {
        $user->update(['role' => $newRole]);
    }

    public function asCommand(Command $command): void
    {
        $this->handle(
            User::findOrFail($command->argument('user_id')),
            $command->argument('role')
        );

        $command->info('Done!');
    }
}

命令元数据(属性/方法两种写法)

元数据 属性写法 方法写法
签名 public string $commandSignature getCommandSignature(): string(必填,若未设属性)
描述 public string $commandDescription getCommandDescription(): string
帮助文本 public string $commandHelp getCommandHelp(): string--help 显示)
是否隐藏 public bool $commandHidden isCommandHidden(): bool(默认 false

注册与聚焦测试

app/Console/Kernel.php 中注册:

protected $commands = [
    UpdateUserRole::class,
];

聚焦的 Artisan 测试:

$this->artisan('users:update-role 1 admin')
    ->expectsOutput('Done!')
    ->assertSuccessful();

Coolify 的 35 个控制台命令(见 app/Console/Commands)中同样存在"业务委托、命令只做 I/O"的同类分层思想,二者可以互相印证。

八、基于内部属性的 Action(WithAttributes)

当一个 Action 需要"先收集输入、后批量校验、再执行"时,可用 WithAttributes trait 把输入存为内部属性,而不是方法参数。它复用了与 AsController 完全相同的校验/授权钩子。

属性生命周期 API

方法 语义
setRawAttributes([...]) 用给定负载整体替换全部属性
fill([...]) 将给定属性合并进现有属性
fillFromRequest($request) 合并请求输入 + 路由参数;键冲突时请求输入优先于路由参数
all() 返回全部属性
only('title','body') 仅返回指定键
except('body') 排除指定键后返回
has('title') 判断键是否存在
get('title', $default) 取值,可带默认值
set('title', $value) 设值
__get / __set / __isset 支持对象属性式读写,如 $action->title
validateAttributes() 用 Action 属性跑授权 + 校验,返回校验后数据

端到端示例

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);
    }
}

$action = CreateArticle::make()->fill([
    'title' => 'My first post',
    'body' => 'Hello world',
]);

$validated = $action->validateAttributes();
$article = $action->handle($validated);

WithAttributes 复用 AttributeValidator,可用的钩子与控制器完全一致:prepareForValidationauthorizeruleswithValidatorafterValidatorgetValidatorgetValidationDatagetValidationMessagesgetValidationAttributesgetValidationRedirectgetValidationErrorBaggetValidationFailuregetAuthorizationFailure

两个易错点fillFromRequest 中请求输入永远盖过路由参数(别假设相反);使用外部输入时务必在副作用前调用 validateAttributes()

九、两层测试策略与 AsFake 动作替身

两层策略

  1. 业务层:直接调用 handle(...),用真实依赖 + 工厂验证业务正确性;
  2. 入口/接线层:分别对 asControllerasJobasListenerasCommand 验证编排与接线是否正确。

AsFake 全部方法(2.x)

方法 语义与适用场景
mock() 用完整 mock 替换 Action,适合严格期望 + 参数断言,快速失败
partialMock() 保留大部分真实行为、只 stub 某昂贵/内部方法
spy() 事后验证("是否以 X 被调用"),无需预先穷举期望
shouldRun() mock()->shouldReceive('handle') 的简写,适合紧凑编排断言
shouldNotRun() mock()->shouldNotReceive('handle') 的简写,适合守卫分支测试
allowToRun() spy + 放行 handle,执行真实代码但允许事后断言交互
isFake() 检查该类当前是否已被替换为 fake
clearFake() 清除 fake,防止跨测试泄漏
// mock:严格期望
PublishArticle::mock()
    ->shouldReceive('handle')
    ->once()
    ->with(42)
    ->andReturnTrue();

// partialMock:只挡一个内部方法
PublishArticle::partialMock()
    ->shouldReceive('fetchRemoteData')
    ->once()
    ->andReturn(['ok' => true]);

// spy:事后断言
$spy = PublishArticle::spy()->allows('handle')->andReturnTrue();
// 触发 Action 的代码...
$spy->shouldHaveReceived('handle')->with(42);

// shouldRun / shouldNotRun:分支可读性最佳
PublishArticle::shouldRun()->once()->with(42)->andReturnTrue();
PublishArticle::shouldNotRun();

// allowToRun:保留真实执行 + 事后断言
$spy = PublishArticle::allowToRun()->andReturnTrue();
// ...
$spy->shouldHaveReceived('handle')->once();

// isFake / clearFake:生命周期管理
expect(PublishArticle::isFake())->toBeFalse();
PublishArticle::mock();
expect(PublishArticle::isFake())->toBeTrue();
PublishArticle::clearFake();
expect(PublishArticle::isFake())->toBeFalse();

推荐测试矩阵

层次 做法
业务规则测试 直接用真实依赖/工厂调用 handle(...)
HTTP 接线测试 请求路由/控制器,用 shouldRun / shouldNotRun fake 下游 Action
队列接线测试 以 Job 形式分发 Action,断言预期的下游 Action 调用
事件监听测试 分发事件,经 fake/spy 断言 Action 交互
控制台测试 运行 artisan 命令,断言 Action 调用与输出

实用默认值

  • 分支测试优先 shouldRun() / shouldNotRun()
  • 行为大体真实、只需调用验证时,优先 spy() / allowToRun()
  • 交互契约严格、需快速失败时,优先 mock()
  • 存在泄漏风险时在清理阶段 clearFake()
  • 只 fake 被测边界上的 Action,不要 fake 一切,保持副作用隔离。

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);
});

先跑最小相关套件,例如:

php artisan test --compact --filter=PublishArticle

Coolify 在 tests 下配有完整的 Feature 与 Unit 测试树(tests/Feature 含 500+ 用例),任何 Action 改动都应在对应测试文件中补充或更新入口用例。

十、排障清单与常见误区

快速检查

  • 类确实 use AsAction,且命名空间与 autoload 匹配;
  • 入口接线已注册:路由、队列 worker/配置、事件映射、命令注册;
  • 方法签名与参数类型与调用方期望一致。

典型失败模式

  • 控制器路由指向了错误的类;
  • 队列 worker/配置不匹配;
  • 监听器映射未被加载;
  • 命令签名不匹配或命令未在 Console Kernel 注册。

调试流程

  1. 先写一个聚焦的失败测试复现问题;
  2. 先验证接线层,再验证领域行为;
  3. 需要时用 fake/spy 隔离依赖。

常见坑

  • 把 HTTP 响应/重定向逻辑写进 handle(...) 而不是 asController(...)
  • as* 方法中复制业务规则,而不是委托给 handle(...)
  • 依赖隐式监听接线,却未在需要处显式注册;
  • 只测入口、跳过对 handle(...) 的直接行为测试;
  • 对一次性、单上下文、无复用压力的逻辑过度 Action 化。

十一、总结

围绕 .cursor/skills/laravel-actions/SKILL.md,可以把 Coolify 的 Action 化实践概括为一条主线:handle(...) 是唯一业务真理,五个入口只是它的传输层镜像。当你在 Coolify 仓库中新增能力时,推荐的落地顺序是:先写 handle(...) 与直接测试 → 按入口需要补 asJob/asController/asListener/asCommand → 用 Queue::fake()Event::fake()Bus::fake()AsFake 替身补齐接线测试。这样既能在多入口复用同一业务规则,又能在队列重试、幂等、超时与 HTTP 校验/授权等横切面上获得框架级的一等支持。

进一步阅读

SKILL 目录按入口/主题拆分了下钻参考,可在仓库中继续阅读:

同时可直接阅读 Coolify 的 app/Actions 源码树(DatabaseApplicationServerDocker 等子目录)作为大型项目真实样例,观察领域 Action 是如何命名、组合与挂接队列的。

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