---
title: "Persistence and the Extbase ORM"
manual: "TYPO3 Explained"
version: "main"
permalink: "https://docs.typo3.org/permalink/t3coreapi:extbase-concepts-persistence@main"
source: "ExtensionArchitecture/Extbase/Concepts/Persistence.rst"
rendered: "2026-09-21T11:24:09+00:00"
---

# Persistence and the Extbase ORM {#extbase-concepts-persistence}

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](https://en.wikipedia.org/wiki/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](https://docs.typo3.org/permalink/t3coreapi:what-the-orm-does-for-you@main)
-   [How models map to the database](https://docs.typo3.org/permalink/t3coreapi:how-models-map-to-the-database@main)
-   [The repository as the only door to the database](https://docs.typo3.org/permalink/t3coreapi:the-repository-as-the-only-door-to-the-database@main)
-   [The storagePid: where Extbase looks for records](https://docs.typo3.org/permalink/t3coreapi:the-storagepid-where-extbase-looks-for-records@main)
-   [Extbase object lifecycle](https://docs.typo3.org/permalink/t3coreapi:extbase-object-lifecycle@main)

## What the ORM does for you {#extbase-concepts-persistence-orm}

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**

```php
<?php

declare(strict_types=1);

namespace MyVendor\MyExtension\Controller;

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\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 ORM (object relational mapping)](https://docs.typo3.org/permalink/t3coreapi:extbase-domain-repository-dbal@main) for how to do this from within a
repository.

## How models map to the database {#extbase-concepts-persistence-mapping}

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

```text
Vendor\MyExtension\Domain\Model\Conference
→ tx_myextension_domain_model_conference
```

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

```text
$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`](../../FileStructure/Configuration/Extbase/Persistence/Index.md#file-extension-configuration-extbase-persistence-classes-php). See
[Table and field mapping](https://docs.typo3.org/permalink/t3coreapi:extbase-domain-model-mapping@main) for the full syntax and examples.

## The repository as the only door to the database {#extbase-concepts-persistence-repository}

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 {#extbase-concepts-persistence-storagepid}

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**

```typoscript
plugin.tx_myextension.persistence.storagePid = 42
```

Or per plugin instance:

**EXT:my_extension/Configuration/Sets/MyExtension/setup.typoscript**

```typoscript
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.recursive` 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`:

**EXT:my_extension/Configuration/Sets/MyExtension/setup.typoscript**

```typoscript
plugin.tx_myextension.persistence.storagePid = 0
```

Or override it in PHP inside a repository method:

**EXT:my_extension/Classes/Domain/Repository/ConferenceRepository.php**

```php
$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](https://docs.typo3.org/permalink/t3coreapi:extbase-persistence-queries@main).

## Extbase object lifecycle {#extbase-persistence-events}

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**

```php
<?php

declare(strict_types=1);

namespace MyVendor\MyExtension\Controller;

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]);
  }
}

```

> [!NOTE]
> **See also**
>
> [Extbase domain model](https://docs.typo3.org/permalink/t3coreapi:extbase-domain-model@main) — defining models and their properties.
>
> [Extbase repository](https://docs.typo3.org/permalink/t3coreapi:extbase-domain-repository@main) — writing custom repository queries.
>
> [Persistence queries](https://docs.typo3.org/permalink/t3coreapi:extbase-persistence-queries@main) — the full query API, storagePid
> deep-dive, and when to use DBAL instead.
