From dd() to Ray: A Debugging Workflow That Doesn't Break Your Flow

From dd() to Ray: A Debugging Workflow That Doesn't Break Your Flow

9 min read Debugging
Share:

From dd() to Ray: A Debugging Workflow That Doesn't Break Your Flow

In my previous post, I talked about understanding the Laravel request lifecycle as a way to know where bugs live instead of guessing. But once you've identified the stage, you still need to actually see what's happening in your code.

For most Laravel developers, that means one thing: dd().

And look, dd() is great. It's been my go-to for years. But somewhere along the way, I realized it was also slowing me down. Not because it's a bad tool — but because of how it works.

Let me show you what I mean, and how I evolved my debugging workflow into something that gives me way more visibility without constantly breaking my application.

The dd() Workflow (And Why It Gets Frustrating)

Let's say I'm debugging a checkout flow. An order is being created, but the total is wrong. I want to see what's happening with the data as it moves through the system.

My first instinct:

public function store(CheckoutRequest $request)
{
    $cart = Cart::findOrFail($request->cart_id);
    
    dd($cart->items);
    
    $order = Order::create([
        'user_id' => auth()->id(),
        'total' => $cart->calculateTotal(),
    ]);
    
    // ... rest of the logic
}

I hit the endpoint, and I see the cart items. Great. But wait — I also want to see what calculateTotal() returns. So I move the dd():

public function store(CheckoutRequest $request)
{
    $cart = Cart::findOrFail($request->cart_id);
    
    $total = $cart->calculateTotal();
    
    dd($total);
    
    $order = Order::create([
        'user_id' => auth()->id(),
        'total' => $total,
    ]);
    
    // ... rest of the logic
}

Okay, the total looks right here. But the order in the database is wrong. So now I want to see the order after it's created:

public function store(CheckoutRequest $request)
{
    $cart = Cart::findOrFail($request->cart_id);
    
    $order = Order::create([
        'user_id' => auth()->id(),
        'total' => $cart->calculateTotal(),
    ]);
    
    dd($order);
    
    // ... rest of the logic
}

See what's happening? I'm playing table tennis. Every time I want to see something new, I have to:

  1. Move or add the dd()
  2. Refresh the page
  3. Lose all the context from before that point
  4. Repeat

And here's the real problem: dd() kills the request. I can never see the full flow. I see one snapshot at a time, and then the request dies. If the bug is caused by something that happens after my dd(), I won't see it until I move the dd() again.

For a simple bug, this is fine. For anything involving multiple steps, events, jobs, or middleware — it becomes tedious.

Leveling Up: Using Log Statements

At some point, I realized I could just... not kill the request. Instead of dd(), I'd use Log::info():

public function store(CheckoutRequest $request)
{
    $cart = Cart::findOrFail($request->cart_id);
    
    Log::info('Checkout: Cart loaded', ['items' => $cart->items->toArray()]);
    
    $total = $cart->calculateTotal();
    
    Log::info('Checkout: Total calculated', ['total' => $total]);
    
    $order = Order::create([
        'user_id' => auth()->id(),
        'total' => $total,
    ]);
    
    Log::info('Checkout: Order created', ['order' => $order->toArray()]);
    
    event(new OrderCreated($order));
    
    Log::info('Checkout: Event dispatched');
    
    return redirect()->route('orders.show', $order);
}

Now I can see everything. The request completes, all the logic runs, and I can trace the full flow in my log file. If something goes wrong after the order is created — like in an event listener — I'll see it.

This was a game changer. I went from seeing one frozen moment to seeing the entire movie.

But then I ran into a new problem: reading log files is painful.

