Emmanuel Corels
← Blog
Web Infrastructure

Migrate a legacy PHP MySQL application without a flag-day rewrite

Inventory removed mysql_* calls, introduce one PDO boundary, preserve charset and transaction behavior, parameterize values, and prove parity before cutover.

By Emmanuel Corels

A PHP upgrade fails with “Call to undefined function mysql_connect().” The old ext/mysql API was deprecated in PHP 5.5 and removed in PHP 7. It is not available in supported PHP 8 releases. The repair is not a search-and-replace of function names; connection state, result handling, escaping, transactions, error behavior and character sets all need an explicit migration.

Inventory the surface before choosing an API

rg -n --glob='*.php'   'mysql_(connect|pconnect|select_db|query|fetch_|num_rows|real_escape_string|insert_id|affected_rows|error|errno)'   app/ public/ scripts/

Classify every call by operation: connection, read, write, transaction, generated ID, error handling and dynamic SQL. Note implicit global connections and helpers that accept raw query strings. Capture representative integration tests and row counts before changing behavior.

Choose PDO or MySQLi deliberately

PHP officially supports both PDO_MySQL and MySQLi. Both provide prepared statements, transactions and current MySQL support. PDO offers a consistent object interface across database drivers; MySQLi exposes more MySQL-specific capabilities and both object/procedural styles. Pick one for the application boundary rather than mixing APIs inside one request.

Create one strict PDO connection factory

final class Database
{
    public static function connect(array $config): PDO
    {
        $dsn = sprintf(
            'mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4',
            $config['host'],
            $config['port'],
            $config['database']
        );

        return new PDO($dsn, $config['username'], $config['password'], [
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            PDO::ATTR_EMULATE_PREPARES => false,
        ]);
    }
}

Set utf8mb4 in the DSN before queries run. Do not depend on a later SET NAMES hidden in application flow. Load credentials from the approved secret system, not source control, and use a least-privileged runtime database account.

Replace interpolation with prepared values

// Legacy and vulnerable
$result = mysql_query(
    "SELECT id, email FROM users WHERE email = '$email'"
);

// Migrated
$statement = $pdo->prepare(
    'SELECT id, email FROM users WHERE email = :email'
);
$statement->execute(['email' => $email]);
$user = $statement->fetch();

Placeholders represent complete data values. They cannot stand for a table, column, operator, sort direction or comma-separated list. For dynamic identifiers, map client choices to server-owned constants:

$columns = [
    'newest' => 'created_at',
    'email' => 'email',
];
$orderBy = $columns[$requestedSort] ?? 'created_at';
$sql = "SELECT id, email FROM users ORDER BY {$orderBy} DESC";

Do not preserve weak password logic during the migration

$statement = $pdo->prepare(
    'SELECT id, password_hash FROM users WHERE email = :email'
);
$statement->execute(['email' => $email]);
$user = $statement->fetch();

if (!$user || !password_verify($password, $user['password_hash'])) {
    denyLogin();
}

Never compare plaintext passwords in SQL or migrate old concatenated password queries mechanically. Use password_hash and password_verify, then rehash on successful login when password_needs_rehash indicates the policy changed.

Translate result behavior explicitly

Legacy intentPDO equivalentMigration check
Fetch one associative row$stmt->fetch()Returns array or false
Fetch all rows$stmt->fetchAll()Memory impact on large results
Affected rows$stmt->rowCount()Do not use for portable SELECT counts
Generated ID$pdo->lastInsertId()Call on same connection at correct point
Escaped inputPrepared placeholderNo manual quoting around marker

Buffered versus unbuffered reads can change memory use and whether another query can run on the same connection. Test large exports separately from ordinary request queries.

Restore transaction boundaries rather than individual calls

$pdo->beginTransaction();
try {
    $debit = $pdo->prepare(
        'UPDATE accounts SET balance = balance - :amount WHERE id = :id'
    );
    $credit = $pdo->prepare(
        'UPDATE accounts SET balance = balance + :amount WHERE id = :id'
    );
    $debit->execute(['amount' => $amount, 'id' => $fromId]);
    $credit->execute(['amount' => $amount, 'id' => $toId]);
    $pdo->commit();
} catch (Throwable $error) {
    if ($pdo->inTransaction()) {
        $pdo->rollBack();
    }
    throw $error;
}

Confirm the storage engine supports transactions. DDL can cause implicit commits in MySQL, so keep schema migration out of application transactions.

Use an adapter to migrate in slices

Introduce a small repository or query boundary whose callers receive domain values rather than driver resources. Migrate one endpoint or command at a time, run legacy and new implementations against a scrubbed snapshot, and compare:

  • selected IDs and ordering;
  • null and numeric type behavior;
  • affected-row and generated-ID behavior;
  • Unicode round trips;
  • rollback after an injected failure;
  • duplicate-key and connection error mapping.

Cut over with observability and a rollback path

php -m | grep -E 'PDO|pdo_mysql|mysqli'
php -r 'var_dump(PDO::getAvailableDrivers());'
composer check-platform-reqs

Deploy the required extension with the runtime artifact, not manually on one host. Monitor query errors, connection counts, latency, deadlocks and character-conversion failures. Keep the previous immutable release available; do not keep both drivers issuing production writes as a casual comparison technique.

Remove the compatibility scaffolding

rg -n --glob='*.php' 'mysql_[a-z_]+' .
rg -n --glob='*.php' '(query|exec)\s*\(.*\$' app/

The second search is only a review lead, not a proof of vulnerability. Remove legacy wrappers, old credentials and unused extensions after every caller is migrated and verified.

The goal is not to make removed function names disappear. It is to replace an implicit database boundary with one whose encoding, parameters, transactions and failures are testable.

Sources: the Stack Overflow migration rationale (question by Madara’s Ghost; accepted answer by Quentin, CC BY-SA), PHP’s official Choosing an API, MySQL driver overview, and PDO prepared statement documentation.

Primary source: Review the official reference ↗