DEV Community

Cover image for Laravel Pulse Is Free Datadog. Here's How to Make It Replace Yours.
Gabriel Anhaia
Gabriel Anhaia

Posted on

Laravel Pulse Is Free Datadog. Here's How to Make It Replace Yours.


A team I talked to last month was paying Datadog $4,800 a month. Eight prod app servers, four queue workers, a couple of staging boxes. The CFO finally asked the obvious question: what do we actually use it for?

Three dashboards. Slow queries, queue depth, exception rate. Two alerts: 5xx spike and queue backlog. That's it. A $57k/year invoice for what php artisan pulse:install ships in the box.

Laravel Pulse covers about 80% of what most Laravel shops pay Datadog for. The 20% gap (alerting, distributed tracing, infrastructure metrics) is real, but it's a weekend of plumbing, not a reason to keep the invoice.

What Pulse ships by default

Run composer require laravel/pulse and php artisan pulse:install on Laravel 11+ and you get a dashboard at /pulse with cards for:

  • Slow queries: every query over the configured threshold (default 1s), grouped by SQL pattern, with sample bindings and the originating action.
  • Slow requests: slow HTTP endpoints with route, count, slowest, average.
  • Slow jobs: same thing for queued jobs.
  • Exceptions: exception class, file, count, last-seen.
  • Queues: per-queue throughput, queued/processing/failed.
  • Cache: hit/miss ratio per key prefix.
  • Servers: CPU, memory, storage (if you run the agent).
  • Usage: top users by request count, by job count, by slow endpoint hit count.

The data lives in your own database (default driver) or Redis (recommended for anything past a small app). Ingestion is async by default. Pulse buffers entries in-memory and flushes them on the Pulse::ingestUntil cycle, so the recording overhead per request is essentially noise.

That's already 80% of an APM. You see what's slow, what's broken, what's growing. The remaining 20% is what the rest of this post covers.

The custom recorder pattern

Pulse's killer feature isn't the default dashboard. It's that adding a recorder for your own metric takes about 30 lines.

Say you want to track Stripe MRR on your dashboard alongside slow queries and queue depth. The whole thing is a recorder + a card.

<?php

namespace App\Pulse\Recorders;

use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Cache;
use Laravel\Pulse\Events\SharedBeat;
use Laravel\Pulse\Pulse;
use Laravel\Pulse\Recorders\Concerns\Throttling;

class StripeMrrRecorder
{
    use Throttling;

    // listen to the heartbeat Pulse fires every second across the cluster
    public string $listen = SharedBeat::class;

    public function __construct(protected Pulse $pulse) {}

    public function record(SharedBeat $event): void
    {
        // throttle so only one node per minute does the work
        $this->throttle(60, $event, function () use ($event) {
            $mrr = Cache::remember(
                'stripe:mrr:cents',
                now()->addMinutes(5),
                fn () => $this->fetchMrrFromStripe()
            );

            $this->pulse->set('stripe_mrr', 'global', $mrr);
        });
    }

    private function fetchMrrFromStripe(): int
    {
        // your Stripe client of choice. Total active subscription MRR in cents.
        return app(\App\Billing\StripeMrrCalculator::class)->totalCents();
    }
}
Enter fullscreen mode Exit fullscreen mode

Register it in config/pulse.php:

'recorders' => [
    \App\Pulse\Recorders\StripeMrrRecorder::class => [],
    // ... the defaults
],
Enter fullscreen mode Exit fullscreen mode

SharedBeat is the trick. Pulse fires it once a second cluster-wide on whichever node grabs the lock first, so you don't need a separate scheduler entry. The Throttling trait makes sure heavy work runs at the cadence you want, not every second.

Now the value is in storage. To render it, build a card.

<?php

namespace App\Pulse\Cards;

use Livewire\Attributes\Lazy;
use Laravel\Pulse\Facades\Pulse;
use Laravel\Pulse\Livewire\Card;

