Legacy PHP → PHP 8.3/8.4 Modernizer
When to use me
Use this skill when a legacy PHP project (typically a custom CMS written for PHP 5.x) must:
- run on PHP 8.3 / 8.4 without a full rewrite, and/or
- be deployed to shared hosting (cPanel/Plesk) over FTP, without Docker.
Typical symptoms: mysql_*, ereg/eregi, <? short tags, PHP4-style constructors,
bareword array keys ($row[Id]), $$var['key'], sizeof(null), MyISAM dump,
TemplatePower templates, plaintext credentials in config/defines*.php.
What this skill ships
| Asset | Purpose |
|---|---|
scripts/compat.php | Shim: mysql_*→mysqli, ereg*/split→preg, userErrorHandler |
scripts/fix_barewords.php | Tokenizer-safe quoting of bareword array keys |
scripts/fix_variable_variables.php | Fixes PHP 8 $$var['key'] uniform-variable-syntax breakage |
scripts/check_signatures.php | Static LSP method-signature checker |
scripts/lint-all.sh | php -l sweep |
scripts/smoke-test.sh | HTTP smoke test of a URL list |
templates/defines.php | .env loader + DB constants (works with and without Docker) |
templates/env.example | .env template (DB_* + FTP_*) |
templates/htaccess | Modern Apache/PHP-FPM .htaccess |
templates/user.ini | PHP-FPM overrides for shared hosting |
templates/timthumb.php | Safe GD thumbnail generator (replaces vulnerable TimThumb) |
deploy/deploy.sh | FTP or rsync deploy; excludes dev files; writes remote .env |
Golden rules
- Safety first:
git init+ baseline commit before touching anything. Back up the DB. - Unblock before refactor: the shim gets the whole codebase running immediately.
- One central DB layer: swap/limit
dbClassfirst; the shim covers rawmysql_*calls. - Security early: remove dangerous tools and TimThumb as soon as the site renders.
- Verify continuously:
php -lafter every batch, then smoke-test every URL.
Procedure
0. Baseline
git init -q && git add -A && git commit -qm "baseline: legacy PHP"
find . -name '*.php' -not -path './node_modules/*' | wc -l
Save a list of real public URLs (from the DB/sitemap) for scripts/smoke-test.sh.
1. Install the compatibility shim
- Copy
scripts/compat.phpto e.g.includes/compat.php. - Load it at the very top of the bootstrap, before any legacy code:
include_once(__DIR__.'/../includes/compat.php'); - It provides
mysql_*,ereg/eregi/ereg_replace/eregi_replace/split,mysqli_report(MYSQLI_REPORT_OFF)(SQL errors returnfalseinstead of throwing), anduserErrorHandler()(suppresses PHP 5's old E_NOTICE "undefined index/variable"). - Register the handler once:
if (function_exists('userErrorHandler')) set_error_handler('userErrorHandler');
2. Mechanical fixes (scripts)
php scripts/fix_barewords.php # dry run
php scripts/fix_barewords.php --apply
php scripts/fix_variable_variables.php # dry run
php scripts/fix_variable_variables.php --apply
scripts/lint-all.sh
Then fix what lint still reports:
- Short tags:
<?→<?php(grep<\?(?!php|=|xml)). - PHP4 constructors:
function ClassName()→function __construct(). - Curly-brace string offsets:
$s{0}→$s[0]. - Stray
breakoutside loops/switches (fatal in PHP 8). - Variable-variables in
${...}: quote barewords / use${$var['k']}.
3. Signatures, static & null-safety
php scripts/check_signatures.php
For each MISMATCH, add default params to the child method (e.g. $tabla = null).
Then fix at runtime as fatals appear:
| Error | Fix |
|---|---|
ArgumentCountError: too few arguments | give trailing params defaults |
$this in static method | use self::/static:: or pass the app context |
method_exists(null, ...) | is_object($x) && method_exists($x, ...) |
Cannot access offset of type string on string | reorder is_array($x) before $x['k'] |
Undefined constant "X" | quote bareword keys / constants |
sizeof/count/array_keys(null) | is_array($x) ? ... : ..., (array)$x |
foreach(null) | foreach ((array)$x as ...) |
Accessing static property X as non static | $this->prop → self::$prop |
4. Run against the real DB and iterate
Point config at the DB, then request every URL. Fix one fatal per test until
scripts/smoke-test.sh reports zero failures and the log has no warnings.
5. Security (do early)
- Replace TimThumb with
templates/timthumb.php. - Delete
scanner.php,chmod777.php,info.php, stray debug scripts. - Make
.htaccessdeny.env,.git,config/defines.php, dotfiles. - Rotate committed DB passwords; move secrets to
.env. - Recommend CSRF tokens, CSP/HSTS, rate limiting, prepared statements.
6. Config + deployment
- Use
templates/defines.php(.envloader) andtemplates/env.example. - Use
templates/htaccessandtemplates/user.ini. - Deploy with
deploy/deploy.sh.
.env system
config/defines.php loads .env from the project root, falling back to getenv()
and finally safe defaults. This keeps Docker working (same DB_* keys) while making
FTP deployment a one-file edit.
.env (never committed):
DB_HOST=localhost
DB_NAME=cpaneluser_termospa
DB_USER=cpaneluser_termospa
DB_PASS=super-secret
DB_SUBDIRECTORIO=
Deployment (no Docker)
Shared hosting (cPanel/Plesk) checklist
- PHP: select 8.3 (or 8.1+); enable
mysqli,gd,mbstring,pdo_mysql. - DB: create DB + user, import the dump. For MySQL 8:
ExpectSET SESSION sql_mode='NO_ENGINE_SUBSTITUTION';utf8_spanish_ci→utf8mb3_spanish_ciwarnings (or run the utf8mb4 migration). - Files: upload into
public_html/(excludedocker/,tools/,router.php,_DOCS/,.git/). - Permissions: dirs
755, files644;recursos/cache/,.cache/,upload/writable (775/777). - Config: create the remote
.env(deploy.sh does this) and verifyDB_*. - Deny
.envaccess in.htaccess.
deploy.sh
./deploy.sh ftp # lftp reverse mirror
./deploy.sh ftp --dry-run
./deploy.sh rsync # rsync over SSH
It reads .env (FTP_HOST/USER/PASS/PATH/TLS, or SSH_*), stages only the runtime
files, writes a remote-only .env containing just DB_* (so FTP creds never land
on the server), uploads, and prints the permission/DB steps.
Verification checklist
-
scripts/lint-all.sh→ all clean -
scripts/smoke-test.sh urls.txt→ 0 failures - home / product / project / news / contact pages render
-
/sitemap.xmland/rssreturn valid XML -
/imagenes/thumbnail/...returns an image (GD + rewrite) -
/administracion/login/loads; login + a content manager works - contact form submits
- admin image upload writes to
upload/ - no
PHP Warning/Fatalin the error log
Gotchas that always bite
mysqli_querythrows on PHP 8.1+ by default; callmysqli_report(MYSQLI_REPORT_OFF)so legacyor die()/if($q=...)keeps working.php_flag/php_valuein.htaccesscause HTTP 500 under PHP-FPM — use.user.ini.- PHP 8's uniform variable syntax silently breaks
$$var['key'](writes the wrong variable) — runfix_variable_variables.php. include()relative paths resolve against the script dir/CWD; prefer__DIR__.- Gone in PHP 8:
mysql_*,ereg*,split,create_function,each, magic quotes. - OPcache caches old files: restart FPM/Apache after edits.
- UTF-8:
SET NAMES utf8mb4; prefer InnoDB +utf8mb4_unicode_cifor new work.
Sibling projects
These CMS projects share the same architecture. Run the same procedure, adjusting only
the bootstrap path and the thumbnail rewrite target. Copy scripts/ into the target's
tools/, run from the project root.