Building a PHP Database Migration System from Scratch: A Complete Developer’s Guide

Imagine you’re working on a team project where five developers are all making database changes. Sarah adds a new column, Mike creates an index, and Lisa drops an old table. Without a proper system to track these changes, your development environment becomes a chaotic mess of conflicting database states. This is exactly why database migrations exist, and today we’re going to build our own migration system from the ground up.

Think of database migrations like a version control system for your database schema. Just as Git tracks changes to your code, migrations track changes to your database structure. Each migration is like a commit that moves your database forward or backward through different states, ensuring everyone on your team has the exact same database structure at any given point in time.

Understanding the Core Concept: What Makes Migrations Tick?

Before we dive into coding, let’s establish a mental model of how migrations work. Picture your database as a book, and each migration as a new page that either adds content or removes it. The migration system keeps track of which pages have been read and applied to your database, ensuring you never accidentally apply the same change twice or skip important updates.

Every migration consists of two essential parts: an “up” method that applies changes and a “down” method that reverses them. This bidirectional approach allows you to move your database forward through new features or backward to previous states when something goes wrong. It’s like having both a “do” and “undo” button for every database change you make.

The migration system maintains a special table in your database that acts like a logbook, recording which migrations have been applied and when. This tracking mechanism ensures that when a new team member clones your project, they can run all pending migrations and have their database match yours exactly.

Setting Up the Foundation: Our Migration Architecture

Let’s start building our migration system by creating the fundamental structure. We’ll begin with a base migration class that defines the interface all migrations must follow, then build the management system that tracks and executes these migrations.

<?php
/**
 * Base Migration Class
 * 
 * This abstract class serves as the blueprint for all migrations.
 * Think of it as a contract that every migration must follow,
 * ensuring consistency across your entire migration system.
 */
abstract class Migration
{
    protected $connection;
    
    public function __construct(PDO $connection)
    {
        // Store the database connection so migrations can execute queries
        $this->connection = $connection;
    }
    
    /**
     * The up method defines what changes to apply to the database.
     * This might include creating tables, adding columns, or inserting data.
     */
    abstract public function up();
    
    /**
     * The down method defines how to reverse the changes made in up().
     * This is crucial for rolling back migrations when needed.
     */
    abstract public function down();
    
    /**
     * Get the unique identifier for this migration.
     * We'll use timestamps to ensure migrations run in chronological order.
     */
    abstract public function getVersion();
    
    /**
     * Helper method to execute SQL statements with proper error handling
     */
    protected function execute($sql, $params = [])
    {
        try {
            $stmt = $this->connection->prepare($sql);
            $result = $stmt->execute($params);
            
            if (!$result) {
                throw new Exception("Failed to execute: " . implode(", ", $stmt->errorInfo()));
            }
            
            return $stmt;
        } catch (PDOException $e) {
            throw new Exception("Database error: " . $e->getMessage());
        }
    }
    
    /**
     * Convenience method for executing DDL statements (CREATE, ALTER, DROP)
     * These don't typically need parameters and return different results
     */
    protected function executeStatement($sql)
    {
        try {
            return $this->connection->exec($sql);
        } catch (PDOException $e) {
            throw new Exception("Failed to execute statement: " . $e->getMessage());
        }
    }
}
?>

This base class establishes the fundamental contract that every migration must follow. Notice how we’ve included helper methods for executing SQL statements with proper error handling. This saves migration writers from dealing with PDO intricacies and ensures consistent error reporting across all migrations.

Creating the Migration Manager: The Brain of Our System

Now we need a manager class that handles the complex orchestration of migrations. This class will track which migrations have been applied, determine what needs to run, and execute migrations in the correct order.

<?php
/**
 * Migration Manager
 * 
 * This class handles all the complex logic of tracking, loading,
 * and executing migrations. Think of it as the conductor of an
 * orchestra, ensuring each migration plays at the right time.
 */
class MigrationManager
{
    private $connection;
    private $migrationsPath;
    private $migrationsTable = 'schema_migrations';
    
    public function __construct(PDO $connection, $migrationsPath)
    {
        $this->connection = $connection;
        $this->migrationsPath = rtrim($migrationsPath, '/');
        
        // Ensure our tracking table exists before we do anything else
        $this->ensureMigrationsTableExists();
    }
    
