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.
On this page
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->conferenceand receive a collection of fully populatedRepository->find All () Conferenceobjects. - Writing: you call
$this->conferenceand the ORM generates the INSERT statement.Repository->add ($conference) - Updating: you modify properties or relations on the object and call
$this->conference.Repository->update ($conference) - Deleting: you call
$this->conference.Repository->remove ($conference)
A controller action that creates and stores a new conference looks like this:
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');
}
}
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\,
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
Property names map to column names by converting camelCase to snake_case:
$conferenceDate → conference_date
$speakerCount → speaker_count
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\ and
is named after its model —
Conference for
Conference. The base
class provides
find,
find,
find, and
find 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:
plugin.tx_myextension.persistence.storagePid = 42
Or per plugin instance:
plugin.tx_myextension_eventlist.persistence.storagePid = 42
Editors can also set it per plugin content element via the Record Storage Page field in the plugin's properties.
Tip
If your repository returns nothing and the records exist in the database,
check the storagePid first. Enable TYPO3's query logging or use the Extbase
query debugger to see the exact SQL being executed and which page restriction
is applied. If records are stored in subfolders below the storagePid page,
also check the
persistence. setting — without it,
Extbase only looks at the configured page itself, not its children.
To disable the storagePid restriction entirely — for example in a backend
context or when querying across all pages — set it to
0:
plugin.tx_myextension.persistence.storagePid = 0
Or override it in PHP inside a repository method:
$query = $this->createQuery();
$query->getQuerySettings()->setRespectStoragePage(false);
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->redirectafter() add, the redirect fires before the automatic flush, so the new object is never written. Call() $this->persistencebefore the redirect.Manager->persist All () - When you need the new UID — a freshly created object has no UID until
it is persisted. Call
persistand then readAll () $object->get.Uid ()
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]);
}
}
See also
Extbase domain model — defining models and their properties.
Extbase repository — writing custom repository queries.
Persistence queries — the full query API, storagePid deep-dive, and when to use DBAL instead.