Laravel Zero

コンソールアプリケーション開発のためのマイクロフレームワーク。Laravelの優雅さをCLIアプリケーションにもたらします。

phpclicommand-linelaravelartisan

フレームワーク

Laravel Zero

概要

Laravel Zeroは、コンソールアプリケーション開発のためのマイクロフレームワークです。Laravelの優雅さをCLIアプリケーションにもたらし、Laravel開発者に人気が高く、Laravelの機能を活用したスタンドアロンCLIツールの開発に使用されています。豊富なLaravelエコシステムを活用しながら、軽量で高速なCLIアプリケーションを構築できます。

詳細

Laravel Zeroは、Laravelフレームワークのコア機能を活用しながら、CLIアプリケーションに特化して最適化されたフレームワークです。Eloquent ORM、Collection、Queue、Schedule、テストなど、Laravelの強力な機能をコマンドラインアプリケーションで利用できます。

主な特徴

  • Laravelエコシステム: Eloquent、Collection、Queueなどの機能を活用
  • Artisanコマンド: Laravelと同様のArtisanコマンドラインツール
  • 設定管理: Laravelスタイルの設定ファイル管理
  • サービスプロバイダー: 依存性注入とサービスコンテナ
  • スケジューリング: cron風のタスクスケジューリング
  • テスト機能: PHPUnitベースの包括的なテスト機能
  • ログ機能: Monologベースの柔軟なログ機能
  • 自動更新: セルフアップデート機能

メリット・デメリット

メリット

  • Laravel開発者親和性: Laravel開発者には非常に馴染みやすい
  • 豊富な機能: Laravelエコシステムの恩恵を受けられる
  • 高品質なコード: Laravelの品質基準に基づく設計
  • 充実したドキュメント: 詳細で分かりやすい公式ドキュメント
  • コミュニティ: Laravel コミュニティからのサポート
  • テスト容易性: 組み込まれたテスト機能
  • パッケージ配布: Pharファイルとしての配布が可能

デメリット

  • 学習コスト: Laravel未経験者には習得に時間が必要
  • 依存関係: 多くのLaravelコンポーネントに依存
  • ファイルサイズ: 軽量なCLIツールには重い場合がある
  • PHP要件: PHP 8.0以上が必要

主要リンク

書き方の例

プロジェクトの作成

composer create-project --prefer-dist laravel-zero/laravel-zero my-cli-app
cd my-cli-app
php application --version

基本的なコマンドの作成

<?php
// app/Commands/HelloCommand.php

namespace App\Commands;

use Illuminate\Console\Scheduling\Schedule;
use LaravelZero\Framework\Commands\Command;

class HelloCommand extends Command
{
    /**
     * コマンドの名前と引数の定義
     */
    protected $signature = 'hello {name : お名前} {--times=1 : 繰り返し回数}';

    /**
     * コマンドの説明
     */
    protected $description = '挨拶を表示します';

    /**
     * コマンドの実行
     */
    public function handle(): int
    {
        $name = $this->argument('name');
        $times = $this->option('times');

        for ($i = 0; $i < $times; $i++) {
            $this->info("こんにちは、{$name}さん!");
        }

        return self::SUCCESS;
    }

    /**
     * スケジュールの定義
     */
    public function schedule(Schedule $schedule): void
    {
        // $schedule->command(static::class)->daily();
    }
}

複雑なコマンドの例

<?php
// app/Commands/DataProcessCommand.php

namespace App\Commands;

use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
use LaravelZero\Framework\Commands\Command;

class DataProcessCommand extends Command
{
    protected $signature = 'data:process 
                            {--source=api : データソース (api|file)} 
                            {--format=json : 出力形式 (json|csv|table)}
                            {--output= : 出力ファイルパス}
                            {--limit=10 : 処理件数制限}';

    protected $description = 'データを取得・処理・出力します';

    public function handle(): int
    {
        $source = $this->option('source');
        $format = $this->option('format');
        $output = $this->option('output');
        $limit = (int) $this->option('limit');

        $this->info('データ処理を開始します...');

        // データ取得
        $data = $this->fetchData($source, $limit);

        if ($data->isEmpty()) {
            $this->error('データが取得できませんでした');
            return self::FAILURE;
        }

        $this->info("取得件数: {$data->count()}件");

        // データ処理
        $processedData = $this->processData($data);

        // 出力
        $this->outputData($processedData, $format, $output);

        $this->info('データ処理が完了しました');
        return self::SUCCESS;
    }

    protected function fetchData(string $source, int $limit): Collection
    {
        $this->task('データ取得中', function () use ($source, $limit) {
            sleep(1); // 処理をシミュレート
        });

        switch ($source) {
            case 'api':
                return $this->fetchFromApi($limit);
            case 'file':
                return $this->fetchFromFile($limit);
            default:
                return collect();
        }
    }

