Developer Documentation

The Nudelsalat Engine

State-based database migrations for PHP with full ORM support. Framework-agnostic, driver-aware, integrity-guaranteed.

PHP ≥ 8.1 55 Tests Passing State-Based MySQL · PostgreSQL · SQLite Full ORM

Why Nudelsalat?

Most PHP migration libraries ask you to write SQL or a series of schema methods by hand — a script-based approach. Nudelsalat follows a state-based philosophy: you describe what your schema looks like (via Model classes), and Nudelsalat figures out what SQL is needed to get there.

🧠
Intelligent Autodetector
Compares two ProjectState snapshots and generates the minimal set of operations — including interactive rename detection.
🛡️
Integrity Shield
SHA256 checksums stored on every applied migration detect silent file edits and prevent database desync.
📊
DAG Dependency Graph
A Directed Acyclic Graph resolves topological order for cross-app dependencies and detects circular references.
🗄️
Multi-Driver
Native SchemaEditor implementations for PostgreSQL, MySQL/MariaDB, and SQLite with per-driver SQL generation.
Historical Models
The StateRegistry freezes model definitions at migration time, so data migrations never break even if models are deleted later.
🔗
Full ORM Layer
Complete QuerySet with CRUD, aggregations, raw SQL, pagination, transactions, signals, and validation. Same API across all databases.
🧪
Battle-Tested
55+ test suite run in Docker against PostgreSQL, MySQL, and SQLite, with smoke-testing on a 147-table production schema.

Installation

Nudelsalat is a Composer package with a CLI binary. Install it into your project and load it through Composer's autoloader.

Requirements

Install via Composer

composer require nudelsalat/migrations

Create Configuration File

After installation, create your configuration file in your project root:

cp vendor/nudelsalat/migrations/nudelsalat.config.php.example nudelsalat.config.php

Then edit nudelsalat.config.php and fill in your database credentials:

<?php
return [
    'database' => [
        'driver' => 'mysql',  // mysql, pgsql, or sqlite
        'dsn' => 'mysql:host=localhost;dbname=myapp',
        'user' => 'root',
        'pass' => 'your_password',
    ],
    'apps' => [
        'main' => [
            'models' => __DIR__ . '/src/Models',
            'migrations' => __DIR__ . '/database/migrations',
        ],
    ],
];
💡

You can also use SQLite for local development: 'dsn' => 'sqlite:' . __DIR__ . '/database/database.sqlite'

Run the CLI

./vendor/bin/nudelsalat --version
PHP requirement: Nudelsalat uses constructor property promotion, named arguments, match expressions, and str_starts_with() — all requiring PHP 8.1+.

Quick Start

  1. Copy the example config and fill in your database credentials.
    cp nudelsalat/nudelsalat.config.php.example nudelsalat/nudelsalat.config.php
  2. Define your first Model.
    namespace App\Models;
    
    use Nudelsalat\ORM\Model;
    use Nudelsalat\Migrations\Fields\IntField;
    use Nudelsalat\Migrations\Fields\StringField;
    
    class User extends Model {
        public static function fields(): array {
            return [
                'id'    => new IntField(primaryKey: true, autoIncrement: true),
                'email' => new StringField(length: 200),
                'name'  => new StringField(length: 100, nullable: true),
            ];
        }
    }
  3. Generate the migration.
    ./nudelsalat make

    Nudelsalat scans your model directory, compares it to the current migration state, and writes a .php migration file.

  4. Apply the migration.
    ./nudelsalat migrate
  5. Verify the status.
    ./nudelsalat show

Configuration

All settings live in nudelsalat.config.php which returns a plain PHP array.

<?php
return [
    'database' => [
        'driver' => 'pgsql',          // mysql | pgsql | sqlite
        'dsn'    => 'pgsql:host=localhost;dbname=mydb;port=5432',
        'user'   => 'postgres',
        'pass'   => getenv('DB_PASS'),
    ],

    'apps' => [
        // Each app has a models directory and a migrations directory.
        'main' => [
            'models'     => __DIR__ . '/src/Models',
            'migrations' => __DIR__ . '/database/migrations',
        ],

        // Multi-app support: cross-app ForeignKeys just work.
        'auth' => [
            'models'     => __DIR__ . '/src/Auth/Models',
            'migrations' => __DIR__ . '/database/auth_migrations',
        ],
    ],
];

Environment Variables

Use getenv() in your config to keep secrets out of version control. The example config uses BEEF_DB_DSN, BEEF_DB_USER, and BEEF_DB_PASS by default.

Defining Models

Every model extends Nudelsalat\ORM\Model and implements a single static method: fields(). The Inspector reads these at runtime to construct the current ProjectState.

namespace App\Models;

use Nudelsalat\ORM\Model;
use Nudelsalat\Migrations\Fields\{IntField, StringField, DecimalField, BooleanField};
use Nudelsalat\Migrations\Fields\ForeignKey;

class Product extends Model {
    // Override the inferred table name (default: lowercase class name)
    public static array $options = [
        'db_table' => 'store_products',
    ];

    public static function fields(): array {
        return [
            'id'       => new IntField(primaryKey: true, autoIncrement: true),
            'sku'      => new StringField(length: 80),
            'price'    => new DecimalField(maxDigits: 10, decimalPlaces: 2),
            'active'   => new BooleanField(default: true),
            'category' => new ForeignKey(to: 'Category', onDelete: 'CASCADE'),
        ];
    }
}
💡

Table name inference: By default, Nudelsalat derives the table name from the short class name lowercased (e.g. BlogPostblogpost). Override with $options['db_table'].

Field Types

All field classes extend Nudelsalat\Migrations\Fields\Field and live in src/Migrations/Fields/. Each implements getType() and optionally overrides deconstruct() for migration serialization.

ClassSQL TypeExtra ParametersNotes
IntFieldINTUse with primaryKey + autoIncrement for surrogate PKs.
StringFieldVARCHAR(n)length (default 255)Length is embedded in the SQL type string.
TextFieldTEXTUnbounded text; no length constraint.
BooleanFieldBOOLEANDriver handles mapping; SQLite stores as 0/1.
DecimalFieldDECIMALmaxDigits, decimalPlacesHigh-precision for currency. Defaults: 19 digits, 4 places.
DateTimeFieldDATETIMEMaps to TIMESTAMP on PostgreSQL.
JSONFieldSee backendsJSONB on PgSQL, JSON on MySQL, TEXT on SQLite. Has toPhp()/toDatabase() helpers.

