Legacy Site Returns 500 on the Second Daily Visit After PHP 8.1
A bug that appears only on the second visit of the day will slip past any test that opens each page just once. The cause is the mysqli default that changed in PHP 8.1, combined with legacy code that had relied on its silence for ten years.
1-minute summary
- Symptom / problem
- An old site returns 500 only on the second visit of the day after moving to PHP 8.1.
- Who should read
- Anyone moving an old PHP site to PHP 8.1 or newer.
- Fix in short
- mysqli now throws exceptions by default. Change the duplicate INSERT to INSERT IGNORE, and test twice after every PHP upgrade.
After I removed the old PHP versions from a DirectAdmin server and moved the remaining sites to a newer version, several old custom-built sites belonging to one client started returning 500 to some visitors, even though I had checked every page and received 200 across the board. Something seemed off, so I checked again immediately, and this time the same page returned 500. It was one of the strangest symptoms I had seen: same page, same IP, first visit works, second visit crashes.
The domain names, table names, and users in this article are all examples. I have kept only the numbers and the mechanics of the problem.
Symptoms: First Visit 200, Second Visit 500
The same code had been copied across roughly eight sites. Every page included a small visitor-counting module at the top. Here is what happened when I opened the same page twice in a row from the same machine.
$ for i in 1 2; do curl -s -o /dev/null -w '%{http_code}\n' https://example.com/index.php; done
200
500
I first worked through the usual causes I had encountered during PHP migrations, such as removed functions or missing extensions. But those cause a failure every time, not just on the second visit. The useful evidence came from the PHP error log.
PHP Fatal error: Uncaught mysqli_sql_exception: Duplicate entry '203.0.113.7-<date>' for key 'uq_ip_day'
in .../inc/counter.php
That message tells the whole story. The failing statement was an INSERT in the visitor-counting module. It hit a duplicate key, and the newer PHP version threw an exception that nothing caught.
The Cause: The Visitor Counter and a Changed Default
This module is very simple. Whenever someone visits a page, it INSERTs an (ip, day) row into a table, which has a unique key across those two columns so that each IP is counted once per day. The first visit of the day from a new IP can therefore always insert a row. The second is a duplicate.
<?php
// inc/counter.php — โมดูลนับผู้เข้าชมแบบเก่า (ย่อ ใช้ชื่อสมมติ)
$db = mysqli_connect('localhost', 'webuser', '<DB_PASS>', 'app_a');
$ip = $_SERVER['REMOTE_ADDR'];
// ตาราง visitors มี UNIQUE KEY (ip, day)
mysqli_query($db, "INSERT INTO visitors (ip, day) VALUES ('$ip', CURDATE())");
// ไม่มีการเช็กค่าที่คืนมา เพราะแต่ก่อนไม่เคยมีใครต้องเช็ก
On PHP versions before 8.1, a duplicate INSERT received error 1062 from MariaDB, but mysqli_query() simply returned false silently, with no effect on the page. It had worked this way for years, and nobody knew it was hitting duplicates every day.
Since PHP 8.1, the default for mysqli_report() has changed to MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT, which means every mysqli error is thrown as a mysqli_sql_exception. Without a try/catch, it immediately becomes a fatal error for the page. I verified this setting on PHP 8.4 on my own machine with one line.
$ php -r 'echo (new mysqli_driver)->report_mode, "\n";'
3
$ php -r 'mysqli_report(MYSQLI_REPORT_OFF); echo (new mysqli_driver)->report_mode, "\n";'
0
The value 3 is ERROR (1) combined with STRICT (2), while 0 disables reporting, which is the behavior the legacy code had relied on all along. I will not claim an exact default value for the older version; I will just say that this code was written on the assumption that it would stay silent.

