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 (
find,
find,
find)
cover the simplest cases and are documented with the repository itself:
See also
The Extbase repository — 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
create, 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()
When
find 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:
- Create a query object with
$this->create. It is already bound to this repository's model and its storage pages.Query () - Constrain it with
matching, passing one constraint built from the query's own constraint methods (() equals,() like,() greater, …).Than () - Execute it with
execute, which returns a() \TYPO3\you can iterate and count.CMS\ Extbase\ Persistence\ Query Result Interface
<?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
$conference 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. 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
$valueisnull, a strictIS NULLcheck is generated instead. For string properties, passfalseas the third argument to match case-insensitively.$query->equals('published', true)Copied!`published` = 1Copied!
like(string $property, string $value)
-
Pattern match on a string property. Use
%as the wildcard. Usinglikeon a non-string property throws an() Invalid.Query Exception $query->like('title', '%symfony%')Copied!`title` LIKE '%symfony%'Copied!
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)
-
Matches when a multivalued property (an
Objectrelation) contains the given object. PassingStorage nullnever 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)
-
Strictly greater than the value.
$query->greaterThan('seats', 100)Copied!`seats` > 100Copied!
greaterThanOrEqual(string $property, mixed $value)
-
Greater than or equal — the usual building block for an open-ended "from this date onward" range. A
Dateis bound according to the column type: a Unix timestamp for an integer column (shown below), or a formatted string for aTime Immutable datetimecolumn.$query->greaterThanOrEqual('conferenceDate', new \DateTimeImmutable('today'))Copied!`conference_date` >= 1782518400Copied!
lessThan(string $property, mixed $value)
-
Strictly less than the value.
$query->lessThan('seats', 50)Copied!`seats` < 50Copied!
lessThanOrEqual(string $property, mixed $value)
-
Less than or equal. Combine with
greaterinside aThan Or Equal () logicalto express a closed range.And () $query->lessThanOrEqual('conferenceDate', new \DateTimeImmutable('2026-12-31'))Copied!`conference_date` <= 1798675200Copied!
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
logical,
logical and
logical, which themselves return a single constraint:
<?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
logical of
greater and
less — the snippet above pairs a comparison with an
equals constraint in the same way.
Ordering and limiting the result
Set the order with
set (or the repository's
$default). The two ordering forms and the
property-versus-column rule are documented with the repository:
See also
Ordering results in Extbase repositories —
$default,
set,
order, and why an unordered query has no guaranteed order.
To return a slice of the result, combine
set and
set:
<?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();
}
}
set 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\ which is iterable
and countable. Two methods on it are worth knowing:
get returns
the first object (or
null) without you slicing the result, and
to 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
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
Query 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:
See also
The storagePid — 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
Every query carries a
query settings <\
object that controls the implicit restrictions Extbase applies. Set it with
$query->get inside a custom repository method:
<?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
(
set and
set) are documented on
the storagePid page.
The remaining settings control language and visibility:
| Name | Type | Default |
|---|---|---|
bool
|
true
|
|
bool
|
false
|
|
bool
|
false
|
setRespectSysLanguage(bool)
-
- Type
bool- Default
true
When
true, results are filtered and overlaid according to the current language. Whenfalse, records of all languages are returned without overlay.
setIgnoreEnableFields(bool)
-
- Type
bool- Default
false
When
true, hidden, start/stop-scheduled and access-restricted records are included. By default theseenable fieldsare honoured.
setIncludeDeleted(bool)
-
- Type
bool- Default
false
When
true, soft-deleted records (those flagged in thedeletedcolumn) are returned. Use with care.
Warning
set and
set 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
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\and expose the current page's items inCMS\ Core\ Pagination\ Abstract Paginator get. Which one you pick depends on the source:Paginated Items () \TYPO3\for an Extbase query result,CMS\ Extbase\ Pagination\ Query Result Paginator \TYPO3\for a plain array,CMS\ Core\ Pagination\ Array Paginator \TYPO3\for a raw DBAL query, and several source-specific paginators shipped by other extensions.CMS\ Core\ Pagination\ Query Builder Paginator - 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\. Core ships two strategies:CMS\ Core\ Pagination\ Pagination Interface \TYPO3\(every page number) andCMS\ Core\ Pagination\ Simple Pagination \TYPO3\(a moving window around the current page, for long result sets).CMS\ Core\ Pagination\ Sliding Window Pagination
For an Extbase query result you therefore combine a
Query with the pagination strategy of your choice. The
controller creates both and assigns them to the view:
<?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. (you loop over that to render the items),
while
{pagination. supplies the page numbers for the
navigation links. The original
find result is never passed to the
view:
<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
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->persistence in both:
- Before a redirect.
$this->redirectends 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.
<?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
Before persistence, an object has no UID —
get returns
null.
To check whether Extbase still considers an object to be new (not yet written), use
the persistence manager's public
is method:
<?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
_is method, but it is marked
as
@internal. Use
\TYPO3\
in extension code — it is the supported public API and will return the same
answer.
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:
- 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.
- Check the recursive depth. Records in subfolders are invisible without it; an over-broad depth pulls in unrelated records.
- Check the language. With
set(the default), records in other languages are filtered out or overlaid.Respect Sys Language (true) - Check enable fields. Hidden or time-restricted records are excluded by default — correct for the frontend, surprising during debugging.
- 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:
<?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\ is
marked as
@internal and may change without notice. Use it for debugging
only, never in production code paths.
See also
Persistence and the Extbase ORM — the ORM mental model and object lifecycle.
Extbase repository — find methods, constraints and the DBAL escape hatch.
Object relations in Extbase — 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.