MidПрактика7 min

Artisan Console

Создание команд, аргументы и опции, ввод-вывод, программный вызов команд, регистрация в планировщике задач Laravel 11

Artisan - это CLI-интерфейс Laravel. Помимо встроенных команд, Laravel позволяет создавать собственные команды для автоматизации задач, обработки данных, обслуживания и многого другого.

Создание команд

php artisan make:command SendWeeklyReports
# Creates: app/Console/Commands/SendWeeklyReports.php
declare(strict_types=1);

namespace App\Console\Commands;

use App\Models\User;
use App\Services\ReportService;
use Illuminate\Console\Command;

final class SendWeeklyReports extends Command
{
    /**
     * The name and signature of the console command.
     */
    protected $signature = 'reports:send-weekly
        {--type=summary : Report type (summary, detailed, executive)}
        {--dry-run : Preview without sending}';

    /**
     * The console command description.
     */
    protected $description = 'Send weekly reports to all subscribed users';

    public function __construct(
        private readonly ReportService $reportService,
    ) {
        parent::__construct();
    }

    /**
     * Execute the console command.
     */
    public function handle(): int
    {
        $type = $this->option('type');
        $dryRun = $this->option('dry-run');

        $this->info("Sending {$type} weekly reports...");

        if ($dryRun) {
            $this->warn('DRY RUN - no emails will be sent.');
        }

        $users = User::where('weekly_reports', true)->get();

        $bar = $this->output->createProgressBar($users->count());
        $bar->start();

        $sent = 0;
        foreach ($users as $user) {
            if (!$dryRun) {
                $this->reportService->sendWeeklyReport($user, $type);
            }
            $sent++;
            $bar->advance();
        }

        $bar->finish();
        $this->newLine(2);

        $this->info("Successfully processed {$sent} reports.");

        return Command::SUCCESS;
    }
}

Сигнатура команды: аргументы и опции

Аргументы

protected $signature = 'users:import
    {file : Path to the CSV file}
    {format? : File format (csv, xlsx) - optional}
    {--chunk=100 : Number of records per batch}';

// Required argument
// users:import /path/to/users.csv

// Optional argument
// users:import /path/to/users.csv xlsx

// With default value
protected $signature = 'users:import
    {file : Path to the CSV file}
    {format=csv : File format}';

Опции

protected $signature = 'deploy:assets
    {--force : Force overwrite existing files}
    {--env= : Target environment}
    {--path=public : Output directory}
    {--tag=* : Tags to include (repeatable)}';

// Boolean option (flag)
// php artisan deploy:assets --force

// Option with value
// php artisan deploy:assets --env=production

// Option with default
// php artisan deploy:assets (path defaults to "public")

// Repeatable option
// php artisan deploy:assets --tag=css --tag=js --tag=images

Массив аргументов

protected $signature = 'users:notify {users* : User IDs to notify}';
// php artisan users:notify 1 2 3 4 5

protected $signature = 'email:send {to*} {--cc=*}';
// php artisan email:send [email protected] [email protected] [email protected]

Получение аргументов и опций

public function handle(): int
{
    // Get argument
    $file = $this->argument('file');

    // Get all arguments
    $allArgs = $this->arguments();

    // Get option
    $environment = $this->option('env');
    $force = $this->option('force'); // bool for flags

    // Get all options
    $allOptions = $this->options();

    // Get array option
    $tags = $this->option('tag'); // ['css', 'js', 'images']

    return Command::SUCCESS;
}

Ввод-вывод (I/O)

Вывод информации

public function handle(): int
{
    // Standard output methods
    $this->info('Information message');       // Green text
    $this->error('Error message');            // Red background
    $this->warn('Warning message');           // Yellow text
    $this->comment('Comment message');        // Yellow text
    $this->question('Question message');      // Black text on cyan
    $this->line('Plain text');                // No formatting
    $this->newLine(2);                        // Empty lines

    // With verbosity levels
    $this->info('Always shown');
    $this->info('Shown with -v', verbosity: 'v');
    $this->info('Shown with -vv', verbosity: 'vv');
    $this->info('Shown with -vvv', verbosity: 'vvv');

    return Command::SUCCESS;
}

Интерактивный ввод

