📦 Marketplace⭐ GitHub
Data Layerv2.0

Migration

Entity-schema diff engine, SQL migration runner, and dialect-aware DDL generation for the MonkeysLegion ecosystem. Compares PHP 8.4 entity attributes against a live database and produces safe, dependency-ordered SQL.

Features

  • Attribute-Driven Schema: Reads #[Entity], #[Field], #[Id], #[Index], #[Timestamps], #[SoftDeletes], #[AuditTrail], #[Versioned], #[ManyToOne], #[OneToOne], #[ManyToMany] — zero manual mapping
  • Structural Diff Engine: Computes column adds/drops/modifications, index changes, and FK changes between desired and current state
  • Multi-Dialect: MySQL, PostgreSQL, SQLite — each with proper quoting, type mapping, and DDL syntax
  • FK Dependency Ordering: Topological sort (Kahn's algorithm) ensures referenced tables are created before referencing tables
  • Migration Runner: Batch tracking, rollback, refresh, fresh, status, and dry-run (pretend)
  • File Generation: Generates timestamped PHP migration classes with up() and down() methods
  • Schema Introspection: Reads live database metadata into the same TableDefinition structures used by the entity builder
  • SQL Injection Prevention: IdentifierValidator blocks dangerous patterns, enforces length limits, and warns on reserved words
  • Protected Tables: migrations and ml_migrations are never dropped automatically
  • PHP 8.4 Native: Property hooks, readonly classes, match expressions, named arguments

Requirements

  • PHP 8.4+
  • monkeyscloud/monkeyslegion-core ^2.0
  • monkeyscloud/monkeyslegion-di ^2.0
  • monkeyscloud/monkeyslegion-entity ^2.0
  • monkeyscloud/monkeyslegion-database ^2.0

Installation

composer require monkeyscloud/monkeyslegion-migration:^2.0

Architecture

The package is decomposed into six independent subsystems:

MigrationGenerator (facade)
├── Schema
│   ├── EntitySchemaBuilder   → Reads entity attributes → TableDefinition
│   ├── SchemaIntrospector    → Reads live DB → TableDefinition
│   ├── TableDefinition       → Immutable table structure
│   ├── ColumnDefinition      → Immutable column structure
│   ├── IndexDefinition       → Immutable index structure
│   └── ForeignKeyDefinition  → Immutable FK structure
├── Diff
│   ├── SchemaDiffer          → Compares two states → DiffPlan
│   ├── DiffPlan              → Structured diff (creates, alters, drops)
│   ├── TableDiff             → Per-table diff (columns, indexes, FKs)
│   └── ColumnChange          → Before/after for modified columns
├── Renderer
│   └── SqlRenderer           → DiffPlan → dialect-specific SQL
├── Dialect
│   ├── SqlDialect (interface)
│   ├── MySqlDialect
│   ├── PostgreSqlDialect
│   └── SqliteDialect
├── Runner
│   ├── MigrationRunner       → Executes migration files
│   ├── BatchTracker          → Tracks executed migrations in ml_migrations
│   ├── RunResult             → Result value object
│   └── MigrationStatus       → Status of each migration file
└── Security
    └── IdentifierValidator   → SQL identifier validation

Quick Start

Schema Update (CLI)

The most common workflow uses the schema:update CLI command provided by monkeyslegion-cli:

# Preview what SQL will be generated (dry-run)
php ml schema:update --dump-sql

# Apply changes directly to the database
php ml schema:update --force

Programmatic Usage

use MonkeysLegion\Migration\MigrationGenerator;
use MonkeysLegion\Database\Contracts\ConnectionInterface;

$generator = new MigrationGenerator($connection);

// Compute the diff between entities and the live database
$sql = $generator->diff(
    entities: [App\Entity\User::class, App\Entity\Post::class],
    schema:   $currentSchema, // or pass null to auto-introspect
);

echo $sql;
// CREATE TABLE `users` (
//   `id` INT NOT NULL AUTO_INCREMENT,
//   `email` VARCHAR(255) NOT NULL,
//   `created_at` DATETIME NULL,
//   PRIMARY KEY (`id`)
// ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

Entity Attributes

The EntitySchemaBuilder reads all MonkeysLegion v2 entity attributes:

Basic Entity

use MonkeysLegion\Entity\Attributes\{Entity, Id, Field, Timestamps, SoftDeletes};

#[Entity(table: 'users')]
#[Timestamps]
#[SoftDeletes]
class User
{
    #[Id]
    #[Field(type: 'int', autoIncrement: true)]
    public int $id;

    #[Field(type: 'string', length: 255, unique: true)]
    public string $email;

    #[Field(type: 'string', length: 100)]
    public string $name;

    #[Field(type: 'boolean', default: true)]
    public bool $active;
}

This generates:

CREATE TABLE `users` (
  `id` INT NOT NULL AUTO_INCREMENT,
  `email` VARCHAR(255) NOT NULL,
  `name` VARCHAR(100) NOT NULL,
  `active` TINYINT(1) NOT NULL DEFAULT TRUE,
  `created_at` DATETIME NULL,
  `updated_at` DATETIME NULL,
  `deleted_at` DATETIME NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE UNIQUE INDEX `uq_users_email` ON `users` (`email`);

Relationships

use MonkeysLegion\Entity\Attributes\{ManyToOne, OneToOne, ManyToMany, JoinTable};

class Post
{
    #[Id]
    #[Field(type: 'int', autoIncrement: true)]
    public int $id;

    // ManyToOne → creates `author_id` FK column
    #[ManyToOne(targetEntity: User::class)]
    public User $author;

    // ManyToMany → creates join table via #[JoinTable]
    #[ManyToMany(targetEntity: Tag::class)]
    #[JoinTable(name: 'post_tags', joinColumn: 'post_id', inverseColumn: 'tag_id')]
    public array $tags;
}

The builder automatically:

  • Creates FK columns with the correct type (matching the referenced PK)
  • Sets ON DELETE behavior (SET NULL for nullable, RESTRICT for required)
  • Generates join tables with composite primary keys for ManyToMany

Supported Attributes

AttributeLevelEffect
#[Entity(table: '...')]ClassSets the table name (default: snake_case of class name)
#[Id]PropertyMarks as primary key
#[Field(...)]PropertyDefines column type, length, nullable, default, unique, etc.
#[Column(name: '...')]PropertyOverrides the DB column name
#[Index(columns: [...], unique: bool)]Class/PropertyCreates indexes (composite when on class)
#[Timestamps]ClassAdds created_at and updated_at columns
#[SoftDeletes]ClassAdds deleted_at column
#[AuditTrail]ClassAdds created_by, updated_by, created_ip, updated_ip
#[Versioned]PropertyMarks as optimistic lock column
#[Uuid]PropertyUses UUID type for the primary key
#[Virtual]PropertySkips — no database column
#[ManyToOne]PropertyCreates FK column + constraint
#[OneToOne]PropertyCreates FK column + constraint (owning side only)
#[ManyToMany] + #[JoinTable]PropertyCreates a join table

Schema Diffing

Structured Diff (v2 API)

$plan = $generator->computeDiff(
    entities: [User::class, Post::class],
    schema:   null, // auto-introspect from live DB
);

if ($plan->isEmpty()) {
    echo "Schema is up to date.\n";
} else {
    echo $plan->toHumanReadable();
    // CREATE TABLE posts (4 columns, 1 indexes, 1 FKs)
    // ALTER TABLE users: +1 columns, ~1 modified

    echo "Total changes: {$plan->changeCount()}\n";
}

DiffPlan Structure

The DiffPlan contains three lists:

PropertyTypeDescription
createTableslist<TableDefinition>Full table definitions to create
alterTableslist<TableDiff>Per-table diffs (columns, indexes, FKs)
dropTableslist<string>Table names to drop

Each TableDiff contains:

PropertyTypeDescription
addedColumnslist<ColumnDefinition>New columns
modifiedColumnslist<ColumnChange>Changed columns (from → to)
droppedColumnslist<string>Removed column names
addedIndexeslist<IndexDefinition>New indexes
droppedIndexeslist<string>Removed index names
addedForeignKeyslist<ForeignKeyDefinition>New FK constraints
droppedForeignKeyslist<string>Removed FK names

Column Change Detection

The differ detects changes in:

  • Type — e.g. VARCHAR(100)VARCHAR(255)
  • NullabilityNOT NULLNULL
  • Default valueDEFAULT 'active'DEFAULT 'pending'
  • Enum valuesENUM('a','b')ENUM('a','b','c')
  • Length/PrecisionDECIMAL(10,2)DECIMAL(12,4)

SQL Rendering

The SqlRenderer converts a DiffPlan into dialect-specific DDL:

use MonkeysLegion\Migration\Renderer\SqlRenderer;
use MonkeysLegion\Migration\Dialect\MySqlDialect;

$renderer = new SqlRenderer(new MySqlDialect());

// Forward SQL (up)
$sql = $renderer->render($plan);

// Reverse SQL (down — for rollback)
$rollbackSql = $renderer->renderReverse($plan);

// Individual statements (no FK-check wrapper)
$statements = $renderer->renderStatements($plan);

The rendered SQL automatically includes:

  • FK check disable/enable wrappers (MySQL: SET FOREIGN_KEY_CHECKS=0/1)
  • Proper identifier quoting per dialect (` for MySQL, " for PostgreSQL/SQLite)
  • Engine suffixes (ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 for MySQL)

Dialect Support

Each dialect implements the SqlDialect interface:

FeatureMySQLPostgreSQLSQLite
Quoting`name`"name""name"
Auto-incrementAUTO_INCREMENTSERIAL / BIGSERIALAUTOINCREMENT
UUID typeCHAR(36)UUIDTEXT
BooleanTINYINT(1)BOOLEANINTEGER
JSONJSONJSONBTEXT
Transactional DDL
FK inline (CREATE TABLE)❌ (ALTER)❌ (ALTER)✅ (inline)
DROP INDEXDROP INDEX idx ON tblDROP INDEX idxDROP INDEX idx
ALTER COLUMNMODIFY COLUMNALTER COLUMN ... TYPE❌ (rebuild)

Type Mapping

All dialects support these logical types:

Logical TypeMySQLPostgreSQLSQLite
intINTINTEGERINTEGER
bigintBIGINTBIGINTINTEGER
smallintSMALLINTSMALLINTINTEGER
stringVARCHAR(n)VARCHAR(n)TEXT
textTEXTTEXTTEXT
booleanTINYINT(1)BOOLEANINTEGER
datetimeDATETIMETIMESTAMPTEXT
dateDATEDATETEXT
decimalDECIMAL(p,s)NUMERIC(p,s)REAL
floatFLOATREALREAL
uuidCHAR(36)UUIDTEXT
jsonJSONJSONBTEXT
enumENUM(...)VARCHAR(255)TEXT
ipaddressVARCHAR(45)INETTEXT

Migration Runner

Running Migrations

use MonkeysLegion\Migration\Runner\{MigrationRunner, BatchTracker};

$tracker = new BatchTracker($connection);
$runner  = new MigrationRunner($connection, $tracker);

// Run all pending migrations
$result = $runner->run('/path/to/var/migrations');

echo "Executed: " . count($result->executed) . "\n";
echo "Duration: {$result->durationMs}ms\n";
echo "Success: " . ($result->success ? 'yes' : 'no') . "\n";

if ($result->error) {
    echo "Error: {$result->error}\n";
}

Rollback

// Rollback the last batch
$result = $runner->rollback();

// Rollback the last 3 individual migrations
$result = $runner->rollback(steps: 3);

// Rollback a specific batch
$result = $runner->rollback(batch: 5);

Reset, Refresh, Fresh

// Reset — rollback ALL migrations
$result = $runner->reset();

// Refresh — rollback all then re-run all
$result = $runner->refresh('/path/to/var/migrations');

// Fresh — drop ALL tables then re-run all
$result = $runner->fresh('/path/to/var/migrations');

Migration Status

$statuses = $runner->status('/path/to/var/migrations');

foreach ($statuses as $status) {
    printf(
        " %s  %-40s  batch: %d\n",
        $status->ran ? '✅' : '⏳',
        $status->name,
        $status->batch ?? 0,
    );
}

Dry Run (Pretend)

$statements = $runner->pretend('/path/to/var/migrations');

foreach ($statements as $stmt) {
    echo "$stmt\n";
}

Transactional DDL

The runner automatically wraps migration execution in transactions for dialects that support transactional DDL:

DialectTransactional DDLBehavior
MySQLEach DDL statement auto-commits
PostgreSQLFull migration wrapped in transaction
SQLiteFull migration wrapped in transaction

Migration File Generation

$path = $generator->generate(
    entities: [User::class, Post::class],
    schema:   $currentSchema,
    name:     'add_posts_table',
);

echo "Generated: {$path}\n";
// var/migrations/M20260426120000_Add_posts_table.php

Generated file:

<?php
declare(strict_types=1);

namespace App\Migration;

use MonkeysLegion\Database\Contracts\ConnectionInterface;

final class M20260426120000_Add_posts_table
{
    public function up(ConnectionInterface $db): void
    {
        $db->pdo()->exec(<<<'SQL'
        CREATE TABLE `posts` (
          `id` INT NOT NULL AUTO_INCREMENT,
          `title` VARCHAR(255) NOT NULL,
          `author_id` INT NOT NULL,
          PRIMARY KEY (`id`)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
        SQL);
    }

    public function down(ConnectionInterface $db): void
    {
        $db->pdo()->exec(<<<'SQL'
        DROP TABLE IF EXISTS `posts`
        SQL);
    }
}

Schema Backup

$backupPath = $generator->backup();
echo "Backup saved to: {$backupPath}\n";
// var/backups/schema_20260426120000.sql

Batch Tracking

The BatchTracker uses the ml_migrations table to track which migrations have been executed:

-- MySQL
CREATE TABLE IF NOT EXISTS ml_migrations (
    id          INT AUTO_INCREMENT PRIMARY KEY,
    migration   VARCHAR(255) NOT NULL,
    batch       INT NOT NULL,
    executed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

The table is automatically created on first use.

Security

Identifier Validation

All SQL identifiers pass through IdentifierValidator before being used in DDL:

use MonkeysLegion\Migration\Security\IdentifierValidator;

// Validates: length (≤64), pattern (alphanumeric + underscore), no injection
IdentifierValidator::validate('users');          // ✅
IdentifierValidator::validate('user; DROP --');  // ❌ InvalidArgumentException

// Check reserved words
IdentifierValidator::isReservedWord('SELECT');   // true
IdentifierValidator::isReservedWord('users');    // false

// Validate with warnings (reserved words are valid but flagged)
$warnings = IdentifierValidator::validateWithWarnings('order');
// ['Identifier "order" is a SQL reserved word. It will be quoted but may cause confusion.']

Blocked Patterns

The validator rejects identifiers containing:

PatternRisk
--SQL comment injection
/* / */Block comment injection
;Statement termination
' / "String escape
\Escape character
\x00Null byte injection

v2 Component Access

The MigrationGenerator facade exposes its internal components for advanced usage:

$generator = new MigrationGenerator($connection);

$builder      = $generator->getEntityBuilder();   // EntitySchemaBuilder
$introspector = $generator->getIntrospector();     // SchemaIntrospector
$differ       = $generator->getDiffer();           // SchemaDiffer
$renderer     = $generator->getRenderer();         // SqlRenderer
$dialect      = $generator->getDialect();          // SqlDialect (MySQL|PG|SQLite)

Testing

composer test              # Run PHPUnit
composer test:unit         # Unit tests only
composer test:integration  # Integration tests only
composer test:coverage     # Generate HTML coverage report
composer phpstan           # Run PHPStan level 8
composer check             # CS + PHPStan + tests

License

MIT — © 2026 MonkeysCloud Inc.