Two days ago I wrote about removing Laravel Pulse because I wasn’t using it. Then I needed an admin panel for Growth Method and ended up reinstalling it. Here’s what happened.
Table of contents
Open Table of contents
What I wanted
I needed a private dashboard where I could:
- Run artisan commands from the browser (like regenerating embeddings for campaigns)
- See agent activity and token costs across the platform
- Monitor queue health, failed jobs, and scheduled tasks
- Check which campaigns were missing embeddings
- Link out to Aikido Security, Sentry, and Horizon
The question was: what’s the best way to build it?
The options: Pulse vs Filament vs Nova vs custom Livewire
I evaluated four approaches. Here’s how they compare:
| Laravel Pulse | Filament | Laravel Nova | Custom Livewire | |
|---|---|---|---|---|
| What it is | First-party monitoring dashboard with custom card support | Full admin panel framework | First-party admin panel (paid) | Build it yourself from scratch |
| UI system | Livewire components | Its own Blade components (~40 packages) | Vue.js | Whatever you already use |
| Best for | Monitoring, health checks, system dashboards | Full CRUD admin panels with resources | Full admin panels with polished UI | Complete control over everything |
| Complexity | Low — add cards to a dashboard | Medium — separate UI system to learn | Medium — Vue.js, separate from Livewire | Low to high — depends on scope |
| Packages added | 1 (laravel/pulse) | ~40 Composer packages | 1 (laravel/nova, paid $99–$299) | 0 |
| Custom features | Custom cards are just Livewire components | Resources, actions, widgets | Resources, actions, lenses | Anything you build |
| Fits existing Livewire app? | Yes — same stack | No — separate UI system | No — Vue.js | Yes |
What the Laravel community thinks
Taylor Otwell built Pulse specifically as the monitoring solution for Laravel apps. Mohamed Said (Laravel core team) was a key contributor. Nuno Maduro integrates Pulse with his tools — it works alongside Horizon. Freek Van der Herten (Spatie) has written about Pulse as the go-to for app monitoring and published custom Pulse card packages. And Caleb Porzio built Livewire, which Pulse runs on — so custom cards feel native.
For CRUD admin panels, Filament or Nova. For monitoring and system health, Pulse.
Why I chose Pulse
The turning point was when I asked:
Based on this simpler starting scope, do you still think Filament is the best way forward vs a custom dashboard?
The answer made sense: my scope was monitoring and health checks, not CRUD. Filament would add ~40 packages and a separate UI system I’d have to learn, when Growth Method already runs on Livewire. I’d be using roughly 2% of what Filament offers.
Then the embarrassing realisation:
Well that’s embarrassing, I only recently removed Pulse! I didn’t realise a) it had so many built-in cards and b) that I can create custom Pulse cards (Livewire components). It feels like the perfect solution for what I need.
Two days after writing a blog post about removing Pulse, I was reinstalling it. The difference: this time I actually understood what it could do.
What I built
The Pulse dashboard now has built-in cards for queue throughput, servers, exceptions, slow queries, slow requests, slow jobs, and cache performance. On top of those, I built four custom cards.
Embedding Coverage
Shows a progress bar of how many campaigns have vector embeddings, with a count of missing ones. A custom Pulse card is just a Livewire component that extends Card:
use Laravel\Pulse\Livewire\Card;
use Livewire\Attributes\Lazy;
#[Lazy]
class EmbeddingCoverage extends Card
{
public function render()
{
$total = Idea::count();
$withEmbedding = Idea::whereNotNull('embedding')->count();
$missing = $total - $withEmbedding;
$percentage = $total > 0
? round(($withEmbedding / $total) * 100, 1) : 0;
return view('livewire.pulse.embedding-coverage', [
'total' => $total,
'withEmbedding' => $withEmbedding,
'missing' => $missing,
'percentage' => $percentage,
]);
}
}
That’s it. Extend Card, return a view. If you can build a Livewire component, you can build a Pulse card.
Agent Activity
Shows agent runs (today, this week, this month), total token usage, and a breakdown by agent type over the last 7 days.
External Monitoring
Clickable links to Aikido Security, Sentry, Horizon, and the /up health check endpoint. Simple but useful — one place to jump to any external monitoring tool.
Command Runner
I asked:
Could I also add custom flags? For example, if I want to re-embed all campaigns for a specific team, select the team from a dropdown first?
The answer: each command is defined as a config array with scopes. The card renders a dropdown for the scope (all / missing / specific team), a team picker when needed, and a “Run” button. Clicking it calls Artisan::queue() to push the command onto the queue:
Artisan::queue($this->selectedCommand, $arguments);
No HTTP timeouts, no blocking the browser. The job runs on the queue and you see it in Pulse’s built-in queue card.
The big gotcha: Laravel 13 and serializable_classes
Everything worked until the Pulse cards tried to auto-refresh. Every 5 seconds, Livewire polls to update the cards. On the first poll, this error appeared:
The script tried to call a method on an incomplete object. Please ensure that the class definition “Illuminate\Support\Collection” of the object you are trying to operate on was loaded before unserialize() gets called.
What’s happening
Laravel 13 introduced a security setting in config/cache.php:
'serializable_classes' => false,
This tells unserialize() to block all PHP objects from being reconstructed from cache. It prevents PHP object injection attacks — a real security risk where an attacker crafts a serialised payload that executes code when unserialised.
The problem: Pulse caches Collection objects for its dashboard cards. When Pulse tries to read cached data on the next poll, Laravel refuses to reconstruct the Collection objects, and they become __PHP_Incomplete_Class — which breaks when the Blade template calls ->isEmpty() on them.
What didn’t work
This took a long debugging session. Things that didn’t fix it:
- Setting
PULSE_CACHE_DRIVER=redis— the default was already Redis. The cache driver wasn’t the issue. - Preloading the Collection class with
class_exists()— the error isn’t about autoloading, it’s aboutserializable_classesblocking reconstruction. - Creating a dedicated cache store with a per-store
serializesetting —CacheManager::getSerializableClasses()ignores per-store config entirely. It only reads the global setting.
That last one was the real trap. I spent time adding a pulse cache store to config/cache.php:
'pulse' => [
'driver' => 'redis',
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
'serialize' => true, // This does nothing!
],
But CacheManager bakes serializable_classes into the store at construction time, using only the global config value. Per-store overrides are silently ignored.
The fix
Three things working together:
1. A dedicated pulse cache store in config/cache.php — not for the config override (that doesn’t work), but so Pulse gets its own memoised instance separate from the app’s default store.
2. Point Pulse at the dedicated store in config/pulse.php:
'cache' => env('PULSE_CACHE_DRIVER', 'pulse'),
3. A custom PulseCacheStoreResolver that flips serializable_classes to true before the pulse store is first constructed, then restores the strict setting:
class PulseCacheStoreResolver extends CacheStoreResolver
{
private bool $resolved = false;
public function store(): CacheRepository
{
if (! $this->resolved) {
$original = $this->config->get('cache.serializable_classes');
$this->config->set('cache.serializable_classes', true);
$storeName = $this->config->get('pulse.cache') ?? 'pulse';
$this->cache->forgetDriver($storeName);
$store = parent::store();
$this->config->set('cache.serializable_classes', $original);
$this->resolved = true;
return $store;
}
return parent::store();
}
}
Registered in AppServiceProvider:
$this->app->bind(
\Laravel\Pulse\Support\CacheStoreResolver::class,
PulseCacheStoreResolver::class,
);
The timing matters. CacheManager memoises stores by name. The app’s default cache store gets built early (by session middleware) with serializable_classes = false. If Pulse shares that store, it inherits the strict setting. The dedicated pulse store is only built when Pulse first needs it — and by then, our resolver has temporarily flipped the config.
The rest of the app keeps serializable_classes => false. Only Pulse’s store allows object deserialisation.
Why not just set serializable_classes => true globally?
That works — it’s the same behaviour as Laravel 12. But the setting exists for a reason. Object injection via cache is a real attack vector. Keeping the default strict and only allowing it for Pulse’s dedicated store is the safer approach.
What would Taylor Otwell do?
Taylor built Pulse with a PULSE_CACHE_DRIVER config specifically because Pulse is expected to have its own cache store. The infrastructure for a dedicated store was always there — Laravel 13’s stricter security default just made it necessary.
For system health and monitoring, Pulse is the convention.