rolland97 / filament-tours
Guided product tours for Filament panels, powered by driver.js.
Fund package maintenance!
Requires
- php: ^8.3
- filament/filament: ^5.0
- spatie/laravel-package-tools: ^1.15.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.0
- nunomaduro/collision: ^8.0
- orchestra/testbench: ^9.0|^10.0|^11.0
- pestphp/pest: ^3.7|^4.0
- pestphp/pest-plugin-arch: ^3.0|^4.0
- pestphp/pest-plugin-laravel: ^3.0|^4.0
- pestphp/pest-plugin-livewire: ^3.0|^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Point a tour at a page, describe a few elements, and it walks the user through them the first time they arrive. Powered by driver.js, which is bundled — you configure no npm, no bundler, and no theme.
FilamentToursPlugin::make()->tours([ Tour::make('create-request') ->for(CreateRequest::class) ->once() ->steps([ Step::make('#data\\.title') ->title('Give it a name') ->body('Something your team will recognise in a list.'), Step::make('[data-tour="items"]') ->title('Add your items') ->body('One row per thing you need.') ->side('left'), ]), ])
Tours are declared centrally, on the panel, rather than on the pages they describe. Tour text is copy, and copy gets rewritten by whoever owns the wording — keeping it in one place makes it reviewable and translatable, and lets a tour attach to a page you do not own, including Filament's own dashboard.
Requirements
- PHP 8.3+
- Filament v5
Installation
composer require rolland97/filament-tours php artisan filament:assets
That is the whole installation. filament:assets is Filament's own step, and most applications
already run it after composer update.
Important
Tagged v1.0.0, but not submitted to Packagist yet, so composer require does not resolve
from the default repository. Point Composer at this repository:
// composer.json "repositories": [ { "type": "vcs", "url": "https://github.com/rolland97/filament-tours" } ]
composer require rolland97/filament-tours:^1.0 php artisan filament:assets
⚠️ Prefer the tag to dev-main. Filament versions package assets as ?v={package version},
so a consumer tracking a branch keeps one unchanging version string and updated assets sit behind
URLs the browser already holds until it revalidates. A real version per release restores that.
The hold before v1.0.0 was deliberate rather than a gap in the work: publishing a version cannot be undone, and building a real application against the package first is how we found out whether the v1 API survived contact with use. It did not, entirely — four releases' worth of gaps came out of that first consumer, and they are in the changelog.
No custom theme is needed. driver.js and its stylesheet are bundled into the package and
registered through Filament's asset system, so there is no @source line to add and nothing for
your build to compile. A dark variant is bundled too, scoped to the .dark class Filament
toggles; override --filament-tours-surface, --filament-tours-text and --filament-tours-heading
if your palette differs. If you have seen those instructions in other Filament plugins, they do not
apply here.
No migration ships with this package, so there is nothing to publish or run. Where "this user has seen this tour" is recorded is your decision — see Remembering what a user has seen.
Publishing the config file is optional:
php artisan vendor:publish --tag="filament-tours-config"
Registering tours
Add the plugin to a panel and hand it your tours:
use Filament\Panel; use Rolland\FilamentTours\FilamentToursPlugin; use Rolland\FilamentTours\Step; use Rolland\FilamentTours\Tour; public function panel(Panel $panel): Panel { return $panel // … ->plugin( FilamentToursPlugin::make()->tours([ Tour::make('dashboard-intro') ->for(Dashboard::class) ->once() ->steps([ Step::make('[data-tour="stats"]') ->title('Your numbers') ->body('Updated hourly.'), ]), ]), ); }
The full API
That is all of it. Nothing else is public in v1.
Tour
| Method | Effect |
|---|---|
Tour::make(string $id) |
Creates a tour. The id must be unique within the panel. |
->for(string $pageClass) |
Applies on that page. Validated at registration — a typo fails loudly. |
->when(Closure $predicate) |
Applies when the predicate returns true. Evaluated per request, server-side. |
->once() |
Records that the user has seen it, so it does not run again. |
->manual() |
Belongs to the page and reaches the browser, but does not start uninvited — only StartTourAction or the start event runs it. |
->steps(array $steps) |
The steps, in order. At least one is required. |
Step
| Method | Effect |
|---|---|
Step::make(string $selector) |
A raw CSS selector for the element to highlight. |
->title(Closure|string) |
Heading text, or a closure resolved at render. |
->body(Closure|string) |
Body text, or a closure resolved at render. |
->side('top'|'right'|'bottom'|'left') |
Which side of the element the popover sits on. |
->align('start'|'center'|'end') |
How the popover aligns along that side. |
A tour with neither ->for() nor ->when() applies nowhere. That is deliberate: "everywhere" is a
predicate you write, not a default this package assumes.
Tours that wait to be asked
->manual() is for a tour describing a page someone came to use. An overlay over a form the
moment it opens is in the way of the task — and, on a page with an automated journey, in the way of
the test too. A manual tour still belongs to its page and still reaches the browser; it simply does
not start by itself:
Tour::make('create-request') ->for(CreateRequest::class) ->manual() ->once() ->steps([...])
Pair it with a replay control (below) so there is something to ask with. ->manual() and ->once()
are orthogonal: "has it been seen" and "does it start by itself" are different questions, and a
manual tour that is also once() records itself when finished — which stops nothing, because
nothing was starting it.
php artisan tours:list reports this as starts: when asked, because a manual tour that never
appears looks exactly like a broken selector until you know it was never going to start.
Targeting elements
Selectors are raw CSS, and the recommended convention is a dedicated attribute:
<div data-tour="items">…</div>
Step::make('[data-tour="items"]')
Using data-tour rather than a class or a Filament-generated id means your tours survive a
restyle, and this package deliberately ships no helpers that know Filament's internal markup —
they would read better and break on point releases.
The engine's own buttons
driver.js labels its buttons Next, Previous and Done. If your application is translated, those three are the only words on the popover this package cannot see — so hand them over:
FilamentToursPlugin::make() ->buttonLabels([ 'next' => fn (): string => __('tours.next'), 'previous' => fn (): string => __('tours.previous'), 'done' => fn (): string => __('tours.done'), ]) ->tours([...])
Important
Pass closures if you translate. A panel is configured before any request
middleware runs, so __('tours.next') evaluated there resolves in your
application's default locale and stays there — every reader sees that one
language. A closure is resolved when the payload is built, by which time the
request's locale is set. Plain strings are fine for wording that never varies.
Keys you leave out keep driver's own defaults. This package ships no wording.
Copy and translation
Copy is plain strings, so translate it however your application already does:
Step::make('#data\\.title') ->title(fn (): string => __('tours.request.title.heading')) ->body(fn (): string => __('tours.request.title.body'))
Important
Pass closures if you translate. A panel is configured before any request
middleware runs, so __('tours.request.title.heading') evaluated there resolves in your
application's default locale and stays there — every reader sees that one language,
whatever their preference. A closure is resolved when the payload is built, by which
point the request's locale is set. Plain strings are right for wording that never varies.
No language files ship with this package, and it will never own your wording.
Accessibility
The tour engine marks the element it highlights with aria-haspopup, aria-expanded and
aria-controls. Those attributes are only valid on certain roles, so this package removes them
again from elements that cannot carry them — otherwise every page with a running tour fails an
aria-allowed-attr audit, and a highlighted <div> is the ordinary case rather than the exception.
When a tour cannot find its target
A step whose selector matches nothing is skipped, and the rest of the tour still runs. Users never meet a broken tour because someone moved a button.
Developers find out instead: with APP_DEBUG=true, each skipped step logs a console warning naming
the tour and the selector that missed. If no step survives, the tour does not start at all.
To see what is registered:
php artisan tours:list
It reports every tour with its page or predicate, step count, and run-once flag. It does not check whether your selectors match anything — that needs a browser, and a listing that implied otherwise would be lying.
Letting users replay a tour
Put the action in a page header:
use Rolland\FilamentTours\Actions\StartTourAction; protected function getHeaderActions(): array { return [ StartTourAction::make('dashboard-intro')->label('Show me around'), ]; }
Or dispatch the event from anywhere on the page:
<button x-on:click="$dispatch('filament-tours:start', { tour: 'dashboard-intro' })"> Replay the tour </button>
Replay ignores whether the tour has been seen — that is the point of asking for it — and finishing a replay does not re-record it. Requesting a tour that does not apply to the current page does nothing and shows the user no error.
Remembering what a user has seen
Only tours marked ->once() need this. Two options.
The default: the browser remembers
Out of the box, config('filament-tours.state') is 'local' and the browser records what it has
seen in localStorage, under filament-tours:{panel}:{tour}. Nothing is stored on your server and
no route is registered.
⚠️ This is per-browser. The same person on a second device, or after clearing site data, sees the tour again. That is fine for hints and onboarding nudges. It is not suitable for anything shaped like "confirm you have read this" — for that, use a server-side driver.
Server-side: you remember
Implement the contract and point the config at it. Tour definitions do not change.
use Rolland\FilamentTours\Contracts\TourState; class DatabaseTourState implements TourState { public function hasSeen(string $tourId): bool { return auth()->user()->seen_tours[$tourId] ?? false; } public function markSeen(string $tourId): void { $user = auth()->user(); $user->seen_tours = [...$user->seen_tours, $tourId => true]; $user->save(); } }
// config/filament-tours.php 'state' => \App\Support\DatabaseTourState::class,
Or leave the config alone and set it per environment:
FILAMENT_TOURS_STATE="App\Support\DatabaseTourState"
Which user "seen" refers to is your driver's business — it reads auth()->user() itself, so this
package never forms an opinion about identity.
With a driver configured, tours you report as seen are filtered out before the page renders, so
their text never reaches the browser at all. One route is registered,
POST filament-tours/{tour}/seen, inside the panel's own middleware group, and it only records —
there is no endpoint to read seen state back.
⚠️ The route inherits your panel's authentication, and only that. Filament does not apply auth middleware on its own; your panel declares it, as generated panels do with
->authMiddleware([Authenticate::class]). A panel that declares none leaves this endpoint open — along with every page in that panel. If you are switching to a server-side driver, it is worth confirming your panel's middleware is what you think it is.
If the browser cannot reach the endpoint — offline, a 500 — the tour is suppressed for the rest of that page visit and may run once more next time. Failing that way round is deliberate: a tour appearing twice is visible and self-correcting, whereas silently retiring one after a network blip is not.
What this package deliberately does not do
- Drive interactions. v1 highlights elements that are on the page when it loads. It does not open modals or switch tabs.
- Span multiple pages. One tour, one page.
- Ship a migration, a language file, or a tour-authoring UI.
- Collect analytics.
Testing
composer test
Changelog
Please see CHANGELOG for more information on what has changed recently.
Contributing
Please see CONTRIBUTING for details.
Security Vulnerabilities
Please review our security policy on how to report security vulnerabilities.
Credits
License
The MIT License (MIT). Please see License File for more information.