Model

Introduction

All classes of the domain model should inherit from the class \TYPO3\CMS\Extbase\DomainObject\AbstractEntity .

An entity is an object fundamentally defined not by its attributes, but by a thread of continuity and identity, for example, a person or a blog post.

Objects stored in the database are usually entities as they can be identified by the uid and are persisted, therefore have continuity.

In the TYPO3 backend models are displayed as Database records.

Example:

Class T3docs\BlogExample\Domain\Model\Comment
class Comment extends AbstractEntity implements \Stringable
{
    protected string $author = '';

    protected string $content = '';

    public function getAuthor(): string
    {
        return $this->author;
    }

    public function setAuthor(string $author): void
    {
        $this->author = $author;
    }

    public function getContent(): string
    {
        return $this->content;
    }

    public function setContent(string $content): void
    {
        $this->content = $content;
    }
}
Copied!

Connecting the model to the database

It is possible to define models that are not persisted to the database. However in the most common use cases you want to save your model to the database and load it from there. See Persistence.

Properties

The properties of a model can be defined either as public class properties:

Class T3docs\BlogExample\Domain\Model\Tag
final class Tag extends AbstractValueObject implements \Stringable
{

    public int $priority = 0;
}
Copied!

Or public getters:

Class T3docs\BlogExample\Domain\Model\Info
class Info extends AbstractEntity implements \Stringable
{
    protected string $name = '';

    protected string $bodytext = '';

    public function getName(): string
    {
        return $this->name;
    }

    public function getBodytext(): string
    {
        return $this->bodytext;
    }

    public function setBodytext(string $bodytext): void
    {
        $this->bodytext = $bodytext;
    }
}
Copied!

A public getter takes precedence over a public property. Getters have the advantage that you can make the properties themselves protected and decide which ones should be mutable.

It is also possible to have getters for properties that are not persisted and get created on the fly:

Class T3docs\BlogExample\Domain\Model\Info
class Info extends AbstractEntity implements \Stringable
{
    protected string $name = '';

    protected string $bodytext = '';

    public function getCombinedString(): string
    {
        return $this->name . ': ' . $this->bodytext;
    }
}
Copied!

One disadvantage of using additional getters is that properties that are only defined as getters do not get displayed in the debug output in Fluid, they do however get displayed when explicitly called:

Debugging different kind of properties
Does not display "combinedString":
<f:debug>{post.info}</f:debug>

But it is there:
<f:debug>{post.info.combinedString}</f:debug>
Copied!

Union types

New in version 12.3

Previously, whenever a union type was needed, union type declarations led Extbase not detecting any type at all, resulting in the property not being mapped. However, union types could be resolved via docblocks. Since TYPO3 v12.3 native PHP union types can be used.

Union types can be used in properties of an entity, for example:

EXT:my_extension/Classes/Domain/Model/Entity.php
<?php

declare(strict_types=1);

namespace MyVendor\MyExtension\Domain\Model\MyEntity;

use TYPO3\CMS\Extbase\DomainObject\AbstractEntity;
use TYPO3\CMS\Extbase\Persistence\Generic\LazyLoadingProxy;

class Entity extends AbstractEntity
{
    private ChildEntity|LazyLoadingProxy $property;
}
Copied!

This is especially useful for lazy-loaded relations where the property type is ChildEntity|\TYPO3\CMS\Extbase\Persistence\Generic\LazyLoadingProxy.

There is something important to understand about how Extbase detects union types when it comes to property mapping, that means when a database row is mapped onto an object. In this case, Extbase needs to know the desired target type - no union, no intersection, just one type. In order to achieve this, Extbase uses the first declared type as a so-called primary type.

EXT:my_extension/Classes/Domain/Model/Entity.php
<?php

declare(strict_types=1);

namespace MyVendor\MyExtension\Domain\Model\MyEntity;

use TYPO3\CMS\Extbase\DomainObject\AbstractEntity;

class Entity extends AbstractEntity
{
    private string|int $property;
}
Copied!

In this case, string is the primary type. int|string would result in int as primary type.

There is one important thing to note and one exception to this rule. First of all, null is not considered a type. null|string results in the primary type string which is nullable. null|string|int also results in the primary type string. In fact, null means that all other types are nullable. null|string|int boils down to ?string or ?int.

Secondly, LazyLoadingProxy is never detected as a primary type because it is just a proxy and not the actual target type, once loaded.

EXT:my_extension/Classes/Domain/Model/Entity.php
<?php

declare(strict_types=1);

namespace MyVendor\MyExtension\Domain\Model\MyEntity;

use TYPO3\CMS\Extbase\DomainObject\AbstractEntity;
use TYPO3\CMS\Extbase\Persistence\Generic\LazyLoadingProxy;

class Entity extends AbstractEntity
{
    private LazyLoadingProxy|ChildEntity $property;
}
Copied!

Extbase supports this and detects ChildEntity as the primary type, although LazyLoadingProxy is the first item in the list. However, it is recommended to place the actual type first, for consistency reasons: ChildEntity|LazyLoadingProxy.

A final word on \TYPO3\CMS\Extbase\Persistence\Generic\LazyObjectStorage : it is a subclass of \TYPO3\CMS\Extbase\Persistence\ObjectStorage , therefore the following code works and has always worked:

EXT:my_extension/Classes/Domain/Model/Entity.php
<?php

declare(strict_types=1);

namespace MyVendor\MyExtension\Domain\Model\MyEntity;

use TYPO3\CMS\Extbase\DomainObject\AbstractEntity;
use TYPO3\CMS\Extbase\Persistence\ObjectStorage;

class Entity extends AbstractEntity
{
    /**
     * @var ObjectStorage<ChildEntity>
     * @TYPO3\CMS\Extbase\Annotation\ORM\Lazy
     */
    private ObjectStorage $property;
}
Copied!

Enumerations

New in version 13.0

Native PHP enumerations can be used for properties, if a database field has a specific set of values which can be represented by a backed enum:

EXT:my_extension/Classes/Domain/Model/Enum/Level.php
<?php

declare(strict_types=1);

namespace MyVendor\MyExtension\Domain\Model\Enum;

enum Level: string
{
    case INFO = 'info';
    case ERROR = 'error';
}
Copied!

The enum can then be used for a property in the model:

EXT:my_extension/Classes/Domain/Model/LogEntry.php
<?php

declare(strict_types=1);

namespace MyVendor\MyExtension\Domain\Model;

use TYPO3\CMS\Extbase\DomainObject\AbstractEntity;

class LogEntry extends AbstractEntity
{
    protected Enum\Level $level;

    // ... more properties
}
Copied!

Relations

Extbase supports different types of hierarchical relationships between domain objects. All relationships can be defined unidirectional or multidimensional in the model.

On the side of the relationship that can only have one counterpart, you must decide whether it is possible to have no relationship (allow null) or not.

Nullable relations

There are two ways to allow null for a property in PHP:

  • Mark the type declaration as nullable by prefixing the type name with a question mark:

    Example for a nullable property
    protected ?Person $secondAuthor = null;
    Copied!
  • Use a union type:

    Example for a union type of null and Person
    protected Person|null $secondAuthor = null;
    Copied!

Both declarations have the same meaning.

1:1-relationship

A blog post can have, in our case, exactly one additional info attached to it. The info always belongs to exactly one blog post. If the blog post gets deleted, the info does get related.

Class T3docs\BlogExample\Domain\Model\Post
class Post extends AbstractEntity implements \Stringable
{
    /**
     * 1:1 optional relation
     */
    protected ?Info $additionalInfo = null;

    public function getAdditionalInfo(): ?Info
    {
        return $this->additionalInfo;
    }

    public function setAdditionalInfo(?Info $additionalInfo): void
    {
        $this->additionalInfo = $additionalInfo;
    }
}
Copied!

1:n-relationship

A blog can have multiple posts in it. If a blog is deleted all of its posts should be deleted. However a blog might get displayed without displaying the posts therefore we load the posts of a blog lazily:

Class T3docs\BlogExample\Domain\Model\Blog
use TYPO3\CMS\Extbase\Persistence\ObjectStorage;

class Blog extends AbstractEntity
{
    /**
     * @var ?ObjectStorage<Post>
     */
    public ?ObjectStorage $posts = null;

    /**
     * Adds a post to this blog
     */
    public function addPost(Post $post): void
    {
        $this->posts?->attach($post);
    }