Field Options

All field constructors accept the following base parameters (inherited from Field):

ParameterTypeDefaultEffect
nullableboolfalseAllows NULL in the column. Nudelsalat will emit NULL instead of NOT NULL.
defaultmixednullSets a column-level DEFAULT. Required for non-nullable AddField ops on non-empty tables — Nudelsalat will prompt you interactively if missing.
primaryKeyboolfalseMarks this column as the table's PRIMARY KEY.
autoIncrementboolfalseEnables AUTO_INCREMENT / SERIAL behaviour.
optionsarray[]Driver-specific extras passed through to the SchemaEditor. Use for db_index, unique, db_column, etc.

Common options Keys

KeyEffect
db_columnOverride the physical column name (if different from the PHP key).
db_indexAuto-create a single-column index for this field.
uniqueEmit a UNIQUE constraint on the column.

Model Meta Options

Set public static array $options = [...] on a Model class to control how Nudelsalat treats the model.

OptionTypeEffect
db_tablestringOverride the auto-derived table name.
managedbool (default true)Set to false to exclude this model from Nudelsalat's migration management. Nudelsalat will never touch its table.
proxybool (default false)Mark a model as a proxy — Nudelsalat skips it during CreateModel/DeleteModel detection. Useful for adding methods to existing tables.
abstractbool (default false)Mark a model as abstract — no database table is created. Used for base classes with shared fields.
m2marray (internal)Populated automatically by the Inspector when a ManyToManyField is found. Do not set manually.

Relationships

ForeignKey

Adds a {field}_id column and an AddForeignKey constraint. The Inspector automatically names the constraint fk_{table}_{column}_id.

use Nudelsalat\Migrations\Fields\ForeignKey;

'author' => new ForeignKey(
    to: 'User',          // target model name (table name)
    onDelete: 'CASCADE'  // CASCADE | SET NULL | RESTRICT | NO ACTION
)

ManyToManyField

Declares an M2M relationship. The Autodetector creates a junction table with two ForeignKey columns and two FK constraints.

use Nudelsalat\Migrations\Fields\ManyToManyField;

'tags' => new ManyToManyField(
    to: 'Tag',
    dbTable: 'article_tags'  // optional — defaults to "{model}_{field}"
)
⚙️

The generated junction table contains: id (PK, auto-increment), {source}_id, and {target}_id, each with a proper FK constraint.

ORM: CRUD Operations

Nudelsalat provides a complete ORM layer for interacting with your database. All models extend Nudelsalat\ORM\Model which provides methods for creating, reading, updating, and deleting records.

Creating Records

use App\Models\User;

// Create and save a new record
$user = new User();
$user->name = 'John Doe';
$user->email = 'john@example.com';
$user->save();

// Or use create() for mass assignment
$user = User::create([
    'name' => 'Jane Smith',
    'email' => 'jane@example.com',
]);

Reading Records

// Get all records
$users = User::all();

// Get by primary key
$user = User::get(1);

// Find first matching record
$user = User::first(['email' => 'john@example.com']);

// Find all matching records
$users = User::where(['status' => 'active']);

// Using the Query Builder fluent interface
$users = User::query()
    ->where('status', '=', 'active')
    ->orderBy('created_at', 'DESC')
    ->limit(10)
    ->get();

Updating Records

// Update a single record
$user = User::get(1);
$user->name = 'John Updated';
$user->save();

// Bulk update
User::where(['status' => 'inactive'])->update(['status' => 'archived']);

// Using query builder
User::query()
    ->where('last_login', '<', $cutoffDate)
    ->update(['active' => false]);

Deleting Records

// Delete a single record
$user = User::get(1);
$user->delete();

// Bulk delete
User::where(['status' => 'deleted'])->delete();

// Using query builder
User::query()
    ->where('created_at', '<', $oldDate)
    ->delete();

getOrCreate

Find an existing record or create a new one if not found. Useful for avoiding duplicate entries.

// Returns existing record if found, otherwise creates new
$user = User::getOrCreate(
    ['email' => 'john@example.com'],  // lookup attributes
    ['name' => 'John Doe']            // attributes to set if creating
);

updateOrCreate

Update an existing record or create a new one if not found.

// Update if exists, create if not
$user = User::updateOrCreate(
    ['email' => 'john@example.com'],  // lookup attributes
    ['name' => 'John Updated', 'status' => 'active']  // values to update/set
);

Model API Reference

MethodDescription
static all(): arrayGet all records as array of Model instances
static get(mixed $id): ?staticGet record by primary key
static first(array $where): ?staticGet first record matching conditions
static where(array $where): QueryStart a query with WHERE conditions
static query(): QueryGet a fresh Query builder instance
static create(array $data): staticCreate and save a new record
static getOrCreate(array $lookup, array $defaults): staticFind or create record
static updateOrCreate(array $lookup, array $data): staticUpdate or create record
save(): boolInsert or update the current record
delete(): boolDelete the current record
refresh(): voidReload attributes from database
toArray(): arrayConvert model to array

ORM: Aggregations

Nudelsalat provides aggregation functions that perform calculations on your dataset. All aggregations return a scalar value.

Available Aggregation Functions

FunctionDescription
Sum($field)Calculate total sum of a numeric field
Avg($field)Calculate average value of a field
Min($field)Get minimum value of a field
Max($field)Get maximum value of a field
Count($field = '*')Count records or distinct values

Using Aggregations

use Nudelsalat\ORM\Aggregates\Sum;
use Nudelsalat\ORM\Aggregates\Avg;
use Nudelsalat\ORM\Aggregates\Min;
use Nudelsalat\ORM\Aggregates\Max;
use Nudelsalat\ORM\Aggregates\Count;
use App\Models\Order;

// Count all orders
$totalOrders = Order::aggregate(new Count());

// Count with conditions
$activeUsers = User::aggregate(new Count('id'), ['status' => 'active']);

// Sum a numeric field
$totalRevenue = Order::aggregate(new Sum('total'));

// Average value
$avgPrice = Product::aggregate(new Avg('price'));

// Min/Max values
$minPrice = Product::aggregate(new Min('price'));
$maxPrice = Product::aggregate(new Max('price'));

Using Aggregations with Query Builder

// Using the aggregate() method on Query
$stats = Order::query()
    ->where('status', '=', 'completed')
    ->aggregate(new Sum('total'));

// Or use aggregate with groupBy (returns array)
$byCategory = Product::query()
    ->groupBy('category_id')
    ->aggregate(new Count('id'), [], 'category_id');

