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.
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 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 changed since the
object was loaded and only writes those columns on
update()
Note
Do not extend
Abstract
directly.
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
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 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 (() 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_. 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.
See also
Private properties silently ignored — 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 the 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 available Rector rule.
The four attributes you will use on model properties:
| 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 entirely. 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 — Extbase will ignore the annotation without an error.
See also
Extbase PHP attributes — all Extbase PHP attributes with parameters and usage examples
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:
<?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);
}
}
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
#is applied, Extbase installs a[Lazy] Lazyinstead of loading the related object immediately. The union typeLoading Proxy Locationis required so 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. #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 was loaded.Storage #on[Cascade ('remove')] $commentsmeans: when thisConferenceis 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 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
add/Comment () removepair usesComment () ObjectandStorage:: attach () Object. Do not manipulateStorage:: detach () $this->commentsdirectly. - The
@var Objectdocblock is required for IDE autocompletion and static analysis, even though PHP itself does not enforce generic types.Storage<Comment>
See also
Persistence relations — relations, lazy loading, and the N+1 query trap.
Extbase PHP attributes — all Extbase PHP attributes with parameters and usage examples
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
Enum converts the stored value to and from the enum instance
automatically.
Define the enum:
<?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 — 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
# 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:
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. The class
\My maps to the table
tx_. 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_ — 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 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
Tip
Writing a model class and its TCA by hand in parallel is error-prone and
repetitive. The TYPO3 Kickstarter
(
friendsoftypo3/kickstarter
) can generate both together via
its
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: 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
equalsmethod, 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
and another
new Color 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\ 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 that only valid cases can be constructed. Value objects are the right choice when the value has structure, behaviour, or validation logic beyond what an enum case can express.
See also
Enum properties in Extbase domain models — backed enums as model properties, including automatic conversion by Extbase.