Stop Guessing: Debug Laravel Like You Actually Understand It

Stop Guessing: Debug Laravel Like You Actually Understand It

9 min read Debugging
Share:

Stop Guessing: Debug Laravel Like You Actually Understand It

After years of involvement in the developer's communities like Laracasts and StackOverflow, I've noticed a pattern. Someone posts a problem, shares their code, and asks "why isn't this working?" Then they share what they've tried: dd() here, dd() there, dump the request, dump the response, check the logs, clear the cache, run composer dump-autoload, restart the server just in case, and maybe run php artisan optimize:clear one more time for good measure.

Sometimes they get lucky. Most of the time, they're just guessing.

Here's the thing — Laravel isn't magic. Every request follows a predictable path through your application. When something breaks, it breaks at a specific point in that path. If you understand the path, you stop guessing and start knowing where to look.

The Request Lifecycle (The Mental Model That Actually Helps)

First I would like to give a huge shout-out to Matt Stauffer and his book Laravel Up & Running as it helped me tremendously to understand lots of things among which is the Request Lifecycle.

I'm not going to bore you with a wall of theory. You can read the docs or the book for that. But here's the mental model I use every single time I debug:

1. The request hits public/index.php

This is ground zero. Every single request to your Laravel application — whether it's an API call, a page load, or a form submission — enters through this one file. It does two critical things: loads Composer's autoloader (so PHP knows where to find all your classes) and bootstraps the Laravel application instance. If something is fundamentally broken at this level — like a syntax error in a service provider or a missing dependency — your app won't even get off the ground. You'll usually see a hard error or a blank page before anything else happens.

2. Service providers register and boot

This is where Laravel wires itself together. Service providers are the backbone of the framework — they register bindings in the service container, set up event listeners, register routes, and configure pretty much everything. There are two phases here: register() runs first on all providers (this is where you bind things into the container), then boot() runs on all providers (this is where you can use those bindings because everything is now registered). The order matters. If you try to use a service in register() that hasn't been registered yet, you'll get cryptic "target class does not exist" errors.

3. The request enters the middleware pipeline

Once the app is bootstrapped, your HTTP request starts traveling through middleware. Think of middleware as a series of checkpoints. Global middleware runs on every request — things like checking if the app is in maintenance mode, handling CORS, starting the session. Then route-specific middleware kicks in — authentication, authorization, rate limiting. Each middleware can inspect the request, modify it, reject it entirely, or pass it along to the next layer. The order here is critical, and it's a common source of bugs.

4. The router matches a route

Now Laravel's router takes the incoming URL and HTTP method and tries to find a matching route. It goes through your registered routes in the order they were defined until it finds a match. If it finds one, it knows which controller and method to call. If it doesn't find a match, you get a 404. Route model binding also happens here — if your route has {user} in it and you've type-hinted a User model in your controller, Laravel will automatically fetch that user from the database.

5. The controller method executes

This is where your actual application code runs. The controller receives the request (already processed by middleware), does whatever business logic you've written, interacts with models and services, and prepares a response. Most of the bugs developers think about live here — wrong data, failed queries, logic errors.

6. The response travels back out

Here's what a lot of developers forget: the response doesn't just teleport to the browser. It travels back through the middleware pipeline in reverse order. Middleware can modify the response on the way out — adding headers, transforming content, logging. CORS headers, for example, often get added at this stage. If your response is getting modified unexpectedly, this is where I'd look.


That's the path. Every single request. Every single time.

When something breaks, it breaks in one of these stages. The trick is recognizing which stage based on what you're seeing.

The 404 That Isn't a 404

Let me give you an example I've seen dozens of times on the forums.

Someone posts: "I'm getting a 404, but my route is definitely there. I've cleared the cache, I've run route:list, I can see it. What's going on?"

Here's their routes file:

// web.php

Route::get('/users/{user}', [UserController::class, 'show'])->name('users.show');
Route::get('/users/export', [UserController::class, 'export'])->name('users.export');

They're hitting /users/export and getting a 404. Or worse, they're getting a 500 error about a missing User model with ID "export".

Now, my first instinct when I see a 404 isn't to check the controller or the database. I ask myself: which stage of the lifecycle is this? A 404 means the router didn't find a match — or did it?

So I'd run:

php artisan route:list --path=users

And I'd look at the order. That's when it clicks.

Laravel's router evaluates routes in registration order. When a request comes in for /users/export, the router checks each route until it finds a match. It hits Route::get('/users/{user}') first, and guess what? The pattern /users/{user} matches /users/export perfectly — {user} is just a wildcard that captures "export" as the value.

