Extbase on the command line
Commands are how an extension does work on a schedule or on demand: importing data, sending notifications, rebuilding derived records, cleaning up. Reusing the domain models and repositories an extension already has keeps that work expressed in the same terms as the rest of the extension.
A command runs without a request. Extbase reads its configuration from the request, so in a command it has none: no TypoScript, no storagePid, no site and no language. Part of Extbase copes with that, and part of it does not. This page shows what a command can use, what it has to supply itself, and when another API is the better fit.
How to write and register the command itself is described in Symfony commands in TYPO3.
Table of contents
What Extbase persistence does without a request
Repositories work in a command. The persistence layer is deliberately allowed to run without a request and falls back to fixed defaults where it would otherwise read configuration:
- The storagePid is `0`. Queries search page
0alone, which for most tables holds no records. A table whose records are stored at the root level is not restricted to pages at all, so its queries find records anyway. - The language is the default language, see Localization outside the frontend context.
- Hidden records are left out, and so are records outside their start and end time. Restrictions to frontend user groups do not apply, as there is no frontend user.
- New records are stored on page `0`, unless the object names a page itself.
A command that works with records in a folder therefore names that folder. The following sections show how.
Telling an Extbase repository where to look from a command
The pages a query searches can be set on its query settings with
set. A repository method can take them as an argument,
so the caller decides:
<?php
declare(strict_types=1);
namespace MyVendor\MyExtension\Domain\Repository;
use MyVendor\MyExtension\Domain\Model\Conference;
use TYPO3\CMS\Extbase\Persistence\QueryResultInterface;
use TYPO3\CMS\Extbase\Persistence\Repository;
class ConferenceRepository extends Repository
{
/**
* @param int[] $storagePageIds
* @return QueryResultInterface<Conference>
*/
public function findPublishedBefore(
\DateTimeImmutable $date,
array $storagePageIds,
): QueryResultInterface {
$query = $this->createQuery();
$query->getQuerySettings()->setStoragePageIds($storagePageIds);
$query->matching(
$query->logicalAnd(
$query->equals('published', true),
$query->lessThan('conferenceDate', $date),
),
);
return $query->execute();
}
}
The pages are used exactly as given. The
recursive
setting that
adds subpages in a plugin is applied while Extbase reads its configuration,
which does not happen in a command. To include subpages, a command can
expand the list with
\TYPO3\,
for example from a folder and a depth given on the command line:
<?php
declare(strict_types=1);
namespace MyVendor\MyExtension\Command;
use MyVendor\MyExtension\Domain\Repository\ConferenceRepository;
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\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use TYPO3\CMS\Core\Domain\Repository\PageRepository;
use TYPO3\CMS\Extbase\Persistence\PersistenceManagerInterface;
#[AsCommand(
name: 'myextension:unpublishpast',
description: 'Unpublish conferences that have taken place',
)]
class UnpublishPastConferencesCommand extends Command
{
public function __construct(
protected readonly ConferenceRepository $conferenceRepository,
protected readonly PersistenceManagerInterface $persistenceManager,
protected readonly PageRepository $pageRepository,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument(
'storagePid',
InputArgument::REQUIRED,
'Folder that stores the conferences',
)
->addOption(
'depth',
null,
InputOption::VALUE_REQUIRED,
'Number of page levels below the folder to include',
'0',
);
}
protected function execute(
InputInterface $input,
OutputInterface $output,
): int {
$storagePageIds = $this->pageRepository->getPageIdsRecursive(
[(int)$input->getArgument('storagePid')],
(int)$input->getOption('depth'),
);
$conferences = $this->conferenceRepository->findPublishedBefore(
new \DateTimeImmutable('today'),
$storagePageIds,
);
$count = 0;
foreach ($conferences as $conference) {
$conference->setPublished(false);
$this->conferenceRepository->update($conference);
$count++;
}
// No Extbase request ends here, so nothing else writes the changes
$this->persistenceManager->persistAll();
$output->writeln(sprintf('%d conferences unpublished.', $count));
return Command::SUCCESS;
}
}
Other ways to pass the folder work just as well, for example a value from the extension configuration. More on building the page list is in Building the storagePid array from editor input, and on the query settings in Overriding the storagePid for a single query.
A command that creates records can set the page on each new object with
set. Otherwise Extbase stores it on page 0.
Choosing the language in an Extbase command
A command has no site and no language, so its queries use the default language. A command that works with translated records can build a language aspect for the language it needs and pass it to the query settings. This is described in Setting the language explicitly.
Saving changes from an Extbase command with persistAll()
In a plugin or a backend module, Extbase writes all changes at the end of the
request. A command is not such a request, and nothing writes the changes for
it. Changed, added and removed objects stay in memory until the command calls
persist of the persistence manager:
<?php
declare(strict_types=1);
namespace MyVendor\MyExtension\Command;
use MyVendor\MyExtension\Domain\Repository\ConferenceRepository;
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\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use TYPO3\CMS\Core\Domain\Repository\PageRepository;
use TYPO3\CMS\Extbase\Persistence\PersistenceManagerInterface;
#[AsCommand(
name: 'myextension:unpublishpast',
description: 'Unpublish conferences that have taken place',
)]
class UnpublishPastConferencesCommand extends Command
{
public function __construct(
protected readonly ConferenceRepository $conferenceRepository,
protected readonly PersistenceManagerInterface $persistenceManager,
protected readonly PageRepository $pageRepository,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument(
'storagePid',
InputArgument::REQUIRED,
'Folder that stores the conferences',
)
->addOption(
'depth',
null,
InputOption::VALUE_REQUIRED,
'Number of page levels below the folder to include',
'0',
);
}
protected function execute(
InputInterface $input,
OutputInterface $output,
): int {
$storagePageIds = $this->pageRepository->getPageIdsRecursive(
[(int)$input->getArgument('storagePid')],
(int)$input->getOption('depth'),
);
$conferences = $this->conferenceRepository->findPublishedBefore(
new \DateTimeImmutable('today'),
$storagePageIds,
);
$count = 0;
foreach ($conferences as $conference) {
$conference->setPublished(false);
$this->conferenceRepository->update($conference);
$count++;
}
// No Extbase request ends here, so nothing else writes the changes
$this->persistenceManager->persistAll();
$output->writeln(sprintf('%d conferences unpublished.', $count));
return Command::SUCCESS;
}
}
Without the call, the command reports success and the database stays unchanged. More about when Extbase writes is described in Forcing a write with persistAll().
What fails in an Extbase command
Everything outside the persistence layer depends on the request. Controllers,
views, and anything that reads
plugin. settings
cannot be used in a command. Reading the configuration throws a
\TYPO3\.
Catching it does not help, because the configuration the code needs is not
there.
Failing is the better outcome here. A controller that ran without its configuration would produce output that only looks right.
Warning
Some commands set
$GLOBALS to a request they build
themselves, for example to generate links or to render a Fluid email.
Extbase then reads its configuration from that request, and the
persistence layer no longer falls back to its defaults:
- With a simulated frontend request, every repository query throws an exception, unless the request carries the full frontend TypoScript.
- With a simulated backend request, queries include hidden records and records outside their start and end time, as they do in a backend module.
Pass a simulated request to the API that needs it rather than storing it in the global, see Simulating a frontend request in TYPO3 commands.
When the database API fits a command better
Extbase is worth its cost in a command when the command works with the domain: it applies the logic of the models, follows their relations, and uses the repository methods the rest of the extension uses.
A command that only reads or writes rows does not need any of that. The database API does the same work without objects, without a storagePid, and without anything to establish first. It is the better choice for bulk changes, such as updating thousands of rows in one statement. A change that editors should see in the record history, or that should trigger the hooks of other extensions, belongs in DataHandler instead.
Next steps for Extbase on the command line
A middleware runs inside a request, but before Extbase gets its configuration, so it meets the same defaults as a command. See Extbase in middlewares.
The overview of all contexts without a frontend is in Using Extbase outside a frontend request.