Dumpio PHP client — complete guide
dumpio/client sends faithful, typed debug data from your PHP application to the
Dumpio desktop viewer over the network. Think of it as a dd() / var_dump()
that streams to a real UI instead of polluting your response — with full type
fidelity, color flags, channels, exception parsing, SQL/HTTP/event timelines, and
flood control for loops.
This guide explains every feature with runnable examples. For a one-page
reference see ../README.md; for the underlying wire format see
../../BUILDING.md.
Table of contents
- How it works
- Requirements & installation
- Quick start
- Configuration
- Core concepts: dumps, flags, channels, labels
vardumps — the flagship- The fluent builder (
dio,Dumpio::make) - Flood control for loops
- Timing with the stopwatch
- Typed messages
- Global helper functions
- Laravel integration
- Symfony integration
- Recipes
- Troubleshooting
- Safety & production behavior
- API cheat sheet
1. How it works
Every call builds a small JSON envelope and POSTs it to the viewer at
http://{host}:{port}/dumps (default localhost:21234), fire-and-forget.
Three properties matter:
- Fire-and-forget. The client never waits for a meaningful response and never retries. If the viewer is closed, the dump is silently dropped.
- It never breaks your app. Every public method is wrapped in a
try/catchthat swallows its own errors. A broken debug line must never take down a request. - Type-faithful. Values are serialized with Reflection, so visibility, class names, enums, typed/uninitialized properties and reference cycles survive the trip — you see the object as PHP sees it, not a flattened string.
your code ──> Dumpio::dump()/dio()/... ──> JSON envelope ──HTTP POST──> Dumpio viewer2. Requirements & installation
- PHP 8.0+ (the package itself; tooling examples assume 8.1+ for enums).
- The Dumpio desktop app running and listening (default
localhost:21234). ext-curlis used when available; otherwise the client falls back to a stream context, so no hard extension requirement.
composer require dumpio/client --devInstall it as a dev dependency. The client is a debugging tool; it should not ship enabled to production (see §16).
The global helpers (dio, dumpio, ddio, …) are autoloaded via Composer's
files autoloader — no bootstrap needed.
3. Quick start
Start the Dumpio app, then:
use Dumpio\Dumpio; // 1. The simplest possible dumpdio('hello from PHP'); // 2. A value with a label and colordio($user, 'current user')->green(); // 3. Dump-and-die (stops execution after sending)ddio($request->all());Smoke-test without writing any PHP — pipe JSON straight into the viewer:
echo '{"message":"hi","flag":"green"}' | nc localhost 21234 # TCPcurl -XPOST localhost:21234/dumps -d '{"message":"hi"}' # HTTPIf something shows up in the viewer, you're wired correctly.
4. Configuration
The client reads its configuration from the environment on first use. Every option has a sensible default, so with a local viewer running you need zero configuration.
Environment variables
| Variable | Default | Meaning |
|---|---|---|
DUMPIO_HOST |
localhost |
Host the viewer listens on. |
DUMPIO_PORT |
21234 |
Port the viewer listens on. |
DUMPIO_TOKEN |
"" |
Shared token; sent as X-Dumpio-Token if non-empty. |
DUMPIO_DISABLE |
(unset) | Set to any non-empty value to disable the client. |
Runtime override
Dumpio::configure() merges over the current config. Call it once during
bootstrap (Laravel/Symfony do this for you — see below).
use Dumpio\Dumpio; Dumpio::configure([ 'host' => '127.0.0.1', 'port' => 21234, 'token' => 'secret', 'enabled' => true, 'timeoutMs' => 1500, // per-request timeout 'maxDepth' => 6, // serializer: nesting depth 'maxItems' => 100, // serializer: array/object children per level 'maxString' => 2000, // serializer: string length before truncation]);The serializer limits (maxDepth / maxItems / maxString) bound how much of a
large structure is walked, so a huge object graph can't stall a request or flood
the viewer. Raise them deliberately when you need to see deeper.
5. Core concepts
Every dump shares the same envelope. You'll see these four ideas everywhere:
Flag (color)
A flag is a color label used to scan the timeline at a glance. The fixed set is:
red yellow blue gray purple pink greenPick them with the named builder methods (->red(), ->green(), …) or
->flag('purple'). Typed messages choose a sensible flag automatically
(exceptions are red, queries purple, HTTP by status, logs by level).
Channel
A free-text string used to group and filter dumps in the viewer (e.g.
auth, billing, query, jobs). Defaults to default. Set it with
->channel('billing') or the channel option on typed helpers.
Label / message
A short human title for the entry. For var dumps it's the label; for typed
messages it's derived (the SQL, the URL, the event name) or set via options.
Caller (file:line)
The client automatically captures the first stack frame outside the SDK, so
each dump is tagged with the exact file:line that produced it — no manual
annotation needed. This is also what flood control keys on.
6. var dumps — the flagship
A var dump captures an arbitrary PHP value with full fidelity. There are three
entry points, by ergonomics:
| Entry point | Returns | Use when… |
|---|---|---|
dio($x) |
a fluent builder | you want to chain color/label/flood control |
dumpio($x) |
$x itself |
you want to wrap an expression (tap-style) |
Dumpio::dump($x) |
void |
you prefer the explicit static call |
dio() — fluent, the default
dio($user); // sends a blue var dumpdio($user, 'current user'); // with a labeldio($user)->green()->label('user')->channel('auth');dio() returns a PendingDump builder. The dump ships
when you call ->send() or automatically when the builder goes out of scope,
so the trailing ->send() is optional. See the next section for the full chain.
dumpio() — tap-style passthrough
dumpio() sends the dump and returns the value unchanged, so it can wrap an
expression inside a larger one without changing behavior:
return dumpio($user, 'user'); // dumps, then returns $user$total = array_sum(dumpio($prices, 'prices'));Why two functions?
dio()returns a builder so you can chain;dumpio()returns the original value so you can tap. A single function can't do both (PHP can't proxy an arbitrary value), so the SDK splits the two roles cleanly.
ddio() — dump and die
ddio($a, $b, $c); // dumps each argument, then exit(1)Equivalent to Dumpio::dd($a, $b, $c). Use it like Laravel's dd() when you
want execution to stop right there.
What fidelity you get
The serializer (Dumpio\Serializer) uses Reflection, matching the goal of
Symfony's VarCloner without a hard dependency:
- Scalars —
null,bool,int,float(includingNAN/INF/-INF), andstring(truncated pastmaxString, flaggedtruncated). - Arrays — keys preserved; bounded by
maxItemsper level andmaxDepth. - Objects — class name plus every property with its visibility
(
public/protected/private). Uninitialized typed properties show asundefinedrather than erroring. - Enums — pure and backed enums carry their
name(and backingvalue), not internal machinery. - Closures / resources — rendered as their kind/type, not expanded.
- Reference cycles — broken with
refnodes (an object seen twice points back instead of recursing forever).
Framework-aware shapes render their logical value instead of internals (detection is by class name, so the core stays framework-agnostic and these are inert when the framework is absent):
DateTimeInterface→ a single formatted timestamp.- Eloquent Model → its casted, visible attributes (via
attributesToArray()), including loaded relations — not the raw$attributes/$castsmachinery. - Laravel Collection → its items as an array, tagged with the class.
enum Status: string { case Active = 'active'; case Banned = 'banned'; } class Account { public int $id = 7; protected Status $status = Status::Active; private ?string $secret = null;} dio(new Account());// Viewer shows: Account { +id: 7, #status: Status::Active('active'), -secret: null }7. The fluent builder
dio($value) and Dumpio::make($value) both return a Dumpio\PendingDump. It is
a chainable builder; the dump ships on ->send() or automatically when the
builder is destroyed (end of statement, or end of scope if you assign it).
dio($payload)->red()->label('webhook')->channel('stripe'); // auto-sendsDumpio::make($payload)->purple()->send(); // explicit sendColor methods
->red() ->yellow() ->blue() ->gray() ->purple() ->pink() ->green()->flag('red') // or set any flag string directlyMetadata
->label('order #42') // the entry title->channel('billing') // grouping/filtering bucketConditional sending
Only ship when a condition holds. A failed gate drops the dump and does not
count toward once()/limit()/count():
dio($payload)->when($request->boolean('debug'))->yellow(); // only if ?debug=1dio($cart)->unless($cart->isEmpty())->label('cart'); // skip empty cartsIdempotency
->send() is idempotent (calling it twice ships once). The destructor calls it
for you, so the shorthand dio($x)->red(); is complete on its own.
8. Flood control for loops
Dumping inside a hot loop floods the viewer. Three modifiers tame it. By default
they are keyed by call-site (file:line), per process:
foreach ($rows as $row) { dio($row)->once(); // only the FIRST iteration is sent dio($row)->limit(5); // at most 5 are sent, then silence dio($row)->count(); // ONE entry that live-updates with "×N"}once()— send the first hit from this line, ignore the rest.limit(int $max)— send at most$maxhits from this line.count(?string $name = null)— collapse every hit onto a single, live-updating entry in the viewer (it carries adedupeKeyand a runningcount). Ships immediately on each call.
Named counters
By default count() groups per call-site. Pass a name to share one counter across
different call-sites — useful for counting a condition that fires in several
places:
foreach ($orders as $order) { if ($order->isPaid()) dio($order)->count('paid'); if ($order->isRefund()) dio($order)->count('refunded');}// Two entries: "paid ×N" and "refunded ×M", regardless of how many lines feed them.9. Timing with the stopwatch
Dumpio::stopwatch() returns a running timer (the equivalent of Ray's
measure()). Call ->stop() for the final timing, or ->lap() for intermediate
splits while it keeps running. Each call ships a measure dump with the elapsed
milliseconds and the memory delta since start.
use Dumpio\Dumpio; $sw = Dumpio::stopwatch('import users'); $rows = parseCsv($path);$sw->lap('parsed'); // intermediate split, timer keeps running User::insert($rows);$sw->stop('done'); // final timing + memory deltaIf you already have a duration measured by other means, report it directly:
Dumpio::measure('render dashboard', 84.2, ['memory' => 2_097_152]);10. Typed messages
Beyond var dumps, the client ships structured message types the viewer
renders with purpose-built UI (stack traces, SQL with bindings, request/response,
tables, …). Each is a static method on Dumpio. Every $opts array accepts the
envelope overrides flag, channel, and origin, plus the type-specific keys
noted below.
exception() — structured errors
try { risky();} catch (\Throwable $e) { Dumpio::exception($e, [ 'request' => ['url' => $url, 'method' => 'POST'], 'user' => ['id' => 1, 'email' => 'a@b.c'], ]); throw $e;}Sends the class, message, file/line, code, and a structured stack trace
(file/line/function/class per frame). The optional $context array is shown
alongside (request/user/database/…). Flag defaults to red. Pass
['framework' => 'laravel'] to help the viewer's parser.
query() — SQL
Dumpio::query( 'select * from users where email = ? and active = ?', ['a@b.c', 1], 1.8, // execution time in ms (optional) ['connection' => 'mysql'] // optional);Shows the SQL with its bindings and timing. Flag defaults to purple.
http() — request/response
Dumpio::http('POST', 'https://api.stripe.com/v1/charges', 201, [ 'headers' => ['Authorization' => 'Bearer …'], 'body' => ['amount' => 1999, 'currency' => 'usd'], 'responseTime' => 120, // ms]);Flag is chosen from the status: ≥500 red, ≥400 yellow, ≥300 blue, else
green (and blue when status is unknown).
log() — log lines
Dumpio::log('warning', 'Login throttled', ['ip' => $ip, 'attempts' => 6]);Flag from the level: error/critical → red, warning/notice → yellow, info → blue, else gray.
model() — a domain object
Dumpio::model(\App\Models\User::class, $user->getAttributes(), [ 'exists' => true, 'relations' => ['roles' => $user->roles->toArray()], 'connection' => 'mysql',]);Renders a single record (Eloquent / Django / Prisma / struct) with its attributes and optional relations.
collection() — a list
Dumpio::collection($users, ['message' => 'active users']);table() — explicit columns and rows
Dumpio::table( ['id', 'name', 'role'], [[1, 'Ada', 'admin'], [2, 'Linus', 'user']], ['message' => 'team']);measure() — one timing
Dumpio::measure('cache warm', 42.0, ['memory' => 1_048_576, 'context' => ['keys' => 30]]);See also the stopwatch, which produces these for you.
performance() — a metric bundle
Dumpio::performance( ['db_queries' => 12, 'cache_hits' => 30], ['breakdown' => ['database' => 120.0, 'render' => 45.0], 'message' => 'request']);event() — a business/domain event
Dumpio::event('order.completed', [ 'entity' => 'order', 'entity_id' => 42, 'actor' => ['id' => 1], 'data' => ['total' => 299.9, 'currency' => 'EUR'],]);HTML and email previews — html() / mailable()
Capture rendered markup — a server-rendered page, a Blade/Twig fragment, or a
whole email — and preview it live in the viewer. The markup rides in a dedicated
html/text field (not the value serializer), so large bodies aren't clipped.
// Any HTML fragment or full page.Dumpio::html($renderedBladeView, ['message' => 'Checkout page']); // A rendered email with its envelope (subject / from / to / attachments).Dumpio::mailable($mailable); // Laravel Mailable: calls render()Dumpio::mail([ // or build the envelope yourself 'subject' => 'Welcome aboard', 'from' => 'App <no-reply@app.test>', 'to' => ['Ada <ada@example.com>'], 'html' => $html, 'text' => $text, 'attachments' => [['name' => 'invoice.pdf', 'size' => 20480, 'mime' => 'application/pdf']],]); // From a Symfony Mime Email (what Laravel Mail & Symfony Mailer produce).Dumpio::mailFromSymfonyEmail($email);The viewer renders the markup inside a fully sandboxed iframe (no scripts, no
same-origin, no forms) and blocks all remote resources by default via a
restrictive CSP — email tracking pixels and external CSS never fire silently.
A per-dump Load remote resources toggle opts in; mail dumps add an envelope
header and an HTML / Text / Source tab switch.
To capture outgoing mail automatically (no explicit call), enable
listen_mail— see Laravel / Symfony integration below.
11. Global helper functions
These are autoloaded for convenience and are thin wrappers over Dumpio::*:
| Helper | Wraps | Returns |
|---|---|---|
dio($value, $label?, $flag?) |
Dumpio::make() |
builder |
dumpio($value, $label?, $flag = 'blue') |
Dumpio::dump() |
$value |
ddio(...$values) |
Dumpio::dd() |
never |
dumpio_exception($e, $context = [], $opts = []) |
Dumpio::exception() |
void |
dumpio_query($sql, $bindings = [], $timeMs?, $opts = []) |
Dumpio::query() |
void |
Use whichever reads best. The static Dumpio::* API is always available too.
12. Laravel integration
The service provider is auto-discovered — no manual registration. It applies
config('dumpio.*'), configures the static client, and (opt-in) wires automatic
forwarding of queries, exceptions, models, cache, jobs and events.
Publish the config
php artisan vendor:publish --tag=dumpio-configconfig/dumpio.php:
return [ 'host' => env('DUMPIO_HOST', 'localhost'), 'port' => (int) env('DUMPIO_PORT', 21234), 'token' => env('DUMPIO_TOKEN', ''), 'enabled' => env('DUMPIO_ENABLED', env('APP_DEBUG', false)), // off in prod 'listen_queries' => env('DUMPIO_LISTEN_QUERIES', false), 'listen_exceptions' => env('DUMPIO_LISTEN_EXCEPTIONS', false), 'intercept_dumps' => env('DUMPIO_INTERCEPT_DUMPS', false), 'listen_models' => env('DUMPIO_LISTEN_MODELS', false), 'listen_cache' => env('DUMPIO_LISTEN_CACHE', false), 'listen_jobs' => env('DUMPIO_LISTEN_JOBS', false), 'listen_events' => env('DUMPIO_LISTEN_EVENTS', false), 'listen_mail' => env('DUMPIO_LISTEN_MAIL', false), 'intercept_mail' => env('DUMPIO_INTERCEPT_MAIL', false), 'register_macros' => env('DUMPIO_REGISTER_MACROS', true),];When enabled is false (the default in production) the provider configures the
client as disabled and registers no listeners — a complete no-op.
Auto-instrumentation (opt-in)
Each switch is off by default; flip the env var to forward that signal:
| Env var | What it forwards |
|---|---|
DUMPIO_LISTEN_QUERIES |
every executed SQL query via DB::listen → query dumps |
DUMPIO_LISTEN_EXCEPTIONS |
reported exceptions → exception dumps |
DUMPIO_INTERCEPT_DUMPS |
dump() / dd() routed into the viewer (via VarDumper::setHandler) |
DUMPIO_LISTEN_MODELS |
Eloquent created/updated/deleted/restored → model dumps (models) |
DUMPIO_LISTEN_CACHE |
cache hit/missed/written/forgotten → event dumps (cache) |
DUMPIO_LISTEN_JOBS |
queue job processing/processed/failed → event dumps (jobs) |
DUMPIO_LISTEN_EVENTS |
your application (non-framework) events → event dumps (events) |
DUMPIO_LISTEN_MAIL |
every outgoing email → mail dumps with rendered preview (mail) |
DUMPIO_INTERCEPT_MAIL |
with LISTEN_MAIL: also swallow the real send in dev (Mailpit-style) |
With DUMPIO_LISTEN_MAIL=true, the provider hooks Laravel's MessageSending
event and forwards every outgoing email to the viewer as a mail preview
(rendered HTML/text + subject/from/to/attachments). Add DUMPIO_INTERCEPT_MAIL=true
and the listener also cancels the real send — a dev-only Mailpit-style trap so
your test emails land only in Dumpio and never reach a real inbox.
The facade
Auto-registered as the Dumpio alias:
use Dumpio\Laravel\Facades\Dumpio; Dumpio::query($sql, $bindings, $timeMs);Chainable macros
->dio() and ->ddio() are registered on query builders and collections, so you
can drop a dump mid-chain without breaking it — it ships the current state and
returns $this:
User::query() ->where('active', true) ->dio() // → query dump (SQL + bindings so far) ->whereDate('created_at', today()) ->dio() // → query dump (with the extra clause) ->get(); collect($users)->dio('after filter'); // → var dump, returns the collection->ddio() is the dump-and-die variant. Disable both with
DUMPIO_REGISTER_MACROS=false.
13. Symfony integration
Register the bundle (dev only is recommended):
// config/bundles.phpreturn [ // … Dumpio\Symfony\DumpioBundle::class => ['dev' => true],];It reads DUMPIO_HOST / DUMPIO_PORT / DUMPIO_TOKEN / DUMPIO_DISABLE from the
environment and adds auto-instrumentation when the relevant component is present:
| Condition | What it forwards |
|---|---|
| always | kernel.exception → exception dumps with request context |
| Doctrine DBAL 4 | every executed SQL → query dumps (database); SQL + timing (no bindings) |
| symfony/messenger | Messenger sent/received/handled/failed → event dumps (messenger) |
symfony/mailer + DUMPIO_LISTEN_MAIL |
every outgoing email (MessageEvent) → mail dumps with preview (mail) |
DUMPIO_INTERCEPT_DUMPS |
dump() / dd() routed into the viewer (via VarDumper::setHandler) |
Symfony cache pools have no built-in event system, so there is no automatic cache forwarding — call
Dumpio::event('cache.…', …)yourself if you need it.
Unlike Laravel, Symfony's
MessageEventhas no cancel hook, so there is nointercept_mailhere — the subscriber only captures mail. To stop dev mail from actually going out, route it to anull://transport (MAILER_DSN=null://null).
The static client and helpers work anywhere:
\Dumpio\Dumpio::query($sql, $bindings, $timeMs);dumpio($entity, 'entity');14. Recipes
Inspect one branch of a request
dio($request->all(), 'payload')->channel('checkout')->yellow();Count how often a condition fires
foreach ($lines as $line) { if ($line->isDiscounted()) { dio($line)->count('discounted lines'); }}Profile a block
$sw = Dumpio::stopwatch('checkout');$cart = $this->buildCart($request); $sw->lap('cart built');$order = $this->placeOrder($cart); $sw->stop('order placed');Debug only when a flag is present (safe in shared/staging)
dio($internalState)->when(config('app.debug'))->channel('internals');Tap inside an expression without changing the result
return response()->json(dumpio($payload, 'response'));Trace a query-builder chain
$users = User::query() ->where('team_id', $teamId) ->dio() // see the SQL at this point ->where('active', true) ->ddio(); // see the final SQL, then stop15. Troubleshooting
Nothing appears in the viewer.
- Is the Dumpio app running and listening on the configured port (
21234)? - Is the client enabled? In Laravel that means
DUMPIO_ENABLED=true(orAPP_DEBUG=true); globally it meansDUMPIO_DISABLEis unset. - Confirm transport independently:
echo '{"message":"ping"}' | nc localhost 21234. - Custom host/port? Make sure
DUMPIO_HOST/DUMPIO_PORTmatch the viewer.
It works locally but not in Docker / a VM. localhost inside the container
isn't your host. Point DUMPIO_HOST at the host (e.g.
host.docker.internal), and make sure the viewer accepts non-loopback binds
(it refuses remote binds unless its allowRemote security option is set).
The viewer shows a red raw entry. The payload wasn't valid JSON the viewer
could parse. This is by design — unparseable input is surfaced, not dropped.
A token is configured. Set DUMPIO_TOKEN to the same value as the viewer; the
client sends it as the X-Dumpio-Token header.
A dump is truncated. You hit maxDepth / maxItems / maxString. Raise them
via Dumpio::configure([...]) for that run.
16. Safety & production behavior
- Off in production by default. In Laravel,
enabledderives fromAPP_DEBUG, so a normal production deploy sends nothing and registers no listeners. Globally, setDUMPIO_DISABLE=1to force-disable. - Never throws. Every public method swallows its own errors; a missing viewer, a serialization edge case, or a network failure is silently ignored.
- Bounded work. The serializer caps depth/items/string length, so even a huge object graph does limited work.
- Install it as
--devand keep it disabled in production. If you must enable it on a shared environment, gate individual dumps with->when(...).
17. API cheat sheet
// var dumpsdio($x)->green()->label('x')->channel('c'); // fluent builder (auto-sends)dumpio($x, 'x'); // returns $x (tap)ddio($a, $b); // dump & dieDumpio::dump($x, 'x', 'blue', 'channel');Dumpio::make($x)->red()->send(); // builder modifiers->red() ->yellow() ->blue() ->gray() ->purple() ->pink() ->green() ->flag('…')->label('…') ->channel('…')->when($bool) ->unless($bool)->once() ->limit(5) ->count('name') ->send() // timing$sw = Dumpio::stopwatch('name'); $sw->lap('split'); $sw->stop('done');Dumpio::measure('name', 84.2, ['memory' => 2_097_152]); // typed messagesDumpio::exception($e, $context = [], $opts = []);Dumpio::query($sql, $bindings = [], $timeMs = null, $opts = []);Dumpio::http($method, $url, $status = null, $opts = []);Dumpio::log($level, $message, $details = [], $opts = []);Dumpio::model($class, $attributes, $opts = []);Dumpio::collection($items, $opts = []);Dumpio::table($columns, $rows, $opts = []);Dumpio::performance($metrics, $opts = []);Dumpio::event($event, $opts = []); // configDumpio::configure(['host' => '…', 'port' => 21234, 'token' => '…', 'enabled' => true]);Flags: red yellow blue gray purple pink green.
Default host/port: localhost:21234. Default channel: default.