This symptom has nothing to do with MariaDB strict mode causing error 1364/1366 after a server migration (I covered that in Legacy Apps Break After a Server Migration — Error 1364/1366 and MariaDB Strict Mode). Here, SQL has been returning the same 1062 for years. What changed is PHP, which stopped swallowing the error.
Why the Upgrade Checks Missed It
Because the first INSERT of the day from a new IP always succeeds. A single curl request, opening a browser once, or a script that visits every page in one pass all count as a "first visit of the day," and every page returns 200. The people who hit the bug are real visitors who click through to a second page or come back again, which happens all the time.
Something else can hide it too: if the IP seen by the module changes on every request, for example through a proxy or a crawler that rotates IPs, there will never be a duplicate row. Some external tests therefore pass completely even though the pages are broken for real people.
A Second Problem: Tables That Never Existed
While checking the other sites using the same module, I found four that returned 500 on every visit, not just the second. The cause was that their databases had no visitor-counting table at all. The old PHP version had swallowed the "table doesn't exist" error for years, and nobody knew the counter had never counted anything. Once the error was no longer swallowed, these sites crashed on the first visit.
The cause belonged to the same family: an error that used to be swallowed could now stop the page. I fixed it by creating the missing tables and applying the same INSERT fix as on the first site.
The Fix: Make "Duplicate" Stop Being an Error
The module is meant to count each IP once per day, so a duplicate is not an error at all. It is the intended behavior. INSERT IGNORE makes the statement match that intent.
-- ตารางตามที่โมดูลต้องการ (สร้างให้เว็บที่ขาด)
CREATE TABLE IF NOT EXISTS visitors (
ip VARCHAR(45) NOT NULL,
day DATE NOT NULL,
UNIQUE KEY uq_ip_day (ip, day)
);
-- แก้ในโมดูล: ซ้ำก็ข้าม ผลที่ได้คือหนึ่งแถวต่อ IP ต่อวันเหมือนเดิม
INSERT IGNORE INTO visitors (ip, day) VALUES ('203.0.113.7', CURDATE());
I updated every copy of the module by searching for the table name across every public_html tree, because this code had been copied to multiple places rather than using a shared file.
grep -rl "INSERT INTO visitors" /home/*/domains/*/public_html/ 2>/dev/null
# แล้วแก้เป็น INSERT IGNORE INTO ทีละไฟล์ (สำรองก่อนแก้)
There are other options worth knowing about. The first is to wrap the specific operation where duplicates are expected in a try/catch for mysqli_sql_exception. The second is to call mysqli_report(MYSQLI_REPORT_OFF) at the top of the file to restore the old behavior for the whole page. The second option works as a temporary measure for legacy apps that cannot be fixed in time, but I do not recommend it as a permanent solution because it also hides errors that really should be visible.
<?php
// ทางเลือก: จับเฉพาะ duplicate (ตรวจรหัส 1062) ให้ error อย่างอื่นยังหยุดหน้าตามปกติ
try {
mysqli_query($db, "INSERT INTO visitors (ip, day) VALUES ('$ip', CURDATE())");
} catch (mysqli_sql_exception $e) {
if ($e->getCode() !== 1062) { throw $e; }
}
Fix the semantics
- Duplicates are intended behavior
- Skip silently, but only duplicate rows
- Other errors still throw as usual
Silence the whole page
- Restore older PHP behavior
- Hide real errors too
- Use only as a temporary measure
Find Code with the Same Risk Before Users Do
Duplicate INSERTs are just one pattern. Legacy code that relies on mysqli staying silent takes several other forms, so I started with a rough grep search before working through the files individually. I did not expect complete coverage, just an indication of which files to open first.
# 1) คำสั่งที่เรียกโดดๆ ไม่เช็กค่าที่คืนมา
grep -rnE '^\s*mysqli_query\(' --include=*.php /home/webuser/public_html | head -50
# 2) @ หน้าฟังก์ชัน mysqli (ใน PHP 8 มันไม่ช่วยกัน exception)
grep -rnE '@\s*mysqli_' --include=*.php /home/webuser/public_html
# 3) การเช็กการต่อฐานข้อมูลแบบเก่าที่ต่อไปจะไม่ถูกรัน
grep -rnE 'if *\( *! *\$(conn|db|link)' --include=*.php /home/webuser/public_html
The second item is something many people misunderstand. The @ before a function suppresses only warnings and notices, not exceptions. So code that used @mysqli_query(...) to keep things quiet will still crash on newer PHP versions. I have seen legacy code with @ on almost every line that talked to the database, because the author knew errors were possible and chose to silence them. That approach worked in its time, and no longer does.
The grep results are not a verdict. I use them as a list of files to read and test further. The indispensable part is still making two passes of requests to the actual pages, as described in the next section.
A Testing Rule for Every PHP Upgrade
The lesson I took from this is that testing after an upgrade needs a second pass. I wrote a script to crawl every page twice from the same machine, record each page's status, and diff the results. Any page that returns 200 on the first pass but 5xx on the second has this kind of second-visit bug.
#!/usr/bin/env python3
# two_pass.py urls.txt — เปิดทุก URL สองรอบจาก IP เดียวกัน แล้วเทียบ status
import sys, urllib.request, urllib.error
def status(u):
try:
return urllib.request.urlopen(u, timeout=20).status
except urllib.error.HTTPError as e:
return e.code
except Exception:
return 0
urls = [l.strip() for l in open(sys.argv[1]) if l.strip() and not l.startswith('#')]
r1 = {u: status(u) for u in urls}
r2 = {u: status(u) for u in urls}
bad = 0
for u in urls:
if r1[u] < 500 and r2[u] >= 500:
print(f'[ERR] {u} รอบ1={r1[u]} รอบ2={r2[u]}')
bad += 1
print(f'[{"FAIL" if bad else "OK"}] ผิดปกติ {bad} จาก {len(urls)} หน้า')
sys.exit(1 if bad else 0)
The thing to watch is that both passes must come from the same IP. If the site is behind Cloudflare, test the origin directly; otherwise, the IP the module sees will belong to a proxy and keep changing. This is the sequence I use after every upgrade.
- Test every page twice from the same IP, then diff the statuses
- Search the PHP error log for
mysqli_sql_exceptionafter the test passes - Search legacy code for INSERTs into tables with unique keys, because those are where duplicates can occur
- Search for
if (!$conn)aftermysqli_connect, which will no longer work
The third item is another behavior changed by the same default. A failed database connection now throws an exception too, rather than returning false as before. Code written as if (!$conn) { echo 'ต่อไม่ได้'; } will never reach that line.

To Sum Up
Most legacy code was not "correct"; it had simply "never been told it was wrong." Some things that had been wrong all along, such as tables that never existed, only came to light when PHP's defaults changed. I am not saying that code was badly written, but upgrading PHP gives us a free code review, and I have to accept all of its findings.
These are the points I will keep using.
- The default for
mysqli_report()changed in PHP 8.1. Every mysqli error becomes an exception, and if nothing catches it, the page crashes. - This kind of bug appears only under repeat conditions, such as the second visit of the day from the same IP. Opening a page once will never find it.
- Errors that legacy code used to swallow, such as tables that never existed, are old debts coming due during an upgrade, not new bugs.
INSERT IGNOREfixes the semantics, whilemysqli_report(MYSQLI_REPORT_OFF)is only a temporary measure.- After any PHP upgrade, request every page twice from the same IP and compare the statuses before calling it done.
After the fix, every affected site returned 200 on both the first and subsequent visits. I will run two_pass.py after every major PHP upgrade. Use it if it suits you, but without a second pass, this kind of bug will wait for real users to find it instead. If I have left anything out or made a typo, I will come back and correct it 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