    /**
     * Create the migrations tracking table if it doesn't exist.
     * This table is like a logbook that records which migrations
     * have been applied to this database.
     */
    private function ensureMigrationsTableExists()
    {
        $sql = "
            CREATE TABLE IF NOT EXISTS {$this->migrationsTable} (
                version VARCHAR(255) PRIMARY KEY,
                executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                execution_time_ms INT DEFAULT 0
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
        ";
        
        $this->connection->exec($sql);
    }
    
    /**
     * Load all migration files from the migrations directory.
     * We'll scan for PHP files and instantiate the migration classes.
     */
    private function loadMigrationFiles()
    {
        $migrations = [];
        $files = glob($this->migrationsPath . '/*.php');
        
        if (!$files) {
            return $migrations;
        }
        
        foreach ($files as $file) {
            // Extract the class name from the filename
            // Expected format: YYYYMMDDHHMMSS_ClassName.php
            $filename = basename($file, '.php');
            $parts = explode('_', $filename, 2);
            
            if (count($parts) !== 2) {
                throw new Exception("Invalid migration filename format: {$filename}");
            }
            
            $version = $parts[0];
            $className = $parts[1];
            
            // Include the file and instantiate the migration class
            require_once $file;
            
            if (!class_exists($className)) {
                throw new Exception("Migration class {$className} not found in {$file}");
            }
            
            $migration = new $className($this->connection);
            
            if (!$migration instanceof Migration) {
                throw new Exception("Migration {$className} must extend Migration class");
            }
            
            // Verify the version matches what we expect
            if ($migration->getVersion() !== $version) {
                throw new Exception("Version mismatch in migration {$className}");
            }
            
            $migrations[$version] = $migration;
        }
        
        // Sort migrations by version to ensure proper execution order
        ksort($migrations);
        return $migrations;
    }
    
    /**
     * Get all versions that have been applied to the database.
     * This tells us which migrations we can skip.
     */
    private function getAppliedVersions()
    {
        $stmt = $this->connection->prepare(
            "SELECT version FROM {$this->migrationsTable} ORDER BY version"
        );
        $stmt->execute();
        
        return $stmt->fetchAll(PDO::FETCH_COLUMN);
    }
    
    /**
     * Record that a migration has been successfully applied.
     * We track both when it ran and how long it took for performance monitoring.
     */
    private function recordMigration($version, $executionTimeMs)
    {
        $stmt = $this->connection->prepare(
            "INSERT INTO {$this->migrationsTable} (version, executed_at, execution_time_ms) 
             VALUES (?, NOW(), ?)"
        );
        
        $stmt->execute([$version, $executionTimeMs]);
    }
    
    /**
     * Remove a migration record when rolling back.
     * This allows the migration to be applied again in the future.
     */
    private function removeMigrationRecord($version)
    {
        $stmt = $this->connection->prepare(
            "DELETE FROM {$this->migrationsTable} WHERE version = ?"
        );
        
        $stmt->execute([$version]);
    }
    
    /**
     * Run all pending migrations.
     * This is the main method developers will use to update their database.
     */
    public function migrate()
    {
        $allMigrations = $this->loadMigrationFiles();
        $appliedVersions = $this->getAppliedVersions();
        
        // Find migrations that haven't been applied yet
        $pendingMigrations = array_diff_key($allMigrations, array_flip($appliedVersions));
        
        if (empty($pendingMigrations)) {
            echo "No pending migrations found.\n";
            return;
        }
        
        echo "Found " . count($pendingMigrations) . " pending migration(s):\n";
        
        foreach ($pendingMigrations as $version => $migration) {
            echo "Migrating: {$version}... ";
            
            try {
                // Use transactions to ensure migration atomicity
                $this->connection->beginTransaction();
                
                $startTime = microtime(true);
                $migration->up();
                $endTime = microtime(true);
                
                $executionTime = round(($endTime - $startTime) * 1000); // Convert to milliseconds
                $this->recordMigration($version, $executionTime);
                
                $this->connection->commit();
                echo "OK ({$executionTime}ms)\n";
                
            } catch (Exception $e) {
                $this->connection->rollBack();
                echo "FAILED\n";
                throw new Exception("Migration {$version} failed: " . $e->getMessage());
            }
        }
        
        echo "All migrations completed successfully.\n";
    }
    
    /**
     * Roll back the most recent migration.
     * This is useful when you need to undo recent changes.
     */
    public function rollback($steps = 1)
    {
        $appliedVersions = $this->getAppliedVersions();
        
        if (empty($appliedVersions)) {
            echo "No migrations to roll back.\n";
            return;
        }
        
        // Get the most recent migrations to roll back
        $versionsToRollback = array_slice(array_reverse($appliedVersions), 0, $steps);
        $allMigrations = $this->loadMigrationFiles();
        
        echo "Rolling back " . count($versionsToRollback) . " migration(s):\n";
        
        foreach ($versionsToRollback as $version) {
            if (!isset($allMigrations[$version])) {
                throw new Exception("Cannot find migration file for version: {$version}");
            }
            
            echo "Rolling back: {$version}... ";
            
            try {
                $this->connection->beginTransaction();
                
                $migration = $allMigrations[$version];
                $migration->down();
                $this->removeMigrationRecord($version);
                
                $this->connection->commit();
                echo "OK\n";
                
            } catch (Exception $e) {
                $this->connection->rollBack();
                echo "FAILED\n";
                throw new Exception("Rollback of {$version} failed: " . $e->getMessage());
            }
        }
        
        echo "Rollback completed successfully.\n";
    }
    
    /**
     * Show the current migration status.
     * This helps developers understand what state their database is in.
     */
    public function status()
    {
        $allMigrations = $this->loadMigrationFiles();
        $appliedVersions = array_flip($this->getAppliedVersions());
        
        if (empty($allMigrations)) {
            echo "No migrations found.\n";
            return;
        }
        
        echo "Migration Status:\n";
        echo str_repeat("-", 50) . "\n";
        
        foreach ($allMigrations as $version => $migration) {
            $status = isset($appliedVersions[$version]) ? "Applied" : "Pending";
            echo "{$version}: {$status}\n";
        }
    }
}
?>

This manager class handles all the complexity of tracking and executing migrations. Notice how we use database transactions to ensure that each migration either completes fully or fails cleanly. The status tracking allows developers to see exactly which migrations have been applied, making debugging much easier.

Creating Your First Migration: A Practical Example

Now let’s create an actual migration to see our system in action. Migrations should have descriptive names and follow a consistent naming convention that includes both timestamp and purpose.

<?php
/**
 * Migration: 20240101120000_CreateUsersTable.php
 * 
 * This migration creates the foundation users table for our application.
 * Notice how the filename includes a timestamp (20240101120000) followed
 * by a descriptive name (CreateUsersTable).
 */
class CreateUsersTable extends Migration
{
    /**
     * Return the version timestamp.
     * This must match the timestamp in the filename.
     */
    public function getVersion()
    {
        return '20240101120000';
    }
    
