PHP pitfalls that even experts barely avoid
Permissive typing, loose comparisons, residual references, silent errors: a practical guide to the PHP 8 pitfalls that cost dearly in production, with the reflexes to neutralize them.
Loose comparisons and value truthiness
Loose comparisons (`==`) apply surprising conversions: `0 == "foo"` is true, `"1e3" == 1000` is true. Strict typing (`declare(strict_types=1)`) fixes function calls but not comparisons. The reflex: use `===`, `match` instead of loose `switch` cascades, and validate inputs with precise types before any comparison.
declare(strict_types=1);
// Loose comparison: 0 == "foo" returns true
if ($status == 'active') { /* possible false positive */ }
// Strict comparison + enum
if ($status === Status::Active) { /* safe */ }Typing boundaries: coercion and union types
Even in strict mode, PHP converts scalars in certain contexts (concatenation, numeric operations) and union types accept implicit coercions. An `int|string` parameter lets `"42"` through without an error. The defense: normalize inputs at the HTTP boundary (validated DTOs), then work with precise types inside the domain.
function route(int|string $id): string
{
return "resource/{$id}"; // both "42" and 42 arrive here
}
// At the boundary, normalize:
$id = filter_var($rawId, FILTER_VALIDATE_INT) ?: throw new InvalidArgumentException();Silent errors and swallowed exceptions
The `@` operator, empty `try/catch` blocks and “by convention” `null` returns turn errors into mysterious behavior. A `catch (\Throwable)` that does nothing also hides fatal errors. The reflex: let exceptions bubble up, log with context, and treat error paths as code paths to test, not as accidents.
try {
$score = $service->answer($payload);
} catch (QuizClosedException $e) {
// Known business case: explicit response
throw new BadRequestHttpException($e->getMessage(), $e);
}
// No empty catch: unexpected errors bubble to the global handlerResidual references and loop leaks
`foreach ($items as &$value)` leaves a reference on the last element: reusing it afterwards corrupts the array. `unset($value)` after the loop is mandatory. More broadly, closures capturing variables by reference extend the life of objects and can turn a cache into a leak. Prefer immutable iterations and copied values.
$items = [1, 2, 3];
foreach ($items as &$value) {
$value *= 2;
}
unset($value); // without this, $value references $items[2]
$items[] = 4; // would silently modify $items[2] if the reference survivedEscaping, SQL injection and XSS
PHP escapes nothing by default: every output must be encoded for its context (HTML, attribute, JSON, URL) and every query must use prepared statements. Classic mistakes: concatenating into queries, disabling `htmlspecialchars` “to go faster”, or validating client-side only. The rule: never trust an input, even after validation.
// SQL: always prepared statements
$stmt = $pdo->prepare('SELECT * FROM quiz WHERE slug = ?');
$stmt->execute([$slug]);
// HTML: encode the output
echo htmlspecialchars($userName, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');OpCache and deployment strategy
In production, OpCache must be sized (memory, file count) and its validation policy aligned with deployment. With `validate_timestamps=1`, each deployment recompiles changed files; with timestamps disabled, the cache must be purged after each release (`cachetool opcache:reset` or a protected endpoint). A saturated cache recompiles everything continuously: monitor `opcache_get_status()`.
// php.ini (production)
opcache.enable=1
opcache.memory_consumption=256
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0
// Purge after deployment: cachetool opcache:resetEnums, readonly and the temptation of over-typing
Native enums and readonly properties make states explicit, but overusing them creates noise: a one-case enum or value objects without invariants. Use them when they carry semantics (session states, statuses, question types), not as an aesthetic reflex. Readable code stays more maintainable than “elegant” unreadable code.
enum QuizMode: string
{
case Free = 'free';
case Timed = 'timed';
case Exam = 'exam';
}
final class QuizSession
{
public function __construct(
public readonly QuizMode $mode,
public readonly int $userId,
) {}
}Measure before optimizing: profile, don’t guess
Optimization without measurement produces micro-optimizations that complicate code without measurable effect. `memory_get_peak_usage()`, a profiler (Blackfire, Xdebug + cachegrind) and slow-query logs provide facts. Optimize architecture first (queries, I/O, cache), then hot code only, with a before/after benchmark.
$start = hrtime(true);
$result = $service->rebuildDashboard($userId);
$elapsedMs = (hrtime(true) - $start) / 1_000_000;
error_log(sprintf('rebuildDashboard: %.1f ms', $elapsedMs));