    protected function fetchFromApi(int $limit): Collection
    {
        try {
            $response = Http::get('https://jsonplaceholder.typicode.com/posts', [
                '_limit' => $limit
            ]);

            return collect($response->json());
        } catch (\Exception $e) {
            $this->error("API取得エラー: {$e->getMessage()}");
            return collect();
        }
    }

    protected function fetchFromFile(int $limit): Collection
    {
        // サンプルデータを生成
        return collect(range(1, $limit))->map(function ($i) {
            return [
                'id' => $i,
                'title' => "サンプルタイトル {$i}",
                'body' => "サンプル本文 {$i}",
                'userId' => rand(1, 10)
            ];
        });
    }

    protected function processData(Collection $data): Collection
    {
        return $data->map(function ($item) {
            return [
                'id' => $item['id'],
                'title' => str($item['title'])->title(),
                'body' => str($item['body'])->limit(50),
                'user_id' => $item['userId'],
                'processed_at' => now()->toDateTimeString()
            ];
        });
    }

    protected function outputData(Collection $data, string $format, ?string $output): void
    {
        switch ($format) {
            case 'json':
                $content = $data->toJson(JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
                break;
            case 'csv':
                $content = $this->toCsv($data);
                break;
            case 'table':
                $this->table(
                    ['ID', 'タイトル', '本文', 'ユーザーID', '処理日時'],
                    $data->map(fn($item) => array_values($item))->toArray()
                );
                return;
            default:
                $this->error("未対応の形式: {$format}");
                return;
        }

        if ($output) {
            file_put_contents($output, $content);
            $this->info("ファイルに出力しました: {$output}");
        } else {
            $this->line($content);
        }
    }

    protected function toCsv(Collection $data): string
    {
        if ($data->isEmpty()) {
            return '';
        }

        $headers = array_keys($data->first());
        $csv = implode(',', $headers) . "\n";

        foreach ($data as $row) {
            $csv .= implode(',', array_map(fn($value) => '"' . str_replace('"', '""', $value) . '"', array_values($row))) . "\n";
        }

        return $csv;
    }
}

インタラクティブなコマンドの例

<?php
// app/Commands/SetupCommand.php

namespace App\Commands;

use LaravelZero\Framework\Commands\Command;

class SetupCommand extends Command
{
    protected $signature = 'setup';
    protected $description = 'アプリケーションの初期設定を行います';

    public function handle(): int
    {
        $this->info('🚀 アプリケーション初期設定');
        $this->newLine();

        // 基本情報の収集
        $appName = $this->ask('アプリケーション名を入力してください', 'My CLI App');
        $version = $this->ask('バージョンを入力してください', '1.0.0');
        
        // 選択式質問
        $environment = $this->choice(
            '実行環境を選択してください',
            ['development', 'production', 'testing'],
            'development'
        );

        // データベース設定
        $useDatabase = $this->confirm('データベースを使用しますか?', false);
        
        $dbConfig = [];
        if ($useDatabase) {
            $dbConfig = [
                'driver' => $this->choice('データベースタイプ', ['mysql', 'postgresql', 'sqlite'], 'sqlite'),
                'host' => $this->ask('ホスト', 'localhost'),
                'database' => $this->ask('データベース名', 'database'),
                'username' => $this->ask('ユーザー名', 'root'),
                'password' => $this->secret('パスワード'),
            ];
        }

        // ログ設定
        $logLevel = $this->choice(
            'ログレベルを選択してください',
            ['debug', 'info', 'warning', 'error'],
            'info'
        );

        // 確認
        $this->newLine();
        $this->info('設定内容を確認してください:');
        $this->table(
            ['設定項目', '値'],
            [
                ['アプリケーション名', $appName],
                ['バージョン', $version],
                ['環境', $environment],
                ['データベース', $useDatabase ? 'あり' : 'なし'],
                ['ログレベル', $logLevel],
            ]
        );

        if (!$this->confirm('この設定で続行しますか?', true)) {
            $this->warn('設定をキャンセルしました');
            return self::FAILURE;
        }

        // 設定ファイルの生成
        $this->task('設定ファイル生成中', function () use ($appName, $version, $environment, $dbConfig, $logLevel) {
            $config = [
                'app' => [
                    'name' => $appName,
                    'version' => $version,
                    'env' => $environment,
                ],
                'database' => $dbConfig,
                'logging' => [
                    'level' => $logLevel,
                ],
            ];

            file_put_contents(
                base_path('.env.generated'),
                $this->generateEnvFile($config)
            );

            sleep(1); // 処理をシミュレート
        });

        $this->info('✅ 初期設定が完了しました!');
        $this->info('生成されたファイル: .env.generated');
        $this->warn('手動で .env ファイルに設定を反映してください。');

        return self::SUCCESS;
    }

