The go-to resource for learning PHP, Laravel, Symfony, and your dependencies.

Handling Database Migrations During Framework Upgrades


In the 1970s, oil companies sometimes faced a formidable challenge: replacing underwater pipelines without interrupting flow. The solution involved building new parallel pipelines, gradually switching over, and only then decommissioning the old lines. This same principle—making changes that allow both old and new systems to coexist—applies directly to database migrations during framework upgrades. You could think of it as replacing the pipeline while oil continues to flow.

Upgrading your application’s framework brings new features, security patches, and performance improvements. Yet the database layer frequently presents the most significant risk: a poorly planned migration can lead to data loss, extended downtime, and frustrated users. In this article, we’ll examine strategies for managing database schema changes during framework upgrades—practices that help you maintain availability while changing your data model incrementally and safely.

Before we dive in, note that while we’ll use PHP framework examples (primarily Laravel and Symfony), the principles we discuss apply broadly across web frameworks and even beyond PHP. The core challenge—changing a database schema while maintaining service—is universal in software maintenance.

The Importance of Planning

Before we write a single line of code, we need a solid plan—and not just for the migration itself. A framework upgrade gives us a rare opportunity: we can assess our database schema and make long-needed improvements that might otherwise never happen. Of course, planning requires us to balance competing priorities: the desire to improve our schema versus the risk of introducing problems during an already risky upgrade.

One may wonder: where should we start? We can broadly divide the planning phase into three overlapping activities:

First, we audit our current schema. This means identifying deprecated columns, unused tables, data type inconsistencies, and opportunities for normalization. We’ll want to document what we find, but more importantly we’ll want to understand why these issues exist—was it historical baggage, quick fixes, or evolving requirements? Understanding the origins helps us avoid repeating past mistakes.

Second, we study the framework’s changes. Review the release notes, migration guides, and changelogs. What database abstractions have changed? Have any query builders been deprecated? Are there new migration utilities we should adopt? For example, Laravel’s shift from the older schema builder to the improved Blueprint system in recent versions affected how we write migrations. We should also note any changes to default character sets, collation behaviors, or connection handling.

Tip: Pay special attention to behavioral changes versus new features. A new feature might be optional; a behavioral change can break working code.

Third, we design our migration strategy. This is where we decide: will we use the framework’s built-in tools, or do we need custom scripts? For most applications, the framework’s migration system (Laravel’s migrations, Symfony’s Doctrine migrations, etc.) is sufficient. However, for very large databases with millions of rows, we might need additional tooling to manage data backfills without locking tables. We’ll discuss these trade-offs in more detail below. Of course, the line between “large” and “very large” isn’t sharp—your team’s tolerance for risk and your maintenance window constraints will factor into this decision too.

Before we get into the specific migration techniques, though, let’s acknowledge a crucial point: planning is not a one-time activity. We’ll revisit our plan as we discover complications, and we should build in checkpoints to validate our assumptions along the way.

Backward-Compatible Schema Changes

One of the biggest challenges with database migrations is ensuring that your application continues to function correctly during the deployment process—not just before and after. The key is making backward-compatible schema changes, meaning that both the old and new versions of your code can operate on both the old and new database schemas.

Note: This requirement leads us to adopt what’s sometimes called a “dual-write” or “expand-contract” pattern. The new schema must be compatible with the old code, and the old schema must accommodate the new code—typically for some overlap period.

Of course, you may wonder: why do we need two-way compatibility? Can’t we just deploy everything at once? In practice, deployments often involve multiple steps: we might deploy new code to application servers before running migrations, or we might run migrations before all code instances are upgraded. Additionally, rollbacks must be possible. If we deploy a change and immediately discover a problem, we need to revert to the previous code version—which must still function with whatever database state we have.

Let’s examine common schema change patterns and their compatibility requirements.

Adding a New Column

This is among the more straightforward cases, but even here we have choices. When adding a new column, we must ensure that existing code that doesn’t know about the new column can still insert new rows. We typically accomplish this by making the column either nullable or providing a default value:

// Good: nullable
$table->string('full_name')->nullable();

// Good: with default
$table->boolean('is_subscribed')->default(false);

What about NOT NULL columns without defaults? These require careful handling. If we add a NOT NULL column without a default, existing rows would violate the constraint immediately. One approach is to add the column as nullable first, backfill data, then alter it to NOT NULL in a separate migration—but only after we’ve updated all code paths to always set a value. We’ll cover backfilling strategies later.

