Back to Blog
Lesson 45 of the Advanced Laravel: Architecture, Scaling & Performance course
LaravelJune 28, 20264 min read

Advanced Database Migration Strategies for Laravel

Master non-breaking migrations and safe rollback procedures. Learn the expand-and-contract pattern to evolve your database schema without production downtime.

LaravelDatabaseDevOpsMigrationsArchitecturephpbackend

Previously in this course, we explored API versioning strategies to maintain backward compatibility. This lesson builds on that foundation by teaching you how to apply similar principles to your Database schema, ensuring your Migrations and DevOps workflows support seamless, zero-downtime deployments.

In a high-traffic environment, a simple ALTER TABLE can lock your database for minutes, effectively taking your application offline. To avoid this, we must shift our mindset from "destructive updates" to "additive evolution."

The Expand-and-Contract Pattern

The core principle of safe schema evolution is the Expand-and-Contract pattern. Instead of changing a column in one step, you break the process into multiple, backward-compatible deployments.

If you need to rename a column first_name to given_name:

  1. Expand: Add the given_name column. The application continues writing to first_name.
  2. Migrate: Use a background job to sync data from first_name to given_name.
  3. Deploy: Update the application to write to both columns, but read from given_name.
  4. Contract: Remove the first_name column once you are confident the new column is reliable.

This approach ensures that at any point during the deployment, both the old and new versions of your code can function against the database. For more complex scenarios, you might consider Laravel online schema change: mastering ghost table shadowing to handle heavy tables without locking.

Worked Example: Safe Column Migration

Let's assume we are refactoring our User model to use a new username field, moving away from a legacy email_as_username approach.

Step 1: The Migration (Expand)

First, we add the new column without touching the old one.

PHP
Schema::table('users', function (Blueprint $table) {
    $table->string('username')->nullable()->after('id');
});

Step 2: Syncing Data

Instead of a heavy SQL update that locks the table, use a chunked command to backfill data.

PHP
User::chunk(100, function ($users) {
    foreach ($users as $user) {
        $user->update(['username' => explode('@', $user->email)[0]]);
    }
});

Step 3: Application Logic

Update your model to support both states.

PHP
#6A9955">// In User Model
public function getUsernameAttribute()
{
    return $this->attributes['username'] ?? explode('@', $this->attributes['email'])[0];
}

By keeping the application logic resilient to the presence of the new column, you decouple the database state from the code deployment. When working with Laravel migrations for blue-green deployments, this decoupling is exactly what prevents service interruptions.

Rollback Procedures

Rollbacks are dangerous. If a migration fails halfway through, simply running php artisan migrate:rollback might not be enough if you have mixed data states.

  • Avoid destructive rollbacks: If you drop a column in a migration, your rollback adds it back—but it will be empty.
  • Version your migrations: Always keep a "migration history" table clean.
  • Use Pre-checks: Before running an ALTER statement, check if the column exists in your migration file to prevent runtime errors during deployment.
PHP
if (!Schema::hasColumn('users', 'username')) {
    Schema::table('users', function (Blueprint $table) {
        $table->string('username')->nullable();
    });
}

Hands-on Exercise

For our running SaaS project, we need to transition the billing_address field from a single text block to a structured JSON column.

  1. Create a migration to add billing_address_json as a json column.
  2. Write a console command that iterates through existing users, transforms the string to an object, and saves it to the new column.
  3. Update your BillingService to read from the JSON column if it exists; otherwise, fallback to the legacy string.
  4. Verify that your tests pass with both the old and new data structures present.

Common Pitfalls

  • Implicit Locks: Using change() on a column in MySQL can trigger a full table copy. Always check your DB engine's documentation on ALGORITHM=INPLACE.
  • Ignoring Queue Workers: If your code updates, but your long-running queue workers are still running the old code, they might crash if they encounter a column they don't expect. Always restart your workers after a deployment.
  • Large Table Migrations: For tables with millions of rows, avoid ALTER commands during peak hours. Use tools like gh-ost or pt-online-schema-change if your infrastructure requires it, as discussed in Kubernetes database migrations: automating schema updates with Liquibase.

Recap

Database evolution is a DevOps challenge, not just a coding one. By using the expand-and-contract pattern, you ensure that your schema changes are additive rather than destructive. Always prioritize backward compatibility in your application code, and test your rollbacks in a staging environment that mirrors production data volume.

Up next: We will explore how to handle webhooks securely to ensure your external integrations remain reliable.

Similar Posts