#[Lazy]
class StripeMrrCard extends Card
{
    public function render()
    {
        $value = Pulse::values('stripe_mrr', ['global'])
            ->first()?->value ?? 0;

        return view('pulse.cards.stripe-mrr', [
            'mrr' => $value / 100,
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

The Blade template is whatever you want it to look like. Drop the card into the Pulse dashboard layout file (resources/views/vendor/pulse/dashboard.blade.php after publishing) with <livewire:stripe-mrr-card /> and you have a finance metric next to your slow query log.

The same shape works for anything: failed payment count per hour, signup funnel conversion, support ticket volume, container restart count. If you can write a 5-line PHP function that returns a number or a small JSON blob, you can have it as a Pulse card.

Per-tenant dashboards

Pulse stores entries with a type and a key. The key field is where multi-tenancy lives. By default the recorders use things like the route name or queue name as the key, but you can put anything in there.

Pattern: every entry your tenant-aware recorder writes gets the tenant ID baked into the key. Then the dashboard becomes a query against entries scoped to one tenant.

<?php

namespace App\Pulse\Recorders;

use Carbon\CarbonImmutable;
use Illuminate\Cache\Events\CacheHit;
use Illuminate\Cache\Events\CacheMissed;
use Laravel\Pulse\Pulse;

class TenantAwareCacheRecorder
{
    public array $listen = [
        CacheHit::class,
        CacheMissed::class,
    ];

    public function __construct(protected Pulse $pulse) {}

    public function record(CacheHit|CacheMissed $event): void
    {
        $tenantId = app('tenant.current')?->id;

        if ($tenantId === null) {
            return; // background jobs without tenant context, skip
        }

        $outcome = $event instanceof CacheHit ? 'hit' : 'miss';

        $this->pulse->record(
            type: 'cache_interaction',
            key: "tenant:{$tenantId}:{$outcome}",
            timestamp: CarbonImmutable::now(),
        )->count();
    }
}
Enter fullscreen mode Exit fullscreen mode

Then your tenant dashboard route filters by tenant:

Route::get('/tenants/{tenant}/pulse', function (Tenant $tenant) {
    $hits = Pulse::aggregate(
        type: 'cache_interaction',
        aggregates: ['count'],
        interval: now()->subHour(),
        keyFilter: "tenant:{$tenant->id}:hit",
    );

    $misses = Pulse::aggregate(
        type: 'cache_interaction',
        aggregates: ['count'],
        interval: now()->subHour(),
        keyFilter: "tenant:{$tenant->id}:miss",
    );

    return view('admin.tenant-pulse', compact('tenant', 'hits', 'misses'));
})->middleware(['auth', 'can:view-tenant-metrics']);
Enter fullscreen mode Exit fullscreen mode

The gotcha here is that Pulse's aggregate method does a LIKE scan on the key column when you use prefix matching. On a table with millions of entries per day, that's not free. The fix is either a composite index on (type, key, bucket) (Pulse already ships one, but check yours hasn't been dropped in a migration), or a dedicated tenant_id column added via a custom recorder that doesn't go through the standard record() method. For most apps the default index is fine until you cross about 5M rows in pulse_aggregates.

Alert plumbing: Pulse has no alerts

This is the legitimate gap. Pulse ships no alerting. If queue depth crosses 10,000, nobody knows unless someone happens to look at the dashboard. That's why teams keep paying for Datadog.

The plumbing is 60 lines. Console command + scheduler entry + Slack webhook.

<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;

class CheckPulseAlerts extends Command
{
    protected $signature = 'pulse:check-alerts';
    protected $description = 'Check pulse thresholds and ping Slack on breach';

    private const THRESHOLDS = [
        'queue.default.size' => 10_000,
        'queue.notifications.size' => 5_000,
        'exceptions.last_minute' => 50,
    ];

    public function handle(): int
    {
        $breaches = [];

        // queue depth: straight from the queue driver
        foreach (['default', 'notifications'] as $queue) {
            $size = Queue::size($queue);
            $threshold = self::THRESHOLDS["queue.{$queue}.size"];

            if ($size > $threshold) {
                $breaches[] = "queue `{$queue}` depth = {$size} (threshold {$threshold})";
            }
        }

        // exception count from Pulse's own storage
        $exceptionCount = \DB::table('pulse_entries')
            ->where('type', 'exception')
            ->where('timestamp', '>=', now()->subMinute()->timestamp)
            ->count();

        if ($exceptionCount > self::THRESHOLDS['exceptions.last_minute']) {
            $breaches[] = "exceptions in last minute = {$exceptionCount}";
        }

        if (empty($breaches)) {
            return self::SUCCESS;
        }

        Http::post(config('services.slack.alerts_webhook'), [
            'text' => "Pulse alerts:\n" . implode("\n", array_map(
                fn ($b) => "- {$b}",
                $breaches,
            )),
        ]);

        return self::SUCCESS;
    }
}
Enter fullscreen mode Exit fullscreen mode

Wire it into the scheduler:

// routes/console.php (Laravel 11+) or app/Console/Kernel.php
use Illuminate\Support\Facades\Schedule;

Schedule::command('pulse:check-alerts')
    ->everyMinute()
    ->withoutOverlapping()
    ->onOneServer();
Enter fullscreen mode Exit fullscreen mode

onOneServer() matters. Without it, every node in your cluster fires the check and you get N Slack messages per breach. With it, Laravel uses the cache lock to elect one node per tick.

That's the full alerting story. You'll add a deduplication layer eventually (nobody wants 60 "queue depth high" messages while they're driving), but the shape is the same. Add a Cache::remember("alert:{$key}", now()->addMinutes(5), ...) guard around each Http::post() and you get cooldown for free.

This is less sophisticated than Datadog's alert composer. It also fits in a single file and doesn't cost $31/host/month.

What Pulse doesn't do

Three real gaps.

Distributed tracing. Pulse records per-request and per-job timings, but it doesn't trace a request across services. If your Laravel app calls a Python ML service which calls a Go pricing service, Pulse shows you the Laravel slice and nothing else. There's no trace ID propagation, no flame graph spanning hops.

Log aggregation. Pulse is metrics + samples, not logs. Your Log::error() calls don't end up in Pulse. You still need somewhere for logs: Papertrail, BetterStack, Loki, even just CloudWatch.

Infrastructure metrics. The default Pulse server card reads /proc/loadavg and friends, which is fine for "is the box on fire" but not for container-level metrics, network latency between pods, disk I/O patterns, or anything below the PHP process. You can write a recorder that shells out, but that's a hack.

The hybrid: Pulse for app, OpenTelemetry for infra, free

Here's the setup most Laravel shops should actually run.

Pulse handles the application layer. Slow queries, slow requests, queue depth, exceptions, business metrics (MRR, signups, conversion), per-tenant cards. Free, in your DB, no vendor.

OpenTelemetry handles distributed tracing and infra. Install the open-telemetry/opentelemetry and open-telemetry/sdk Composer packages (version 1.0+ has been stable since 2024) and wire up the Laravel auto-instrumentation. Export to an OTel collector that fans out to whatever backend you want: Grafana Tempo, Honeycomb's free tier, even self-hosted Jaeger.

The Composer install is one line:

composer require open-telemetry/opentelemetry \
                open-telemetry/sdk \
                open-telemetry/exporter-otlp \
                open-telemetry/opentelemetry-auto-laravel
Enter fullscreen mode Exit fullscreen mode

Then in config/otel.php (or just env vars):

return [
    'service' => [
        'name' => env('OTEL_SERVICE_NAME', 'laravel-app'),
    ],
    'exporter' => [
        'endpoint' => env('OTEL_EXPORTER_OTLP_ENDPOINT', 'http://localhost:4318'),
    ],
];
Enter fullscreen mode Exit fullscreen mode

The auto-instrumentation package hooks into Laravel's HTTP kernel, queue worker, Eloquent, and Guzzle. Each request gets a trace ID. Outgoing HTTP calls propagate the traceparent header automatically (W3C Trace Context). When your Python service receives the request, it picks up the same trace ID and the picture stitches together in the backend of your choice.

The cost: $0 self-hosted, $0 on Grafana Cloud's free tier (50GB traces/month as of 2026), Honeycomb's free tier is 20M events/month. Even at Datadog APM's free trial limits, you're paying nothing.

The gotcha with the OTel + Pulse hybrid: don't double-record. Pulse's slow query recorder and OTel's DB instrumentation both record query timing. That's fine for the dashboard, but it doubles the per-query overhead. Pick one of them as the source of truth for query latency. Let OTel handle it if you're going to consume traces, and disable Pulse's slow query recorder via config/pulse.php:

'recorders' => [
    \Laravel\Pulse\Recorders\SlowQueries::class => false,
    // ... rest
],
Enter fullscreen mode Exit fullscreen mode

Or keep both and accept the cost. On a typical request the extra overhead is sub-millisecond. Your call.

The math

Datadog pricing in 2026:

  • APM: $31/host/month
  • Log Management: $1.27/GB ingested
  • RUM: $1.50/1000 sessions
  • Infra: $15/host/month

A modest setup (eight app hosts, four workers, log ingestion at 500GB/month) is around $1,400/month before the surprise overage line items.

Pulse + OTel + Honeycomb free tier + your existing log destination is $0 in vendor cost. You pay in the ~4 hours it takes to wire up the recorders you actually need and the alert command. The book pays for itself in the first month.

The teams I see this work for are anyone running Laravel 11+ with under a few hundred RPS. Past that, you start needing real infrastructure observability that goes deeper than what a self-hosted setup gives you on autopilot. But "we're at hyperscale and need Datadog" is rarer than the invoices suggest.


If this was useful

Pulse is the kind of thing that works because Laravel made the conventions sharp enough that you can extend it without breaking the model. The same instinct (write the boring plumbing once, keep the framework's defaults where they win, swap the layers you've outgrown) is what Decoupled PHP is about. It's the architectural layer your codebase reaches for once you've decided which parts of the framework you want to keep and which parts you want to own outright.

What's the first card you'd add to your own Pulse dashboard?

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)