signifly/laravel-database-refactors
A Laravel package for organising database refactors as trackable, repeatable classes separate from migrations. Run schema and data refactors safely, keep a history of applied refactors, and roll changes out consistently across environments.
This is a small, single-purpose package: it adds a migration-like runner for data refactors — the backfills, column moves, and normalizations that you don't want living inside schema migrations. Before adopting, note it is archived (last release 2021-05-21) and only supports the Laravel versions of that era; check composer.json constraints against your app, or plan to vendor the few files it ships.
Minimal setup:
composer require signifly/laravel-database-refactors
php artisan vendor:publish --tag=database-refactors # config + migration
php artisan migrate # creates the `refactors` tracking table
Where to look first, in this order:
config/database-refactors.php — table name and the refactor path (database/refactors by default).Refactor base class and the refactor / refactor:rollback commands in src/ — the package is tiny, so read it end to end in ten minutes.First use case — a backfill that shouldn't be a migration:
php artisan make:refactor BackfillUserSlugs
// database/refactors/2021_05_21_000000_backfill_user_slugs.php
use Illuminate\Support\Facades\DB;
use Signifly\DatabaseRefactors\Refactor;
return new class extends Refactor
{
public function up(): void
{
DB::table('users')->whereNull('slug')->orderBy('id')
->chunkById(500, function ($users) {
foreach ($users as $user) {
DB::table('users')->where('id', $user->id)
->update(['slug' => Str::slug($user->name).'-'.$user->id]);
}
});
}
public function down(): void
{
// deliberately a no-op: we don't want to drop data
}
};
php artisan refactor
Split DDL from DML. Keep php artisan migrate for schema and php artisan refactor for data. The canonical sequence for a rename or type change is: migration adds the new column → refactor backfills it → later migration drops the old column. Each step is independently deployable and reversible.
Deploy pipeline. Add the refactor runner after migrations in your release script, and make it explicit rather than implicit:
php artisan migrate --force
php artisan refactor --force # verify the flag exists in this version's signature
If the command has no --force, guard it in production with --env=production confirmation or call it from your own artisan command so it isn't skipped silently.
Chunk everything. Use chunkById() or lazyById(); never get(). Data refactors are the one place where a 2M-row table is normal. Prefer DB::table() over Eloquent so model events, casts, and global scopes (soft deletes especially) don't corrupt the backfill — or wrap in Model::withoutEvents() if you must use models.
Make down() honest. Either implement a real inverse or make it a documented no-op/exception. Silent half-reversals are worse than a loud failure. For destructive refactors, throw in down() and reverse manually.
Write refactors against a frozen schema snapshot. A refactor written today may run against a schema that has moved on in six months. Qualify columns explicitly, avoid SELECT *, and don't reference model attributes that may be renamed.
Idempotency inside up(). The tracker prevents double-running, but partial failures and manual re-runs happen. whereNull(...), updateOrInsert, and firstOrCreate-style guards make a refactor safe to run twice.
Testing. RefreshDatabase resets your tables but the refactors bookkeeping is just another table, so seed representative rows and invoke the refactor directly:
(new BackfillUserSlugs)->up(); // or resolve the file the way the command does
$this->assertDatabaseHas('users', ['id' => 1, 'slug' => 'jane-doe-1']);
Add one test per refactor asserting the post-state; that's your regression net when the refactor is edited.
Large / risky backfills. Pair the refactor with a feature flag: deploy dual-write, run the refactor, verify counts, then flip reads and drop the old path. For anything over a few minutes, consider running the refactor as a queued job the command dispatches, so the deploy isn't blocked.
Conventions. One refactor per PR, descriptive class name (BackfillUserSlugs, MoveBillingAddressToAddresses), and a comment at the top stating why it exists and which release introduced it.
app/ — it's a few hundred lines and removes a dead dependency.migrate && refactor in that order, and never run refactors on a fresh database before migrations finish.refactor invocation re-runs up(). If your up() isn't idempotent, rollback becomes a footgun.DB::transaction() only where the driver supports it; otherwise design for resumability (idempotent updates, progress markers).return the class instance in the shape the loader expects, it may be skipped quietly. Verify by checking the refactors table after the first run and using -v for output.UPDATE ... JOIN over millions of rows will hold locks. Batch it, and run it off-peak; on replicas, watch for lag while backfilling.User::where(...) break when the model changes or is deleted. Use the query builder, or an inline class snapshot of the columns you need.refactors? Does the file live in the configured path with the expected naming convention? Does up() throw when run manually via tinker? That covers 90% of "it didn't run."Refactor class and the command are the only seams. Subclass the base to add shared helpers (a chunked() wrapper, a logger, a progress bar), and publish/tweak the config to point at a different directory if you want refactors grouped by domain (database/refactors/billing, etc.).How can I help you explore Laravel packages today?