9

Dzień 9 z 18

Komendy konsolowe

Opublikowany

Tworzymy własne komendy konsolowe: komendę tworzącą kategorię (podstawy) oraz praktyczną komendę usuwającą przeterminowane oferty. Poznamy #[AsCommand], SymfonyStyle, argumenty i opcje, wstrzykiwanie serwisów oraz harmonogram symfony/scheduler.

Czego się nauczysz

  • Tworzenie komendy: #[AsCommand], execute(): int, Command::SUCCESS
  • SymfonyStyle — czytelne wyjście (title, success, error)
  • Argumenty i opcje: addArgument, addOption
  • Wstrzykiwanie serwisów przez konstruktor
  • Interakcja z użytkownikiem ($io->ask z walidacją)
  • Komenda app:jobs:cleanup — usuwanie przeterminowanych ofert
  • Harmonogram przez symfony/scheduler

Co zmieniło się w Symfony 8 vs 4.2

  • #[AsCommand] zamiast setName()/setDescription() w configure()
  • execute(): int z Command::SUCCESS/FAILURE
  • SymfonyStyle zamiast surowego OutputInterface i tagów kolorów
  • symfony/scheduler (od 6.3) zamiast crona systemowego

Dzień 9: Komendy konsolowe

Symfony udostępnia wiele poleceń przez bin/console (np. cache:clear). Buduje je komponent Console — i my też możemy tworzyć własne komendy: do zadań administracyjnych, importów, czyszczenia danych czy uruchamianych cyklicznie (cron). Dziś stworzymy komendę tworzącą kategorię (nauka podstaw) oraz praktyczną komendę usuwającą przeterminowane oferty, którą uruchomimy z harmonogramu.

W Symfony 8 komendę definiujemy atrybutem #[AsCommand], metoda execute() zwraca kod wyjścia (Command::SUCCESS), a do wyjścia używamy SymfonyStyle — zamiast surowego writeln i ręcznych tagów kolorów.


Tworzenie komendy

Komendy to klasy rozszerzające Command, umieszczane w src/Command/. Wygeneruj szkielet:

docker compose exec app php bin/console make:command app:create-category

Maker utworzy src/Command/CreateCategoryCommand.php. Docelowo:

<?php

namespace App\Command;

use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;

#[AsCommand(
    name: 'app:create-category',
    description: 'Tworzy nową kategorię ofert.',
)]
final class CreateCategoryCommand extends Command
{
    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $io = new SymfonyStyle($input, $output);

        $io->title('Kreator kategorii');
        $io->success('Komenda działa!');

        return Command::SUCCESS;
    }
}

Uruchom:

docker compose exec app php bin/console app:create-category

Zmiana vs Symfony 4.2: nazwę i opis podajemy w atrybucie #[AsCommand] zamiast wywołań setName()/setDescription() w configure(). Metoda execute() ma typ zwracany : int i zwraca Command::SUCCESS (lub Command::FAILURE) — dawniej zwracała 0/1 bez typu.


SymfonyStyle — ładne wyjście

Zamiast surowego $output->writeln() używamy SymfonyStyle — pomocnika z gotowymi, spójnymi stylami (tytuły, sekcje, komunikaty sukcesu/błędu, tabele, pytania):

$io = new SymfonyStyle($input, $output);

$io->title('Kreator kategorii');   // duży nagłówek
$io->text('Za chwilę utworzysz kategorię.');
$io->success('Gotowe!');           // zielony komunikat
$io->error('Coś poszło nie tak.'); // czerwony komunikat
$io->warning('Uwaga!');

Zmiana vs Symfony 4.2: oryginał kolorował tekst ręcznie tagami (<fg=green>…</>, <info>, <error>). SymfonyStyle daje spójny wygląd bez ręcznego formatowania — to dziś standard.


Argumenty i opcje

Aby przekazać dane do komendy, definiujemy argumenty (pozycyjne) i opcje (--flaga) w metodzie configure():

