Querying the database with Extbase 

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:

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.

Building a query with createQuery() 

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.
  2. Constrain it with matching(), passing one constraint built from the query's own constraint methods ( equals(), like(), greaterThan(), …).
  3. 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

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();
    }
}
Copied!

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 

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)

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.

$query->equals('published', true)
Copied!
`published` = 1
Copied!

like(string $property, string $value)

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 .

$query->like('title', '%symfony%')
Copied!
`title` LIKE '%symfony%'
Copied!

in(string $property, array $values)

in(string $property, array $values)

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

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

contains(string $property, mixed $value)

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.

$query->contains('speakers', $speaker)
Copied!
`uid` IN (
    SELECT `uid_local` FROM `tx_myextension_conference_speaker_mm`
    WHERE `uid_foreign` = 42
)
Copied!

greaterThan(string $property, mixed $value)

greaterThan(string $property, mixed $value)

Strictly greater than the value.

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

greaterThanOrEqual(string $property, mixed $value)

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.

$query->greaterThanOrEqual('conferenceDate', new \DateTimeImmutable('today'))
Copied!
`conference_date` >= 1782518400
Copied!

lessThan(string $property, mixed $value)

lessThan(string $property, mixed $value)

Strictly less than the value.

$query->lessThan('seats', 50)
Copied!
`seats` < 50
Copied!

lessThanOrEqual(string $property, mixed $value)

lessThanOrEqual(string $property, mixed $value)

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

$query->lessThanOrEqual('conferenceDate', new \DateTimeImmutable('2026-12-31'))
Copied!
`conference_date` <= 1798675200
Copied!

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

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

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();
    }
}
Copied!

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 

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:

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

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

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 

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 

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:

Overriding query behaviour with query settings 

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

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();
    }
}
Copied!

The storagePid is one of these settings; its two methods ( setRespectStoragePage() and setStoragePageIds()) are documented on the storagePid page. The remaining settings control language and visibility:

Name Type Default
bool true
bool false
bool false

setRespectSysLanguage(bool)

setRespectSysLanguage(bool)
Type
bool
Default
true

When true, results are filtered and overlaid according to the current language. When false, records of all languages are returned without overlay.

setIgnoreEnableFields(bool)

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)

setIncludeDeleted(bool)
Type
bool
Default
false

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

Paginating a query result 

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

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();
    }
}
Copied!

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
<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>
Copied!

When changes reach the database 

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

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

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

Detecting an unpersisted object 

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

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');
    }
}
Copied!

Debugging an Extbase 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.
  2. Check the recursive depth. Records in subfolders are invisible without it; an over-broad depth pulls in unrelated records.
  3. Check the language. With setRespectSysLanguage(true) (the default), records in other languages are filtered out or overlaid.
  4. Check enable fields. Hidden or time-restricted records are excluded by default — correct for the frontend, surprising during debugging.
  5. 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

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();
    }
}
Copied!

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.