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
<?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;
    }
}