首页
/ Coolify 中 Laravel Action 的 Command 入口:用 lorisleiva/laravel-actions 的 asCommand 编写可测试的 Artisan 命令

Coolify 中 Laravel Action 的 Command 入口:用 lorisleiva/laravel-actions 的 asCommand 编写可测试的 Artisan 命令

2026-09-05 13:48:34作者:平淮齐Percy

本文围绕 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.phpapp/Actions/Application/StopApplication.php 等)目前主要使用 object/job 入口,尚未检索到使用 asCommand 暴露为命令的 Action;其控制台入口仍由 app/Console/Commands/ 下的传统命令承担。本文讲解的 asCommand 模式正是该技能体系为“新增或重构命令行功能”推荐的写法。

核心模式:handle(...) 承载逻辑,asCommand(...) 负责 I/O

参考文档给出的推荐模式只有三条:

  1. 定义 $commandSignature$commandDescription
  2. 实现 asCommand(Command $command) 处理控制台 I/O;
  3. 业务逻辑保留在 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 命令应该覆盖两个层面:

  1. 业务层:直接调用 handle(...),用真实依赖/工厂数据验证业务规则(例如角色确实被更新);
  2. 入口层:通过 $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 代码库一致性的组成部分。

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