Creating a custom Laravel Pulse card

December 4, 2023

Laravel Pulse is a lightweight application monitoring tool for Laravel. It was just released today and I took a bit of time to create a custom card to show outdated composer dependencies.

This is what the card looks like right now:

I was surprised at how easy the card was to create. Pulse has all of the infrastructure built out for:

  • storing data in response to events or on a schedule
  • retrieving your data back, aggregated or not
  • rendering your data into a view.

The hooks are all very well thought through.

There is no official documentation for custom cards yet, so much of this is subject to change. Everything I'm telling you here I learned through diving into the source code.

Recording data

The first step is to create a Recorder that will record the data you're looking to monitor. If you open config/pulse.php you'll see a list of recorders:

/*
|--------------------------------------------------------------------------
| Pulse Recorders
|--------------------------------------------------------------------------
|
| The following array lists the "recorders" that will be registered with
| Pulse, along with their configuration. Recorders gather application
| event data from requests and tasks to pass to your ingest driver.
|
*/
'recorders' => [
Recorders\Servers::class => [
'server_name' => env('PULSE_SERVER_NAME', gethostname()),
'directories' => explode(':', env('PULSE_SERVER_DIRECTORIES', '/')),
],
 
// more recorders ...
]

The recorders listen for application events. Pulse emits a SharedBeat event if your recorder needs to run on an interval instead of in response to an application event.

For example, the Servers recorder records server stats every 15 seconds in response to the SharedBeat event:

class Servers
{
public string $listen = SharedBeat::class;
 
public function record(SharedBeat $event): void
{
if ($event->time->second % 15 !== 0) {
return;
}
 
// Record server stats...
}
}

But the Queue recorder listens for specific application events:

class Queues
{
public array $listen = [
JobReleasedAfterException::class,
JobFailed::class,
JobProcessed::class,
JobProcessing::class,
JobQueued::class,
];
 
public function record(
JobReleasedAfterException|JobFailed|JobProcessed|JobProcessing|JobQueued $event
): void
{
// Record the job...
}
}

In our case, we just need to check for outdated packages once a day on a schedule, so we'll use the SharedBeat event.

Creating the recorder

The recorder is a plain PHP class with a record method. Inside of that method you're given one of the events to which you're listening. You also have access to Pulse in the constructor.

class Outdated
{
public string $listen = SharedBeat::class;
 
public function __construct(
protected Pulse $pulse,
protected Repository $config
) {
//
}
 
public function record(SharedBeat $event): void
{
//
}
 
}

The SharedBeat event has a time property on it, which we can use to decide if we want to run or not.

class Outdated
{
// ...
 
public function record(SharedBeat $event): void
{
// Only run once per day
if ($event->time !== $event->time->startOfDay()) {
return;
}
}
}
Code highlighting powered by torchlight.dev (A service I created!)

Pulse will handle invoking the record method, we just need to figure out what to do there. In our case we're going to run composer outdated.

class Outdated
{
// ...
 
public function record(SharedBeat $event): void
{
// Only run once per day
if ($event->time !== $event->time->startOfDay()) {
return;
}
 
// Run composer to get the outdated dependencies
$result = Process::run("composer outdated -D -f json");
 
if ($result->failed()) {
throw new RuntimeException(
'Composer outdated failed: ' . $result->errorOutput()
);
}
 
// Just make sure it's valid JSON
json_decode($result->output(), JSON_THROW_ON_ERROR);
}
}

Writing to the Pulse tables

Pulse ships with three separate tables:

  • pulse_aggregates
  • pulse_entries
  • pulse_values

There is currently no documentation, but from what I can tell the pulse_aggregates table stores pre-computed rollups of time-series data for better performance. The entries table stores individual events, like requests or exceptions. The values table seems to be a simple "point in time" store.

We're going to use the values table to stash the output of composer outdated. To do this, we use the pulse->set() method.