F Expressions

Use F expressions to reference field values in queries — useful for comparisons and calculations.

use Nudelsalat\ORM\Expressions\F;

// Find products where price equals cost
$products = Product::query()
    ->where('price', '=', new F('cost'))
    ->get();

// Find orders where quantity is less than stock
$lowStock = Product::query()
    ->where('quantity', '<', new F('reorder_level'))
    ->get();

Q Objects (Complex Queries)

Use Q objects to build complex queries with OR, AND, and NOT operations.

use Nudelsalat\ORM\Expressions\Q;

// AND conditions (default)
$activeAdmins = User::query()
    ->where(new Q(['status' => 'active', 'role' => 'admin']))
    ->get();

// OR conditions
$premiumOrVIP = User::query()
    ->where(
        new Q(['subscription' => 'premium'], 'OR'),
        new Q(['is_vip' => true], 'OR')
    )
    ->get();

// NOT conditions
$inactiveUsers = User::query()
    ->where(new Q(['status' => 'active'], 'NOT'))
    ->get();

// Nested conditions
$query = new Q([
    'status' => 'active',
    'role' => 'admin',
], 'AND', [
    new Q(['last_login' => null], 'OR'),
    new Q('last_login', '<', $thirtyDaysAgo)
]);

ORM: Raw SQL

When you need full control, Nudelsalat allows executing raw SQL queries with parameter binding for security.

raw() Method

use App\Models\User;

// Execute a raw SELECT query
$results = User::raw(
    'SELECT * FROM users WHERE status = ? AND created_at > ?',
    ['active', '2024-01-01']
);

// Get single value from raw query
$user = User::raw(
    'SELECT * FROM users WHERE email = ? LIMIT 1',
    ['john@example.com']
)->first();

// Raw INSERT
User::raw(
    'INSERT INTO users (name, email, created_at) VALUES (?, ?, NOW())',
    ['John Doe', 'john@example.com']
);

// Raw UPDATE
User::raw(
    'UPDATE users SET status = ? WHERE last_login < ?',
    ['inactive', $cutoffDate]
);

// Raw DELETE
User::raw(
    'DELETE FROM users WHERE status = ? AND created_at < ?',
    ['deleted', $oldDate]
);

Using Query Builder with Raw SQL

// Use select() for custom SELECT statements
$results = User::query()
    ->select('id, name, email, status')
    ->where('status', '=', 'active')
    ->get();

// Use raw() in conjunction with query builder
$results = User::query()
    ->whereRaw('status = ? AND created_at > ?', ['active', $date])
    ->get();
Security: Always use parameter placeholders (?) instead of string interpolation to prevent SQL injection attacks.

ORM: Pagination

Nudelsalat provides built-in pagination support for efficiently handling large datasets.

Using the Paginator

use App\Models\User;
use Nudelsalat\ORM\Paginator;

// Paginate with default settings (15 per page)
$paginator = User::paginate();

// Custom page size
$paginator = User::paginate(perPage: 25);

// Paginate with conditions
$paginator = User::paginate(
    perPage: 20,
    conditions: ['status' => 'active']
);

// Using query builder
$paginator = User::query()
    ->where('status', '=', 'active')
    ->orderBy('created_at', 'DESC')
    ->paginate(perPage: 15);

Paginator Results

$paginator = User::paginate(perPage: 20);

// Get the current page items
$users = $paginator->items();

// Get pagination metadata
$currentPage = $paginator->currentPage();    // 1
$totalPages = $paginator->totalPages();      // 5
$totalItems = $paginator->totalItems();      // 100
$perPage = $paginator->perPage();           // 20

// Check navigation state
$hasNextPage = $paginator->hasNextPage();   // true
$hasPrevPage = $paginator->hasPrevPage();   // false

// Get next/previous page numbers
$nextPage = $paginator->nextPage();          // 2
$prevPage = $paginator->prevPage();        // null

Manual Pagination

// For more control, use the Paginator class directly
$paginator = new Paginator(
    query: User::query()->where('status', '=', 'active'),
    perPage: 10,
    page: (int) ($_GET['page'] ?? 1)
);

// Or use limit/offset directly
$users = User::query()
    ->limit(20)
    ->offset(40)
    ->get();

Paginator API

MethodReturn TypeDescription
items()arrayCurrent page items as Model instances
currentPage()intCurrent page number (1-based)
totalPages()intTotal number of pages
totalItems()intTotal record count
perPage()intItems per page
hasNextPage()boolCheck if there's a next page
hasPrevPage()boolCheck if there's a previous page
nextPage(): ?int?intNext page number or null
prevPage(): ?int?intPrevious page number or null

ORM: Transactions

Nudelsalat supports atomic transactions to ensure data integrity when performing multiple database operations.

Using Transactions

use Nudelsalat\ORM\Model;

// Begin a transaction manually
Model::transaction(function() {
    // All operations in this closure are atomic
    $order = Order::create([
        'total' => 100.00,
        'status' => 'pending',
    ]);

    // Deduct inventory
    Product::where('id', $productId)->update([
        'stock' => Product::first(['id' => $productId])->stock - 1
    ]);

    // Create order items
    OrderItem::create([
        'order_id' => $order->id,
        'product_id' => $productId,
        'quantity' => 1,
        'price' => 100.00,
    ]);
});

Manual Transaction Control

use Nudelsalat\Database\Connection;

// Get the connection and control manually
$conn = Connection::getInstance();

// Begin transaction
$conn->beginTransaction();

try {
    // Perform operations
    $user = User::create(['name' => 'John', 'email' => 'john@example.com']);
    $profile = Profile::create(['user_id' => $user->id, 'bio' => 'Hello']);

    // Commit
    $conn->commit();
} catch (\Exception $e) {
    // Rollback on error
    $conn->rollback();
    throw $e;
}

Transaction Isolation Levels

// Set isolation level before beginning
$conn = Connection::getInstance();
$conn->exec('SET TRANSACTION ISOLATION LEVEL SERIALIZABLE');
$conn->beginTransaction();
💡

PostgreSQL fully supports transactional DDL, meaning schema changes (like CREATE TABLE) can be rolled back. MySQL auto-commits DDL statements, but Nudelsalat's transaction wrapper protects data operations.

ORM: Signals

Nudelsalat emits signals (events) during the model lifecycle, allowing you to hook into save, delete, and other operations.

Available Signals

