Skip to content

Migrations: rollback, reset, refresh, status, db:wipe, pretend, locking and transactional runs - #347

Open
techmahedy wants to merge 1 commit into
doppar:4.xfrom
techmahedy:migration
Open

techmahedy wants to merge 1 commit into
doppar:4.xfrom
techmahedy:migration

Conversation

@techmahedy

@techmahedy techmahedy commented Oct 2, 2026 •

Copy link
Copy Markdown
Member

Pull Request Checklist

Q A
Branch? 4.x
Bug fix? yes (batch numbering)
New feature? yes
Deprecations? no
Issues -
License MIT

Summary

The framework could only run migrations forward (migrate) or drop everything (migrate:fresh). A mistake in one migration meant wiping the database. This PR adds the missing commands and makes the migrator safer to run in production.

Bug fix

MigrationRepository::log() computed MAX(batch) + 1 for every migration, so a run of five migrations got five different batch numbers. Batch-based rollback would therefore undo one migration at a time. The batch is now computed once per run(); --step opts into one batch per migration.

Databases that already ran migrations keep their old numbering, so rollback still goes one migration at a time for those until new migrations run.

New commands

Command Behaviour
migrate:rollback Reverts the last batch, the last --step=N migrations, or one --batch=N. Resolves every file first, so a missing file aborts before anything is reverted.
migrate:reset Rolls back every migration.
migrate:refresh Rolls back (all, or --step=N) and re-runs. Optional --seed.
migrate:status Table of ran/pending migrations with batch, run date and duration. --pending exits 1 when anything is pending (for CI); --json for machine-readable output.
db:wipe Drops all tables without re-migrating.

Changes to existing commands

  • migrate: new --step, --pretend, --force, --seed. Each migration prints name ..... 12ms DONE.
  • migrate:fresh: new --seed, --force. The confirmation now uses the console question helper instead of reading STDIN directly. The class was renamed from MigrateRefreshCommand to MigrateFreshCommand (command name unchanged) so the new migrate:refresh can use that name.

Behaviour

  • --pretend prints the SQL a migration or rollback would run and changes nothing, including the migrations table. It captures statements that go through Database::execute() (all Schema calls and DB::execute()). Statements issued through DB::statement() are not captured.
  • Transactions: on PostgreSQL and SQLite each migration (and its migrations row) runs in a transaction, so a failure leaves no half-applied schema. MySQL commits DDL implicitly, so it runs without one. A migration can opt out with public bool $withinTransaction = false; (e.g. CREATE INDEX CONCURRENTLY).
  • Locking: MySQL (GET_LOCK) and PostgreSQL (pg_try_advisory_lock) take a lock for the whole run/rollback, so two deploys cannot migrate at once; the second fails with a clear error. SQLite is not locked.
  • Drift detection: the migrations table now also stores checksum, execution_time (ms) and ran_at. migrate:status marks a migration "modified" when its file changed after it ran (line endings are ignored) and "missing" when the file is gone. Existing migrations tables get the three nullable columns added automatically on the next migrate; old rows have no checksum and are never flagged.
  • Safety: fresh, refresh, reset and wipe always ask for confirmation; the other commands ask only in production. Without a terminal they refuse unless --force is passed.
  • A failing migration is reported as Migration <file> failed: <message> with the original exception as previous.

Compatibility

  • Migrator::run() gained an optional third array $options argument; existing calls are unaffected.
  • New Migration::$withinTransaction (default true). Behaviour change: migrations on PostgreSQL/SQLite now run in a transaction. Anything that cannot run inside one (including toggling SQLite PRAGMA foreign_keys) must set it to false.
  • New public API: Migrator::rollback(), reset(), status(), getPendingMigrations(); MigrationRepository::getRecords(), getRollbackCandidates(), delete(), upgrade(); Database::pretend(), isPretending().
  • No config changes.

Files

  • Database/Migration/Migrator.php, MigrationRepository.php, Migration.php, and Database/Database.php (pretend capture).
  • Console/Commands/Migrations/: MigrateCommand, MigrateFreshCommand (renamed), and new MigrateRollbackCommand, MigrateResetCommand, MigrateRefreshCommand, MigrateStatusCommand, DbWipeCommand.
  • Console/Support/InteractsWithMigrations.php: shared confirmation, progress output and seeding.

Testing

  • MigratorTest (16 tests, real SQLite file and real migration files): shared/step batches, rollback by batch/step/last batch, reset, missing file aborts untouched, pretend leaves nothing behind, failed migration leaves no partial table, opt-out of the transaction, status flags modified/missing, legacy migrations table upgraded in place.
  • MigratorPgsqlTest: the same 16 tests on PostgreSQL (opt-in via DOPPAR_TEST_PGSQL_HOST / DOPPAR_TEST_PGSQL_DATABASE; it drops every table in that database).
  • MigrateCommandsTest (11 tests): output, exit codes, option validation, --force and production guard, --pending, --json.
  • Full suite: 3246 tests pass; PHPStan clean.
  • MySQL and PostgreSQL advisory locks were checked by hand with two concurrent sessions (second session refused while the first holds it, succeeds after release). The migrator itself has not been run against MySQL, and --seed was only checked to invoke db:seed.

Checklist

  • Tests have been added or updated
  • Documentation has been updated
  • Code follows the project coding standards
  • All tests pass locally

@techmahedy
techmahedy requested a review from rrr63 October 2, 2026 16:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant