Сущности
Сущность — обычный PHP-класс, у которого две работы: описывать таблицу и принимать её строки. Схема берётся из атрибутов над свойствами, а результат запроса гидрируется в объекты того же класса. Ниже — как из свойства получается колонка, и разбор каждого атрибута с готовым SQL по трём диалектам.
Что такое сущность
<?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;
}Из этого класса получается и CREATE TABLE, и тип результата: findById() вернёт
User с заполненными полями.
Две роли одного класса
| Роль | Кто использует | Что берёт |
|---|---|---|
| Описание схемы | миграции | атрибуты над свойствами |
| Цель гидрации | репозиторий | имена и типы свойств |
Роли независимы: класс без атрибутов гидрируется как обычно, просто миграции о нём ничего не скажут. И наоборот — атрибуты не мешают классу быть обычным объектом.
Сущность — не модель предметной области
В ней нет ленивой загрузки, отслеживания изменений и связей-объектов. Репозиторий отдаёт заполненные экземпляры; что с ними делать дальше — дело приложения. Это сознательная граница: каждая из перечисленных возможностей переносит часть состояния из вашего кода в слой данных.
Как из свойства получается колонка
Три источника, в таком порядке:
тип свойства PHP → тип колонки по умолчанию
атрибут типа → переопределяет его
атрибут-модификатор → дописывает к результату (AUTO_INCREMENT и подобное)Плюс два независимых источника, которые смотрят не на тип:
Значение свойства по умолчанию становится DEFAULT в DDL.
#[Integer] public int $views = 0; // → views INT NOT NULL DEFAULT 0null в типе делает колонку обнуляемой.
#[Integer] public ?int $rating = null; // → rating INT DEFAULT NULL
#[Integer] public int $views = 0; // → views INT NOT NULL DEFAULT 0Свойство вообще без атрибутов колонкой всё равно станет — по типу PHP:
public string $note; // → note VARCHAR(255) NOT NULL
public ?int $flags = null; // → flags INT DEFAULT NULLЭто удобно для черновика, но полагаться на это в рабочей схеме не стоит: VARCHAR(255)
здесь — умолчание слоя, а не ваше решение.
Справочник атрибутов
Разметка класса
#[Table]
#[Table]
class User { … }Помечает класс как сущность. Без него PPAMapping пройдёт мимо: класс останется годным
для гидрации, но в схему не попадёт.
Имя таблицы задаётся не здесь
Атрибут аргументов не принимает — а #[Table('users')] не ошибка только потому, что
лишний аргумент молча игнорируется. Имя таблицы берётся из репозитория:
class UserRepository extends Repository
{
public static string $table = 'users'; // ← вот отсюда
}Схема — оттуда же, из $schema репозитория.
#[Migratable]
#[Migratable(priority: MigratablePriority::High)]| Аргумент | Тип | По умолчанию | Что делает |
|---|---|---|---|
$priority |
MigratablePriority |
Normal |
в каком порядке применять таблицу |
Ставится на класс конфигурации базы, а не на сущность. Порядок нужен там, где внешние ключи требуют, чтобы одна таблица существовала раньше другой.
#[Extension]
#[Extension('uuid-ossp')]
#[Extension('pg_trgm', version: '1.6', cascade: true)]| Аргумент | Тип | По умолчанию | Что делает |
|---|---|---|---|
$name |
string |
— | имя расширения |
$version |
?string |
null |
требуемая версия |
$schema |
?string |
null |
схема установки |
$cascade |
bool |
false |
ставить зависимости |
Тоже на классе конфигурации. Миграция выпустит CREATE EXTENSION IF NOT EXISTS до
создания таблиц — иначе колонка типа UUID с gen_random_uuid() окажется без функции.
Типы колонок
Один и тот же атрибут даёт разный SQL в разных базах — это и есть работа слоя. Таблица снята с рендера, а не составлена по памяти.
| Атрибут | 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 |
Три различия стоит держать в голове, потому что они меняют поведение, а не только слово в DDL:
#[DateTime] против #[Timestamp]. В PostgreSQL первый даёт время без зоны,
второй — с зоной. Для момента времени, который читают в разных часовых поясах, нужен
второй; для «даты и времени как написано» — первый.
#[Uuid] в MySQL — это CHAR(36), то есть текст: сравнение и индексация идут как по
строке, а не по 16 байтам.
#[TextArray] — родной массив только в PostgreSQL. В MySQL и SQLite он превращается в
JSON, и запросы по элементам придётся писать по-разному.
#[Type]
#[Type('inet')] public string $ip; // → ip inet NOT NULL
#[Type('geometry(Point, 4326)')] public string $location;| Аргумент | Тип | Что делает |
|---|---|---|
$definition |
string |
тип, который уйдёт в DDL дословно |
Запасной выход для того, чего в списке нет: inet, tsvector, geometry, домены. Слой
ничего про такой тип не знает, поэтому и не переводит его между диалектами — переносимость
здесь ваша забота.
Первичные ключи
#[Id], #[BigId], #[SmallId]
#[Id] public ?int $id = null;
#[BigId] public ?int $id = null;
#[SmallId] public ?int $id = null;| Аргумент | Тип | По умолчанию | Что делает |
|---|---|---|---|
$always |
bool |
false |
GENERATED ALWAYS вместо BY DEFAULT (только PostgreSQL) |
Это гибриды: каждый разворачивается в четыре обычных атрибута —
#[Primary], #[AutoIncrement], #[NullableIs(false)] и тип (Integer, BigInteger
или SmallInteger). Отсюда результат:
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)Тип свойства — ?int со значением null: свойства со значением null при вставке не
отправляются, поэтому идентификатор проставляет база.
#[Id(always: true)] в PostgreSQL запрещает вставлять свой идентификатор — база откажет
даже при явном значении. Полезно, когда ключ обязан быть только от базы; мешает при
переносе данных со старыми идентификаторами.
Первичный ключ объявляйте первым свойством
На PostgreSQL и MariaDB вставка дописывает RETURNING по первой колонке — то есть по
первому объявленному свойству, — и именно её значение возвращает insert().
$id = $repo->insert(['name' => 'Alice', 'id' => null]); // вернёт 'Alice'Правило: ключ — первое свойство сущности и первый ключ в массиве.
#[UuidPk]
#[UuidPk] public ?string $id = null;Гибрид для ключа-UUID: PRIMARY KEY, тип UUID, NOT NULL и умолчание —
gen_random_uuid() в PostgreSQL, UUID() в MySQL.
Берут его, когда идентификатор нужно знать до вставки или когда он приходит извне и не должен выдавать порядок записей.
#[Primary]
#[Primary] #[BigInteger] public int $user_id;
#[Primary] #[BigInteger] public int $role_id;Аргументов нет. На нескольких свойствах собирается в один составной ключ в порядке объявления:
CREATE TABLE user_roles (
user_id BIGINT NOT NULL,
role_id BIGINT NOT NULL,
PRIMARY KEY (user_id, role_id)
);Это обычный случай для связующих таблиц: своего id у них нет, а пара и есть ключ.
#[AutoIncrement]
#[Primary] #[AutoIncrement] #[BigInteger] public ?int $id = null; // то же, что #[BigId]
#[AutoIncrement] #[Integer] public ?int $seq = null; // счётчик, не ключ| Аргумент | Тип | По умолчанию | Что делает |
|---|---|---|---|
$always |
bool |
false |
GENERATED ALWAYS вместо BY DEFAULT (PostgreSQL) |
Отдельно нужен редко — гибриды разворачиваются именно в него. Руками ставят, когда колонка растёт сама, но ключом не является, или когда нужна ширина, которой среди гибридов нет.
Принимает только целочисленное свойство (int или mixed, null допустим) и только типы
SMALLINT, INT, BIGINT — на остальном сборка схемы остановится с ошибкой, а не
выпустит неверный DDL. В SQLite объявленная ширина игнорируется: колонка становится
псевдонимом rowid, только если тип написан ровно INTEGER.
Индексы
#[Index], #[Unique]
#[Varchar(255)] #[Index] public string $title;
#[Varchar(160)] #[Unique] public string $slug;| Аргумент | Тип | По умолчанию | Что делает |
|---|---|---|---|
$columns |
array |
[] |
дополнительные колонки — для составного индекса |
$name |
?string |
null |
своё имя; по умолчанию собирается из таблицы и колонки |
$method |
IndexMethod |
BTREE |
метод: BTREE, HASH, GIN, GIST… |
$where |
?string |
null |
условие частичного индекса (PostgreSQL) |
$opClass |
?string |
null |
класс операторов (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);Имя собирается как {таблица}_{колонка}_idx и _udx — предсказуемо, поэтому в миграциях
индекс всегда узнаётся, даже если его переносили между окружениями.
Составной индекс объявляют на одном свойстве, перечисляя остальные колонки:
#[Index(['status', 'created_at'])] #[Varchar(64)] public string $status;Частичный — только PostgreSQL, и это часто лучший способ сделать «уникальный среди активных»:
#[Unique(where: 'deleted_at IS NULL')] #[Varchar(255)] public string $email;Ограничения
#[ForeignKey]
#[Integer]
#[ForeignKey('users', 'id', onDelete: FKAction::CASCADE)]
public int $author_id;| Аргумент | Тип | По умолчанию | Что делает |
|---|---|---|---|
$referencedTable |
string |
— | таблица, на которую ссылаемся |
$referencedColumn |
string |
— | её колонка |
$onUpdate |
FKAction |
RESTRICT |
что делать при изменении ключа |
$onDelete |
FKAction |
RESTRICT |
что делать при удалении строки |
$name |
?string |
null |
своё имя ограничения |
ALTER TABLE posts ADD CONSTRAINT fk_posts_author_id
FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE RESTRICT;FKAction — RESTRICT, CASCADE, SET NULL, SET DEFAULT, NO ACTION. Умолчание
RESTRICT выбрано намеренно: удаление, у которого есть зависимые строки, должно падать, а
не уносить их молча.
#[ForeignRepo]
#[Integer]
#[ForeignRepo(UserRepository::class, onDelete: FKAction::CASCADE)]
public int $author_id;| Аргумент | Тип | По умолчанию | Что делает |
|---|---|---|---|
$referencedRepoClass |
class-string |
— | репозиторий, на таблицу которого ссылаемся |
$onUpdate / $onDelete |
FKAction |
RESTRICT |
то же, что выше |
$name |
?string |
null |
своё имя |
То же самое, но таблица и колонка ключа берутся у репозитория, а не пишутся строкой. Переименовали таблицу в одном месте — внешние ключи поехали за ней, и опечатка в имени становится невозможной.
Предпочитайте его; #[ForeignKey] остаётся для таблиц, у которых репозитория нет.
#[Check]
#[Integer] #[Check('views >= 0')] public int $views = 0;| Аргумент | Тип | По умолчанию | Что делает |
|---|---|---|---|
$expression |
string |
— | условие, которое обязано выполняться |
$name |
?string |
null |
своё имя ограничения |
ALTER TABLE posts ADD CONSTRAINT chk_posts_a1739ad7… CHECK (views >= 0);Имя по умолчанию содержит хеш выражения — так два разных условия на одной колонке не столкнутся именами. Своё имя стоит задать, если ограничение упоминается в документации или в обработке ошибок.
#[CheckEnum]
enum Status: string { case Draft = 'draft'; case Live = 'live'; }
#[Varchar(16)] #[CheckEnum(Status::class)] public string $status;| Аргумент | Тип | По умолчанию | Что делает |
|---|---|---|---|
$enumClassName |
class-string |
— | перечисление, задающее допустимые значения |
$name |
?string |
null |
своё имя ограничения |
ALTER TABLE posts ADD CONSTRAINT chk_posts_b8679390… CHECK (status IN ('draft', 'live'));Список значений берётся из перечисления — то есть источник правды один, и добавленный вариант доезжает до базы миграцией. Класс, не являющийся перечислением, отвергается сразу, при разборе атрибута.
Пустота и умолчания
#[NullableIs]
#[Integer] #[NullableIs(true)] public ?int $rating = null;| Аргумент | Тип | Что делает |
|---|---|---|
$nullable |
bool |
разрешить или запретить NULL явно |
Нужен редко: обнуляемость и так берётся из типа свойства. Ставят его, когда тип и схема
должны разойтись намеренно — например, свойство типизировано строго, а колонка обязана
принимать NULL из-за старых данных.
#[DefaultVal]
#[Timestamp] #[DefaultVal('CURRENT_TIMESTAMP')] public string $created_at;| Аргумент | Тип | Что делает |
|---|---|---|
$definition |
string |
выражение, которое уйдёт в DEFAULT дословно |
Это выражение SQL, а не значение PHP
Строка подставляется как есть, поэтому #[DefaultVal('new')] даёт
DEFAULT new — а это ссылка на колонку new, и база такую таблицу не создаст.
Строковое значение нужно писать с кавычками внутри аргумента:
#[DefaultVal("'new'")] // → DEFAULT 'new'
#[DefaultVal('CURRENT_TIMESTAMP')] // → DEFAULT CURRENT_TIMESTAMP
#[DefaultVal('0')] // → DEFAULT 0Для простых значений проще обойтись без атрибута: значение свойства по умолчанию само
становится DEFAULT.
Гидрация
Репозиторий заполняет свойства по именам колонок результата.
| Ситуация | Что происходит |
|---|---|
| Колонка совпала со свойством | значение записывается |
| Колонки нет в результате | свойство остаётся со своим значением по умолчанию |
| В результате колонка, которой нет в классе | создаётся динамическое свойство — PHP выдаст Deprecated |
Свой select() в запросе |
гидрация уходит в stdClass |
Отсюда практическое следствие: если сущность отдаётся в нескольких формах, сужать нужно
select() и брать под это отдельный класс через $entityClassName — иначе лишние
колонки осядут динамическими свойствами.
Полный пример
#[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);Дальше
- Репозитории — как эту сущность читать и писать
- Миграции — как описанная схема доезжает до базы
- Конфигурация БД — где живут
#[Migratable]и#[Extension]