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.
On this page
AbstractEntity — what you get for free
Every persisted domain object extends
\TYPO3\:
use TYPO3\CMS\Extbase\DomainObject\AbstractEntity;
class Conference extends AbstractEntity
{
// your properties here
}
You do not need to declare
$uid or
$pid — they are inherited.
Abstract provides:
get— returnsUid (): ?int nulluntil the object is persistedget— the page UID the record lives onPid (): ?int 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()
Note
Do not extend
Abstract.
Abstract
is the correct base class for objects that have identity (a UID).
Abstract exists but
is marked
@internal — see Value objects in Extbase domain models below.
Defining model properties in Extbase
Properties should be
protected, typed, and have a default value:
<?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;
}
}
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_setmethod from writing to them — and changes to private properties are never persisted for the same reason. See Model properties declared private are never populated.Property () - 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 (() is, notPublished () get).Published ()
Column mapping convention: Extbase maps camelCase property names to
snake_case database columns automatically. The property
$conference
maps to the column
conference_. 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.
See also
Private properties silently ignored for why private properties are silently ignored, with the full technical explanation.
Field and table mapping overrides are covered in the mapping reference (coming soon) and in storagePid — when findAll() returns nothing.
PHP attributes in Extbase domain models
Extbase uses native PHP attribute syntax to control persistence behaviour and validation.
Changed in version 14.0
DocBlock annotation support was removed. See Annotations replaced by PHP attributes (TYPO3 v12 / required from v14) for the migration table and the relevant Rector rule.
The four possible attributes on model properties are:
| Name | Type | Default |
|---|---|---|
Lazy
|
||
Cascade
|
||
Transient
|
||
\Validate
|
#[Lazy]
-
- Type
Lazy
Defers loading of a related object or
Objectuntil the getter is first called. Use on relations in list views where you often do not need the related data.Storage
#[Cascade('remove')]
-
- Type
Cascade
Deletes related objects automatically when the owning object is deleted. Only
'remove'is supported.
#[Transient]
-
- Type
Transient
Excludes a property from persistence. Useful for computed values or temporary state that should never reach the database.
#[Validate]
-
- Type
\Validate
Declares a validation rule on a property. The validator runs when the object is submitted via a controller action.
#is repeatable — apply multiple validators to one property.[Validate]
Import from the :php:`TYPO3CMSExtbaseAttributeORM` namespace:
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;
Warning
If you are migrating from an older extension, replace all DocBlock
annotations (
@Extbase\,
@Extbase\, etc.)
with their attribute equivalents. The old syntax causes a silent failure
in v14 meaning that Extbase will ignore the annotation without an error.
See also
Extbase PHP attributes for all Extbase PHP attributes with parameters and usage examples
Modelling relations in Extbase
A relation is just a property, and Extbase offers two shapes for it: a property
that holds one other object, and a property that holds many objects in an
Object. The following example shows both — a relation to one
Location and a relation to many
Comment objects:
<?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 = '';
// a relation to one other object — a nullable typed property
protected Location|LazyLoadingProxy|null $location = null;
/**
* @var ObjectStorage<Comment>
*/
#[Lazy]
#[Cascade('remove')]
protected ObjectStorage $comments;
public function __construct()
{
$this->initializeObject();
}
public function initializeObject(): void
{
$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);
}
}
A few things to note in the example above:
- A relation to a single other object is a single typed property, nullable when
the related object is optional. If
#is applied, Extbase installs a[Lazy] Lazyinstead of loading the related object immediately. The union typeLoading Proxy Locationis required so that Extbase can set the proxy. The|Lazy Loading Proxy |null instanceof Lazycheck in the getter exists solely for static analysis — without it PHPStan cannot narrow the return type toLoading Proxy ?Location. If you do not need a precisely typed getter, the proxy resolves automatically on any access and the check can be omitted. - A relation to many objects is an
Object. You read it by iterating, and change it throughStorage add/Comment () removemethods that callComment () attachand() detach— never manipulate the storage property directly. The() @var Objectannotation is required for IDE autocompletion and static analysis, even though PHP does not enforce the generic type.Storage<Comment> - Each ObjectStorage must be initialised, otherwise the first access
to the typed property triggers a fatal error. Do this in
initializeand call that method from the constructor. Extbase callsObject () initializeitself after mapping a record from the database, so the storage is ready on loaded objects; calling it fromObject () __as well covers objects you create withconstruct () new. Both code paths then end up with an initialised storage. #on an[Lazy] Objectmeans 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 is loaded.Storage #on[Cascade ('remove')] $commentsmeans that when thisConferenceobject is deleted, all relatedCommentobjects 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.
See also
- Object relations in Extbase — explains the two relation types, how they are stored, unidirectional and bidirectional relations, lazy loading, and the N+1 query problem.
- Extbase PHP attributes — all Extbase PHP attributes, with parameters and usage examples.
File reference properties
A model property that maps to a
FAL file uses
File — Extbase's own
thin wrapper around the
sys_ table. A single file becomes
a nullable property. A collection uses
Object:
<?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;
}
}
The corresponding TCA column must be of type=file.
In a Fluid template, pass the
File object to
f:image. This will honour crop
configuration or any additional properties set in the TYPO3 backend for that file reference:
<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>
See also
File uploads in Extbase domain models for handling file uploads submitted through a
frontend form, including the
# attribute, validation,
and deletion.
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
Enum converts the
stored value to and from the enum instance.
Define the enum as follows:
<?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';
}
Use it as a model property:
<?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;
}
}
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.
Note
Pure unit enums (without a backing type) are not supported because there is no stable scalar value to store in the database. Always use backed enums for model properties.
Non-persisted (transient) properties in Extbase models
Mark a property as
# 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:
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;
}
}
Table and field mapping
Extbase derives the database table name from the class name, for example, the class
\My maps to the table
tx_. 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_ which already exists in the system, override the mapping
in 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'],
],
],
];
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
Tip
Writing a model class and its TCA by hand is error-prone and
repetitive. The TYPO3 Kickstarter
(
friendsoftypo3/kickstarter
) can generate both together via
vendor/ commands. It is the recommended
starting point when creating new models from scratch.
See also
Extension folder Configuration/TCA — the Configuration/
folder in your extension, where TCA files live.
TCA reference — column types — full list of column types and their auto-creation support.
File structure — complete extension file and folder structure reference.
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
equalsmethod, 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
and another
new Color 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\ exists in v14 but
is marked
@internal. It is not public API and must not be extended in
extension code.
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);
}
}
Note that
with 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:
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;
}
}
If the value object genuinely needs its own table and identity, it is no longer
a value object — use
Abstract instead.
Note
If the set of possible values is fixed and known at compile time — a salutation, a status, a priority level — use a backed enum instead. Enums are simpler, Extbase maps them automatically, and PHP enforces the construction of only valid cases. Value objects are the right choice when the value has structure, behaviour, or validation logic beyond what an enum can express.
See also
Enum properties in Extbase domain models for backed enums as model properties, including automatic conversion by Extbase.
Hydrating / thawing objects of Extbase models
Warning
The information in this section might be outdated!
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\.
During the process of hydrating, the
Data 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 model objects with constructor arguments
Imagine you have a table
tx_ and a
corresponding model or entity (entity is used as a synonym here)
\My.
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.
<?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)
{
// Property "posts" is not initialized on thawing / fetching from database!!
// Must be initialized in initializeObject()!!
$this->posts = new ObjectStorage();
}
}
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
Object without further initialization.
new Blog is
all one need to create a blog object with a valid state.
What happens in the
Data however, is a totally different thing.
When hydrating an object, the
Data cannot follow any domain rules.
Its only job is to map the raw database values onto a
Blog instance. The
Data 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
Data 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
Data ignores the constructor, that property is in an invalid state,
that means, uninitialized.
To address this problem and possible others, the
Data will call the
method
initialize on models, if it exists.
Here is an updated version of the model:
<?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();
}
}
This example demonstrates how Extbase expects the user to set up their models.
If the method
initialize is used for initialization logic that
needs to be triggered on initial creation and on hydration. Please mind
that
__ should call
initialize.
If there are no domain rules to follow, the recommended way to set up a model
would then still be to define a
__ and
initialize method like this:
<?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();
}
}
Warning
Since Extbase does not call the constructor when thawing objects, special care must be taken regarding default values. See Default values for model properties for recommendations.
Mutating objects
Some few more words on mutators (setter, adder, etc.). One might think that
Data 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
Data uses the internal method
Abstract to update object properties. This
looks a bit dirty and is a way around all business rules but that is what the
Data needs in order to leave the mutators to the users.
Warning
While the
Data does not use any mutators, other parts of
Extbase do. Both, validation and property
mapping, either use existing mutators or gather type information from them.
Property visibility
One important thing to know is that Extbase needs entity properties to be
protected or public. As written in the former paragraph,
Abstract is used to bypass setters.
However,
Abstract 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.
Datadoes 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.Mapper
If you think that your entities need to use/access services, you need to find other ways to implement it.
Using an event when a object is thawed
The PSR-14 event AfterObjectThawedEvent is available to modify values when creating domain objects.