SignalDescription
Model::CREATEDFired after a new model is saved to database
Model::UPDATEDFired after an existing model is updated
Model::DELETEDFired after a model is deleted
Model::SAVINGFired before saving (create or update)
Model::DELETINGFired before deleting

Connecting Signal Handlers

use App\Models\User;
use Nudelsalat\ORM\Signals\Signal;

// Connect to CREATED signal
User::connectSignal(Signal::CREATED, function($model) {
    // Send welcome email
    email_send($model->email, 'Welcome!');

    // Log the event
    Log::info("New user created: {$model->id}");
});

// Connect to UPDATED signal
User::connectSignal(Signal::UPDATED, function($model) {
    // Clear cache when user is updated
    Cache::forget("user:{$model->id}");
});

// Connect to DELETED signal
User::connectSignal(Signal::DELETED, function($model) {
    // Clean up related data
    Profile::where('user_id', $model->id)->delete();
    Session::where('user_id', $model->id)->delete();
});

// Pre-save hook (SAVING)
User::connectSignal(Signal::SAVING, function($model) {
    // Validate or modify before save
    if (empty($model->slug)) {
        $model->slug = slugify($model->name);
    }
});

Disconnecting Signals

// Disconnect a specific handler
User::disconnectSignal(Signal::CREATED, $callback);

// Disconnect all handlers for a signal
User::disconnectSignal(Signal::CREATED);

// Disconnect all handlers for all signals
User::disconnectAllSignals();

Custom Signal Handlers

// Create a global event dispatcher for application-wide signals
use Nudelsalat\ORM\Signals\Dispatcher;

$dispatcher = Dispatcher::getInstance();

// Register a global handler
$dispatcher->on('user.created', function($data) {
    ActivityLog::log("User {$data['email']} registered");
});

// Dispatch custom events anywhere
$dispatcher->dispatch('user.created', [
    'email' => $user->email,
    'id' => $user->id
]);

ORM: Validation

Nudelsalat provides built-in validation to ensure data integrity before saving to the database.

Defining Validation Rules

use Nudelsalat\ORM\Model;
use Nudelsalat\ORM\Validation\Rules;

class User extends Model {
    protected static function validationRules(): array {
        return [
            'name' => [
                Rules::required(),
                Rules::minLength(2),
                Rules::maxLength(100),
            ],
            'email' => [
                Rules::required(),
                Rules::email(),
                Rules::unique(),  // checks database for duplicates
            ],
            'age' => [
                Rules::integer(),
                Rules::min(0),
                Rules::max(150),
            ],
            'password' => [
                Rules::required(),
                Rules::minLength(8),
            ],
            'url' => [
                Rules::url(),  // valid URL format
            ],
        ];
    }

    public static function fields(): array {
        return [
            'id' => new \Nudelsalat\Migrations\Fields\IntField(primaryKey: true, autoIncrement: true),
            'name' => new \Nudelsalat\Migrations\Fields\StringField(length: 100),
            'email' => new \Nudelsalat\Migrations\Fields\StringField(length: 255),
            'age' => new \Nudelsalat\Migrations\Fields\IntField(),
            'password' => new \Nudelsalat\Migrations\Fields\StringField(length: 255),
            'url' => new \Nudelsalat\Migrations\Fields\StringField(length: 500),
        ];
    }
}

Available Validation Rules

RuleParametersDescription
Rules::required()Field must not be empty
Rules::email()Valid email format
Rules::minLength($len)intMinimum string length
Rules::maxLength($len)intMaximum string length
Rules::min($value)mixedMinimum numeric value
Rules::max($value)mixedMaximum numeric value
Rules::integer()Must be integer
Rules::numeric()Must be numeric
Rules::url()Valid URL format
Rules::unique()No duplicate in database
Rules::in($values)arrayMust be one of the values
Rules::regex($pattern)stringMust match regex pattern
Rules::matches($field)stringMust match another field

Running Validation

$user = new User();
$user->name = 'J';
$user->email = 'invalid-email';
$user->age = -5;

// Validate and check for errors
if (!$user->validate()) {
    // Get validation errors
    $errors = $user->getErrors();

    foreach ($errors as $field => $messages) {
        echo "$field: " . implode(', ', $messages) . "\n";
    }
}

// Or use save() with validation (throws on failure)
try {
    $user->save();
} catch (\Nudelsalat\ORM\Exceptions\ValidationException $e) {
    echo "Validation failed: " . $e->getMessage();
}

Custom Validation Messages

protected static function validationRules(): array {
    return [
        'email' => [
            Rules::required()->message('Email is required'),
            Rules::email()->message('Please provide a valid email'),
            Rules::unique()->message('This email is already taken'),
        ],
    ];
}

// Or define custom messages method
protected static function validationMessages(): array {
    return [
        'email.required' => 'Please enter your email address',
        'email.email' => 'Invalid email format',
        'name.min_length' => 'Name must be at least 2 characters',
    ];
}

Custom Validation Rules

use Nudelsalat\ORM\Validation\Rule;

class CustomRule extends Rule {
    public function passes($value, $context = []): bool {
        // Custom validation logic
        return someBusinessLogic($value);
    }

    public function message(): string {
        return 'The :field does not meet our requirements';
    }
}

// Use custom rule
protected static function validationRules(): array {
    return [
        'field' => [
            new CustomRule(),
        ],
    ];
}

ORM: Abstract & Proxy Models

Nudelsalat supports abstract base classes and proxy models for advanced inheritance patterns.

Abstract Models

use Nudelsalat\ORM\Model;
use Nudelsalat\Migrations\Fields\{IntField, StringField, DateTimeField};

// Abstract base class with common fields
abstract class BaseModel extends Model {
    // Common fields for all models
    public static function fields(): array {
        return [
            'id' => new IntField(primaryKey: true, autoIncrement: true),
            'created_at' => new DateTimeField(autoNow: true),
            'updated_at' => new DateTimeField(autoNow: true),
        ];
    }

    // Common methods
    public function timestamp(): string {
        return $this->created_at->format('Y-m-d H:i:s');
    }
}

// Concrete model extending abstract
class User extends BaseModel {
    public static function fields(): array {
        return array_merge(parent::fields(), [
            'name' => new StringField(length: 100),
            'email' => new StringField(length: 255),
        ]);
    }
}

class Post extends BaseModel {
    public static function fields(): array {
        return array_merge(parent::fields(), [
            'title' => new StringField(length: 200),
            'content' => new StringField(), // TextField
        ]);
    }
}

Proxy Models

