Reading a PHP stack trace like a detective

By Ricardo Ribeiro · · 5 min read · PHP, Web Development

The twenty frames between the message and your code

An exception message tells you that the engine refused to continue. It does not tell you how the program arrived at that line. In a small script the difference does not matter, because you can see the whole file at once. In a framework project with thirty frames in the trace, that difference is the entire job.

Header against frames

Every PHP trace has a header and a list of frames, and they answer different things. The header names the exception, the message, and the place where it was thrown:

Uncaught TypeError: Cart::add(): Argument #1 ($item) must be of type Item, string given in /var/www/src/Cart.php:42

The frames below are the call chain, most recent call first:

#0 /var/www/src/Order.php(31): Cart->add('sku-9')
#1 /var/www/src/Checkout.php(18): Order->addLine('sku-9', 2)
#2 /var/www/public/index.php(12): Checkout->run()
#3 {main}

How to read a frame

Frame zero reads like this: at line 31 of Order.php, something called Cart->add() and passed it the string 'sku-9'. The file and line in the parentheses belong to the caller, and the function after the colon is the call that was made there. This is where most people go wrong. Cart.php is not mentioned in frame zero at all, and the line you need to open is the one where the call happened.

So in this trace the fault is at Order.php line 31, not in Cart.php, even though the header points at Cart.php line 42, which is where add() checked its argument and gave up. Cart.php is doing its job.

Read it from the bottom up

Frame numbers go the opposite way to the story. Frame zero is the deepest point, the call closest to the failure. The last line, {main}, is the entry script. Reading from the bottom up means reading the order in which things happened: a request reached index.php, index.php called run(), run() called addLine(), addLine() called add(). Nothing is missing from the trace. The trace is the route.

There is a practical reason to start at the bottom: in a real application most frames live in vendor. The last frame before the trace leaves your namespace is the one that made the decision, and it is usually the interesting one.

Thrown location against call chain

The header is the only place that tells you where the error was raised. The frames tell you how the program got there. Confusing the two costs an hour and produces a change in the wrong file, because a library that throws a well described exception for bad input is working correctly. The bug is in the code that built the input.

Mine or theirs

You can usually tell from the argument values in the frames.

  • If the failing value looks like data you produced, such as a raw request parameter, a database column or a string where an object belongs, it is yours.
  • If the library receives exactly what its own documentation promises and still fails, check the version you have installed and its changelog before assuming a bug. Version mismatches look like library bugs and are not.

Two details settle it. If the trace runs through a vendor method twice with no code of yours between the two, the library is failing on its own internals. If the exception is a typed argument error or a missing array key, the input came from outside the library, and the frame below is where it came from.

When the arguments are missing

Production configurations often strip argument values from traces with zend.exception_ignore_args, because arguments can carry passwords and personal data. If your frames show add() with empty parentheses and no values, that is the reason. Reproduce it locally with the default configuration and the values come back.

Use the file and line, and read the whole function

Open the file, go to the line, and read the function that contains it rather than the single line. The line in the trace is where execution stopped, and the reason is usually one line above or one condition earlier. If the file is generated, from a compiled template or a cached container, read the template and not the generated output.

Since PHP 8.5 fatal errors such as a timeout or an out of memory condition carry a backtrace as well, so the same reading applies to the errors that used to give you a message and nothing else.

The habit

Take the bottom frame, walk upwards, and stop at the last frame that is your code. That frame either contains the bug or built the value that caused it. It is that mechanical, and it beats any debugger for the first five minutes of an investigation.

← All posts

Privacy · Cookies

Need a hand with a WordPress site that keeps breaking? See what I do and how I work.