Extbase domain model 

A domain model is a PHP class that represents one type of data your extension works with — an event, a product, a blog post, 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 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 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 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 the object is always in a valid state before it is populated by Extbase or by your code.
  • Provide getters. Provide setters for properties that should be changeable after construction. Read-only properties need only 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. When 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 the native PHP attribute syntax to control persistence behaviour and validation.

Changed in version 14.0

The four attributes you will use on model properties:

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 entirely. 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 leaves 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.

Enum properties in Extbase domain models 

Backed enums — enums with an underlying string or int value, introduced with 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 automatically.

Define the enum:

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 #[Transient] to exclude it from persistence entirely. Extbase will never read or write the corresponding column. The property is populated by your own code — typically a getter that computes the value from 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. The class \MyVendor\MyExtension\Domain\Model\Conference maps to the table tx_myextension_domain_model_conference. Within the table, each camelCase property maps to a snake_case column.

When a table or column does not match the convention — for example, when you are mapping to an existing table like fe_users — 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 table. TCA tells TYPO3 what columns exist, what type they are, and how they behave in the backend. Without it, neither the backend nor the database analyser knows 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 the SQL from the TCA and creates or updates the columns when the database analyser runs (for example after installing or updating an extension).

You still need ext_tables.sql when you require:

  • 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: 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 the existing one.
  • Equality by value — two instances with identical properties are interchangeable. Compare them with an equals() method, not with ===.
  • 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 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.