PHPackages                             nandan108/attrecord-migrations - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Database &amp; ORM](/categories/database)
4. /
5. nandan108/attrecord-migrations

ActiveLibrary[Database &amp; ORM](/categories/database)

nandan108/attrecord-migrations
==============================

Declarative schema convergence for attrecord: introspect the live database, diff it against attribute-derived TableSchema, and apply a classified, guarded ALTER plan.

v0.2.0(today)02↑2900%MITPHPPHP ^8.1CI passing

Since Jul 27Pushed todayCompare

[ Source](https://github.com/Nandan108/attrecord-migrations)[ Packagist](https://packagist.org/packages/nandan108/attrecord-migrations)[ Docs](https://github.com/Nandan108/attrecord-migrations)[ RSS](/packages/nandan108-attrecord-migrations/feed)WikiDiscussions main Synced today

READMEChangelog (2)Dependencies (4)Versions (3)Used By (0)

attrecord-migrations
====================

[](#attrecord-migrations)

[![CI](https://github.com/Nandan108/attrecord-migrations/actions/workflows/ci.yml/badge.svg)](https://github.com/Nandan108/attrecord-migrations/actions/workflows/ci.yml)[![Coverage](https://camo.githubusercontent.com/8e83d38d8d3415375a33c93361628205887fd8980280cc627e7cf8938d7c3582/68747470733a2f2f636f6465636f762e696f2f67682f6e616e64616e3130382f6174747265636f72642d6d6967726174696f6e732f6272616e63682f6d61696e2f67726170682f62616467652e737667)](https://codecov.io/gh/nandan108/attrecord-migrations)[![Packagist Version](https://camo.githubusercontent.com/f7c2da07b2c1c5148056a14a68615c3d483ac96bf24d1951d3a8fd95c019c705/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f6e616e64616e3130382f6174747265636f72642d6d6967726174696f6e73)](https://packagist.org/packages/nandan108/attrecord-migrations)[![PHP Version](https://camo.githubusercontent.com/26375886ba51a72e265aa2e5eb780b355c4115d943bda596ac7398e39d78c319/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f7068702d762f6e616e64616e3130382f6174747265636f72642d6d6967726174696f6e73)](https://packagist.org/packages/nandan108/attrecord-migrations)[![License](https://camo.githubusercontent.com/8becd1b0f9986f073778d4e87b845eae320c2ee113e753291c74e01c71b98733/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f6c2f6e616e64616e3130382f6174747265636f72642d6d6967726174696f6e73)](LICENSE)

Declarative schema convergence for [attrecord](https://github.com/Nandan108/attrecord): the Record class **is** the schema, so evolution is *convergence* — introspect the live database, diff it against the attribute-derived `TableSchema`, and apply a classified, guarded `ALTER` plan. No migration files, no second source of truth, no down scripts.

Design contract: [attrecord's `docs/arch-migrations.md`](https://github.com/Nandan108/attrecord/blob/main/docs/arch-migrations.md)— read it for the full rationale (why convergence beats a migration chain for attribute-driven schemas and file-replacement deployments, prior art, and the non-goals fence).

Backends: **MySQL/MariaDB, PostgreSQL, SQLite** — same tri-dialect matrix as attrecord itself.

---

Installation
------------

[](#installation)

```
composer require nandan108/attrecord-migrations
```

Requires PHP 8.1+ and `nandan108/attrecord` ^0.12. No other runtime dependencies.

It is a **separate package by design**: attrecord's DDL producer is fresh-install only, and schema *evolution* carries risks (destructive ALTERs, live introspection, a safety model) that have no business being reachable from a library you pull in to write rows. Nothing here runs unless you call it.

---

Quick start
-----------

[](#quick-start)

```
use Nandan108\AttrecordMigrations\SchemaMigrator;
use Nandan108\AttrecordMigrations\Plan\ChangeClass;

$migrator = new SchemaMigrator($connection);      // an attrecord Connection

// plan() is PURE: reads the catalog, executes nothing. Always safe to call.
$plan = $migrator->plan([OrderRecord::class, SupplierRecord::class]);   // any order — see below

$plan->isEmpty();                 // fast path — nothing to do
foreach ($plan->changes as $c) {  // inspectable: SQL + classification + reason
    printf("[%s] %s %s.%s — %s\n", $c->class->value, $c->kind, $c->table, $c->subject, $c->reason);
}

$migrator->apply($plan);                                   // Safe changes only (default)
$migrator->apply($plan, allow: ChangeClass::Destructive);  // opt-in escalation
```

A converged database re-plans **empty** — that invariant (create → introspect → identical canonical tuples on both sides) is pinned by the test suite on all three backends.

Change classes — the safety model
---------------------------------

[](#change-classes--the-safety-model)

Every planned change carries a class; `apply(allow:)` is a **ceiling**:

ClassAppliedExamples`Safe` (default)yes`ADD COLUMN` (nullable/defaulted), `ADD INDEX`, widenings (`VARCHAR(64)→(191)`, `SMALLINT→INT`), default changes, declared renames. `ADD UNIQUE`/`ADD FK` are Safe but flagged `mayRejectExistingRows` — they can *loudly* reject (atomic failure, never silent loss).`Destructive`opt-in only`DROP COLUMN`, narrowing conversions, `NULL→NOT NULL` tightening, undeclared-index drops.`Manual`**never**PK changes, auto-increment/generation drift, anything the pipeline is *unsure* about, SQLite rebuild-only changes. No SQL — a reason to read, not a statement to run.The pipeline's bias is **fail-safe**: an unparseable live type or ambiguous facet degrades to Manual with a reason. It never guesses an ALTER (the Doctrine `schema-tool:update` lesson — normalization + classification are the load-bearing parts, not the diff).

Renames are declared, never inferred
------------------------------------

[](#renames-are-declared-never-inferred)

```
#[Column(ColumnType::VarChar, length: 64, renamedFrom: 'sku_code')]
public string $sku = '';
```

produces a data-preserving `RENAME`/`CHANGE COLUMN` instead of a destructive drop+add. Inference from drop+add similarity is a known trap (Skeema refuses it; Django prompts a human); the `renamedFrom` marker is permanent, cheap documentation of the column's history.

Run-once data steps
-------------------

[](#run-once-data-steps)

Schema converges because its state is introspectable. Data *shape* is not — so transforms the differ cannot see (content changes within an unchanged column type) use the run-once registry:

```
$migrator->dataStep('2026-07-wrap-payload-json', function ($session): void {
    $session->exec("UPDATE orders SET payload = JSON_OBJECT('data', payload) WHERE …");
});
```

At most once per database, recorded in the step ledger (`attrecord_schema_steps`) — the one place the ledger is authoritative. There is no `down()`: on a file-replacement deployment model no code exists to run it at the right moment; roll forward or restore a backup.

Circular foreign keys
---------------------

[](#circular-foreign-keys)

Two tables that reference each other have **no** creation order that works while every FK is emitted inline — whichever goes first points at a table that does not exist yet. Rather than refuse the model, one edge of each loop is deferred:

```
CREATE TABLE b (…)                     -- without its FK to a
CREATE TABLE a (… FOREIGN KEY → b)     -- b exists now
ALTER TABLE b ADD CONSTRAINT … FOREIGN KEY → a

```

Nothing to configure: the cycle is found in the declared graph, and which edge gets deferred falls out of the input order, so the same model set always resolves the same way. The deferred `ADD` is ordered after *every* create in the plan, because its target may be created later in the same run.

Tables whose shape is computed
------------------------------

[](#tables-whose-shape-is-computed)

Some tables have a shape that is only known at runtime: a registry that grows a column per registered dimension, an extension table a plugin writes into. A Record class cannot describe those, so `plan()` also accepts a ready-made `TableSchema` — derive one with attrecord's `TableSchema::extendedWith()` and pass it alongside your class-strings:

```
$schema = TableSchema::fromClass(SlotSpace::class)->extendedWith(
    columns: ['dim_loc' => new ColumnDefinition(name: 'dim_loc', /* … */)],
    indexes: ['idx_dim_loc' => ['active', 'dim_loc']],
);

$migrator->plan([Order::class, Supplier::class, $schema]);
```

Those columns are then created, converged and diffed like any other — including being **added** to an existing table when the runtime set grows. `fingerprint()` covers them too, so the fast path notices when the runtime set changes. Describing them beats the alternative, a hand-written `ALTER TABLE` run at boot: a second source of DDL that no tooling can see or verify.

### When you can't describe them: `PartiallyDeclared`

[](#when-you-cant-describe-them-partiallydeclared)

If the extra columns genuinely cannot be enumerated, the Record can opt out of drift detection for whatever it does not declare:

```
#[Table(name: 'app_slotspace')]
final class SlotSpace extends Record implements PartiallyDeclared {}
```

The differ is then narrowed to what the Record declares: missing declared columns and indexes are still added, declared ones still converge, but nothing undeclared is ever proposed for dropping — columns, indexes and constraints alike. The trade-off is one-directional, which is why it is opt-in per Record: on such a table, a genuinely stray column from an old version is never surfaced either. Prefer describing the columns when you can; this is the fallback.

(The two do not combine: a `TableSchema` you built is taken at face value, since the point of building it is that the columns are now described.)

Fingerprint fast path
---------------------

[](#fingerprint-fast-path)

```
$fp = $migrator->fingerprint($classes);   // == $plan->fingerprint
```

sha256 over the dialect's own `buildCreateTable()` output. Store the last-converged value (e.g. a WordPress option) and skip even `plan()`'s introspection while the running code's fingerprint matches.

The ledger
----------

[](#the-ledger)

Every `apply()` run is recorded in `attrecord_schema_runs` (statements + outcomes + error) — for **forensics only**. The differ never reads it: truth about the live schema comes from the live schema, so a restored backup or hand-edited database simply re-plans correctly.

Both ledger tables can live under your own naming — subclass the Record with its own `#[Table]`and hand the subclass to the migrator:

```
#[Table(name: 'myapp_schema_runs')]
final class MyRunRecord extends SchemaRunRecord {}

$migrator = new SchemaMigrator($connection, runRecordClass: MyRunRecord::class);
```

Limitations (all fail LOUD as `Manual`, never silently wrong)
-------------------------------------------------------------

[](#limitations-all-fail-loud-as-manual-never-silently-wrong)

- **SQLite**: no in-place column modification and no FK add/drop — those changes classify Manual (the table-rebuild dance is phase 2). `ADD COLUMN` with a non-constant default likewise. This also means a **cyclic** schema only partly converges there: the tables are created, but the deferred constraint (see "Circular foreign keys" above) cannot be added and is reported as Manual.
- **Enum members** are only introspectable on MySQL-family (PG/SQLite store them in CHECK constraints, not modeled yet) — member drift is caught on MySQL, invisible elsewhere.
- **MySQL-family `json`** folds to `longtext` (MariaDB stores JSON as LONGTEXT) — json↔longtext drift is undetectable there.
- **Generated columns** are compared on every facet *except* nullability and the expression itself — both of which the engine owns rather than the declaration (it reports them nullable whatever you wrote, and stores its own spelling of the expression). Comparing either would make a correct database report drift forever. The cost: a *changed* generation expression is not detected. A column gaining or losing generation entirely still is.
- `ON UPDATE CURRENT_TIMESTAMP` drift and PostgreSQL `BIT` round-trips are not compared.
- Tables the Records don't declare are **invisible** — never dropped, never touched.
- An index whose leading columns are a foreign key's columns is treated as that FK's supporting plumbing and never proposed for dropping, even if the Records don't declare it. A genuinely operator-added index on exactly an FK's columns therefore survives — the fail-safe direction.

---

Running tests
-------------

[](#running-tests)

```
# Unit tests (no DB needed) — differ/classifier against hand-built live schemas
composer test -- --testsuite unit

# Integration tests. The MariaDB + PostgreSQL containers are attrecord's; SQLite needs no server.
docker compose up -d          # in ../attrecord
composer test -- --testsuite integration

# All tests
composer test

# One backend only (the integration suites are tagged by @group)
composer test -- --testsuite integration --group Mysql
composer test -- --testsuite integration --group Pgsql
composer test -- --testsuite integration --group Sqlite
```

Integration tests reuse attrecord's containers but their **own database**(`attrecord_migrations_test`, auto-created by the test support layer) — never attrecord's `attrecord_test`. Environment variables (defaults shown):

```
# MySQL / MariaDB
DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=attrecord_migrations_test
DB_USER=root
DB_PASS=root

# PostgreSQL
PGSQL_HOST=127.0.0.1
PGSQL_PORT=5432
PGSQL_DB=attrecord_migrations_test
PGSQL_USER=postgres
PGSQL_PASS=postgres

```

An unreachable engine makes its suite **skip**, which locally is a convenience and in CI would be a lie — so CI runs with `--fail-on-skipped`. A skipped backend is not a passing backend.

Two suites carry most of the weight, both against real engines:

- **Golden round-trip** — every portable `ColumnType` (plus `SET`/`BIT` on MySQL, where they exist) created by the DDL producer, introspected back, and asserted to normalize to *identical* tuples on both sides. A type missing here is a type that could false-positive an ALTER at a consumer.
- **Drift matrix** — one scenario per kind of drift (widen/narrow, nullability, default, integer and decimal widening, enum members, rename, index reshape, FK action change, undeclared FK), injected as raw DDL into a converged database, then `plan → assert kind + class → apply → re-plan EMPTY`. Each backend states its own expectations, so the drifts an engine *cannot see*(SQLite stores affinity, not width; enum members are MySQL-only) are pinned as explicitly empty rather than quietly untested.

### Code style &amp; static analysis

[](#code-style--static-analysis)

Style is enforced with [PHP CS Fixer](https://cs.fixer.dev/) (the `@Symfony` ruleset plus project overrides in `.php-cs-fixer.php`), and types with [Psalm](https://psalm.dev/) at level 1:

```
composer cs-fix     # apply PHP CS Fixer
composer cs-check   # report violations without changing files (used in CI)
composer psalm      # static analysis — must be zero errors
```

All three run in CI against PHP 8.1–8.4 with MySQL 8.0/8.4, MariaDB 10.11/11.4, PostgreSQL 14–17 and SQLite — the same published attrecord release a consumer would get.

---

Contributing
------------

[](#contributing)

Issues and pull requests are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) for the dev setup and the checks to run. Behavioural design changes belong in the design contract ([attrecord's `docs/arch-migrations.md`](https://github.com/Nandan108/attrecord/blob/main/docs/arch-migrations.md)), whose §7 is the non-goals fence.

---

License
-------

[](#license)

[MIT](LICENSE) © Samuel de Rougemont

###  Health Score

37

—

LowBetter than 81% of packages

Maintenance100

Actively maintained with recent releases

Popularity3

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity33

Early-stage or recently created project

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~0 days

Total

2

Last Release

0d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/024c3beb5cbe22cd3e3f3db6938cf893c09d0b3b90076e2e1566530cd0693372?d=identicon)[Nandan108](/maintainers/Nandan108)

---

Top Contributors

[![Nandan108](https://avatars.githubusercontent.com/u/354944?v=4)](https://github.com/Nandan108 "Nandan108 (20 commits)")

---

Tags

diffschemamysqlsqlitepostgresqlmariadbmigrationsdeclarativephp8ddlattrecord

###  Code Quality

TestsPHPUnit

Static AnalysisPsalm

Code StylePHP CS Fixer

Type Coverage Yes

### Embed Badge

![Health badge](/badges/nandan108-attrecord-migrations/health.svg)

```
[![Health](https://phpackages.com/badges/nandan108-attrecord-migrations/health.svg)](https://phpackages.com/packages/nandan108-attrecord-migrations)
```

###  Alternatives

[doctrine/dbal

Powerful PHP database abstraction layer (DBAL) with many features for database schema introspection and management.

9.7k605.0M7.0k](/packages/doctrine-dbal)[catfan/medoo

The lightweight PHP database framework to accelerate development

5.0k1.6M207](/packages/catfan-medoo)[scienta/doctrine-json-functions

A set of extensions to Doctrine that add support for json query functions.

58925.9M54](/packages/scienta-doctrine-json-functions)[tommyknocker/pdo-database-class

Framework-agnostic PHP database library with unified API for MySQL, MariaDB, PostgreSQL, SQLite, MSSQL, and Oracle. Query Builder, caching, sharding, window functions, CTEs, JSON, migrations, ActiveRecord, CLI tools, AI-powered analysis. Zero external dependencies.

826.1k](/packages/tommyknocker-pdo-database-class)[aura/sqlschema

Provides facilities to read table names and table columns from a database using PDO.

44243.2k4](/packages/aura-sqlschema)[watheqalshowaiter/model-fields

Get model fields fast — required, nullable, or default

402.1k](/packages/watheqalshowaiter-model-fields)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