public function handle(): int
{
    // Ask for input
    $name = $this->ask('What is your name?');
    $name = $this->ask('What is your name?', 'Default Name');

    // Secret input (hidden)
    $password = $this->secret('Enter the database password');

    // Confirmation (yes/no)
    if ($this->confirm('Do you want to continue?')) {
        $this->info('Proceeding...');
    }

    // Default to yes
    if ($this->confirm('Deploy to production?', true)) {
        // Deploys by default if user just presses Enter
    }

    // Autocomplete
    $framework = $this->anticipate('Which framework?', [
        'Laravel', 'Symfony', 'CodeIgniter',
    ]);

    // Choice (select)
    $color = $this->choice('Pick a color', [
        'red', 'green', 'blue',
    ], defaultIndex: 0);

    // Multiple choice
    $permissions = $this->choice(
        'Select permissions',
        ['read', 'write', 'execute'],
        multiple: true
    );

    return Command::SUCCESS;
}

Таблицы

public function handle(): int
{
    $users = User::all(['id', 'name', 'email', 'created_at']);

    $this->table(
        ['ID', 'Name', 'Email', 'Created'],
        $users->map(fn (User $user) => [
            $user->id,
            $user->name,
            $user->email,
            $user->created_at->format('Y-m-d'),
        ])
    );

    return Command::SUCCESS;
}

Прогресс-бар

public function handle(): int
{
    $items = Order::where('status', 'pending')->get();

    $bar = $this->output->createProgressBar($items->count());
    $bar->setFormat(' %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s%');
    $bar->start();

    foreach ($items as $item) {
        $this->processItem($item);
        $bar->advance();
    }

    $bar->finish();
    $this->newLine();

    // Or use withProgressBar helper
    $this->withProgressBar($items, function (Order $order) {
        $this->processItem($order);
    });

    return Command::SUCCESS;
}

Коды возврата

use Illuminate\Console\Command;

public function handle(): int
{
    try {
        // Process...
        return Command::SUCCESS;   // 0
    } catch (ValidationException $e) {
        $this->error($e->getMessage());
        return Command::FAILURE;   // 1
    } catch (\Exception $e) {
        $this->error('Unexpected error: ' . $e->getMessage());
        return Command::INVALID;   // 2
    }
}

Вызов команд программно

Из контроллера или сервиса

use Illuminate\Support\Facades\Artisan;

// Basic call
Artisan::call('reports:send-weekly', [
    '--type' => 'detailed',
    '--dry-run' => true,
]);

// Get output
$output = Artisan::output();

// Queue the command
Artisan::queue('reports:send-weekly', [
    '--type' => 'summary',
])->onQueue('reports');

Из другой команды

public function handle(): int
{
    // Call another command
    $this->call('cache:clear');

    // Call silently (suppress output)
    $this->callSilently('config:cache');

    // Call with arguments
    $this->call('users:import', [
        'file' => '/path/to/file.csv',
        '--format' => 'xlsx',
    ]);

    return Command::SUCCESS;
}

Регистрация команд в планировщике

В Laravel 11 регистрация расписания происходит в routes/console.php:

// routes/console.php
use Illuminate\Support\Facades\Schedule;

// Run command every day at midnight
Schedule::command('reports:send-weekly --type=summary')
    ->weeklyOn(1, '8:00')       // Every Monday at 8:00
    ->timezone('Europe/Moscow')
    ->withoutOverlapping()       // Prevent parallel execution
    ->onOneServer()              // Run on only one server (requires cache driver)
    ->emailOutputOnFailure('[email protected]');

// Run with closure
Schedule::call(function () {
    DB::table('sessions')
        ->where('last_activity', '<', now()->subDay())
        ->delete();
})->daily()->description('Clean expired sessions');

// Common schedule frequencies
Schedule::command('telescope:prune')->daily();
Schedule::command('queue:restart')->hourly();
Schedule::command('backup:run')->dailyAt('02:00');
Schedule::command('sitemap:generate')->weekly();
Schedule::command('reports:monthly')->monthly();
Schedule::command('cache:warm')->everyFiveMinutes();
Schedule::command('health:check')->everyMinute();

// Cron expression
Schedule::command('custom:task')->cron('0 */6 * * *');

