Filament v4 Render Hooks: Injecting UI Into Any Panel Without Hacking Core
#filament #laravel #filament-v4 #panels

Filament v4 Render Hooks: Injecting UI Into Any Panel Without Hacking Core

3 min read Mohamed Said Mohamed Said

The Problem With Publishing Views

The moment you run php artisan vendor:publish --tag=filament-views you own those views forever. Every Filament upgrade becomes a manual diff exercise. Render hooks exist precisely to avoid that trap — they are named slots baked into Filament's own Blade templates where you can push arbitrary HTML, Livewire components, or Alpine snippets without touching a single vendor file.

How Render Hooks Work

Filament ships a FilamentView facade (backed by Filament\Support\Facades\FilamentView) that maintains a registry of closures keyed by hook name. At render time each Blade template calls @filamentRenderHook('hook.name'), which resolves and echoes every registered closure in order.

Registration lives in a PanelProvider or any service provider booted after Filament:

use Filament\Support\Facades\FilamentView;
use Filament\View\PanelsRenderHook;

public function boot(): void
{
    FilamentView::registerRenderHook(
        PanelsRenderHook::BODY_START,
        fn (): string => Blade::render('<livewire:impersonation-banner />'),
    );
}

The closure must return a string or a Htmlable. Returning a View instance works too because View implements Htmlable:

FilamentView::registerRenderHook(
    PanelsRenderHook::PAGE_HEADER_ACTIONS_BEFORE,
    fn (): \Illuminate\Contracts\View\View =>
        view('partials.environment-ribbon', ['env' => app()->environment()]),
);

Scoping Hooks to Specific Pages or Resources

Global hooks fire on every page. Pass a scopes array to limit execution:

use App\Filament\Resources\OrderResource\Pages\ListOrders;
use Filament\View\PanelsRenderHook;

FilamentView::registerRenderHook(
    PanelsRenderHook::PAGE_FOOTER_WIDGETS_AFTER,
    fn (): string => Blade::render('<livewire:order-export-progress />'),
    scopes: [ListOrders::class],
);

Scopes accept any combination of page classes, resource classes, or widget classes. Filament resolves the current page class at render time and skips hooks whose scope does not match.

Key Hook Names in v4

Filament v4 consolidates hook names under PanelsRenderHook. The most useful ones:

| Constant | Location | |---|---| | BODY_START | Right after <body> | | BODY_END | Right before </body> | | SIDEBAR_NAV_START | Top of sidebar nav | | SIDEBAR_NAV_END | Bottom of sidebar nav | | PAGE_HEADER_ACTIONS_BEFORE | Before page header action buttons | | PAGE_FOOTER_WIDGETS_AFTER | After footer widget grid | | GLOBAL_SEARCH_START | Above the global search input | | TOPBAR_START | Left side of the top bar |

Always reference the PanelsRenderHook class constants rather than raw strings — they are typed and refactor-safe.

Injecting a Livewire Component With Context

Closures receive the current $livewire component instance when Filament passes it. Declare it in the closure signature:

use Livewire\Component;

FilamentView::registerRenderHook(
    PanelsRenderHook::PAGE_HEADER_ACTIONS_BEFORE,
    function (Component $livewire): string {
        if (! $livewire instanceof \App\Filament\Resources\InvoiceResource\Pages\EditInvoice) {
            return '';
        }
        $id = $livewire->record?->getKey();
        return Blade::render("<livewire:invoice-status-badge :invoice-id=\"$id\" />");
    },
);

This pattern is cleaner than scopes when you need access to the record or route parameters.

Organising Hooks at Scale

Once you have more than a handful of hooks, extract them into dedicated classes:

// app/Filament/Hooks/ImpersonationHooks.php
class ImpersonationHooks
{
    public static function register(): void
    {
        FilamentView::registerRenderHook(
            PanelsRenderHook::BODY_START,
            fn (): View => view('filament.hooks.impersonation-banner'),
        );
    }
}

// In PanelProvider::boot()
ImpersonationHooks::register();

Group by feature domain, not by hook position. This makes it trivial to disable an entire feature's UI injection in one line.

Takeaways

  • Register hooks in PanelProvider::boot() or any service provider; never publish core views.
  • Use PanelsRenderHook constants — not raw strings — for type safety.
  • Scope hooks to specific page or resource classes to avoid unnecessary rendering.
  • Accept the Component $livewire argument when you need record or route context.
  • Extract hook registrations into feature-scoped classes as the panel grows.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Can I register render hooks inside a Filament plugin's register method?
Yes. Plugins receive the panel instance in `register(Panel $panel)`, but render hooks are global to FilamentView, so you can call `FilamentView::registerRenderHook()` from either `register` or `boot` inside your plugin class. Using `boot` is safer if your hook depends on other bindings being resolved first.
Q02 Do render hooks affect performance when registered but not scoped?
Each hook closure is called on every matching page render, so keep closures lightweight. For Livewire components the cost is the component mount, not the hook itself. Scoping to specific page classes eliminates the closure call entirely on non-matching pages.
Q03 How do I remove a render hook registered by a third-party package?
Filament v4 does not expose a public deregister API. The practical workaround is to override the package's service provider or use a macro/decorator on FilamentView if the package supports it. Alternatively, file an issue with the package author to wrap their hook in a config flag.

Continue reading

More Articles

View all