Database · PPA

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.

Command call db migrateOpt-in #[Migratable] + #[Table]Mode create only, idempotent

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

text
[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:

main/Entity/Order.php
#[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:

sql
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

bash
php call db ping       # is the database reachable at all
php call db sql        # print the DDL, change nothing
php call db migrate    # execute it

Start 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:

text
[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:

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

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

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

php
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

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

bash
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:

bash
php call db migrate --plugin=billing    # one registered plugin
php call db migrate --plugins           # every registered plugin

What 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 db command and flag
  • Repository — working with the tables once created