Skip to content
Go back

Pulse vs Filament vs Nova: Choosing the Right Laravel Admin Panel

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:

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 PulseFilamentLaravel NovaCustom Livewire
What it isFirst-party monitoring dashboard with custom card supportFull admin panel frameworkFirst-party admin panel (paid)Build it yourself from scratch
UI systemLivewire componentsIts own Blade components (~40 packages)Vue.jsWhatever you already use
Best forMonitoring, health checks, system dashboardsFull CRUD admin panels with resourcesFull admin panels with polished UIComplete control over everything
ComplexityLow — add cards to a dashboardMedium — separate UI system to learnMedium — Vue.js, separate from LivewireLow to high — depends on scope
Packages added1 (laravel/pulse)~40 Composer packages1 (laravel/nova, paid $99–$299)0
Custom featuresCustom cards are just Livewire componentsResources, actions, widgetsResources, actions, lensesAnything you build
Fits existing Livewire app?Yes — same stackNo — separate UI systemNo — Vue.jsYes

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:

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.


Back to top ↑