hassanejazpvt / phased-migrations
Zero-downtime, expand-and-contract schema migrations for Laravel 10 and newer.
Package info
github.com/hassanejazpvt/phased-migrations
pkg:composer/hassanejazpvt/phased-migrations
Requires
- php: ^8.1
- illuminate/console: ^10.0|^11.0|^12.0|^13.0
- illuminate/database: ^10.0|^11.0|^12.0|^13.0
- illuminate/support: ^10.0|^11.0|^12.0|^13.0
Requires (Dev)
- orchestra/testbench: ^8.0|^9.0|^10.0|^11.0
- phpunit/phpunit: ^10.5|^11.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-20 15:36:15 UTC
README
Zero-downtime database schema changes for Laravel 10+ using the expand → sync → contract pattern. Structural changes are applied in small, deploy-safe steps: add new schema, dual-write and backfill data, then remove the old schema—without long table locks or taking the app offline.
Requirements: PHP 8.1+, Laravel 10 / 11 / 12 / 13 (illuminate/database ^10.0)
Why phased migrations?
A single ALTER TABLE that renames or reshapes columns can lock large tables and break running code. This package splits the work across releases:
| Phase | What happens | App impact |
|---|---|---|
| Expand | Add new columns/tables (usually nullable) | Old code keeps working |
| Sync | Dual-write on save + chunked backfill for existing rows | Old and new columns stay in sync |
| Contract | Drop obsolete columns and tighten constraints | Run only after all writers use the new shape |
Progress is tracked in a phased_migration_states table so dual-writes and backfills can resume safely.
Installation
composer require hassanejazpvt/phased-migrations
Laravel auto-discovers the service provider. Publish the package migration and run it once:
php artisan migrate
That creates phased_migration_states, which records each migration’s model, current phase, and backfill checkpoint.
Quick start: rename a column
Example: copy subtitle into a new description column, then drop subtitle.
1. Define a phased migration class
Create a class anywhere Composer can autoload it (for example app/Database/PhasedMigrations/RenameSubtitleToDescription.php). Extend PhasedMigration and implement four methods:
<?php namespace App\Database\PhasedMigrations; use App\Models\Post; use Illuminate\Database\Schema\Blueprint; use PhasedMigrations\Schema\PhasedMigration; class RenameSubtitleToDescription extends PhasedMigration { public function targetModel(): string { return Post::class; } /** Phase 1: add new structure (prefer nullable columns first). */ public function expand(Blueprint $table): void { $table->text('description')->nullable(); } /** * Map row attributes from the old layout to the new layout. * Used for dual-writes on save and for the backfill command. */ public function transform(array $attributes): array { return [ 'description' => $attributes['subtitle'] ?? null, ]; } /** Phase 3: remove old structure after backfill and code cutover. */ public function contract(Blueprint $table): void { $table->dropColumn('subtitle'); } }
| Method | Role |
|---|---|
targetModel() |
Eloquent model whose table is being changed |
expand() |
Schema changes for phase 1 (additive only) |
transform() |
Column mapping for dual-write and backfill |
contract() |
Schema changes for phase 3 (drops, NOT NULL, etc.) |
2. Enable dual-writes on the model
Add InterceptsPhasedWrites to the target model. On saving, when that model has an active expand/backfill state, attributes are passed through transform() and merged onto the model before persistence.
<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use PhasedMigrations\Traits\InterceptsPhasedWrites; class Post extends Model { use InterceptsPhasedWrites; protected $fillable = ['title', 'subtitle', 'description']; }
Deploy this trait before you run phased:expand, so new saves dual-write as soon as expand completes.
3. Run the three Artisan steps
Use the fully qualified class name (FQCN) as the migration_class argument.
Step A — Expand (phase 1)
php artisan phased:expand "App\Database\PhasedMigrations\RenameSubtitleToDescription"
- Runs
expand()on the model’s table. - Inserts a state row with phase
expand. - Dual-writes are active for that model until the phase moves past
backfilling.
Deploy application code that reads/writes the new column when you are ready; until then, old code can keep using subtitle while saves still populate description.
Step B — Backfill (phase 2)
php artisan phased:backfill "App\Database\PhasedMigrations\RenameSubtitleToDescription" \
--chunk=1000 \
--pause=50
- Requires state
expandorbackfilling. - Walks the table by primary key in chunks inside transactions.
- Updates rows via the query builder (no Eloquent events) using
transform(). - Stores
last_processed_idinmapping_metaso a interrupted run can resume. - Sets phase to
backfilledwhen finished.
| Option | Default | Meaning |
|---|---|---|
--chunk |
1000 |
Rows per batch |
--pause |
50 |
Milliseconds to sleep between batches (e.g. 1000 ≈ 1 second) |
Step C — Contract (phase 3)
php artisan phased:contract "App\Database\PhasedMigrations\RenameSubtitleToDescription"
- Requires phase
backfilled. - Runs
contract()and sets phase tocontracted.
Only run contract after every writer uses the new schema and backfill has completed. Dual-writes stop once phase is backfilled (only expand and backfilling enable interception).
Migration state lifecycle
expand → backfilling → backfilled → contracted
| Phase | Dual-write on save | Backfill allowed | Contract allowed |
|---|---|---|---|
expand |
Yes | Yes | No |
backfilling |
Yes | Yes (in progress) | No |
backfilled |
No | No (done) | Yes |
contracted |
No | No | No (finished) |
Checking state in code
The package registers phased.manager (PhasedMigrationManager) and the PhasedManager facade:
use PhasedMigrations\Facades\PhasedManager; if (PhasedManager::isModelTransitioning(\App\Models\Post::class)) { // Model is in expand or backfilling }
In tests, call app('phased.manager')->reset() after inserting state rows so the in-memory cache reloads.
Operational checklist
- Design — New columns nullable first; destructive changes only in
contract(). - Model — Ship
InterceptsPhasedWriteson the target model. - Expand — Run
phased:expandin production (or per environment). - Deploy — Roll out code that prefers the new fields; dual-writes keep both sides consistent.
- Backfill — Run
phased:backfillwith chunk/pause tuned for your database load. - Verify — Spot-check rows; confirm phase is
backfilled. - Contract — Run
phased:contractduring a low-traffic window if drops are heavy.
How it works (internals)
- ExpandCommand —
Schema::table()+ state insert (phase = expand). - InterceptsPhasedWrites — On
saving, loads active migrations for the model fromphased_migration_statesand appliestransform()to the model’s attributes. - BackfillCommand — Keyset pagination by primary key, transactional updates, resumable checkpoints.
- ContractCommand — Schema contraction after
backfilled.
Phased migration classes are not run with php artisan migrate; they are plain PHP classes invoked by the phased:* commands. Only the package’s phased_migration_states migration is loaded automatically.
License
MIT. See composer.json for package metadata.