Database · Entities

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.

Package flytachi/winter-ppaAttributes 37Dialects PostgreSQL · MySQL · SQLite

What an entity is

main/Entities/User.php
<?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:

text
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.

php
#[Integer] public int $views = 0;    // → views INT NOT NULL DEFAULT 0

A null in the type makes the column nullable.

php
#[Integer] public ?int $rating = null;   // → rating INT DEFAULT NULL
#[Integer] public int $views = 0;        // → views INT NOT NULL DEFAULT 0

A property with no attributes at all still becomes a column, decided by the PHP type:

php
public string $note;       // → note VARCHAR(255) NOT NULL
public ?int $flags = null; // → flags INT DEFAULT NULL

Handy 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]

php
#[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]

php
#[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]

php
#[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]

php
#[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]

php
#[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:

text
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]

php
#[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]

php
#[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:

text
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]

php
#[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]

php
#[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)
text
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:

php
#[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”:

php
#[Unique(where: 'deleted_at IS NULL')] #[Varchar(255)] public string $email;

Constraints

#[ForeignKey]

php
#[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
text
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]

php
#[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]

php
#[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
text
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]

php
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
text
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]

php
#[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]

php
#[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 0

For 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

main/Entities/Post.php
#[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;
}
text
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