← Persistence

PERSISTENCE FEATURE

Schema Sync, Not Migration Churn

You do not hand-write busywork migrations all day. The ORM derives the plan, blocks dangerous drops by default, and records the exact SQL it ran.

Feature Guide

A quick orientation block that answers the essential questions: what this feature does, how it works, why it matters, and the key concepts behind it.

What this does

Semitexa minimizes migration churn by computing schema changes directly from code and the current database instead of requiring constant hand-written migrations.

How it works

orm:sync collects the code schema, compares it with the live database, builds an execution plan, and separates safe changes from destructive ones. Drops use a two-phase flow: first mark deprecated, later drop only with explicit approval.

Why it matters

This reduces busywork, makes destructive intent visible, and still gives teams exact SQL artifacts when they need them for review or deployment.

Key concepts

orm:sync
Command that computes and optionally executes the schema synchronization plan.
Two-phase drop
Column or table removal is delayed: first deprecate, then drop on a later explicit destructive pass.
AuditLogger
Writes executed sync operations as both JSON and SQL files under var/migrations/history.

Minimal Migration Surface

SQL appears only when the schema actually changed

Semitexa does not force teams into constant hand-written migration churn. The ORM computes a sync plan from code vs database, separates safe and destructive operations, and only executes what is explicitly allowed.

What this kills

  • Teams waste time writing empty or obvious migrations just to mirror what the code already says.
  • Column drops are dangerous when one careless migration can erase data immediately.
  • Ops often needs the actual SQL plan, not a hand-wavy promise that the framework will figure it out.
1 command to compare code and DB
2 phases before a destructive drop completes
2 audit outputs written per sync run (.json + .sql)

Phase 1

Mark deprecated, do not drop yet

If a column disappears from code, the ORM first marks it deprecated instead of deleting it immediately.

safe operation comment marker review window

Phase 2

Drop only when explicitly allowed

A later sync can perform the actual DROP, and only when destructive operations are explicitly allowed.

DROP COLUMN --allow-destructive intentional action

Dry-run sync plan

bin/semitexa orm:sync --dry-run

Safe operations: 3
Destructive operations: 1 (require --allow-destructive)

Export SQL plan

bin/semitexa orm:sync --dry-run --output var/migrations/history/plan.sql

Audit files written after real sync

var/migrations/history/2026-03-27_12-14-03.184_sync.json
var/migrations/history/2026-03-27_12-14-03.184_sync.sql
orm:sync --dry-run --allow-destructive two-phase drop AuditLogger

Verified against Semitexa Ultimate 2026.09.19.1020

Schema Sync, Not Migration Churn

Semitexa derives the schema change plan by comparing resource attribute definitions against the live database — no hand-written migration files required.

How it works

Running bin/semitexa orm:sync computes the diff between code and database. Safe operations execute immediately. Destructive operations such as DROP COLUMN are separated in the plan and require --allow-destructive to execute. A missing column triggers a two-phase flow: the first sync marks it deprecated, and a subsequent sync with the explicit flag performs the drop. Every executed sync writes an audit file as both .json and .sql to var/migrations/history/.

Why this matters

Teams waste time writing empty or obvious migrations that mirror what the code already says. Blocking destructive drops by default prevents accidental data loss, and the structured audit output gives ops a reviewable record of exactly what SQL ran and when.

© Brian Kernighan: "Controlling complexity is the essence of computer programming."