use Nudelsalat\ORM\Model;
use Nudelsalat\Migrations\Fields\StringField;

// Proxy model - uses same table as base but can add custom methods
class UserProxy extends User {
    public static array $options = [
        'proxy' => true,  // Tell Nudelsalat this is a proxy
    ];

    // Custom methods specific to proxy
    public function getFullNameAttribute(): string {
        return ucwords($this->name);
    }

    public function isAdmin(): bool {
        return $this->role === 'admin';
    }
}

// Usage
$user = User::get(1);
$proxy = UserProxy::get(1);  // Same database record

// Proxy can have additional methods not in base
echo $proxy->getFullNameAttribute();  // "John Doe"
echo $proxy->isAdmin();  // false

Multi-Table Inheritance

// Base user model
class User extends Model {
    public static function fields(): array {
        return [
            'id' => new IntField(primaryKey: true, autoIncrement: true),
            'email' => new StringField(length: 255),
        ];
    }
}

// Admin extends User (creates separate table)
class Admin extends User {
    public static array $options = [
        'db_table' => 'admins',  // Separate table
    ];

    public static function fields(): array {
        return array_merge(parent::fields(), [
            'access_level' => new IntField(default: 1),
            'department' => new StringField(length: 100),
        ]);
    }
}

// Staff extends User (separate table)
class Staff extends User {
    public static array $options = [
        'db_table' => 'staff',
    ];

    public static function fields(): array {
        return array_merge(parent::fields(), [
            'department' => new StringField(length: 100),
            'employee_id' => new StringField(length: 20),
        ]);
    }
}

Model Options Reference

OptionTypeDescription
db_tablestringCustom table name
managedboolIf false, Nudelsalat skips migration operations
proxyboolIf true, skip CreateModel/DeleteModel detection
abstractboolIf true, model has no database table
order_byarrayDefault ordering e.g. ['name' => 'ASC']
💡

Proxy models are useful for adding methods to existing tables without modifying the original model. They share the same database table but can have additional functionality.

./nudelsalat makemigrations

The core workflow command. Inspects your model files, computes the previous project state from the migration graph, and auto-generates a new migration file with the minimum required operations.

# Generate migrations from model changes
./nudelsalat makemigrations

# Reverse engineer from existing database (for legacy databases)
./nudelsalat makemigrations --from-database

# For specific database (when using multi-db)
./nudelsalat makemigrations --from-database --database=secondary

Options

OptionDescription
--from-databaseReverse engineer migrations from an existing database. Reads current schema and generates CreateModel operations for all tables.
--fakeMark generated migrations as applied without executing SQL. Useful when tables already exist (use with --from-database).
--databaseTarget database alias (when using multiple databases).
--schemaFilter to specific PostgreSQL schema (default: public, use for multi-schema databases).

What it does internally

  1. Conflict check — reads the DAG and aborts if any app has multiple head nodes (divergent branches). Run nudelsalat merge first.
  2. Inspect models — reads all PHP files in configured models directories, discovering every concrete subclass of Model.
  3. Compute previous state — replays all applied migrations through mutateState() to get the current DB state as Nudelsalat understands it.
  4. Detect changes — runs the Autodetector comparing old vs new ProjectState.
  5. Validate — checks for reserved SQL keywords and orphaned ForeignKey operations.
  6. Write file — serializes operations via MigrationWriter and saves m{timestamp}.php to your migrations directory.
Interactive prompts: When a rename is likely, Nudelsalat asks "Did you rename X to Y?". When a non-nullable field with no default is added to a non-empty table, Nudelsalat asks for a one-time default value.

Legacy Database Support

Nudelsalat can reverse-engineer migrations from an existing database - perfect for onboarding legacy databases that were created without a migration system.

# Step 1: Generate initial migrations from existing database
./nudelsalat makemigrations --from-database

# Step 2: Apply migrations (faked, since tables already exist)
./nudelsalat migrate --fake

# OR use --fake-initial to auto-fake the first migration
./nudelsalat migrate --fake-initial
💡

Pro tip: Use --fake with --from-database together to generate and apply in one step:

./nudelsalat makemigrations --from-database --fake

./nudelsalat migrate

Applies all pending migrations in topological order.

# Apply all pending
./nudelsalat migrate

# Apply only up to a specific migration
./nudelsalat migrate main.m1713000000

# Apply all migrations for a specific app
./nudelsalat migrate main

# Mark migrations as applied without touching the DB
./nudelsalat migrate --fake

# Treat initial migrations as fake if the table already exists
./nudelsalat migrate --fake-initial

# Roll back to a specific migration (unapply)
./nudelsalat migrate --unapply main.m1713000000
# or:
./nudelsalat migrate --rollback main.m1713000000

Flags

FlagEffect
--fakeRecords the migration as applied in nudelsalat_migrations without executing any SQL. Useful for syncing legacy databases.
--fake-initialLike --fake but only for the very first migration of each app, and only if the target table already exists on disk.
--unapply, --rollbackRuns migrations backwards: executes databaseBackwards() on each operation in reverse order.

./nudelsalat show

Prints the full migration status, grouped by app. Applied migrations show [X], pending show [ ]. Squash replacements are annotated.

./nudelsalat show

# Output example:
main:
 [X] m1713000001 (replaces: m1700000001, m1700000002)
 [X] m1713000002
 [ ] m1713000003

Also surfaces conflicts inline: if an app has multiple heads, the conflict is reported directly below the app name prefixed with !.

./nudelsalat check

Detects if there are any model changes not yet captured in a migration. Exits with code 1 if changes exist — ideal for CI/CD gating.

# Standard check
./nudelsalat check

# Deep relational audit (cross-app FK validation + graph consistency)
./nudelsalat check --deploy

./nudelsalat sqlmigrate

Prints the raw SQL that a specific migration would execute, without touching the database. Uses the SchemaEditor in dry-run mode (with a SQL logger callback).

./nudelsalat sqlmigrate main.m1713000001

./nudelsalat introspect

Reverse-engineers an existing database and generates PHP Model stubs for every table it finds. Reads columns, indexes, and foreign keys using the driver-specific Introspector.

./nudelsalat introspect
Real-world verified: Successfully introspected a 147-table production schema, detecting columns, indexes, and FK constraints across all tables.

./nudelsalat merge

Resolves divergent migration branches. If two developers both created migrations from the same parent (resulting in two heads), merge creates a new empty migration that depends on both heads — restoring a single linear history.

./nudelsalat merge main

