zpmlabs/filament-popup
Filament Popup lets you create and manage modal popups directly from your Filament admin panel. Configure content, display conditions, and scheduling through a friendly resource, then show targeted messages to users without custom frontend code.
Install and register
composer require zpmlabs/filament-popup
php artisan vendor:publish --tag="filament-popup-migrations"
php artisan vendor:publish --tag="filament-popup-config"
php artisan migrate
Register the plugin on the panel(s) that should display popups — not globally in config/app.php:
// app/Providers/Filament/AdminPanelProvider.php
public function panel(Panel $panel): Panel
{
return $panel
->plugin(FilamentPopupPlugin::make())
// ...
}
Then run php artisan filament:assets (and php artisan optimize:clear if you're on a cached config) so the plugin's CSS/JS lands in public/.
Where to look first
config/filament-popup.php — this is where the real behaviour lives: frequency/dismissal strategy, delay, cache TTL, whether the resource is registered, and the render hook the modal is injected into. Read this before writing any code.src/FilamentPopupPlugin.php — confirms which render hook (PanelsRenderHook::BODY_END or similar) is used, how resources/pages are registered, and what ->navigation...() / ->resource(...) style fluent methods exist.PopupResource — it's the editing UI for popup records; its form schema tells you exactly which columns exist (title, body, image, CTA label/URL, is_active, publish window).--tag="filament-popup-views" if you need to override) — this is where the Alpine component and dismissal storage key are defined.First use case
A "what's new" announcement on the dashboard for logged-in users: create one popup record in the admin resource, set it active, point it at the dashboard, and give it a "Got it" dismiss that sticks per-user. That exercises the whole path — record → visibility rules → render hook → dismissal — in about five minutes, and it's the lowest-risk way to confirm the plugin is wired correctly on your panel before you start targeting.
Content lives in the database, not in code. Treat popup records as content: seed them in a seeder for local/staging so you're not clicking through the admin every time you migrate:fresh. Keep a PopupSeeder alongside your UserSeeder and gate it on app()->environment('local').
Targeting is the whole game. The naive install shows the popup everywhere. The patterns that actually hold up:
->modifyQueryUsing()-style hook, use it; otherwise an Eloquent global scope on the model is the clean fallback.starts_at / ends_at for scheduled announcements instead of manually toggling is_active. You will forget to turn it off at 9am on Monday.Dismissal semantics — pick one and be consistent.
| Strategy | Implementation | Good for |
|---|---|---|
| Per user, permanent | row in a popup_dismissals pivot or JSON column |
feature announcements |
| Per session | session key | "we've updated the dashboard" |
| Time-based | cookie with expiry | promotions, trials |
| Per popup version | key on popup_id + updated_at |
re-show after you edit the copy |
If the package ships cookie/localStorage-only dismissal, that's a real limitation: dismissal doesn't follow the user across devices and doesn't survive private browsing. For anything product-critical, add a dismissals relationship on User and a small ->visible(fn () => ! auth()->user()->hasDismissed($popup)) style constraint.
Multi-tenancy. The migration almost certainly ships without a tenant_id. If you're on Filament tenancy, add the column in your own migration and scope it in the resource's getEloquentQuery() — otherwise tenant A's admin sees tenant B's popups in the list.
Triggering popups from code, not from the DB. For "show a welcome modal right after registration" or "confirm before leaving a dirty form", don't create a popup record — use Filament's own Notification/Action modal or dispatch a browser event and let a small Alpine listener open the plugin's modal component directly. Database-backed popups are for content editors; programmatic modals are for developers. Mixing the two produces records named test123 in production.
Resource hardening. The popup resource is a CMS surface. Add a policy (PopupPolicy) so only admins create/edit, and consider hiding it from the nav (->navigationGroup(...) + shouldRegisterNavigation()) if content folks live in a separate panel.
Testing. In phpunit.xml, point FILAMENT_POPUP_ENABLED=false (or whatever the config toggle is) so popups don't randomly appear in Dusk/browser tests. For unit tests, assertDatabaseHas('popups', ...) after seeding, and use Livewire::test(PopupResource\Pages\CreatePopup::class) for the admin path.
Publish migrations before migrate, always. If you install into an existing project and run php artisan migrate first, Laravel may prompt for the package migration while your app migrations are half-applied. vendor:publish → review the migration → migrate, in that order.
Config caching bites on deploy. php artisan config:cache snapshots filament-popup.php. If you flip the plugin on for one environment via env vars, you need config:cache in your deploy script after the deploy, or you'll get a stale value. Same for view:cache if you override the Blade view.
SPA mode (wire:navigate) is the most likely breakage. With Filament's SPA mode enabled, the page body isn't fully re-rendered on navigation, so a popup injected at BODY_END may only ever evaluate on hard loads. Test explicitly: navigate between two pages and see whether the popup re-fires. Fix by hooking livewire:navigated in a small Alpine listener that checks dismissal state, rather than relying on a server render.
z-index and focus trapping. Filament modals sit at a high z-index; a popup with a hardcoded z-50 will appear behind an action modal and in front of notifications. If the package's view uses a fixed z-index, override the view and bump it. Also verify Escape closes it and focus is trapped — cheap to check, embarrassing to ship otherwise.
Dismissal keys must be versioned. If the storage key is just popup-{id}, editing a popup's copy won't re-show it to anyone who already dismissed it. Key on id + a version column you bump intentionally.
Editing a popup doesn't invalidate caches. If the config has a cache TTL (common in these plugins), a content editor will save and see nothing change for up to an hour. Add a model observer on saved/deleted that calls Cache::forget(...) with the package's cache key — or set the TTL to 0 in non-production.
Rendering cost on public panels. If you enable the plugin on a guest-facing panel, the popup query runs on every request including login. Gate it behind auth()->check() in the plugin's canView/visible callback so anonymous traffic doesn't pay for it.
Accessibility is usually the weakest part of small popup packages. If the shipped markup is a bare div with x-show, you'll want to add role="dialog", aria-modal="true", aria-labelledby pointing at the heading, and x-trap from Alpine Focus. That's a 10-line view override and worth doing before any compliance review.
Extension points worth knowing: the published view (full markup control), the plugin's fluent setters (usually ->delay(), ->frequency(), ->pages()), and the config file. If a requirement doesn't fit any of those three, resist forking — a custom Livewire component using Filament's own modal is cleaner than maintaining a fork of a 9-star package.
How can I help you explore Laravel packages today?