Persistence and the Extbase ORM 

Extbase includes an ORM. The ORM's job is to translate between the relational world of database tables and the object-oriented world of PHP classes — so you work with Conference objects rather than raw database rows. SQL (SQL) is the language used to read from and write to relational databases; Extbase's persistence layer generates and executes it for you, covering the full lifecycle of your domain objects: loading records from the database, tracking changes you make to objects during a request, and writing inserts, updates, and deletes back when the request ends.

What the ORM does for you 

Without an ORM, reading a record means writing a query, iterating over result rows, and manually constructing PHP objects. Writing a record means constructing an INSERT or UPDATE statement yourself. With the Extbase ORM:

  • Reading: you call $this->conferenceRepository->findAll() and receive a collection of fully populated Conference objects.
  • Writing: you call $this->conferenceRepository->add($conference) and the ORM generates the INSERT statement.
  • Updating: you modify properties or relations on the object and call $this->conferenceRepository->update($conference).
  • Deleting: you call $this->conferenceRepository->remove($conference).

A controller action that creates and stores a new conference looks like this:

EXT:my_extension/Classes/Controller/ConferenceController.php
use MyVendor\MyExtension\Domain\Model\Conference;
use MyVendor\MyExtension\Domain\Repository\ConferenceRepository;
use Psr\Http\Message\ResponseInterface;
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;

class ConferenceController extends ActionController
{
    public function __construct(
        protected readonly ConferenceRepository $conferenceRepository,
    ) {}

    public function createAction(Conference $conference): ResponseInterface
    {
        $this->conferenceRepository->add($conference);
        return $this->redirect('list');
    }
}
Copied!

No SQL, no INSERT, no result-set iteration. The ORM handles the mapping.

The ORM also handles relations. If a Conference has many Speaker objects stored in an \TYPO3\CMS\Extbase\Persistence\ObjectStorage , Extbase loads those related objects automatically when you access the property — without you writing any JOIN.

This convenience comes with a trade-off: the ORM is optimised for working with individual domain objects, not for bulk operations or complex aggregate queries. When you need those, dropping down to raw queries and result sets directly is the right choice. That is not a failure — it is the intended design. See When to drop out of the ORM (Object Relational Mapping) for how to do this from within a repository.

How models map to the database 

Extbase derives the database table name from the model class name by convention:

Vendor\MyExtension\Domain\Model\Conference
→ tx_myextension_domain_model_conference
Copied!

Property names map to column names by converting camelCase to snake_case:

$conferenceDate  →  conference_date
$speakerCount    →  speaker_count
Copied!

These conventions work automatically. When the defaults do not fit — for example when mapping to an existing table with its own naming — you can override them in Configuration/Extbase/Persistence/Classes.php. See Table and field mapping for the full syntax and examples.

The repository as the only door to the database 

Controllers should not query the database directly, but refer everything related to persistence to a repository. This is a deliberate constraint: it keeps persistence logic in one place and keeps controllers thin.

A repository class extends \TYPO3\CMS\Extbase\Persistence\Repository and is named after its model — ConferenceRepository for Conference. The base class provides findAll(), findByUid(), findBy(array $criteria), and findOneBy(array $criteria) without any additional code. Custom queries are added as methods on the repository.

The storagePid: where Extbase looks for records 

This is the single most common source of confusion in Extbase: a repository returns no results even though the records exist in the database.

The cause is almost always the storagePid.

Extbase does not query all records in a table by default. It restricts queries to records stored on a specific TYPO3 page — the storage page (or storagePid). This mirrors how TYPO3 editors organise records: they create a sysfolder, store domain records there, and point the plugin at that folder.

If the storagePid is not configured, or points at the wrong page, the repository returns an empty result — silently.

The storagePid is configured in TypoScript:

EXT:my_extension/Configuration/Sets/MyExtension/setup.typoscript
plugin.tx_myextension.persistence.storagePid = 42
Copied!

Or per plugin instance:

EXT:my_extension/Configuration/Sets/MyExtension/setup.typoscript
plugin.tx_myextension_eventlist.persistence.storagePid = 42
Copied!

Editors can also set it per plugin content element via the Record Storage Page field in the plugin's properties.

To disable the storagePid restriction entirely — for example in a backend context or when querying across all pages — set it to 0:

EXT:my_extension/Configuration/Sets/MyExtension/setup.typoscript
plugin.tx_myextension.persistence.storagePid = 0
Copied!

Or override it in PHP inside a repository method:

EXT:my_extension/Classes/Domain/Repository/ConferenceRepository.php
$query = $this->createQuery();
$query->getQuerySettings()->setRespectStoragePage(false);
Copied!

The full storagePid resolution order and how to override it in PHP are covered in Querying the database with Extbase.

Extbase object lifecycle 

Extbase tracks the state of every domain object it loads. At the end of a request the persistence manager flushes automatically, comparing each object's current state against its original state and writing only the differences. You do not need to call a "save" method on objects you retrieved and modified — the ORM detects the change.

Objects you create with new are not tracked until you pass them to a repository with add(). Objects you pass to remove() are deleted at flush time.

In most actions the automatic flush at the end of the request is sufficient. Two cases require an earlier flush:

  • Before a redirect — if you call $this->redirect() after add(), the redirect fires before the automatic flush, so the new object is never written. Call $this->persistenceManager->persistAll() before the redirect.
  • When you need the new UID — a freshly created object has no UID until it is persisted. Call persistAll() and then read $object->getUid().
EXT:my_extension/Classes/Controller/ConferenceController.php
use MyVendor\MyExtension\Domain\Model\Conference;
use MyVendor\MyExtension\Domain\Repository\ConferenceRepository;
use Psr\Http\Message\ResponseInterface;
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;
use TYPO3\CMS\Extbase\Persistence\PersistenceManagerInterface;

class ConferenceController extends ActionController
{
    public function __construct(
        protected readonly ConferenceRepository $conferenceRepository,
        protected readonly PersistenceManagerInterface $persistenceManager,
    ) {}

    public function createAction(Conference $conference): ResponseInterface
    {
        $this->conferenceRepository->add($conference);
        $this->persistenceManager->persistAll();
        $uid = $conference->getUid();
        return $this->redirect('show', null, null, ['conference' => $uid]);
    }
}
Copied!