Entities extend AbstractEntity which provides built-in validation, lifecycle callback delegation, JSON serialization, and static repository access. One convention differs from standard Symfony/Doctrine practice and is non-negotiable — properties must be protected not private.
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.
// App/Entity/Player.php
namespace App\Entity;
use App\Repository\PlayerRepository;
use Doctrine\ORM\Events;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
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;
#[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();
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.
Error messages reference the property using its auto-generated translation key (player.fields.email, player.fields.login, etc.). Translate those keys in your locale file to customize the label that appears in the error:
# translations/en.yaml
player.fields.login: Login
player.fields.email: E-mail
player.fields.level: Level
See Translation for how entity field keys are generated and used.
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;
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
{
#[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 Symfony\Component\Validator\Constraints as Assert;
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;
#[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;
}
}
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 []; }
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.