Major PHP version migration (7.4 → 8.3)
Major PHP migration (7.4 → 8.3) of my company's proprietary CMS.
- Codebase audit: reviewed the internal framework for breaking changes — strict typing, explicit property declarations (PHP 8.2 dynamic property deprecation), and replacement of deprecated functions. Also caught and fixed an unrelated encoding bug (utf8 → utf8mb4) during the audit.
- Validation process: tested changes locally, then on a dedicated staging site, before any production rollout.
- Controlled rollout: deployed sequentially across a dozen live client sites — each placed in scheduled maintenance mode, updated via GitHub, and regression-tested — roughly 1-2 hours per site.
- Why it matters: PHP 7.4 reached end-of-life in November 2022, meaning it no longer receives security patches — this migration moved every client site off an unsupported, unpatched version.
- PHP 8.3
- Legacy Refactoring
- Git / GitHub Workflows
- Code Audit
Code Snippet
<?php
/** * Proprietary CMS internal framework — database access layer (DB class). * Condensed before/after excerpt from the PHP 7.4 -> 8.3 migration, * showing the key changes: fixing a dynamic-property deprecation, * adding return types / union types, tightening error handling, and * one incidental bug fix spotted during the audit. Full class trimmed * to the methods that best illustrate the migration. */
// ============================================================================// BEFORE (PHP 7.4)// ============================================================================
class DB{ protected static $dbh = null;
public static function connect() { if (is_null(self::$dbh)) { try { self::$dbh = new PDO(DBPATH, DBUSER, DBPASS, [ PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8' ]); } catch (PDOException $e) { // retry logic omitted here for brevity } } return self::$dbh; }
// Runs any prepared query, returns the PDOStatement so callers can // fetch results AND read the last insert ID off the same object. public static function exec($sql, array $params = array()) { if (self::$dbh = self::connect()) { try { $stmt = self::$dbh->prepare($sql); $stmt->execute($params);
// Dynamic property assignment — deprecated as of PHP 8.2: // PDOStatement has no $lastInsertId property, so this // silently created one at runtime. $stmt->lastInsertId = self::$dbh->lastInsertId();
return $stmt; } catch (Exception $e) { setSqlError($e->getMessage()); } } return false; }}
// ============================================================================// AFTER (PHP 8.3)// ============================================================================
final class DB{ protected static ?PDO $dbh = null;
public static function connect(): ?PDO { if (is_null(self::$dbh)) { try { self::$dbh = new PDO(DBPATH, DBUSER, DBPASS, [ // Errors now raise exceptions instead of failing silently PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8mb4', ]); } catch (PDOException $e) { // retry logic omitted here for brevity } } return self::$dbh; }
// lastInsertId no longer piggybacks on the statement object — it's // its own method, called separately when a caller actually needs it. public static function exec($sql, array $params = []): false|PDOStatement { $dbh = self::connect(); if ($dbh) { try { $stmt = $dbh->prepare($sql); $stmt->execute($params); return $stmt; } catch (Exception $e) { setSqlError($e->getMessage()); } } return false; }
public static function lastInsertId(): string|false { $dbh = self::connect(); return $dbh ? $dbh->lastInsertId() : false; }}