// Conditional scheduling
Schedule::command('import:currency-rates')
    ->hourly()
    ->when(fn () => app()->environment('production'))
    ->before(fn () => Log::info('Starting currency import'))
    ->after(fn () => Log::info('Currency import completed'));

// Output handling
Schedule::command('reports:generate')
    ->daily()
    ->sendOutputTo(storage_path('logs/reports.log'))
    ->emailOutputTo('[email protected]');

Запуск планировщика

# Add to crontab (single entry):
* * * * * cd /path-to-project && php artisan schedule:run >> /dev/null 2>&1

# Test schedule locally:
php artisan schedule:work

# List scheduled tasks:
php artisan schedule:list

# Run specific scheduled command:
php artisan schedule:test

Изолированные команды (Isolated Commands)

declare(strict_types=1);

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Contracts\Console\Isolatable;

final class ImportProducts extends Command implements Isolatable
{
    protected $signature = 'products:import {source}';
    protected $description = 'Import products from external source';

    /**
     * Unique ID for isolation lock.
     */
    public function isolatableId(): string
    {
        return $this->argument('source');
    }

    /**
     * Return code when command is already running.
     */
    public function isolationLockExpiresAt(): \DateTimeInterface
    {
        return now()->addHours(2);
    }

    public function handle(): int
    {
        // Only one instance with the same source can run at a time
        $this->info('Importing from: ' . $this->argument('source'));

        // Process...

        return Command::SUCCESS;
    }
}

Сигналы (Signal Handling)

declare(strict_types=1);

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Console\Signals;

final class LongRunningProcess extends Command
{
    protected $signature = 'process:run';
    protected $description = 'Long running process with graceful shutdown';

    private bool $shouldStop = false;

    public function handle(): int
    {
        // Register signal handlers
        $this->trap([SIGTERM, SIGINT], function () {
            $this->warn('Received stop signal, finishing current batch...');
            $this->shouldStop = true;
        });

        while (!$this->shouldStop) {
            $batch = $this->getNextBatch();

            if ($batch->isEmpty()) {
                sleep(5);
                continue;
            }

            $this->processBatch($batch);
        }

        $this->info('Gracefully stopped.');
        return Command::SUCCESS;
    }
}

Тестирование команд

declare(strict_types=1);

namespace Tests\Feature\Commands;

use Tests\TestCase;

final class SendWeeklyReportsTest extends TestCase
{
    public function test_command_sends_reports(): void
    {
        User::factory()->count(3)->create(['weekly_reports' => true]);

        $this->artisan('reports:send-weekly', ['--type' => 'summary'])
            ->expectsOutput('Sending summary weekly reports...')
            ->expectsOutput('Successfully processed 3 reports.')
            ->assertExitCode(0);
    }

    public function test_dry_run_doesnt_send(): void
    {
        $this->artisan('reports:send-weekly', ['--dry-run' => true])
            ->expectsOutput('DRY RUN - no emails will be sent.')
            ->assertExitCode(0);
    }

    public function test_interactive_command(): void
    {
        $this->artisan('users:create')
            ->expectsQuestion('What is the user name?', 'John')
            ->expectsQuestion('What is the email?', '[email protected]')
            ->expectsConfirmation('Create this user?', 'yes')
            ->expectsOutput('User created successfully.')
            ->assertExitCode(0);
    }

    public function test_command_with_choice(): void
    {
        $this->artisan('deploy:assets')
            ->expectsChoice('Pick environment', 'staging', ['local', 'staging', 'production'])
            ->assertExitCode(0);
    }

    public function test_command_with_table(): void
    {
        User::factory()->create(['name' => 'John', 'email' => '[email protected]']);

        $this->artisan('users:list')
            ->expectsTable(['ID', 'Name', 'Email'], [
                [1, 'John', '[email protected]'],
            ])
            ->assertExitCode(0);
    }
}

Проверь себя

Где в Laravel 11 регистрируется расписание задач (schedule)?

Какие коды возврата определены в классе Command?

Что делает интерфейс Isolatable в Artisan-команде?

Как запустить Artisan-команду из контроллера так, чтобы она выполнилась асинхронно в очереди?

Чем отличается аргумент от опции в сигнатуре Artisan-команды?