PHPCommon Mistakes

Common PHP Mistakes

Some mistakes show up in almost every PHP codebase, regardless of experience level, because the language makes the risky option just as easy to reach for as the safe one. This page walks through the recurring ones — the kind that pass a quick test, work fine in a demo, and then cause a confusing bug or a security incident later. Each comes with a short example of the mistake next to the fix, so the difference is concrete rather than abstract.

Using == instead of ===

PHP's == operator compares values after converting them to a common type, which produces results that surprise almost everyone at least once — the string "abc" loosely equals 0 in older PHP versions, and "0" == false is true. === compares both value and type with no conversion, so it only returns true when the two operands are genuinely the same kind of thing with the same value. Defaulting to === and only reaching for == when loose comparison is specifically what you want avoids an entire category of subtle bugs.

Loose comparison surprises

PHP
<?php
var_dump(0 == 'abc');      // false in PHP 8 (was true before PHP 8)
var_dump('1' == '01');     // true — both convert to numeric 1
var_dump('10' == '1e1');   // true — both convert to numeric 10
var_dump(100 == '1e2');    // true

// Fix: compare type and value together
var_dump(0 === 'abc');     // false
var_dump('1' === '01');    // false — different strings
Not validating or escaping user input

Any value coming from outside the application — a form field, a query string, a cookie, an uploaded file's name — is untrusted until proven otherwise. Using it directly to build a SQL query, print it into HTML, or pass it to a shell command opens the door to SQL injection, cross-site scripting, or command injection respectively. The fix isn't one universal function; it's validating that the value looks like what you expect, and then escaping it correctly for whatever context it's about to enter.

Printing user input without escaping vs. escaping it

PHP
<?php
// Mistake: raw user input printed straight into HTML
echo "Welcome, " . $_GET['name'];

// Fix: escape for the HTML context before output
echo "Welcome, " . htmlspecialchars($_GET['name'], ENT_QUOTES, 'UTF-8');
Suppressing errors with @

Prefixing an expression with @ silences any warning or notice it would otherwise raise, which feels convenient when a function is expected to occasionally fail. The cost is that it hides real problems too — a typo in a variable name, a missing array key, a failed file read all disappear along with the noise you meant to silence, and you find out something was wrong only when its downstream effects surface somewhere else, much harder to trace back. Handling the specific failure explicitly is almost always better than muting it.

Silencing errors vs. handling them explicitly

PHP
<?php
// Mistake: hides every possible failure, including real bugs
$data = @file_get_contents($path);

// Fix: check the actual failure condition
$data = file_get_contents($path);
if ($data === false) {
    throw new RuntimeException("Could not read file: {$path}");
}
Giant God-classes and God-functions

A class or function that keeps absorbing new responsibilities every time a feature is added eventually becomes a single point where everything routes through, and everything is scared to touch. A 500-line function that validates, queries, calculates, and formats all in one place can't be tested piece by piece and can't be reasoned about without holding the whole thing in your head at once. Splitting it along its natural responsibilities — one function per job, one class per concern — makes each piece independently understandable and testable.

Forgetting unset() after a foreach by reference

Iterating an array with foreach ($array as &$value) makes $value a reference to the actual array element, which is useful for modifying elements in place. The trap is that $value keeps pointing at the last array element after the loop ends, since PHP doesn't automatically break the reference. A second foreach reusing the same variable name then silently overwrites that last element instead of assigning to a fresh loop variable, corrupting data with no error or warning to flag it.

A stale reference silently corrupting data

PHP
<?php
$numbers = [1, 2, 3];
foreach ($numbers as &$value) {
    $value *= 2;
}
// $numbers is now [2, 4, 6], but $value still references $numbers[2]

foreach ($numbers as $value) {
    // this loop reuses $value, silently overwriting $numbers[2] each iteration
}
// $numbers ends up [2, 4, 4] — not what was intended

// Fix: break the reference as soon as the first loop is done
foreach ($numbers as &$value) {
    $value *= 2;
}
unset($value);
Relying on $_REQUEST

$_REQUEST merges values from $_GET, $_POST, and $_COOKIE, which means code reading from it can't tell where a value actually came from. A value you expect to arrive only via a POST form submission could just as easily be supplied through the query string or an attacker-controlled cookie, which weakens any assumption your validation logic makes about how the request was submitted. Reading explicitly from $_GET or $_POST (whichever the endpoint is actually designed to accept) keeps that assumption honest.

$_REQUEST vs. an explicit superglobal

PHP
<?php
// Mistake: accepts the value from GET, POST, or a cookie interchangeably
$userId = $_REQUEST['user_id'];

// Fix: explicit about where this value is expected to come from
$userId = $_POST['user_id'] ?? null;
Not using prepared statements

Building a SQL query by concatenating user input directly into the string is the classic path to SQL injection — an attacker who controls part of that string can change the query's meaning entirely, reading or modifying data they shouldn't have access to. Prepared statements separate the query structure from the data: placeholders mark where values go, and the database driver binds the actual values afterward, so user input can never be interpreted as SQL syntax.

String concatenation vs. a prepared statement

PHP
<?php
// Mistake: user input concatenated straight into the query
$result = $pdo->query(
    "SELECT * FROM users WHERE email = '" . $_POST['email'] . "'"
);

// Fix: prepared statement with a bound parameter
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = :email');
$stmt->execute(['email' => $_POST['email']]);
$result = $stmt->fetchAll();
Ignoring strict_types

Without declare(strict_types=1), PHP coerces scalar values to match a function's declared parameter types wherever it can — passing "5" to a function expecting an int just works, silently converted. That flexibility can mask a real bug: a value that should have been an integer arriving as a string somewhere upstream goes unnoticed instead of raising an immediate error at the function boundary where the mismatch actually happened.

Loose coercion hiding a bug vs. strict_types catching it

PHP
<?php
// Without strict_types, this silently coerces and runs
function chargeAccount(int $cents): void
{
    // ...
}
chargeAccount("500"); // works silently, string coerced to int

// With declare(strict_types=1) at the top of the calling file,
// the same call throws a TypeError immediately instead of coercing
These mistakes rarely fail loudly
Almost none of these produce an error on the happy path — that's exactly why they survive in production for so long. == vs === passes most manual testing, a suppressed error just goes quiet, and a stale reference from foreach only corrupts data under the right conditions. Code review and static analysis tools catch these far more reliably than manual testing does.
Tip
A static analysis tool like `PHPStan` or `Psalm` catches several of these automatically — loose comparisons, missing null checks, and type mismatches — before the code ever runs, which is worth adding to a CI pipeline even on an existing codebase.