TYPO3

Symfony console commands in TYPO3 v14 with #[AsCommand]

5 min read

CLI commands the modern way

Hi guys,

Every TYPO3 project I touch sooner or later needs a CLI command. A data import from a partner system, a cleanup after a migration, a report for an editor. In the old days that meant a scheduler task class with a lot of boilerplate, later an Extbase command controller. Today a TYPO3 command is just a Symfony console command, and in TYPO3 v14 you need almost no configuration for it.

This post builds one small but real command, step by step, and ends with a functional test. The example comes from my hoja.in migration: after importing old content into TYPO3 v14, I wanted to be sure that no “External link” page is left without a link target (the old pages.url field was replaced by pages.link, Breaking #17406). The command lists such pages and can hide them.

Step 1: Services.yaml with autoconfiguration

Your extension needs the usual Configuration/Services.yaml. With autoconfigure: true, every class that carries #[AsCommand] is registered as a console command. No extra tag needed.

# EXT:sitepackage/Configuration/Services.yaml
services:
  _defaults:
    autowire: true
    autoconfigure: true
    public: false

  HarvisoftSitepackage:
    resource: '../Classes/*'

The Symfony attribute is accepted by TYPO3 since 12.4 (Important #101567). Before that you had to tag every command in Services.yaml with console.command. The old way still works, but there is no reason to write it for new code.

Step 2: the command class

<?php

declare(strict_types=1);

namespace HarvisoftSitepackageCommand;

use SymfonyComponentConsoleAttributeAsCommand;
use SymfonyComponentConsoleCommandCommand;
use SymfonyComponentConsoleInputInputInterface;
use SymfonyComponentConsoleInputInputOption;
use SymfonyComponentConsoleOutputOutputInterface;
use SymfonyComponentConsoleStyleSymfonyStyle;
use TYPO3CMSCoreDatabaseConnection;
use TYPO3CMSCoreDatabaseConnectionPool;
use TYPO3CMSCoreDomainRepositoryPageRepository;

#[AsCommand(
    name: 'sitepackage:links:hide-empty',
    description: 'Hides "External link" pages that have no link target.',
)]
final class HideEmptyLinkPagesCommand extends Command
{
    public function __construct(private readonly ConnectionPool $connectionPool)
    {
        parent::__construct();
    }

    protected function configure(): void
    {
        $this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Only report, change nothing');
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $io = new SymfonyStyle($input, $output);
        $rows = $this->findLinkPagesWithoutTarget();

        if ($rows === []) {
            $io->success('Every external link page has a target.');
            return Command::SUCCESS;
        }

        $io->table(['uid', 'title'], array_map(
            static fn(array $row): array => [$row['uid'], $row['title']],
            $rows
        ));

        if ($input->getOption('dry-run')) {
            $io->note(sprintf('%d page(s) would be hidden.', count($rows)));
            return Command::SUCCESS;
        }

        $connection = $this->connectionPool->getConnectionForTable('pages');
        $io->progressStart(count($rows));
        foreach ($rows as $row) {
            $connection->update('pages', ['hidden' => 1], ['uid' => (int)$row['uid']]);
            $io->progressAdvance();
        }
        $io->progressFinish();
        $io->success(sprintf('%d page(s) hidden.', count($rows)));

        return Command::SUCCESS;
    }

    /**
     * @return list<array{uid: int, title: string}>
     */
    private function findLinkPagesWithoutTarget(): array
    {
        $queryBuilder = $this->connectionPool->getQueryBuilderForTable('pages');
        // Default restrictions already skip deleted and hidden pages
        return $queryBuilder
            ->select('uid', 'title')
            ->from('pages')
            ->where(
                $queryBuilder->expr()->eq(
                    'doktype',
                    $queryBuilder->createNamedParameter(PageRepository::DOKTYPE_LINK, Connection::PARAM_INT)
                ),
                $queryBuilder->expr()->eq('link', $queryBuilder->createNamedParameter(''))
            )
            ->orderBy('uid')
            ->executeQuery()
            ->fetchAllAssociative();
    }
}