orm:sync Command Implementation slice
<?phpdeclare(strict_types=1);namespace Semitexa\Orm\Application\Console\Command;use Semitexa\Core\Attribute\AsCommand;use Semitexa\Core\Console\BaseCommand;use Semitexa\Orm\Application\Service\Connection\ConnectionRegistry;use Symfony\Component\Console\Command\Command;use Symfony\Component\Console\Input\InputInterface;use Symfony\Component\Console\Input\InputOption;use Symfony\Component\Console\Output\OutputInterface;use Symfony\Component\Console\Style\SymfonyStyle;#[AsCommand(name: 'orm:sync', description: 'Synchronize ORM schema with the database')]class OrmSyncCommand extends BaseCommand{    public function __construct(        private readonly ConnectionRegistry $connections,    ) {        parent::__construct();    }    protected function configure(): void    {        $this->setName('orm:sync')            ->setDescription('Synchronize ORM schema with the database')            ->addOption('connection', 'c', InputOption::VALUE_REQUIRED, 'Connection name to sync', 'default')            ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show SQL plan without executing')            ->addOption('allow-destructive', null, InputOption::VALUE_NONE, 'Allow destructive operations (DROP, type narrowing)')            ->addOption('output', 'o', InputOption::VALUE_REQUIRED, 'Save SQL plan to file');    }    protected function execute(InputInterface $input, OutputInterface $output): int    {        $io = new SymfonyStyle($input, $output);        $connectionOption = $input->getOption('connection');        if (!is_string($connectionOption)) {            $io->error('Invalid --connection option value.');            return Command::FAILURE;        }        $connection = trim($connectionOption);        if ($connection === '') {            $io->error('The --connection option must not be empty.');            return Command::FAILURE;        }        $dryRun = (bool) $input->getOption('dry-run');        $allowDestructive = (bool) $input->getOption('allow-destructive');        $outputOption = $input->getOption('output');        $outputFile = is_string($outputOption) && $outputOption !== '' ? $outputOption : null;        try {            $orm = $this->connections->manager($connection);            // 1. Collect schema from code            $io->section("Collecting schema from code (connection: {$connection})...");            $collector = $orm->getSchemaCollector();            $codeSchema = $collector->collect();            $errors = $collector->getErrors();            if ($errors !== []) {                $io->error('Schema validation errors:');                $io->listing($errors);                return Command::FAILURE;            }            $warnings = $collector->getWarnings();            if ($warnings !== []) {                $io->warning('Schema warnings:');                $io->listing($warnings);            }            $io->text(sprintf('Found %d table(s) in code.', count($codeSchema)));            // 2. Compare with DB            $io->section('Comparing with database...');            $comparator = $orm->getSchemaComparator();            $diff = $comparator->compare($codeSchema);            if ($diff->isEmpty()) {                $io->success('Database is up to date. No changes needed.');                return Command::SUCCESS;            }            // 3. Build execution plan            $syncEngine = $orm->getSyncEngine();            $plan = $syncEngine->buildPlan($diff);            $io->text($plan->getSummary());            $io->newLine();            // Show plan details            $safeOps = $plan->getSafeOperations();            $destructiveOps = $plan->getDestructiveOperations();            if ($safeOps !== []) {                $io->section('Safe operations:');                foreach ($safeOps as $op) {                    $io->text("  <info>[{$op->type->value}]</info> {$op->description}");                    if ($output->isVerbose()) {                        $io->text("    SQL: {$op->sql}");                    }                }            }            if ($destructiveOps !== []) {                $io->section('Destructive operations:');                foreach ($destructiveOps as $op) {                    $io->text("  <fg=red>[{$op->type->value}]</> {$op->description}");                    if ($output->isVerbose()) {                        $io->text("    SQL: {$op->sql}");                    }                }                if (!$allowDestructive) {                    $io->warning('Destructive operations will be skipped. Use --allow-destructive to include them.');                }            }            // Save to file if requested            if ($outputFile !== null) {                $statements = $plan->toSqlStatements($allowDestructive);                $content = implode(";\n", $statements) . ";\n";                file_put_contents($outputFile, $content);                $io->text("SQL plan saved to: {$outputFile}");            }            // Execute if not dry-run            if ($dryRun) {                $io->note('Dry run mode — no changes applied.');                return Command::SUCCESS;            }            $executed = $syncEngine->execute($plan, $allowDestructive);            $io->success(sprintf('Executed %d operation(s).', count($executed)));            $orm->shutdown();            return Command::SUCCESS;        } catch (\Throwable $e) {            $io->error('Sync failed: ' . $e->getMessage());            if ($output->isVerbose()) {                $io->text($e->getTraceAsString());            }            return Command::FAILURE;        }    }}

Destructive Safety

Why column drops are intentionally slow

A missing column does not become an immediate DROP; the first pass only marks it deprecated.

Real destructive operations are separated in the execution plan and require explicit opt-in with --allow-destructive.

The executed plan is logged as both structured JSON and plain SQL for review, audit, and DevOps handoff.

If code and database already match, there is nothing to write and nothing to execute.

How it works

orm:sync collects the code schema, compares it with the live database, builds an execution plan, and separates safe changes from destructive ones. Drops use a two-phase flow: first mark deprecated, later drop only with explicit approval.

Why it matters

This reduces busywork, makes destructive intent visible, and still gives teams exact SQL artifacts when they need them for review or deployment.

Key concepts

orm:sync
Command that computes and optionally executes the schema synchronization plan.
Two-phase drop
Column or table removal is delayed: first deprecate, then drop on a later explicit destructive pass.
AuditLogger
Writes executed sync operations as both JSON and SQL files under var/migrations/history.

Support Semitexa
Built for developers who prefer control over magic. Your support helps keep it fast, open, and evolving.

Donate via PayPal