cycle/migrations
Cycle Database Migrations provides structured, versioned database schema changes for PHP applications. Configure a migrations directory and vendor paths, initialize the migration table, then run migrations programmatically or generate migration files during schema compilation.
Know what you're installing first. cycle/migrations is the migration engine for Cycle ORM / cycle/database — it is not a Laravel package. There are no service providers, no php artisan migrate, no Eloquent model introspection, and no make:migration generator. In a Laravel app it is an alternative migrator that runs beside (not on top of) Laravel's own Migrator. Everything day-to-day is driven from a PHP bootstrap script or a thin Artisan command you write yourself.
Minimal install
composer require cycle/migrations ^4.0 cycle/database ^2.0
Pin at least ^4.2.6: that release fixes after/first attributes being emitted when a default value is present, and 4.2.5 fixes an implicit-nullable deprecation on PHP 8.4.
Bootstrap in ~15 lines
use Cycle\Database;
use Cycle\Database\Config;
use Cycle\Migrations;
$dbal = new Database\DatabaseManager(new Config\DatabaseConfig([
'default' => 'default',
'databases' => ['default' => ['connection' => 'sqlite']],
'connections' => [
'sqlite' => new Config\SQLiteDriverConfig(
connection: new Config\SQLite\MemoryConnectionConfig(),
queryCache: true,
),
],
]));
$config = new Migrations\Config\MigrationConfig([
'directory' => database_path('cycle-migrations'),
'vendorDirectories' => [],
'table' => 'cycle_migrations', // NOT "migrations" — see Gotchas
'safe' => true,
]);
$migrator = new Migrations\Migrator(
$config,
$dbal,
new Migrations\FileRepository($config),
);
$migrator->configure(); // creates the bookkeeping table
First run
while (($migration = $migrator->run()) !== null) {
echo 'Migrated: ' . $migration->getState()->getName() . PHP_EOL;
}
run() applies exactly one pending migration per call and returns null when the queue is empty — the while loop is the intended idiom, not a convenience.
Where to look first, in order
Cycle\Migrations\Migrator — the only public entry point you'll call daily (run, rollback, configure, repository accessors).Cycle\Migrations\Migration (abstract base for your migration files) and TableBlueprint — this is the DSL generated migrations use.Cycle\Migrations\FileRepository + Config\MigrationConfig — controls discovery, ordering, and the vendor path list.cycle/schema-migrations-generator — the actual "automatic migration generation" from the package description; it diffs a compiled Cycle schema against the DB.First realistic use case: you have (or are adopting) Cycle ORM somewhere in the stack, define schema in PHP, let the generator emit create_users_table-style files, review them, then apply via the run() loop. If your schema lives in Eloquent models, stop here and use Laravel's migrator — see Gotchas.
Wrap the migrator in an Artisan command. This is the single highest-value integration step, because it gives you the ergonomics you expect from php artisan migrate:
class CycleMigrateCommand extends Command
{
protected $signature = 'cycle:migrate {--rollback : Roll back the last batch}';
public function handle(): int
{
$migrator = app(CycleMigratorFactory::class)->make();
$migrator->configure();
if ($this->option('rollback')) {
while (($m = $migrator->rollback()) !== null) {
$this->line('Rolled back: ' . $m->getState()->getName());
}
return self::SUCCESS;
}
while (($m = $migrator->run()) !== null) {
$this->line('Migrated: ' . $m->getState()->getName());
}
return self::SUCCESS;
}
}
Build the DatabaseConfig inside a small factory bound in a service provider so credentials come from config/database.php rather than being hard-coded in the command.
Generate, then review — never generate and auto-apply. The generator emits real PHP files into directory. Treat them as a PR: read the up(), read the down(), adjust destructive operations, then commit. This is the "freedom to alter such migrations manually before running them" the README promises, and it's the reason the generator is a separate package.
Use vendorDirectories for package migrations. This is the closest analogue to Laravel's loadMigrationsFrom() / --path publishing: third-party Cycle packages ship a migrations/ folder, you register the absolute path, and they participate in the same ordering and bookkeeping as your own. Keep the list short and explicit; ordering is by migration name across all directories.
Pin the schema-generation flow to a real database per environment. Because the generator diffs compiled schema against the live DB, it must be run against a database that is at the same migration state as the branch you're on. A common pattern:
# local
php artisan cycle:migrate # catch up
vendor/bin/generate-migrations # diff compiled schema -> files
git add database/cycle-migrations
Never regenerate on CI — you'll get migrations that don't match the committed schema.
Testing: SQLite in-memory + full replay. Because configure() is cheap and the DBAL supports MemoryConnectionConfig, you can replay the whole migration set in a test bootstrap. This is the cheapest way to prove down() methods actually work, which is where hand-written migrations usually rot.
protected function setUp(): void
{
parent::setUp();
$this->migrator->configure();
while ($this->migrator->run() !== null) {}
}
Column and index details worth knowing
CURRENT_TIMESTAMP defaults on datetime columns have been supported since 3.1.0 — use it instead of hand-written SQL defaults.Multi-database setups. DatabaseManager accepts several databases/connections; the migrator walks them and (since 4.2.4) skips connections flagged read-only, which matters if you point at a read replica. Keep a dedicated migrations table per database rather than sharing one.
Don't point it at Laravel's migrations table. The bookkeeping schema is Cycle's own (name, time, etc.) and is not compatible with Laravel's migration/batch columns. Using the default 'table' => 'migrations' will make php artisan migrate and cycle:migrate corrupt each other's state. Use cycle_migrations and be explicit about which one owns a given schema.
The PM/TPM notes about php artisan make:migration User and Eloquent are wrong. No part of this package reads Eloquent models or registers Artisan commands. If your day-to-day is "add a column to a Laravel model and get a migration", Laravel's native tooling wins on every axis (integrations, --pretend, migrate:fresh, schema dumps). Adopt cycle/migrations only when Cycle's TableBlueprint/schema definitions are already your source of truth, or when you're consuming a Cycle package's shipped migrations.
safe => true is mandatory in CI. With safe false the migrator will ask for confirmation on run, which hangs non-interactive pipelines. The README is easy to skim past here.
Call configure() before every entry point, not just once at install. It's cheap and idempotent; forgetting it in a fresh test database or a newly provisioned environment is the most common "no such table: migrations" failure.
run() is one-at-a-time; forgetting the loop silently applies a single migration. Similarly, null is the only terminator — a false-y check or a foreach over getMigrations() will not give you the same semantics.
Ordering is name-based across directories. Two packages that both ship 20240101000000_create_users_table will collide or order unpredictably. Prefix vendor-generated names, and don't rely on vendorDirectories array order.
Verify rollbacks before you trust generated down(). Generated migrations sometimes can't reverse a destructive change (dropped column with data, narrowed types). Any migration that loses information should be edited by hand and, for production, preceded by a backup gate.
Driver parity is on you. The DBAL normalises a lot, but SQLite's ALTER TABLE limitations and MySQL's index naming will bite differently. Run your test suite against the same driver family as production, or you'll discover the difference during a deploy.
Keep the version current on modern PHP. 4.2.5 (PHP 8.4 nullable deprecations), 4.2.4 (read-only schema skip), 4.2.2 (binary column size), 4.2.1 (a real array_multisort argument-order bug in sorting) are all fixes you do not want to backport yourself.
Extension points are narrow but real. FileRepository can be swapped for your own repository implementation (useful if migrations live in a DB, a phar, or a read-only container image), and MigrationConfig is just an options array. There is no event system and no hook into Migration::up() execution — for anything finer-grained you're forking. Budget for that if you need audit trails or dry-run planning.
Debugging: the fastest signal is the cycle_migrations table itself plus $migration->getState()->getName(); second fastest is enabling query logging on the cycle/database connection config (queryCache: true in the sample is for compiled query caching — separate concern, don't confuse the two).
How can I help you explore Laravel packages today?