# 0022. Migrations replace `changes.sql`

- **Status:** Accepted
- **Date:** 2026-08-08

## Context

Until now, ongoing schema changes were **not** migrations. The documented process was: change the model, write
the statement into a `changes.sql` file, run it by hand on the dev database, and later by hand on production.
New models additionally had to be registered in the init migration so a from-scratch install would create
them.

Three problems with that, in increasing order of severity:

1. **`changes.sql` was gitignored** (`/*.sql`) and did not exist in the working tree. The file the process
   depended on was not in the repository and could not be reviewed, shared or replayed.
2. **Nothing recorded whether a change had been applied**, on dev or on production. The only record was
   someone's memory.
3. **The two paths could diverge**: a fresh install ran the init migration and got the model's current schema;
   an existing database got whatever hand-run statements it happened to receive.

The technical foundation for migrations was already in place and in use — `SchemaHelper`, the models'
`getColumns()`/`getIndexes()`, and three existing migrations.

## Decision

**Every schema change is a migration.** `changes.sql` is retired and its rules are removed from `CLAUDE.md`.

The rule, stated in [`docs/CODE_STYLE.md`](../CODE_STYLE.md):

> Adding a column means editing the model **and** writing a migration.

Migrations keep calling `SchemaHelper` rather than spelling out columns — see
[0003](0003-models-own-their-schema.md).

## Alternatives

- **Keep `changes.sql`, but commit it.** Fixes reviewability, not the "has this been applied" problem, and
  still leaves two ways to change a schema.
- **A hybrid: `changes.sql` for dev, migrations for production.** Two sources of truth, guaranteed to drift.
- **Keep the status quo.** The status quo was already broken: the file did not exist.

## Consequences

- **`./yii migrate` is the only way a schema changes**, and `migration` records which have been applied.
- **The deploy procedure is unchanged** — it already ran `./yii migrate` between two cache flushes.
- **The dev database was checked and baselined.** Every model's `getColumns()`/`getIndexes()` was compared
  against the actual schema: 53 tables, **no column or table drift at all**, and two index differences — a
  declared `ix_customer_billingoPartnerDirty` that had never been created, and an `ix_company_invoiceFileId`
  created by hand on the `payment` table under the wrong prefix. `m260808_101500_index_drift.php` fixes both,
  checking the current state before each step so it is safe on an environment that differs.
- **Production was checked too**, from a schema export imported into a scratch database, after the developer
  had run the migration there. It matches the models exactly — the manual process had drifted only on those
  two indexes, and only on dev. The migration is still written defensively, because that was not knowable in
  advance.
- **A schema change now has to be called out in the phase review** and named in the deployment steps, so a
  migration is never pushed unnoticed.
- Migrations are **not** a record of historical shapes: `SchemaHelper::create()` reflects the model as it is
  today. This decision makes them a record of *when* a change was applied, not of *what the table used to
  look like*.
- Rollback stays manual. Most migrations rely on the models' schema declarations, so a `down` path is not
  automatic — take a database dump before deploying one.