    /**
     * Apply the migration - create the users table.
     * We're building a comprehensive user system with proper indexing.
     */
    public function up()
    {
        $sql = "
            CREATE TABLE users (
                id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
                username VARCHAR(50) NOT NULL UNIQUE,
                email VARCHAR(255) NOT NULL UNIQUE,
                password_hash VARCHAR(255) NOT NULL,
                first_name VARCHAR(100),
                last_name VARCHAR(100),
                is_active BOOLEAN DEFAULT TRUE,
                email_verified_at TIMESTAMP NULL,
                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
                
                -- Indexes for common query patterns
                INDEX idx_username (username),
                INDEX idx_email (email),
                INDEX idx_active (is_active),
                INDEX idx_created_at (created_at)
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
        ";
        
        $this->executeStatement($sql);
        
        // Add some initial admin user data
        $this->execute(
            "INSERT INTO users (username, email, password_hash, first_name, last_name, is_active) 
             VALUES (?, ?, ?, ?, ?, ?)",
            ['admin', 'admin@example.com', password_hash('admin123', PASSWORD_DEFAULT), 'System', 'Administrator', true]
        );
        
        echo "    - Created users table with indexes\n";
        echo "    - Added default admin user\n";
    }
    
    /**
     * Reverse the migration - remove the users table.
     * Always be careful with down migrations that destroy data!
     */
    public function down()
    {
        $this->executeStatement("DROP TABLE IF EXISTS users");
        echo "    - Dropped users table\n";
    }
}
?>

This migration demonstrates several important practices. We create comprehensive table structures with proper indexes, include helpful comments explaining our decisions, and provide informative output so developers can see what’s happening. The down method safely reverses all changes made in the up method.

Advanced Migration: Adding Columns and Indexes

Let’s create a more complex migration that modifies existing table structures. This type of migration is common as your application evolves and requires new features.

<?php
/**
 * Migration: 20240101130000_AddUserProfileFields.php
 * 
 * This migration adds social profile fields to our users table.
 * It demonstrates how to safely modify existing table structures.
 */
class AddUserProfileFields extends Migration
{
    public function getVersion()
    {
        return '20240101130000';
    }
    
