10 PHP Bugs That Break Real Projects — And How to Fix Them (Part 2)
Real bugs from real PHP projects — with broken code, correct fix, and clear explanation

Every PHP developer has lived this moment. The code works perfectly on localhost. You push to production. Something silently breaks — no error, no log, just wrong output and a few wasted hours tracing the problem.
These are not bugs pulled from a textbook. They come from actual PHP projects — login systems, e-commerce stores, admin panels, school management portals, and APIs. Bugs that pass code review because they look correct at first glance.
This is Part 2 of the PHP Real-Time Bugs series on CodePractice, covering Bug #11 through Bug #20. Each bug includes the broken code, the correct fix, and a clear explanation of what actually goes wrong on a live server.
https://youtube.com/shorts/BpE1WmEfoUs?si=HTNpTou8FLepcuyn
Bug #11 — Infinite Loop from a Missing Counter Increment
// ❌ Wrong
$i = 0;
while($i < 10) {
echo $i;
// forgot $i++
}
// ✅ Correct
$i = 0;
while($i < 10) {
echo $i;
$i++;
}
What actually goes wrong
This looks embarrassingly simple, but it happens more than developers like to admit — especially when copy-pasting loop code and forgetting to adjust the increment line.
On a live server, this locks up the PHP process entirely. That request never completes. On shared hosting or low-memory servers, this can take down the entire application for all users until the process times out. If the loop also touches a database, you are now firing thousands of repeated queries. One missing line — enormous consequences.
Lesson: Always test loop termination conditions before pushing. If a loop queries a database inside the body, an infinite loop also means thousands of repeated database hits.
Bug #12 — String Comparison Failing Because of Letter Case
// ❌ Wrong — "admin" or "ADMIN" will not match
if($_POST['role'] == "Admin") {
// access granted only for exact "Admin"
}
// ✅ Correct
if(strtolower($_POST['role']) == "admin") {
// matches admin, Admin, ADMIN, AdMiN
}
What actually goes wrong
PHP string comparisons are case-sensitive by default. "Admin" and "admin" are two completely different values as far as PHP is concerned.
In a real project, users submit form data in any casing they feel like. If your role-based access control depends on this comparison, some users will be incorrectly denied — or worse, incorrectly granted access.
strtolower() normalizes input before comparing. For maximum safety, also store the value in lowercase in your database so both sides of the comparison are always in the same format.
Bug #13 — Integer Cast Cutting Off Decimals in Division
// ❌ Wrong
$result = 5 / 2;
echo (int)$result; // Output: 2 — not 2.5
// ✅ Correct
echo $result; // 2.5
echo number_format($result, 2); // 2.50 — for display
echo round($result, 2); // 2.5 — for calculation
What actually goes wrong
PHP returns 2.5 for 5 / 2 — that is correct behavior. The bug is the (int) cast applied afterward. Casting a float to an integer in PHP truncates, it does not round. So 2.9 becomes 2, 9.99 becomes 9.
In a billing system, GST calculation, or cart total — this kind of truncation produces real financial errors on every single invoice. A product priced at ₹999 with 18% GST gives ₹179.82. Cast that to (int) anywhere in the chain and your invoice is wrong.
Use round() for rounding to decimal places. Use number_format() for display output. Never cast floats to integers unless you explicitly intend to drop the decimal — and if you do, add a comment explaining why.
Bug #14 — JSON Decode Returning an Object Instead of an Array
// ❌ Wrong
\(data = json_decode(\)jsonString);
echo $data['name']; // Fatal error: Cannot use object as array
// ✅ Correct
\(data = json_decode(\)jsonString, true); // true = associative array
echo $data['name']; // Works
// Always add error checking
if(json_last_error() !== JSON_ERROR_NONE) {
error_log('JSON decode failed: ' . json_last_error_msg());
}
What actually goes wrong
json_decode() returns a PHP stdClass object by default. Object properties use -> notation. Array values use [] notation. Using the wrong syntax for the wrong type causes a fatal error — which in production means a blank page or a 500 error with no clear reason.
Passing true as the second argument forces an associative array. In most real-world PHP code — especially when handling payment gateway responses, SMS service APIs, or third-party data feeds — keeping everything as arrays is more consistent.
API responses can also arrive malformed. Always check json_last_error() after decoding.
Bug #15 — Printing User Input Directly to HTML (XSS Vulnerability)
// ❌ Wrong
echo "Hello " . $_GET['name'];
// ✅ Correct
echo "Hello " . htmlspecialchars($_GET['name'], ENT_QUOTES, 'UTF-8');
What actually goes wrong
Cross-Site Scripting (XSS) is one of the most common vulnerabilities in PHP applications. When you print user-supplied input directly to the page, an attacker can inject a <script> tag that runs in the browser of every user who loads that URL.
A request like:
yoursite.com/hello.php?name=<script>document.cookie='stolen='+document.cookie</script>
...executes JavaScript in the victim's browser if $_GET['name'] is printed without escaping.
htmlspecialchars() converts <, >, ", ', and & into safe HTML entities. Always use ENT_QUOTES to escape both quote types, and always specify 'UTF-8' as the charset. This is non-negotiable: never print user input to HTML without htmlspecialchars().
Bug #16 — Relative Include Paths Breaking on Different Servers
// ❌ Wrong — works locally, breaks on server
include("includes/header.php");
// ✅ Correct — always works everywhere
include(__DIR__ . "/includes/header.php");
What actually goes wrong
Relative paths in PHP resolve based on the current working directory — which is not always the directory of the PHP file being executed. It depends on how PHP is called, which web server you are using, and from which directory the script is triggered.
On localhost with XAMPP or WAMP, the working directory is usually where you expect. On a live Apache or Nginx server with different virtual host configurations, that same relative path fails silently with a "Failed to open stream: No such file or directory" error.
__DIR__ is a PHP magic constant that always returns the absolute path of the directory containing the current file — regardless of environment, server configuration, or how the script was triggered. Use it for all file paths, always.
Bug #17 — Duplicate Database Entries from Form Resubmission
// ❌ Wrong — direct insert, no duplicate check
\(sql = "INSERT INTO users (email) VALUES ('\)email')";
// ✅ Correct — check first, then insert
\(stmt = \)pdo->prepare("SELECT id FROM users WHERE email = ?");
\(stmt->execute([\)email]);
if(!$stmt->fetch()) {
\(insert = \)pdo->prepare("INSERT INTO users (email) VALUES (?)");
\(insert->execute([\)email]);
}
What actually goes wrong
Two real-world causes of duplicate entries: users double-clicking the submit button on slow connections, and browsers resubmitting POST data when the page is refreshed after a form submission.
The PHP-side check catches duplicates before they reach the database — but this alone is not enough. You should also enforce uniqueness at the database level:
ALTER TABLE users ADD UNIQUE (email);
A UNIQUE constraint means even if two simultaneous requests both pass the PHP check at the exact same moment, the database rejects the second insert. Both layers together — PHP check plus database constraint — is the correct pattern.
After a successful form submission, always redirect:
header("Location: success.php");
exit;
This prevents resubmission on page refresh.
Bug #18 — array_merge Resetting Numeric Keys
$a = [1 => "one", 2 => "two"];
$b = [2 => "TWO", 3 => "three"];
// ❌ Wrong — keys reset to 0, 1, 2, 3
print_r(array_merge(\(a, \)b));
// ✅ Correct — preserve keys, left side wins on conflict
\(result = \)a + $b;
// ✅ Or — preserve keys, right side wins on conflict
\(result = array_replace(\)a, $b);
What actually goes wrong
array_merge() reindexes all numeric keys starting from zero. If you pass two arrays both containing key 2, you end up with four elements keyed 0, 1, 2, 3 — the original keys are completely gone.
This matters when you are merging configuration arrays, building data structures with specific IDs as keys, or working with database query results where numeric IDs are the array keys. array_merge() silently destroys your key structure without any error or warning.
The + union operator preserves keys — left array wins on conflicts. array_replace() also preserves keys but right array values overwrite left array values for matching keys. Choose based on which side should win.
Bug #19 — No Error Logging Set Up in Production
// ❌ Wrong — errors vanish completely
// php.ini: display_errors = Off
// (no error_log path set — errors go nowhere)
// ✅ Correct
// Development:
ini_set('display_errors', 1);
error_reporting(E_ALL);
// Production:
ini_set('display_errors', 0); // Never show errors to users
ini_set('log_errors', 1);
ini_set('error_log', '/var/log/php/error.log');
error_reporting(E_ALL);
What actually goes wrong
Turning off display_errors in production is correct — you never want PHP stack traces visible to users or attackers. But many developers stop there and configure no logging either. Errors then disappear completely. Your application misbehaves, users complain, and you have zero record of what broke or when.
log_errors sends errors to a file instead of the browser. Set the error_log path and check it regularly. For professional-grade production monitoring, tools like Sentry or Bugsnag integrate with PHP to deliver real-time error alerts with full stack traces — the industry standard for production PHP applications.
Bug #20 — foreach Not Modifying the Original Array
// ❌ Wrong — modifies only a copy
$prices = [100, 200, 300];
foreach(\(prices as \)price) {
\(price = \)price * 1.18;
}
print_r($prices); // Still [100, 200, 300]
// ✅ Correct — use reference with &
$prices = [100, 200, 300];
foreach(\(prices as &\)price) {
\(price = \)price * 1.18;
}
unset($price); // ⚠️ Critical — must unset after loop ends
print_r($prices); // [118, 236, 354]
// ✅ Alternative — array_map (cleaner, no references)
\(prices = array_map(fn(\)p) => \(p * 1.18, \)prices);
What actually goes wrong
foreach works on a copy of each element by default. Whatever you do to \(price inside the loop has no effect on the original \)prices array — which looks like it should work, and silently does not.
The & before \(price creates a reference — \)price now points to the actual array element, not a copy.
The unset(\(price) after the loop is not optional. After the loop ends, \)price is still a reference pointing to the last element of the array. If you use a variable named \(price anywhere later in the same scope, it silently modifies the last element of \)prices. This bug can live in a codebase for months before anyone notices.
If you prefer to avoid references altogether, array_map() with an arrow function is a clean, safe alternative.
Quick Reference Table
| Bug | Issue | Root Cause | Risk |
|---|---|---|---|
| #11 | Infinite loop | Missing $i++ |
Server crash |
| #12 | Case-sensitive comparison fails | No string normalization | Access control failure |
| #13 | Decimal truncated to integer | (int) cast on float |
Financial calculation error |
| #14 | JSON returns object not array | Missing second param in json_decode |
Fatal error on API data |
| #15 | XSS vulnerability | Unescaped user output | Security breach |
| #16 | Include path breaks on server | Relative path used | Fatal include error |
| #17 | Duplicate database records | No existence check before insert | Data corruption |
| #18 | Array keys reset after merge | array_merge reindexes keys |
Lost key structure |
| #19 | Errors invisible in production | No error logging configured | Silent failures |
| #20 | Original array not modified | foreach works on copy |
Wrong data processed |
What These Bugs Have in Common
Looking at all ten, there is a clear pattern. Most of them do not throw errors. PHP's forgiving nature means the code runs — it just does not do the right thing. No warning. No crash. Just silent wrong behavior that costs hours to trace. That is what makes them dangerous in production.
The developers who catch these early are the ones who have been burned by them before, or who studied them before getting burned. Syntax knowledge is not enough — understanding how PHP fails is what separates code that works locally from code that is actually production-ready.
Read the Full Article
For complete explanations and additional context on each bug, read the original article on CodePractice:
👉 10 PHP Bugs That Break Real Projects — Part 2
Also Read
Part 3 of this series covers bugs #21–30 — session handling, file upload security, date and timezone bugs, and database connection errors. Follow to get notified when it goes live.





