Filament Feedback
In-app feedback for Filament panels. People tag the parts of the app they mean and how they feel, the team replies in a thread, and a board shows how a release landed.
Overview #
A Feedback button sits in the corner of every page. Pressing it opens a small sheet above the button: pick what kind of message it is, the parts of the app it's about and how you feel, then write. The page's own module is already picked, so most posts take one line and a click.
Each person sees their threads on a My feedback page, with the team's replies, reactions and a timeline. The team works from an inbox: every post in a table, a thread beside it, a status to set and a reply box. Above the table, a board shows the average mood per module, so after a release you can see which screens people like and which ones frustrate them.
- Four kinds of post: feedback, a question, a problem, or a note for the developers.
- Private by default: a post is seen by its author and the people you let triage.
- Modules stay compact: the page's module is pre-picked, the rest sit behind a search, and typing
#DTRin the message tags one with autocomplete. - Reactions (Thanks, Same here, Seen, Fixed) and a "Did this fix it?" check once a post is Done.
- Stats on the inbox: posts, median first-reply time, what got done, and mood against the previous 30 days.
- An optional Google Chat ping for every new post. No Cloud project needed.
- Plain CSS and a small motion script, published by
filament:assets. Your app's build is never involved.
Installation #
Install it from Packagist, then run the migrations and publish the assets:
composer require carljanzell/filament-feedback:^0.4
php artisan migrate
php artisan filament:assets
There's no config file and no service provider to register. Migrations load from the package, and all setup happens on the plugin object.
The sheet's CSS and motion script are Filament assets, not part of your Vite or Tailwind build. If the
button or sheet looks unstyled, that step was skipped. Most apps already run it from
composer.json through filament:upgrade.
Setting up panels #
Register the plugin on each panel that needs it. In a one-panel app, keep both the button and the inbox on (the default) and say who triages:
use CarlJanzell\FilamentFeedback\FilamentFeedbackPlugin;
$panel->plugin(
FilamentFeedbackPlugin::make()
->modules(['dtr' => 'DTR', 'leave' => 'Leave'])
->canTriage(fn (): bool => auth()->user()?->hasRole('admin') ?? false),
);
A typical app has a panel everyone uses and an admin panel. Put the button on the first and the inbox on the second:
$modules = [
'dtr' => 'DTR',
'leave' => 'Leave',
'design' => ['label' => 'Overall look', 'icon' => 'heroicon-o-swatch'],
];
// The staff panel: the floating button and My feedback, no inbox.
$panel->plugin(
FilamentFeedbackPlugin::make()
->modules($modules)
->moduleFromRoute(fn (?string $route): ?string => str_contains((string) $route, 'leave') ? 'leave' : null)
->inbox(false)
->chatWebhook(env('FEEDBACK_CHAT_WEBHOOK')),
);
// The admin panel: the inbox, no button.
$panel->plugin(
FilamentFeedbackPlugin::make()
->modules($modules)
->button(false)
->canTriage(fn (): bool => auth()->user()?->hasRole('admin') ?? false)
->navigationGroup('System'),
);
Posts are created on the button's panel, so moduleFromRoute(), chatWebhook()
and developerPosts() belong there. canTriage() and the team's side of
allowUnsend() belong on the inbox's panel. Give both panels the same modules().
The Feedback sheet #
The button shows for signed-in users on every page of its panel. The sheet opens above it (a bottom sheet on phones) and asks for three things:
Kind
| Kind | Stored as | Asks for |
|---|---|---|
| Share feedback | feedback | Modules, a mood, a message |
| Ask a question | question | A message, optional modules |
| Report a problem | problem | A message, optional modules |
| For the developers | developers | A message. See For the developers |
Modules
The module of the page the person is on is picked already (see Modules and icons).
Others are behind an Add button with a search. Typing # in the message suggests modules:
#DTR, #dtr, #BreakRoom and #break-room all tag the
same one. The server parses mentions too, so a tag typed without the suggestions still counts. Only slugs
from modules() are ever stored.
Mood
Feedback posts carry a mood on a five-step scale, stored as a score so the board can average it:
Each post also saves its context: the page URL, the route name, the browser's user agent and
config('app.version') when it's set.
My feedback #
On the button's panel, /my-feedback lists everything the person has sent, one thread open at
a time. A thread shows:
- The team's replies and the author's own, with a composer. Enter sends, Shift+Enter adds a line.
- Reactions on the post and on each reply.
- A timeline of the status: Sent, then each change. A post set back to New reads as Reopened.
- Once a post is Done, a Did this fix it? check. "Still broken" reopens it for the team.
Threads with a team reply the author hasn't opened get an unread badge in the navigation.
The inbox #
On panels with inbox(), triagers get a Feedback page at /feedback,
with a badge for New posts. It holds:
- The stats widget.
- The mood board: mood counts per module, busiest module first, over feedback posts.
- A table of every post with filters for kind, status, mood and module. Opening a row shows the thread, where the team replies and sets the status.
Statuses move a post along:
Every change is written to the post's status_log by the model, whoever makes it, so the
author's timeline can't drift from what the team did.
Who sees what #
| Who | Sees |
|---|---|
| An author | Their own posts and threads, on My feedback |
A triager (canTriage()) | Every post in the inbox, except developer posts when developerPosts(canRead: β¦) says no |
| Everyone else | Nothing. The inbox is hidden and its route refuses them |
canTriage() is false until you set it, so a fresh install has no inbox
visible to anyone.
Modules and icons #
modules() takes slug => label, or slug => ['label' => β¦, 'icon' => β¦],
or a closure returning either. Slugs are what's stored, so keep them stable once posts exist. With no
modules, the field is hidden.
moduleFromRoute() receives $route (the route name, or null) and $url
and returns a slug or null. It pre-picks the module of the page the person is on.
A module without an icon borrows one from your navigation. The plugin runs each page's and resource's
route through moduleFromRoute(), and the first page that maps to a module lends it its
navigation icon. So the DTR chip shows the same icon as the DTR item in the sidebar. An 'icon'
in modules() wins.
Notifications #
The author, when the team replies
The author gets a Filament database notification whose button opens the thread on My feedback
(/my-feedback?post=β¦). Turn on ->databaseNotifications() on the button's panel,
and make sure your user model is Notifiable.
If notifications in your app go through another package, replace the default with
notifyAuthorUsing(). A failure there is reported, and never undoes the reply:
FilamentFeedbackPlugin::make()
->notifyAuthorUsing(function (Model $author, FeedbackReply $reply, ?string $url): void {
// Send it your way. $post is available too.
});
The team, when someone posts
There's no built-in triager notification yet. Listen to FeedbackPosted,
or use the Google Chat ping.
Google Chat #
chatWebhook() sends one line to a Chat space for every new post. In the space: space name β
Apps & integrations β Webhooks β add one, and copy its URL. The app only
calls out, so it works from a server Google can't reach, and it needs no Cloud project. If the option is
missing, your Workspace admin has turned webhooks off.
->chatWebhook(env('FEEDBACK_CHAT_WEBHOOK')) // null or empty turns it off
For the developers #
The fourth kind is for messages meant for the dev team rather than the people who handle feedback: ideas, a slow query someone noticed, a note from a power user. It's on by default and every triager reads it.
->developerPosts(
canRead: fn (): bool => auth()->user()?->hasRole('dev') ?? false,
chatWebhook: env('FEEDBACK_DEV_CHAT_WEBHOOK'), // the dev team's own space
)
->developerPosts(false) // hide the box
Events #
| Event | Carries | Fires |
|---|---|---|
Events\FeedbackPosted | $post | On every new post |
Events\ReplyPosted | $reply | On every reply, both sides. $reply->is_from_team tells them apart |
use CarlJanzell\FilamentFeedback\Events\FeedbackPosted;
use Illuminate\Support\Facades\Event;
Event::listen(function (FeedbackPosted $event): void {
// e.g. notify HR: $event->post->kind, ->modules, ->body, ->author
});
Links and placement #
- A link to
#feedbackon any page of the button's panel opens the sheet. Handy for a "Tell us" link in a banner or an empty state. FilamentFeedbackPlugin::threadUrl($post->panel, $post->id)links to a thread on My feedback;FilamentFeedbackPlugin::inboxUrl()to the first inbox. Both are for links sent outside the app.- The button sits bottom-left. Move it, and the sheet with it, with CSS variables:
:root { --ffb-launcher-bottom: 5rem; --ffb-launcher-left: 1rem; }
launcherAttributes() adds attributes to the button's wrapper for your own CSS, for
example ['data-tour-fab' => ''] to stack it with another floating button.
Stats widget #
The inbox opens with CarlJanzell\FilamentFeedback\Widgets\FeedbackStats:
- Posts in the last 30 days, with a 14-day sparkline.
- The median time to the team's first reply.
- Posts done, and how many authors confirmed the fix.
- The average mood against the 30 days before.
It's a plain Filament stats widget, so a dashboard can use it too, for example
filament-insights'
->widgets([FeedbackStats::class]). On the inbox only triagers see it; elsewhere the page decides.
Plugin API #
| Method | Default | What it does |
|---|---|---|
modules(array|Closure) | [] | The parts of the app people can tag. See Modules |
moduleFromRoute(?Closure) | none | Pre-picks the page's module from $route and $url |
canTriage(bool|Closure) | false | Who opens the inbox and replies as the team |
button(bool|Closure) | true | The floating button and My feedback. A closure is checked on each page and hides only the button |
inbox(bool) | true | The inbox page |
chatWebhook(?string) | none | Google Chat ping on every new post |
developerPosts(bool, bool|Closure|null, ?string) | on, all triagers | The "For the developers" kind: enabled, canRead, chatWebhook |
allowUnsend(team:, authors:) | team true, authors false | Who may unsend their own replies. An unsent reply stays as "β¦ unsent a message" |
notifyAuthorUsing(?Closure) | database notification | Replaces the "team replied" notification. Gets $post, $reply, $author, $url |
impersonateUsing(?Closure, bool|Closure $can) | off | "View as" on an inbox thread: sign in as the author and open the page they wrote from, through your app's own impersonation package. Gets $author, $post, $url (on this app and still routed, else null), $guard; triagers only |
launcherAttributes(array) | [] | Extra attributes on the button's wrapper |
navigationGroup(?string) | none | Where the inbox sits in the navigation |
FilamentFeedbackPlugin::get() returns the current panel's instance, for reading the
configuration at runtime (getModules(), isTriager(), β¦).
Data #
Three tables, created by the package's migrations. Authors are polymorphic (morphs()), so any
authenticatable model works, UUID keys included.
| Table | Model | Holds |
|---|---|---|
feedback_posts | FeedbackPost | panel, kind, mood, modules (JSON slugs), body, status, status_log, context, fix_confirmed, author_seen_at |
feedback_replies | FeedbackReply | body, is_from_team, unsent_at |
feedback_reactions | FeedbackReaction | One row per person, reaction and post or reply |
use CarlJanzell\FilamentFeedback\Enums\FeedbackKind;
use CarlJanzell\FilamentFeedback\Enums\FeedbackStatus;
use CarlJanzell\FilamentFeedback\Models\FeedbackPost;
FeedbackPost::query()->ownedBy($user)->latest()->get();
FeedbackPost::query()->where('kind', FeedbackKind::Problem)->whereJsonContains('modules', 'dtr')->get();
$post->addReply($teamMember, 'Fixed in 2.3.', fromTeam: true); // fires ReplyPosted
$post->update(['status' => FeedbackStatus::Done]); // logged to status_log
Enums, all in CarlJanzell\FilamentFeedback\Enums: FeedbackKind,
FeedbackStatus, FeedbackMood (int, β2 to 2) and Reaction
(thanks, same, seen, fixed).
Filament versions #
One codebase supports Filament 3.3, 4 and 5. CI runs the floor and the newest release of each line: 3.3 on Livewire 3, 4 on Livewire 3, and 5 on Livewire 4. Upgrading your app between majors needs no change to how you register the plugin.
AI agents #
The package ships an agent guide at resources/boost/guidelines/core.blade.php: setup, every
plugin method, events, data and common tasks. Apps using Laravel
Boost pick it up on php artisan boost:install or boost:update. Without Boost,
point your agent at that file. Contributors' agents start from AGENTS.md in the repo.
Known limitations #
- Plain-text messages. Rich text, screenshots and
@mentionsare planned. - No built-in triager notification yet; use the event or the Chat ping.
- Copy is English and inline; translations are planned.
- One inbox per app. A shared inbox across apps waits until more apps use it.
- No public board: posts are private to their author and the team.
See ROADMAP.md for what's next and what 1.0 means.
Licence #
MIT. Motion by anime.js (MIT), bundled and loaded only when the viewer allows motion.