Entities
An entity is an ordinary PHP class with two jobs: describing a table and receiving its rows. The schema comes from attributes on the properties, and query results are hydrated into objects of the same class. Below: how a property becomes a column, and every attribute with the SQL it produces in three dialects.
What an entity is
<?php
namespace Main\Entities;
use Flytachi\Winter\Ppa\Mapping\Attributes\Entity\Table;
use Flytachi\Winter\Ppa\Mapping\Attributes\Hybrid\Id;
use Flytachi\Winter\Ppa\Mapping\Attributes\Primal\{Timestamp, Varchar};
#[Table]
class User
{
#[Id] public ?int $id = null;
#[Varchar(255)] public string $email;
#[Varchar(64)] public string $status;
#[Timestamp] public string $created_at;
}This one class yields both the CREATE TABLE and the result type: findById() returns a
User with its fields filled in.
Two roles of one class
| Role | Who uses it | What it takes |
|---|---|---|
| Schema description | migrations | the attributes on the properties |
| Hydration target | the repository | the property names and types |
The roles are independent: a class with no attributes hydrates as usual, migrations simply have nothing to say about it. And the other way round — attributes do not stop it being an ordinary object.
An entity is not a domain model
There is no lazy loading, no change tracking, no object relations. The repository hands back filled instances; what happens to them afterwards is the application’s business. That is a deliberate boundary: every one of those features moves a piece of state out of your code and into the data layer.
How a property becomes a column
Three sources, in this order:
the PHP type → the default column type
a type attribute → overrides it
a modifier → appends to the result (AUTO_INCREMENT and the like)Plus two independent sources that do not look at the type at all:
A property’s default value becomes the DEFAULT in the DDL.
#[Integer] public int $views = 0; // → views INT NOT NULL DEFAULT 0A null in the type makes the column nullable.
#[Integer] public ?int $rating = null; // → rating INT DEFAULT NULL
#[Integer] public int $views = 0; // → views INT NOT NULL DEFAULT 0A property with no attributes at all still becomes a column, decided by the PHP type:
public string $note; // → note VARCHAR(255) NOT NULL
public ?int $flags = null; // → flags INT DEFAULT NULLHandy for a draft, but not something to rely on in a real schema: the VARCHAR(255) there
is the layer’s default, not your decision.
Attribute reference
Class markup
#[Table]
#[Table]
class User { … }Marks the class as an entity. Without it PPAMapping walks past: the class still works for
hydration, but never reaches the schema.
The table name is not set here
The attribute takes no arguments — #[Table('users')] is not an error only because the
extra argument is silently ignored. The table name comes from the repository:
class UserRepository extends Repository
{
public static string $table = 'users'; // ← from here
}The schema comes from the same place, the repository’s $schema.
#[Migratable]
#[Migratable(priority: MigratablePriority::High)]| Argument | Type | Default | What it does |
|---|---|---|---|
$priority |
MigratablePriority |
Normal |
the order in which the table is applied |
It goes on the database config class, not on the entity. The order matters where foreign keys require one table to exist before another.
#[Extension]
#[Extension('uuid-ossp')]
#[Extension('pg_trgm', version: '1.6', cascade: true)]| Argument | Type | Default | What it does |
|---|---|---|---|
$name |
string |
— | the extension’s name |
$version |
?string |
null |
the version required |
$schema |
?string |
null |
the schema to install into |
$cascade |
bool |
false |
install dependencies too |
Also on the config class. The migration emits CREATE EXTENSION IF NOT EXISTS before the
tables — otherwise a UUID column defaulting to gen_random_uuid() would find no such
function.
Column types
The same attribute produces different SQL in different databases — which is the layer’s whole job. This table was taken from the renderer, not written from memory.
| Attribute | PostgreSQL | MySQL | SQLite |
|---|---|---|---|
#[Varchar(255)] |
VARCHAR(255) |
VARCHAR(255) |
VARCHAR(255) |
#[Char(2)] |
CHAR(2) |
CHAR(2) |
CHAR(2) |
#[Text] |
TEXT |
TEXT |
TEXT |
#[SmallInteger] |
SMALLINT |
SMALLINT |
SMALLINT |
#[Integer] |
INT |
INT |
INT |
#[BigInteger] |
BIGINT |
BIGINT |
BIGINT |
#[Decimal(10, 2)] |
NUMERIC(10, 2) |
DECIMAL(10, 2) |
NUMERIC(10, 2) |
#[FloatType] |
REAL |
FLOAT |
REAL |
#[Double] |
DOUBLE PRECISION |
DOUBLE |
REAL |
#[Boolean] |
BOOLEAN |
BOOLEAN |
BOOLEAN |
#[Date] |
DATE |
DATE |
DATE |
#[Time] |
TIME |
TIME |
TIME |
#[DateTime] |
TIMESTAMP WITHOUT TIME ZONE |
DATETIME |
DATETIME |
#[Timestamp] |
TIMESTAMP WITH TIME ZONE |
TIMESTAMP |
DATETIME |
#[Json] |
JSONB |
JSON |
JSON |
#[Uuid] |
UUID |
CHAR(36) |
TEXT |
#[TextArray] |
TEXT[] |
JSON |
JSON |
#[Binary(16)] |
BYTEA |
VARBINARY(16) |
BLOB |
#[Blob] |
BYTEA |
BLOB |
BLOB |
Three differences are worth carrying in your head, because they change behaviour and not just a word in the DDL:
#[DateTime] versus #[Timestamp]. On PostgreSQL the first is time without a
zone, the second with one. A moment read across time zones needs the second; “a date
and time exactly as written” needs the first.
#[Uuid] on MySQL is CHAR(36) — text: comparison and indexing behave like a string,
not like sixteen bytes.
#[TextArray] is a native array only on PostgreSQL. On MySQL and SQLite it becomes
JSON, and querying its elements has to be written differently.
#[Type]
#[Type('inet')] public string $ip; // → ip inet NOT NULL
#[Type('geometry(Point, 4326)')] public string $location;| Argument | Type | What it does |
|---|---|---|
$definition |
string |
the type, passed into the DDL verbatim |
The escape hatch for what the list does not cover: inet, tsvector, geometry, domains.
The layer knows nothing about such a type, so it does not translate it between dialects —
portability there is on you.
Primary keys
#[Id], #[BigId], #[SmallId]
#[Id] public ?int $id = null;
#[BigId] public ?int $id = null;
#[SmallId] public ?int $id = null;| Argument | Type | Default | What it does |
|---|---|---|---|
$always |
bool |
false |
GENERATED ALWAYS instead of BY DEFAULT (PostgreSQL only) |
These are hybrids: each unfolds into four ordinary attributes — #[Primary],
#[AutoIncrement], #[NullableIs(false)] and a type (Integer, BigInteger or
SmallInteger). Hence the result:
PostgreSQL: id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL + PRIMARY KEY (id)
MySQL: id BIGINT AUTO_INCREMENT NOT NULL + PRIMARY KEY (id)
SQLite: id INTEGER NOT NULL + PRIMARY KEY (id)The property type is ?int with a null value: properties whose value is null are not
sent on insert, so the database assigns the identifier.
#[Id(always: true)] on PostgreSQL forbids inserting your own identifier — the database
refuses even an explicit value. Useful when the key must come only from the database; in
the way when importing data with existing identifiers.
Declare the primary key as the first property
On PostgreSQL and MariaDB an insert appends RETURNING on the first column — that is,
on the first declared property — and that value is what insert() returns.
$id = $repo->insert(['name' => 'Alice', 'id' => null]); // returns 'Alice'The rule: the key is the entity’s first property, and the first key in the array.
#[UuidPk]
#[UuidPk] public ?string $id = null;The hybrid for a UUID key: PRIMARY KEY, the UUID type, NOT NULL, and a default of
gen_random_uuid() on PostgreSQL, UUID() on MySQL.
Reach for it when the identifier must be known before the insert, or when it comes from outside and must not reveal the order of records.
#[Primary]
#[Primary] #[BigInteger] public int $user_id;
#[Primary] #[BigInteger] public int $role_id;No arguments. On several properties it collects into one composite key, in declaration order:
CREATE TABLE user_roles (
user_id BIGINT NOT NULL,
role_id BIGINT NOT NULL,
PRIMARY KEY (user_id, role_id)
);The ordinary case for join tables: they have no id of their own, and the pair is the key.
#[AutoIncrement]
#[Primary] #[AutoIncrement] #[BigInteger] public ?int $id = null; // the same as #[BigId]
#[AutoIncrement] #[Integer] public ?int $seq = null; // a counter, not a key| Argument | Type | Default | What it does |
|---|---|---|---|
$always |
bool |
false |
GENERATED ALWAYS instead of BY DEFAULT (PostgreSQL) |
Rarely needed on its own — the hybrids unfold into exactly this. By hand it is for a column that counts up without being a key, or for a width none of the hybrids offers.
It accepts an integer property only (int or mixed, null allowed) and only the types
SMALLINT, INT, BIGINT — anything else stops schema building with an error rather than
emitting wrong DDL. On SQLite the declared width is ignored: a column becomes a rowid
alias only when its type is spelled exactly INTEGER.
Indexes
#[Index], #[Unique]
#[Varchar(255)] #[Index] public string $title;
#[Varchar(160)] #[Unique] public string $slug;| Argument | Type | Default | What it does |
|---|---|---|---|
$columns |
array |
[] |
further columns — for a composite index |
$name |
?string |
null |
a custom name; by default built from the table and column |
$method |
IndexMethod |
BTREE |
the method: BTREE, HASH, GIN, GIST… |
$where |
?string |
null |
the condition of a partial index (PostgreSQL) |
$opClass |
?string |
null |
the operator class (PostgreSQL) |
PostgreSQL: CREATE UNIQUE INDEX posts_slug_udx ON posts USING BTREE (slug);
CREATE INDEX posts_title_idx ON posts USING BTREE (title);
MySQL: CREATE UNIQUE INDEX posts_slug_udx ON posts (slug);
CREATE INDEX posts_title_idx ON posts (title);Names are built as {table}_{column}_idx and _udx — predictably, so a migration always
recognises an index even when it was carried between environments.
A composite index is declared on one property, listing the rest of the columns:
#[Index(['status', 'created_at'])] #[Varchar(64)] public string $status;A partial one is PostgreSQL-only, and it is often the best way to express “unique among the active ones”:
#[Unique(where: 'deleted_at IS NULL')] #[Varchar(255)] public string $email;Constraints
#[ForeignKey]
#[Integer]
#[ForeignKey('users', 'id', onDelete: FKAction::CASCADE)]
public int $author_id;| Argument | Type | Default | What it does |
|---|---|---|---|
$referencedTable |
string |
— | the table referred to |
$referencedColumn |
string |
— | its column |
$onUpdate |
FKAction |
RESTRICT |
what to do when the key changes |
$onDelete |
FKAction |
RESTRICT |
what to do when the row is deleted |
$name |
?string |
null |
a custom constraint name |
ALTER TABLE posts ADD CONSTRAINT fk_posts_author_id
FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE RESTRICT;FKAction is RESTRICT, CASCADE, SET NULL, SET DEFAULT, NO ACTION. The
RESTRICT default is deliberate: a delete with dependent rows should fail rather than
quietly take them along.
#[ForeignRepo]
#[Integer]
#[ForeignRepo(UserRepository::class, onDelete: FKAction::CASCADE)]
public int $author_id;| Argument | Type | Default | What it does |
|---|---|---|---|
$referencedRepoClass |
class-string |
— | the repository whose table is referred to |
$onUpdate / $onDelete |
FKAction |
RESTRICT |
as above |
$name |
?string |
null |
a custom name |
The same thing, except the table and the key column are taken from the repository rather than written as strings. Rename the table in one place and the foreign keys follow — and a typo in a name becomes impossible.
Prefer it; #[ForeignKey] remains for tables that have no repository.
#[Check]
#[Integer] #[Check('views >= 0')] public int $views = 0;| Argument | Type | Default | What it does |
|---|---|---|---|
$expression |
string |
— | the condition that must hold |
$name |
?string |
null |
a custom constraint name |
ALTER TABLE posts ADD CONSTRAINT chk_posts_a1739ad7… CHECK (views >= 0);The default name contains a hash of the expression, so two different conditions on one column cannot collide. A custom name is worth setting when the constraint is mentioned in documentation or in error handling.
#[CheckEnum]
enum Status: string { case Draft = 'draft'; case Live = 'live'; }
#[Varchar(16)] #[CheckEnum(Status::class)] public string $status;| Argument | Type | Default | What it does |
|---|---|---|---|
$enumClassName |
class-string |
— | the enum that defines the allowed values |
$name |
?string |
null |
a custom constraint name |
ALTER TABLE posts ADD CONSTRAINT chk_posts_b8679390… CHECK (status IN ('draft', 'live'));The list of values comes from the enum — one source of truth, and a newly added case reaches the database through a migration. A class that is not an enum is rejected right there, while the attribute is being read.
Emptiness and defaults
#[NullableIs]
#[Integer] #[NullableIs(true)] public ?int $rating = null;| Argument | Type | What it does |
|---|---|---|
$nullable |
bool |
allow or forbid NULL explicitly |
Rarely needed: nullability already follows from the property’s type. It is for when the
type and the schema must deliberately differ — a strictly typed property whose column still
has to accept NULL because of old data.
#[DefaultVal]
#[Timestamp] #[DefaultVal('CURRENT_TIMESTAMP')] public string $created_at;| Argument | Type | What it does |
|---|---|---|
$definition |
string |
the expression that goes into DEFAULT verbatim |
This is a SQL expression, not a PHP value
The string is substituted as it is, so #[DefaultVal('new')] produces DEFAULT new — a
reference to a column called new, and the database will not create such a table.
A string value needs its quotes inside the argument:
#[DefaultVal("'new'")] // → DEFAULT 'new'
#[DefaultVal('CURRENT_TIMESTAMP')] // → DEFAULT CURRENT_TIMESTAMP
#[DefaultVal('0')] // → DEFAULT 0For simple values the attribute is unnecessary: a property’s default value becomes the
DEFAULT on its own.
Hydration
The repository fills properties by the column names of the result.
| Situation | What happens |
|---|---|
| A column matches a property | the value is written |
| The column is missing from the result | the property keeps its default value |
| The result has a column the class lacks | a dynamic property is created — PHP raises a Deprecated |
The query uses a custom select() |
hydration falls back to stdClass |
The practical consequence: if one entity is served in several shapes, narrow the select()
and take a separate class through $entityClassName — otherwise the extra columns
settle as dynamic properties.
A full example
#[Table]
class Post
{
#[BigId] public ?int $id = null;
#[Varchar(160)] #[Unique] public string $slug;
#[Varchar(255)] #[Index] public string $title;
#[Integer] #[ForeignRepo(UserRepository::class, onDelete: FKAction::CASCADE)]
public int $author_id;
#[Varchar(16)] #[CheckEnum(Status::class)] public string $status;
#[Integer] #[Check('views >= 0')] public int $views = 0;
#[Timestamp] #[DefaultVal('CURRENT_TIMESTAMP')] public string $created_at;
#[Timestamp] public ?string $deleted_at = null;
}CREATE TABLE posts (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
slug VARCHAR(160) NOT NULL,
title VARCHAR(255) NOT NULL,
author_id INT NOT NULL,
status VARCHAR(16) NOT NULL,
views INT NOT NULL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP WITH TIME ZONE DEFAULT NULL,
PRIMARY KEY (id)
);
CREATE UNIQUE INDEX posts_slug_udx ON posts USING BTREE (slug);
CREATE INDEX posts_title_idx ON posts USING BTREE (title);
ALTER TABLE posts ADD CONSTRAINT fk_posts_author_id
FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE RESTRICT;
ALTER TABLE posts ADD CONSTRAINT chk_posts_b8679390… CHECK (status IN ('draft', 'live'));
ALTER TABLE posts ADD CONSTRAINT chk_posts_a1739ad7… CHECK (views >= 0);Next
- Repositories — reading and writing this entity
- Migrations — how the described schema reaches the database
- Database configuration — where
#[Migratable]and#[Extension]live