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.
Minimal step
Section titled “Minimal step”<?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; }}Version gating
Section titled “Version gating”public function shouldRun(UpgradeContext $context): bool{ $current = $context->composerVersion('acme/my-package') ?? '0.0.0';
return $context->compareVersions($current, '2.0.0') >= 0;}Dependencies
Section titled “Dependencies”public function dependsOn(): array{ return ['core.create-widget-schema-version-column'];}Steps with unsatisfied dependencies are skipped with a clear reason in the log.
Reversible steps
Section titled “Reversible steps”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.
Register the step
Section titled “Register the step”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 insideDB::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.
Testing
Section titled “Testing”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);});