Tip: When designing nullable columns, consider whether null truly represents “unknown” or “not applicable” in your domain. This semantic distinction can help you decide which approach to take.

Renaming a Column

Renaming a column is inherently a multi-step process that often spans several deployments. The typical sequence looks like this:

  1. Add the new column with the desired name, keeping the old column intact.
  2. Modify the application code to write to both columns (dual-write). This ensures that new rows have data in the new column while existing code continues reading from the old column.
  3. Backfill existing data from the old column to the new column. This might be done via a one-off script or a migration with caution (see below).
  4. Update the application code to read from the new column instead of the old.
  5. Drop the old column in a separate deploy after we’re confident the new column works correctly.

One may wonder: why not combine steps 4 and 5? The answer is safety. If we switch reading to the new column and then immediately drop the old column, any rollback would break reads—because the old code expects the old column to exist. By separating these steps, we ensure that a rollback after step 4 (but before step 5) still works: the old code reads the old column, which still exists. Only after we’re confident in the new code do we remove the old column.

There’s also a subtlety about when to run backfills. If we backfill as part of a migration that runs during deployment, we risk long-running operations that lock tables—especially on large datasets. For production databases with millions of rows, we typically run backfills via a separate, carefully scheduled maintenance window, or we chunk the updates. We’ll discuss these practical considerations in the zero-downtime section.

Changing a Column’s Type

Changing a column’s type resembles renaming but adds complexity around data conversion. For example, converting a VARCHAR(100) to a TEXT column, or changing an integer to a decimal for precision. The general pattern is:

  1. Add a new column with the desired type.
  2. Dual-write to both columns.
  3. Backfill with type conversion (ensuring data fits the new type).
  4. Switch reads to the new column.
  5. Drop the old column.

The challenge with type changes is ensuring that the conversion doesn’t lose information. Changing from INT to VARCHAR is generally safe; changing from VARCHAR(50) to VARCHAR(20) risks truncation. We must verify that all existing data fits within the new constraints—and that future data will too.

Tip: For complex type conversions (e.g., JSON to normalized tables, string dates to timestamp), consider writing idempotent backfill scripts that can be rerun safely if interrupted. This is especially important for multi-hour operations on large tables.

Splitting or Merging Columns

Schema changes that involve multiple columns—such as splitting a full_name column into first_name and last_name—follow similar principles but require more careful coordination. The general approach:

  1. Add the new target columns (first_name, last_name) as nullable.
  2. Update code to write to both the old and new columns.
  3. Backfill by parsing the old column’s value into the new ones.
  4. Update code to read from the new columns.
  5. Drop the old column.

The backfill step here often involves string parsing, which can introduce edge cases: what about names with multiple spaces, suffixes like “Jr.”, or compound surnames? We should test these scenarios on realistic data samples rather than assuming perfect formatting. Of course, you might have naming conventions in place, but legacy data rarely conforms perfectly to current standards—that’s why we’re making this change after all.

The Trade-offs of In-Place ALTERs

On some database systems (MySQL with certain engines, PostgreSQL), an ALTER TABLE that changes a column’s type can lock the table or cause long rebuilds. For large tables, this may mean hours of downtime. While we can often use online DDL operations (like PostgreSQL’s CONCURRENTLY), these aren’t always available depending on the change type and database version. Though the database documentation will tell you whether a specific operation is online, it’s worth testing on a staging copy of your actual data—theory and practice sometimes diverge.

Thus, another option is the shadow table pattern: create a new table with the desired schema, copy data gradually, switch over via a view or application logic, then drop the old table. This approach is more complex but can achieve near-zero downtime even for large schema changes. We won’t detail it here—see the “Further Reading” section for resources on online schema change tools like pt-online-schema-change or PostgreSQL’s logical replication for table migrations.

Let’s summarize the decision factors:

  • Data size: Small tables (thousands of rows) can typically use standard ALTER TABLE. Large tables (millions) may need chunked updates or shadow tables.
  • Downtime tolerance: If you can afford a maintenance window, the classic approach may suffice. For 24/7 applications, consider online schema change tools.
  • Database capabilities: Some databases support online operations better than others. MySQL’s ALGORITHM=INPLACE helps for many changes but not all; PostgreSQL’s CREATE INDEX CONCURRENTLY is useful for indexes but not column type changes.
  • Change complexity: Adding columns is safe; renaming or changing types introduces risk that scales with data complexity.

