# Production Database Migration Strategy Sichere und zuverlässige Database Migration Strategies für Production Deployment des Custom PHP Frameworks. ## Migration System Overview Das Framework nutzt ein **Safe Rollback Architecture** System: ``` Migration Interface (Forward-Only) ↓ └─→ SafelyReversible Interface (Optional - nur bei safe rollback) ↓ └─→ MigrationRunner ↓ ├─→ Apply (up) └─→ Rollback (down) - nur wenn SafelyReversible ``` **Core Principle**: Migrations sind **forward-only by default**. Rollback nur wenn SICHER (no data loss). ## Safe vs Unsafe Migrations ### ✅ Safe for Rollback (implement SafelyReversible) Diese Migrations können sicher rückgängig gemacht werden: - **Creating new tables** (can be dropped without data loss) - **Adding nullable columns** (can be removed) - **Creating/dropping indexes** (no data affected) - **Renaming columns** (data preserved) - **Adding/removing foreign keys** (constraints only) - **Adding CHECK constraints** (can be removed) - **Creating empty tables** (no data to lose) ### ❌ Unsafe for Rollback (only Migration interface) Diese Migrations können NICHT sicher zurückgerollt werden: - **Dropping columns with data** (data is LOST) - **Transforming data formats** (original format lost) - **Changing column types** (data loss risk) - **Merging/splitting tables** (data restructured) - **Deleting data** (information cannot be restored) - **Complex data migrations** (multiple steps, state changes) ## Migration Implementation ### 1. Safe Migration Example ```php create(TableName::fromString('user_profiles'), function (Blueprint $table) { // Primary Key $table->string(ColumnName::fromString('ulid'), 26)->primary(); // Foreign Key to users $table->string(ColumnName::fromString('user_id'), 26); // Profile Data (nullable - safe to drop) $table->string(ColumnName::fromString('bio'))->nullable(); $table->string(ColumnName::fromString('avatar_url'))->nullable(); $table->string(ColumnName::fromString('website'))->nullable(); // Timestamps $table->timestamps(); // Foreign Key Constraint $table->foreign(ColumnName::fromString('user_id')) ->references(ColumnName::fromString('ulid')) ->on(TableName::fromString('users')) ->onDelete(ForeignKeyAction::CASCADE); // Index $table->index( ColumnName::fromString('user_id'), IndexName::fromString('idx_user_profiles_user_id') ); }); $schema->execute(); } /** * Rollback is SAFE because: * - Table is new (no existing data to lose) * - If table has data, it's from testing/staging only * - Production: Use fix-forward migration instead */ public function down(ConnectionInterface $connection): void { $schema = new Schema($connection); $schema->dropIfExists(TableName::fromString('user_profiles')); $schema->execute(); } public function getVersion(): MigrationVersion { return MigrationVersion::fromString('2024_01_15_143000'); } public function getDescription(): string { return 'Create user_profiles table'; } } ``` ### 2. Unsafe Migration Example ```php table('users', function (Blueprint $table) { // Data is LOST after this operation! $table->dropColumn('legacy_id'); }); $schema->execute(); } // NO down() method - data cannot be recovered // Use fix-forward migration if needed public function getVersion(): MigrationVersion { return MigrationVersion::fromString('2024_01_15_150000'); } public function getDescription(): string { return 'Remove deprecated legacy_id column from users table'; } } ``` ### 3. Fix-Forward Migration Example Statt unsicheren Rollback: Neue Forward-Migration erstellen. ```php table('users', function (Blueprint $table) { // Restore column (data cannot be recovered, but column structure can) $table->string('deprecated_field')->nullable(); }); $schema->execute(); } /** * SAFE: Column is empty after restoration, can be dropped */ public function down(ConnectionInterface $connection): void { $schema = new Schema($connection); $schema->table('users', function (Blueprint $table) { $table->dropColumn('deprecated_field'); }); $schema->execute(); } public function getVersion(): MigrationVersion { return MigrationVersion::fromString('2024_01_15_160000'); } public function getDescription(): string { return 'Restore deprecated_field column to users table'; } } ``` ## Migration Commands ### Console Commands ```bash # Create new migration php console.php make:migration CreateUsersTable [Domain] # Run all pending migrations php console.php db:migrate # Check migration status php console.php db:status # Rollback last migration (only if SafelyReversible) php console.php db:rollback [steps] # Test migration (dry-run) php console.php db:migrate --dry-run # Force migration (skip confirmation) php console.php db:migrate --force ``` ### Production Migration Workflow ```bash # 1. Backup database before migration ./scripts/backup-database.sh # 2. Check migration status docker exec php php console.php db:status # 3. Test migration (dry-run) docker exec php php console.php db:migrate --dry-run # 4. Apply migrations docker exec php php console.php db:migrate # 5. Verify migration success docker exec php php console.php db:status # 6. Run application health check curl -f https://your-domain.com/health || exit 1 ``` ## Migration Rollback Safety Check Der `MigrationRunner` prüft automatisch, ob eine Migration sicher rollbar ist: ```bash $ php console.php db:rollback 1 🔄 Rolling back migrations... ⚠️ Safety Check: Only migrations implementing SafelyReversible will be rolled back. ❌ Rollback failed: Migration 2024_01_15_150000 does not support safe rollback ℹ️ This migration cannot be safely rolled back. Reason: Data loss would occur during rollback. 💡 Recommendation: Create a new forward migration to undo the changes instead: php console.php make:migration FixYourChanges 📖 See docs/deployment/database-migration-strategy.md for guidelines. ``` ## Production Migration Best Practices ### 1. Pre-Migration Checklist - [ ] **Test in Staging**: Run migration in staging environment first - [ ] **Backup Database**: Create full database backup before migration - [ ] **Review SQL**: Inspect generated SQL for correctness - [ ] **Check Dependencies**: Verify migration dependencies are applied - [ ] **Monitor Resources**: Ensure sufficient disk space and memory - [ ] **Schedule Maintenance**: Plan migration during low-traffic window - [ ] **Prepare Rollback**: Have rollback plan ready (fix-forward migration) - [ ] **Team Notification**: Inform team of deployment window - [ ] **Health Checks**: Verify health check endpoints before migration ### 2. Migration Execution Strategy **Option A: Zero-Downtime Migration** (Recommended) ```bash # 1. Deploy new code (migration not yet applied) ./scripts/deploy-production.sh --skip-migrations # 2. Verify application works with old schema curl -f https://your-domain.com/health # 3. Apply backward-compatible migration docker exec php php console.php db:migrate # 4. Verify application works with new schema curl -f https://your-domain.com/health # 5. Complete deployment ``` **Option B: Maintenance Window Migration** ```bash # 1. Enable maintenance mode ./scripts/maintenance-mode.sh enable # 2. Backup database ./scripts/backup-database.sh # 3. Apply migrations docker exec php php console.php db:migrate # 4. Verify health curl -f https://your-domain.com/health # 5. Disable maintenance mode ./scripts/maintenance-mode.sh disable ``` ### 3. Backward-Compatible Migrations **Guidelines for Zero-Downtime**: ✅ **Safe Operations**: - Adding nullable columns - Creating new tables - Adding indexes (CONCURRENTLY in PostgreSQL) - Renaming columns (in two-step process) ❌ **Unsafe Operations**: - Dropping columns (old code will fail) - Renaming columns (one-step process) - Changing column types (old code may fail) - Adding NOT NULL columns (without default) **Two-Step Column Rename Example**: ```php // Step 1: Add new column (nullable) final readonly class AddNewEmailColumn implements Migration, SafelyReversible { public function up(ConnectionInterface $connection): void { $schema = new Schema($connection); $schema->table('users', function (Blueprint $table) { $table->string('email_address')->nullable(); }); $schema->execute(); // Copy data from old column $connection->execute("UPDATE users SET email_address = email WHERE email_address IS NULL"); } } // Deploy new code (uses email_address, falls back to email) // Step 2: Drop old column (after code deployed) final readonly class DropOldEmailColumn implements Migration { public function up(ConnectionInterface $connection): void { $schema = new Schema($connection); $schema->table('users', function (Blueprint $table) { $table->dropColumn('email'); }); $schema->execute(); } } ``` ### 4. Performance Considerations **Long-Running Migrations**: ```php // ❌ Blocking operation (can timeout) $table->addIndex(['email']); // Locks table during index creation // ✅ Non-blocking operation (PostgreSQL) $connection->execute("CREATE INDEX CONCURRENTLY idx_users_email ON users (email)"); ``` **Large Table Migrations**: ```php // ❌ Single UPDATE for millions of rows $connection->execute("UPDATE users SET status = 'active' WHERE status IS NULL"); // ✅ Batch processing $batchSize = 10000; $offset = 0; do { $affected = $connection->execute( "UPDATE users SET status = 'active' WHERE status IS NULL AND ulid IN ( SELECT ulid FROM users WHERE status IS NULL LIMIT ? OFFSET ? )", [$batchSize, $offset] ); $offset += $batchSize; // Small delay to reduce load usleep(100000); // 100ms } while ($affected > 0); ``` ### 5. Data Migration Patterns **Option 1: In-Migration Data Transform**: ```php final readonly class MigrateUserRoles implements Migration { public function up(ConnectionInterface $connection): void { // Schema change $schema = new Schema($connection); $schema->table('users', function (Blueprint $table) { $table->string('role')->default('user'); }); $schema->execute(); // Data migration $connection->execute(" UPDATE users SET role = CASE WHEN is_admin THEN 'admin' WHEN is_moderator THEN 'moderator' ELSE 'user' END "); // Drop old columns $schema->table('users', function (Blueprint $table) { $table->dropColumn('is_admin', 'is_moderator'); }); $schema->execute(); } } ``` **Option 2: Separate Data Migration Script**: ```php // Migration: Schema only final readonly class AddRoleColumn implements Migration, SafelyReversible { public function up(ConnectionInterface $connection): void { $schema = new Schema($connection); $schema->table('users', function (Blueprint $table) { $table->string('role')->nullable(); }); $schema->execute(); } } // Separate script: Data migration // scripts/data-migrations/migrate-user-roles.php $repository = $container->get(UserRepository::class); $users = $repository->findAll(); foreach ($users as $user) { $role = $user->isAdmin() ? 'admin' : ($user->isModerator() ? 'moderator' : 'user'); $user->setRole($role); $repository->save($user); } ``` ## Migration Testing ### 1. Local Testing ```bash # Reset database and re-run all migrations php console.php db:fresh # Run specific migration php console.php db:migrate --step=1 # Test rollback (if SafelyReversible) php console.php db:rollback --step=1 # Re-apply migration php console.php db:migrate --step=1 ``` ### 2. Staging Environment Testing ```bash # Copy production data to staging (anonymized) ./scripts/anonymize-production-data.sh # Apply migrations in staging ssh staging "cd /app && php console.php db:migrate" # Run integration tests ssh staging "cd /app && ./vendor/bin/pest --testsuite=Integration" # Verify application health curl -f https://staging.your-domain.com/health ``` ### 3. Migration Test Checklist - [ ] **Migration applies successfully** (no errors) - [ ] **Schema matches expectations** (inspect database) - [ ] **Data integrity preserved** (no data loss) - [ ] **Application health checks pass** (all endpoints work) - [ ] **Performance acceptable** (migration completes in reasonable time) - [ ] **Rollback works** (if SafelyReversible) - [ ] **Idempotency verified** (can run migration multiple times) - [ ] **Foreign key constraints intact** (referential integrity) - [ ] **Indexes created properly** (query performance maintained) ## Monitoring & Validation ### Post-Migration Health Checks ```bash # 1. Database connection test docker exec php php console.php db:test-connection # 2. Table integrity check docker exec php php console.php db:check-integrity # 3. Application health check curl -f https://your-domain.com/health # 4. Check for errors in logs docker-compose logs php | grep -i error # 5. Verify data consistency docker exec php php console.php db:verify-data ``` ### Migration Metrics **Track These Metrics**: - Migration execution time - Database size before/after - Number of affected rows - Query performance (slow query log) - Error rate (application logs) - Health check failures **Example Monitoring**: ```php // Log migration metrics $startTime = microtime(true); $sizeBeforeMB = $this->getDatabaseSize(); $migration->up($connection); $executionTimeMs = (microtime(true) - $startTime) * 1000; $sizeAfterMB = $this->getDatabaseSize(); $this->logger->info('Migration completed', [ 'migration' => $migration->getVersion()->value, 'execution_time_ms' => $executionTimeMs, 'size_before_mb' => $sizeBeforeMB, 'size_after_mb' => $sizeAfterMB, 'size_delta_mb' => $sizeAfterMB - $sizeBeforeMB, ]); ``` ## Rollback Strategy ### When to Rollback vs Fix-Forward **Rollback (if SafelyReversible)**: - Migration applied in last 5 minutes - No production traffic affected yet - Schema change only (no data migration) - Quick fix available **Fix-Forward (Recommended for Production)**: - Migration applied > 5 minutes ago - Production traffic affected - Data migrations applied - Complex schema changes - Uncertain about data loss ### Rollback Execution ```bash # Check if migration supports rollback docker exec php php console.php db:status # Rollback last migration (if safe) docker exec php php console.php db:rollback --step=1 # Verify rollback success docker exec php php console.php db:status # Check application health curl -f https://your-domain.com/health ``` ### Fix-Forward Execution ```bash # 1. Create fix-forward migration docker exec php php console.php make:migration FixBrokenMigration # 2. Implement fix in new migration # Edit: src/.../Migrations/2024_XX_XX_XXXXXX_FixBrokenMigration.php # 3. Test in staging ssh staging "cd /app && php console.php db:migrate" # 4. Apply in production docker exec php php console.php db:migrate # 5. Verify fix curl -f https://your-domain.com/health ``` ## Disaster Recovery ### Database Backup Before Migration ```bash #!/bin/bash # scripts/backup-database.sh BACKUP_DIR="/backups/database" TIMESTAMP=$(date +%Y%m%d_%H%M%S) DB_NAME="michaelschiemer_prod" # Create backup directory mkdir -p "$BACKUP_DIR" # Backup database docker exec db pg_dump -U postgres "$DB_NAME" | \ gzip > "$BACKUP_DIR/backup_${TIMESTAMP}.sql.gz" # Verify backup if [ $? -eq 0 ]; then echo "✅ Backup successful: $BACKUP_DIR/backup_${TIMESTAMP}.sql.gz" else echo "❌ Backup failed" exit 1 fi # Keep only last 30 days of backups find "$BACKUP_DIR" -name "backup_*.sql.gz" -mtime +30 -delete ``` ### Database Restore ```bash #!/bin/bash # scripts/restore-database.sh BACKUP_FILE="$1" if [ -z "$BACKUP_FILE" ]; then echo "Usage: $0 /path/to/backup.sql.gz" exit 1 fi # Confirm restore echo "⚠️ This will REPLACE the current database with backup: $BACKUP_FILE" read -p "Are you sure? (yes/no): " confirm if [ "$confirm" != "yes" ]; then echo "Restore cancelled" exit 0 fi # Drop and recreate database docker exec db psql -U postgres -c "DROP DATABASE IF EXISTS michaelschiemer_prod" docker exec db psql -U postgres -c "CREATE DATABASE michaelschiemer_prod" # Restore backup gunzip -c "$BACKUP_FILE" | docker exec -i db psql -U postgres michaelschiemer_prod echo "✅ Database restored from backup" ``` ## Troubleshooting ### Problem: Migration Times Out **Cause**: Long-running operation on large table **Solution**: ```php // Increase timeout for migration $connection->execute("SET statement_timeout = '300s'"); // Or split into smaller batches $batchSize = 10000; // ... batch processing code ``` ### Problem: Migration Fails Midway **Cause**: Error during schema change or data migration **Solution**: ```bash # Check migration status docker exec php php console.php db:status # If migration is partially applied: # 1. Manual cleanup (if needed) docker exec db psql -U postgres michaelschiemer_prod # 2. Mark migration as failed docker exec php php console.php db:reset [version] # 3. Fix migration code # 4. Re-run migration docker exec php php console.php db:migrate ``` ### Problem: Foreign Key Constraint Violation **Cause**: Data inconsistency or missing referenced rows **Solution**: ```sql -- Find orphaned rows SELECT * FROM child_table WHERE parent_id NOT IN (SELECT id FROM parent_table); -- Fix data before migration DELETE FROM child_table WHERE parent_id NOT IN (SELECT id FROM parent_table); -- Then run migration ``` ## See Also - **Production Prerequisites**: `docs/deployment/production-prerequisites.md` - **Database Patterns**: `docs/claude/database-patterns.md` - **Deployment Guide**: `docs/deployment/deployment-guide.md` (TODO) - **Rollback Guide**: `docs/deployment/rollback-guide.md` (TODO)