Skip to content

Authoring an upgrade step

Use an upgrade step for one-time operations that run at deploy time:

  • Backfilling new columns on existing rows
  • Rewriting JSON blobs to a new schema
  • Re-indexing cache keys
  • Rotating cryptographic material

Not for schema changes — use a normal Laravel migration registered via HasMigrations.

<?php
declare(strict_types=1);
namespace Acme\MyPackage\Upgrade;
use Acme\MyPackage\Models\Widget;
use Capell\Core\Data\UpgradeContext;
use Capell\Core\Support\Upgrade\AbstractUpgradeStep;
class BackfillWidgetSchemaV2 extends AbstractUpgradeStep
{
public function id(): string
{
// Stable forever. Never change. Never reuse.
return 'acme.backfill-widget-schema-v2';
}
public function label(): string
{
return 'Backfill Acme widget schema v2';
}
public function package(): string
{
return 'acme/my-package';
}
public function run(UpgradeContext $context): bool
{
Widget::query()
->whereNull('schema_version')
->each(function ($widget): void {
$widget->update(['schema_version' => 2]);
});
return true;
}
}
public function shouldRun(UpgradeContext $context): bool
{
$current = $context->composerVersion('acme/my-package') ?? '0.0.0';
return $context->compareVersions($current, '2.0.0') >= 0;
}
public function dependsOn(): array
{
return ['core.create-widget-schema-version-column'];
}

Steps with unsatisfied dependencies are skipped with a clear reason in the log.

public function rollback(UpgradeContext $context): bool
{
Widget::query()
->where('schema_version', 2)
->each(function ($widget): void {
$widget->update(['schema_version' => null]);
});
return true;
}

Invoke: php artisan capell:rollback --step=acme.backfill-widget-schema-v2.

In your package service provider register():

$this->app->tag([
\Acme\MyPackage\Upgrade\BackfillWidgetSchemaV2::class,
], 'capell.upgrade-steps');
  • Stable id: never change id(); never reuse.
  • Idempotent body: run() must be safe to retry — a failed partial run will be retried next upgrade.
  • DB work only inside the transaction: run() executes inside DB::transaction(). Don’t do HTTP calls, queue dispatches that need a worker, or file-system work that can’t be rolled back by a transaction abort.
  • Priority discipline: 0–99 = early, 100 = default, 200+ = late.
  • Rollback is optional: default returns false. Only override if your step is genuinely reversible.
it('backfills missing schema_version', function (): void {
Widget::factory()->create(['schema_version' => null]);
$context = new UpgradeContext([], [], [], false);
$result = (new BackfillWidgetSchemaV2())->run($context);
expect($result)->toBeTrue()
->and(Widget::where('schema_version', 2)->count())->toBe(1);
});