Debugging and Best Practices
Bugs are inevitable; long debugging sessions shouldn't be. This lesson covers reading PHP's error messages, inspecting values, using a real debugger and static analysis, configuring errors for development and production, and the habits and conventions that keep PHP code clean, secure and maintainable.
Reading error messages
An uncaught exception shows the exception type, the message, where it was thrown, and a stack trace — the chain of calls that led there, most recent first:
<?php function average(array $values): float { return array_sum($values) / count($values); } function report(array $orders): string { return "Average order: " . average(array_column($orders, 'total')); } echo report([]);
Fatal error: Uncaught DivisionByZeroError: Division by zero in main.php:5
Stack trace:
#0 main.php(10): average(Array)
#1 main.php(13): report(Array)
#2 {main}
thrown in main.php on line 5Read it from the top: what happened (division by zero), where (line 5, in average), and how we got there — report() called average() with an empty list. The fix belongs in whichever function should handle "no orders": probably report(), by checking for an empty list first.
Inspecting values
The quickest debugging tool is printing values. var_dump shows types; print_r is more compact; var_export prints valid PHP code; debug_print_backtrace shows how the current function was reached:
<?php $row = ['id' => '7', 'price' => 19.9, 'active' => null]; var_dump($row); echo var_export($row, true), "\n"; error_log("price was " . $row['price']); // to the error log (here: the terminal)
array(3) {
["id"]=>
string(1) "7"
["price"]=>
float(19.9)
["active"]=>
NULL
}
array (
'id' => '7',
'price' => 19.9,
'active' => NULL,
)
price was 19.9var_dump revealed something a plain echo wouldn't: the ID is a string, not an integer. On a web page, wrap dumps in <pre>; frameworks offer nicer helpers such as Laravel's dump()/dd() and Symfony's VarDumper. Remove debug output before committing.
Using a debugger
Xdebug is a PHP extension that lets your editor pause a script at a breakpoint, step through it line by line, and inspect every variable — far faster than adding and removing var_dumps. Install it (pecl install xdebug, or your OS package), set xdebug.mode=debug in php.ini, and use the "PHP Debug" extension in VS Code or PhpStorm's built-in support. Xdebug can also produce code-coverage reports for your tests and profile slow pages.
Static analysis
Static analysers read your code without running it and find bugs like calling methods that don't exist, passing the wrong types or forgetting a null check:
composer require --dev phpstan/phpstan vendor/bin/phpstan analyse src --level 6
PHPStan and Psalm understand your type declarations and docblocks (@param list<int>). Start at a low level and raise it over time. Combined with tests, they catch most bugs before code reaches production.
Errors in development and production
| Setting | Development | Production |
|---|---|---|
error_reporting | E_ALL | E_ALL |
display_errors | On | Off — never show errors to visitors |
log_errors | On | On |
error_log | (terminal / server log) | A log file or logging service |
Error messages reveal file paths, SQL and configuration details that help attackers, so production sites log errors and show a generic error page instead. Use php.ini-production as the starting point on servers. A logging library such as Monolog adds log levels, context and destinations (files, email, monitoring services).
Performance basics
- Enable OPcache in production: it caches compiled scripts and is the single biggest speed-up.
- Most slow pages are slow because of the database: add indexes, avoid running a query inside a loop (the "N+1" problem), and fetch only the columns you need.
- Cache expensive results (in APCu, Redis or files) rather than recomputing them on every request.
- Measure before optimising —
microtime(true)around a block, or a profiler such as Xdebug's or Blackfire.
Best practices checklist
- Use a recent PHP version and keep it updated.
- Start files with
declare(strict_types=1);and declare types for parameters, returns and properties. - Follow PER/PSR-12 coding style, and let a tool (PHP CS Fixer, PHP_CodeSniffer) enforce it.
- Use Composer for dependencies and PSR-4 autoloading; keep only
public/web-accessible. - Separate concerns: routing, controllers, business logic, database access and templates in different places.
- Prefer small, final classes with constructor-injected dependencies; prefer immutable value objects for data.
- Validate input, escape output, use prepared statements — every time.
- Store secrets outside the code — environment variables or untracked config files.
- Write tests for business logic, and run them and static analysis on every change.
- Use Git for every project, with small, descriptive commits.
Before and after
A quick, old-style script:
<?php $id = $_GET['id']; $r = mysqli_query($db, "SELECT * FROM products WHERE id = $id"); $p = mysqli_fetch_assoc($r); echo "<h1>" . $p['name'] . "</h1><p>Price: " . $p['price'] . "</p>";
It has SQL injection, XSS, no validation, no handling of a missing product, and mixes database code with HTML. The same feature, written with the practices from this course:
<?php declare(strict_types=1); $id = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT) ?: null; $product = $id ? $products->find($id) : null; // repository using a prepared statement if ($product === null) { http_response_code(404); echo render('errors/404'); exit; } echo render('products/show', ['product' => $product]); // template escapes all output
Try it yourself
This function has a bug. Run it, use var_dump to find out why the total is wrong, and fix it.
Warning: Undefined array key "qty" in main.php on line 7 10.6