Building custom Laravel Pulse recorders to track your own metrics
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.
Steven is a software engineer with a passion for building scalable web applications. He enjoys sharing his knowledge through articles and tutorials.