So the router did find a match. It matched the wrong route, then tried to find a User with ID "export", which doesn't exist — hence the error.

The fix is dead simple:

// web.php

Route::get('/users/export', [UserController::class, 'export'])->name('users.export');
Route::get('/users/{user}', [UserController::class, 'show'])->name('users.show');

Specific routes before wildcard routes. Always.

But here's my point: if I'd been thinking "my code is broken," I'd be checking the controller, checking the model, checking the database. The bug isn't in any of those places. The bug is in the routing stage of the lifecycle.

The Authenticated User That's Always Null

Here's another one that comes up constantly.

Someone's building an API and they want to log which user makes each request. They create a middleware:

// app/Http/Middleware/LogApiRequests.php

class LogApiRequests
{
    public function handle(Request $request, Closure $next)
    {
        Log::info('API Request', [
            'user_id' => auth()->id(),
            'path' => $request->path(),
        ]);

        return $next($request);
    }
}

They register it in their kernel (Laravel < 12) or their bootstrap/app.php (Laravel >12), and... user_id is always null. Even when they're definitely authenticated. They check their token, they check their auth config, they dump auth()->user() in the controller and it works fine there.

My first thought: which stage of the lifecycle is this?

This screams middleware pipeline to me. The user is authenticated by the time they hit the controller. But in this middleware, they're not. That tells me this middleware is running before the authentication middleware has done its job.

So I'd check the middleware registration order in bootstrap/app.php (or app/Http/Kernel.php in older Laravel versions). And sure enough, their logging middleware is registered as global middleware, running before the auth middleware processes the request.

The fix? Either move the logging to after authentication:

->withMiddleware(function (Middleware $middleware) {
    $middleware->api(append: [
        LogApiRequests::class, // Now runs after auth
    ]);
})

Or accept that in this middleware, the user might not be authenticated yet and handle that case:

public function handle(Request $request, Closure $next)
{
    $response = $next($request);
    
    // Log on the way OUT, after auth has run
    Log::info('API Request', [
        'user_id' => auth()->id(),
        'path' => $request->path(),
    ]);

    return $response;
}

This bug has nothing to do with authentication configuration. It has nothing to do with tokens or guards. It's purely about when in the lifecycle you're trying to access the authenticated user.

A Debugging Framework (Not a Tool — A Way of Thinking)

When something breaks, I don't immediately reach for dd(). Instead, I ask myself: what stage of the lifecycle is this?

Symptoms that point to bootstrap/service provider issues:

  • Class not found errors
  • Method not found on facades
  • Configuration values not loading
  • "Target class does not exist" in the container

Symptoms that point to middleware issues:

  • Authentication/authorization working inconsistently
  • Request data being modified unexpectedly
  • CORS problems
  • Session/cookie weirdness
  • Data available in controller but not in middleware (or vice versa)

Symptoms that point to routing issues:

  • 404 errors when the route "should" exist
  • Wrong controller method being called
  • Route parameters not matching what you expect
  • Route model binding failing

Symptoms that point to controller/application logic:

  • Wrong data coming back
  • Database queries not doing what you expect
  • Business logic bugs

Once I know the stage, I know where to look. Then — and only then — do I pull out my debugging tools.

The Tools (Now That You Know Where to Point Them)

Once I've identified the stage, here's how I actually debug:

For routing issues:

php artisan route:list --path=users

This shows you exactly what routes are registered and in what order. If your route isn't there, it's not registered. If it's in the wrong order, now you know.

For middleware issues:

// Temporarily in your controller
public function show(Request $request)
{
    dd($request->all(), $request->user(), session()->all());
}

Check what the request looks like after it's been through the middleware pipeline. If something's missing here that you expected middleware to provide, work backwards.

For service provider issues:

php artisan tinker
>>> app()->make(SomeService::class)

If it blows up, your service isn't registered properly. Check which provider should be registering it and whether that provider is listed in your app config or bootstrap/app.php file.

For "is this even being reached" issues:

Log::info('Checkpoint 1: Before auth check');

Sometimes you just need to know if a piece of code is executing at all. Strategic logging beats scattered dd() calls because you can trace the full flow.

The Actual Skill

Here's what I want you to take away from this: debugging isn't about knowing every method and every tool. It's about having a mental model of how requests flow through your application.

When you understand the lifecycle, you stop asking "why is my code broken?" and start asking "which stage is broken?" That one shift in thinking will save you hours.

Next time something breaks, don't scatter dd() calls like you're hoping to get lucky. Stop. Think about the lifecycle. Identify the stage. Then investigate.

You'll be surprised how often the answer becomes obvious.

Share: