Laravel Error Tracking: Exception Handler to Funnel Impact
Laravel error tracking that correlates exceptions with funnel drop-offs. Stop debugging blind.
The checkout page had been throwing a 500 for three hours before anyone noticed. We only caught it because a customer emailed support asking why their order didn't go through. Sentry had the exception. GA4 showed the traffic drop. Neither tool knew they were looking at the same broken user flow.
I spent forty minutes correlating timestamps by hand — error spike at 14:23, traffic cliff at 14:24, support ticket at 17:08. By then we'd lost maybe 200 orders. Not catastrophic, but embarrassing. The kind of thing you lie awake thinking about.
That week I decided I was done running two tools that should've been one. (My cofounder still teases me about the "200-order incident." Fair.)
This tutorial walks through wiring Laravel's exception handler to JustAnalytics so you get error tracking and pageview analytics in a single integration. We'll cover the exception handler hook, Blade component tracking, Livewire events, and queue job instrumentation. The whole thing is about 90 lines of PHP and replaces what used to require Sentry + GA4 + a lot of manual timestamp matching.
What you'll have by the end
A Laravel project with:
- Automatic exception capture from Laravel's exception handler (with full stack traces, request context, and user IDs)
- Pageview tracking from a Blade component you can drop into your layout
- Livewire action tracking for SPA-style interactions
- Queue job instrumentation for Horizon and standalone workers
- All of it hitting one dashboard where you can correlate errors with funnel drop-offs
If you're currently paying $26/month for Bugsnag's Team plan plus whatever your analytics costs, JustAnalytics Pro at $49/month ($39/month billed annually) covers 1M events/month across up to 5 sites — and includes APM, session replay, uptime monitoring, and structured logs alongside the Laravel error tracking you're building here.
Prerequisites
- PHP 8.1+ and Laravel 10 or 11 (tested on Laravel 11.x)
- Composer for package management
- A JustAnalytics account — free tier works fine for testing (100K events/month)
composer require guzzlehttp/guzzleif not already installed- Optional: Laravel Livewire 3.x for component tracking
- Optional: Laravel Horizon for queue monitoring
If you're on Django instead of Laravel, we have a parallel middleware tutorial that covers the same pattern.
Step 1: Create the tracking service
First, let's build a service class that handles both pageview and exception tracking. Create a new file at app/Services/Analytics/JustAnalyticsService.php:
<?php
namespace App\Services\Analytics;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Throwable;
class JustAnalyticsService
{
private string $apiKey;
private string $siteId;
private string $endpoint = 'https://api.justanalytics.app/v1/events';
public function __construct()
{
$this->apiKey = config('services.justanalytics.api_key', '');
$this->siteId = config('services.justanalytics.site_id', '');
}
public function trackPageview(Request $request, float $durationMs = 0): void
{
if (empty($this->apiKey)) {
return;
}
$this->sendEvent('pageview', [
'path' => $request->path(),
'method' => $request->method(),
'status_code' => 200,
'duration_ms' => round($durationMs, 2),
'user_id' => $request->user()?->id,
'referrer' => $request->header('referer'),
'user_agent' => substr($request->userAgent() ?? '', 0, 500),
]);
}
public function trackException(Throwable $e, ?Request $request = null): void
{
if (empty($this->apiKey)) {
return;
}
$properties = [
'exception_type' => get_class($e),
'exception_message' => substr($e->getMessage(), 0, 1000),
'file' => $e->getFile(),
'line' => $e->getLine(),
'traceback' => substr($e->getTraceAsString(), 0, 5000),
];
if ($request) {
$properties['path'] = $request->path();
$properties['method'] = $request->method();
$properties['user_id'] = $request->user()?->id;
}
$this->sendEvent('exception', $properties);
}
public function trackEvent(string $eventName, array $properties = []): void
{
if (empty($this->apiKey)) {
return;
}
$this->sendEvent($eventName, $properties);
}
private function sendEvent(string $eventName, array $properties): void
{
try {
Http::timeout(2)
->withToken($this->apiKey)
->post($this->endpoint, [
'site_id' => $this->siteId,
'event' => $eventName,
'properties' => $properties,
]);
} catch (Throwable) {
// Fail silently — don't break the app for analytics
}
}
}
The 2-second timeout is deliberate. You don't want a flaky analytics endpoint holding up user requests. If the event doesn't make it, that's fine — we're tracking trends, not running a payment ledger.
Look, I'm not pretending this is the most elegant code I've ever written. It's 90 lines of "good enough" that replaced two vendor SDKs and a lot of frustration.
Register the service in your AppServiceProvider:
// app/Providers/AppServiceProvider.php
use App\Services\Analytics\JustAnalyticsService;
public function register(): void
{
$this->app->singleton(JustAnalyticsService::class);
}
And add the config values to config/services.php:
// config/services.php
'justanalytics' => [
'api_key' => env('JUSTANALYTICS_API_KEY'),
'site_id' => env('JUSTANALYTICS_SITE_ID'),
],
Drop your credentials in .env:
JUSTANALYTICS_API_KEY=your-api-key-here
JUSTANALYTICS_SITE_ID=your-site-id-here
Grab both from the JustAnalytics dashboard under Settings → API Keys.
Step 2: Hook into the exception handler
Laravel 11 simplified exception handling. You don't need a dedicated Handler.php anymore — everything goes in bootstrap/app.php. Add the reporting callback:
// bootstrap/app.php
use App\Services\Analytics\JustAnalyticsService;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
)
->withExceptions(function (Exceptions $exceptions) {
$exceptions->report(function (Throwable $e) {
$analytics = app(JustAnalyticsService::class);
$analytics->trackException($e, request());
});
})
->create();
For Laravel 10, you'd modify app/Exceptions/Handler.php instead:
// app/Exceptions/Handler.php (Laravel 10)
protected function reportable(Throwable $e): bool
{
app(JustAnalyticsService::class)->trackException($e, request());
return true;
}
That's it for error tracking. Every unhandled exception now gets captured with stack trace, file, line number, and request context. The request() helper might return null in queue contexts — the service handles that gracefully by just skipping request properties.
Step 3: Blade component for pageviews
For pageview tracking, create a Blade component you can drop into your layout. This fires on every page load and captures the current route:
// app/View/Components/Analytics.php
<?php
namespace App\View\Components;
use App\Services\Analytics\JustAnalyticsService;
use Illuminate\View\Component;
class Analytics extends Component
{
public function __construct(
private JustAnalyticsService $analytics
) {}
public function render()
{
$this->analytics->trackPageview(request());
return '';
}
}
Register it as an anonymous component in your layout:
{{-- resources/views/layouts/app.blade.php --}}
<!DOCTYPE html>
<html>
<head>
<title>@yield('title')</title>
</head>
<body>
<x-analytics />
@yield('content')
</body>
</html>
Every page that extends this layout now gets tracked. The component renders nothing visible — it just fires the event and returns empty string.
One thing to watch: if you're using full-page caching (like Varnish or Laravel's page cache), the component might not execute on cached hits. Either exclude tracked pages from the cache or switch to client-side tracking with the under-5KB script tag approach.
Step 4: Livewire action tracking
If you're using Livewire, you probably want visibility into component actions — which methods get called, how often, and how long they take. Add a trait:
// app/Traits/TracksLivewireActions.php
<?php
namespace App\Traits;
use App\Services\Analytics\JustAnalyticsService;
trait TracksLivewireActions
{
public function bootTracksLivewireActions(): void
{
$this->listeners[] = 'trackLivewireAction';
}
public function trackLivewireAction(string $action, array $params = []): void
{
app(JustAnalyticsService::class)->trackEvent('livewire_action', [
'component' => static::class,
'action' => $action,
'params' => $params,
]);
}
protected function trackAction(string $action, array $params = []): void
{
$this->trackLivewireAction($action, $params);
}
}
Use it in your Livewire components:
// app/Livewire/CheckoutForm.php
<?php
namespace App\Livewire;
use App\Traits\TracksLivewireActions;
use Livewire\Component;
class CheckoutForm extends Component
{
use TracksLivewireActions;
public function submitOrder(): void
{
$this->trackAction('submit_order', ['plan' => $this->selectedPlan]);
// ... actual order logic
}
}
Now you can see which Livewire actions users trigger most, correlate them with errors, and spot broken flows. I found a race condition this way once — two users clicked "submit" twice in quick succession and the component threw a duplicate key exception that only surfaced in analytics.
Is this overkill for a simple CRUD app? Probably. But if you've ever spent an afternoon debugging "it works on my machine," you'll appreciate knowing exactly which action blew up.
Step 5: Queue job instrumentation
Queue jobs don't go through the HTTP exception handler, but Laravel still processes their exceptions through the same pipeline. The exception hook we added in Step 2 catches queue failures automatically.
For success tracking and timing, add a base job class:
// app/Jobs/TrackedJob.php
<?php
namespace App\Jobs;
use App\Services\Analytics\JustAnalyticsService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
abstract class TrackedJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function handle(): void
{
$start = microtime(true);
try {
$this->execute();
$this->trackCompletion($start, 'completed');
} catch (\Throwable $e) {
$this->trackCompletion($start, 'failed');
throw $e;
}
}
abstract protected function execute(): void;
private function trackCompletion(float $start, string $status): void
{
$durationMs = (microtime(true) - $start) * 1000;
app(JustAnalyticsService::class)->trackEvent('job_' . $status, [
'job' => static::class,
'queue' => $this->queue ?? 'default',
'duration_ms' => round($durationMs, 2),
]);
}
}
Extend it for your jobs:
// app/Jobs/ProcessPayment.php
<?php
namespace App\Jobs;
class ProcessPayment extends TrackedJob
{
public function __construct(
private int $orderId
) {}
protected function execute(): void
{
// ... payment processing logic
}
}
If you're running Horizon, you get the job timing and completion status across all your workers in one dashboard. We've caught three slow-creeping performance regressions this way — jobs that crept from 200ms to 4 seconds over two weeks because of an N+1 query nobody noticed until queue depth started building.
Common errors and how to fix them
"Class JustAnalyticsService not found" in production. You probably cached config before setting the env vars. Run php artisan config:clear and php artisan cache:clear on deploy. Laravel's config cache doesn't re-read .env automatically.
Events fire in local but not in production. Check that your production secrets manager is actually exposing JUSTANALYTICS_API_KEY to your PHP process. Laravel Forge and Vapor handle this automatically, but if you're on a custom setup with systemd or supervisor, the env vars might not be inherited. I've lost two hours to this exact problem on a Docker deployment where the .env was mounted but the container wasn't restarting.
Queue jobs show "exception" events but no "job_completed" events. Your jobs aren't extending TrackedJob. The exception handler catches failures automatically, but success tracking requires the base class.
Livewire actions aren't tracked. Make sure you're calling $this->trackAction() explicitly in your methods. The trait doesn't auto-instrument — you have to opt in per action. I considered using PHP attributes for this but honestly, explicit calls are easier to grep for.
Pageviews are doubled. You've probably got the Blade component in multiple layouts, or you're rendering it inside a cached partial that gets included twice. Check your view hierarchy. I made this mistake myself when I had both layouts/app.blade.php and layouts/admin.blade.php including the component, then extended admin from app. Classic.
What Laravel Error Tracking Won't Fix
I said I'd be honest, so here's the part where I temper expectations.
This setup doesn't give you distributed tracing across microservices. If you're running 10 services and need to follow a request through all of them, you want OpenTelemetry or Datadog's APM — not this. JustAnalytics supports OpenTelemetry (we've got a guide on that), but that's a different architecture than "drop in a service class and forget it."
It also doesn't replace real-time alerting. You'll see errors in the dashboard, but if you need PagerDuty integration or a 2am phone call, wire up a separate alerting pipeline. JustAnalytics has alerting and webhooks through its uptime monitoring feature, but if you want SMS to your actual phone at 3am, you'll want to hook those webhooks into something like Twilio or PagerDuty yourself.
And if you're behind a load balancer that strips headers, you'll lose referrer and user-agent data. Most cloud providers preserve these, but I've seen custom Kubernetes ingresses that don't. Check your headers before assuming the service is broken.
Next steps
You've got unified analytics and error tracking in one service. The obvious follow-up is wiring it into your deploy pipeline so you can correlate errors with releases — tag your events with APP_VERSION from your .env and you'll see exactly which deploys broke things.
If you're running paid acquisition alongside your Laravel app, the analytics data pairs well with ClickzProtect for click fraud detection. We've written up how to correlate errors with funnel drop-offs if you want the full picture. For teams managing email sequences, JustEmails integrates with your analytics events to track opens and clicks alongside pageviews. And if you're building browser-based tools, check out JustBrowser for browser automation that plays nice with your Laravel error tracking stack.
The full code from this tutorial is in our examples repo. Copy it, break it, make it yours. If something doesn't work, yell at me in the comments — I probably forgot to mention some edge case.
Frequently Asked Questions
Does this work with Laravel Octane and Swoole?
Yes, but you need to watch for request context leaking between coroutines. Octane recycles the application container, so if you store state in a service provider singleton, it can bleed across requests. The solution is either using request-scoped bindings or passing the request explicitly to your tracking methods. We've run this pattern on an Octane app handling 4K requests/second without issues once we fixed the scoping.
How does this compare to installing Bugsnag or Sentry's Laravel package separately?
Bugsnag and Sentry are solid for error tracking — but that's all they do. You still need GA4 or something else for pageviews and conversion tracking. This setup gives you both in one integration, one vendor, one dashboard. JustAnalytics Pro at $49/month ($39/month billed annually) covers 1M events/month across up to 5 sites, plus APM, session replay, uptime, and structured logs — five tools, one bill.
Can I track errors from queued jobs and Artisan commands?
Yes. Queue jobs run through Laravel's exception handler just like HTTP requests, so they get captured automatically. For Artisan commands, wrap your command logic in a try-catch and call the tracking service directly. We include an example for scheduled tasks in the Horizon section — the pattern is identical for standalone commands.
Will this slow down my request/response cycle?
The HTTP call adds about 2-4ms depending on network latency. We set a 2-second timeout and fire-and-forget, so even if the endpoint is slow, it won't block your response past the timeout. On a Laravel app doing 300 requests/second behind nginx, we measured p99 overhead of 5ms — noticeable if you're chasing sub-10ms responses, but most apps won't feel it.
Try JustAnalytics
All-in-one observability in one under-5KB script: cookieless analytics + error tracking + APM + session replay + uptime + structured logs. Replaces GA4 + Sentry + Datadog + Pingdom + LogRocket. Free tier (100K events/mo), Pro $49/month ($39 annual).
Author at JustAnalytics.