Entities extend AbstractEntity which provides built-in validation, lifecycle callback delegation, JSON serialization, and static repository access. There are two conventions that differ from standard Symfony/Doctrine practice and are non-negotiable — properties must be protected not private, and almost all of them must have #[TranslatablePropertyName].
In standard Symfony projects, entity properties are typically private. In this framework they must be protected. This is not optional — AbstractEntity accesses properties directly via reflection for validation, JSON serialization, and lifecycle callbacks. Private properties are invisible to parent class reflection in PHP.
// Wrong — will break validation and jsonSerialize()
private string $email;
// Correct
protected string $email;
Every column-mapped property, every join column property, and every relationship property must be protected.
Every protected property that participates in validation or appears in error messages must have #[TranslatablePropertyName]. The only exceptions are:
bool properties — boolean validation errors rarely reference the property name in a human-readable way$id property — handled internally by ModelPropertyIdTraitMissing #[TranslatablePropertyName] on a property that needs it throws InvalidEntityConfigurationException during validation with a clear message. In DEV_MODE the Translator also scans entities and adds the attribute value to locale files automatically — this only works if the attribute is present.
When in doubt, add it. The cost of adding it when not strictly needed is zero. The cost of missing it when it is needed is a runtime exception.
// App/Entity/Player.php
namespace App\Entity;
use App\Repository\PlayerRepository;
use Doctrine\ORM\Events;
use Doctrine\ORM\Mapping as ORM;
use PHP_SF\System\Attributes\Validator\Constraints as Validate;
use PHP_SF\System\Attributes\Validator\TranslatablePropertyName;
use PHP_SF\System\Classes\Abstracts\AbstractEntity;
use PHP_SF\System\Traits\ModelProperty\ModelPropertyCreatedAtTrait;
#[ORM\Entity( repositoryClass: PlayerRepository::class )]
#[ORM\Table( name: 'players' )]
#[ORM\Cache( usage: 'READ_WRITE' )]
class Player extends AbstractEntity
{
use ModelPropertyCreatedAtTrait;
#[Validate\Length( min: 2, max: 35 )]
#[TranslatablePropertyName( 'Login' )]
#[ORM\Column( type: 'string', unique: true )]
protected string $login;
#[Validate\Email]
#[Validate\Length( min: 6, max: 50 )]
#[TranslatablePropertyName( 'E-mail' )]
#[ORM\Column( type: 'string', unique: true )]
protected string $email;
#[TranslatablePropertyName( 'Password' )]
#[ORM\Column( type: 'string' )]
protected string $password;
#[Validate\Range( min: 1, max: 100 )]
#[TranslatablePropertyName( 'Level' )]
#[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();
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() checks two things in order:
1. Unique and nullable constraints — scans #[ORM\Column] and #[ORM\JoinColumn] attributes for unique: true and nullable: false. For unique columns, queries the database to check for existing records with the same value (excluding the current entity if it has an ID). For non-nullable columns, verifies the property is set.
2. Validator constraints — runs all AbstractConstraint subclass attributes found on properties through their corresponding validator classes.
Validation stops at the first failure per property — if $email fails Length it won't also run Email. But multiple properties can have errors simultaneously.
All built-in constraints live in PHP_SF\System\Attributes\Validator\Constraints:
#[Validate\Length( min: 2, max: 35 )]
#[TranslatablePropertyName( 'Login' )]
#[ORM\Column( type: 'string' )]
protected string $login;
| Parameter | Type | Description |
|---|---|---|
min |
int |
Minimum string length |
max |
int |
Maximum string length |
allowNull |
bool\|null |
Allow null values, defaults to false |
#[Validate\Email]
#[TranslatablePropertyName( 'E-mail' )]
#[ORM\Column( type: 'string' )]
protected string $email;
Validates using PHP's FILTER_VALIDATE_EMAIL. No parameters.
#[Validate\Range( min: 1, max: 100 )]
#[TranslatablePropertyName( 'Level' )]
#[ORM\Column( type: 'integer' )]
protected int $level;
| Parameter | Type | Description |
|---|---|---|
min |
int\|float |
Minimum value (inclusive) |
max |
int\|float |
Maximum value (inclusive) |
allowNull |
bool\|null |
Allow null values, defaults to false |
#[Validate\Min( 0 )]
#[TranslatablePropertyName( 'Gold' )]
#[ORM\Column( type: 'integer' )]
protected int $gold;
Single minimum value check. Use Range when you need both min and max.
#[Validate\Max( 999 )]
#[TranslatablePropertyName( 'Inventory Size' )]
#[ORM\Column( type: 'integer' )]
protected int $inventorySize;
Single maximum value check.
#[Validate\DateTime]
#[TranslatablePropertyName( 'Last Login' )]
#[ORM\Column( type: 'datetime', nullable: true )]
protected string|DateTimeInterface|null $lastLogin = null;
| Parameter | Type | Description |
|---|---|---|
allowNull |
bool\|null |
Allow null values, defaults to false |
Validates that the value is an instance of PHP_SF\System\Core\DateTime. Properties typed as string|DateTimeInterface|null are common — the validator checks the actual runtime value, not the declared type.
#[Validate\OneOfTheValues( [ 'archer', 'warrior', 'mage' ] )]
#[TranslatablePropertyName( 'Class' )]
#[ORM\Column( type: 'string' )]
protected string $class;
Validates that the value is one of the allowed strings or integers. The $arr parameter must contain only strings and integers — other types throw UnexpectedValueException at attribute instantiation.
#[Validate\OneOfTheNumbers( [ 1, 3, 6 ] )]
#[TranslatablePropertyName( 'User Group ID' )]
#[ORM\Column( type: 'integer' )]
protected int $userGroupId;
Like OneOfTheValues but restricted to integers only. Throws UnexpectedValueException if any value in the array is not an integer.
Constraints skip validation when the property value matches the default option in #[ORM\Column]. This prevents validation errors on freshly created entities where a property hasn't been explicitly set but has a database default:
#[Validate\Range( min: 1, max: 100 )]
#[TranslatablePropertyName( 'Level' )]
#[ORM\Column( type: 'integer', options: [ 'default' => 1 ] )]
protected int $level = 1;
If $level is 1 (the default), Range validation is skipped entirely. This is handled by AbstractConstraintValidator::isDefaultValue() which checks the column's options['default'] against the current value.
Relationship properties follow the same protected convention and also need #[TranslatablePropertyName]:
// ManyToOne
#[TranslatablePropertyName( 'Guild' )]
#[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;
final 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|DateTimeInterface|null $createdAt
// Adds: getCreatedAt(): DateTimeInterface
// Adds: setCreatedAt(): static
}
Set in the constructor:
public function __construct()
{
$this->setCreatedAt( new DateTime );
}
use PHP_SF\System\Traits\ModelProperty\ModelPropertyUpdatedAtTrait;
class Player extends AbstractEntity
{
use ModelPropertyUpdatedAtTrait;
// Adds: protected string|DateTimeInterface|null $updatedAt = null
// Adds: getUpdatedAt(): DateTimeInterface
// Adds: setUpdatedAt(): 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() returns an array of all protected properties, 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.
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
{
#[TranslatablePropertyName( 'Name' )]
#[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 Doctrine\ORM\Events;
use Doctrine\ORM\Mapping as ORM;
use PHP_SF\System\Attributes\Validator\Constraints as Validate;
use PHP_SF\System\Attributes\Validator\TranslatablePropertyName;
use PHP_SF\System\Classes\Abstracts\AbstractEntity;
use PHP_SF\System\Traits\ModelProperty\ModelPropertyCreatedAtTrait;
use PHP_SF\System\Traits\ModelProperty\ModelPropertyUpdatedAtTrait;
#[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;
#[Validate\Length( min: 2, max: 50 )]
#[TranslatablePropertyName( 'Name' )]
#[ORM\Column( type: 'string' )]
protected string $name;
#[Validate\Range( min: 1, max: 20 )]
#[TranslatablePropertyName( 'Level' )]
#[ORM\Column( type: 'integer', options: [ 'default' => 1 ] )]
protected int $level = 1;
#[Validate\Min( 0 )]
#[TranslatablePropertyName( 'Construction Time' )]
#[ORM\Column( type: 'integer', options: [ 'default' => 0 ] )]
protected int $constructionTime = 0;
#[Validate\OneOfTheValues( [ 'farm', 'barracks', 'market', 'wall' ] )]
#[TranslatablePropertyName( 'Type' )]
#[ORM\Column( type: 'string' )]
protected string $type;
#[TranslatablePropertyName( 'Player' )]
#[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;
}
}
Private properties — using private instead of protected is the single most common mistake. Everything silently works until validation or jsonSerialize() is called, at which point reflection can't see the property and either returns wrong data or throws. Always use protected.
Missing getLifecycleCallbacks() — this method is abstract on DoctrineCallbacksLoaderInterface. Every entity must implement it. Return an empty array if no callbacks are needed:
public function getLifecycleCallbacks(): array { return []; }
Missing #[TranslatablePropertyName] — throws InvalidEntityConfigurationException during validation. In DEV_MODE the error message is clear — outside of DEV_MODE it surfaces as a 500. Add the attribute to every property that participates in validation.
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]. Redefining it in the entity causes a Doctrine mapping error.