Run mysql_* on PHP 8.4 with a Single-File auto_prepend_file Shim
A shim does not make old code safer. It only buys time by bringing broken pages back online while they wait to be rewritten, and it must be limited to individual sites, not enabled across the whole server.
1-minute summary
- Symptom / problem
- Old PHP sites call mysql_*, removed since PHP 7.0, so their pages are dead.
- Who should read
- People running old PHP sites on PHP 8.4 with no time to rewrite yet.
- Fix in short
- One file defines the mysql_* functions on top of mysqli, loaded per site through .user.ini with an automatic rollback. It buys time and does not make the code safer.
I had to remove all end-of-life PHP versions from a shared hosting server I manage, both 5.6 and 7.4, because of remotely exploitable vulnerabilities described in the php2shell news article. After moving the remaining sites to 8.4, I found that some pages on a few old sites, written by several customers a long time ago, were completely broken. The cause was straightforward: the code called mysql_connect(), but the mysql_* extension had been removed in PHP 7.0. The site owners did not yet have the budget for a rewrite, and editing every file was too much work for a single day. I looked for a way to get the existing pages working first without touching the customers' code.
The symptom: functions called by the code do not exist in 8.4
Every broken page showed the same message, and PHP returned 500.
PHP Fatal error: Uncaught Error: Call to undefined function mysql_connect()
The obvious shortcut is to use sed to replace the mysql_ function prefix with mysqli_ throughout the directory tree. I ruled this out immediately because the argument order is different: mysql_query($sql, $link) must become mysqli_query($link, $sql), and a lot of old code omits the link argument. Text replacement would produce code that compiles but fails at runtime, and it would also overwrite every customer file. I did not want anyone to have to take responsibility for that kind of change.
Before choosing a solution, I wanted to understand the scope of the work and where the calls to missing functions were scattered. I used grep to read through the site's entire folder without changing anything, then counted the files for each function type. I did not need a pretty list; I needed to know how many functions the shim would have to cover.
# นับไฟล์ที่เรียกฟังก์ชันที่ถูกถอด แยกตามชื่อ (อ่านอย่างเดียว)
cd /home/webuser/domains/example.com/public_html
for fn in mysql_connect mysql_query mysql_fetch_array mysql_fetch_assoc \
mysql_num_rows mysql_real_escape_string ereg split each mcrypt_encrypt; do
printf '%-26s %s\n' "$fn" "$(grep -rlE "\b${fn}[[:space:]]*\(" --include='*.php' . | wc -l)"
done
If most of the calls are in the mysql_* family and have direct mysqli equivalents, a shim is worth building. Functions without equivalents, such as mcrypt_*, show up separately and make it clear where the shim cannot help. I used these counts to discuss how much I could recover with the site owners before starting.
The idea: define the missing functions instead of changing their callers
I took the opposite approach: leave the calling code alone and make the functions it calls exist again. I wrote a single PHP file defining mysql_connect, mysql_query, and related functions on top of mysqli, then used auto_prepend_file to have PHP load it before the site's code on every request.
With PHP-FPM, this can be configured per site through a .user.ini file in the document root, without touching the pool or server configuration.
; .user.ini ในโฟลเดอร์รากของเว็บที่ต้องการ
auto_prepend_file = /usr/local/php84/lib/legacy-shim/legacy_shim.php
FPM caches .user.ini for 300 seconds by default. To avoid waiting, reload php-fpm and the change takes effect immediately.
The important thing about the file's location is that the shim must be in a folder already allowed by every pool's open_basedir. Otherwise, PHP will refuse to load it, leaving every configured page worse off than before. I placed it under the PHP 8.4 lib directory, in a subfolder containing only this file.
The table below shows the argument order differences that prevent a direct sed name replacement from working, and which the shim must handle instead.
| Old function | mysqli equivalent | Difference handled by the shim |
|---|---|---|
| mysql_query($sql, $link) | mysqli_query($link, $sql) | Argument order is reversed, and link can be optional |
| mysql_select_db($db, $link) | mysqli_select_db($link, $db) | Same reversal |
| mysql_real_escape_string($s) | mysqli_real_escape_string($link, $s) | The old function does not require a link argument |
| mysql_result($r, $row, $field) | No direct equivalent | Must seek to the row and read the field manually |
| mysql_error() | mysqli_error($link) | Must know which link was used most recently |
The shim I wrote and three decisions behind it
The file wraps every function in function_exists to avoid conflicting with the real functions if the extension ever returns. The version below is shortened to show the approach. The actual file is longer because it includes fetch, num_rows, result, escape, and many other functions.
<?php
// legacy_shim.php (ย่อ) ประกาศเฉพาะสิ่งที่ยังไม่มี
if (!function_exists('mysql_connect') && extension_loaded('mysqli')) {
$GLOBALS['__legacy_mysql_link'] = null;
function __legacy_link($link = null) {
if ($link instanceof mysqli) return $link;
return $GLOBALS['__legacy_mysql_link'] instanceof mysqli ? $GLOBALS['__legacy_mysql_link'] : null;
}
function mysql_connect($server = null, $user = null, $pass = null) {
mysqli_report(MYSQLI_REPORT_OFF); // ของเก่าคืน false ไม่โยน exception
$l = @mysqli_connect($server ?: 'localhost', $user, $pass);
if (!$l) return false;
mysqli_set_charset($l, 'latin1'); // เหมือนส่วนขยายเก่า
$GLOBALS['__legacy_mysql_link'] = $l;
return $l;
}
function mysql_query($sql, $link = null) {
$l = __legacy_link($link);
return $l ? mysqli_query($l, $sql) : false;
}
}
Three deliberate decisions I made, and why:
- Disable mysqli_report when connecting Since PHP 8.1, mysqli throws exceptions by default, but the old code assumes functions return false on failure. Without disabling this, it runs into the same issue I described in the article about mysqli throwing exceptions
- Default the charset to latin1 This matches the old extension. Thai data stored as raw bytes in a latin1 database will still display correctly. Pages that issue their own
SET NAMEScan still override this as before - Keep the latest link in a global A lot of old code calls
mysql_querywithout passing a link, so the function must find the latest connection itself, as it did before
Besides mysql_*, I also wrapped functions removed in PHP 7 and 8: ereg, eregi, and split, mapped to preg_*; get_magic_quotes_gpc, which always returns false; and each(). All of these are defined only when the function does not already exist.

Why I chose auto_prepend_file per site over other methods
I weighed four approaches before choosing. This table shows the reasons behind the decision.
| Approach | Requires changing customer files? | Scope of impact | Rollback |
|---|---|---|---|
| Replace function names with sed | Yes, every file | That site | Difficult; requires a full backup |
| Add an include at the end of every file | Yes, every file | That site | Remove it from each file |
| auto_prepend_file per site | No, only .user.ini | Only that site | Remove one line |
| auto_prepend_file in the pool's php.ini | No | Every site in the pool | Remove it and reload |
The last approach looks easiest, but it forces every site in the pool to use the shim, including properly written sites that do not need it. I did not want code that was already safe to pass through a layer emulating old behavior. Configuring it per site was therefore the only approach that affected just the broken sites and could be removed without affecting any others.
Enable it one site at a time, with rollback
Enabling this on someone else's code needs a safeguard. I wrote a script that follows the sequence in the diagram: measure the current state before changing anything, enable the shim, then measure again. If any page gets worse, it automatically removes the shim from that site alone.
My probe fetches 3 to 4 sample pages per site directly from the origin using curl --resolve, recording three things: HTTP status, response body size, and a flag indicating whether the content contains Fatal error or Call to undefined function.
probe() { # host path -> "code size errflag"
f=$(mktemp)
c=$(curl -sk -o "$f" -w '%{http_code}' --max-time 40 \
--resolve "$1:443:203.0.113.10" "https://$1$2")
e=$(grep -ciE 'Fatal error|Call to undefined function|mysql_connect' "$f")
echo "$c $(stat -c %s "$f") $e"; rm -f "$f"
}
# worse = หน้าที่เคยต่ำกว่า 500 แล้วกลายเป็น 500 ขึ้นไป -> ถอดบรรทัด shim ของเว็บนั้น
sed -i '/legacy_shim.php/d' "$DOCROOT/.user.ini"
File permissions are a small detail that is easy to get wrong. .user.ini must belong to the site's owner and be readable by the pool running that site. I set its owner to match the document root and its permissions to 644. If it is created as root and the ownership is left unchanged, some servers will refuse to read it, or the site owner will be unable to edit it themselves, which becomes a problem when it needs to be removed.
After enabling the shim, I check the pool's error log for new lines related to the shim or mysqli as well as checking HTTP status, because a page returning 200 with incorrect data will not show up in the status counts.
# ดูข้อความผิดพลาดใหม่ของ pool หลังเปิด shim (ปรับพาธ log ตามเครื่อง)
tail -n 200 /var/log/php-fpm84/error.log | grep -Ei 'mysqli|mysql_|legacy_shim' | tail -n 20
Before adding the line, I back up the site's existing .user.ini, if there is one, and save the removal command in a RESTORE file alongside it.
After enabling it, all five broken pages across the sites returned 200, and one additional issue needed fixing: a comparison written as == NA without quotation marks. In PHP 8, a bare word like this is an undefined constant and causes a fatal error. It had to be changed to 'NA', which was an actual one-line change to the site's code, unrelated to the shim.
<?php
// ก่อนแก้: PHP 7 เตือนแล้วตีความ NA เป็นสตริง แต่ PHP 8 โยน Error "Undefined constant"
if ($row['grade'] == NA) { show_none(); }
// หลังแก้: ใส่เครื่องหมายคำพูดให้เป็นสตริงตรง ๆ
if ($row['grade'] == 'NA') { show_none(); }
This is an example of why a shim is not a cure-all. It restores only the removed functions; other language behavior that became stricter in PHP 8 still needs to be checked separately. I always run probes before and after, because a page fixed by the shim may still have another problem waiting underneath, and the remaining 500 responses after enabling the shim become the list of work to follow up on.

Plan how to retire it from the start
A shim with no expiry date becomes permanent without anyone intending it to. From the moment I enable it, I treat it as a debt that needs to be paid off. My plan is to rewrite one page at a time, starting with pages containing forms or accepting user input, because those are the easiest places to exploit SQL injection. Pages that only display data come later.
Once every file in a site has stopped calling mysql_*, I test it by removing the auto_prepend_file line from that site's .user.ini and running the same probes again. If every page still returns 200, the .user.ini file can be deleted. I use the same probes when enabling and retiring the shim. I see this as a benefit of measuring with a script from the start, because it answers whether the site is ready to stop using the shim by the same criteria used when enabling it.
- Rewrite pages accepting user input with mysqli and prepared statements first
- Rewrite pages that only display data next
- Remove that site's auto_prepend_file line and run the original probes
- If every page still returns a status below 500, delete .user.ini and record that the site no longer uses the shim
Another thing I do for every site is confirm that PHP actually reads that .user.ini, rather than assuming so because the pages work again. I place a temporary PHP file with a random name in the site's document root, request it once over HTTPS, then delete it immediately. This is the same verification method I use for other PHP settings on this server, because a CLI check does not read the same file as the FPM pool.
<?php
// ลบทิ้งทันทีหลังเรียกหนึ่งครั้ง
echo json_encode([
'prepend' => ini_get('auto_prepend_file'),
'shim' => function_exists('mysql_connect'),
]);
If prepend is empty, .user.ini has not yet been read. The file may have the wrong owner or filename, or the 300-second cache may still be in effect. A shim value of true confirms that the function has actually been defined.
Limitations that need to be stated clearly
The shim reproduces the old API's behavior in full, including its unsafe parts. Old code that builds SQL strings itself without prepared statements still has exactly the same SQL injection vulnerabilities everywhere; it can simply run on 8.4 again. I do not recommend using it for new code, and I do not recommend enabling it across the whole server. It must be enabled per site, always alongside a rewrite plan.
There are two more limitations. First, mcrypt_* has no shim because there is no direct replacement. On sites still using it, pages that call it will remain broken. That part needs an actual rewrite. Second, .user.ini works only with FPM or CGI. If the site runs on mod_php, this method has no effect.
Buys time, keeps the same risk
Broken pages can be back online within minutes without touching customer files. Rollback takes one line removal, but all vulnerabilities in the old code remain.
Fixes the cause, takes time and money
Requires working through each file, changing argument order, and switching to prepared statements. It can actually close injection vulnerabilities, but the site owner has to pay and wait.
I covered the related issue of moving to a server with stricter database behavior separately in the article on MariaDB strict mode, another layer that old sites often stumble over after a move.
To sum up, a single-file shim can restore pages broken by mysql_* calls if it is enabled per site, measured before and after, and paired with automatic removal. When to rewrite depends on each site owner's budget and time. I chose to use it only for these sites, and I will remove the line from each one as its rewrite is completed. I will come back and add more later.
Want us to set this up?
Get help with server setup, configuration, migration, or troubleshooting. Save time for the work that matters.
Hit something like this? Stuck somewhere?
Come trade notes on the LinuxThai board



Comments 0
No comments yet — be the first