Extbase domain model 

A domain model is a PHP class that represents one type of data that your extension works with, for example, an event, a product, a blog post, or a speaker. Each instance of the class corresponds to one database record. Extbase maps between the two automatically.

AbstractEntity — what you get for free 

Every persisted domain object extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity :

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

class Conference extends AbstractEntity
{
    // your properties here
}
Copied!

You do not need to declare $uid or $pid — they are inherited. AbstractEntity provides:

  • getUid(): ?int — returns null until the object is persisted
  • getPid(): ?int — the page UID the record lives on
  • setPid(int $pid): void
  • Dirty-state tracking — Extbase knows which properties have changed since the object was loaded and only writes those columns on update()

Defining model properties in Extbase 

Properties should be protected, typed, and have a default value:

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

declare(strict_types=1);

namespace MyVendor\MyExtension\Domain\Model;

use TYPO3\CMS\Extbase\Attribute\ORM\Validate;
use TYPO3\CMS\Extbase\DomainObject\AbstractEntity;

class Conference extends AbstractEntity
{
    #[Validate(validator: 'NotEmpty')]
    protected string $title = '';

    protected string $description = '';

    protected ?\DateTimeImmutable $conferenceDate = null;

    protected bool $published = false;

    public function getTitle(): string
    {
        return $this->title;
    }

    public function setTitle(string $title): void
    {
        $this->title = $title;
    }

    public function getDescription(): string
    {
        return $this->description;
    }

    public function setDescription(string $description): void
    {
        $this->description = $description;
    }

    public function getEventDate(): ?\DateTimeImmutable
    {
        return $this->conferenceDate;
    }

    public function setEventDate(?\DateTimeImmutable $conferenceDate): void
    {
        $this->conferenceDate = $conferenceDate;
    }

    public function isPublished(): bool
    {
        return $this->published;
    }

    public function setPublished(bool $published): void
    {
        $this->published = $published;
    }
}
Copied!

Key rules:

  • Declare properties as protected. Public properties work but bypass getters and setters, making lazy loading and dirty-state tracking harder to reason about. Private properties are never populated during hydration — PHP prevents the parent _setProperty() method from writing to them — and changes to private properties are never persisted for the same reason. See Model properties declared private are never populated.
  • Every property needs a meaningful default so that the object is always in a valid state before it is populated by Extbase or your code.
  • Provide getters. Provide setters for properties that should be changeable after construction. Read-only properties onyl need a getter.
  • Boolean properties follow the is*() / set*() convention ( isPublished(), not getPublished()).

Column mapping convention: Extbase maps camelCase property names to snake_case database columns automatically. The property $conferenceDate maps to the column conference_date. If your table or column names do not follow this convention, override the mapping in Configuration/Extbase/Persistence/Classes.php. See Table and field mapping for the full syntax.

PHP attributes in Extbase domain models 

Extbase uses native PHP attribute syntax to control persistence behaviour and validation.

Changed in version 14.0

The four possible attributes on model properties are:

Name Type Default
Lazy
Cascade
Transient
\Validate

#[Lazy]

#[Lazy]
Type
Lazy

Defers loading of a related object or ObjectStorage until the getter is first called. Use on relations in list views where you often do not need the related data.

#[Cascade('remove')]

#[Cascade('remove')]
Type
Cascade

Deletes related objects automatically when the owning object is deleted. Only 'remove' is supported.

#[Transient]

#[Transient]
Type
Transient

Excludes a property from persistence. Useful for computed values or temporary state that should never reach the database.

#[Validate]

#[Validate]
Type
\Validate

Declares a validation rule on a property. The validator runs when the object is submitted via a controller action. #[Validate] is repeatable — apply multiple validators to one property.

Import from the :php:`TYPO3CMSExtbaseAttributeORM` namespace:

EXT:my_extension/Classes/Domain/Model/Conference.php
use TYPO3\CMS\Extbase\Attribute\ORM\Cascade;
use TYPO3\CMS\Extbase\Attribute\ORM\Lazy;
use TYPO3\CMS\Extbase\Attribute\ORM\Transient;
use TYPO3\CMS\Extbase\Attribute\ORM\Validate;

// on model properties:
#[Validate(validator: 'NotEmpty')]
protected string $title = '';

#[Validate(validator: 'StringLength', options: ['minimum' => 3, 'maximum' => 50])]
protected string $slug = '';

#[Lazy]
#[Cascade('remove')]
protected ObjectStorage $comments;

#[Transient]
protected ?string $computedLabel = null;
Copied!

Modelling relations in Extbase 

The following example shows a model with both relation types — a 1:1 relation to a Location and a 1:n relation to a collection of Comment objects:

EXT:my_extension/Classes/Domain/Model/Conference.php (with relations)
<?php

declare(strict_types=1);

namespace MyVendor\MyExtension\Domain\Model;

use TYPO3\CMS\Extbase\Attribute\ORM\Cascade;
use TYPO3\CMS\Extbase\Attribute\ORM\Lazy;
use TYPO3\CMS\Extbase\DomainObject\AbstractEntity;
use TYPO3\CMS\Extbase\Persistence\Generic\LazyLoadingProxy;
use TYPO3\CMS\Extbase\Persistence\ObjectStorage;

class Conference extends AbstractEntity
{
    protected string $title = '';

    // 1:1 relation — a nullable typed property
    protected Location|LazyLoadingProxy|null $location = null;

    /**
     * @var ObjectStorage<Comment>
     */
    #[Lazy]
    #[Cascade('remove')]
    protected ObjectStorage $comments;

    public function __construct()
    {
        $this->comments = new ObjectStorage();
    }

    public function getTitle(): string
    {
        return $this->title;
    }

    public function setTitle(string $title): void
    {
        $this->title = $title;
    }

    public function getLocation(): ?Location
    {
        // type check only for phpstan
        if ($this->location instanceof LazyLoadingProxy) {
            $this->location = $this->location->_loadRealInstance();
        }
        return $this->location;
    }

    public function setLocation(?Location $location): void
    {
        $this->location = $location;
    }

    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!

A few things to note in the example above:

  • Singular relations (a typed property, nullable when the related object is optional) are one common pattern. When #[Lazy] is applied, Extbase installs a LazyLoadingProxy instead of loading the related object immediately. The union type Location|LazyLoadingProxy|null is required so Extbase can set the proxy. The instanceof LazyLoadingProxy check in the getter exists solely for static analysis — without it PHPStan cannot narrow the return type to ?Location. If you do not need a precisely typed getter, the proxy resolves automatically on any access and the check can be omitted.
  • #[Lazy] on an ObjectStorage means Extbase loads the related records only when you first iterate over the storage or call a method on it. This avoids loading potentially hundreds of related records just because the parent object was loaded.
  • #[Cascade('remove')] on $comments means: when this Conference is deleted, all related Comment objects are also deleted. A comment has no life outside its event, so this is the right choice. Without this attribute, deleting the event would leave orphaned comment records in the database. Use cascade remove only when the related objects genuinely belong to the parent and have no independent existence.
  • The addComment() / removeComment() pair uses ObjectStorage::attach() and ObjectStorage::detach(). Do not manipulate $this->comments directly.
  • The @var ObjectStorage<Comment> docblock is required for IDE autocompletion and static analysis, even though PHP itself does not enforce generic types.

File reference properties 

A model property that maps to a FAL file uses FileReference — Extbase's own thin wrapper around the sys_file_reference table. A single file becomes a nullable property. A collection uses ObjectStorage :

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

declare(strict_types=1);

namespace MyVendor\MyExtension\Domain\Model;

use TYPO3\CMS\Extbase\Domain\Model\FileReference;
use TYPO3\CMS\Extbase\DomainObject\AbstractEntity;
use TYPO3\CMS\Extbase\Persistence\ObjectStorage;

class Conference extends AbstractEntity
{
    protected ?FileReference $logo = null;

