【PHP】LaravelでArtisanコマンドを自作する方法(make:command / Signature)

対象バージョン: Laravel 13.x / PHP 8.3+(laravel/framework v13.40.0 で動作確認)

Laravel の Artisan では、業務処理やメンテ作業を CLI コマンドとして切り出せます。この記事は、make:command でクラスを作り、#[Signature] で引数・オプションを定義したい方向けです。生成から入力の取り方、出力と終了コード、クロージャコマンド、Feature テストまでを順に説明します。完成したコマンドをスケジュールに載せる手順は【PHP】Laravelでタスクスケジューリングを実装する方法で扱っており、この記事ではスケジューラ本体は扱いません。

この記事で分かること

  • php artisan make:command でコマンドクラスを生成する方法
  • Laravel 13 の #[Signature] / #[Description] と、従来の $signature / $description
  • 引数・オプション・ショートカットの書き方と取得方法
  • info / table / withProgressBar / fail などの出力と終了コード
  • routes/console.php のクロージャコマンド
  • Isolatable による --isolated
  • $this->artisan() による Feature テスト

前提

Laravel 13.x は PHP 8.3 以上が必要です。動作確認は、composer create-project laravel/laravel:^13.0 で新規作成したアプリ(laravel/laravel v13.11.0、laravel/framework v13.40.0、PHP 8.4.27)で行いました。プロジェクトの作成方法は【PHP】Laravel入門:インストールから簡単なサンプルアプリケーションの作成を参照してください。

キューやジョブの詳細、スケジューラの cron 設定、スタブのカスタマイズは対象外です。コマンドからジョブを投げる場合は【PHP】Laravelでキューとジョブを実装する方法とその利用シーンを、ログ出力のチャンネル設定は【PHP】Laravelでログを設定・出力する方法(チャンネル・stack・daily)を参照してください。

make:command でクラスを作る

次のコマンドで、app/Console/Commands 配下にクラスが生成されます。

php artisan make:command SendEmails

Laravel 13.x のスタブは、$signature プロパティではなく #[Signature] / #[Description] 属性を使います。生成直後の例は次のとおりです(既定のシグネチャは app:send-emails)。

<?php

namespace App\Console\Commands;

use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;

#[Signature('app:send-emails')]
#[Description('Command description')]
class SendEmails extends Command
{
    /**
     * Execute the console command.
     */
    public function handle()
    {
        //
    }
}

make:command には --command / --force / --test / --pest / --phpunit などのオプションもあります。