use Symfony\Component\Console\Input\InputArgument;

protected function configure(): void
{
    $this->addArgument('name', InputArgument::REQUIRED, 'Nazwa kategorii.');
}

Wartość odczytujemy w execute():

$name = $input->getArgument('name');
$io->text(sprintf('Nazwa: %s', $name));

Teraz:

docker compose exec app php bin/console app:create-category Tester

Wstrzykiwanie serwisów

Logikę tworzenia kategorii wydzielamy do serwisu. Utwórz src/Service/CategoryService.php (slug policzy się automatycznie w lifecycle callbacku z Dnia 7):

<?php

namespace App\Service;

use App\Entity\Category;
use Doctrine\ORM\EntityManagerInterface;

class CategoryService
{
    public function __construct(private readonly EntityManagerInterface $em) {}

    public function create(string $name): Category
    {
        $category = (new Category())->setName($name);

        $this->em->persist($category);
        $this->em->flush();

        return $category;
    }
}

Komenda jest zarejestrowana jako serwis (dzięki #[AsCommand] i autoconfigure), więc zależności wstrzykujemy przez konstruktor — pamiętając o parent::__construct():

use App\Service\CategoryService;

#[AsCommand(name: 'app:create-category', description: 'Tworzy nową kategorię ofert.')]
final class CreateCategoryCommand extends Command
{
    public function __construct(private readonly CategoryService $categoryService)
    {
        parent::__construct();
    }

    protected function configure(): void
    {
        $this->addArgument('name', InputArgument::REQUIRED, 'Nazwa kategorii.');
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $io = new SymfonyStyle($input, $output);

        $category = $this->categoryService->create($input->getArgument('name'));

        $io->success(sprintf('Utworzono kategorię "%s" (slug: %s).', $category->getName(), $category->getSlug()));

        return Command::SUCCESS;
    }
}

Zmiana vs Symfony 4.2: zależności wstrzykujemy przez constructor promotion (PHP 8). Rejestracja komendy jest automatyczna dzięki atrybutowi #[AsCommand] — nie trzeba tagów w services.yaml.


Interakcja z użytkownikiem

Gdy argument nie zostanie podany, możemy o niego zapytać. SymfonyStyle ma metodę ask() z walidacją — zastępuje ona ręczne użycie helpera Question i metody interact():

protected function interact(InputInterface $input, OutputInterface $output): void
{
    if (null === $input->getArgument('name')) {
        $io = new SymfonyStyle($input, $output);

        $name = $io->ask('Podaj nazwę kategorii', null, function (?string $value): string {
            if (null === $value || '' === trim($value)) {
                throw new \RuntimeException('Nazwa nie może być pusta.');
            }

            return $value;
        });

        $input->setArgument('name', $name);
    }
}

Teraz uruchomienie app:create-category bez argumentu poprosi o nazwę interaktywnie.


Cykl życia komendy

Komenda ma trzy metody wywoływane po kolei:

  • initialize() (opcjonalna) — przygotowanie zmiennych używanych dalej,
  • interact() (opcjonalna) — ostatnie miejsce, aby dopytać o brakujące argumenty/opcje,
  • execute() (wymagana) — właściwa logika; musi zwrócić kod wyjścia.

Praktyczny przykład: usuwanie przeterminowanych ofert

Zbudujmy komendę przydatną w Jobeet — usuwającą stare, przeterminowane oferty. Najpierw metoda w src/Repository/JobRepository.php:

public function deleteExpired(int $olderThanDays = 0): int
{
    $threshold = (new \DateTimeImmutable())->modify(sprintf('-%d days', $olderThanDays));

    return (int) $this->createQueryBuilder('j')
        ->delete()
        ->andWhere('j.expiresAt < :threshold')
        ->setParameter('threshold', $threshold)
        ->getQuery()
        ->execute();
}

Komenda src/Command/CleanupJobsCommand.php z opcją --days:

<?php

namespace App\Command;

use App\Repository\JobRepository;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;

#[AsCommand(
    name: 'app:jobs:cleanup',
    description: 'Usuwa przeterminowane oferty pracy.',
)]
final class CleanupJobsCommand extends Command
{
    public function __construct(private readonly JobRepository $jobs)
    {
        parent::__construct();
    }

