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.

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 0 alone, 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 setStoragePageIds() . A repository method can take them as an argument, so the caller decides:

EXT:my_extension/Classes/Domain/Repository/ConferenceRepository.php
<?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();
  }
}
Copied!

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\CMS\Core\Domain\Repository\PageRepository::getPageIdsRecursive() , for example from a folder and a depth given on the command line:

EXT:my_extension/Classes/Command/UnpublishPastConferencesCommand.php
<?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;
  }
}
Copied!

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 setPid() . 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 persistAll() of the persistence manager:

EXT:my_extension/Classes/Command/UnpublishPastConferencesCommand.php
<?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;
  }
}
Copied!

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.tx_<extension> settings cannot be used in a command. Reading the configuration throws a \TYPO3\CMS\Extbase\Configuration\Exception\NoServerRequestGivenException . 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.

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.