Coolify 中 Laravel Action 的 Command 入口:用 lorisleiva/laravel-actions 的 asCommand 编写可测试的 Artisan 命令
本文围绕 Coolify 仓库内 .agents/skills/laravel-actions/references/command.md 这一 Action 技能参考文档展开,系统讲解如何把一个 laravel-actions Action 暴露为 Artisan 命令:asCommand(...) 入口与 handle(...) 回退机制、签名/描述/帮助/隐藏四种命令元数据的两种定义方式、在 Console Kernel 中的注册方式,以及聚焦式的 artisan 命令测试写法。读完后你可以按同一套模式,在 Coolify(或任何使用 lorisleiva/laravel-actions 的 Laravel 项目)中实现“领域逻辑与终端 I/O 彻底分离”的可复用命令行入口。
背景:Action 在 Coolify 技术栈中的位置
Coolify 是一个基于 Laravel 的自托管 PaaS。从 composer.json 可以看到其关键依赖组合:
"php": "^8.4",
"laravel/framework": "^12.65.0",
"lorisleiva/laravel-actions": "^2.10.2",
"pestphp/pest": "^4.7.8"
即 PHP 8.4 + Laravel 12 + laravel-actions 2.x + Pest 测试框架。Action 的核心骨架是引入 AsAction trait 并实现 handle(...) 方法:
<?php
namespace App\Actions;
use Lorisleiva\Actions\Concerns\AsAction;
class PublishArticle
{
use AsAction;
public function handle(int $articleId): bool
{
return true;
}
}
同一份 handle(...) 业务逻辑可以被多种“入口(entrypoint)”复用:作为对象直接调用(PublishArticle::run(...))、作为控制器(asController)、作为队列任务(asJob)、作为事件监听器(asListener),以及本文的主题——作为 Artisan 命令(asCommand)。仓库中配套的总纲文档 SKILL.md 明确了项目约定:Action 类放在 App\Actions 命名空间、采用 VerbNoun 命名、领域逻辑只写在 handle(...) 中、传输层/框架相关的代码(包括控制台 I/O)只写在 as* 适配方法里。
需要说明一点现状:从源码结构看,Coolify 现有的 app/Actions/ 下大量 Action(如 app/Actions/Server/CleanupDocker.php、app/Actions/Application/StopApplication.php 等)目前主要使用 object/job 入口,尚未检索到使用 asCommand 暴露为命令的 Action;其控制台入口仍由 app/Console/Commands/ 下的传统命令承担。本文讲解的 asCommand 模式正是该技能体系为“新增或重构命令行功能”推荐的写法。
核心模式:handle(...) 承载逻辑,asCommand(...) 负责 I/O
参考文档给出的推荐模式只有三条:
- 定义
$commandSignature和$commandDescription; - 实现
asCommand(Command $command)处理控制台 I/O; - 业务逻辑保留在
handle(...)中。
完整示例(来自 command.md):
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!');
}
}
执行链路的工作机制是:当该 Action 以命令方式被执行时,框架首先查找 asCommand(Command $command) 方法;如果类中没有实现 asCommand,则自动回退到 handle(...)(入参直接来自命令参数)。这个回退行为意味着一个 Action 可以“渐进式”接入命令行——先只用 handle(...) 跑通,之后需要读取参数或输出提示时再补一个 asCommand 适配器,handle 不需要任何改动。
这种分层带来了三个实际收益:
- 复用:同一段
handle(...)逻辑既可以被服务层直接调用,也可以被 artisan 命令调用,规则不会复制两份; - 可测试性:业务规则用单元测试直接打
handle(...),命令行为用$this->artisan(...)断言输出,互不干扰; - 依赖方向清晰:
handle不依赖Command实例,测试时无需构造控制台上下文。
命令元数据:四种属性的“属性式”与“方法式”
命令的元数据(签名、描述、帮助、隐藏)在参考文档中均以成对形式给出:一个公共属性(Property alternative)和一个同语义方法。二者功能等价,任选其一即可。
命令签名:$commandSignature / getCommandSignature
签名决定命令名、参数与选项。当以属性方式声明时:
public string $commandSignature = 'users:update-role {user_id} {role}';
当未设置 $commandSignature 属性、又要注册该 Action 为命令时,则必须提供方法形式:
public function getCommandSignature(): string
{
return 'users:update-role {user_id} {role}';
}
签名内 {user_id}、{role} 这类花括号占位符就是命令参数,后续在 asCommand 中通过 $command->argument('user_id') 取值。参数、选项的定义与 Laravel 原生 signature() 写法完全一致(支持 {name|default}、{--flag} 等语法)。
命令描述:$commandDescription / getCommandDescription
描述会出现在 artisan list 的输出中:
// 属性式
public string $commandDescription = 'Updates the role of a given user.';
// 方法式
public function getCommandDescription(): string
{
return 'Updates the role of a given user.';
}
帮助文本:$commandHelp / getCommandHelp
额外帮助文本,在用户执行 php artisan users:update-role --help 时展示:
// 属性式
public string $commandHelp = 'My help message.';
// 方法式
public function getCommandHelp(): string
{
return 'My help message.';
}
是否隐藏:$commandHidden / isCommandHidden
控制命令是否从 artisan list 中隐藏,默认值为 false。适合那些仅用于内部运维、不希望出现在帮助列表中的命令:
// 属性式
public bool $commandHidden = true;
// 方法式
public function isCommandHidden(): bool
{
return true;
}
注册:在 Console Kernel 中暴露命令
把 Action 暴露为命令需要将其注册到控制台。参考文档给出的最小写法是把 Action 类加入 Console Kernel 的 $commands 数组:
// app/Console/Kernel.php
protected $commands = [
UpdateUserRole::class,
];
结合 Coolify 当前代码看,app/Console/Kernel.php 是 Laravel 11/12 风格的新版控制台内核:没有 $commands 属性,而是在 commands() 方法中做注册:
protected function commands(): void
{
$this->load(__DIR__.'/Commands');
require base_path('routes/console.php');
}
这里 $this->load(...) 会自动发现 app/Console/Commands 目录下的所有命令。若按 asCommand 模式新增一个 Action 命令,可以推断有两条落地路径:将 Action 类文件放入被 load 的目录,或在 commands() 中显式 Artisan::command() / $this->commands([...]) 注册该类——无论哪条路径,本质都是让框架在启动时把 Action 类当作命令类解析并读取其签名元数据。
测试:聚焦式的 artisan 命令测试
参考文档给出的命令层测试写法是:
$this->artisan('users:update-role 1 admin')
->expectsOutput('Done!')
->assertSuccessful();
这条断言同时验证了三件事:命令被成功解析并执行(assertSuccessful)、终端输出符合预期(expectsOutput('Done!'),对应 asCommand 中的 $command->info('Done!'))、且执行过程中没有抛异常。
结合 SKILL.md 中的“两层测试策略”,一个完整的 Action 命令应该覆盖两个层面:
- 业务层:直接调用
handle(...),用真实依赖/工厂数据验证业务规则(例如角色确实被更新); - 入口层:通过
$this->artisan('...')验证参数解析、输出与退出码。
在 Coolify 的 Pest 测试栈下,还可以配合 Action fakes 做隔离断言(MyAction::fake()、MyAction::assertDispatched()),例如验证“命令执行触发了下游 Action”。运行最小相关测试集可以用 php artisan test --compact --filter=UpdateUserRole 这类过滤命令,避免全量跑测试。
检查清单与常见陷阱
参考文档在结尾给出的检查清单(Checklist):
- 已导入
use Illuminate\Console\Command;(asCommand的方法签名需要该类型提示); - 命令的签名/选项/参数已被文档化(描述与帮助文本齐备);
- 命令测试同时验证了调用与输出。
常见陷阱(Common pitfalls):
- 在
handle(...)中混入命令 I/O——把$command->info()、参数读取写进业务方法是该模式最大的反模式,它会破坏handle的多入口复用与可测性; - 缺失或含糊的命令签名——签名占位符与
asCommand中$command->argument(...)的 key 不一致、或参数缺少默认值约束,会导致运行时取参失败。
小结
asCommand 入口为 Coolify 这类以 Action 组织业务逻辑的 Laravel 项目提供了一条清晰的命令行扩展路径:用 $commandSignature / getCommandSignature(及描述、帮助、隐藏三组元数据)声明命令元数据,把参数解析与终端输出集中在 asCommand(Command $command),将业务规则留在 handle(...),再在 Console Kernel 中注册并用 $this->artisan(...)->expectsOutput(...)->assertSuccessful() 做聚焦测试。该模式与其 object、controller、job、listener 各入口共享同一套 AsAction 约定,是保持 Coolify app/Actions 代码库一致性的组成部分。
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 StartedRust0623
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