Developer Documentation
The Nudelsalat Engine
State-based database migrations for PHP with full ORM support. Framework-agnostic, driver-aware, integrity-guaranteed.
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.
Installation
Nudelsalat is a Composer package with a CLI binary. Install it into your project and load it through Composer's autoloader.
Requirements
- PHP 8.1 or higher
- PDO extension (with your target driver:
pdo_mysql,pdo_pgsql, orpdo_sqlite)
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
str_starts_with() — all requiring PHP 8.1+.
Quick Start
-
Copy the example config and fill in your database credentials.
cp nudelsalat/nudelsalat.config.php.example nudelsalat/nudelsalat.config.php -
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), ]; } } -
Generate the migration.
./nudelsalat makeNudelsalat scans your model directory, compares it to the current migration state, and writes a
.phpmigration file. -
Apply the migration.
./nudelsalat migrate -
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. BlogPost → blogpost). 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.
| Class | SQL Type | Extra Parameters | Notes |
|---|---|---|---|
IntField | INT | — | Use with primaryKey + autoIncrement for surrogate PKs. |
StringField | VARCHAR(n) | length (default 255) | Length is embedded in the SQL type string. |
TextField | TEXT | — | Unbounded text; no length constraint. |
BooleanField | BOOLEAN | — | Driver handles mapping; SQLite stores as 0/1. |
DecimalField | DECIMAL | maxDigits, decimalPlaces | High-precision for currency. Defaults: 19 digits, 4 places. |
DateTimeField | DATETIME | — | Maps to TIMESTAMP on PostgreSQL. |
JSONField | See backends | — | JSONB 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):
| Parameter | Type | Default | Effect |
|---|---|---|---|
nullable | bool | false | Allows NULL in the column. Nudelsalat will emit NULL instead of NOT NULL. |
default | mixed | null | Sets a column-level DEFAULT. Required for non-nullable AddField ops on non-empty tables — Nudelsalat will prompt you interactively if missing. |
primaryKey | bool | false | Marks this column as the table's PRIMARY KEY. |
autoIncrement | bool | false | Enables AUTO_INCREMENT / SERIAL behaviour. |
options | array | [] | Driver-specific extras passed through to the SchemaEditor. Use for db_index, unique, db_column, etc. |
Common options Keys
| Key | Effect |
|---|---|
db_column | Override the physical column name (if different from the PHP key). |
db_index | Auto-create a single-column index for this field. |
unique | Emit 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.
| Option | Type | Effect |
|---|---|---|
db_table | string | Override the auto-derived table name. |
managed | bool (default true) | Set to false to exclude this model from Nudelsalat's migration management. Nudelsalat will never touch its table. |
proxy | bool (default false) | Mark a model as a proxy — Nudelsalat skips it during CreateModel/DeleteModel detection. Useful for adding methods to existing tables. |
abstract | bool (default false) | Mark a model as abstract — no database table is created. Used for base classes with shared fields. |
m2m | array (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
| Method | Description |
|---|---|
static all(): array | Get all records as array of Model instances |
static get(mixed $id): ?static | Get record by primary key |
static first(array $where): ?static | Get first record matching conditions |
static where(array $where): Query | Start a query with WHERE conditions |
static query(): Query | Get a fresh Query builder instance |
static create(array $data): static | Create and save a new record |
static getOrCreate(array $lookup, array $defaults): static | Find or create record |
static updateOrCreate(array $lookup, array $data): static | Update or create record |
save(): bool | Insert or update the current record |
delete(): bool | Delete the current record |
refresh(): void | Reload attributes from database |
toArray(): array | Convert model to array |
ORM: Aggregations
Nudelsalat provides aggregation functions that perform calculations on your dataset. All aggregations return a scalar value.
Available Aggregation Functions
| Function | Description |
|---|---|
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();
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
| Method | Return Type | Description |
|---|---|---|
items() | array | Current page items as Model instances |
currentPage() | int | Current page number (1-based) |
totalPages() | int | Total number of pages |
totalItems() | int | Total record count |
perPage() | int | Items per page |
hasNextPage() | bool | Check if there's a next page |
hasPrevPage() | bool | Check if there's a previous page |
nextPage(): ?int | ?int | Next page number or null |
prevPage(): ?int | ?int | Previous 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
| Signal | Description |
|---|---|
Model::CREATED | Fired after a new model is saved to database |
Model::UPDATED | Fired after an existing model is updated |
Model::DELETED | Fired after a model is deleted |
Model::SAVING | Fired before saving (create or update) |
Model::DELETING | Fired 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
| Rule | Parameters | Description |
|---|---|---|
Rules::required() | — | Field must not be empty |
Rules::email() | — | Valid email format |
Rules::minLength($len) | int | Minimum string length |
Rules::maxLength($len) | int | Maximum string length |
Rules::min($value) | mixed | Minimum numeric value |
Rules::max($value) | mixed | Maximum 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) | array | Must be one of the values |
Rules::regex($pattern) | string | Must match regex pattern |
Rules::matches($field) | string | Must 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
| Option | Type | Description |
|---|---|---|
db_table | string | Custom table name |
managed | bool | If false, Nudelsalat skips migration operations |
proxy | bool | If true, skip CreateModel/DeleteModel detection |
abstract | bool | If true, model has no database table |
order_by | array | Default 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
| Option | Description |
|---|---|
--from-database | Reverse engineer migrations from an existing database. Reads current schema and generates CreateModel operations for all tables. |
--fake | Mark generated migrations as applied without executing SQL. Useful when tables already exist (use with --from-database). |
--database | Target database alias (when using multiple databases). |
--schema | Filter to specific PostgreSQL schema (default: public, use for multi-schema databases). |
What it does internally
- Conflict check — reads the DAG and aborts if any app has multiple head nodes (divergent branches). Run
nudelsalat mergefirst. - Inspect models — reads all PHP files in configured
modelsdirectories, discovering every concrete subclass ofModel. - Compute previous state — replays all applied migrations through
mutateState()to get the current DB state as Nudelsalat understands it. - Detect changes — runs the
Autodetectorcomparing old vs newProjectState. - Validate — checks for reserved SQL keywords and orphaned ForeignKey operations.
- Write file — serializes operations via
MigrationWriterand savesm{timestamp}.phpto your migrations directory.
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
| Flag | Effect |
|---|---|
--fake | Records the migration as applied in nudelsalat_migrations without executing any SQL. Useful for syncing legacy databases. |
--fake-initial | Like --fake but only for the very first migration of each app, and only if the target table already exists on disk. |
--unapply, --rollback | Runs 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
./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:
CreateModel+AddField=CreateModelwith field mergedCreateModel+DeleteModel= eliminated (no net change)AddField+RemoveField= eliminated- And more...
./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
| Option | Description |
|---|---|
--output | Output file (supports .gz, .bz2, .lzma compression) |
--format | json, xml, or python (default: json) |
--exclude | Comma-separated models to exclude |
--pks | Export only specific primary keys (comma-separated) |
--natural-primary | Use natural keys for primary keys |
--natural-foreign | Use natural keys for foreign keys |
--no-indent | Disable JSON indentation |
--database | Database alias (for multi-db) |
-o | Short 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
| Option | Description |
|---|---|
--exclude | Comma-separated models to exclude |
--app | Load fixtures from specific app only |
--ignorenonexistent, -i | Ignore fields that no longer exist in model |
--format | Specify format when reading from stdin (json, xml, yaml) |
--database | Database alias (for multi-db) |
--quiet | Suppress 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
| Method | Description |
|---|---|
$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
| Object | Description |
|---|---|
| Schemas | CREATE SCHEMA migrations for each non-system schema |
| Tables | CREATE TABLE with columns, types, defaults, primary keys with preserved names |
| Foreign Keys | ADD CONSTRAINT FOREIGN KEY with original constraint names |
| Functions | CREATE FUNCTION using pg_get_functiondef |
| Views | CREATE OR REPLACE VIEW with dependency ordering |
| Materialized Views | CREATE MATERIALIZED VIEW |
| Triggers | CREATE TRIGGER with full definition |
| Indexes & Constraints | Non-constraint indexes, UNIQUE and CHECK constraints |
Options
| Option | Description |
|---|---|
--schema | Target specific PostgreSQL schema (default: all non-system schemas) |
--output | Save migrations to directory instead of stdout |
--database | Target specific database alias |
--dry-run | Preview 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
| Operation | SQL Action | State Action |
|---|---|---|
CreateModel($name, $columns, $options) | CREATE TABLE | Adds the table to ProjectState |
DeleteModel($name) | DROP TABLE | Removes the table from ProjectState |
RenameModel($old, $new) | RENAME TABLE | Renames the entry in ProjectState |
Column Lifecycle
| Operation | SQL 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
| Add | Remove | Notes |
|---|---|---|
AddIndex | RemoveIndex | Supports composite multi-column indexes. |
AddConstraint | RemoveConstraint | UniqueConstraint, CheckConstraint (PgSQL/MySQL 8+). |
AddForeignKey | RemoveForeignKey | Named FK constraints with configurable ON DELETE actions. |
Logic Operations & Model Options
Nudelsalat includes 9 additional model-option operations for handling metadata changes.
| Operation | Purpose |
|---|---|
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
- Model Renames — compares added vs removed tables. If column counts match, asks via
Questioner::askRenameModel(). - Created Models — generates
CreateModel+ index/constraint/FK operations in one pass. Handles M2M junction tables automatically. - Deleted Models — emits
DeleteModel. Skipsproxymodels. - 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.
- Field renames: checked using
$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.
Key Methods
| Method | Purpose |
|---|---|
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.
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
| Method | Signature | Notes |
|---|---|---|
all() | array | Returns all rows as associative arrays. |
first(array $where) | ?array | Get first record matching conditions. |
find(mixed $value, string $column = 'id') | ?array | Find by specific column value. |
where(array $where, ?int $limit) | array | Get records matching conditions. |
insert(array $data) | void | Prepared statement insert. |
update(array $data, array $where) | void | Prepared statement update. |
delete(array $where) | void | Delete records matching conditions. |
count(array $where = []) | int | Count records. |
exists(array $where = []) | bool | Check if any records exist. |
value(string $column, array $where = []) | mixed | Get single column value. |
values(string $column) | array | Get all values for a column. |
orderBy(string $column, string $direction = 'ASC', ?int $limit) | array | Get records ordered by column. |
raw(string $sql, array $params = []) | array | Execute raw SQL. |
getFields() | array | Get all columns. |
getField(string $name) | ?Column | Get specific field. |
getForeignKeys() | array | Get all FK constraints. |
getIndexes() | array | Get all indexes. |
getConstraints() | array | Get all constraints. |
related(string $column, array $row) | ?array | Get related object via FK. |
getRelatedObjects(array $row) | array | Get 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
);
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.
| Scenario | Setting | Behaviour |
|---|---|---|
| Normal DDL (default) | $atomic = true | Full transaction: rollback on failure. Safe for PgSQL transactional DDL. |
| Concurrent index creation | $atomic = false | No transaction wrapper. Required for CREATE INDEX CONCURRENTLY. |
| MySQL DDL | Any | MySQL 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
| Before | After |
|---|---|
CreateModel + DeleteModel | Eliminated entirely (no net change). |
AddField + RemoveField | Eliminated entirely. |
CreateModel + AddField | Folded: 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:
| Check | Method | What it catches |
|---|---|---|
| State Validation | validateState() | Table and column names that match SQL reserved keywords: order, group, user, select, table, index, primary, key. |
| Operation Validation | validateOperations() | 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
| Feature | Method | Purpose |
|---|---|---|
| Dry Run | enableDryRun(?callable $logger) | Prevents any SQL from executing; passes SQL to logger callback instead. Used by sqlmigrate. |
| Deferred SQL | deferredSql[] + flushDeferredSql() | Queues SQL to run after the main operation block. Used for operations that must happen after a transaction commits. |
| Raw SQL | runRawSql(string $sql) | Executes arbitrary SQL — used by RunSQL operation. |
PostgreSQL pgsql
- Transactional DDL: All schema changes can be rolled back inside a transaction. Nudelsalat's
$atomic = truedefault fully protects your schema. - JSONB:
JSONFieldmaps toJSONB— binary JSON with indexing and operator support. - Check Constraints: Full support via
AddConstraint/RemoveConstraint. - Identifier quoting: Double-quotes (
"name") used throughout. - Sequences:
SERIAL/BIGSERIALused for auto-increment columns.
MySQL / MariaDB mysql
- Non-transactional DDL: MySQL auto-commits DDL. Data operations inside a
RunPHPin the same migration are still protected. - JSON type:
JSONFieldmaps to the nativeJSONcolumn type. - InnoDB FKs: Foreign keys require InnoDB. Named constraints are used throughout for reliable
removeForeignKey. - Identifier quoting: Backtick quoting (
`name`).
SQLite sqlite
- Limited ALTER TABLE: SQLite does not support dropping columns or altering column types natively. Nudelsalat handles this via Transparent Table Rebuilds: create new table → copy data → drop old → rename new.
- JSON as TEXT:
JSONFieldmaps toTEXT. ThetoPhp()/toDatabase()methods onJSONFieldhandle encode/decode. - Identifier quoting: Double-quotes (same as PostgreSQL).
- Use case: Ideal for unit tests and local development with zero infrastructure.
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
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 File | Coverage |
|---|---|
AutodetectorTest | Rename detection, field diffs, M2M junction generation, proxy skip |
GraphTest | Topological sort, cycle detection, head/root finding, squash replacement routing |
LoaderWriterTest | Disk loading, conflict detection, require re-entry, writer serialization |
CommandTest | CLI workflow: make → migrate → show → check → sqlmigrate |
ExecutorIntegrationTest | Full migrate/unmigrate cycle, fake flags, replacement paths, stale record pruning |
BackendPortabilityTest | Same migrations applied against MySQL, PostgreSQL, and SQLite drivers |
Roadmap
Implemented Features
- ORM Layer — Complete QuerySet implementation with fluent query builder
- Aggregations — Sum, Avg, Min, Max, Count with Q objects and F expressions
- Raw SQL — Parameterized queries with full SQL control
- Pagination — Built-in Paginator with metadata
- Transactions — Atomic transactions with manual control
- Signals — Pre/post hooks for save, delete, update operations
- Validation — Comprehensive validation rules with custom messages
- Abstract & Proxy Models — Full inheritance support
- Multi-Database Routing — PostgreSQL, MySQL, SQLite support
- Fixtures / Seeding — dumpdata, loaddata, and seed commands
- Historical Models — StateRegistry for safe data migrations
- Integrity Shield — SHA256 checksums prevent silent file edits
Upcoming
- Admin Interface — Dynamic administrative panel that auto-discovers models via the migration state
- Broader Check Constraints — Extend the validator to emit
CHECK (…)from model-level validators - Soft Deletes — Add built-in soft delete support
- Model Events — More granular lifecycle events and observers