    protected function configure(): void
    {
        $this->addOption('days', null, InputOption::VALUE_REQUIRED, 'Usuń oferty przeterminowane od co najmniej N dni', 0);
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $io = new SymfonyStyle($input, $output);

        $days = (int) $input->getOption('days');
        $count = $this->jobs->deleteExpired($days);

        $io->success(sprintf('Usunięto %d przeterminowanych ofert.', $count));

        return Command::SUCCESS;
    }
}

Uruchom:

docker compose exec app php bin/console app:jobs:cleanup
docker compose exec app php bin/console app:jobs:cleanup --days=7

Harmonogram (symfony/scheduler)

Taka komenda powinna działać cyklicznie. Można ją wywołać systemowym cronem, ale Symfony ma własny Scheduler — harmonogram opisany w kodzie. Zainstaluj komponent:

docker compose exec app composer require symfony/scheduler

Zdefiniuj harmonogram w src/Scheduler/JobsSchedule.php — wiadomość uruchamiana codziennie o 3:00:

<?php

namespace App\Scheduler;

use App\Scheduler\Message\CleanupExpiredJobs;
use Symfony\Component\Scheduler\Attribute\AsSchedule;
use Symfony\Component\Scheduler\RecurringMessage;
use Symfony\Component\Scheduler\Schedule;
use Symfony\Component\Scheduler\ScheduleProviderInterface;

#[AsSchedule]
final class JobsSchedule implements ScheduleProviderInterface
{
    public function getSchedule(): Schedule
    {
        return (new Schedule())->add(
            RecurringMessage::cron('0 3 * * *', new CleanupExpiredJobs()),
        );
    }
}

Wiadomość to prosty znacznik (src/Scheduler/Message/CleanupExpiredJobs.php):

<?php

namespace App\Scheduler\Message;

final class CleanupExpiredJobs {}

A jej handler wywołuje tę samą metodę repozytorium co komenda:

<?php

namespace App\Scheduler\Handler;

use App\Repository\JobRepository;
use App\Scheduler\Message\CleanupExpiredJobs;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;

#[AsMessageHandler]
final class CleanupExpiredJobsHandler
{
    public function __construct(private readonly JobRepository $jobs) {}

    public function __invoke(CleanupExpiredJobs $message): void
    {
        $this->jobs->deleteExpired();
    }
}

Harmonogram obsługuje worker Messengera (transport scheduler_default):

docker compose exec app php bin/console messenger:consume scheduler_default

Zmiana vs Symfony 4.2: symfony/scheduler (od Symfony 6.3) to nowość — pozwala opisać harmonogram w kodzie zamiast konfigurować crona systemowego. Logikę trzymamy w repozytorium, więc współdzielą ją komenda i handler.


Podsumowanie

Umiemy tworzyć komendy konsolowe:

  • ✅ definicja atrybutem #[AsCommand], execute(): int z Command::SUCCESS,
  • ✅ czytelne wyjście przez SymfonyStyle (bez ręcznych tagów kolorów),
  • ✅ argumenty i opcje (addArgument, addOption),
  • ✅ wstrzykiwanie serwisów przez konstruktor (automatyczna rejestracja komendy),
  • ✅ interakcja z użytkownikiem ($io->ask() z walidacją),
  • ✅ praktyczna komenda app:jobs:cleanup + harmonogram symfony/scheduler.

W Dniu 10 zbudujemy panel administracyjny — ręczny CRUD ofert i kategorii (zgodnie z konwencją tego projektu), z formularzami Symfony, paginacją i ochroną CSRF.

Narzędzia / paczki

symfony/console symfony/scheduler

Spis treści