    /**
     * Remove a post from this blog
     */
    public function removePost(Post $postToRemove): void
    {
        $this->posts?->detach($postToRemove);
    }

    /**
     * Returns all posts in this blog
     *
     * @return ObjectStorage<Post>
     */
    public function getPosts(): ObjectStorage
    {
        return $this->posts;
    }

    /**
     * @param ObjectStorage<Post> $posts
     */
    public function setPosts(ObjectStorage $posts): void
    {
        $this->posts = $posts;
    }
}
Copied!

Each post belongs to exactly one blog, of course a blog does not get deleted when one of its posts gets deleted.

Class T3docs\BlogExample\Domain\Model\Post
class Post extends AbstractEntity implements \Stringable
{
    protected ?Blog $blog = null;
}
Copied!

A post can also have multiple comments and each comment belongs to exactly one blog. However we never display a comment without its post therefore we do not need to store information about the post in the comment's model: The relationship is unidirectional.

Class T3docs\BlogExample\Domain\Model\Post
use TYPO3\CMS\Extbase\Persistence\ObjectStorage;

class Post extends AbstractEntity implements \Stringable
{
    /**
     * @var ?ObjectStorage<Comment>
     */
    public ?ObjectStorage $comments = null;
}
Copied!

The model of the comment has no property to get the blog post in this case.

n:1-relationships

n:1-relationships are the same like 1:n-relationsships but from the perspective of the object:

Each post has exactly one main author but an author can write several blog posts or none at all. He can also be a second author and no main author.

EXT:blog_example/Classes/Domain/Model/Post.php
class Post extends AbstractEntity
{
    /**
     * @var Person
     */
    protected Person $author;

    protected Person|null $secondAuthor;
}
Copied!

Once more the model of the author does not have a property containing the authors posts. If you would want to get all posts of an author you would have to make a query in the PostRepository taking one or both relationships (first author, second author) into account.

m:n-relationship

A blog post can have multiple categories, each category can belong to multiple blog posts.

Class T3docs\BlogExample\Domain\Model\Post
class Post extends AbstractEntity implements \Stringable
{
    /**
     * @var Person
     */
    protected ?Person $author = null;

    protected ?Person $secondAuthor = null;
}
Copied!

Hydrating objects

Hydrating (the term originates from doctrine/orm), or in Extbase terms thawing, is the act of creating an object from a given database row. The responsible class involved is the \TYPO3\CMS\Extbase\Persistence\Generic\Mapper\DataMapper . During the process of hydrating, the DataMapper creates objects to map the raw database data onto.

Before diving into the framework internals, let us take a look at models from the user's perspective.

Creating objects with constructor arguments

Imagine you have a table tx_extension_domain_model_blog and a corresponding model or entity (entity is used as a synonym here) \MyVendor\MyExtension\Domain\Model\Blog.

Now, also imagine there is a domain rule which states, that all blogs must have a title. This rule can easily be followed by letting the blog class have a constructor with a required argument string $title.

EXT:my_extension/Classes/Domain/Model/Blog.php
<?php

declare(strict_types=1);

namespace MyVendor\MyExtension\Domain\Model;

use TYPO3\CMS\Extbase\DomainObject\AbstractEntity;
use TYPO3\CMS\Extbase\Persistence\ObjectStorage;

class Blog extends AbstractEntity
{
    protected ObjectStorage $posts;

    public function __construct(protected string $title)
    {
        $this->posts = new ObjectStorage();
    }
}
Copied!

This example also shows how the posts property is initialized. It is done in the constructor because PHP does not allow setting a default value that is of type object.

Hydrating objects with constructor arguments

Whenever the user creates new blog objects in extension code, the aforementioned domain rule is followed. It is also possible to work on the posts ObjectStorage without further initialization. new Blog('title') is all one need to create a blog object with a valid state.

What happens in the DataMapper however, is a totally different thing. When hydrating an object, the DataMapper cannot follow any domain rules. Its only job is to map the raw database values onto a Blog instance. The DataMapper could of course detect constructor arguments and try to guess which argument corresponds to what property, but only if there is an easy mapping, that means, if the constructor takes the argument string $title and updates the property title with it.

To avoid possible errors due to guessing, the DataMapper simply ignores the constructor at all. It does so with the help of the library doctrine/instantiator.

But there is more to all this.

Initializing objects