./nudelsalat squash

Collapses a range of migrations into a single optimized migration file, using the Optimizer Engine for logical reduction. The squash migration carries a $replaces list so Nudelsalat can handle environments where the originals were already applied.

./nudelsalat squash main

./nudelsalat optimize

Optimizes an existing migration file by applying logical reduction rules (like merging CreateModel+AddField into a single CreateModel). The optimized migration replaces the original while preserving the same end result.

# Optimize a specific migration
./nudelsalat optimize main.m1713000001

# Optimize all migrations in an app
./nudelsalat optimize main

What it does

The Optimizer applies state-based multi-pass reduction rules:

./nudelsalat dumpdata

Export database data to JSON, XML, or YAML fixtures. Supports compression.

# Export all models
./nudelsalat dumpdata

# Export specific model
./nudelsalat dumpdata core.User

# Export to file (also supports .json.gz, .json.bz2, .json.lzma)
./nudelsalat dumpdata core.User --output=users.json
./nudelsalat dumpdata core.User --output=users.json.gz

# Export specific IDs
./nudelsalat dumpdata core.User --pks=1,2,3

# Exclude models
./nudelsalat dumpdata --exclude=core.LogEntry,core.Session

# Natural keys (for FK references)
./nudelsalat dumpdata --natural-primary --natural-foreign

# Compressed output
./nudelsalat dumpdata -o data.json.gz

Options

OptionDescription
--outputOutput file (supports .gz, .bz2, .lzma compression)
--formatjson, xml, or python (default: json)
--excludeComma-separated models to exclude
--pksExport only specific primary keys (comma-separated)
--natural-primaryUse natural keys for primary keys
--natural-foreignUse natural keys for foreign keys
--no-indentDisable JSON indentation
--databaseDatabase alias (for multi-db)
-oShort for --output

./nudelsalat loaddata

Import data from fixture files. Supports JSON, YAML, XML, and compressed formats.

# Load from specific file
./nudelsalat loaddata fixtures/users.json

# Load from compressed fixture
./nudelsalat loaddata data.json.gz

# Load multiple files
./nudelsalat loaddata users.json posts.json comments.json

# Load from YAML
./nudelsalat loaddata data.yaml

# Load from XML
./nudelsalat loaddata data.xml

# Load from stdin (pipe from dumpdata)
./nudelsalat dumpdata core.User | ./nudelsalat loaddata -
./nudelsalat loaddata --format=json -

# Exclude specific models
./nudelsalat loaddata all.json --exclude=core.Session

# Load from specific app only
./nudelsalat loaddata data.json --app=core

# Ignore fields that no longer exist in model
./nudelsalat loaddata data.json --ignorenonexistent

Options

OptionDescription
--excludeComma-separated models to exclude
--appLoad fixtures from specific app only
--ignorenonexistent, -iIgnore fields that no longer exist in model
--formatSpecify format when reading from stdin (json, xml, yaml)
--databaseDatabase alias (for multi-db)
--quietSuppress output

Auto-discovery

If no file specified, loads from configured fixtures_dir or fixtures/ directory. Supports all formats including compressed.

./nudelsalat seed

Run database seeders to populate test data.

# Run all seeders
./nudelsalat seed

# Run specific seeder
./nudelsalat seed UserSeeder

# Truncate tables before seeding (fresh data)
./nudelsalat seed --fresh

Creating Seeders

Create seeders in database/seeders/ directory:

<?php
namespace Database\Seeders;

use Nudelsalat\Database\Seeder;

class UserSeeder extends Seeder
{
    protected function seed(): void
    {
        $this->batch('users', [
            ['name' => 'John Doe', 'email' => 'john@example.com'],
            ['name' => 'Jane Smith', 'email' => 'jane@example.com'],
        ]);
    }
}

Seeder Methods

MethodDescription
$this->batch($table, $records)Insert multiple records
$this->create($table, fn($r) => $record)Insert single record via callback
$this->has($table, $where)Check if record exists
$this->count($table)Get record count
$this->deleteAll($table)Truncate table

./nudelsalat scaffold

Generate complete database migrations from an existing PostgreSQL database. This is the ultimate reverse-engineering tool that captures all database objects.

# Scaffold entire database to stdout
./nudelsalat scaffold

# Scaffold specific schema to directory
./nudelsalat scaffold --schema=analytics --output=database/migrations

# Scaffold with custom database
./nudelsalat scaffold --database=production --output=./migrations

# Dry run preview
./nudelsalat scaffold --dry-run

What it generates

ObjectDescription
SchemasCREATE SCHEMA migrations for each non-system schema
TablesCREATE TABLE with columns, types, defaults, primary keys with preserved names
Foreign KeysADD CONSTRAINT FOREIGN KEY with original constraint names
FunctionsCREATE FUNCTION using pg_get_functiondef
ViewsCREATE OR REPLACE VIEW with dependency ordering
Materialized ViewsCREATE MATERIALIZED VIEW
TriggersCREATE TRIGGER with full definition
Indexes & ConstraintsNon-constraint indexes, UNIQUE and CHECK constraints

Options

OptionDescription
--schemaTarget specific PostgreSQL schema (default: all non-system schemas)
--outputSave migrations to directory instead of stdout
--databaseTarget specific database alias
--dry-runPreview migrations without generating files

Schema Operations (26 Operations)

All operations implement stateForwards(), databaseForwards(), databaseBackwards(), deconstruct(), reversible(), and elidable(). Nudelsalat comes with 26 built-in operations.

Table Lifecycle

OperationSQL ActionState Action
CreateModel($name, $columns, $options)CREATE TABLEAdds the table to ProjectState
DeleteModel($name)DROP TABLERemoves the table from ProjectState
RenameModel($old, $new)RENAME TABLERenames the entry in ProjectState

Column Lifecycle

OperationSQL Action
AddField($model, $name, $column)ALTER TABLE … ADD COLUMN
RemoveField($model, $name)ALTER TABLE … DROP COLUMN
AlterField($model, $name, $new, $old)ALTER TABLE … MODIFY/ALTER COLUMN
RenameField($model, $old, $new)ALTER TABLE … RENAME COLUMN

Indexes, Constraints & Foreign Keys

AddRemoveNotes
AddIndexRemoveIndexSupports composite multi-column indexes.
AddConstraintRemoveConstraintUniqueConstraint, CheckConstraint (PgSQL/MySQL 8+).
AddForeignKeyRemoveForeignKeyNamed FK constraints with configurable ON DELETE actions.

