gheb/data-migrations-bundle
gheb/data-migrations-bundle provides a Symfony/Doctrine workflow for versioned data migrations, letting you organize, track, and run one-time or repeatable data changes alongside schema migrations so application data transformations stay structured and maintainable.
Reality check first. gheb/data-migrations-bundle is a Symfony bundle, not a Laravel package. It hooks Symfony's DI container, the Console kernel, and doctrine/doctrine-migrations-bundle. There is no service provider, no facade, no Eloquent integration, and no recipe. It is also archived, 4 stars, 0 dependents. Do not composer require it into a Laravel app — you would drag Doctrine Migrations in just to run a second migration stream, and inherit an unmaintained dependency in your deploy path.
What to take instead: the one design decision it makes well. The bundle registers a second migrations configuration — its own directory, its own ..._versions ledger table, its own command namespace — so structure and data advance independently but in a controlled order. That is the whole idea, and it costs a sprint at most to reproduce in Laravel.
Where to look first in the repo (for the design, not the code):
table_name, directory, and namespace. That is the mechanism — copy the config, not the class.doctrine:migrations:migrate against the second config. In Laravel this is a thin artisan command.First use case — the only one you actually need this week. You added users.full_name and need to backfill from first_name/last_name. Do it in a normal migration, in a chunk, with a guard:
// database/migrations/2024_05_01_000000_backfill_users_full_name.php
return new class extends Migration {
public function up(): void
{
DB::table('users')
->whereNull('full_name')
->orderBy('id')
->chunkById(1000, function ($rows) {
foreach ($rows as $row) {
DB::table('users')->where('id', $row->id)->update([
'full_name' => trim($row->first_name.' '.$row->last_name),
]);
}
});
}
public function down(): void
{
// Backfills are not reversible. See Gotchas.
}
};
That is 20 lines and it is more trustworthy than a forked bundle, because you own the tests. Reach for spatie/laravel-migration-actions when you want the ledger/ordering separation; reach for nothing when a chunked DB::table() inside a normal migration does the job.
Pattern 1 — data steps inside normal migrations (default). Laravel migrations are arbitrary PHP, so schema and data already live in one ordered stream. Put the Schema::table() call and the backfill in the same migration file when they are inseparable, and use separate files when you need the expand/contract gap:
1. expand — add nullable column, deploy
2. backfill — data migration, deploy
3. cutover — make NOT NULL / drop old column, deploy
Name data-only files so they sort where you want them (..._backfill_..., ..._populate_...). Reviewers can then grep for them.
Pattern 2 — a separate ledger, if you truly need two streams. Only justify this when a backfill takes >60s and you must not block a schema push. Mirror the bundle:
// database/data-migrations/2024_05_01_backfill_full_name.php
return new class {
public function up(): void { /* chunked DB::table() work */ }
};
// app/Console/Commands/MigrateData.php
class MigrateData extends Command
{
protected $signature = 'data:migrate {--dry-run} {--step=}';
public function handle(): int
{
Schema::hasTable('data_migrations') || Schema::create('data_migrations', function ($t) {
$t->string('migration')->primary();
$t->string('environment');
$t->unsignedInteger('batch');
$t->timestamp('ran_at');
});
$ran = DB::table('data_migrations')->where('environment', app()->environment())->pluck('migration');
foreach (File::glob(database_path('data-migrations/*.php')) as $file) {
$name = basename($file, '.php');
if ($ran->contains($name)) continue;
$this->line("Running {$name}");
if (! $this->option('dry-run')) {
require $file;
DB::table('data_migrations')->insert([...]);
}
}
return self::SUCCESS;
}
}
Keep the table name, directory, namespace, and batch concepts from the bundle. Drop everything else.
Pattern 3 — service-based migrations. The bundle's "service-based migration classes" maps to Laravel as invokable action classes injected from the container: app(BackfillFullName::class)(). This is worth copying for anything reused across environments (demo data, reference tables, tenant provisioning), because it becomes testable in isolation and callable from a seeder and a migration.
Wiring into the deploy. Run data migrations after php artisan migrate --force and before traffic shifts to new code:
php artisan down --retry=60
php artisan migrate --force
php artisan data:migrate --force
php artisan up
If you are doing expand/contract properly, the backfill step ships in a release between the expand and cutover deploys — not in the same one.
Idempotency is a design requirement, not a nicety. Every data migration must be safe to re-run: filter on the state you are fixing (whereNull('full_name')), not on "I assume this hasn't run." The ledger protects you from re-runs in the happy path; it does not protect you from a deploy that died mid-chunk and never got recorded.
Reference data (roles, permissions, country lists) is a data migration, not a seeder. Seeders are for local dev and get truncated; reference data ships to production and should be versioned and re-runnable (use upsert).
down() for data is usually a lie. Decide up front: rollback means "run a compensating migration" or "restore from backup." Document it in the migration's docblock. A down() that deletes a column you just backfilled will pass CI and destroy production.chunk() vs chunkById(). If your update touches the column you are ordering by, chunk() skips rows. Always chunkById() on the primary key for backfills.updated_at touch, casts, and global scopes (soft deletes!) will silently change your result set or triple your runtime. Use DB::table() for the write and for the read.Schema::table() then a backfill can leave you half-expanded with no ledger row. Split schema and data into separate migration files so each fails independently.data:migrate command in Cache::lock('data-migrate', 600)->block(30, fn () => ...) and verify the lock actually works across your cache driver (database/redis — not array).usleep() in the chunk callback, or restrict to off-peak windows.queue:restart) after migrations, and never make a data migration depend on code that the worker is also running.RefreshDatabase in tests will run your data migrations on every test. Keep them fast and keep a small test fixture set; guard production-scale loops behind app()->isProduction() only if you've measured it.php artisan migrate has no per-migration timeout, but your deploy platform does. Set --timeout explicitly and size chunks so the whole thing finishes in one window.spatie/laravel-migration-actions gives you the migration_actions ledger and ->onFailure(), ->pretend() hooks, and is maintained. Evaluate it against the same requirements — before adding anything, check its release cadence and open-issue count, because this whole exercise exists because of an abandoned dependency.data:migrate command above — is the correct answer.How can I help you explore Laravel packages today?