Debugging

dump and dd for a quick look, the dump server when output is in the way, Xdebug when you need to stop time.

Syntaxdump(mixed $var, mixed ...$moreVars): mixed

Debugging is narrowing down where reality stops matching your expectation. Three tools cover almost all of it, and they differ in how much they interrupt.

dump and dd

dump() prints a variable as a structure you can expand, and returns the value it was given, so it can wrap an expression without changing the result. dd() is shorthand for exactly two statements: dump($var); exit;. Templates have both forms too: {{ dump(user) }} writes into the output, while {% dump user %} leaves the page alone and sends the data to the web debug toolbar.

The dump server

A dump in the middle of a JSON response corrupts it, and a dump in a worker process goes nowhere useful. Setting debug.dump_destination to a TCP address sends dumps to a separate process instead. Start it with php bin/console server:dump, which listens on port 9912 by default, and the dumps appear in that terminal with the file and line they came from.

Xdebug

When the question is "how did it get here", stop the process. Xdebug 3 connects back to your editor on port 9003. Set xdebug.start_with_request=trigger so only requests carrying XDEBUG_TRIGGER are paused, rather than all of them.

Reading the trace

A Symfony stack trace passes through generated files in var/cache that build your services, and through the HTTP kernel. Those frames are plumbing. The useful frame is the highest one under src/.

Example

Example · php
// src/Service/CartSummary.php
declare(strict_types=1);

namespace App\Service;

final class CartSummary
{
    /**
     * @param list<array{sku: string, qty: int, unitPrice: int}> $lines
     *
     * @return array{items: int, totalInCents: int}
     */
    public function summarise(array $lines): array
    {
        $summary = ['items' => 0, 'totalInCents' => 0];

        foreach ($lines as $line) {
            $summary['items'] += $line['qty'];
            $summary['totalInCents'] += $line['qty'] * $line['unitPrice'];
        }

        // dump() returns its argument, so wrapping the return value prints
        // the structure without changing what the method gives back.
        return dump($summary);

        // dd($summary) is shorthand for these two statements:
        //
        //     dump($summary);
        //     exit;
    }
}

// Called with [['sku' => 'A1', 'qty' => 2, 'unitPrice' => 500]], the dump
// prints on the command line:
//
// Output:
// array:2 [
//   "items" => 2
//   "totalInCents" => 1000
// ]

When to use it

  • Inspect a value inside a Messenger worker, where there is no browser and no response to print into.
  • Check what a service actually received without breaking a JSON endpoint's response body.
  • Step through a request to find which listener modified the response before it was sent.
  • Work out which of your own classes a long framework stack trace actually blames.

More examples

Sending dumps to the dump server

Use this whenever the thing you are debugging is not an HTML page: an API controller, a console command, a queue worker. If the server is not running, dumps fall back to normal output rather than being lost, so leaving the config in place is safe.

Example · yaml
# config/packages/debug.yaml
# Installed by the symfony/debug-bundle recipe.
when@dev:
    debug:
        # Dumps leave the process over TCP instead of being written into
        # the response. VAR_DUMPER_SERVER defaults to 127.0.0.1:9912.
        dump_destination: "tcp://%env(VAR_DUMPER_SERVER)%"

# Then, in a terminal you keep open:
#
#   $ php bin/console server:dump
#    [OK] Server listening on tcp://127.0.0.1:9912
#
# Or capture a session to a file instead of watching it:
#
#   $ php bin/console server:dump --format=html > dump.html

Xdebug 3 set to pause only when asked

Reach for a breakpoint when a dump would need to be placed in six files to find out which one runs. The trigger mode is what makes this practical on a shared container: the overhead only applies to requests you opt in to.

Example · ini
; php.ini (or a conf.d file, or docker-php-ext-xdebug.ini in an image)
zend_extension=xdebug

; debug = step debugging. Other modes: develop, coverage, profile, trace.
xdebug.mode=debug

; trigger = only pause when the request carries XDEBUG_TRIGGER.
; yes = pause on every request, which makes a dev server crawl.
xdebug.start_with_request=trigger

; Xdebug 3 connects back to the editor. Port 9003 is its default;
; Xdebug 2 used 9000, which is also PHP-FPM's port - a common clash.
xdebug.client_host=host.docker.internal
xdebug.client_port=9003

; Trigger a paused request from the command line:
;   XDEBUG_TRIGGER=1 php bin/console app:import
; Or in a browser, with the ?XDEBUG_TRIGGER=1 query parameter.

A stack trace with the plumbing marked

Read a trace from the top for the failure and from the bottom for the entry point, then look for the second frame under src/: that is the caller that passed the wrong thing. Line numbers are omitted here because they change; in a real trace they are the part you click.

Example · text
TypeError: App\Service\CartSummary::summarise(): Argument #1 ($lines)
must be of type array, null given

  at src/Service/CartSummary.php            <- the failure
  at src/Controller/CartController.php      <- YOUR BUG IS USUALLY HERE
  at vendor/symfony/http-kernel/HttpKernel.php
       HttpKernel->handleRaw()              <- the kernel calling the controller
  at vendor/symfony/http-kernel/HttpKernel.php
       HttpKernel->handle()
  at vendor/symfony/http-kernel/Kernel.php
       Kernel->handle()
  at public/index.php

# Frames like these may also appear between your classes:
#
#   var/cache/dev/ContainerA1b2c3d/getCartSummaryService.php
#
# That is the generated container building the service. It is not where
# the problem is; read past it to the next file under src/.

Discussion

  • Be the first to comment on this lesson.