コマンドの構造(#[Signature] / #[Description])

Laravel 13 では、Illuminate\Console\Command を継承したクラスに #[Signature] と #[Description] を付け、処理は handle() に書きます。これらの属性は Laravel 13.0.0 で導入され、12.x にはありません。名前空間は Illuminate\Console\Attributes です(同じ名前空間に Help / Hidden / Aliases / Usage もあります)。

属性で定義したシグネチャ文字列の構文は、後述の入力定義と同じです。handle() の戻り値は int にでき、成功時は 0(Symfony\Component\Console\Command\Command::SUCCESS)を返します。

従来の $signature / $description

属性の代わりに、従来どおり protected $signature と protected $description でも定義できます。ドキュメントの「Defining Input Expectations」でもこの書き方が使われており、Laravel 13 でもサポートされています。属性形式とプロパティ形式はシグネチャ文字列の定義として同等です。短い等価例は次のとおりです。

<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;

class LegacySignatureDemo extends Command
{
    protected $signature = 'mail:legacy {user}';
    protected $description = 'Legacy property-based signature demo';

    public function handle(): int
    {
        $this->info('legacy user=' . $this->argument('user'));

        return self::SUCCESS;
    }
}

引数とオプションの書き方

シグネチャ文字列(#[Signature] でも $signature でも同じ)の主な記法は次のとおりです。

記法 意味
{user} 必須引数
{user?} 任意引数
{user=foo} デフォルト付き引数
{user*} / {user?*} 配列引数(必須 / 任意)
{--queue} 真偽のオプション
{--queue=} 値を取るオプション
{--queue=default} デフォルト値付きオプション
{--Q\|queue=} ショートカット -Q
{--id=*} 配列オプション
{user : The ID} コロン以降が説明

値付きショートカットは、ドキュメントどおり -Qhigh のように = なしで渡します。-Q=high とすると、値は =high になります。

入力の取得とデモコマンド

入力は $this->argument() / $this->option() で取れます。キーなしの $this->input() は Illuminate\Console\CommandInput を返し、string() / boolean() などの型付きアクセサも使えます。

検証用のデモコマンド全体は次のとおりです。

<?php

namespace App\Console\Commands;

use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Symfony\Component\Console\Command\Command as SymfonyCommand;

#[Signature('mail:demo {user : The user ID} {tags?* : Optional tags} {--Q|queue=default : Queue name} {--force : Force send} {--notify=* : Notification channels}')]
#[Description('Demo marketing email command for verification')]
class DemoMailSend extends Command
{
    public function handle(): int
    {
        $user = $this->argument('user');
        $tags = $this->argument('tags');
        $queue = $this->option('queue');
        $force = $this->option('force');
        $notify = $this->option('notify');

        $this->info("Sending to user={$user}");
        $this->line('tags=' . json_encode(array_values((array) $tags)));
        $this->line('queue=' . var_export($queue, true));
        $this->line('force=' . var_export($force, true));
        $this->line('notify=' . json_encode(array_values((array) $notify)));

        // CommandInput typed accessors
        $input = $this->input();
        $this->line('input_user=' . $input->string('user'));
        $this->line('input_queue=' . $input->string('queue'));
        $this->line('input_force=' . ($input->boolean('force') ? 'true' : 'false'));

        // table output
        $this->table(['Key', 'Value'], [
            ['user', $user],
            ['queue', $queue],
        ]);

        // progress bar over a tiny iterable
        $this->withProgressBar([1, 2, 3], function () {
            // no-op
        });
        $this->newLine();

        if ($user === 'failme') {
            $this->error('Forced failure');
            $this->fail('Something went wrong.');
        }

        return SymfonyCommand::SUCCESS;
    }
}

実行例です。

php artisan mail:demo 42
php artisan mail:demo 99 news sports -Qhigh --force --notify=mail --notify=slack
php artisan mail:demo 7

user に failme を渡すと $this->fail() により終了コード 1 になります。

php artisan mail:demo failme

出力には info / line / error、table、withProgressBar が含まれます。終了コードは handle() の戻り値、または $this->fail()(終了コード 1)で決まります。定数としては SUCCESS=0 / FAILURE=1 / INVALID=2 が使えます。

クロージャコマンド(routes/console.php)

クラスを作らず、routes/console.php でクロージャとして定義することもできます。新規アプリの bootstrap/app.php では、withRouting の commands にこのファイルが渡されています。

bootstrap/app.php(確認環境の全体):

<?php

use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
    )
    ->withMiddleware(function (Middleware $middleware): void {
        //
    })
    ->withExceptions(function (Exceptions $exceptions): void {
        $exceptions->shouldRenderJsonWhen(
            fn (Request $request) => $request->is('api/*') || $request->expectsJson(),
        );
    })->create();

routes/console.php(確認環境の全体。既定の inspire に加え、検証用の demo:closure を追加しています):

<?php

use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;

Artisan::command('inspire', function () {
    $this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');


Artisan::command('demo:closure {name}', function (string $name) {
    $this->info("Hello {$name}");
})->purpose('Closure command verification');

追加したクロージャは次のように動きます。

php artisan demo:closure World

出力は Hello World です。

コマンドの登録

既定では app/Console/Commands 配下のコマンドは自動登録されます。追加のディレクトリやクラス名を明示したい場合は、bootstrap/app.php のアプリケーションビルダで ->withCommands([...]) を使います。

Isolatable(任意)

コマンドクラスに Illuminate\Contracts\Console\Isolatable を実装すると、シグネチャに書かなくても --isolated オプションが付きます。確認環境では demo:isolated の help に --isolated が表示され、次の実行は終了コード 0 でした(既定のキャッシュを利用)。

use Illuminate\Contracts\Console\Isolatable;

class DemoIsolated extends Command implements Isolatable
{
    // シグネチャは通常どおり属性または $signature で定義する
}
php artisan demo:isolated --isolated

同時実行時のロック競合や、ロックキー/TTL のカスタムは、この記事では検証していません。

PHP からコマンドを呼ぶ

Artisan::call() と Artisan::output() で、PHP コードからコマンドを実行できます。確認では mail:legacy を次の引数で呼び出し、終了ステータス 0 と legacy user=call-via-facade を得ました。

use Illuminate\Support\Facades\Artisan;

$status = Artisan::call('mail:legacy', [
    'user' => 'call-via-facade',
]);
$output = Artisan::output();

Feature テスト

コンソール/Feature テストでは $this->artisan() でコマンドを実行し、expectsOutputToContain と assertExitCode で確認できます。Feature テストの基本は【PHP】Laravelでテストを実装する方法を参照してください。

tests/Feature/DemoMailSendCommandTest.php の例です。

<?php

namespace Tests\Feature;

use Tests\TestCase;

class DemoMailSendCommandTest extends TestCase
{
    public function test_mail_demo_command_succeeds_with_output(): void
    {
        $this->artisan('mail:demo', [
            'user' => '42',
            '--queue' => 'high',
            '--force' => true,
        ])
            ->expectsOutputToContain('Sending to user=42')
            ->expectsOutputToContain("queue='high'")
            ->assertExitCode(0);
    }

    public function test_legacy_signature_command(): void
    {
        $this->artisan('mail:legacy', ['user' => '9'])
            ->expectsOutputToContain('legacy user=9')
            ->assertExitCode(0);
    }

    public function test_closure_command(): void
    {
        $this->artisan('demo:closure', ['name' => 'World'])
            ->expectsOutputToContain('Hello World')
            ->assertExitCode(0);
    }
}

必須引数が欠けている場合、CLI では終了コード 1(Not enough arguments)になりますが、PHPUnit の $this->artisan() では例外が投げられます。どちらも確認済みの挙動です。

よくあるつまずき

  • ショートカットに = を付けてしまう … -Qhigh が正しく、-Q=high だと値は =high になります。
  • 12.x の書き方のまま属性を探す … #[Signature] / #[Description] は Laravel 13 で導入されました。12.x では $signature を使います。
  • 必須引数なしのテストを CLI と同じ感覚で書く … $this->artisan() は CLI の exit 1 ではなく例外になります。
  • スケジュールやキューまでこの記事で探す … それぞれ専用記事側です。

まとめ

  • Laravel 13 の make:command は #[Signature] / #[Description] を生成します。
  • 引数・オプションの文字列記法は属性でも $signature でも共通です。
  • ショートカットは -Qhigh のように渡し、出力と終了コードは handle() / fail() で制御します。
  • クロージャは routes/console.php、重複実行を避けたい場合は Isolatable を実装して --isolated 付きで実行、テストは $this->artisan() が使えます。

関連記事

参考リンク

コメントする