Entities extend AbstractEntity which provides built-in validation, lifecycle callback delegation, JSON serialization, and static repository access. The framework's convention differs from standard Symfony/Doctrine practice on one point: ORM-mapped properties should be protected rather than private — see Properties should be protected below for the rationale.
In standard Symfony projects, entity properties are typically private. In this framework they should be protected. AbstractEntity::jsonSerialize() filters properties by ReflectionProperty::IS_PROTECTED, and lifecycle callbacks also read state via reflection — private column/join/relationship properties are skipped by both, so they effectively vanish from the serialized output (the entity exposes only its id) and are unreachable from lifecycle callbacks. Entity-column auto-discovery for translation keys relies on the same reflection-based scan, so a private column/relationship property also breaks the per-entity translation label flow.
// Wrong for ORM-mapped properties — jsonSerialize() will skip this field
private string $email;
// Correct
protected string $email;
Every column-mapped property, every join column property, and every relationship property should be protected. private is acceptable only for non-ORM-mapped state (computed properties, internal caches) that is not part of the entity's persisted or serialized surface — but if you ever serialize the entity or hook a lifecycle callback to it, those private fields will not be visible.
// App/Entity/Player.php
namespace App\Entity;
use App\Repository\PlayerRepository;
use PHP_SF\System\Traits\ModelProperty\ModelPropertyCreatedAtTrait;
use Doctrine\ORM\Events;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
use PHP_SF\System\Classes\Abstracts\AbstractEntity;
#[ORM\Entity( repositoryClass: PlayerRepository::class )]
#[ORM\Table( name: 'players' )]
#[ORM\Cache( usage: 'READ_WRITE' )]
class Player extends AbstractEntity
{
use ModelPropertyCreatedAtTrait;
#[Assert\Length( min: 2, max: 35 )]
#[ORM\Column( type: 'string', unique: true )]
protected string $login;
#[Assert\Email]
#[Assert\Length( min: 6, max: 50 )]
#[ORM\Column( type: 'string', unique: true )]
protected string $email;
#[ORM\Column( type: 'string' )]
protected string $password;
#[Assert\Range( min: 1, max: 100 )]
#[ORM\Column( type: 'integer', options: [ 'default' => 1 ] )]
protected int $level = 1;
public function getLifecycleCallbacks(): array
{
return [];
}
public function getLogin(): string { return $this->login; }
public function setLogin( string $login ): self
{
$this->login = $login;
return $this;
}
public function getEmail(): ?string { return $this->email; }
public function setEmail( ?string $email ): self
{
$this->email = $email;
return $this;
}
public function getPassword(): ?string { return $this->password; }
public function setPassword( #[SensitiveParameter] ?string $password ): self
{
$this->password = password_hash( $password, PASSWORD_ARGON2I );
return $this;
}
public function getLevel(): int { return $this->level; }
public function setLevel( int $level ): self
{
$this->level = $level;
return $this;
}
}
AbstractEntity provides static methods via EntityRepositoriesTrait for common queries without touching the repository directly:
// Find by primary key
$player = Player::find( 42 ); // Player|null
// Find by criteria
$player = Player::findOneBy( [ 'email' => 'user@example.com' ] );
// Find multiple by criteria
$players = Player::findBy(
[ 'level' => 10 ],
[ 'createdAt' => 'DESC' ],
limit: 20,
offset: 0
);
// Find all
$allPlayers = Player::findAll();
All collection methods return arrays keyed by entity ID — [ $id => $entity ]. This is intentional: it makes deduplication and ID-based lookup cheap without an additional pass.
// Create a new instance
$player = Player::new();
// Or directly
$player = new Player();
AbstractEntity carries the #[ORM\MappedSuperclass] and #[ORM\HasLifecycleCallbacks] attributes on AbstractEntityContract (the actual base — AbstractEntity itself is a thin shell that adds the integer PK). Entities that extend AbstractEntity therefore:
MappedSuperclass descendants (no schema is generated for the parent itself — only for concrete subclasses),DoctrineCallbacksLoader (see Doctrine lifecycle callbacks for the supported event list and registration pattern).This is also why entity-compact --entity_dir=App/Entity regenerates the right compact-DTO outputs after you add or remove properties — it walks the project entity tree and uses these class-level attributes to discover what to project.
Call validate() before persisting. It returns true on success or false on failure, with errors accessible via getValidationErrors():
$player = new Player();
$player->setLogin( $login );
$player->setEmail( $email );
$player->setPassword( $password );
if ( $player->validate() !== true )
return $this->redirectBack(
errors: array_values( $player->getValidationErrors() )
);
Player::rep()->persist( $player );
validate() runs the Symfony validator (with attribute mapping enabled) over the entity and collects every violation into getValidationErrors(). It does not scan #[ORM\Column]/#[ORM\JoinColumn] attributes for unique/nullable, and it does not query the database — uniqueness and nullability are enforced at the schema level, not here.
Symfony collects ALL violations per property — if $email fails both Length and Email, both are reported. Multiple properties can have errors simultaneously.
getValidationErrors() has two return shapes (signature array<string, string>|bool):
// On success — returns bool true. The empty-array branch is unreachable
// because validate() returns true on the same condition (empty $validationErrors).
if ( $player->getValidationErrors() === true ) { /* clean */ }
// On failure — returns array<string, string> keyed by Symfony property path,
// values are pre-translated message strings (see translation key flow below).
$errors = $player->getValidationErrors(); // [ 'email' => 'E-mail is too long', ... ]
Always guard on the return value of validate() first; only call getValidationErrors() inside the failure branch.
Each violation message is rendered by the framework as _t('entity.field_validation_error', ['field' => '@:player.fields.email', 'message' => 'too long']). The wrapper key (entity.field_validation_error) is shipped by the framework; only the per-property field label needs to exist in your locale file. The @:player.fields.email value is a lazy @: reference — at render time it resolves to the value of player.fields.email in the current locale (falling back to DEFAULT_LOCALE on a miss).
By framework convention, new translation keys are auto-discovered the first time _t() resolves them — devs don't hand-place entries under translations/. You only need to supply the actual translation values for each locale (the bare field-label keys, one per constrained property):
# translations/en.yaml
player.fields.login: Login
player.fields.email: E-mail
player.fields.level: Level
Do not translate entity.field_validation_error yourself — it is shared across every entity. See Translation for the wider key-discovery flow.
Entities use standard Symfony constraints from Symfony\Component\Validator\Constraints. The import alias used throughout the codebase is:
use Symfony\Component\Validator\Constraints as Assert;
The full constraint reference is in the Symfony docs. The most commonly used ones:
#[Assert\NotBlank]
#[Assert\Length( min: 2, max: 35 )]
#[ORM\Column( type: 'string' )]
protected string $login;
#[Assert\NotBlank]
#[Assert\Email]
#[Assert\Length( min: 6, max: 50 )]
#[ORM\Column( type: 'string', unique: true )]
protected string $email;
#[Assert\Range( min: 1, max: 100 )]
#[ORM\Column( type: 'integer', options: [ 'default' => 1 ] )]
protected int $level = 1;
Use Assert\GreaterThanOrEqual or Assert\LessThanOrEqual for one-sided bounds.
#[Assert\NotBlank]
#[Assert\Choice( choices: [ 'draft', 'published', 'archived' ] )]
#[ORM\Column( type: 'string' )]
protected string $status = 'draft';
Works for both string and integer choices.
#[Assert\Type( type: \DateTimeInterface::class )]
#[ORM\Column( type: 'datetime', nullable: true )]
protected ?DateTimeInterface $lastLogin = null;
Also useful for Assert\Type(type: 'float'), Assert\Type(type: 'array'), etc.
Relationship properties follow the same protected convention:
// ManyToOne
#[ORM\ManyToOne( targetEntity: Guild::class )]
#[ORM\JoinColumn( name: 'guild_id', nullable: true )]
protected int|Guild|null $guild = null;
The int|ClassName union type is a common pattern — Doctrine may load the related entity as a proxy integer ID rather than a full object, especially with lazy loading. Handle this in the getter:
public function getGuild(): ?Guild
{
if ( is_int( $this->guild ) )
$this->guild = Guild::find( $this->guild );
return $this->guild;
}
public function setGuild( int|Guild|null $guild ): self
{
$this->guild = $guild;
return $this;
}
The framework provides traits for common timestamp properties:
Included automatically via AbstractEntity. Provides:
#[ORM\Id]
#[ORM\Cache]
#[ORM\Column( type: 'integer' )]
#[ORM\GeneratedValue( 'AUTO' )]
protected int $id;
public function getId(): int { return $this->id; }
Do not redefine $id in your entity — it's already there.
use PHP_SF\System\Traits\ModelProperty\ModelPropertyCreatedAtTrait;
class Player extends AbstractEntity
{
use ModelPropertyCreatedAtTrait;
// Adds: protected string|DateTimeImmutable|null $createdAt
// Adds: getCreatedAt(): DateTimeImmutable
// Adds: setCreatedAt(DateTimeInterface): static
}
Set in the constructor:
public function __construct()
{
$this->setCreatedAt( new DateTimeImmutable );
}
use PHP_SF\System\Traits\ModelProperty\ModelPropertyUpdatedAtTrait;
class Player extends AbstractEntity
{
use ModelPropertyUpdatedAtTrait;
// Adds: protected string|DateTimeImmutable|null $updatedAt = null
// Adds: getUpdatedAt(): ?DateTimeImmutable
// Adds: setUpdatedAt(DateTimeInterface): void
}
Typically set in a preUpdate lifecycle callback:
public function getLifecycleCallbacks(): array
{
return [
Events::preUpdate => PlayerPreUpdateCallback::class,
];
}
// App/DoctrineLifecycleCallbacks/PlayerPreUpdateCallback.php
class PlayerPreUpdateCallback extends AbstractDoctrineLifecycleCallback
{
public function callback(): void
{
$this->entity->setUpdatedAt( new DateTime );
}
}
AbstractEntity implements JsonSerializable. jsonSerialize() walks the entity's protected properties via reflection (specifically, ReflectionProperty::IS_PROTECTED) and returns them as an associative array, replacing related entity objects with their IDs:
$player->jsonSerialize();
// [
// 'id' => 42,
// 'login' => 'john_doe',
// 'email' => 'john@example.com',
// 'level' => 10,
// 'guild' => 7, ← Guild entity replaced with its ID
// 'createdAt' => '...',
// ]
Doctrine proxy objects (lazy-loaded relationships) serialize to just their integer ID. This prevents accidental N+1 queries during JSON serialization of collections.
Because the filter is IS_PROTECTED, any private column or relationship property is silently omitted from the output. If an entity has only private column fields, jsonSerialize() returns only its id — see Properties should be protected above and the GH issue referenced there.
Use directly in controllers:
return $this->ok( $player->jsonSerialize() );
// Collection
return $this->ok(
array_map(
fn( Player $p ) => $p->jsonSerialize(),
Player::findAll()
)
);
For lookup tables that never change after fixtures are loaded, mark the entity as read-only:
#[ORM\Entity( repositoryClass: UserGroupRepository::class, readOnly: true )]
#[ORM\Table( name: 'user_groups' )]
#[ORM\Cache( usage: 'READ_ONLY' )]
class UserGroup extends AbstractEntity
{
#[ORM\Column( type: 'string', unique: true )]
protected string $name;
public function getName(): string { return $this->name; }
public function getLifecycleCallbacks(): array { return []; }
}
READ_ONLY cache usage tells Doctrine this entity is never modified — it can be cached aggressively without worrying about cache invalidation on writes.
After any persist, update, or remove operation, AbstractEntity automatically clears query builder cache:
// Called automatically on postPersist, postUpdate, postRemove
public static function clearQueryBuilderCache(): void
{
ca()->deleteByKeyPattern( '*doctrine_result_cache:*' );
}
This prevents stale query results after write operations. You don't need to call it manually — it's wired into the lifecycle callbacks in DoctrineCallbacksLoader.
// App/Entity/Building.php
namespace App\Entity;
use App\DoctrineLifecycleCallbacks\BuildingPrePersistCallback;
use App\DoctrineLifecycleCallbacks\BuildingPreUpdateCallback;
use App\Repository\BuildingRepository;
use PHP_SF\System\Traits\ModelProperty\ModelPropertyCreatedAtTrait;
use PHP_SF\System\Traits\ModelProperty\ModelPropertyUpdatedAtTrait;
use Doctrine\ORM\Events;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
use PHP_SF\System\Classes\Abstracts\AbstractEntity;
#[ORM\Entity( repositoryClass: BuildingRepository::class )]
#[ORM\Table( name: 'buildings' )]
#[ORM\Cache( usage: 'READ_WRITE' )]
#[ORM\Index( columns: [ 'player_id' ] )]
class Building extends AbstractEntity
{
use ModelPropertyCreatedAtTrait;
use ModelPropertyUpdatedAtTrait;
#[Assert\NotBlank]
#[Assert\Length( min: 2, max: 50 )]
#[ORM\Column( type: 'string' )]
protected string $name;
#[Assert\Range( min: 1, max: 20 )]
#[ORM\Column( type: 'integer', options: [ 'default' => 1 ] )]
protected int $level = 1;
#[Assert\GreaterThanOrEqual( 0 )]
#[ORM\Column( type: 'integer', options: [ 'default' => 0 ] )]
protected int $constructionTime = 0;
#[Assert\NotBlank]
#[Assert\Choice( choices: [ 'farm', 'barracks', 'market', 'wall' ] )]
#[ORM\Column( type: 'string' )]
protected string $type;
#[ORM\ManyToOne( targetEntity: Player::class )]
#[ORM\JoinColumn( name: 'player_id', nullable: false )]
protected int|Player $player;
public function __construct()
{
$this->setCreatedAt( new \PHP_SF\System\Core\DateTime );
}
public function getLifecycleCallbacks(): array
{
return [
Events::prePersist => BuildingPrePersistCallback::class,
Events::preUpdate => BuildingPreUpdateCallback::class,
];
}
public function getName(): string { return $this->name; }
public function setName( string $name ): self
{
$this->name = $name;
return $this;
}
public function getLevel(): int { return $this->level; }
public function setLevel( int $level ): self
{
$this->level = $level;
return $this;
}
public function getConstructionTime(): int { return $this->constructionTime; }
public function setConstructionTime( int $time ): self
{
$this->constructionTime = $time;
return $this;
}
public function getType(): string { return $this->type; }
public function setType( string $type ): self
{
$this->type = $type;
return $this;
}
public function getPlayer(): Player
{
if ( is_int( $this->player ) )
$this->player = Player::find( $this->player );
return $this->player;
}
public function setPlayer( int|Player $player ): self
{
$this->player = $player;
return $this;
}
}
Most entities should keep extending AbstractEntity. If, however, you need a non-integer primary key — UUID v4/v7, ULID, a manually-assigned string, or a composite key — you can bypass AbstractEntity entirely by extending AbstractEntityContract instead. The contract is the same class that powers AbstractEntity underneath: it owns validation, JSON serialization, translation keys, lifecycle callback wiring, and the static repository helpers (rep(), findBy(), findAll(), findOneBy()). It deliberately does not declare a primary key, so each subclass picks its own #[ORM\Id] field.
Install a UUID generator before copying the example:
composer require symfony/uid
(ramsey/uuid works too — the example below uses symfony/uid because it's already pulled in by Symfony 8.x.)
Doctrine 3 removed the built-in UUID generator. The legacy
Doctrine\ORM\Id\UuidGeneratorand the'UUID'strategy are gone. UUIDs must now be generated application-side and assigned in your entity's factory method or constructor.
AbstractEntityContract lives in Platform/src/Classes/Abstracts/AbstractEntityContract.php and looks like this:
abstract class AbstractEntityContract extends DoctrineCallbacksLoader implements JsonSerializable
{
use EntityRepositoriesTrait;
/** @return array<string, string>|bool */
final public function validate(): bool { /* ... */ }
/** @return array<string, mixed>|int */
final public function jsonSerialize(): array|int { /* ... */ }
final public static function new(): static { return new static(); }
public static function clearQueryBuilderCache(): void { /* ... */ }
abstract public function getId(): mixed;
}
getId(): mixed is the only abstract method — each subclass narrows the return type to whatever its PK actually is. The framework ships ModelPropertyUuidTrait as a drop-in helper:
trait ModelPropertyUuidTrait
{
#[ORM\Id]
#[ORM\Column(type: 'guid')]
protected ?string $id = null;
public function getId(): ?string { return $this->id; }
public function setId(string $id): static { $this->id = $id; return $this; }
}
<?php declare(strict_types=1);
// App/Entity/Base/AbstractUuidEntity.php
namespace App\Entity\Base;
use Doctrine\ORM\Events;
use PHP_SF\System\Classes\Abstracts\AbstractEntityContract;
use PHP_SF\System\Classes\DoctrineLifecycleCallbacks\SetUuidIfEmptyCallback;
use PHP_SF\System\Traits\ModelProperty\ModelPropertyUuidTrait;
/**
* Project-wide base for entities with a string UUID primary key.
*
* Extends AbstractEntityContract directly (NOT AbstractEntity, which would
* force an integer $id on top of ours). The prePersist wiring auto-assigns a
* UUID v4 on first persist if you forgot to set one manually — see the
* "Auto-UUID lifecycle callback" section below.
*/
abstract class AbstractUuidEntity extends AbstractEntityContract
{
use ModelPropertyUuidTrait;
public function getLifecycleCallbacks(): array
{
return [Events::prePersist => SetUuidIfEmptyCallback::class];
}
}
<?php declare(strict_types=1);
// App/Entity/Order.php
namespace App\Entity;
use App\Entity\Base\AbstractUuidEntity;
use App\Repository\OrderRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: OrderRepository::class)]
#[ORM\Table(name: 'orders')]
class Order extends AbstractUuidEntity
{
#[Assert\NotBlank]
#[Assert\Length(min: 2, max: 50)]
#[ORM\Column(type: 'string')]
protected string $customerName;
public static function create(string $customerName): self
{
// No need to setId() here — SetUuidIfEmptyCallback on prePersist
// will assign a UUID v4 if (and only if) $id is still null/empty.
$order = new self();
$order->setCustomerName($customerName);
return $order;
}
public function getCustomerName(): string { return $this->customerName; }
public function setCustomerName(string $name): self
{
$this->customerName = $name;
return $this;
}
}
If you ever need to override the auto-UUID (e.g. for import scripts that load IDs from a legacy system), just call setId() yourself before persist and the callback will skip:
$order = Order::create('Acme');
$order->setId('0190e7a5-7c3a-7c2a-9d3e-1f1a2b3c4d5e'); // preserved on flush
SetUuidIfEmptyCallback is shipped as a working example of the framework's DoctrineLifecycleCallbacks pattern. It hooks Events::prePersist and assigns a UUID v4 via Symfony\Component\Uid\Uuid::v4()->toRfc4122() whenever the entity's getId() returns null or ''. Calling setId() explicitly always wins — the callback only fills in empty values, so it never overwrites an ID you've chosen deliberately.
The callback is intentionally duck-typed: it does not require the entity to extend any specific PHP-SF class, only that the entity exposes getId(): ?string and setId(string $id): static. Entities that use ModelPropertyUuidTrait get both for free, but you can also roll your own setId()/getId() if you prefer a different generator (ULID v7, KSUID, etc.).
Full lifecycle callback conventions — naming, registration, supported events, the auto cache-clearing after writes — are documented in Doctrine lifecycle callbacks.
EntityRepositoriesTrait::find() is widened to int|string, so the static shortcut accepts any PK flavor:
// Direct — works with int (auto-increment) and string (UUID/ULID) PKs:
$order = Order::find($uuid);
// Equivalent:
$order = Order::findOneBy(['id' => $uuid]);
$order = Order::rep()->find($uuid);
AbstractEntityContract makes composite keys trivial — declare multiple #[ORM\Id] fields on your own abstract base, and provide a single getId() that returns them together (typically as an array, or as a dedicated value object):
abstract class AbstractCompositeIdEntity extends AbstractEntityContract
{
#[ORM\Id]
#[ORM\Column(type: 'integer')]
protected int $tenantId = 0;
#[ORM\Id]
#[ORM\Column(type: 'string')]
protected string $slug = '';
public function getId(): array
{
return ['tenantId' => $this->tenantId, 'slug' => $this->slug];
}
}
When bypassing AbstractEntity, the contract already covers everything you need — you only re-implement parts if you fork the contract itself:
#[ORM\MappedSuperclass] and #[ORM\HasLifecycleCallbacks] — declared on AbstractEntityContract, inherited automatically. Reference: Platform/src/Classes/Abstracts/AbstractEntity.php (now a thin shell) and Platform/src/Classes/Abstracts/AbstractEntityContract.php.extends PHP_SF\System\Core\DoctrineCallbacksLoader — done by the contract.JsonSerializable::jsonSerialize() — inherited, iterates protected properties via reflection. The "properties should be protected" rule applies here too.validate(), getValidationErrors(), getTranslatablePropertyName(), new(), clearQueryBuilderCache() — all inherited from the contract.use EntityRepositoriesTrait — inherited. You get rep(), findOneBy(), findBy(), findAll() for free. find(int|string $id) accepts both PK flavors (auto-increment integer and string UUID/ULID) since v4.2.0.ModelPropertyCreatedAtTrait / ModelPropertyUpdatedAtTrait — drop-in, work unchanged. Only ModelPropertyIdTrait is off-limits here. In this project, prefer the project-local App\Doctrine\Trait\ModelProperty{CreatedAt,UpdatedAt}Trait variants so the columns stay datetimetz_immutable (see Reusable property traits).#[ORM\Entity(repositoryClass: …)], prefer PHP_SF\System\Classes\Abstracts\AbstractEntityRepository — persist() / remove() accept AbstractEntityContract since v4.2.0, so UUID and composite-PK entities work the same as integer-PK entities. Vanilla Doctrine\ORM\EntityRepository is also fine.Private column/relationship properties — using private instead of protected is the single most common mistake. The code path that breaks is silent: AbstractEntity::jsonSerialize() filters by ReflectionProperty::IS_PROTECTED, lifecycle callbacks (DoctrineCallbacksLoader) read state via reflection, and the entity-column auto-discovery for translation labels scans the same set of protected properties — all three skip private properties. So the entity's persisted form is correct (Doctrine uses its own hydration, not reflection), but jsonSerialize() returns only the id field, prePersist/preUpdate callbacks can't see the field, and the corresponding *.fields.* translation key never gets registered. Always use protected for ORM-mapped properties.
Overriding getLifecycleCallbacks() unnecessarily — DoctrineCallbacksLoader already provides a concrete default that returns [], so entities are not required to implement it. Override it only when you need to register callbacks.
Calling validate() without checking the return value — validate() returns true or false. Not checking it and calling persist() anyway means invalid data goes to the database. Always guard with an if ( $entity->validate() !== true ) check.
Redefining $id — AbstractEntity includes ModelPropertyIdTrait which already defines $id with #[ORM\Id], #[ORM\GeneratedValue], and #[ORM\Column]. If your entity genuinely needs a non-integer PK (UUID, ULID, custom string), do not redefine $id on top of AbstractEntity — bypass AbstractEntity entirely and follow Custom abstract entities and alternative primary keys above. Redefining $id while still extending AbstractEntity always causes a Doctrine mapping error.
Importing the framework timestamp trait — the framework's ModelPropertyCreatedAtTrait / ModelPropertyUpdatedAtTrait are fine to use as long as they meet your needs. If a particular project needs datetimetz_immutable (or any other column type), create a project-local replacement trait under App\Doctrine\Trait\… that extends or replaces the framework one — don't redefine the column on every entity.