We should choose our approach accordingly, rather than assuming one method fits all scenarios. One may wonder: how do we know which approach is right for our application? The answer depends on your specific context—data size, tolerance for downtime, and database capabilities. We’ll examine these decision factors in the zero-downtime section.

Zero-Downtime Migrations

For many applications, downtime is not an option. A zero-downtime migration strategy is essential to ensure your application remains available throughout the upgrade process. There are, of course, multiple approaches to achieving this—each with its own trade-offs.

Using Feature Flags

Feature flags (also called feature toggles) are a powerful tool for deploying changes incrementally. The basic idea: we deploy new code that knows about both the old and new schemas, but we gate the new behavior behind a flag. This allows us to:

  1. Deploy the new code to production (which connects to the existing database).
  2. Run the database migration to add new columns/tables.
  3. Backfill data without immediately switching traffic.
  4. Enable the feature flag for a subset of users (canary testing).
  5. Gradually ramp up to 100% of users.
  6. Remove the flag and old code in a later deploy.

The advantage of this approach is fine-grained control: if something goes wrong, we can disable the feature instantly without rolling back code. The downside is added complexity—we now have conditional logic in our application and more moving parts.

A Progressive Walkthrough: Column Rename in Laravel

Let’s examine a concrete example step by step. Suppose we’re upgrading a Laravel application and need to rename the users.name column to users.full_name. We’ll follow a five-deployment strategy that maintains compatibility at each step.

Step 1: Add the New Column (Deploy 1)

First, we create a migration that adds the full_name column as nullable:

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->string('full_name', 255)->nullable()->after('name');
        });
    }

    public function down(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->dropColumn('full_name');
        });
    }
};

We deploy only this change to production. At this point:

  • The new column exists but is empty for existing rows (typically thousands to millions depending on your user base).
  • All code continues using name exclusively.
  • No functionality changes; this is a fully backward-compatible schema addition.

Important: We do not modify the User model yet. Adding the column is schema-only and fully backward compatible.

Step 2: Implement Dual-Write (Deploy 2)

Now we update the application to write to both columns. We’ll modify the User model:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;

class User extends Model
{
    protected function name(): Attribute
    {
        return Attribute::make(
            set: function ($value) {
                $this->attributes['name'] = $value;
                $this->attributes['full_name'] = $value;
                return $value;
            }
        );
    }
}

Alternatively, if we’re using older Laravel versions (say, Laravel 7 or earlier) without Attribute classes:

public function setNameAttribute($value)
{
    $this->attributes['name'] = $value;
    $this->attributes['full_name'] = $value;
}

Of course, you’ll need to ensure both Laravel versions (old and new) can read this code during the transition period—which is why we keep the dual-write approach rather than changing the accessor yet.

What about reads? We keep reading from name for now. All writes update both columns, but existing reads continue unaffected.

Step 3: Backfill Existing Data

Now we need to populate full_name for users that existed before step 2. There are a few ways to do this:

Note: Before running any backfill on production, test your approach on a staging copy with a realistic dataset. A million-user backfill that takes 15 minutes on staging might take several hours in production if your dataset is significantly larger or your database load differs.

Option A: One-off Artisan command

<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;

class BackfillUserFullName extends Command
{
    protected $signature = 'users:backfill-full-name';
    protected $description = 'Backfill full_name from name for existing users';

    public function handle(): int
    {
        $count = DB::table('users')
            ->whereNull('full_name')
            ->update(['full_name' => DB::raw('name')]);

        $this->info("Updated {$count} users.");
        return 0;
    }
}

We’d run: php artisan users:backfill-full-name

Option B: Chunked updates for large tables

If we have millions of users, the single UPDATE might lock the table for too long. Instead:

User::whereNull('full_name')
    ->chunk(1000, function ($users) {
        foreach ($users as $user) {
            $user->update(['full_name' => $user->name]);
        }
    });

This updates in batches of 1000, reducing lock duration. We could even schedule this to run during low traffic. The downside: if the job crashes, we can rerun it safely (due to the whereNull('full_name') condition).

Option C: Use database-level tools

On MySQL, pt-online-schema-change from Percona Toolkit can backfill data without locking writes. On PostgreSQL, we might use CREATE INDEX CONCURRENTLY patterns adapted for data updates.

Step 4: Switch Reads to the New Column (Deploy 3)

After the backfill completes and we’ve verified data integrity, we deploy code that reads from full_name instead of name. This might involve:

  • Updating queries to use full_name directly.
  • Modifying accessors/mutators if we still want to use $user->name as the public API (we could make name an alias).

For example, we might keep the accessor for backward compatibility with any code still using $user->name:

protected function name(): Attribute
{
    return Attribute::make(
        get: fn () => $this->full_name,
        set: fn ($value) => [
            $this->attributes['full_name'] = $value,
            $this->attributes['name'] = $value,
        ],
    );
}

This way, $user->name returns full_name while still writing to both during the transition.

At this point, the old column (name) is essentially deprecated—but still written to for safety.

Step 5: Drop the Old Column (Deploy 4+)

Only after we’re confident that all code paths read from full_name and no rollback to old code (that expects name) is needed do we drop name. This should be a separate deployment scheduled after the previous one has been stable in production for some time.

public function up(): void
{
    Schema::table('users', function (Blueprint $table) {
        $table->dropColumn('name');
    });
}

We might also add a post-deploy check: confirm that no queries in our logs are accessing users.name anymore. Database query monitors can help catch lingering references.

What If We Need to Roll Back?

The staged approach lets us roll back safely at most stages:

  • After step 1: Simple database rollback (drop full_name).
  • After step 2: Roll back the code change; the database remains compatible.
  • After step 3: Still safe; we haven’t changed reads yet.
  • After step 4: This is the dangerous zone. We’ve changed reads to full_name but name still exists. Rolling back code that expects to read name would still work because name exists. So it’s safe.
  • After step 5: Dropped name. Rolling back code to before step 4 would break if that code reads name. Therefore, we should keep the old code deployable only if we haven’t dropped the column. Once name is gone, we cannot roll back to a version that expects it. That’s why step 5 comes later and only after thorough testing.

Alternative Approaches: Shadow Tables and Blue-Green Deployments

For very large or complex schema changes, the column-by-column approach may be impractical. Consider these alternatives:

Shadow table pattern: Create a new table (users_new) with the desired schema. Gradually copy data from users to users_new using triggers or application logic. Update application code to read from users_new (possibly via a database view named users). Finally, switch over completely and drop the old table. This pattern is more complex but allows near-zero downtime even for massive schema overhauls.

Blue-green deployment: Deploy a new version of the application alongside the old, each using its own database schema. Use a load balancer to route traffic gradually. Keep data synchronized via replication triggers. This is essentially the shadow table pattern at the application-architecture level.

These approaches add considerable complexity and are typically only justified for systems with strict uptime requirements (24/7 services with global traffic) or massive datasets where even online ALTERs would take hours.

The Role of Testing

Thorough testing is critical for a successful database migration—but what exactly should we test, and how? Testing migrations requires a different approach than testing application logic, because we’re validating not just that code works, but that data remains intact and accessible throughout the transition.

One may wonder: can’t we just test the migration scripts themselves? That’s part of it, but we must also test the combined state of old code with new schema, and new code with old schema. Let’s break down our testing strategy.

Unit Tests

Your unit tests should cover the data access layer: models, repositories, and any business logic that interacts directly with database columns. When we change a schema, these tests often need updating to reflect the new column names or types. However, unit tests alone aren’t sufficient—they typically use an in-memory or isolated test database that doesn’t capture the complexities of a production dataset.

Integration Tests

Integration tests are essential to ensure that your application works correctly with the new database schema. For migrations, we want to verify:

  • Queries return expected results with the new schema.
  • Data written via the new code is readable by the old code (and vice versa) during the overlap period.
  • Backfill scripts correctly populate new columns without corrupting existing data.
  • Constraints (unique indexes, foreign keys) behave as expected after schema changes.

We recommend using a realistic dataset for these tests—not just a few factory-generated records. Consider cloning a sanitized production database (removing sensitive data) and running your migrations against it. This can reveal performance issues or data edge cases that synthetic data misses.

Staging Environment with Production-Like Data

Before deploying to production, always test your migrations in a staging environment that closely mirrors production—ideally using a recent copy of production data. This helps us discover:

  • Migration duration and locking behavior on production-scale datasets (say, a 500GB database rather than your 5MB development DB).
  • Queries that become slow after schema changes.
  • Unexpected constraint violations during backfill.
  • Resource consumption (memory, disk I/O) on realistic data volumes.

Of course, staging environments can’t perfectly replicate production load. For that reason, we should also consider canary deployments: deploy to a small subset of production servers first and monitor query performance, error rates, and migration duration before rolling out widely.

