
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.
ConnectionPoolcomes through the constructor, noGeneralUtility::makeInstance(). Do not forgetparent::__construct(), otherwise Symfony never callsconfigure()and your options are missing. - A
--dry-runoption. 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()andnote()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. ReturnCommand::FAILUREwhen something went wrong, never just print a red line. - Direct database writes. For a flag like
hiddenon 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 withBootstrap::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-runStep 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","",0pages_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
- Put
#[AsCommand]on the class and keepautoconfigure: trueinServices.yaml. - Inject services through the constructor and call
parent::__construct(). - Add
--dry-runto everything that writes. - Return proper exit codes, because the scheduler and cron only see those.
- Mark commands that must not run unattended with
#[AsNonSchedulableCommand](v14). - Test with
CommandTesterand CSV fixtures.
Cheers!