Building custom Laravel Pulse recorders to track your own metrics

4 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.

laravel
pulse
monitoring
devops
Steven Richardson

Steven is a software engineer with a passion for building scalable web applications. He enjoys sharing his knowledge through articles and tutorials.