Have a look at the $posts property in the example above. If the DataMapper ignores the constructor, that property is in an invalid state, that means, uninitialized.

To address this problem and possible others, the DataMapper will call the method initializeObject(): void on models, if it exists.

Here is an updated version of the model:

EXT:my_extension/Classes/Domain/Model/Blog.php
<?php

declare(strict_types=1);

namespace MyVendor\MyExtension\Domain\Model;

use TYPO3\CMS\Extbase\DomainObject\AbstractEntity;
use TYPO3\CMS\Extbase\Persistence\ObjectStorage;

class Blog extends AbstractEntity
{
    protected ObjectStorage $posts;

    public function __construct(protected string $title)
    {
        $this->initializeObject();
    }

    public function initializeObject(): void
    {
        $this->posts = new ObjectStorage();
    }
}
Copied!

This example demonstrates how Extbase expects the user to set up their models. If the method initializeObject() is used for initialization logic that needs to be triggered on initial creation and on hydration. Please mind that __construct() should call initializeObject().

If there are no domain rules to follow, the recommended way to set up a model would then still be to define a __construct() and initializeObject() method like this:

EXT:my_extension/Classes/Domain/Model/Blog.php
<?php

declare(strict_types=1);

namespace MyVendor\MyExtension\Domain\Model;

use TYPO3\CMS\Extbase\DomainObject\AbstractEntity;
use TYPO3\CMS\Extbase\Persistence\ObjectStorage;

class Blog extends AbstractEntity
{
    protected ObjectStorage $posts;

    public function __construct()
    {
        $this->initializeObject();
    }

    public function initializeObject(): void
    {
        $this->posts = new ObjectStorage();
    }
}
Copied!

Mutating objects

Some few more words on mutators (setter, adder, etc.). One might think that DataMapper uses mutators during object hydration but it does not. Mutators are the only way for the user (developer) to implement business rules besides using the constructor.

The DataMapper uses the internal method AbstractDomainObject::_setProperty() to update object properties. This looks a bit dirty and is a way around all business rules but that is what the DataMapper needs in order to leave the mutators to the users.

Property visibility

One important thing to know is that Extbase needs entity properties to be protected or public. As written in the former paragraph, AbstractDomainObject::_setProperty() is used to bypass setters. However, AbstractDomainObject is not able to access private properties of child classes, hence the need to have protected or public properties.

Dependency injection

Without digging too deep into dependency injection the following statements have to be made:

  • Extbase expects entities to be so-called prototypes, that means classes that do have a different state per instance.
  • DataMapper does not use dependency injection for the creation of entities, that means it does not query the object container. This also means, that dependency injection is not possible in entities.

If you think that your entities need to use/access services, you need to find other ways to implement it.

Event

The PSR-14 event AfterObjectThawedEvent is available to modify values when creating domain objects.

Eager loading and lazy loading

By default, Extbase loads all child objects with the parent object (so for example, all posts of a blog). This behavior is called eager loading. The annotation @TYPO3\CMS\Extbase\Annotation\ORM\Lazy causes Extbase to load and build the objects only when they are actually needed (lazy loading). This can lead to a significant increase in performance.

On cascade remove

The annotation @TYPO3\CMS\Extbase\Annotation\ORM\Cascade("remove") has the effect that, if a blog is deleted, its posts will also be deleted immediately. Extbase usually leaves all child objects' persistence unchanged.

Besides these two, there are a few more annotations available, which will be used in other contexts. For the complete list of all Extbase supported annotations, see the chapter Annotations.

Identifiers in localized models

Domain models have a main identifier uid and an additional property _localizedUid.

Depending on whether the languageOverlayMode mode is enabled (true or 'hideNonTranslated') or disabled (false), the identifier contains different values.

When languageOverlayMode is enabled, then the uid property contains the uid value of the default language record, the uid of the translated record is kept in the _localizedUid.

Context Record in language 0 Translated record
Database uid:2 uid:11, l10n_parent:2
Domain object values with languageOverlayMode enabled uid:2, _localizedUid:2 uid:2, _localizedUid:11
Domain object values with languageOverlayMode disabled uid:2, _localizedUid:2 uid:11, _localizedUid:11

Extending existing models

It is possible, with some limitations, to extend existing Extbase models in another extension. See also Tutorial: Extending an Extbase model.