Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Data Migrations Bundle Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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):

  1. The compiler pass / extension that clones the Doctrine migrations configuration and rewrites the table_name, directory, and namespace. That is the mechanism — copy the config, not the class.
  2. The versions table schema. Note it stores the version string plus timestamp; you want the same, plus an environment column if you run per-env ledgers.
  3. The console command registration — it wraps 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.

Implementation Patterns

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).

Gotchas and Tips

  • 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.
  • Skip Eloquent in backfills. Model events, observers, 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.
  • MySQL DDL is not transactional. A migration that does 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.
  • Concurrent runners. Rolling deploys can start two containers that both run migrations. Laravel's migrator takes no advisory lock. Wrap your 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).
  • Replica lag. Backfilling millions of rows will push your read replicas behind and your new code reads from them. Throttle with usleep() in the chunk callback, or restrict to off-peak windows.
  • Queue workers hold stale code. A worker booted before the deploy will keep processing with the old model. Restart workers (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.
  • Timeouts. 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.
  • Extension point you actually have: 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.
  • If you ever fork the bundle: pin exact versions, add characterization tests for ordering and re-run idempotency before changing anything, and name a single owning engineer. Otherwise the fallback — the ~200-line data:migrate command above — is the correct answer.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
robertmarney/lara-hierarchial-collections
simonschaufi/rule-doc-generator
emissaoja/emissaoja
blackbricksoftware/gravityforms-civicrm-form-processor
al-rahabi/fancy-crud
jinas/dhivatar-component
mltstephane/laravel-quality
bernskiold/laravel-talentlms
bernskioldmedia/laravel-form-architect