vaslv / filament-topbar-menu
A configurable topbar menu plugin for Filament 5 — external and internal links, dropdowns, favicons, caching.
Requires
- php: ^8.2
- filament/filament: ^5.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- orchestra/testbench: ^10.0|^11.0
- phpunit/phpunit: ^11.5|^12.0
README
A Filament 5 plugin that adds a configurable menu to the panel topbar, right after the logo. Use it to link to your other services (external URLs) or to pages of the current Laravel application (named routes) — with dropdowns, favicons, per-item visibility, and caching out of the box.
- 🔗 External URLs and internal Laravel routes (with route parameters)
- 📂 Nested items — a top-level item can stay a link and open a dropdown with its children
- 🖼️ Icons and favicons — auto-resolve favicons for external links (never at render time)
- ⚡ Cached — no database query on page render; the cache is flushed automatically on changes
- 🛠️ Full Filament resource — create, edit, delete, drag-and-drop reordering, activate/deactivate
- 🌙 Truly Filament-native look — built from Filament's own topbar/dropdown components, so it's pixel-identical to the panel's
topNavigation()menu (dark mode, active-item highlight, spacing) with zero custom CSS - 🌍 Translatable — ships with English, Russian, German, Spanish and French; add your own
- 🪝 Rendered through the official
PanelsRenderHook::TOPBAR_LOGO_AFTERrender hook — no layout overrides
Requirements
- PHP 8.2+
- Filament ^5.0
Installation
Install the package via Composer:
composer require vaslv/filament-topbar-menu
The package migration is loaded automatically. Run it:
php artisan migrate
The menu is rendered with Filament's own topbar and dropdown components, so it inherits your theme automatically — there are no assets to build or publish.
Optionally publish the migration and config instead of using the bundled ones:
php artisan vendor:publish --tag=filament-topbar-menu-migrations php artisan vendor:publish --tag=filament-topbar-menu-config
Registering the plugin
Add the plugin to your panel in your PanelProvider (e.g. app/Providers/Filament/AdminPanelProvider.php):
use Vaslv\FilamentTopbarMenu\TopbarMenuPlugin; public function panel(Panel $panel): Panel { return $panel // ... ->plugin(TopbarMenuPlugin::make()); }
That's it — the menu renders in the topbar right after the logo, and a Topbar Menu resource appears in the panel navigation for managing the items.
Plugin options
TopbarMenuPlugin::make() // Hide the management resource on this panel (e.g. a public panel // that should only display the menu): ->resource(false) // Put the resource into a navigation group / position: ->resourceNavigationGroup('Settings') ->resourceNavigationSort(10) // Render the menu at a different panel render hook: ->renderHook(\Filament\View\PanelsRenderHook::TOPBAR_START)
Responsive behavior
The default TOPBAR_LOGO_AFTER hook renders the menu inside Filament's
.fi-topbar-start region, which Filament itself hides below the lg (1024px)
breakpoint — the same as its logo/sidebar controls. So with the default hook the
menu is visible on desktop widths and hidden on smaller screens, consistent with
the rest of the Filament topbar.
If you need the menu on narrow viewports, register it at a hook that stays visible
there, e.g. ->renderHook(\Filament\View\PanelsRenderHook::TOPBAR_END).
Managing menu items
Menu items live in the filament_topbar_menu_items table and are managed through the included Filament resource:
- Label, icon (any Filament-supported icon, e.g.
heroicon-o-link) and favicon URL - Link type — external
URLor internalLaravel route(with optional route parameters) - Target — open in the same tab or a new tab
- Parent item — items with a parent are shown in the parent's dropdown
- Active toggle and sort order (rows can also be reordered by drag & drop)
- Visibility — everyone, authenticated users only, or guests only
Demo data
To try the menu out quickly, seed a demo tree that exercises every feature — external links, an internal route link, a dropdown group, visibility rules and an inactive item:
php artisan db:seed --class="Vaslv\FilamentTopbarMenu\Database\Seeders\TopbarMenuSeeder"
The seeder is idempotent: items are matched by parent + label, so re-running it updates the demo items in place instead of duplicating them.
The role-restricted example group ("Admin Tools") is only seeded when your user
model can evaluate roles — i.e. it has a hasAnyRole() method, e.g. from
spatie/laravel-permission. Without roles support the package hides
role-restricted items from everyone (it fails closed), so the seeder skips the
example instead of seeding an item nobody can see — and removes it again on
re-run if roles support has gone away.
Example: external links
use Vaslv\FilamentTopbarMenu\Models\TopbarMenuItem; TopbarMenuItem::create([ 'label' => 'Grafana', 'type' => 'url', 'url' => 'https://grafana.example.com', 'target' => '_blank', ]);
The per-item target is authoritative: "Same tab" (_self) always opens in the same tab and "New tab" (_blank) always opens in a new one. The open_external_links_in_new_tab config only decides the default value of the target field when you create a new item in the resource (default: new tab) — it never overrides an explicit choice.
Example: internal route links
TopbarMenuItem::create([ 'label' => 'Orders', 'type' => 'route', 'route' => 'filament.admin.resources.orders.index', ]); TopbarMenuItem::create([ 'label' => 'Monthly report', 'type' => 'route', 'route' => 'reports.show', 'route_parameters' => ['report' => 'monthly'], ]);
If a named route no longer exists, the item is skipped instead of breaking the page.
Example: a dropdown menu
A top-level item with children renders as a Filament dropdown group — exactly like
the panel's native top navigation. The group label is a pure dropdown toggle and
the children are its links. Like Filament's own groups, the toggle itself does not
navigate: if a parent has children, its own url/route is ignored, so to make a
landing page reachable add it as an explicit child item.
$services = TopbarMenuItem::create([ 'label' => 'Services', 'type' => 'url', // a group with children is a toggle; its own url is not used ]); TopbarMenuItem::create([ 'label' => 'Analytics', 'type' => 'url', 'url' => 'https://analytics.example.com', 'parent_id' => $services->id, ]); TopbarMenuItem::create([ 'label' => 'Admin dashboard', 'type' => 'route', 'route' => 'filament.admin.pages.dashboard', 'parent_id' => $services->id, ]);
Visibility rules
The visibility JSON column supports:
['auth' => true] // authenticated users only ['guest' => true] // guests only ['roles' => ['admin', 'ops']] // users with any of these roles // (requires a hasAnyRole() method on your user model, // e.g. from spatie/laravel-permission) ['hide_on_current_domain' => true] // hide the item on the service its URL points at // (URL items only — see "Hiding each service's own link")
Visibility is evaluated per request — it is never baked into the cache.
Export & import
The list page has Export and Import header actions for moving the whole menu between installs (e.g. staging → production) or keeping it as a backup.
- Export downloads a JSON file with every item and all of its settings —
hierarchy, URLs/routes with parameters, targets, icons, favicons, sort order,
active state, and visibility rules (including
roles). - Import accepts such a file and recreates the items. By default the items are appended to the existing menu; enable "Replace the current menu" in the import dialog to wipe the menu first. The whole file is validated before anything is written, and the import runs in a single transaction — a broken file never deletes or half-imports anything.
Because the file is untrusted input, import re-applies the same guards as the
form: URLs and favicon URLs must be plain http(s) links (a javascript: or
data: link is rejected, never rendered into the topbar), route parameters must
be scalar, visibility rules must be well-shaped, and the tree may be at most two
levels deep. Export is gated behind the resource's view permission and import
behind create; the replace option only appears for users the delete policy
allows.
The file contains no database ids (hierarchy is expressed by nesting), so an export from one application imports cleanly into another. Unknown keys are ignored, so a file from a newer plugin version still imports as long as its export format version is unchanged.
Favicons
For external links the plugin can resolve the site's favicon and store it in the favicon_url column, so no remote HTTP request ever happens while the menu renders.
Resolution strategy (FaviconResolver):
- Try the conventional
https://host/favicon.ico. - Fall back to parsing
<link rel="icon">tags from the page HTML.
Ways to resolve favicons:
- The "Fetch favicon" suffix button on the Favicon URL field in the create/edit form.
- The "Fetch favicon" row action and the bulk action in the items table.
- The artisan command:
# Fill favicons for items that don't have one yet: php artisan filament-topbar-menu:refresh-favicons # Re-resolve all favicons (including existing ones): php artisan filament-topbar-menu:refresh-favicons --force # Only specific items: php artisan filament-topbar-menu:refresh-favicons --id=1 --id=2
Disable the whole feature with 'enable_favicons' => false in the config — the actions and the command become no-ops.
Configuration
// config/filament-topbar-menu.php return [ 'table_name' => 'filament_topbar_menu_items', // Database connection for the menu table. Unset it resolves to null and // follows the app's default connection (the one set by DB_CONNECTION), so // no separate menu database is assumed and existing installs keep working // unchanged after an update. Set the optional FILAMENT_TOPBAR_MENU_DB_CONNECTION // env variable to a dedicated connection to keep the menu in a separate, // possibly shared, database (see "Shared menu across projects" below). 'connection' => env('FILAMENT_TOPBAR_MENU_DB_CONNECTION') ?: null, 'cache_key' => 'filament-topbar-menu.items', 'cache_ttl' => 3600, 'enable_favicons' => true, 'favicon_request_timeout' => 5, // Default value of the target field for new items. The per-item choice // ("Same tab" / "New tab") always wins at render time; this is only a default. 'open_external_links_in_new_tab' => true, ];
Shared menu across projects
By default no separate menu database is assumed — the menu lives on the app's
default connection. To move it onto a dedicated connection (defined in
config/database.php), set the FILAMENT_TOPBAR_MENU_DB_CONNECTION env variable.
Point several apps at the same menu database and they all render one
centrally managed menu:
// config/database.php 'connections' => [ 'menu' => [ 'driver' => 'mysql', // ...credentials for the shared menu database... ], ], // .env (in every app that shares the menu) FILAMENT_TOPBAR_MENU_DB_CONNECTION=menu
The migration, every model query and the import transaction all follow this
connection. On a fresh install plain php artisan migrate is enough — the
package migration reads the connection config itself, so only one app needs
it pending for the table to be created. To create the table explicitly,
without touching any migration repository, use the dedicated command:
php artisan filament-topbar-menu:create-table
It creates the menu table on the configured connection when it is missing and
does nothing otherwise, so it is safe to keep in every app's deploy script.
Do not use php artisan migrate --database=menu: that relocates the
migration repository to the menu connection and replays every pending
application migration against the shared database.
Each app keeps its own cache, which is flushed locally whenever that app edits
an item — flush the others (TopbarMenu::flushCache()) or wait out cache_ttl
for cross-app edits to appear.
Switching an existing install
On an existing install the package migration is already recorded in the app's
own migrations table, so after setting FILAMENT_TOPBAR_MENU_DB_CONNECTION
plain php artisan migrate will skip it and the menu table never appears on
the new connection. The whole switch is handled at deploy time: set the env
variable and add one command to the deploy script, after php artisan migrate:
php artisan filament-topbar-menu:create-table --copy-from-default
It creates the table on the new connection, and --copy-from-default then
moves the data: when the dedicated menu is still empty and this app's
default connection still has a menu table, the local menu is copied over
(validated like an import, in a transaction, cache flushed). A populated
dedicated menu is never touched — the copy refuses to merge a second app's
local menu into an already-seeded shared menu. Both steps are idempotent, so
the command can stay in the deploy script permanently.
Until the command has run, a missing menu table does not take the app down: the topbar renders an empty menu and reports the underlying error to the app's exception handler. The failure is never cached, so the menu reappears on the first request after the table exists.
The switch can also be finished from the panel, without console access: when the table is missing, the menu items page renders an explanation instead of failing, and users allowed to create menu items get a Create menu table header action (next to Export/Import) that performs the same create-and-copy as the deploy command.
The old table stays behind on the previous connection — drop it manually once you have verified the move. For moves the copy flag does not cover (e.g. seeding an already-populated shared menu), use the Export/Import actions on the menu items page.
Hiding each service's own link
A fleet-shared menu usually lists every service of the fleet — including the
one currently being viewed. Enable "Hide on its own domain" on a menu item
(or set visibility.hide_on_current_domain to true) and the item disappears
on the service whose host matches the item's URL, while every other service
keeps showing it. Add a service to the menu once and each app automatically
renders "everyone but me" — no per-app menus, no self-links.
The rule applies to URL items only. Laravel route items always render; the key is ignored on them (a route by definition lives on the current app, hiding it would be a different feature). The admin table marks items the flag hides on the current host with a crossed-eye badge — the item is hidden here, not deleted, and stays visible on the fleet's other services.
Host matching compares service identity, not URLs:
- Case-insensitive; a trailing dot and a
www.prefix are ignored on both sides (www.a.example.com≡a.example.com). - Subdomains are significant:
a.example.com≠b.example.com. - The scheme is not compared (
http://≡https://), and the default ports 80/443 equal an absent port. A non-standard port is part of the identity, so a dev fleet onlocalhost:8000/localhost:8001works. - Punycode and unicode spellings of one domain match (via
ext-intl, when installed).
A seeder example for a fleet of three services:
foreach ([ 'Accounts' => 'https://accounts.example.com', 'Billing' => 'https://billing.example.com', 'Analytics' => 'https://analytics.example.com', ] as $label => $url) { TopbarMenuItem::create([ 'label' => $label, 'type' => TopbarMenuItem::TYPE_URL, 'url' => $url, 'visibility' => ['hide_on_current_domain' => true], ]); }
Older package versions on other apps in the fleet simply ignore the unknown key, so the flag can be rolled out without synchronized upgrades.
Behind a reverse proxy
The current host comes from Laravel's request()->getHost() (and, for
non-standard ports, request()->getPort()). Behind a reverse proxy or load
balancer (nginx, Traefik, …) that is only correct when trusted proxies are
configured and the proxy forwards X-Forwarded-Host — plus X-Forwarded-Port
(or X-Forwarded-Proto) when the fleet runs on non-standard ports — otherwise
Laravel sees the internal container host/port and the flag silently never
matches.
This is the consuming app's configuration (see the
Laravel docs on trusted proxies),
not the package's.
Note that a spoofed Host header only changes which menu items appear in the
response served to the spoofing client itself — the flag is a display rule,
not a security boundary, and grants no access either way.
Operating notes for a shared menu
- Rollback is guarded. When
connectionis set, the package migration'sdown()is a no-op: a routinemigrate:rollbackin one app must not drop the menu of the whole fleet. Dropping a shared menu table is a deliberate manual step. - Keep package versions in sync. The create-only migration never alters an existing table, so future schema changes ship as separate migrations. Apps pinned to different package versions writing to one shared table are unsupported — upgrade them together.
- Restrict who edits. Every app with the resource enabled can edit the
shared menu. In apps that should only render it, disable the management UI
with
TopbarMenuPlugin::make()->resource(false); read-only database credentials for the menu connection make a good second layer. - Test suites. Laravel's
RefreshDatabaseonly wraps the app's default connection in a transaction. IfFILAMENT_TOPBAR_MENU_DB_CONNECTIONleaks into the test environment, feature tests write straight into the real shared menu — and an import withreplacewipes it. Unset the variable inphpunit.xmlor point it at a dedicated test connection.
Caching
The menu tree is cached under cache_key for cache_ttl seconds, so rendering the topbar performs zero database queries. The cache is flushed automatically whenever an item is created, updated, deleted, or reordered. To flush it manually:
use Vaslv\FilamentTopbarMenu\Facades\TopbarMenu; TopbarMenu::flushCache();
Customizing the views
The Blade templates work out of the box; publish them only if you want to change the markup:
php artisan vendor:publish --tag=filament-topbar-menu-views
Views are published to resources/views/vendor/filament-topbar-menu.
Translations
The entire interface (resource form, table, actions, notifications, the artisan command output and the menu's ARIA labels) is translatable. The package ships with:
- English (
en) - Arabic (
ar) - German (
de) - Spanish (
es) - Persian (
fa) - French (
fr) - Indonesian (
id) - Italian (
it) - Dutch (
nl) - Brazilian Portuguese (
pt_BR) - Russian (
ru) - Turkish (
tr) - Chinese, Simplified (
zh_CN)
The language follows the application locale (app()->setLocale(...)), so nothing
needs to be configured — set your app locale and the menu is translated.
To add another language or tweak the wording, publish the translation files:
php artisan vendor:publish --tag=filament-topbar-menu-translations
They are published to lang/vendor/filament-topbar-menu/{locale}/filament-topbar-menu.php.
To add a new language, copy the en file to a new locale folder (e.g. pl/) and
translate the values.
Testing & code quality
composer test # PHPUnit composer lint # apply Laravel Pint code style composer lint:test # check code style without changing files composer analyse # PHPStan (level 6, via Larastan) composer check # lint:test + analyse + test (what CI runs)
CI runs the full test matrix (PHP 8.2 / 8.3 / 8.4) plus a code-quality job (Pint + PHPStan) on every push and pull request.
License
The MIT License (MIT). See LICENSE.