class Outdated
{
// ...
 
public function record(SharedBeat $event): void
{
// Only run once per day
if ($event->time !== $event->time->startOfDay()) {
return;
}
 
// Run composer to get the outdated dependencies
$result = Process::run("composer outdated -D -f json");
 
if ($result->failed()) {
throw new RuntimeException(
'Composer outdated failed: ' . $result->errorOutput()
);
}
 
// Just make sure it's valid JSON
json_decode($result->output(), JSON_THROW_ON_ERROR);
 
// Store it in one of the Pulse tables
$this->pulse->set('composer_outdated', 'result', $result->output());
}
}

Now our data is stored and will be updated once per day. Let's move on to displaying that data!

(Note: You don't have to create a recorder. Your card can pull data from anywhere!)

Displaying the data

Pulse is built on top of Laravel Livewire. To add a new Pulse card to your dashboard, we'll create a new Livewire component called ComposerOutdated.

php artisan livewire:make ComposerOutdated
 
# COMPONENT CREATED 🤙
# CLASS: app/Livewire/ComposerOutdated.php
# VIEW: resources/views/livewire/composer-outdated.blade.php

By default, our ComposerOutdated class extends Livewire's Component class, but we're going to change that to extend Pulse's Card class.

namespace App\Livewire;
 
use Livewire\Component;
use Laravel\Pulse\Livewire\Card;
 
class ComposerOutdated extends Component
class ComposerOutdated extends Card
{
public function render()
{
return view('livewire.composer-outdated');
}
}

To get our data back out of the Pulse data store, we can just use the Pulse facade. This is one of the things I'm really liking about Pulse. I don't have to add migrations, maintain tables, add new models, etc. I can just use their data store!

class ComposerOutdated extends Card
{
public function render()
{
// Get the data out of the Pulse data store.
$packages = Pulse::values('composer_outdated', ['result'])->first();
 
$packages = $packages
? json_decode($packages->value, JSON_THROW_ON_ERROR)['installed']
: []
 
return View::make('composer-outdated', [
'packages' => $packages,
]);
}
}

Publishing the Pulse dashboard

To add our card to the Pulse dashboard, we must first publish the vendor view.

php artisan vendor:publish --tag=pulse-dashboard

Now, in our resources/views/vendor/pulse folder, we have a new dashboard.blade.php where we can add our custom card. This is what it looks like by default:

<x-pulse>
<livewire:pulse.servers cols="full" />
 
<livewire:pulse.usage cols="4" rows="2" />
 
<livewire:pulse.queues cols="4" />
 
<livewire:pulse.cache cols="4" />
 
<livewire:pulse.slow-queries cols="8" />
 
<livewire:pulse.exceptions cols="6" />
 
<livewire:pulse.slow-requests cols="6" />
 
<livewire:pulse.slow-jobs cols="6" />
 
<livewire:pulse.slow-outgoing-requests cols="6" />
</x-pulse>

Adding our custom card

We can now add our new card wherever we want!

<x-pulse>
<livewire:composer-outdated cols="1" rows="3" />
 
<livewire:pulse.servers cols="full" />
 
<livewire:pulse.usage cols="4" rows="2" />
 
<livewire:pulse.queues cols="4" />
 
<livewire:pulse.cache cols="4" />
 
<livewire:pulse.slow-queries cols="8" />
 
<livewire:pulse.exceptions cols="6" />
 
<livewire:pulse.slow-requests cols="6" />
 
<livewire:pulse.slow-jobs cols="6" />
 
<livewire:pulse.slow-outgoing-requests cols="6" />
</x-pulse>

Community site

There is a lot to learn about Pulse, and I'll continue to post here as I do. I'm working on builtforpulse.com to showcase Pulse-related packages and articles, so make sure you stay tuned over there!

GitHub Package

You can see this package at github.com/aarondfrancis/pulse-outdated.

YouTube Video

Me

Thanks for reading! My name is Aaron and I'm a Developer Educator at PlanetScale — a MySQL platform.

If you ever have any questions or want to chat, I'm always on Twitter.

You can find me on YouTube on my personal channel or my work channel, or my behind the scenes channel.