    /**
     * @var ObjectStorage<FileReference>
     */
    protected ObjectStorage $impressions;

    public function __construct()
    {
        $this->impressions = new ObjectStorage();
    }

    public function initializeObject(): void
    {
        $this->impressions = $this->impressions ?? new ObjectStorage();
    }

    public function getLogo(): ?FileReference
    {
        return $this->logo;
    }

    public function setLogo(?FileReference $logo): void
    {
        $this->logo = $logo;
    }

    /**
     * @return ObjectStorage<FileReference>
     */
    public function getImpressions(): ObjectStorage
    {
        return $this->impressions;
    }

    public function setImpressions(ObjectStorage $impressions): void
    {
        $this->impressions = $impressions;
    }
}
Copied!

The corresponding TCA column must be of type=file.

In a Fluid template, pass the FileReference object to f:image. This will honour crop configuration or any additional properties set in the TYPO3 backend for that file reference:

EXT:my_extension/Resources/Private/Templates/Conference/Show.fluid.html
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
      data-namespace-typo3-fluid="true">

<f:layout name="Default" />

<f:section name="main">
    <f:if condition="{conference.logo}">
        <f:image image="{conference.logo}" cropVariant="default" alt="{conference.title}" />
    </f:if>

    <f:for each="{conference.impressions}" as="impression">
        <f:image image="{impression}" cropVariant="default" alt="{conference.title}" />
    </f:for>
</f:section>

</html>
Copied!

Enum properties in Extbase domain models 

Backed enums — enums with an underlying string or int value (introduced in PHP 8.1) — work in Extbase models without any extra configuration. Extbase's built-in EnumConverter converts the stored value to and from the enum instance.

Define the enum as follows:

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

declare(strict_types=1);

namespace MyVendor\MyExtension\Domain\Model\Enum;

enum Salutation: string
{
    case None = '';
    case Mr = 'mr';
    case Ms = 'ms';
    case Mx = 'mx';
}
Copied!

Use it as a model property:

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

declare(strict_types=1);

namespace MyVendor\MyExtension\Domain\Model;

use MyVendor\MyExtension\Domain\Model\Enum\Salutation;
use TYPO3\CMS\Extbase\DomainObject\AbstractEntity;

class Speaker extends AbstractEntity
{
    protected string $name = '';

    protected Salutation $salutation = Salutation::None;

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

    public function setName(string $name): void
    {
        $this->name = $name;
    }

    public function getSalutation(): Salutation
    {
        return $this->salutation;
    }

    public function setSalutation(Salutation $salutation): void
    {
        $this->salutation = $salutation;
    }
}
Copied!

The database column stores the raw backing value ( '', 'mr', 'ms', 'mx'). Extbase converts it to the enum case on read and back to the string on write.

Non-persisted (transient) properties in Extbase models 

Mark a property as #[Transient] to exclude it from persistence. Extbase will never read or write the corresponding column. The property should then be populated by your own code, which is typically a getter that computes a value using other properties:

EXT:my_extension/Classes/Domain/Model/Conference.php
namespace MyVendor\MyExtension\Domain\Model;

use TYPO3\CMS\Extbase\Attribute\ORM\Transient;
use TYPO3\CMS\Extbase\DomainObject\AbstractEntity;

class Conference extends AbstractEntity
{
    protected string $title = '';

    protected ?\DateTimeImmutable $conferenceDate = null;

    #[Transient]
    protected ?string $displayLabel = null;