    /**
     * Add new profile fields to support social features.
     * We're using ALTER TABLE statements to modify the existing structure.
     */
    public function up()
    {
        // Add the new columns
        $alterSql = "
            ALTER TABLE users 
            ADD COLUMN bio TEXT,
            ADD COLUMN avatar_url VARCHAR(500),
            ADD COLUMN location VARCHAR(100),
            ADD COLUMN website VARCHAR(200),
            ADD COLUMN date_of_birth DATE,
            ADD COLUMN privacy_level ENUM('public', 'friends', 'private') DEFAULT 'public'
        ";
        
        $this->executeStatement($alterSql);
        
        // Add indexes for fields that will be queried frequently
        $indexSql = "
            ALTER TABLE users
            ADD INDEX idx_location (location),
            ADD INDEX idx_privacy_level (privacy_level),
            ADD INDEX idx_date_of_birth (date_of_birth)
        ";
        
        $this->executeStatement($indexSql);
        
        echo "    - Added profile fields: bio, avatar_url, location, website, date_of_birth, privacy_level\n";
        echo "    - Created indexes for location, privacy_level, and date_of_birth\n";
    }
    
    /**
     * Remove the profile fields we added.
     * Note: This will permanently delete any data in these fields!
     */
    public function down()
    {
        // Drop indexes first (some MySQL versions require this)
        $dropIndexesSql = "
            ALTER TABLE users
            DROP INDEX idx_location,
            DROP INDEX idx_privacy_level,
            DROP INDEX idx_date_of_birth
        ";
        
        $this->executeStatement($dropIndexesSql);
        
        // Then drop the columns
        $dropColumnsSql = "
            ALTER TABLE users
            DROP COLUMN bio,
            DROP COLUMN avatar_url,
            DROP COLUMN location,
            DROP COLUMN website,
            DROP COLUMN date_of_birth,
            DROP COLUMN privacy_level
        ";
        
        $this->executeStatement($dropColumnsSql);
        
        echo "    - Removed profile fields and their indexes\n";
        echo "    - WARNING: All profile data has been permanently deleted\n";
    }
}
?>

This migration shows how to safely modify existing tables. Notice how we add indexes after creating columns and drop them before removing columns in the down method. This prevents potential MySQL errors and ensures clean reversibility.

Building the Command Line Interface

A migration system needs an easy way for developers to interact with it. Let’s create a simple command-line interface that makes running migrations as easy as typing a single command.

<?php
/**
 * Migration CLI Tool: migrate.php
 * 
 * This command-line interface provides easy access to migration functionality.
 * Usage: php migrate.php [command] [options]
 */

// Include our migration classes
require_once 'Migration.php';
require_once 'MigrationManager.php';

// Database configuration - in production, load this from environment variables
$config = [
    'host' => 'localhost',
    'dbname' => 'your_database',
    'username' => 'your_username',
    'password' => 'your_password'
];

/**
 * Create database connection with proper error handling
 */
function createDatabaseConnection($config)
{
    try {
        $dsn = "mysql:host={$config['host']};dbname={$config['dbname']};charset=utf8mb4";
        $pdo = new PDO($dsn, $config['username'], $config['password']);
        
        // Configure PDO for optimal error handling
        $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
        
        return $pdo;
    } catch (PDOException $e) {
        die("Database connection failed: " . $e->getMessage() . "\n");
    }
}

/**
 * Display help information for users
 */
function showHelp()
{
    echo "PHP Database Migration System\n";
    echo "============================\n\n";
    echo "Usage: php migrate.php [command] [options]\n\n";
    echo "Available commands:\n";
    echo "  migrate     Run all pending migrations\n";
    echo "  rollback    Roll back the last migration\n";
    echo "  rollback N  Roll back the last N migrations\n";
    echo "  status      Show migration status\n";
    echo "  help        Show this help message\n\n";
    echo "Examples:\n";
    echo "  php migrate.php migrate\n";
    echo "  php migrate.php rollback\n";
    echo "  php migrate.php rollback 3\n";
    echo "  php migrate.php status\n";
}

// Parse command line arguments
$command = isset($argv[1]) ? $argv[1] : 'help';
$argument = isset($argv[2]) ? $argv[2] : null;

// Create database connection and migration manager
$pdo = createDatabaseConnection($config);
$migrationManager = new MigrationManager($pdo, __DIR__ . '/migrations');

echo "PHP Database Migration System\n";
echo "============================\n\n";

try {
    switch ($command) {
        case 'migrate':
            $migrationManager->migrate();
            break;
            
        case 'rollback':
            $steps = is_numeric($argument) ? (int)$argument : 1;
            $migrationManager->rollback($steps);
            break;
            
        case 'status':
            $migrationManager->status();
            break;
            
        case 'help':
        default:
            showHelp();
            break;
    }
    
} catch (Exception $e) {
    echo "Error: " . $e->getMessage() . "\n";
    exit(1);
}

echo "\n";
?>

This command-line interface makes the migration system accessible and user-friendly. Developers can quickly check migration status, run pending migrations, or roll back recent changes with simple commands.

Best Practices for Migration Success

Creating effective migrations requires following several important principles that prevent common pitfalls and ensure long-term maintainability. Always make your migrations atomic, meaning each migration should represent a single, complete change that can be applied or reversed cleanly.

Never edit existing migrations after they’ve been applied in production environments. Once a migration has been deployed, treat it as immutable history. If you need to make changes, create a new migration that modifies the previous work.

Be extremely careful with down migrations that destroy data. Consider adding warnings or confirmation prompts for destructive operations. In production environments, you might want to create data backups before running rollbacks.

Test your migrations thoroughly in development environments before deploying to production. Both the up and down methods should be tested to ensure they work correctly and don’t cause unexpected side effects.

Use descriptive names for your migrations that clearly communicate their purpose. Future developers (including yourself) will thank you for clear, meaningful migration names that explain what changes they make.

Handling Complex Migration Scenarios

Real-world applications often require more sophisticated migration patterns. Let’s explore how to handle data transformations, large table modifications, and dependency management between migrations.

<?php
/**
 * Migration: 20240101140000_MigrateUserPasswordSystem.php
 * 
 * This migration demonstrates complex data transformation.
 * We're upgrading from a simple password field to a more secure
 * system with salt and stronger hashing algorithms.
 */
class MigrateUserPasswordSystem extends Migration
{
    public function getVersion()
    {
        return '20240101140000';
    }
    
