wallacemartinss/filament-onboarding
Database-driven onboarding for Filament v5: a progress checklist that follows users across every panel page, guided spotlight tours, video steps, and condition-based steps that complete automatically so existing users enter journeys already partly done.
The 1.x backport of v2.6.0: the same changes, on Filament v4, and every one of them applied unchanged — the two lines share their source and differ only in their Composer constraints and their Livewire internals. No breaking changes, no new migrations, nothing to migrate. Upgrade with composer update wallacemartinss/filament-onboarding — and run php artisan filament:assets this time, because the tour runner changed.
Most of this release was found and fixed by [@jyrkidn](https://github.com/jyrkidn), while measuring the package against a CMS panel whose content resources nest their fields. The rest was filed by [@DamienJolly](https://github.com/DamienJolly) and [@PatrickJunod](https://github.com/PatrickJunod), and the definitions cache was sharpened by [@felipeArnold](https://github.com/felipeArnold). Thank you, all four.
A field nested under a state path is found by its name again (#16). A stop stores a field's name; the markup wears its state path. Those are the same string on a flat form and not on a nested one — any Group, Section, Tab or wizard step with a state path of its own prefixes what sits under it, so a field still called title renders as form.details.title. The selector matched the name as the whole path only, so every stop on such a form pointed at nothing. That is not a soft landing: the tour parks, Next stays disabled, and the stall is written to localStorage, so it comes back stalled on every reload until the subject presses Skip. The name is now matched at the end of the path as well as as the whole of it, with the dot included so subtitle cannot answer for title.
A form that asks who is rendering it keeps its fields. The target picker built forms against a bare Schema::make(), and Schema::getLivewire() has a return type — so a form, or one field inside one, that reaches for the component got a TypeError rather than a null. The disclaimer held (nothing was taken down), but the cost was silent and landed on exactly the resources worth touring: on the panel this was measured against, 8 of 34 form resources offered no targets at all for this reason. The schema is now built against the resource's own page, the component Filament itself would use, and the one the table side already asks for its columns.
A stop whose target is hidden after it lands goes back to waiting. Filament does not remove the tab, the wizard step or the collapsed section the subject just left — it hides it. The element stays connected while its rectangle collapses to nothing, so the spotlight was redrawn as a padding-sized square in the corner of the screen with the popover still claiming to point at something. A hidden target is now dropped like a removed one.
A stop reached without being walked to still presses the control it names. advance names the control a stop lives behind — a wizard's next, a tab — and only the forward path ever pressed it. A tour crossing to another page arrives through the render on the far side; so does one picked back up after a reload, and one resumed from the stop the subject parked on. All three sat waiting in front of a closed tab the stop had already said how to open.
A stop reaches a column the table is holding off the side of the screen (#15). "Already visible" was decided on the vertical axis alone, and a table wide enough to scroll sideways — which on a phone is every table — keeps its far columns at a perfectly ordinary height several hundred pixels past the right of the window. Both axes are asked about now, and the scroll asks for inline: 'center': 'nearest' is no movement at all for something already as near as it gets on its own axis.
The watcher stands down while the tour waits. measure() runs once a frame and hands back to render() when the target has gone, but render() does not say it is waiting until it has finished looking — three seconds, when the element is not coming back on its own. So the next frame started the search over, and so did the one after it: sixty renders a second, each with a poller of its own, for as long as the subject stayed on the other tab.
A worker that outlives the request stops serving yesterday's definitions (#19). The definitions are memoised for the length of one request. On FPM that memo cannot outlive the request that made it; under Octane the manager is the same object on every request a worker serves, and under queue:work the same object on every job. "Once per request" quietly became "once per worker" — and since the write that clears it fires its model events in whichever process handled it, every other worker went on serving a flow the author had switched off. The memo is now tied to the boundaries a long-lived worker actually has. What gets dropped there is only the copy that process is carrying, never the copy the processes share.
A multi-tenant panel can send somebody to its tenant profile (#21). Where a step sends somebody is picked from the panel's own dropdowns, so a destination can only ever be one that exists — which means anything the panel can reach and the picker cannot see is, from the author's side, simply not there. The tenant profile was one of those: registered through ->tenantProfile() and never through ->pages(), so getPages() never listed it, while "finish setting up your team" is close to the first thing a multi-tenant onboarding wants to say.
The JavaScript toolchain, and the pinned actions, catch up with the 2.x line. esbuild 0.20 → 0.28.2, jsdom 30.1.0, vitest 5.0.1, and actions/checkout and actions/setup-node two majors forward onto the SHAs the 2.x workflow is already running. Dependabot only opens pull requests against the default branch, so none of this reaches 1.x on its own. Build output is unchanged.
Full changelog: https://github.com/wallacemartinss/filament-onboarding/compare/v1.1.0...v1.2.0
The 1.x backport of v2.5.0: the same two changes, on Filament v4. No breaking changes, no new migrations, nothing to migrate — composer update wallacemartinss/filament-onboarding and you are done. Both filed by [@DarkGhostHunter](https://github.com/DarkGhostHunter) — thank you.
A panel running ->strictAuthorization() no longer throws on ->manageFlows() (#3). Strict mode throws for any resource model without a policy, and the package registered three that had none — the flows, the steps (the relation manager authorizes against the related model), and the conditions. Entering such a panel died on a LogicException before it drew anything.
Each model now ships a policy that answers yes to every ability Filament probes — deliberately not an opinion, just the access the resources always had, sitting in the package's Policies namespace where Laravel's guesser finds it on its own. Narrowing it is yours, three ways: extend the shipped policy and override only what you mean to restrict; register your own with Gate::policy(), which boots later and therefore wins; or name a class in the new policies config block. The README grew an Authorization section, because "where does the policy even go" was the other half of the report.
A per-user switch: ->skipWhen() (#4). Onboarding used to run for everybody on every page — for a user done for months, that meant reading their progress out of the database to conclude, again, that there was nothing to show.
FilamentOnboardingPlugin::make()
->skipWhen(fn (User $user): bool => $user->onboarded_at !== null),
When it answers true, the whole machine steps aside: no surface mounts, no onboarding_* table is read, and the Livewire endpoints a browser could still call by hand write nothing — the switch turns the endpoints off too, not just the rendering. Onboarding::for() ignores it on purpose: resetting a done user's onboarding must reach exactly the people the switch has hidden. The README shows the full wiring — the column, the switch, and a FlowCompleted listener that stamps the user when the last journey closes.
Filament v4 carries strictAuthorization() and the Filament\authorize() helper just as v5 does, so both fixes applied without a single v4-specific variant — the two lines stay feature-identical. Suite green on Filament v4.12.5: 130 PHPUnit tests, PHP 8.2 through 8.4.
On Filament v5, require ^2.5 from the main branch instead.
Full changelog: https://github.com/wallacemartinss/filament-onboarding/compare/v1.0.0...v1.1.0
A minor release: no breaking changes, no new migrations, nothing to migrate. Upgrade with composer update wallacemartinss/filament-onboarding — no filament:assets needed this time, nothing in the browser changed. Both changes were filed by [@DarkGhostHunter](https://github.com/DarkGhostHunter) — thank you.
A panel running ->strictAuthorization() no longer throws on ->manageFlows() (#3). Strict mode throws for any resource model without a policy, and the package registered three that had none — the flows, the steps (the relation manager authorizes against the related model), and the conditions. Entering such a panel died on a LogicException before it drew anything.
Each model now ships a policy that answers yes to every ability Filament probes — deliberately not an opinion, just the access the resources always had, sitting in the package's Policies namespace where Laravel's guesser finds it on its own. Narrowing it is yours, three ways: extend the shipped policy and override only what you mean to restrict; register your own with Gate::policy(), which boots later and therefore wins; or name a class in the new policies config block. The README grew an Authorization section, because "where does the policy even go" was the other half of the report.
A per-user switch: ->skipWhen() (#4). Onboarding used to run for everybody on every page — for a user done for months, that meant reading their progress out of the database to conclude, again, that there was nothing to show.
FilamentOnboardingPlugin::make()
->skipWhen(fn (User $user): bool => $user->onboarded_at !== null),
When it answers true, the whole machine steps aside: no surface mounts, no onboarding_* table is read, and the Livewire endpoints a browser could still call by hand write nothing — the switch turns the endpoints off too, not just the rendering. Onboarding::for() ignores it on purpose: resetting a done user's onboarding must reach exactly the people the switch has hidden. The README shows the full wiring — the column, the switch, and a FlowCompleted listener that stamps the user when the last journey closes.
Full changelog: https://github.com/wallacemartinss/filament-onboarding/compare/v2.4.1...v2.5.0
First release of the 1.x line: the plugin runs on Filament v4 (Livewire 3).
It is feature-identical to the 2.x / Filament v5 line — same journeys, checklist, tours, conditions and programmatic API. The only differences are the Composer constraints and the Livewire component-registration internals.
composer require wallacemartinss/filament-onboarding:^1.0
^2.1 from the main branch instead.A patch release: no API changes, nothing to migrate. Upgrade with composer update wallacemartinss/filament-onboarding and php artisan filament:assets — the stylesheet changed, and it is versioned by content hash, so that second command is what puts it in front of the browser.
"Start over" and "Hide" are buttons now. They sat at the foot of a journey as bare underlined words, which is how a control that does something ends up reading like a footnote: nothing about them said they could be pressed, and so nobody pressed them. They are grey buttons now — with icons, and a rule above them — the same secondary button Filament dresses its own "Cancel" in.
The progress page got its air back. The journey description had no bottom margin at all: zero pixels between the sentence introducing the journey and the first card of it, so the text read as though it were falling into the card rather than presenting it. And the step cards carried 18 px of horizontal padding where Filament gives a card 24 px. Both now match what the panel around them does.
The Composer archive no longer carries the whole repository. There was no .gitattributes, so tests/, .github/ and the docs were being unpacked into the vendor/ directory of everyone who installs this package.
This release adds 2.7 MB of screenshots to the repository — so the file had to exist before the screenshots did. With it, the install shrinks from 1.0 MB to 0.8 MB instead of growing to roughly 3.5 MB. Nothing needed at runtime was excluded: src, config, database, resources/ and stubs all still ship.
The README shows the package instead of describing it. Twenty-two screenshots of it running in a real panel, each placed next to the claim it proves, rather than in a gallery nobody scrolls to:
field:name, field:status, action:submit, picked from dropdowns;A new "Writing a journey" section. The README had always claimed that flows, steps and the conditions they hang off are written in the panel, and had never once shown the panel.
Full changelog: https://github.com/wallacemartinss/filament-onboarding/compare/v2.4.0...v2.4.1
A tour stop is picked, not typed. Writing one meant typing
[data-onboarding="client-submit"], which assumed two things about whoever was expected to
do it: that they know CSS, and that they know a developer went and put that hook in the code.
Journeys are supposed to be product's to write. Product knows neither of those.
"What to spotlight" is a dropdown, read out of your own panel. Pick the page a stop lives on, and the package offers what is on it:
Select and a
Toggle, which have no id of their own; the spotlight lands on the whole field, label
and all, rather than on the box you type inWhat is stored is the choice (field:status), not the selector — the same reason a route
name is stored and not a URL: the markup is Filament's to change, and a journey should
survive it changing.
A form that cannot be read from the outside — one that leans on the record being edited, or on who is looking — gives up its fields and nothing else: they are not listed, the panel does not fall over, and the CSS box is right there.
sessionStorage to cross a page, while "not
now" lives in the server's session. Let the session turn over in between — it expires, it
rotates on a login — and the page comes back with the tour resuming and the welcome sure
it was never answered. Somebody walking a tour has already started; there is nothing left
to invite them to, so the welcome stands down while one is running.The step editor was write-once. Every field that depends on what kind of step it is — the whole Tour tab and all of its stops, the condition dropdown, the visit URL, every media field — was there when a step was created and gone when one was opened to be edited. So a tour could be written and then never touched again: to change a stop you had to delete the step and start over.
The state of a field backed by an enum comes in two shapes, and the editor only knew one.
Creating, the state is what the browser posted — the string 'tour'. Editing, the form was
filled from the record, whose attribute is cast, so the state is StepType::Tour, the
enum itself. $get('type') === StepType::Tour->value is therefore true exactly half the
time, and the half it answers no is the half where the field simply is not rendered. Nothing
threw. The form quietly showed less of itself, on the one path nobody tests: the second time
you open something.
Both shapes now answer the same. (Two (string) casts over the same state went with it —
an enum is not a string, and PHP says so by dying.)
Conditions no longer need a deploy. The package's argument has always been that a journey is a thing you write, not a thing you ship — and it held right up to the most valuable kind of step, the one that completes itself, which needed a closure in a service provider and a commit to go with it. A product person could rewrite the copy of a journey without asking anybody, and then had to open a pull request to ask "have they added a client yet?".
Conditions are written in the panel. Onboarding → Conditions → New, and the two shapes cover what onboarding actually asks:
Client · at least 1 · only where status is active.
Optionally scoped to the tenant, so a client added in one does not tick the step in another.email_verified_at is filled in.No code, no deploy. And no SQL either: the model comes from an allowlist
(conditions_builder.models, which offers app/Models when left empty), the column from
that table's real columns, the operator from an enum — and the one thing an author types,
the value, is bound rather than interpolated.
php artisan make:onboarding-condition HasActivePlan — for the questions a form cannot
ask (a subscription, an API call, a score). It writes the class, tells you the key it
answers to, and there is nothing to register.
Condition classes are discovered. Anything in app/Onboarding/Conditions is found and
registered on its own. Writing the class and naming it in a config file was saying the
same thing twice, and the second half is the half that gets forgotten in the deploy where
it matters. (discovery.enabled => false to opt out.)
Code always wins a name clash: a condition written in the panel cannot take a key a class already answers to, so a row can never quietly redefine what a step means.
.fio-hero was what carried the radial gradient, the border and the shadow, and
the new header left each journey as a bare row on the page background. The header carries
them now — same gradient, same border, same shadow, holding a summary rather than
everything.New tables ship with new releases, and this one adds onboarding_conditions:
composer update wallacemartinss/filament-onboarding
php artisan vendor:publish --tag=filament-onboarding-migrations # existing files are left alone
php artisan migrate
php artisan filament:assets
Conditions registered in code keep working exactly as they did — in the config, at runtime,
or (now) simply by existing in app/Onboarding/Conditions. Nothing needs to be rewritten.
StartTourAction with one tour asked which of the one. It always set a modal heading,
and Filament opens a modal for that alone — shouldOpenModal() asks
hasCustomModalHeading() long before it looks at whether there is a schema to show. So the
common case, StartTourAction::make('servers-tour'), opened an empty modal asking "which
tutorial do you want?" and offering no answer. The modal is now turned off unless there is
more than one tour to choose between — including when the list names several and only one
of them exists.Required on Laravel 13 — and on any Laravel 12.63+ whose cache config is current.
cache.serializable_classes => false, so that a leaked APP_KEY cannot be turned
into a gadget chain through the cache. The manager cached a Collection of Eloquent
models — which writes perfectly well, and reads back as __PHP_Incomplete_Class. The first
request of the panel's life populated the cache and worked; every request after it died on
the return type of cachedFlows(), which is a 500 on every page, since the launcher
renders in the layout. Definitions are now cached as plain attributes and rebuilt on the
way out — casts, relations and translatable columns intact — so nothing in that cache is an
object. A cache holding the old shape reads as a miss and is overwritten, so upgrading
needs no cache:clear.The release to upgrade to, from anywhere. The 2.0.0 → 2.1.0 migration now survives the duplicate rows 2.0.0 left behind, so nothing stands between 2.0.0 and here.
->welcome()) — shown once, on the first page after logging in, with
"get started", "not now" (this session) and "do not show this again" (which takes the
floating button and its ring with it). Reversible from the progress page.StartTourAction accepts several tours — hand it a list and it asks which one, naming
each by its own title; a screen can be walked more than one way. With a single tour there
is nothing to ask, and it starts outright.completeStep()
refuses. A tour now completes only a Manual step, where the tour is the task; for
every other mode it records that the tour was watched and leaves completion to the mode.'' made the duplicates collide with the
index mid-UPDATE, and php artisan migrate died on precisely the installations that
needed it. Duplicates are now merged first: the oldest row stays and inherits the
furthest the others got — the latest of each timestamp, the union of the metas.QueryException over the
whole modal.$flowKey, so published views keep
working).handleVisit()
checked the step's visibility condition but not the flow's, so standing on the right
page completed steps of a journey that did not exist for that account.completed_at and fire FlowCompleted after the
first tick, with the journey barely begun.aria-modal was
promising both without delivering either. The duplicated class attribute on the
progress page's restart note now applies its spacing.SubjectOnboarding per component request instead of one per question: a launcher
render asked "current flow?", "hidden?", "welcome?" and each fresh engine re-read all of
the subject's progress. The engine keeps its own maps current as it writes, so nothing
stale survives the memoisation.How can I help you explore Laravel packages today?