[2024-01-15 10:23:45] local.INFO: Checkout: Cart loaded {"items":[{"id":1,"product_id":5,"quantity":2,"price":"29.99"},{"id":2,"product_id":12,"quantity":1,"price":"49.99"}]}
[2024-01-15 10:23:45] local.INFO: Checkout: Total calculated {"total":109.97}
[2024-01-15 10:23:45] local.INFO: Checkout: Order created {"order":{"id":42,"user_id":1,"total":"109.97","created_at":"2024-01-15T10:23:45.000000Z","updated_at":"2024-01-15T10:23:45.000000Z"}}
[2024-01-15 10:23:45] local.INFO: Checkout: Event dispatched
[2024-01-15 10:23:46] local.INFO: Some other unrelated log
[2024-01-15 10:23:46] local.INFO: Another log from somewhere else
[2024-01-15 10:23:46] local.INFO: Checkout: Cart loaded {"items":[{"id":3,"product_id":8,"quantity":1,"price":"19.99"}]}

This works, but it's not exactly pleasant. The JSON is cramped. Multiple requests get interleaved. I have to dig through a wall of text to find what I'm looking for. And if I want to inspect a nested object, I'm squinting at a single line of JSON trying to make sense of it.

I needed something better.

Enter Ray: The Debugging Experience I Didn't Know I Wanted

Ray is a desktop application made by Spatie that receives debug output from your application. Instead of dumping to the browser or writing to a log file, you send data to Ray, and it displays it in a beautiful, searchable, organized interface.

Here's the same debugging flow with Ray:

public function store(CheckoutRequest $request)
{
    $cart = Cart::findOrFail($request->cart_id);
    
    ray('Cart loaded', $cart->items);
    
    $total = $cart->calculateTotal();
    
    ray('Total calculated', $total);
    
    $order = Order::create([
        'user_id' => auth()->id(),
        'total' => $total,
    ]);
    
    ray('Order created', $order);
    
    event(new OrderCreated($order));
    
    ray('Event dispatched');
    
    return redirect()->route('orders.show', $order);
}

When I hit the endpoint now, Ray lights up with each piece of data — displayed in a clean, expandable format. I can click into nested objects, see the data types, and everything stays organized by request.

The request completes. Nothing breaks. And I have a clear, visual timeline of exactly what happened.

Installing Ray

Getting Ray into your Laravel project takes about 30 seconds:

composer require spatie/laravel-ray --dev

That's it. The ray() helper is now globally available. Open the Ray app, run your code, and watch the output stream in.

Why Ray Changed How I Debug

Let me show you some specific scenarios where Ray really shines.

Seeing Multiple Values Without Multiple Dumps

With dd(), I'd often do this:

dd($user, $order, $items, $total);

Which kind of works, but the output is a mess — everything crammed together with no labels.

With Ray:

ray($user)->label('User');
ray($order)->label('Order');
ray($items)->label('Items');
ray($total)->label('Total');

Each one shows up as a separate, labeled entry. I can collapse the ones I don't care about and expand the ones I do.

Or even simpler:

ray([
    'user' => $user,
    'order' => $order,
    'items' => $items,
    'total' => $total,
]);

Tracking Execution Flow with Colors

When I'm debugging something complex, I like to visually distinguish different phases:

ray('Starting checkout process')->blue();

// ... validation logic
ray('Validation passed')->green();

// ... payment processing
ray('Payment processed')->green();

// ... if something fails
ray('Inventory check failed', $unavailableItems)->red();

In the Ray app, these show up with colored markers. I can scan the list and immediately see where things went wrong — the red entries jump out.

Conditional Debugging

Sometimes I only want to see output when a certain condition is true:

ray($order)->showWhen($order->total > 1000);

This only sends to Ray if the order total exceeds 1000. Super useful when I'm debugging a specific edge case and don't want to be flooded with noise from normal requests.

Or the inverse:

ray($cart)->removeWhen($cart->items->isEmpty());

Measuring Performance

When I suspect something is slow, I can measure it:

ray()->measure();

$results = $this->heavyDatabaseQuery();

ray()->measure();

$processed = $this->processResults($results);

ray()->measure();

Ray shows the time elapsed between each measure() call. No more manually calculating timestamps.

Pausing Execution (When You Actually Need It)

Sometimes I do want to stop execution — but in a more controlled way than dd(). Ray has a pause feature:

ray('About to do something important', $data)->pause();

