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\ObjectStorage .

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 ObjectStorage getter/setter pattern — is covered with the model:

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:

EXT:my_extension/Classes/Domain/Model/Conference.php
use TYPO3\CMS\Extbase\DomainObject\AbstractEntity;

class Conference extends AbstractEntity
{

    protected ?Location $location = null;

    protected function getLocation(): ?Location
    {
        return $this->location;
    }

}
Copied!

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.

A relation to many objects 

When an object holds a collection of others, the property is an ObjectStorage — Extbase's container for related objects. A Conference collects many Comment objects, and is staffed by many Speaker objects; both are ObjectStorage properties:

EXT:my_extension/Classes/Domain/Model/Conference.php
<?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);
    }
}
Copied!

You iterate the storage to read the related objects, and add or remove single objects through dedicated addComment() / removeComment() methods that call attach() and detach(). The @var ObjectStorage<Comment> 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_field 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 #[Cascade('remove')]: 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 MM table) 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 an MM setting naming the intermediate table.
  • A comma-separated list of UIDs in a single column on the owning table (the same type=select / group field without an MM setting). 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.

EXT:my_extension/Classes/Domain/Model/Conference.php
<?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);
    }
}
Copied!

Because the related objects are shared, never put #[Cascade('remove')] on this kind of relation — removing the link must not delete a speaker who still appears at other conferences. Removing an object from the ObjectStorage 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 Location on 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 the Location side — 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.

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 ObjectStorage of comments, means Extbase loads every comment of every conference up front — even if the list view never displays them.

Marking the relation #[Lazy] defers loading: Extbase installs a lightweight placeholder and loads the related objects only on first access. A single relation receives a \TYPO3\CMS\Extbase\Persistence\Generic\LazyLoadingProxy ; an ObjectStorage becomes a \TYPO3\CMS\Extbase\Persistence\Generic\LazyObjectStorage . 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:

EXT:my_extension/Resources/Private/Templates/Conference/List.fluid.html
<f:for each="{conferences}" as="conference">
    <h2>{conference.title}</h2>
    <p>{conference.comments -> f:count()} comments</p>
</f:for>
Copied!

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 ( #[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.
  • 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.

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 optimising.

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 (Object Relational Mapping).