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.

Filament 3.3 Β· 4 Β· 5 PHP 8.2+ No build step
Source on GitHub Β· MIT licence Β· pre-release

The Feedback inbox: stats, the mood board per module, and the table of posts

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 #DTR in 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.

Run filament:assets after every update

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'),
);
Which panel gets which setting

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:

The Feedback sheet: kind boxes, the page’s module picked, a mood and a message with a # suggestion

Kind

KindStored asAsks for
Share feedbackfeedbackModules, a mood, a message
Ask a questionquestionA message, optional modules
Report a problemproblemA message, optional modules
For the developersdevelopersA 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:

Angry βˆ’2Frustrated βˆ’1Unsure 0Good 1Love it 2

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.
My feedback: a list of the author’s posts and one open thread with replies and reactions

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.
A thread open in the inbox, with replies, the status timeline and the status buttons

Statuses move a post along:

New→Seen→Planned→Done

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 #

WhoSees
An authorTheir own posts and threads, on My feedback
A triager (canTriage())Every post in the inbox, except developer posts when developerPosts(canRead: …) says no
Everyone elseNothing. 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 #

EventCarriesFires
Events\FeedbackPosted$postOn every new post
Events\ReplyPosted$replyOn 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
});
  • A link to #feedback on 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 #

MethodDefaultWhat it does
modules(array|Closure)[]The parts of the app people can tag. See Modules
moduleFromRoute(?Closure)nonePre-picks the page's module from $route and $url
canTriage(bool|Closure)falseWho opens the inbox and replies as the team
button(bool|Closure)trueThe floating button and My feedback. A closure is checked on each page and hides only the button
inbox(bool)trueThe inbox page
chatWebhook(?string)noneGoogle Chat ping on every new post
developerPosts(bool, bool|Closure|null, ?string)on, all triagersThe "For the developers" kind: enabled, canRead, chatWebhook
allowUnsend(team:, authors:)team true, authors falseWho may unsend their own replies. An unsent reply stays as "… unsent a message"
notifyAuthorUsing(?Closure)database notificationReplaces 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)noneWhere 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.

TableModelHolds
feedback_postsFeedbackPostpanel, kind, mood, modules (JSON slugs), body, status, status_log, context, fix_confirmed, author_seen_at
feedback_repliesFeedbackReplybody, is_from_team, unsent_at
feedback_reactionsFeedbackReactionOne 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 @mentions are 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.