Своя artisan-команда в Laravel: где спотыкаются новички
Разбираем создание artisan-команды в Laravel 11.x: как правильно объявлять аргументы, не ловить ошибки с dependency injection и не превращать handle() в километровый скрипт.

Вы написали команду php artisan report:generate, запустили её по крону, а через неделю обнаружили, что она молча падает на проде: аргумент передан не в том формате, зависимость не подтянулась через конструктор, а весь код склеен в один метод handle() на двести строк. Разберём, как объявлять и писать artisan-команды в Laravel 11.x так, чтобы они переживали ревью и работу в фоне. Формат — практика: несколько типичных ошибок, у каждой цена и рабочее решение.
Как объявляется команда и где начинаются проблемы
Скелет команды создаётся через генератор:
php artisan make:command GenerateReport
Файл появится в app/Console/Commands/GenerateReport.php. В Laravel 11.x регистрация ручного массива $commands в Kernel больше не нужна — команды из этой директории подхватываются автоматически. Если вы держите команды в другом каталоге, вызовите ->withCommands() в bootstrap/app.php. Проверять наличие команды удобно через php artisan list: если её там нет, дело почти всегда в неправильной сигнатуре $signature.
Ошибка 1. Сигнатура написана «на глаз»
Самое частое — синтаксис $signature. Аргументы и опции пишутся строго по формату Symfony Console, и одна лишняя скобка ломает регистрацию всей команды без внятной ошибки.
class GenerateReport extends Command
{
// period — обязательный аргумент
// --format — опция со значением и дефолтом
// --dry — флаг без значения
protected $signature = 'report:generate
{period : Период в формате YYYY-MM}
{--format=pdf : Формат вывода}
{--dry : Не сохранять, только показать}';
protected $description = 'Генерирует отчёт за период';
public function handle(): int
{
$period = $this->argument('period');
$format = $this->option('format');
$isDry = $this->option('dry'); // bool
$this->info("Отчёт за {$period}, формат {$format}");
return self::SUCCESS;
}
}
Ключевые тонкости, на которых спотыкаются:
- Обязательный аргумент — просто
{name}. Необязательный —{name?}. С дефолтом —{name=guest}. - Опция-флаг (
{--dry}) возвращаетtrue/false. Опция со значением обязательно с=:{--format=pdf}. Перепутаете —option('format')вернёт не то, что ждёте. - Массив значений —
{--id=*}, тогда--id=1 --id=2придёт как массив.
Цена ошибки: команда либо не регистрируется вовсе, либо тихо получает null вместо значения, и логика уходит не туда. Проверяйте сигнатуру запуском php artisan report:generate --help — Laravel сам выведет разобранный список аргументов и опций.
Ошибка 2. Возврат из handle() игнорируется
Метод handle() должен вернуть код завершения. Если вы не вернёте ничего, Laravel посчитает выполнение успешным. Для крона и CI это критично: скрипт упал по логике, а планировщик и пайплайн видят exit 0 и идут дальше.
Команда, которая всегда «успешна», хуже команды, которой нет: она маскирует поломку до тех пор, пока последствия не станут дорогими.
Используйте константы self::SUCCESS (0), self::FAILURE (1) и self::INVALID (2) вместо магических чисел. При ошибке — return self::FAILURE, чтобы вызывающая сторона получила ненулевой код.
Зависимости: конструктор против handle()
Вторая большая группа ошибок — как получить сервисы внутри команды. Многие тянут всё через app() или фасады прямо в теле метода, теряя контроль над зависимостями и усложняя тесты.
Ошибка 3. Инъекция в конструктор вместо handle()
Конструктор команды вызывается при регистрации всех команд — то есть на каждый запуск artisan, включая php artisan list. Тяжёлая зависимость в конструкторе означает, что она инстанцируется, даже когда команда не выполняется. Laravel умеет резолвить зависимости прямо в handle() через контейнер — так и делайте.
use App\Services\ReportBuilder;
public function handle(ReportBuilder $builder): int
{
// ReportBuilder придёт из контейнера автоматически
$report = $builder->build($this->argument('period'));
$this->line("Готово: {$report->id}");
return self::SUCCESS;
}
В конструкторе оставляйте только то, что нужно самой команде на этапе регистрации (обычно — ничего). Не забывайте вызвать parent::__construct(), если конструктор всё же переопределяете, иначе получите ошибку инициализации базового класса.
Прогресс, вывод и взаимодействие
Ошибка 4. echo и var_dump вместо методов вывода
Прямой echo ломает форматирование и не учитывает уровень verbosity. У команды есть свои методы, и они пишут в правильный поток:
$this->info()— зелёный текст, для успеха;$this->error()— красный, пишет в stderr;$this->warn(),$this->line(),$this->comment()— остальные уровни;$this->table(['ID', 'Сумма'], $rows)— таблица;$this->withProgressBar($items, fn ($item) => ...)— прогресс-бар для долгих операций.
Для интерактивного ввода — $this->ask(), $this->confirm(), $this->secret(). Важный нюанс: в неинтерактивном режиме (крон, CI) эти вызовы зависнут в ожидании ввода. Если команда может запускаться в фоне — обходитесь без вопросов или проверяйте $this->option('no-interaction') и подставляйте дефолты.
Ошибка 5. Вся логика внутри handle()
Команда — это точка входа, а не место для бизнес-логики. Когда handle() разрастается, его невозможно ни протестировать без запуска artisan, ни переиспользовать из очереди или контроллера. Держите в команде разбор аргументов и вызов сервиса, а работу выносите в отдельный класс.
Сравнение подходов к получению зависимостей
| Подход | Плюс | Минус | Когда уместно |
|---|---|---|---|
Инъекция в handle() | Резолвится только при запуске, легко мокать в тестах | Нет для «глобальных» команд без зависимостей | По умолчанию для большинства команд |
| Инъекция в конструктор | Одна зависимость на всё тело класса | Инстанцируется на каждый artisan-вызов | Только лёгкие зависимости, которые нужны всегда |
app(Service::class) в теле | Быстро написать | Скрытая зависимость, тяжело тестировать | Разовые скрипты, не для проды |
Тестирование команды
Команду можно вызвать из теста без запуска процесса — через artisan() и цепочку ожиданий:
public function test_report_generates(): void
{
$this->artisan('report:generate', ['period' => '2024-05'])
->expectsOutput('Готово: 1')
->assertExitCode(0);
}
Если команда задаёт вопросы — их эмулируют через expectsQuestion(). Именно поэтому вынесенная логика и корректный код возврата окупаются: тест проверяет assertExitCode, а не гадает по выводу в консоль.
Что сделать после
Когда команда готова, добавьте её в планировщик через bootstrap/app.php с помощью ->withSchedule() и метода $schedule->command(). Обязательно навесьте withoutOverlapping(), если запуск может пересечься с предыдущим, и onFailure() для оповещений. Проверьте, что команда возвращает ненулевой код при ошибке — иначе мониторинг крона не заметит поломку. Точный синтаксис регистрации расписания в Laravel 11.x сверяйте по разделу Scheduling официальной документации, он менялся относительно Laravel 10.x.
Сочинение: Тема, тезис, аргументы, готовый текст нужного объёма
Спросить за 12 ₽Источники: Laravel — Artisan Console (документация), Laravel — Task Scheduling (документация)
Частые вопросы
У меня Laravel 10.x, а не 11.x — что изменится?
Почему моя команда не появляется в php artisan list?
Команда зависает при запуске по крону — в чём причина?
Можно ли вызвать одну команду из другой?
Инъекция в handle() не работает — приходит ошибка резолва. Что не так?
Стоит ли класть бизнес-логику прямо в команду?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.