Coolify 开发实践:用 laravel-actions 的 asCommand 把 Action 暴露为 Artisan 命令
本文围绕 Coolify 仓库中的 Command Entrypoint 参考文档 展开,系统讲解 lorisleiva/laravel-actions 包提供的 asCommand(...) 命令入口模式:如何通过 $commandSignature、$commandDescription 等元数据把一个 Action 类注册为标准的 Artisan 命令,如何在 asCommand(...) 与 handle(...) 之间保持控制台 I/O 与领域逻辑的清晰分离,以及如何为命令编写聚焦的 artisan 测试。读完本文,你可以在 Coolify 这类以 App\Actions 为核心组织业务逻辑的 Laravel 项目中,快速把任意复用场景暴露成 CLI 命令。
适用场景
该参考文档明确了自己的定位:当需要把 Action 暴露为 Artisan 命令时使用。它覆盖四个要点:
- 通过
asCommand(...)执行命令、缺失时回退到handle(...)的机制; - 通过方法/属性两种方式定义命令元数据(signature、description、help、hidden);
- 命令注册示例与聚焦的 artisan 测试模式;
- 控制台 I/O 与领域逻辑的分离原则。
这与 SKILL.md 中"Adapter 方法仅在需要的入口点才添加"的总原则一致:asCommand 属于四个可选适配器入口之一(其余为 asController、asJob、asListener),只有当同一个用例还需要 CLI 入口时才引入。
核心模式:asCommand 负责 I/O,handle 负责业务
文档推荐的模式可以概括为三条:
- 定义
$commandSignature和$commandDescription两个属性; - 实现
asCommand(Command $command),只在这里处理控制台 I/O(读取参数、输出结果); - 把业务逻辑保留在
handle(...)中,供对象、Job、Listener、Command 等所有入口复用。
文档给出的完整示例如下(注意 use Illuminate\Console\Command; 的引入是必须的,这也是文末 Checklist 的第一项):
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!');
}
}
这个例子里的分层值得逐行拆解:
handle(User $user, string $newRole)接受的是"领域对象 + 领域值"——一个已解析的User模型和新角色字符串。它完全不知道参数是从 CLI 参数、HTTP 请求还是队列里来的。这正是该模式最大的收益:同一份业务逻辑可以被UpdateUserRole::run($user, 'admin')(对象入口)、队列 dispatch(Job 入口)或本例的 CLI 调用,三种方式共享。asCommand(Command $command)只做两件事:把命令行原始参数({user_id}是一个字符串 ID)翻译成领域输入(User::findOrFail(...)),以及调用$command->info('Done!')输出结果。所有input/info/warn这类交互都只出现在这里。- 回退机制:
asCommand由包的CommandDecorator在命令执行时调用;如果 Action 没有实现asCommand,则直接回退调用handle(...)。这意味着最简形态的 Action(只写handle)也可以被注册成命令,只要签名参数能与方法参数对齐。
命令元数据:属性与方法的两种写法
文档为每一项命令元数据都提供了"方法"和"属性"两种等价写法。两者效果相同,属性写法更简洁,方法写法适合需要动态计算的场景(例如根据配置拼装签名)。
签名(Signature)
签名是命令的"命令行语法",定义命令名、参数与选项。
方法形式——getCommandSignature(),在没有设置 $commandSignature 属性且要注册为命令时必须提供:
public function getCommandSignature(): string
{
return 'users:update-role {user_id} {role}';
}
属性形式——$commandSignature:
public string $commandSignature = 'users:update-role {user_id} {role}';
其中 {user_id}、{role} 是位置参数,对应 asCommand(...) 里通过 $command->argument('user_id') 读取的原始输入。
描述(Description)
描述会显示在 php artisan list 中。
public function getCommandDescription(): string
{
return 'Updates the role of a given user.';
}
属性等价形式:
public string $commandDescription = 'Updates the role of a given user.';
帮助文本(Help)
getCommandHelp() 提供额外帮助文本,在用户执行 php artisan users:update-role --help 时展示:
public function getCommandHelp(): string
{
return 'My help message.';
}
属性等价形式:
public string $commandHelp = 'My help message.';
是否隐藏(Hidden)
isCommandHidden() 控制命令是否从 artisan list 中隐藏,默认值为 false——即默认对列表可见。运维工具中经常用它隐藏内部维护命令:
public function isCommandHidden(): bool
{
return true;
}
属性等价形式:
public bool $commandHidden = true;
四项元数据速查:
| 元数据 | 方法形式 | 属性形式 | 作用 | 默认值 |
|---|---|---|---|---|
| 签名 | getCommandSignature(): string |
$commandSignature |
命令名与参数定义 | 无(注册为命令时必填其一) |
| 描述 | getCommandDescription(): string |
$commandDescription |
显示在 artisan list |
无 |
| 帮助 | getCommandHelp(): string |
$commandHelp |
--help 时的额外说明 |
无 |
| 隐藏 | isCommandHidden(): bool |
$commandHidden |
是否从 artisan list 隐藏 |
false |
注册:在 Console Kernel 中声明命令
定义好 Action 后,需要把类注册进 Console Kernel 的 $commands 属性,Laravel 才会发现它并解析其签名。文档给出的注册示例:
// app/Console/Kernel.php
protected $commands = [
UpdateUserRole::class,
];
Coolify 自身的 app/Console/Kernel.php 是标准的 Illuminate\Foundation\Console\Kernel 子类,它通过 protected function commands(): void 中的 $this->load(__DIR__.'/Commands') 批量加载 app/Console/Commands 目录下的命令类,并 require base_path('routes/console.php') 注册路由式命令。$commands = [...] 属性数组与 load() 目录扫描、routes/console.php 三种方式可以并存,按需选择即可。
测试:聚焦命令入口的 artisan 测试
文档给出的命令测试模式只有三行,却同时验证了"命令能被调用"、"输出符合预期"和"执行成功"三件事:
$this->artisan('users:update-role 1 admin')
->expectsOutput('Done!')
->assertSuccessful();
artisan('users:update-role 1 admin')模拟真实命令行调用,参数1与admin会按签名依次填入{user_id}和{role};expectsOutput('Done!')断言asCommand(...)中$command->info('Done!')的终端输出——这类断言只应该针对asCommand层的 I/O,而handle(...)的业务正确性应当由直接调用handle的单元/特性测试来覆盖。
这也呼应了 SKILL.md 中的两层测试策略:第一层测 handle(...) 的业务正确性,第二层测 asCommand 这类入口的接线与编排。
清单与常见陷阱
文档末尾给出的 Checklist 与 Common pitfalls 浓缩了该模式的实践纪律:
上线前检查:
use Illuminate\Console\Command;已引入(asCommand的参数类型提示依赖它);- 签名中的参数/选项都有文档说明;
- 存在一个验证"命令可调用 + 输出正确"的命令测试。
常见陷阱:
- 把控制台 I/O 混进
handle(...):一旦handle里出现$command->info(...)或参数读取,Action 就绑死在 CLI 入口上,无法再被 Job、Listener 或对象方式复用。正确的方向是单向的:asCommand -> handle; - 签名缺失或歧义:注册为命令时既没有
$commandSignature属性也没有getCommandSignature()方法,或签名中的参数名与asCommand里argument(...)取的名字不一致,都会导致命令注册失败或取参失败。
Coolify 仓库中的应用背景
从源码结构看,该文档是 Coolify 仓库内置的 laravel-actions 开发技能(.claude/skills/laravel-actions/)的一部分,与 object.md、controller.md、job.md、listener.md 等入口点参考文档并列,共同约束 app/Actions 目录下的 Action 编写风格。
仓库中已有大量遵循这一模式的 Action 类:composer.json 声明了 "lorisleiva/laravel-actions": "^2.10.2",app/Actions 下约六十个类(如 app/Actions/Server/RunCommand.php)都 use AsAction 并把核心逻辑放在 handle(...) 中。以 RunCommand 为例,它的 handle(Server $server, $command) 只做一件事——调用 remote_process() 在远端服务器执行命令——没有掺杂任何 HTTP 或队列细节,因此它可以被 Livewire 组件、Job、或未来新增的 Artisan 命令任意复用;若需要 CLI 入口,按本文模式补上签名与 asCommand(...) 即可,无需改动业务代码。
最后需要说明一点适用范围:文档引用的外部参考链接为 laravelactions.com 的 2.x 文档(本文按规范不输出外部链接),其描述的 CommandDecorator 与 asCommand 回退机制属于 lorisleiva/laravel-actions 2.x 包的能力,与 Coolify 当前锁定的 ^2.10.2 版本对应。
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 StartedRust0627
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