Migrations
The schema is not described separately: it is already described by entity
attributes. The db migrate command reads that annotation and creates
what the database does not have yet — tables, indexes, constraints. Running it again
is safe.
What this is and why
A migration here means creating database structure from code.
The problem. A schema living apart from the entities is double work and a
constant risk of divergence: add a property to a class, forget the ALTER TABLE,
and the application fails at runtime. Bringing a database up from scratch on a new
machine or in CI is manual work everyone does differently.
The solution. The structure is described once — with entity attributes. The command derives the DDL from it and applies it. There is one source of truth, and it is the code.
This is not a versioned migration system
The tool creates what is missing and nothing else. It does not compare the schema with the database, does not alter or drop what exists, does not move data and keeps no table of applied versions.
That is enough for a first deployment, for CI and for development. Evolving a live production schema — renames, type changes, data moves — is done with a separate tool. The boundaries are covered below.
What a table needs in order to be created
Three conditions, all mandatory. Miss any one and the table simply does not appear, with no error.
| Condition | Where | |
|---|---|---|
| 1 | A repository exists for the table | The walk goes over repositories, not entities |
| 2 | The entity carries #[Table] |
A class without it is skipped |
| 3 | The configuration carries #[Migratable] |
Without it the whole database is ignored |
#[Migratable] // (3) the database is opted in
class MainDbConfig extends PgDbConfig { /* ... */ }
#[Table] // (2) the entity is a table
class Order { /* ... */ }
class OrderRepository extends Repository // (1) ties the two together
{
protected string $dbConfigClassName = MainDbConfig::class;
protected string $entityClassName = Order::class;
public static string $table = 'orders';
}The command tells you which condition failed:
[Project] No DB configs found — no entity has #[Table].
[Project] No migratable configs — add #[Migratable] to a DbConfig to opt in.Why opt-in rather than everything
#[Migratable] is a statement that “this database’s schema is managed by the code”.
Its absence means the opposite: the schema is maintained elsewhere and must not be
touched.
That is how one project accommodates both a database Winter brings up and a database owned by another team or another tool. The second one simply goes unmarked.
What comes out
Take the entity from the Entities page:
#[Table]
class Order
{
#[BigId]
public ?int $id = null;
#[Varchar(32)]
#[Unique]
public string $number;
#[BigInteger]
#[Index(['created_at'])]
public int $user_id;
#[SmallInteger]
public int $status = 0;
#[Decimal(12, 2)]
#[Check('total >= 0')]
public string $total;
#[Timestamp]
#[DefaultVal('NOW()')]
public string $created_at;
}For PostgreSQL it produces:
CREATE TABLE public.orders (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
number VARCHAR(32) NOT NULL,
user_id BIGINT NOT NULL,
status SMALLINT NOT NULL DEFAULT 0,
total NUMERIC(12, 2) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
PRIMARY KEY (id)
);
CREATE UNIQUE INDEX orders_number_udx ON public.orders USING BTREE (number);
CREATE INDEX orders_user_id_created_at_idx ON public.orders USING BTREE (user_id, created_at);
ALTER TABLE public.orders ADD CONSTRAINT chk_orders_a0f6358602c3db66492a6ac4bcdb3862 CHECK (total >= 0);For MySQL — the same set of objects in its dialect: AUTO_INCREMENT instead of
GENERATED … AS IDENTITY, DECIMAL instead of NUMERIC, TIMESTAMP without a
zone, and no schema.
Object names
When a name is not given explicitly it is assembled by rule — predictably, and the same way in every project:
| Object | Pattern | Example |
|---|---|---|
| Primary key | table_pkey |
orders_pkey |
| Unique index | table_columns_udx |
orders_number_udx |
| Index | table_columns_idx |
orders_user_id_created_at_idx |
| Foreign key | fk_table_column |
fk_orders_user_id |
CHECK constraint |
chk_table_hash |
chk_orders_a0f63586… |
A CHECK name carries a hash of its expression — otherwise two different
constraints on one table would collide. If the name has to be readable, set it
explicitly: #[Index], #[Unique], #[ForeignKey] and #[Check] all take a
name argument.
Your own name is validated: it must start with a letter or an underscore and consist
of letters, digits and underscores — otherwise an InvalidArgumentException at
parse time.
Running it
php call db ping # is the database reachable at all
php call db sql # print the DDL, change nothing
php call db migrate # execute itStart with db sql: it prints exactly the statements db migrate will run, while
touching nothing. It is also a convenient way to hand the DDL to a database
administrator when you are not the one applying it.
db sql — preview
The output is grouped by configuration and, inside it, by phase, with the number of statements in each:
[Project] MainConfigurationsMainDbConfig
Extensions (1)
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
Tables (3)
CREATE TABLE public.users ( … );
CREATE TABLE public.orders ( … );
Indexes (4)
CREATE UNIQUE INDEX orders_number_udx ON public.orders USING BTREE (number);
Constraints (2)
ALTER TABLE public.orders ADD CONSTRAINT fk_orders_user_id FOREIGN KEY … ;The command does connect to the database — it needs the driver to pick the dialect —
but it runs none of the statements it prints. Flags work exactly as they do for
migrate: db sql -t shows tables only.
db migrate — execution
The output is line by line, object by object:
[Project] MainMainDbConfig
Tables (3)
public.users OK
public.orders OK
public.order_items EXIST
Indexes (4)
orders_number_udx OK
orders_user_id_created_at_idx EXIST
Constraints (2)
constraint 'fk_orders_user_id' OK| Label | Meaning |
|---|---|
OK |
The object was created |
EXIST |
It was already there — the statement was skipped; not an error |
FAILED |
Not created; the error text is printed with DEBUG=true |
A `FAILED` with no explanation — set `DEBUG=true`
The database’s message is shown only in debug mode, so the command’s output does not
expose schema internals where it runs into a shared log. If an object was not
created and it is unclear why, run it with DEBUG=true.
Command reference
call db [command] -[flags] --[options]| Command | What it does |
|---|---|
ping |
checks connectivity and prints the latency of every configuration |
sql |
prints the generated DDL without executing anything |
migrate |
executes it |
pool |
pool utilisation of the running server — see Pooling |
Flags (-e -s -t -i -c) and options (--plugin=<name>, --plugins) are covered
below; call db --help prints the full list.
Order
Within one database the statements follow the dependency chain — each step relies on the previous one:
1. EXTENSIONS CREATE EXTENSION IF NOT EXISTS … (PostgreSQL only)
2. SCHEMAS CREATE SCHEMA … (PostgreSQL only)
3. TABLES CREATE TABLE … + PRIMARY KEY
4. INDEXES CREATE INDEX / CREATE UNIQUE INDEX
5. CONSTRAINTS ALTER TABLE … ADD CONSTRAINT (FK, CHECK)Extensions first, because default values reference their functions: DEFAULT gen_random_uuid() cannot be created before pgcrypto exists. Schemas before
tables. Indexes and constraints after, since they reference existing columns.
Order between databases
When there are several configurations, priority decides the sequence:
use Flytachi\Winter\Ppa\Mapping\Attributes\Config\Migratable;
use Flytachi\Winter\Ppa\Mapping\Constants\MigratablePriority;
#[Migratable(MigratablePriority::High)] // first
class AuthDbConfig extends PgDbConfig { /* ... */ }
#[Migratable] // Normal — the default
class MainDbConfig extends PgDbConfig { /* ... */ }
#[Migratable(MigratablePriority::Low)] // last
class AnalyticsDbConfig extends PgDbConfig { /* ... */ }Within one database the table order is not configurable — and that is not a problem: foreign keys are created in a separate phase, after all the tables, so the order the tables are created in does not matter.
PostgreSQL extensions
use Flytachi\Winter\Ppa\Mapping\Attributes\Config\Extension;
#[Migratable]
#[Extension('pgcrypto')] // gen_random_uuid() for #[UuidPk]
#[Extension('postgis', version: '3.4', schema: 'gis')]
class MainDbConfig extends PgDbConfig { /* ... */ }| Argument | Purpose |
|---|---|
name |
The extension name |
version |
The required version |
schema |
The schema to install it into |
cascade |
Install it together with its dependencies |
The attribute is repeatable — one per extension. On non-PostgreSQL it is silently ignored.
Running it again
db migrate can be run as many times as you like: existing objects are marked
EXIST and skipped. That works two ways — where the dialect allows it,
IF NOT EXISTS is used; where it does not, the “object already exists” error code is
recognised:
| Code | What it means |
|---|---|
42P07 (pgsql), 42S01 (mysql) |
The table already exists |
42P07 (pgsql), 42000 (mysql) |
The index already exists |
42P06 |
The schema already exists |
42710 |
The constraint already exists |
Any other code is a genuine error, and the object is marked FAILED.
Idempotency is not schema comparison
“Safe to run again” means only that existing objects are not recreated. It does not mean the database is brought into line with the code.
Add a property to an entity and run the migration again — the table already exists, so its statement is skipped entirely and the new column never appears. Adding columns to an existing table is something you do yourself.
Running selected phases
Flags narrow the set of phases — without them all of them run:
php call db migrate # all phases: -e -s -t -i -c
php call db migrate -t # tables only
php call db migrate -i -c # indexes and constraints only
php call db migrate -e # extensions only (pgsql)| Flag | Phase |
|---|---|
-e |
Extensions |
-s |
Schemas |
-t |
Tables |
-i |
Indexes |
-c |
Constraints |
The split helps when the tables already exist and an index was added later: -i
runs only that, leaving everything else alone.
Plugins
By default the project itself is processed. Plugins are included with options:
php call db migrate --plugin=billing # one registered plugin
php call db migrate --plugins # every registered pluginWhat the tool does not do
The boundaries are worth knowing in advance — they explain where a second tool is needed.
| Does not | What that means in practice |
|---|---|
| Compare the schema with the code | A changed column type or a removed property goes unnoticed |
| Alter what exists | A new column in an existing table will not appear |
| Drop anything | Not tables, not columns, not indexes — DROP is never generated |
| Move data | Structure only; populating it is a separate job |
| Track versions | There is no applied-migrations table and no rollback |
| Wrap in a transaction | Every statement runs on its own; a failure halfway leaves some objects created |
Hence its scope: bringing a schema up from scratch — on a new machine, in CI, in a container on first start. For evolving a live production schema, take a tool with versions and rollbacks.
One database under code, another under an external tool
The approaches do not exclude each other. A configuration without #[Migratable] is
entirely invisible to the command — so a database managed by Liquibase or Phinx
stays untouched while the project’s own database keeps coming up from code.
An individual entity can be excluded the same way — by removing its #[Table]. That
affects neither reads nor writes: the attribute governs migration only.
Next
- Entities — the attributes the schema is built from
- Connection —
#[Migratable]and#[Extension] - CLI → db — every
dbcommand and flag - Repository — working with the tables once created