Object relations in Extbase
Domain objects are rarely isolated. A
Conference
is held at a
Location
, collects many
Comment
objects, and is staffed by many
Speaker
objects. Extbase maps these relations onto the relational
database and loads the related objects for you when you access the property — no
JOIN
in your own code.
From the model's point of view a relation is just a property, and Extbase offers exactly two shapes for it:
- a property that holds one other object — a direct, typed property; or
- a property that holds many objects — an
\TYPO3\.CMS\ Extbase\ Persistence\ Object Storage
This page starts from those two shapes — what you write and how each behaves — then goes behind the scenes to the TCA that drives them, explains why "cardinality" (1:1, 1:n, …) is a property of both sides of a relation rather than of a single field, and finishes with how related objects are loaded and the lazy-loading performance trade-off.
How to declare relations in a model — the attributes, the
Object getter/setter pattern — is covered with the model:
See also
Modelling relations in Extbase — declaring relation properties, lazy and cascade attributes, ObjectStorage accessors.
On this page
A relation to one other object
The simplest relation points from one object to a single other one — a
Conference
is held at a
Location
. In the model this is a single,
typed, usually nullable property:
<?php
namespace MyVendor\MyExtension\Domain\Model;
use TYPO3\CMS\Extbase\DomainObject\AbstractEntity;
class Conference extends AbstractEntity
{
protected ?Location $location = null;
protected function getLocation(): ?Location
{
return $this->location;
}
}
The property is nullable because, from this object's side, the relation is optional: a conference may or may not have a location set. Reading it is a plain getter method.
In the database, the relation is stored as a single column on the owning
table that holds the UID of the related record (for example a
location
column on the conference table). The related record does not know it is being
pointed at — nothing on the
Location
side records the conference.
Note
This is a relation to at most one other object (0..1). It is tempting to call it a "1:1 relation", but that is not what the model states: nothing here says a location belongs to exactly one conference, or that it knows about the conference at all. The cardinality language (1:1, 1:n, …) only becomes meaningful once both sides are declared — see Behind the scenes: TCA, unidirectional and bidirectional relations.
A relation to many objects
When an object holds a collection of others, the property is an
Object — Extbase's container for
related objects. A
Conference
collects many
Comment
objects, and is
staffed by many
Speaker
objects; both are
Object properties:
<?php
namespace MyVendor\MyExtension\Domain\Model;
use TYPO3\CMS\Extbase\DomainObject\AbstractEntity;
use TYPO3\CMS\Extbase\Persistence\ObjectStorage;
class Conference extends AbstractEntity
{
/** @var ObjectStorage<Comment> */
protected ObjectStorage $comments;
/** @var ObjectStorage<Speaker> */
protected ObjectStorage $speakers;
public function __construct()
{
$this->initializeObject();
}
public function initializeObject(): void
{
$this->comments = new ObjectStorage();
$this->speakers = new ObjectStorage();
}
/** @return ObjectStorage<Comment> */
public function getComments(): ObjectStorage
{
return $this->comments;
}
public function addComment(Comment $comment): void
{
$this->comments->attach($comment);
}
public function removeComment(Comment $comment): void
{
$this->comments->detach($comment);
}
}
You iterate the storage to read the related objects, and add or remove single
objects through dedicated
add /
remove methods
that call
attach and
detach. The
@var
Object annotation is required so that IDEs and static analysis
know the element type.
The two collections above look identical in PHP, but they are stored differently in the database — and that difference decides what happens when you delete the owning object.
Children that belong to the parent
The comments of a conference exist only as part of that conference: a comment
has no life of its own. The natural way to store this is a back-reference
column on the child table — a
conference
column on the comment table
holding the parent UID, so no intermediate table is needed. The most common TCA
design for such relations is
type="inline" with a
foreign_ naming that back-reference column. Other field types can
express the same relation, but inline is the usual and most convenient choice
for parent-owned children.
Because the child belongs to the parent, this is the natural place for
#: deleting the conference deletes its comments. See
Modelling relations
for the attribute.
Shared records linked through an MM table
Speakers are different: a speaker appears at many conferences, and deleting one conference must not delete the speaker. The related records are shared and exist independently. TYPO3 offers two ways to store such a collection:
- An intermediate table (an
MMtable) with one row per pairing — a row linking a conference UID to a speaker UID, so neither domain table stores the relation directly. TCA expresses this as type="select" or type="group" plus anMMsetting naming the intermediate table. - A comma-separated list of UIDs in a single column on the owning table
(the same
type=select/groupfield without anMMsetting). This avoids the extra table but does not scale, is harder to query, and records the relation on one side only.
Prefer the MM table for genuine many-relations: it scales, supports relation ordering, and can be queried efficiently. The comma-separated form is acceptable for short, fixed lists where those limitations do not matter.
<?php
namespace MyVendor\MyExtension\Domain\Model;
use TYPO3\CMS\Extbase\DomainObject\AbstractEntity;
use TYPO3\CMS\Extbase\Persistence\ObjectStorage;
class Conference extends AbstractEntity
{
/**
* @var ObjectStorage<Speaker>
*/
protected ObjectStorage $speakers;
public function __construct()
{
$this->speakers = new ObjectStorage();
}
public function getSpeakers(): ObjectStorage
{
return $this->speakers;
}
public function setSpeakers(ObjectStorage $speakers): void
{
$this->speakers = $speakers;
}
public function addSpeaker(Speaker $speaker): void
{
$this->speakers->attach($speaker);
}
public function removeSpeaker(Speaker $speaker): void
{
// Detaches the link only — the Speaker record itself is not deleted,
// because it may still belong to other conferences.
$this->speakers->detach($speaker);
}
}
Because the related objects are shared, never put
# on this kind of relation — removing the link must
not delete a speaker who still appears at other conferences. Removing an object
from the
Object deletes the link between the two records (the
MM row, or the entry in the comma-separated list), not the speaker record itself.
Behind the scenes: TCA, unidirectional and bidirectional relations
The model property tells you that there is a relation and whether it holds one
object or many. It does not, on its own, tell you the full shape of the
relation. That comes from the TCA of the
column: whether there is a back-reference field, an MM table, a
maxitems
limit, or an explicit
relationship
setting. Extbase reads
that TCA and maps each column to one of its internal relation kinds — a single
related object, a collection with a back-reference, or a collection through an MM
table.
The examples above are all unidirectional: you can navigate from the
conference to its location, comments and speakers, but not back. A
Location
usually does not know which conference is held there; a
Speaker
does not list the conferences they speak at. Each relation is declared once, on
the owning side only.
This is why the classic cardinality labels do not apply to a single property. Terms like 1:1, 1:n, n:1 and n:m describe how both ends of a relation are constrained — and you only know both ends when the relation is bidirectional, declared on each side:
Conference→Locationon its own is a relation to one other object. Whether it is "1:1" (each location used by exactly one conference) or "n:1" (many conferences share a location) depends on a constraint that lives on theLocationside — and nothing in the conference model states it.- Likewise a relation to many becomes a "1:n" only if the child side is constrained to a single parent. As written, the comment relation simply collects children; it does not, by itself, forbid a comment from being attached elsewhere.
For everyday extension code this rarely matters, because Extbase relations are overwhelmingly unidirectional — you model the direction you actually navigate and leave the other side unaware. Reach for a bidirectional relation only when you genuinely need to navigate and query from both ends; it means declaring and maintaining the relation, and its TCA, on both models.
Tip
When you do need cardinality made explicit in TCA, the
relationship key
(
'one,
'many,
'one)
declares it on the field. Extbase honors it when deciding whether a column
is a single relation or a collection.
How Extbase loads related objects
By default Extbase loads a relation eagerly: when the parent object is
hydrated, its related objects are loaded in the same operation and the property
already contains ready-to-use objects. You call
$conference->get
or iterate
$conference->get and the objects are simply there.
The order of children inside a collection is not controlled by repository
queries — there is no query to override, because Extbase loads the children as
part of the parent. Child ordering comes from the TCA of the relation
(
foreign_ /
foreign_), not from
$default or
set. This catches developers out:
See also
Ordering in Extbase relations — why repository sort orders do not affect relation children.
Lazy loading and the n+1 query trap
Eager loading is convenient but not free. Loading a list of 50 conferences, each
with an eagerly loaded
Object of comments, means Extbase loads
every comment of every conference up front — even if the list view never displays
them.
Marking the relation
# defers loading: Extbase installs a
lightweight placeholder and loads the related objects only on first access. A
single relation receives a
\TYPO3\; an
Object becomes a
\TYPO3\. Both resolve on
first access.
Lazy loading solves the load-everything-up-front problem but introduces its opposite — the N+1 query problem. Consider a template that does need the relation for every row:
<f:for each="{conferences}" as="conference">
<h2>{conference.title}</h2>
<p>{conference.comments -> f:count()} comments</p>
</f:for>
If this were a lazy relation, it would run one query to fetch 50 conferences and then one more query per conference to load comments — 51 queries for one page. Making the relation lazy would not mean less work here; it would spread the same work across many small queries, which is usually slower than one larger one.
The rule of thumb:
- Eager (the default) when you usually need the related objects — for example a detail view that always renders the location and all comments.
- Lazy (
#) when you usually do not need the relation — for example a list view that shows only titles, where loading every child would be wasted work.[Lazy] - If a list view does need related data for every row, neither is ideal. It is often best to step outside the ORM and fetch what the view needs in a single query.
See also
When to drop out of the ORM — using raw DBAL from a repository when relation loading creates a performance problem.
The lazy-versus-eager decision is per relation and reversible — it is a property attribute, not a schema change — so measure with real data before optimizing.
See also
- Persistence and the Extbase ORM — the ORM mental model.
- Querying the database with Extbase — storagePid, query settings, and debugging.
- Modelling relations in Extbase — declaring relations in the model.
With relations and queries understood, you have the full persistence picture of an Extbase extension. For everything the ORM does not do — aggregates, bulk writes, complex joins — see When to drop out of ORM.