---
title: "Querying the database with Extbase"
manual: "TYPO3 Explained"
version: "main"
permalink: "https://docs.typo3.org/permalink/t3coreapi:extbase-persistence-queries@main"
source: "ExtensionArchitecture/Extbase/Persistence/Queries.rst"
rendered: "2026-09-21T11:24:09+00:00"
---

# Querying the database with Extbase {#extbase-persistence-queries}

Most of what an extension does with the database is *reading* it: fetching the
records a list view shows, the single record behind a detail page, the handful
that match a filter. In Extbase you do this by building a **query** on a
repository and executing it.

The built-in find methods (`findAll()`, `findBy()`, `findByUid()`)
cover the simplest cases and are documented with the repository itself:

> [!NOTE]
> **See also**
>
> [The Extbase repository](https://docs.typo3.org/permalink/t3coreapi:extbase-domain-repository@main) — what a repository is, the built-in find methods, and injecting it into a controller.

This page covers everything beyond those: writing your own query methods with
`createQuery()`, constraining and ordering the result, and then the parts
that decide *which* records a query can even see — storage pages, language,
visibility — followed by paging, persisting and debugging.

**On this page**

-   [Building a query with createQuery()](https://docs.typo3.org/permalink/t3coreapi:building-a-query-with-createquery@main)
-   [The storagePid page restriction](https://docs.typo3.org/permalink/t3coreapi:the-storagepid-page-restriction@main)
-   [Overriding query behaviour with query settings](https://docs.typo3.org/permalink/t3coreapi:overriding-query-behaviour-with-query-settings@main)
-   [Paginating a query result](https://docs.typo3.org/permalink/t3coreapi:paginating-a-query-result@main)
-   [When changes reach the database](https://docs.typo3.org/permalink/t3coreapi:when-changes-reach-the-database@main)
-   [Debugging an Extbase query](https://docs.typo3.org/permalink/t3coreapi:debugging-an-extbase-query@main)

## Building a query with `createQuery()` {#extbase-persistence-queries-build}

When `findBy(['published' => true])` is not expressive enough — you need a
range, a pattern, or a combination of conditions — write a method on the
repository that builds the query itself. Every custom query follows the same
three steps:

1.  **Create** a query object with `$this->createQuery()`. It is already
    bound to this repository's model and its storage pages.
1.  **Constrain** it with `matching()`, passing one constraint built from
    the query's own constraint methods (`equals()`, `like()`,
    `greaterThan()`, …).
1.  **Execute** it with `execute()`, which returns a
    `\TYPO3\CMS\Extbase\Persistence\QueryResultInterface` you can iterate
    and count.

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

```php
<?php

namespace MyVendor\MyExtension\Domain\Repository;

use TYPO3\CMS\Extbase\Persistence\QueryResultInterface;
use TYPO3\CMS\Extbase\Persistence\Repository;

class ConferenceRepository extends Repository
{
  public function findPublished(): QueryResultInterface
  {
    $query = $this->createQuery();
    $query->matching(
      $query->equals('published', true),
    );
    return $query->execute();
  }
}

```

The method lives on the repository because the repository is the only place
that should talk to the database. The controller calls
`$conferenceRepository->findPublished()` and never sees the query.

### Constraining the result {#extbase-persistence-queries-constraints}

A *constraint* is one condition the records must satisfy. The query object
creates them — each method returns a constraint and does not change the query
until you hand it to `matching()`. In every method the first argument is a
**model property name**, not a database column name; Extbase maps it for you.

In the reference below, each PHP line is the constraint you pass to
`matching()` — as in `$query->matching($query->equals(...))`. The
generated SQL shows the `WHERE` fragment Extbase produces **on MariaDB**.
Extbase always binds values as named parameters; the SQL here shows the values
inlined as they would land, for readability. The page restrictions Extbase adds
(storagePid, enable fields) are omitted for clarity. Other database platforms may
differ — most visibly PostgreSQL, which uses `ILIKE` for `like()`.

-   **equals(string $property, mixed $value, bool $caseSensitive = true)**

    Exact match. If `$value` is `null`, a strict `IS NULL`
    check is generated instead. For string properties, pass `false` as
    the third argument to match case-insensitively.

    ```php
    $query->equals('published', true)
    ```

    ```sql
    `published` = 1
    ```

-   **like(string $property, string $value)**

    Pattern match on a string property. Use `%` as the wildcard. Using
    `like()` on a non-string property throws an
    `InvalidQueryException`.

    ```php
    $query->like('title', '%symfony%')
    ```

    ```sql
    `title` LIKE '%symfony%'
    ```

-   **in(string $property, array $values)**

    Matches when the property equals any value in the array. An empty array
    never matches.

    ```php
    $query->in('uid', [4, 8, 15])
    ```

    ```sql
    `uid` IN (4, 8, 15)
    ```

-   **contains(string $property, mixed $value)**

    Matches when a multivalued property (an `ObjectStorage` relation)
    contains the given object. Passing `null` never matches. Unlike the
    scalar comparisons, this resolves through the relation: Extbase joins the
    MM table (or matches the foreign key) for the related object's UID.

    ```php
    $query->contains('speakers', $speaker)
    ```

    **Resulting SQL query**

    ```sql
    `uid` IN (
        SELECT `uid_local` FROM `tx_myextension_conference_speaker_mm`
        WHERE `uid_foreign` = 42
    )
    ```

-   **greaterThan(string $property, mixed $value)**

    Strictly greater than the value.

    ```php
    $query->greaterThan('seats', 100)
    ```

    ```sql
    `seats` > 100
    ```

-   **greaterThanOrEqual(string $property, mixed $value)**

    Greater than or equal — the usual building block for an open-ended
    "from this date onward" range. A `DateTimeImmutable` is bound
    according to the column type: a Unix timestamp for an integer column
    (shown below), or a formatted string for a `datetime` column.

    ```php
    $query->greaterThanOrEqual('conferenceDate', new \DateTimeImmutable('today'))
    ```

    ```sql
    `conference_date` >= 1782518400
    ```

-   **lessThan(string $property, mixed $value)**

    Strictly less than the value.

    ```php
    $query->lessThan('seats', 50)
    ```

    ```sql
    `seats` < 50
    ```

-   **lessThanOrEqual(string $property, mixed $value)**

    Less than or equal. Combine with `greaterThanOrEqual()` inside a
    `logicalAnd()` to express a closed range.

    ```php
    $query->lessThanOrEqual('conferenceDate', new \DateTimeImmutable('2026-12-31'))
    ```

    ```sql
    `conference_date` <= 1798675200
    ```

To count matches without loading the objects, call `count()` on the query
after `matching()` instead of `execute()`. It applies the same
constraints and returns an integer.

### Combining constraints with `logicalAnd()`, `logicalOr()` and `logicalNot()` {#extbase-persistence-queries-combine}

`matching()` takes exactly one constraint. To apply several conditions,
combine them with `logicalAnd()`, `logicalOr()` and
`logicalNot()`, which themselves return a single constraint:

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

```php
<?php

namespace MyVendor\MyExtension\Domain\Repository;

use TYPO3\CMS\Extbase\Persistence\QueryResultInterface;
use TYPO3\CMS\Extbase\Persistence\Repository;

class ConferenceRepository extends Repository
{
  public function findUpcomingPublished(): QueryResultInterface
  {
    $query = $this->createQuery();
    $query->matching(
      $query->logicalAnd(
        $query->equals('published', true),
        $query->greaterThanOrEqual('conferenceDate', new \DateTimeImmutable('today')),
      ),
    );
    return $query->execute();
  }
}

```

A date range is a `logicalAnd()` of `greaterThanOrEqual()` and
`lessThanOrEqual()` — the snippet above pairs a comparison with an
`equals()` constraint in the same way.

### Ordering and limiting the result {#extbase-persistence-queries-order-limit}

Set the order with `setOrderings()` (or the repository's
`$defaultOrderings`). The two ordering forms and the
property-versus-column rule are documented with the repository:

> [!NOTE]
> **See also**
>
> [Ordering results in Extbase repositories](https://docs.typo3.org/permalink/t3coreapi:extbase-domain-repository-ordering@main) — `$defaultOrderings`, `setOrderings()`, `orderBy()`, and why an unordered query has no guaranteed order.

To return a slice of the result, combine `setLimit()` and
`setOffset()`:

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

```php
<?php

namespace MyVendor\MyExtension\Domain\Repository;

use TYPO3\CMS\Extbase\Persistence\QueryResultInterface;
use TYPO3\CMS\Extbase\Persistence\Repository;

class ConferenceRepository extends Repository
{
  public function findSlice(int $limit, int $offset = 0): QueryResultInterface
  {
    $query = $this->createQuery();
    $query->setLimit($limit);
    $query->setOffset($offset);
    return $query->execute();
  }
}

```

`setLimit()` expects a positive integer. Passing `0` throws an
exception rather than returning an empty result, so guard against it if the
value is computed.

### Working with the query result {#extbase-persistence-queries-results}

`execute()` returns a
`\TYPO3\CMS\Extbase\Persistence\QueryResultInterface` which is iterable
and countable. Two methods on it are worth knowing: `getFirst()` returns
the first object (or `null`) without you slicing the result, and
`toArray()` returns the results as a plain array of **fully mapped domain
objects**. This is useful when you need an array rather than the lazy result object.

If you do not need domain objects at all, pass `true` to
`execute()`. This is a different array: `$query->execute(true)`
returns the **raw database rows** as a `list<array<string, mixed>>` and
skips object mapping entirely. It is the lighter option for read-only data you
will not modify or persist at the cost of losing the typed model objects, their
getters, and relation access. Use it when hydration is pure overhead — a
report, an export, a quick lookup — and stay with the default
`QueryResultInterface` whenever you work with the objects themselves.

## The `storagePid` page restriction {#extbase-persistence-queries-storagepid}

Every query a repository builds is restricted to records on configured *storage
pages* (the **storagePid**), in addition to the constraints you set with
`matching()`. This is the most common reason a query returns something other
than what you expect, and it has its own page:

> [!NOTE]
> **See also**
>
> [The storagePid](https://docs.typo3.org/permalink/t3coreapi:extbase-persistence-storagepid@main) — the resolution chain, the recursive setting, why `storagePid = 0` does not disable the restriction, and how to override or drop the restriction for a single query.

## Overriding query behaviour with query settings {#extbase-repository-query-setting}

Every query carries a
`query settings <\TYPO3\CMS\Extbase\Persistence\Generic\QuerySettingsInterface>`
object that controls the implicit restrictions Extbase applies. Set it with
`$query->getQuerySettings()` inside a custom repository method:

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

```php
<?php

namespace MyVendor\MyExtension\Domain\Repository;

use TYPO3\CMS\Extbase\Persistence\QueryResultInterface;
use TYPO3\CMS\Extbase\Persistence\Repository;

class ConferenceRepository extends Repository
{
  public function findAllAcrossPages(): QueryResultInterface
  {
    $query = $this->createQuery();
    $query->getQuerySettings()->setRespectStoragePage(false);
    return $query->execute();
  }
}

```

The storagePid is one of these settings; its two methods
(`setRespectStoragePage()` and `setStoragePageIds()`) are documented on
the [storagePid page](https://docs.typo3.org/permalink/t3coreapi:extbase-persistence-storagepid-override@main).
The remaining settings control language and visibility:

-   **setRespectSysLanguage(bool)**

    -   *Type:* `bool`
    -   *Default:* `true`

    Controls whether the query is restricted to the current language.

    When `true`, a request in language 2 returns the Polish records.
    Whether records without a Polish translation are included depends on
    the site's language configuration.

    When `false`, the language restriction is dropped and records of
    every language are returned side by side. A conference that exists in
    English, Polish and German is returned three times — once per
    language.

    Use `false` for deliberate cross-language listings, such as a
    backend overview showing every translation of a record. In a frontend
    list it produces apparent duplicates.

-   **setIgnoreEnableFields(bool)**

    -   *Type:* `bool`
    -   *Default:* `false`

    When `true`, hidden, start/stop-scheduled and access-restricted
    records are included. By default these `enable fields` are honoured.

-   **setIncludeDeleted(bool)**

    -   *Type:* `bool`
    -   *Default:* `false`

    When `true`, soft-deleted records (those flagged in the
    `deleted` column) are returned. Use with care.

> [!WARNING]
> `setIgnoreEnableFields(true)` and `setIncludeDeleted(true)` bypass
> the visibility rules editors rely on. A frontend query that ignores enable
> fields will show hidden and time-restricted records to visitors. Reserve
> these for backend or maintenance contexts and never apply them to a default
> frontend listing.

## Paginating a query result {#extbase-persistence-queries-pagination}

For page-by-page navigation, do not compute offsets by hand. TYPO3 splits
pagination into two collaborating roles, each with its own family of classes:

-   A **paginator** wraps the item source and slices it to the current page.
    All paginators extend
    `\TYPO3\CMS\Core\Pagination\AbstractPaginator` and expose the current
    page's items in `getPaginatedItems()`. Which one you pick depends
    on the source: `\TYPO3\CMS\Extbase\Pagination\QueryResultPaginator` for
    an Extbase query result,
    `\TYPO3\CMS\Core\Pagination\ArrayPaginator` for a plain array,
    `\TYPO3\CMS\Core\Pagination\QueryBuilderPaginator` for a raw DBAL
    query, and several source-specific paginators shipped by other extensions.
-   **Pagination** takes a paginator and computes the page-number metadata
    used to render the navigation — previous, next, first, last and the list of
    page numbers. All implement
    `\TYPO3\CMS\Core\Pagination\PaginationInterface`. Core ships two
    strategies: `\TYPO3\CMS\Core\Pagination\SimplePagination` (every page
    number) and `\TYPO3\CMS\Core\Pagination\SlidingWindowPagination` (a
    moving window around the current page, for long result sets).

For an Extbase query result you therefore combine a
`QueryResultPaginator` with the pagination strategy of your choice. The
controller creates both and assigns them to the view:

**EXT:my_extension/Classes/Controller/ConferenceController.php**

```php
<?php

namespace MyVendor\MyExtension\Controller;

use MyVendor\MyExtension\Domain\Repository\ConferenceRepository;
use Psr\Http\Message\ResponseInterface;
use TYPO3\CMS\Core\Pagination\SimplePagination;
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;
use TYPO3\CMS\Extbase\Pagination\QueryResultPaginator;

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

  public function listAction(int $currentPage = 1): ResponseInterface
  {
    $conferences = $this->conferenceRepository->findAll();
    $paginator = new QueryResultPaginator($conferences, $currentPage, 10);
    $pagination = new SimplePagination($paginator);

    $this->view->assignMultiple([
      'paginator' => $paginator,
      'pagination' => $pagination,
    ]);
    return $this->htmlResponse();
  }
}

```

The paginator queries lazily — it only fetches the records for the current page,
not the whole result set — so it is safe to use over large tables. In the
template the records for the current page come from
`{paginator.paginatedItems}` (you loop over that to render the items),
while `{pagination.allPageNumbers}` supplies the page numbers for the
navigation links. The original `findAll()` result is never passed to the
view:

**EXT:my_extension/Resources/Private/Templates/Conference/List.fluid.html**

```html
<h1>Conferences</h1>

<ul>
  <f:for each="{paginator.paginatedItems}" as="conference">
    <li>{conference.title}</li>
  </f:for>
</ul>

<nav aria-label="Pagination">
  <f:for each="{pagination.allPageNumbers}" as="page">
    <f:link.action action="list" arguments="{currentPage: page}">
      {page}
    </f:link.action>
  </f:for>
</nav>

```

## When changes reach the database {#extbase-persistence-queries-write}

Reads happen immediately. Writes do not. Extbase tracks the state of every
object it loads and flushes the accumulated changes once, automatically, at the
end of the request — comparing each object against its original state and
writing only what has changed. In most actions you do not need to call a save method on an
object that you have loaded and modified; the ORM will detect the change and
write it for you.

This deferred flush is convenient, but has two consequences that are worth knowing.

### Forcing a write with `persistAll()` {#extbase-persistence-queries-persistall}

Two situations need the flush to happen earlier than the end of the request.
Call `$this->persistenceManager->persistAll()` in both:

-   **Before a redirect.** `$this->redirect()` ends the request before the
    automatic flush runs, so an object that is added just before it is never written.
-   **When you need the new UID.** A freshly created object has no UID until it
    is persisted. Persist first, then read the UID.

**EXT:my_extension/Classes/Controller/ConferenceController.php**

```php
<?php

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

    // Force the write now: the redirect below ends the request before the
    // automatic flush, and we need the new UID for the show action.
    $this->persistenceManager->persistAll();

    return $this->redirect('show', null, null, ['conference' => $conference->getUid()]);
  }
}

```

### Detecting an unpersisted object {#extbase-persistence-queries-isnew}

Before persistence, an object has no UID — `getUid()` returns `null`.
To check whether Extbase still considers an object to be new (not yet written), use
the persistence manager's public `isNewObject()` method:

**EXT:my_extension/Classes/Controller/ConferenceController.php**

```php
<?php

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 saveAction(Conference $conference): ResponseInterface
  {
    if ($this->persistenceManager->isNewObject($conference)) {
      $this->conferenceRepository->add($conference);
    } else {
      $this->conferenceRepository->update($conference);
    }

    return $this->redirect('list');
  }
}

```

> [!NOTE]
> The domain object also carries an `_isNew()` method, but it is marked
> as `@internal`. Use
> `\TYPO3\CMS\Extbase\Persistence\PersistenceManagerInterface::isNewObject()`
> in extension code — it is the supported public API and will return the same
> answer.

## Debugging an Extbase query {#extbase-repository-debug-query}

When a query returns something other than what you expect — nothing, too few
records, too many, or the wrong ones — work through this checklist first before
checking for possibly incorrect constraint logic:

1.  **Check the storagePid first.** This is the cause far more often than the
    query itself. Confirm which page the records live on and which page the query
    searches.
1.  **Check the recursive depth.** Records in subfolders are invisible without
    it; an over-broad depth pulls in unrelated records.
1.  **Check the language.** With `setRespectSysLanguage(true)` (the
    default), records in other languages are filtered out or overlaid.
1.  **Check enable fields.** Hidden or time-restricted records are excluded by
    default — correct for the frontend, surprising during debugging.
1.  **Inspect the generated SQL.** Extbase builds real SQL; seeing the exact
    SQL statement and its parameters will show the page restrictions and any conditions that
    have been applied.

To see the SQL, convert the query to a Doctrine query builder and dump it using
the Extbase debugger:

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

```php
<?php

namespace MyVendor\MyExtension\Domain\Repository;

use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Persistence\Generic\Storage\Typo3DbQueryParser;
use TYPO3\CMS\Extbase\Persistence\QueryResultInterface;
use TYPO3\CMS\Extbase\Persistence\Repository;
use TYPO3\CMS\Extbase\Utility\DebuggerUtility;

class ConferenceRepository extends Repository
{
  public function findPublished(bool $debug = false): QueryResultInterface
  {
    $query = $this->createQuery();
    $query->matching($query->equals('published', true));

    if ($debug) {
      $parser = GeneralUtility::makeInstance(Typo3DbQueryParser::class);
      $queryBuilder = $parser->convertQueryToDoctrineQueryBuilder($query);
      DebuggerUtility::var_dump($queryBuilder->getSQL());
      DebuggerUtility::var_dump($queryBuilder->getParameters());
    }

    return $query->execute();
  }
}

```

> [!WARNING]
> `\TYPO3\CMS\Extbase\Persistence\Generic\Storage\Typo3DbQueryParser` is
> marked as `@internal` and may change without notice. Use it for debugging
> only, never in production code paths.

> [!NOTE]
> **See also**
>
> [Persistence and the Extbase ORM](https://docs.typo3.org/permalink/t3coreapi:extbase-concepts-persistence@main) — the ORM mental model and object lifecycle.
>
> [Extbase repository](https://docs.typo3.org/permalink/t3coreapi:extbase-domain-repository@main) — find methods, constraints and the DBAL escape hatch.
>
> [Object relations in Extbase](https://docs.typo3.org/permalink/t3coreapi:extbase-persistence-relations@main) — how related objects are loaded and the lazy-loading trade-off.

Once you can query confidently, the next step is understanding how Extbase loads
*related* objects — and the performance trade-off that comes with them. That is
covered in [Object relations in Extbase](https://docs.typo3.org/permalink/t3coreapi:extbase-persistence-relations@main).