$this->doSomethingImportant();

The request pauses, and Ray shows a "Continue" button. I can inspect the data, and when I'm ready, I click Continue and the request proceeds. It's like a breakpoint, but without needing to set up Xdebug.

Debugging Queries: Where Ray Really Shines

This is where Ray became indispensable for me. Instead of manually logging queries or digging through Laravel Telescope, I can just tell Ray to watch them:

ray()->showQueries();

$users = User::with('posts.comments')
    ->where('active', true)
    ->get();

ray()->stopShowingQueries();

Ray now shows me every query that ran, with:

  • The full SQL (with bindings interpolated so I can copy-paste it directly into a database client)
  • The time each query took
  • Where the query originated in my code

This is invaluable for N+1 debugging. I can immediately see if a loop is firing off hundreds of queries when it should be firing one.

Seeing Query Count

Sometimes I don't need to see every query — I just want to know how many ran:

ray()->countQueries(function () {
    $this->loadDashboardData();
});

Ray tells me: "23 queries executed." Now I know whether my optimization efforts are working.

Debugging Beyond HTTP Requests

One place where dd() completely falls apart is debugging queued jobs or event listeners. The job runs in a separate process — there's no browser to dump to.

With Log statements, I can see what happened, but I'm back to reading log files.

With Ray, it just works:

// app/Jobs/ProcessOrder.php

public function handle()
{
    ray('ProcessOrder job started', $this->order)->blue();
    
    // ... processing logic
    
    ray()->showQueries();
    
    $this->order->items->each(function ($item) {
        ray('Processing item', $item)->gray();
        $this->updateInventory($item);
    });
    
    ray()->stopShowingQueries();
    
    ray('ProcessOrder job completed')->green();
}

I queue the job, and Ray shows me everything that happened inside it — queries included. No log file parsing required.

Debugging Artisan Commands

Same deal with Artisan commands:

public function handle()
{
    ray()->newScreen('Import Users Command');
    
    $rows = $this->parseCSV();
    
    ray(count($rows) . ' rows to import');
    
    foreach ($rows as $index => $row) {
        ray("Processing row {$index}")->gray();
        
        // ... import logic
    }
    
    ray('Import complete')->green();
}

The newScreen() method clears Ray and starts fresh — useful when I'm running a command repeatedly and don't want old output cluttering things up.

My Current Workflow

Here's how I actually debug these days:

  1. Identify the lifecycle stage (from my previous post) — this tells me where to look
  2. Add a few ray() calls at key points in that stage — this tells me what is happening
  3. Use ray()->showQueries() if I suspect a database issue
  4. Run the code and watch Ray
  5. Refine — add more ray calls if needed, remove them when done

I rarely use dd() anymore except for quick one-off checks. And I almost never read raw log files for debugging — that's reserved for production issues where Ray isn't available.

When to Use What

Here's my mental framework:

Situation Tool
Quick "what is this value?" check dd()
Need to see the full request flow ray() or Log::info()
Debugging in development ray()
Debugging jobs, commands, or events ray()
Debugging queries or performance ray()->showQueries()
Production debugging Log::info() (Ray isn't for production)

Cleaning Up

One thing I appreciate about Ray: cleaning up is easy. I can search my codebase for ray( before committing and make sure I haven't left any debug statements behind.

Or I can just leave them. In production, if Ray isn't installed or the app isn't configured to send to Ray, the calls are silently ignored. They won't break anything.

That said, I still prefer to remove them. Debug code is noise, and I like my code clean.

The Bigger Picture

The point of all this isn't that Ray is magic or that dd() is bad. The point is that debugging is a workflow, and the right tools make that workflow faster.

dd() is fine for quick checks. Log statements are essential for production. But for serious development debugging — where I need to understand the full flow of a complex feature — Ray has become my go-to.

It lets me see everything without breaking anything. And that changes how I approach problems. I'm not playing table tennis anymore. I'm watching the full picture unfold.

Give it a try. I think you'll find it hard to go back.

Share: