Files
michaelschiemer/docs/claude/examples/migrations/SafeVsUnsafeMigrations.md
Michael Schiemer fc3d7e6357 feat(Production): Complete production deployment infrastructure
- Add comprehensive health check system with multiple endpoints
- Add Prometheus metrics endpoint
- Add production logging configurations (5 strategies)
- Add complete deployment documentation suite:
  * QUICKSTART.md - 30-minute deployment guide
  * DEPLOYMENT_CHECKLIST.md - Printable verification checklist
  * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle
  * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference
  * production-logging.md - Logging configuration guide
  * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation
  * README.md - Navigation hub
  * DEPLOYMENT_SUMMARY.md - Executive summary
- Add deployment scripts and automation
- Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment
- Update README with production-ready features

All production infrastructure is now complete and ready for deployment.
2025-10-25 19:18:37 +02:00

11 KiB

Safe vs Unsafe Migration Examples

This document provides examples of migrations that are safely reversible vs those that are not.

Safe Rollback Examples (implement SafelyReversible)

1. Create New Table

use App\Framework\Database\Migration\{Migration, SafelyReversible};

/**
 * Safe: New empty table can be dropped without data loss
 */
final readonly class CreateSessionsTable implements Migration, SafelyReversible
{
    public function up(ConnectionInterface $connection): void
    {
        $schema = new Schema($connection);
        $schema->create('sessions', function ($table) {
            $table->string('id', 255)->primary();
            $table->text('data');
            $table->timestamp('created_at');
        });
        $schema->execute();
    }

    public function down(ConnectionInterface $connection): void
    {
        // Safe: Table is new, no existing data
        $schema = new Schema($connection);
        $schema->dropIfExists('sessions');
        $schema->execute();
    }
}

2. Add Nullable Column

/**
 * Safe: Adding nullable column can be dropped without data loss
 */
final readonly class AddPhoneToUsersTable implements Migration, SafelyReversible
{
    public function up(ConnectionInterface $connection): void
    {
        $schema = new Schema($connection);
        $schema->table('users', function ($table) {
            $table->string('phone', 20)->nullable();
        });
        $schema->execute();
    }

    public function down(ConnectionInterface $connection): void
    {
        // Safe: Column is nullable and likely empty or not critical
        $schema = new Schema($connection);
        $schema->table('users', function ($table) {
            $table->dropColumn('phone');
        });
        $schema->execute();
    }
}

3. Create Index

/**
 * Safe: Indexes don't affect data, only query performance
 */
final readonly class AddEmailIndexToUsers implements Migration, SafelyReversible
{
    public function up(ConnectionInterface $connection): void
    {
        $schema = new Schema($connection);
        $schema->table('users', function ($table) {
            $table->index('email', 'idx_users_email');
        });
        $schema->execute();
    }

    public function down(ConnectionInterface $connection): void
    {
        // Safe: Dropping index doesn't lose data
        $schema = new Schema($connection);
        $schema->table('users', function ($table) {
            $table->dropIndex('idx_users_email');
        });
        $schema->execute();
    }
}

4. Rename Column (Data Preserved)

/**
 * Safe: Renaming preserves all data
 */
final readonly class RenameEmailColumnInUsers implements Migration, SafelyReversible
{
    public function up(ConnectionInterface $connection): void
    {
        $schema = new Schema($connection);
        $schema->table('users', function ($table) {
            $table->renameColumn('email', 'email_address');
        });
        $schema->execute();
    }

    public function down(ConnectionInterface $connection): void
    {
        // Safe: Rename back, all data intact
        $schema = new Schema($connection);
        $schema->table('users', function ($table) {
            $table->renameColumn('email_address', 'email');
        });
        $schema->execute();
    }
}

5. Add Foreign Key

/**
 * Safe: Foreign keys are constraints, can be removed without data loss
 */
final readonly class AddUserIdForeignKey implements Migration, SafelyReversible
{
    public function up(ConnectionInterface $connection): void
    {
        $schema = new Schema($connection);
        $schema->table('orders', function ($table) {
            $table->foreign('user_id')
                ->references('id')
                ->on('users')
                ->onDelete(ForeignKeyAction::CASCADE);
        });
        $schema->execute();
    }

    public function down(ConnectionInterface $connection): void
    {
        // Safe: Removing constraint doesn't delete data
        $schema = new Schema($connection);
        $schema->table('orders', function ($table) {
            $table->dropForeign('user_id');
        });
        $schema->execute();
    }
}

Unsafe Rollback Examples (DO NOT implement SafelyReversible)

1. Drop Column with Data

/**
 * UNSAFE: Dropping column with data - NOT reversible
 * DO NOT implement SafelyReversible!
 */
final readonly class RemoveLegacyIdColumn implements Migration
{
    public function up(ConnectionInterface $connection): void
    {
        $schema = new Schema($connection);
        $schema->table('users', function ($table) {
            $table->dropColumn('legacy_id');  // Data is LOST!
        });
        $schema->execute();
    }

    // NO down() method - data cannot be recovered
}

Why Unsafe? Once the column is dropped, all data in that column is permanently lost. You cannot restore it via rollback.

Fix-Forward Instead:

// If you need to restore it, create a new migration:
final readonly class RestoreLegacyIdColumn implements Migration, SafelyReversible
{
    public function up(ConnectionInterface $connection): void
    {
        $schema = new Schema($connection);
        $schema->table('users', function ($table) {
            $table->integer('legacy_id')->nullable();
        });
        $schema->execute();

        // Note: Original data is gone, you'll need to repopulate
    }
}

2. Data Transformation

/**
 * UNSAFE: Data format transformation - original format lost
 * DO NOT implement SafelyReversible!
 */
final readonly class MigratePreferencesToJsonb implements Migration
{
    public function up(ConnectionInterface $connection): void
    {
        // Transform multiple columns into single JSONB column
        $connection->execute("
            UPDATE users
            SET preferences = jsonb_build_object(
                'theme', theme_preference,
                'notifications', notification_preference,
                'language', language_preference
            )
        ");

        $schema = new Schema($connection);
        $schema->table('users', function ($table) {
            $table->dropColumn('theme_preference', 'notification_preference', 'language_preference');
        });
        $schema->execute();
    }

    // NO down() - original column structure is gone
}

Why Unsafe? Once you've merged columns into JSONB and dropped the originals, you cannot perfectly restore the original schema structure.

3. Data Aggregation/Merge

/**
 * UNSAFE: Merging tables loses original structure
 * DO NOT implement SafelyReversible!
 */
final readonly class MergeUserProfilesIntoUsers implements Migration
{
    public function up(ConnectionInterface $connection): void
    {
        // Merge user_profiles data into users table
        $connection->execute("
            UPDATE users u
            SET bio = up.bio,
                avatar = up.avatar,
                settings = up.settings
            FROM user_profiles up
            WHERE u.id = up.user_id
        ");

        $schema = new Schema($connection);
        $schema->dropIfExists('user_profiles');
        $schema->execute();
    }

    // NO down() - user_profiles table structure is lost
}

Why Unsafe? The original user_profiles table schema and any additional data it contained is lost forever.

4. Change Column Type (Data Loss Risk)

/**
 * UNSAFE: Changing column type may lose precision/data
 * DO NOT implement SafelyReversible!
 */
final readonly class ChangeUserAgeToInteger implements Migration
{
    public function up(ConnectionInterface $connection): void
    {
        $schema = new Schema($connection);
        $schema->table('users', function ($table) {
            $table->changeColumn('age', 'integer');  // Was VARCHAR, now INT
        });
        $schema->execute();
    }

    // NO down() - original string format may have been lost during conversion
}

Why Unsafe? Converting from string to integer may lose data (e.g., "25 years" becomes 25, losing " years"). Converting back doesn't restore original format.

5. Truncate/Delete Data

/**
 * UNSAFE: Deleting data permanently
 * DO NOT implement SafelyReversible!
 */
final readonly class CleanupOldSessions implements Migration
{
    public function up(ConnectionInterface $connection): void
    {
        $connection->execute("
            DELETE FROM sessions
            WHERE created_at < NOW() - INTERVAL '90 days'
        ");
    }

    // NO down() - deleted data is gone forever
}

Why Unsafe? Deleted data cannot be recovered via rollback.


Decision Matrix: SafelyReversible or Not?

Operation SafelyReversible? Reason
Create table Yes New table can be dropped
Drop table (with data) No Data is lost
Add nullable column Yes Column likely empty
Drop column (with data) No Data is lost
Add NOT NULL column ⚠️ Maybe Only if default value is safe
Change column type No Data format may be lost
Rename column Yes Data is preserved
Create index Yes Only affects performance
Drop index Yes Only affects performance
Add foreign key Yes Only adds constraint
Drop foreign key Yes Only removes constraint
Data transformation No Original format lost
Merge tables No Original structure lost
Delete data No Data is gone
Aggregate data No Original granularity lost

Best Practices

1. Default to Forward-Only

// Default: Only implement Migration interface
final readonly class MyMigration implements Migration
{
    public function up(ConnectionInterface $connection): void
    {
        // Migration logic
    }

    // No down() unless CERTAIN it's safe
}

2. Document Why SafelyReversible

/**
 * This migration is safely reversible because:
 * - Only creates a new empty table
 * - No existing data is affected
 * - Can be dropped without data loss
 */
final readonly class CreateCacheTable implements Migration, SafelyReversible
{
    // ...
}

3. Use Fix-Forward for Errors

Instead of rollback:

# Instead of:
php console.php db:rollback 1

# Do:
php console.php make:migration FixUserTableColumnName

4. Test Rollback in Development

If implementing SafelyReversible:

# Test the full cycle in dev
php console.php db:migrate
php console.php db:rollback 1
php console.php db:migrate  # Should work again

5. Production: Forward-Only Mindset

In production, even "safe" rollbacks should be avoided:

  • Create fix-forward migration instead
  • Test thoroughly in staging first
  • Never rollback without backup
  • Document why rollback was needed

Summary

Implement SafelyReversible when:

  • Creating new tables
  • Adding nullable columns
  • Creating/dropping indexes
  • Renaming columns (data preserved)
  • Adding/removing constraints

DO NOT implement SafelyReversible when:

  • Dropping columns with data
  • Transforming data formats
  • Changing column types
  • Merging/splitting tables
  • Deleting data
  • Any operation that loses information

When in doubt: Don't implement SafelyReversible. Use fix-forward migrations instead.