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
<?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([]);
Output
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 5

Read 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
<?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)
Output
array(3) {
  ["id"]=>
  string(1) "7"
  ["price"]=>
  float(19.9)
  ["active"]=>
  NULL
}
array (
  'id' => '7',
  'price' => 19.9,
  'active' => NULL,
)
price was 19.9

var_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

SettingDevelopmentProduction
error_reportingE_ALLE_ALL
display_errorsOnOff — never show errors to visitors
log_errorsOnOn
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
<?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
<?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.

Try it yourself · main.php
Edit the code, copy it into main.php, then run it with php main.php (PHP 8.5). How to install PHP
Output
Warning: Undefined array key "qty" in main.php on line 7
10.6