    public function up()
    {
        // Step 1: Add new columns for the improved password system
        $this->executeStatement("
            ALTER TABLE users 
            ADD COLUMN password_hash_new VARCHAR(255),
            ADD COLUMN password_salt VARCHAR(255),
            ADD COLUMN password_algorithm VARCHAR(50) DEFAULT 'bcrypt'
        ");
        
        echo "    - Added new password system columns\n";
        
        // Step 2: Migrate existing password data
        // This demonstrates how to handle data transformation during migrations
        $users = $this->execute("SELECT id, password_hash FROM users WHERE password_hash IS NOT NULL");
        
        foreach ($users->fetchAll() as $user) {
            // Generate new salt and re-hash existing passwords
            $salt = bin2hex(random_bytes(16));
            $newHash = password_hash($user['password_hash'] . $salt, PASSWORD_BCRYPT);
            
            $this->execute(
                "UPDATE users SET password_hash_new = ?, password_salt = ?, password_algorithm = ? WHERE id = ?",
                [$newHash, $salt, 'bcrypt', $user['id']]
            );
        }
        
        echo "    - Migrated " . $users->rowCount() . " user passwords to new system\n";
        
        // Step 3: Remove old password column and rename new one
        $this->executeStatement("ALTER TABLE users DROP COLUMN password_hash");
        $this->executeStatement("ALTER TABLE users CHANGE password_hash_new password_hash VARCHAR(255) NOT NULL");
        
        echo "    - Replaced old password column with new secure system\n";
    }
    
    public function down()
    {
        // This rollback is intentionally limited because we can't recover
        // the original password hashes after they've been transformed
        echo "    - WARNING: Cannot fully reverse password system migration\n";
        echo "    - Original password hashes cannot be recovered\n";
        
        // We can restore the table structure but not the original data
        $this->executeStatement("
            ALTER TABLE users 
            ADD COLUMN old_password_hash VARCHAR(255),
            DROP COLUMN password_salt,
            DROP COLUMN password_algorithm
        ");
        
        $this->executeStatement("
            ALTER TABLE users 
            CHANGE password_hash password_hash_temp VARCHAR(255)
        ");
        
        $this->executeStatement("
            ALTER TABLE users 
            CHANGE old_password_hash password_hash VARCHAR(255)
        ");
        
        $this->executeStatement("
            ALTER TABLE users 
            DROP COLUMN password_hash_temp
        ");
        
        echo "    - Restored table structure (data transformation cannot be reversed)\n";
    }
}
?>

This migration demonstrates several advanced concepts including data transformation, handling irreversible changes, and providing clear warnings about data loss during rollbacks.

Integration with Your Development Workflow

Your migration system becomes most powerful when integrated smoothly into your development workflow. Consider creating helper scripts that automatically run migrations during deployment, or integrate migration checks into your application’s startup process.

You might want to add a migration verification system that checks if pending migrations exist and warns developers before they start working with an outdated database schema. This prevents the common problem of developers unknowingly working with different database structures.

For team environments, establish clear conventions about when and how to create migrations. Some teams prefer creating migrations immediately when making schema changes, while others batch related changes into single migrations. Choose an approach that works for your team and stick to it consistently.

Extending Your Migration System

The migration system we’ve built provides a solid foundation, but real-world applications often need additional features. You might want to add support for seeding initial data, creating database views, managing stored procedures, or handling database-specific features like triggers and functions.

Consider adding migration validation that checks for common mistakes like missing down methods, duplicate version numbers, or migrations that reference non-existent tables. These checks can prevent deployment issues and catch problems during development.

You might also want to add support for environment-specific migrations, allowing different database changes for development, staging, and production environments. This flexibility helps manage complex deployment scenarios where different environments have different requirements.

Conclusion: Building Confidence in Database Changes

Building your own migration system from scratch provides deep understanding of how database versioning works and gives you complete control over your schema management process. The system we’ve created handles the core challenges of migration management: tracking applied changes, ensuring proper ordering, providing rollback capabilities, and maintaining data integrity through transactions.

This foundation will serve you well as your applications grow and evolve. You’ll find that having reliable, repeatable database changes makes development much more confident and deployment much less stressful. Your team can collaborate effectively knowing that everyone can achieve the same database state with simple commands.

The patterns and principles we’ve explored here extend beyond just database migrations. The concepts of versioned changes, atomic operations, and reversible transformations apply to many aspects of software development. Understanding these concepts deeply will make you a better developer and architect.

Remember that migrations are about much more than just technical implementation. They’re about creating a shared understanding of how your application’s data layer evolves over time. Good migrations communicate intent clearly, handle edge cases gracefully, and provide safety nets for when things go wrong.

As you continue building and refining your migration system, always keep the human element in mind. Future developers will need to understand and work with the migrations you create today. Clear naming, comprehensive comments, and thoughtful error handling will make your migration system not just functional, but truly helpful for your entire development team.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top