Writing a Command

A command is a class with #[AsCommand] and an __invoke method that returns an exit code; Symfony finds it and builds it for you.

Syntax#[AsCommand(name: 'app:do-thing')] class DoThingCommand { public function __invoke(): int }

A command is a class. Put the #[AsCommand] attribute on it, give it an __invoke() method that returns an integer, and save it under src/Command/. It then appears in php bin/console list under the name you chose.

Why it is a service

A service is an object the framework creates and keeps for you rather than one you build with new. Your command class is registered as a service automatically, which is why you can declare a constructor and receive working objects in it without writing any wiring. Stage 2 covers how that happens. Two consequences matter now: the class must live somewhere the service configuration scans (src/ by default), and Symfony reads the name out of the attribute without creating the object, so a command is only instantiated when it is actually run.

Exit codes

The integer you return is the process exit status. Command::SUCCESS is 0, Command::FAILURE is 1, and Command::INVALID is 2, meaning the user called it wrongly. An uncaught exception also exits non-zero. Shell scripts, CI and cron read that number, so return FAILURE on failure rather than printing a message and returning success.

Two ways to write the same command

The short form is the invokable class above, which needs no base class; it was introduced in Symfony 7.3. The long form extends Command, declares input in configure() and does the work in execute(InputInterface $input, OutputInterface $output). Symfony 8.1 supports both and they produce the same command. Symfony 8.1 also added method-based commands: #[AsCommand] on several public methods of one class, so related commands can share a constructor.

Example

Example · php
<?php

// src/Command/SlugifyCommand.php

declare(strict_types=1);

namespace App\Command;

use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\String\Slugger\SluggerInterface;

#[AsCommand(
    name: 'app:slugify',
    description: 'Turns a title into a URL slug.',
    help: 'Pass the title as one argument. Quote it if it contains spaces.',
)]
class SlugifyCommand
{
    // Symfony creates this object and passes the slugger in.
    // Type-hint the interface, not a concrete class. Stage 2 explains why.
    public function __construct(
        private readonly SluggerInterface $slugger,
    ) {
    }

    // $io and $title are both filled in for you: $io because SymfonyStyle is
    // a known type, $title because of the #[Argument] attribute on it.
    public function __invoke(
        SymfonyStyle $io,
        #[Argument('The title to convert')]
        string $title,
    ): int {
        $slug = (string) $this->slugger->slug($title)->lower();

        if ('' === $slug) {
            $io->error('That title produced an empty slug.');

            return Command::FAILURE; // exit status 1
        }

        $io->writeln($slug);

        return Command::SUCCESS; // exit status 0
    }
}

// $ php bin/console app:slugify "Héllo Wörld"
// hello-world

When to use it

  • Add a one-off maintenance task to a project without exposing it as a URL.
  • Wrap a deploy step so CI can run it and check the exit status.
  • Give operations staff a safe entry point for a job that would otherwise be a hand-written script.

More examples

The same command, written the long way

This is the same command as the primary example: same name, same argument, same output. The invokable form is shorthand for exactly this. Both are supported in Symfony 8.1, and most existing projects and bundles are written this way, so you need to be able to read it. Extending `Command` is still the right choice when you want the lifecycle hooks `initialize()` and `interact()`, which run before the main method and let you prompt for a missing argument.

Example · php
<?php

// src/Command/SlugifyCommand.php

declare(strict_types=1);

namespace App\Command;

use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\String\Slugger\SluggerInterface;

#[AsCommand(name: 'app:slugify', description: 'Turns a title into a URL slug.')]
class SlugifyCommand extends Command
{
    public function __construct(
        private readonly SluggerInterface $slugger,
    ) {
        // extending Command means its constructor must run
        parent::__construct();
    }

    protected function configure(): void
    {
        $this->addArgument('title', InputArgument::REQUIRED, 'The title to convert');
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $io = new SymfonyStyle($input, $output);
        $slug = (string) $this->slugger->slug($input->getArgument('title'))->lower();

        $io->writeln($slug);

        return Command::SUCCESS;
    }
}

Exit codes are the contract

Three distinct results, three distinct numbers. The `&& echo "ok"` in the output block shows why it matters: the shell only runs the second half because the exit status was 0. Use this whenever a command runs inside a deploy script, a Makefile or a CI job, where a command that prints an error and returns SUCCESS looks like a passing step.

Example · php
<?php

// src/Command/CheckUrlCommand.php

declare(strict_types=1);

namespace App\Command;

use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Style\SymfonyStyle;

#[AsCommand(name: 'app:check-url', description: 'Fails if a URL is not well formed.')]
class CheckUrlCommand
{
    public function __invoke(
        SymfonyStyle $io,
        #[Argument('The URL to check')]
        string $url,
    ): int {
        if (!str_starts_with($url, 'https://')) {
            // the caller passed something unusable: usage error, not a failure
            $io->error('The URL must start with https://');

            return Command::INVALID; // exit status 2
        }

        if (false === filter_var($url, \FILTER_VALIDATE_URL)) {
            $io->error('That is not a valid URL.');

            return Command::FAILURE; // exit status 1
        }

        $io->success('URL looks fine.');

        return Command::SUCCESS; // exit status 0
    }
}

// $ php bin/console app:check-url https://example.com/ && echo "ok"
//
//  [OK] URL looks fine.
//
// ok

Registering a command without the attribute

`#[AsCommand]` is shorthand for this tag. You rarely write it, because a default project already applies it for you, but you will meet it in two places: legacy code written before attributes, and classes you cannot add attributes to because they come from a third-party package. Note that Symfony 8.1 deprecated the old alternative of registering commands by overriding `Bundle::registerCommands()`.

Example · yaml
# config/services.yaml
services:
    App\Command\SlugifyCommand:
        tags:
            # the 'command' key lets Symfony learn the name without
            # creating the object, which keeps the command lazy
            - { name: 'console.command', command: 'app:slugify' }

Discussion

  • Be the first to comment on this lesson.