Building custom Laravel Pulse recorders to track your own metrics

Add custom metrics to Laravel Pulse with a recorder class. Track payment success rates, API quota, or any domain event — no third-party monitoring tool needed.

Steven Richardson
Steven Richardson
· 5 min read

Laravel Pulse ships with solid built-in recorders — slow queries, exceptions, failed jobs. But your app has domain-specific things worth tracking too: payment success rates, API quota consumption, feature flag evaluations. Pulse's recorder system is designed to be extended, and it's simpler than it looks.

What a Laravel Pulse custom recorder does#

Pulse's ingest pipeline has three stages: record → aggregate → display. Recorders sit at the first stage. They listen for an event (or hook into a framework extension point) and write an entry to the Pulse ingest using Pulse::record().

The service provider boots registered recorders at startup. If your recorder class has a public $listen property, Pulse wires it up to the event dispatcher automatically. No manual event listener registration needed.

Building a custom Pulse recorder#

Create app/Pulse/Recorders/PaymentSuccessRecorder.php:

<?php

namespace App\Pulse\Recorders;

use App\Events\PaymentSucceeded;
use Illuminate\Contracts\Config\Repository;
use Laravel\Pulse\Pulse;
use Laravel\Pulse\Recorders\Concerns\Sampling;

class PaymentSuccessRecorder
{
    use Sampling;

    /**
     * Pulse wires this up to the event dispatcher automatically.
     */
    public string $listen = PaymentSucceeded::class;

    public function __construct(
        protected readonly Pulse $pulse,
        protected readonly Repository $config,
    ) {}

    /**
     * Called once per dispatched PaymentSucceeded event.
     */
    public function record(PaymentSucceeded $event): void
    {
        if (! $this->shouldSample()) {
            return;
        }

        $this->pulse->record(
            type: 'payment_success',
            key: $event->currency,           // group by currency
            value: (int) ($event->amount * 100), // store in pence/cents
        )->sum()->count();
    }
}

A few things to note. $listen is a plain string, not an array — Pulse handles the event subscription. The Sampling trait reads pulse.recorders.{YourClass}.sample_rate from config, so you get sampling for free with zero extra logic. ->sum()->count() tells Pulse to aggregate this type as both a running sum and a count, which lets your card display total revenue and number of transactions separately.

Wiring the recorder into Pulse#

Open config/pulse.php and add your recorder to the recorders array:

'recorders' => [
    // ... built-ins ...

    \App\Pulse\Recorders\PaymentSuccessRecorder::class => [
        'sample_rate' => env('PULSE_PAYMENT_SAMPLE_RATE', 1),
    ],
],

That's it. No service provider changes, no manual event registration. Pulse's boot process picks it up.

Displaying the data in a Pulse card#

You need two things: a Livewire component and a Blade view.

The Livewire component (app/Livewire/Pulse/PaymentSuccessCard.php):

<?php

namespace App\Livewire\Pulse;

use Illuminate\Contracts\Support\Renderable;
use Illuminate\Support\Facades\View;
use Laravel\Pulse\Livewire\Card;
use Livewire\Attributes\Lazy;

#[Lazy]
class PaymentSuccessCard extends Card
{
    public function render(): Renderable
    {
        [$entries, $time, $runAt] = $this->remember(
            fn () => $this->aggregate(
                'payment_success',   // must match type string in recorder
                ['sum', 'count'],
                'sum',               // order by highest revenue first
            )
        );

        return View::make('livewire.pulse.payment-success-card', [
            'entries' => $entries,
            'time'    => $time,
            'runAt'   => $runAt,
        ]);
    }
}

The Blade view (resources/views/livewire/pulse/payment-success-card.blade.php):

<x-pulse::card :cols="$cols" :rows="$rows" :class="$class">
    <x-pulse::card-header name="Payment Successes" />

    <x-pulse::scroll>
        @foreach ($entries as $entry)
            <div class="flex justify-between px-4 py-2">
                <span>{{ strtoupper($entry->key) }}</span>
                <span>{{ $entry->count }} × {{ number_format($entry->sum / 100, 2) }}</span>
            </div>
        @endforeach
    </x-pulse::scroll>
</x-pulse::card>

Register the Livewire component in a service provider:

// AppServiceProvider::boot()
use Livewire\Livewire;

Livewire::component('pulse.payment-success-card', \App\Livewire\Pulse\PaymentSuccessCard::class);

Then add it to your published Pulse dashboard view (resources/views/vendor/pulse/dashboard.blade.php):

<livewire:pulse.payment-success-card cols="4" rows="2" />

Gotchas and Edge Cases#

The $listen property must be the FQCN. Using a string alias or short name won't work — the service provider passes it directly to the event dispatcher's listen() method.

The type string in aggregate() must exactly match what you pass to record(). If they differ by even a character, the card renders empty. I've caught myself using payment-success in one place and payment_success in the other more than once.

shouldSample() uses Lottery::odds() — it's probabilistic, not deterministic. For high-frequency events (thousands per minute), this is fine. If you need reproducible sampling keyed to a specific ID (e.g., always record or skip the same user), use shouldSampleDeterministically($user->id) instead.

Pulse writes are buffered. If a request ends before the buffer flushes, entries can be lost. For critical metrics, consider wrapping in $this->pulse->lazy() or using the Redis ingest driver with pulse:work running as a daemon.

PHP 8.4 note: The readonly modifier on constructor-promoted properties in the recorder class is fine and encouraged. Pulse resolves recorders through the container, so all standard DI patterns work.

Wrapping Up#

The full pattern is: a class with $listen and record(), registered in config/pulse.php, paired with a Livewire card that queries by type. Once you've built one, the second takes about ten minutes. Start with whatever your on-call team actually asks about first.

Pulse covers application-level metrics; for exception tracking and stack traces you'll want Sentry self-hosted running alongside it — they serve different purposes and complement each other well. To reduce noise from bot-generated exceptions polluting both tools, blocking malicious 404s with NGINX stops the traffic before it reaches your application. Pulse fits within The Complete Laravel Developer Toolchain for 2026 as the metrics layer alongside Nightwatch's APM data.

FAQ#

What's the difference between Sampling and deterministic sampling in Pulse recorders?

The shouldSample() method uses Lottery::odds(), which is probabilistic — each call has a random chance to sample. Use shouldSampleDeterministically($identifier) instead when you need deterministic sampling based on a user ID, order ID, or other stable identifier so the same entity is always sampled or always skipped.

Why is my custom Pulse recorder not firing?

Check that your $listen property is the FQCN (fully qualified class name) as a string, not an alias. Also verify the type string in aggregate() matches exactly what you pass to record() — even a single character difference will cause the card to render empty.

Can I record custom metrics outside of event listeners?

Yes. You can call Pulse::record() anywhere in your application directly, not just in a recorder's record() method. However, wrapping it in a recorder with $listen keeps your code organized and enables sampling automatically.

How do I prevent memory loss if my queue worker crashes between buffer flushes?

Use the Redis ingest driver with pulse:work running as a daemon, or wrap critical recordings in $this->pulse->lazy() to ensure they're flushed immediately. For most applications, the default storage driver with periodic flushing is sufficient.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.