Logic Operations & Model Options

Nudelsalat includes 9 additional model-option operations for handling metadata changes.

OperationPurpose
RunSQL($forward, $backward) Execute raw SQL strings. Accepts separate SQL for forward and backward directions.
RunPHP($forward, $backward) Execute PHP callables. Receives the StateRegistry for safe data access via HistoricalModel.
SeparateDatabaseAndState($databaseOps, $stateOps) Runs $databaseOps against the DB while updating the Nudelsalat state using $stateOps. Enables complex refactors where schema and state deliberately diverge.
AlterModelTable($name, $table) Rename the model's database table.
AlterModelTableComment($name, $comment) Set or change the table comment.
AlterModelOptions($name, $options) Change Meta options (verbose_name, permissions, ordering, etc.).
AlterModelManagers($name, $managers) Change custom model managers.
AlterUniqueTogether($name, $value) Change unique_together constraints.
AlterIndexTogether($name, $value) Change index_together indexes.
AlterOrderWithRespectTo($name, $to) Change order_with_respect_to option.
RenameIndex($name, $newName) Rename an existing index.
AlterConstraint($name, $constraint) Modify an existing constraint.

Writing a Migration File

Migration files return an anonymous class that extends Nudelsalat\Migrations\Migration. The MigrationWriter generates these automatically; you can also write them by hand.

<?php

use Nudelsalat\Migrations\Migration;
use Nudelsalat\Migrations\Operations\CreateModel;
use Nudelsalat\Migrations\Operations\AddForeignKey;
use Nudelsalat\Schema\Column;

return new class('m1713000001') extends Migration {
    public function __construct(string $name)
    {
        parent::__construct($name);

        $this->dependencies = ['m1713000000'];  // relative names only
        $this->replaces     = [];               // squash: lists replaced migrations
        $this->atomic       = true;             // wrap in transaction (default)

        $this->operations = [
            new CreateModel('post', [
                new Column('id',      'int',          false, null, true, true),
                new Column('title',   'varchar(200)', false),
                new Column('user_id', 'int',          false),
            ]),
            new AddForeignKey(
                'post', 'fk_post_user_id', 'user_id', 'user', 'id', 'CASCADE'
            ),
        ];
    }
};
🔑

Key property: $atomic — set to false for migrations that contain statements incompatible with transactions (e.g. CREATE INDEX CONCURRENTLY in PostgreSQL).

Autodetector

The Autodetector class takes two ProjectState objects (old and new) and produces a minimal ordered list of Operation instances. It runs the result through Optimizer before returning.

Detection Order

  1. Model Renames — compares added vs removed tables. If column counts match, asks via Questioner::askRenameModel().
  2. Created Models — generates CreateModel + index/constraint/FK operations in one pass. Handles M2M junction tables automatically.
  3. Deleted Models — emits DeleteModel. Skips proxy models.
  4. Field Changes (per-table):
    • Field renames: checked using isColumnRenameCandidate() — must have same type, nullability, default, and key attributes.
    • Field alterations: deep equality check on type, nullable, default, primaryKey, autoIncrement, and extra options.
    • FK, Index, Constraint changes: removed first (pre-field), added last (post-field) to avoid constraint conflicts.
    • New non-nullable fields: if no default, Questioner::askDefault() is called interactively.
Proxy models are skipped at all detection phases. Set $options['proxy'] = true on models that map to views or externally managed tables.

Migration Graph (DAG)

The Graph class is a Directed Acyclic Graph of all loaded migrations. It powers dependency resolution, conflict detection, and plan generation.

📂
Disk Load
📊
Build Graph
validate()
📋
Plan

Key Methods

MethodPurpose
forwards_plan($targets)DFS through dependencies — returns migrations in the order they must be applied.
backwards_plan($targets)DFS through children — returns migrations in reverse application order.
validate()Checks for missing nodes and calls ensureAcyclic(). Throws DependencyError or CircularDependencyError.
find_heads($appName)Returns terminal nodes for an app. More than one head = migration conflict.
leaf_nodes()All nodes with no children (across all apps).
root_nodes($appName?)All nodes with no parents (optionally filtered by app).

Squash & Replacement Tracking

The Loader builds two maps: replacementMap (squash → originals) and replacedByMap (original → squash). This lets Nudelsalat route any mixed-history environment — where some original migrations were applied before switching to the squash — to the correct active node set.

Executor Pipeline

The Executor orchestrates the full migration lifecycle: history verification → planning → application → recording. It coordinates the Loader, Recorder, SchemaEditor, and an EventDispatcher.

🔒
Integrity Check
📋
Build Plan
🔄
Apply Ops
💾
Record
🧹
Prune Stale

Events

The executor fires MigrationEvent::PRE_MIGRATE and MigrationEvent::POST_MIGRATE via the EventDispatcher, passing the migration and current driver name. Register listeners in Bootstrap to hook into the pipeline.

Progress Callback

$executor->setProgressCallback(function(string $event, Migration $migration) {
    if ($event === 'apply_start') {
        echo "  Applying {$migration->name}... ";
    } elseif ($event === 'apply_success') {
        echo "\033[32mOK\033[0m\n";
    }
});

State Registry & Historical Models

The StateRegistry solves the "Historical Ghost" problem: a data migration referencing a model that was later deleted would crash without it. The registry provides a frozen snapshot of every model exactly as it existed at migration time.

Using HistoricalModel in a RunPHP operation

use Nudelsalat\Migrations\Operations\RunPHP;

new RunPHP(function(StateRegistry $registry) {
    $users = $registry->getModel('user');

    // Read all rows
    foreach ($users->all() as $row) {
        // insert into a new table
        $profiles = $registry->getModel('profile');
        $profiles->insert([
            'user_id'    => $row['id'],
            'display_name' => $row['first'] . ' ' . $row['last'],
        ]);
    }
})

HistoricalModel API

MethodSignatureNotes
all()arrayReturns all rows as associative arrays.
first(array $where)?arrayGet first record matching conditions.
find(mixed $value, string $column = 'id')?arrayFind by specific column value.
where(array $where, ?int $limit)arrayGet records matching conditions.
insert(array $data)voidPrepared statement insert.
update(array $data, array $where)voidPrepared statement update.
delete(array $where)voidDelete records matching conditions.
count(array $where = [])intCount records.
exists(array $where = [])boolCheck if any records exist.
value(string $column, array $where = [])mixedGet single column value.
values(string $column)arrayGet all values for a column.
orderBy(string $column, string $direction = 'ASC', ?int $limit)arrayGet records ordered by column.
raw(string $sql, array $params = [])arrayExecute raw SQL.
getFields()arrayGet all columns.
getField(string $name)?ColumnGet specific field.
getForeignKeys()arrayGet all FK constraints.
getIndexes()arrayGet all indexes.
getConstraints()arrayGet all constraints.
related(string $column, array $row)?arrayGet related object via FK.
getRelatedObjects(array $row)arrayGet all related objects.