    protected function generateEnvFile(array $config): string
    {
        $env = "# Generated configuration\n";
        $env .= "APP_NAME=\"{$config['app']['name']}\"\n";
        $env .= "APP_VERSION={$config['app']['version']}\n";
        $env .= "APP_ENV={$config['app']['env']}\n";
        $env .= "\n";

        if (!empty($config['database'])) {
            $env .= "DB_CONNECTION={$config['database']['driver']}\n";
            $env .= "DB_HOST={$config['database']['host']}\n";
            $env .= "DB_DATABASE={$config['database']['database']}\n";
            $env .= "DB_USERNAME={$config['database']['username']}\n";
            $env .= "DB_PASSWORD={$config['database']['password']}\n";
            $env .= "\n";
        }

        $env .= "LOG_LEVEL={$config['logging']['level']}\n";

        return $env;
    }
}

バックグラウンドタスクとスケジューリング

<?php
// app/Commands/ScheduleCommand.php

namespace App\Commands;

use Illuminate\Console\Scheduling\Schedule;
use LaravelZero\Framework\Commands\Command;

class MaintenanceCommand extends Command
{
    protected $signature = 'maintenance:run {--dry-run : 実際には実行せずに表示のみ}';
    protected $description = 'メンテナンスタスクを実行します';

    public function handle(): int
    {
        $dryRun = $this->option('dry-run');

        if ($dryRun) {
            $this->warn('ドライランモード: 実際の処理は実行されません');
        }

        $this->info('メンテナンスタスクを開始します...');

        // ログファイルのクリーンアップ
        $this->cleanupLogs($dryRun);

        // 一時ファイルの削除
        $this->cleanupTempFiles($dryRun);

        // データベースの最適化
        $this->optimizeDatabase($dryRun);

        $this->info('メンテナンスタスクが完了しました');
        return self::SUCCESS;
    }

    public function schedule(Schedule $schedule): void
    {
        // 毎日午夜2時に実行
        $schedule->command(static::class)->dailyAt('02:00');
        
        // 毎週日曜日にドライランモードで実行
        $schedule->command(static::class, ['--dry-run'])->weekly();
    }

    protected function cleanupLogs(bool $dryRun): void
    {
        $this->task('ログファイルのクリーンアップ', function () use ($dryRun) {
            $logPath = storage_path('logs');
            $oldLogs = glob("{$logPath}/*.log");
            
            $cleaned = 0;
            foreach ($oldLogs as $log) {
                if (filemtime($log) < strtotime('-30 days')) {
                    if (!$dryRun) {
                        unlink($log);
                    }
                    $cleaned++;
                }
            }

            if ($dryRun) {
                $this->comment("削除対象ログファイル: {$cleaned}件");
            } else {
                $this->comment("削除したログファイル: {$cleaned}件");
            }
        });
    }

    protected function cleanupTempFiles(bool $dryRun): void
    {
        $this->task('一時ファイルの削除', function () use ($dryRun) {
            $tempPath = sys_get_temp_dir();
            $pattern = "{$tempPath}/app_temp_*";
            $tempFiles = glob($pattern);
            
            $cleaned = count($tempFiles);
            
            if (!$dryRun) {
                foreach ($tempFiles as $file) {
                    if (is_file($file)) {
                        unlink($file);
                    }
                }
            }

            $this->comment($dryRun ? "削除対象: {$cleaned}件" : "削除完了: {$cleaned}件");
        });
    }

    protected function optimizeDatabase(bool $dryRun): void
    {
        $this->task('データベース最適化', function () use ($dryRun) {
            if ($dryRun) {
                $this->comment('データベース最適化をスキップ(ドライラン)');
                return;
            }

            // 実際のデータベース最適化処理
            // DB::statement('OPTIMIZE TABLE users');
            $this->comment('データベース最適化完了');
        });
    }
}

テストの例

<?php
// tests/Feature/HelloCommandTest.php

namespace Tests\Feature;

use Tests\TestCase;

class HelloCommandTest extends TestCase
{
    public function test_hello_command_with_name(): void
    {
        $this->artisan('hello', ['name' => 'Laravel'])
             ->expectsOutput('こんにちは、Laravelさん!')
             ->assertExitCode(0);
    }

    public function test_hello_command_with_times_option(): void
    {
        $this->artisan('hello', ['name' => 'Test', '--times' => 3])
             ->expectsOutput('こんにちは、Testさん!')
             ->expectsOutput('こんにちは、Testさん!')
             ->expectsOutput('こんにちは、Testさん!')
             ->assertExitCode(0);
    }
}

アプリケーション設定

<?php
// config/app.php

return [
    'name' => env('APP_NAME', 'Laravel Zero'),
    'version' => app('git.version'),
    'env' => env('APP_ENV', 'production'),
    'providers' => [
        App\Providers\AppServiceProvider::class,
    ],
];

カスタムサービスプロバイダー

<?php
// app/Providers/AppServiceProvider.php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        // サービスの登録
        $this->app->singleton('custom.service', function ($app) {
            return new \App\Services\CustomService();
        });
    }

    public function boot(): void
    {
        // 起動時の処理
    }
}

ビルドとコンパイル

# Pharファイルの作成
php application app:build my-app

# 実行可能ファイルの作成
php application app:build my-app --build-version=1.0.0

# リリース用ビルド
php application app:build my-app --optimize