Some decisions in there:

  • Constructor injection. ConnectionPool comes through the constructor, no GeneralUtility::makeInstance(). Do not forget parent::__construct(), otherwise Symfony never calls configure() and your options are missing.
  • A --dry-run option. Every command that changes data should have one. You run it first on production, read the table, and only then run it for real.
  • SymfonyStyle. table(), progressStart(), success() and note() give readable output for free. When the scheduler runs the command, the output is not shown to anybody, so the return code is what counts. Return Command::FAILURE when something went wrong, never just print a red line.
  • Direct database writes. For a flag like hidden on a handful of records this is fine, but it bypasses the DataHandler: no history entry, no cache clearing, no hooks. When editors must see the change in the history or other extensions react to it, use the DataHandler instead; on the CLI that needs a backend user, which you get with Bootstrap::initializeBackendAuthentication().

Step 3: run it

After a cache flush the command is listed under its namespace:

ddev exec vendor/bin/typo3 cache:flush
ddev exec vendor/bin/typo3 list sitepackage
ddev exec vendor/bin/typo3 sitepackage:links:hide-empty --dry-run

Step 4: the scheduler

If EXT:scheduler is installed, every command is schedulable by default. You create a task of type “Execute console commands” and pick your command in the “Schedulable Command” field; arguments and options can be set after saving the task once. That is all, no separate task class.

Some commands should never run unattended, for example an import that needs a fresh file from a person. Before v14 you had to go back to the Services.yaml tag and set schedulable: false. TYPO3 v14.0 added an attribute for this (Feature #107151):

use SymfonyComponentConsoleAttributeAsCommand;
use TYPO3CMSCoreAttributeAsNonSchedulableCommand;

#[AsCommand('sitepackage:import:partners', 'Imports the partner list from a CSV file')]
#[AsNonSchedulableCommand]
final class ImportPartnersCommand extends Command
{
}

The core uses it itself: extension:setup carries #[AsNonSchedulableCommand]. If you do not use the scheduler at all, a plain cron entry with vendor/bin/typo3 sitepackage:links:hide-empty works just as well. On hoja.in there is no scheduler installed, so that would be the way.

Step 5: a functional test

A command that changes data deserves a test with a real database. The TYPO3 testing framework (version 9 supports v13 and v14) gives you a fresh instance per test case. SymfonyComponentConsoleTesterCommandTester runs the command without a shell. Commands are public services in TYPO3, so the container hands you the real, injected object:

<?php

declare(strict_types=1);

namespace HarvisoftSitepackageTestsFunctionalCommand;

use HarvisoftSitepackageCommandHideEmptyLinkPagesCommand;
use PHPUnitFrameworkAttributesTest;
use SymfonyComponentConsoleTesterCommandTester;
use TYPO3TestingFrameworkCoreFunctionalFunctionalTestCase;

final class HideEmptyLinkPagesCommandTest extends FunctionalTestCase
{
    protected array $testExtensionsToLoad = ['harvisoft/sitepackage'];

    #[Test]
    public function dryRunChangesNothing(): void
    {
        $this->importCSVDataSet(__DIR__ . '/Fixtures/pages.csv');
        $tester = new CommandTester($this->get(HideEmptyLinkPagesCommand::class));

        $tester->execute(['--dry-run' => true]);

        $tester->assertCommandIsSuccessful();
        self::assertStringContainsString('1 page(s) would be hidden', $tester->getDisplay());
        $this->assertCSVDataSet(__DIR__ . '/Fixtures/pages.csv');
    }

    #[Test]
    public function hidesOnlyLinkPagesWithoutTarget(): void
    {
        $this->importCSVDataSet(__DIR__ . '/Fixtures/pages.csv');
        $tester = new CommandTester($this->get(HideEmptyLinkPagesCommand::class));

        $tester->execute([]);

        $tester->assertCommandIsSuccessful();
        $this->assertCSVDataSet(__DIR__ . '/Fixtures/pages_hidden.csv');
    }
}

The fixture has one healthy link page and one broken one:

"pages"
,"uid","pid","doktype","title","link","hidden"
,1,0,1,"Home","",0
,2,1,3,"Blog","/blog/",0
,3,1,3,"Old link","",0

pages_hidden.csv is the same file with hidden set to 1 on uid 3. The first test is the one I care about most: a dry run that writes something is a nasty surprise on production.

Summary

  1. Put #[AsCommand] on the class and keep autoconfigure: true in Services.yaml.
  2. Inject services through the constructor and call parent::__construct().
  3. Add --dry-run to everything that writes.
  4. Return proper exit codes, because the scheduler and cron only see those.
  5. Mark commands that must not run unattended with #[AsNonSchedulableCommand] (v14).
  6. Test with CommandTester and CSV fixtures.

Cheers!

Further reading

Leave a comment

Your email address will not be published.