Identifiers are quoted correctly per driver: backticks for MySQL, double-quotes for PostgreSQL/SQLite.

Integrity Shield

Every applied migration file has its SHA256 hash stored in the nudelsalat_migrations table alongside its name and timestamp. On every subsequent run, Nudelsalat re-hashes the file on disk and compares.

-- nudelsalat_migrations table schema
CREATE TABLE nudelsalat_migrations (
    id          INTEGER PRIMARY KEY AUTO_INCREMENT,
    migration   VARCHAR(255) NOT NULL UNIQUE,
    applied_at  DATETIME NOT NULL,
    checksum    VARCHAR(64) NOT NULL   -- SHA256 hex
);
IntegrityError: If checksums don't match, Nudelsalat throws Nudelsalat\Migrations\Exceptions\IntegrityError and halts immediately. Never edit an applied migration file without first running ./nudelsalat migrate --fake to reset the record.

Transaction Control

Nudelsalat wraps each migration in a transaction by default. The $atomic property on the Migration class controls this.

ScenarioSettingBehaviour
Normal DDL (default)$atomic = trueFull transaction: rollback on failure. Safe for PgSQL transactional DDL.
Concurrent index creation$atomic = falseNo transaction wrapper. Required for CREATE INDEX CONCURRENTLY.
MySQL DDLAnyMySQL auto-commits DDL; Nudelsalat's transaction protects data operations inside the migration but not schema changes.

Squash Optimizer

The Optimizer (called internally by Squasher and also after every Autodetector run) applies logical reduction rules to shrink the operation list.

Reduction Rules

BeforeAfter
CreateModel + DeleteModelEliminated entirely (no net change).
AddField + RemoveFieldEliminated entirely.
CreateModel + AddFieldFolded: AddField merged into CreateModel.fields.

The Squash Lifecycle

# 1. Generate the squash migration
./nudelsalat squash main

# 2. Commit it. Old migrations STAY until all environments
#    have been updated to the squash.

# 3. Once all environments are on the squash, delete the originals.

The squash migration's $replaces list + the Loader's replacement maps ensure Nudelsalat handles every possible history state correctly — whether an environment has the originals applied, the squash applied, or a mix.

Validator

The Validator runs two checks before writing a migration file:

CheckMethodWhat it catches
State ValidationvalidateState()Table and column names that match SQL reserved keywords: order, group, user, select, table, index, primary, key.
Operation ValidationvalidateOperations()Orphaned AddForeignKey operations — where the target table doesn't exist in the destination state.

SchemaEditor Abstract API

Every database driver implements the abstract SchemaEditor. No database-specific code ever appears in the migration engine — only in the driver editors.

Abstract Methods (must implement)

createTable(Table $table): void
deleteTable(string $name): void
renameTable(string $oldName, string $newName): void
addColumn(string $tableName, Column $column): void
removeColumn(string $tableName, string $columnName): void
renameColumn(string $tableName, string $oldName, Column $newColumn): void
alterColumn(string $tableName, Column $column): void
addIndex(string $tableName, Index $index): void
removeIndex(string $tableName, string $indexName): void
addConstraint(string $tableName, Constraint $constraint): void
removeConstraint(string $tableName, string $constraintName): void
addForeignKey(string $tableName, string $columnName, string $toTable, string $toColumn, string $onDelete): void
removeForeignKey(string $tableName, string $constraintName): void

Shared Features

FeatureMethodPurpose
Dry RunenableDryRun(?callable $logger)Prevents any SQL from executing; passes SQL to logger callback instead. Used by sqlmigrate.
Deferred SQLdeferredSql[] + flushDeferredSql()Queues SQL to run after the main operation block. Used for operations that must happen after a transaction commits.
Raw SQLrunRawSql(string $sql)Executes arbitrary SQL — used by RunSQL operation.

PostgreSQL pgsql

MySQL / MariaDB mysql

SQLite sqlite

Deployment Strategy

Nudelsalat is CI/CD-native. Use the following pattern in your pipeline:

# 1. Verify no unmade changes exist (fails the build if your model changes aren't committed)
./nudelsalat check

# 2. Deep relational audit before promoting to production
./nudelsalat check --deploy

# 3. Apply pending migrations (in production, behind a deploy lock)
./nudelsalat migrate
Zero-downtime tips: For large tables, run index creation manually with CREATE INDEX CONCURRENTLY (PostgreSQL) and use a SeparateDatabaseAndState operation to keep Nudelsalat's state in sync.

The nudelsalat_migrations Table

Nudelsalat creates this table automatically on the first migrate run (Recorder::ensureSchema()). It is the single source of truth for applied migrations and their integrity checksums. Never truncate or manually edit it.

Testing Suite

Nudelsalat ships with a PHPUnit-based test harness covering all subsystems. 55+ tests, 0 failures verified in Docker against PostgreSQL, MySQL, and SQLite.

Local (SQLite only)

./test

Docker (All Databases)

./test-docker
# Spins up: postgres:15, mysql:8, php:8.2-cli with pdo_pgsql + pdo_mysql + pdo_sqlite
# Runs: php tests/run.php
# Output: Passed: 55+, Failed: 0

Test Coverage Areas

Test FileCoverage
AutodetectorTestRename detection, field diffs, M2M junction generation, proxy skip
GraphTestTopological sort, cycle detection, head/root finding, squash replacement routing
LoaderWriterTestDisk loading, conflict detection, require re-entry, writer serialization
CommandTestCLI workflow: make → migrate → show → check → sqlmigrate
ExecutorIntegrationTestFull migrate/unmigrate cycle, fake flags, replacement paths, stale record pruning
BackendPortabilityTestSame migrations applied against MySQL, PostgreSQL, and SQLite drivers

Roadmap

Current status: Core migration engine is complete. Full ORM layer implemented with CRUD, Aggregations, Raw SQL, Pagination, Transactions, Signals, and Validation. All tests pass across PostgreSQL, MySQL, and SQLite.

Implemented Features

Upcoming