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.
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 intent | PDO equivalent | Migration 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 input | Prepared placeholder | No 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 ↗