    public function getDisplayLabel(): string
    {
        if ($this->displayLabel === null) {
            $this->displayLabel = $this->title . ' (' . $this->conferenceDate?->format('Y') . ')';
        }
        return $this->displayLabel;
    }
}
Copied!

Table and field mapping 

Extbase derives the database table name from the class name, for example, the class \MyVendor\MyExtension\Domain\Model\Conference maps to the table tx_myextension_domain_model_conference. Each camelCase property maps to a snake_case column inside the table.

If a table or column does not match the convention, for example, a table like fe_users which already exists in the system, override the mapping in Configuration/Extbase/Persistence/Classes.php:

EXT:my_extension/Configuration/Extbase/Persistence/Classes.php
// Configuration/Extbase/Persistence/Classes.php
return [
    \MyVendor\MyExtension\Domain\Model\FrontendUser::class => [
        'tableName' => 'fe_users',
        'properties' => [
            'firstName' => ['fieldName' => 'first_name'],
        ],
    ],
];
Copied!

Configuring persistence for Extbase models 

A model class alone is not enough — TYPO3 also needs a TCA (Table Configuration Array) definition for the corresponding database table. TCA tells TYPO3 which columns exist, what type they are, and how they behave in the backend. Without TCA, neither the backend nor the database analyser would know anything about your table.

The database analyser can create the actual database columns automatically from TCA definitions. This means you do not need ext_tables.sql for standard column types — TYPO3 derives SQL from the TCA and creates or updates the columns when the database analyser runs (for example after installing or updating an extension).

You only need ext_tables.sql for:

  • Custom column types not covered by TCA (for example JSON, spatial types)
  • Explicit indices beyond the defaults
  • Precise control over column length or collation

Value objects in Extbase domain models 

In Domain-Driven Design, a value object is an object defined entirely by its value rather than by an identity. Two value objects are equal if all their properties are equal — there is no UID, no database row, no concept of "the same object over time". Classic examples are: a monetary amount, a date range, a GPS coordinate, a color.

Value objects have three characteristics that make them useful:

  • Immutable — once created, they cannot be changed. Operations return a new instance rather than modifying an existing one.
  • Equality by value — two instances with identical properties are interchangeable. Compare them using an equals() method, not ===.
  • Self-validating — the constructor rejects invalid state, so a value object that exists is always valid.

A Color value object is a straightforward example: new Color('Midnight Blue', '#191970') and another new Color('Midnight Blue', '#191970') are equal and interchangeable. Neither has an identity. You never update a colour, you just replace it with a new one.

In TYPO3 and Extbase, value objects are implemented as plain PHP classes. \TYPO3\CMS\Extbase\DomainObject\AbstractValueObject exists in v14 but is marked @internal. It is not public API and must not be extended in extension code.

EXT:my_extension/Classes/Domain/Model/Color.php
final class Color
{
    public function __construct(
        public readonly string $name,
        public readonly string $hex,
    ) {
        if (!preg_match('/^#[0-9a-fA-F]{6}$/', $hex)) {
            throw new \InvalidArgumentException('Invalid hex color: ' . $hex);
        }
    }

    public function equals(self $other): bool
    {
        return $this->hex === $other->hex;
    }

    public function withName(string $name): self
    {
        return new self($name, $this->hex);
    }
}
Copied!

Note that withName() returns a new Color instance rather than modifying $this — that is the immutability principle in practice. The constructor validates the hex format immediately, so an invalid Color can never exist.

Persistence: value objects are not persisted as their own database records. Store them as scalar columns on the owning entity and reconstruct the object in a getter:

EXT:my_extension/Classes/Domain/Model/Product.php
class Product extends AbstractEntity
{
    protected string $colorName = '';
    protected string $colorHex = '#000000';

    public function getColor(): Color
    {
        return new Color($this->colorName, $this->colorHex);
    }

    public function setColor(Color $color): void
    {
        $this->colorName = $color->name;
        $this->colorHex = $color->hex;
    }
}
Copied!

If the value object genuinely needs its own table and identity, it is no longer a value object — use AbstractEntity instead.