Search by

hassanejazpvt / phased-migrations

hassanejazpvt

Zero-downtime, expand-and-contract schema migrations for Laravel 10 and newer.

Package info

github.com/hassanejazpvt/phased-migrations

pkg:composer/hassanejazpvt/phased-migrations

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

1.0.0 2026-10-20 15:19 UTC

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 expand or backfilling.
  • Walks the table by primary key in chunks inside transactions.
  • Updates rows via the query builder (no Eloquent events) using transform().
  • Stores last_processed_id in mapping_meta so a interrupted run can resume.
  • Sets phase to backfilled when 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 to contracted.

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

  1. Design — New columns nullable first; destructive changes only in contract().
  2. Model — Ship InterceptsPhasedWrites on the target model.
  3. Expand — Run phased:expand in production (or per environment).
  4. Deploy — Roll out code that prefers the new fields; dual-writes keep both sides consistent.
  5. Backfill — Run phased:backfill with chunk/pause tuned for your database load.
  6. Verify — Spot-check rows; confirm phase is backfilled.
  7. Contract — Run phased:contract during 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 from phased_migration_states and applies transform() 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.