Local development
How to run this package inside a real Laravel application before publishing it — for working on the package itself, or for trying it against your own data.
The whole trick is Composer's path repository, which symlinks the package into vendor/ so edits are live immediately with no reinstall.
Set up a testbed
cd ~/Projects
laravel new backfill-testbed --database=sqlite --phpunit --no-nodeYou end up with two sibling directories:
~/Projects/
laravel-backfill/ the package
backfill-testbed/ a real app that consumes itWire the package in
cd backfill-testbed
composer config repositories.laravel-backfill path ../laravel-backfill
composer require "kstmostofa/laravel-backfill:@dev"Require @dev, not *
A path repository takes its version from the branch — dev-main — which does not satisfy * under Composer's default stable minimum-stability:
Root composer.json requires kstmostofa/laravel-backfill *,
found kstmostofa/laravel-backfill[dev-main] but it does not match
your minimum-stability."kstmostofa/laravel-backfill:@dev" fixes it without loosening stability for everything else.
Confirm it symlinked rather than copied — this is what makes edits live:
ls -la vendor/kstmostofa/
# laravel-backfill -> ../../../laravel-backfill/php artisan migrate
php artisan backfill:listPackage discovery registers the service provider automatically, so the commands appear with no further wiring.
Give it something to do
php artisan make:migration add_slug_to_users_tableSchema::table('users', function (Blueprint $table) {
$table->string('slug')->nullable()->after('name');
// Lets you prove every row was processed exactly once.
$table->unsignedInteger('process_count')->default(0);
});php artisan migrate
php artisan tinker --execute="App\Models\User::factory()->count(250)->create();"
php artisan make:backfill BackfillUserSlugspublic int $batchSize = 25;
public function collection(): Builder
{
return User::query()->whereNull('slug');
}
public function process($record): void
{
// A couple of rows failing on purpose is worth keeping while you
// exercise error isolation and backfill:retry-failed.
if (in_array($record->id, [42, 137], true)) {
throw new RuntimeException("User {$record->id} has no usable name.");
}
$record->forceFill([
'slug' => Str::slug($record->name).'-'.$record->id,
'process_count' => $record->process_count + 1,
])->save();
}Exercise it
php artisan backfill:run user-slugs --dry-run # scope, index, diffs, no writes
php artisan backfill:run user-slugs # the real thing
php artisan backfill:status user-slugs # progress and failed rowsCheck the result properly — the count that matters is "processed exactly once":
php artisan tinker --execute="
echo 'unslugged: ' . App\Models\User::whereNull('slug')->count() . PHP_EOL;
echo 'once: ' . App\Models\User::where('process_count', 1)->count() . PHP_EOL;
echo 'more than once: ' . App\Models\User::where('process_count', '>', 1)->count() . PHP_EOL;
"Resume
php artisan tinker --execute="App\Models\User::query()->update(['slug' => null, 'process_count' => 0]);"
php artisan backfill:run user-slugs --max-batches=3 # stops at cursor 75
php artisan backfill:resume user-slugs # finishes the restOne run row, not two — check BackfillRun::count().
Failed rows
php artisan backfill:retry-failed user-slugs # exits 1 while rows still failFix the cause in process(), run it again, and watch the run's failed_count drop to zero.
Queue mode
php artisan backfill:run user-slugs --queue --batches-per-job=3
php artisan queue:work --stop-when-emptyNothing should happen until the worker runs — that is the point.
The dashboards
composer require livewire/livewire
php artisan vendor:publish --tag=backfill-config// config/backfill.php
'dashboard' => ['enabled' => true, ...],
'record_batches' => true, // for the sparklinephp artisan servehttp://localhost:8000/backfills— the engineer dashboardhttp://localhost:8000/backfills/tasks— the operator panel
Both are open in local without configuring a gate. In any other environment they are closed until you call Dashboard::auth() — see the dashboard.
For the operator panel you need a backfill with $operatorRunnable = true and some parameters; one without them will not appear.
Working on the package
Because vendor/kstmostofa/laravel-backfill is a symlink, editing the package is immediately reflected in the app. No composer update, no cache clear for PHP changes.
Two things do need a nudge:
php artisan config:clear # after editing config/backfill.php in the package
php artisan view:clear # after editing the dashboard Blade viewsRun the package's own suite from the package directory, not the app:
cd ../laravel-backfill
composer test
BACKFILL_DRIVER=mysql composer test
BACKFILL_DRIVER=pgsql composer testWhy bother, when the suite is green
The package's 245 tests run under Orchestra Testbench, which is a real Laravel application — but not your Laravel application. Building this testbed immediately surfaced two bugs the suite could not have caught:
Laravel's console output strips U+2192. The dry run's diff was built with a → between the before and after values. In Testbench assertions the string is compared in memory, so it passed; printed through Artisan in a real app, the arrow vanished and rows rendered as slug: null user-1. Em dash, ellipsis, bullet and check mark all survive — only that one character is affected. The fix was to use ASCII ->.
Dates rendered differently on each side of the diff. getOriginal() returns cast attributes, so updated_at came back as a Carbon and printed as "2026-08-14T14:51:13.000000Z", while the "after" side read raw strings from the database and printed 2026-08-14 14:54:51. The comparison was correct — only the display was inconsistent — so no test noticed. Switching to getRawOriginal() puts both sides in the same format.
Both are cosmetic, and both would have been the first thing a user saw.
Tearing it down
The testbed is a throwaway. Delete the directory and nothing in the package is affected — the symlink points the other way.
To go back to the published package, drop the repositories entry from the app's composer.json and composer update kstmostofa/laravel-backfill.