Tip: If you can’t copy full production data due to privacy or size constraints, consider generating synthetic data that matches your production schema distribution. A million rows with realistic text lengths and patterns will reveal issues your 100-row development DB won’t.

Testing the Rollback Path

An often-overlooked aspect is testing rollbacks. If we deploy a migration and something goes wrong, can we revert safely? We must verify:

  • The down method (if using framework migrations) truly restores the previous schema.
  • Old code versions continue working after a database rollback.
  • Data written during the failure period (with mixed schema states) remains consistent.

We should test these rollback scenarios in staging before ever running the migration in production. It’s better to discover that our rollback plan doesn’t work while it’s still safely reversible.

What to Watch For

Here are common pitfalls that testing sometimes misses:

1. Time-based or conditional logic in code
If our application has code that checks schema versions or uses feature flags to handle multiple schema states, we need to test the interactions explicitly. For example, code that conditionally uses full_name if it exists, falling back to name otherwise. These branches must be verified with both column present and absent.

2. Background jobs and queues
Migrations may affect background job payloads or processing logic. If jobs were enqueued before a schema change but processed after, they might reference old column names. We should test job serialization/deserialization across schema versions.

3. Read replicas
If our application uses read replicas, we must ensure that migrations run on the primary and replicate correctly before queries hit the replicas. Delayed replication could cause temporary inconsistencies where some replicas have the new schema while others don’t. Testing should include replication lag scenarios.

4. Cache invalidation
If we cache database query results (e.g., in Redis or Memcached), we need to invalidate relevant caches after schema changes. Otherwise, cached rows might reference old column structures that no longer match the database.

5. External dependencies
Some frameworks generate model code or ORM configuration based on the database schema. If we use such tools (e.g., Laravel’s model generators, Doctrine’s schema validation), we should verify they update correctly and don’t produce invalid code.


A Testing Checklist

To summarize, ensure your test plan covers:

Migration Execution:

  • Migration scripts execute successfully on a realistic dataset (ideally a sanitized copy of production).
  • Migration duration meets your maintenance window constraints, if any—or that the migration can run without impacting user experience during peak hours.
  • Locking behavior is acceptable for your database engine and data volume.

Compatibility:

  • Backward compatibility: old code works with new schema.
  • Forward compatibility: new code works with old schema (during the overlap period).
  • Data written by old code is readable by new code (and vice versa).

Rollback and Recovery:

  • Rollback procedures work and preserve data integrity.
  • Data written during a failed partial deployment remains consistent.

Performance and Operations:

  • Performance of common queries remains acceptable (run EXPLAIN ANALYZE on key queries before and after).
  • Background jobs handle schema transitions gracefully—what happens if a job queued before the migration runs after?
  • Read replicas catch up without errors, even with replication lag.
  • Caches are invalidated appropriately when schema changes occur.

One may wonder: what about testing with actual user traffic patterns? That’s where canary deployments come in—test on a small subset of production servers before full rollout, monitoring error rates, query performance, and user-visible effects. Of course, this requires appropriate monitoring and the ability to quickly roll back if anomalies appear.

Remember: testing is not about guaranteeing perfection—it’s about reducing risk to an acceptable level. Even with thorough testing, we should still have a rollback plan and monitoring in place for production deployments.

Conclusion

Handling database migrations during a framework upgrade is a complex but manageable task. By planning carefully, making backward-compatible changes, using a zero-downtime strategy, and testing thoroughly, you can ensure a smooth and successful upgrade. Remember that the goal is not just to upgrade your framework, but to do so without disrupting your users or losing data.

Of course, every application’s situation differs—your database size, your team’s expertise, your tolerance for downtime. Though the patterns we’ve discussed apply broadly, you’ll need to adapt them to your specific context. If you’re working with particularly large datasets or complex legacy systems, consider exploring specialized tools like pt-online-schema-change for MySQL or logical replication for PostgreSQL—both can enable near-zero-downtime migrations even for massive tables. For further reading, the PostgreSQL documentation’s section on “ALTER TABLE” and the MySQL Manual’s “Online DDL” provide valuable details on what operations can be performed without locking—valuable knowledge before you begin planning your next upgrade.

Sponsored by Durable Programming

Need help with your PHP application? Durable Programming specializes in maintaining, upgrading, and securing PHP applications.

Hire Durable Programming