ruvelo / laravel-feedback
A feedback board, public roadmap and changelog for Laravel: people ask, vote and comment, you build, and they hear when it ships.
Requires
- php: ^8.3
- laravel/framework: ^12.4.1|^13.0
- league/commonmark: ^2.6
Requires (Dev)
- larastan/larastan: ^3.13
- laravel/pint: ^1.32
- orchestra/testbench: ^10.0|^11.0
- phpunit/phpunit: ^11.5|^12.0
- ruvelo/laravel-comments: ^1.1
Suggests
- ruvelo/laravel-comments: Richer comment threads on requests: reactions, mentions and moderation (^1.1).
- ruvelo/laravel-inbox: Show "your request shipped" notifications in an in-app inbox and bell (^1.0).
Provides
None
Conflicts
None
Replaces
None
README
Live demo Configuration Developer guide Changelog
Laravel Feedback
A feedback board, public roadmap and changelog that mount into any Laravel app. Your users ask for things and vote, you plan and build, and when it ships they're told, in their inbox and by email. The loop that Canny, Featurebase and Beamer sell as a subscription, as one free package that lives in your own app and database.
composer require ruvelo/laravel-feedback
php artisan migrate
Open /feedback. Define a feedback-manage gate to let your team in, and drop <x-feedback::whats-new /> into your app's header. Or click around the live demo first.
A quick tour
A board people actually use. Requests with one-click votes, filtered by status, category and tag, sorted by top, new or trending. Pinned requests stay on top. Guests can read everything; voting and posting need a sign-in.
Fewer duplicates. While someone types a title, similar requests appear with their votes, so they vote instead of posting the same idea again. When duplicates slip through, merge them: votes carry over and nobody is counted twice.
Every request tells its story. Status history, the team's public notes, threaded comments with Markdown (team replies are marked), and a link to the changelog entry it shipped in.
A public roadmap of what's planned, in progress and shipped, straight from the board. Columns on desktop, stacked on phones.
A changelog with feeds. Markdown entries with labels (New, Improved, Fixed), cover images, and the requests they answer. RSS and Atom included.
"What's new" in your app. <x-feedback::whats-new /> shows how many updates each user hasn't seen and a dropdown of the latest. Without JavaScript it's a link to the changelog.
Close the loop. Change a status with a public note, or publish a changelog entry that ships the linked requests: the author and every voter get a Laravel notification (database and mail). Its title, body and url show up in ruvelo/laravel-inbox with no setup.
Features
- Feedback board: requests with a title and Markdown description, one vote per person (enforced by a unique index), categories and tags, filters, search, and top, new and trending sorts. Pin what matters.
- Similar requests as you type a title, and merging that carries votes and comments over.
- Statuses: Under review, Planned, In progress, Shipped and Closed, with your own labels and colours, or extra statuses of your own. Shipped and closed requests stop taking votes.
- Comments: simple threads with replies and Markdown, or ruvelo/laravel-comments (reactions, mentions, moderation) when it's installed.
- Public roadmap: your planned, in-progress and shipped requests as columns.
- Changelog: Markdown entries with labels, cover images, drafts and scheduling, linked requests, RSS and Atom feeds.
- "What's new" widget: an unread badge per user (a last-seen time; a cookie for guests) and a dropdown of recent entries.
- Notifications for authors and voters when a request changes status or ships, through any Laravel channels.
- Admin area: an approval queue (optional), status changes with public notes, merging, editing, categories and tags, the changelog editor and stats.
- Safe by default: everything escaped, raw HTML in Markdown escaped,
javascript:links dropped, images in user posts turned into links. Rate limits and a honeypot on every public write. Guests trying to write go to yourloginroute, or get a 403: never a 500. - Works without JavaScript, and gets better with it: votes, similar requests, replies and the editor preview happen in place.
- For developers: a typed PHP API, events for every change, typed exceptions, factories, a JSON API, and Laravel Boost guidelines.
- Ruvelo house style: scoped styles that never leak into your app, themable with CSS variables, or rendered inside your own layout.
Requirements
- PHP 8.3+
- Laravel 12 or 13
- Any database Laravel supports (tested on SQLite)
Getting started
1. Install and migrate, as above. The board is at /feedback, the roadmap at /feedback/roadmap and the changelog at /feedback/changelog.
2. Let your team manage it. Nobody can until you define the gate, for example in your AppServiceProvider:
use Illuminate\Support\Facades\Gate; Gate::define('feedback-manage', fn ($user) => $user->is_admin);
Managers see a Manage button, a panel on every request, and the admin area at /feedback/manage.
3. Notifications. When a request changes status, its author and voters get a RequestStatusChanged notification through the database and mail channels. Your user model needs the Notifiable trait, and the database channel needs the notifications table (php artisan make:notifications-table && php artisan migrate); until it exists, that channel is skipped.
4. Add "What's new" to your header:
<header> … <a href="{{ route('feedback.home') }}">Feedback</a> <x-feedback::whats-new /> </header>
It brings its own styles and script, once per page. Options: :limit="3", label="Updates", data-theme="light" for a light-only app.
Who can do what
| Gate | Arguments | Default |
|---|---|---|
feedback-manage |
$user |
Nobody, until you define it |
feedback-post |
$user |
Any signed-in user |
feedback-vote |
$user, $request |
Any signed-in user |
feedback-comment |
$user, $request |
Any signed-in user |
Define a gate with the same name to decide yourself, e.g. Gate::define('feedback-post', fn ($user) => $user->hasVerifiedEmail()). Comments can be deleted by their author or a manager.
Configuration
Publish the config file to change the defaults:
php artisan vendor:publish --tag=feedback-config
| Key | Default | |
|---|---|---|
name |
APP_NAME (FEEDBACK_NAME) |
Shown in the header, titles and feeds |
sections.board / .roadmap / .changelog |
true |
Switch a section off: its routes and tab go away |
path |
feedback (FEEDBACK_PATH) |
URL prefix for everything |
domain |
null |
Serve it on another (sub)domain |
middleware |
['web'] |
Every route. Add auth to make the whole portal private |
manage_middleware |
[] |
Added to the admin area, on top of the gate |
routes |
true |
Set to false to register routes yourself (copy routes/web.php) |
layout / section |
null / content |
Your layout view, and the section the pages fill. null uses the package's page |
app.label / app.url |
null |
A link back to your app in the header |
statuses |
five built-in | Key ⇒ label and color (accent, lilac, success, warning, danger, neutral, or a hex colour). Keep the built-in keys; add your own |
open_statuses |
under review, planned, in progress | The board's default filter |
locked_statuses |
shipped, closed | No new votes in these |
board.sort |
top |
Default sort: top, new or trending |
board.trending_days |
14 |
What "trending" counts |
board.per_page |
20 |
Requests per page |
board.require_approval |
false |
Hold new requests for a manager |
board.similar |
5 |
How many similar requests to suggest |
roadmap.statuses / .limit |
planned, in progress, shipped / 20 |
The roadmap's columns and their length |
changelog.labels |
New, Improved, Fixed | Key ⇒ label and color |
changelog.per_page / .feed |
10 / 20 |
Entries per page and in the feeds |
widget.limit / .fresh_days |
5 / 30 |
Entries in the dropdown; what counts as new for someone who never looked |
comments.driver |
auto (FEEDBACK_COMMENTS) |
builtin, ruvelo (ruvelo/laravel-comments), auto (ruvelo when installed), or null for none |
notifications.enabled |
true (FEEDBACK_NOTIFICATIONS) |
Tell authors and voters about status changes |
notifications.channels |
['database', 'mail'] |
Any Laravel channels |
notifications.statuses |
null |
Only notify for these statuses; null for all |
rate_limits.requests / .comments / .votes |
5 / 10 / 60 |
Per user per minute, through the routes. null turns one off |
honeypot |
website |
Hidden field name; posts that fill it are dropped. null turns it off |
max_length.title / .body / .comment |
160 / 10000 / 5000 |
Characters |
name_attribute |
name |
The user attribute shown as a name |
api.enabled |
false (FEEDBACK_API) |
Turn on the JSON API |
api.prefix / .middleware / .write_middleware |
api/feedback / ['api'] / ['auth:sanctum'] |
Where it lives and how writes authenticate |
markdown.extensions / .options |
[] |
Extra CommonMark extensions and options. html_input and allow_unsafe_links can't be loosened |
table_prefix |
feedback_ |
Tables: requests, votes, comments, status_changes, categories, tags, request_tag, entries, seen |
run_migrations |
true |
Set to false if you publish and run the migration yourself (--tag=feedback-migrations) |
Making it look like your app
The pages and the widget are styled with CSS variables, scoped to .fb and .fb-wn. Override any of them:
.fb, .fb-wn { --feedback-accent: #0f766e; --feedback-accent-soft: #e6f4f1; --feedback-radius: 4px; --feedback-sans: Inter, sans-serif; }
To render the portal inside your app's chrome, set layout to your layout view: the pages fill its content section (or whichever you name in section) and push their meta tags to a feedback-head stack if your layout has one.
For deeper changes, publish the views (php artisan vendor:publish --tag=feedback-views). They land in resources/views/vendor/feedback: the board, request, roadmap and changelog pages, the admin area in manage/, the widget in components/whats-new.blade.php, and the partials/ they're built from.
For developers
PHP API
Ruvelo\Feedback\Feedback is the one write path: the routes use it too, so counts stay right and events fire.
use Ruvelo\Feedback\Feedback; $request = Feedback::post($user, 'Dark mode', 'For **late** nights', category: 'Billing', tags: ['Enterprise']); Feedback::vote($request, $other); // true when added, false if they'd already voted Feedback::unvote($request, $other); Feedback::toggleVote($request, $other); // whether they're voting now Feedback::comment($request, $user, 'Me too', parent: $comment); Feedback::changeStatus($request, 'planned', 'Starting in March.', by: $admin); // notifies, unless notify: false Feedback::ship($request, $entry, by: $admin); Feedback::merge($duplicate, into: $request, by: $admin); Feedback::pin($request); Feedback::update($request, ['title' => 'Dark theme', 'tags' => ['Quick win']]); Feedback::approve($request); Feedback::delete($request); $entry = Feedback::publish('Dark mode is here', $markdown, ['new'], coverUrl: $url, requests: [$request]); Feedback::saveEntry($entry, ['published_at' => null]); // back to draft Feedback::requests($viewer)->where('status', 'planned')->get(); // what $viewer may see Feedback::similar('dark theme'); Feedback::roadmap(); // ['planned' => Collection, …] Feedback::changelog()->limit(5)->get(); Feedback::unreadCount($user); Feedback::markSeen($user); Feedback::stats(); Feedback::allows('feedback-vote', $user, $request); Feedback::render('**Markdown**'); // the same safe HTML requests get
The PHP API doesn't check gates or rate limits, so imports and seeders aren't slowed down. Call Feedback::allows() (or authorize(), which throws NotAllowed) when acting for a user.
JSON API
Off by default: set FEEDBACK_API=true. Reads are public and only show what the caller may see. Writes need write_middleware (Sanctum by default; use ['web'] for both middleware and write_middleware to rely on the session in an Inertia or Livewire app on the same domain). The web routes answer JSON too when asked (Accept: application/json), which is how the vote buttons work.
| Request | Who | Does |
|---|---|---|
GET /api/feedback/requests?status=&category=&tag=&q=&sort=&per_page= |
Anyone | The board, paginated |
GET /api/feedback/requests/{id} |
Anyone | One request with its history |
GET /api/feedback/requests/similar?q= |
Anyone | Similar requests |
GET /api/feedback/requests/{id}/comments |
Anyone | Comments with nested replies (built-in comments) |
GET /api/feedback/roadmap |
Anyone | Columns of requests |
GET /api/feedback/statuses, /categories, /tags |
Anyone | For filters |
GET /api/feedback/changelog?label= |
Anyone | Published entries |
GET /api/feedback/changelog/{slug} |
Anyone | One entry with its requests |
GET /api/feedback/changelog/whats-new?limit=&since= |
Anyone | unread and the latest entries, for your own widget |
POST /api/feedback/requests |
Signed in | Post {title, body?, category?} |
POST / DELETE /api/feedback/requests/{id}/vote |
Signed in | Vote / take it back. Returns voted and votes |
POST /api/feedback/requests/{id}/comments |
Signed in | Comment {body, parent_id?} |
POST /api/feedback/changelog/seen |
Signed in | Mark the changelog as seen |
PATCH /api/feedback/requests/{id} |
Managers | {title?, body?, category?, tags?, status?, note?, notify?, entry?, pinned?} |
POST /api/feedback/requests/{id}/merge |
Managers | {into} |
POST /api/feedback/requests/{id}/approve, DELETE …/{id} |
Managers | Approve, delete |
POST /api/feedback/changelog, PATCH / DELETE …/{id} |
Managers | Write entries {title, body, labels, cover_url, published_at, requests, notify} |
A request looks like this:
{
"id": 3, "title": "Usage-based billing", "slug": "usage-based-billing",
"body": "We sell API access…", "html": "<p>We sell API access…</p>\n", "excerpt": "We sell API access…",
"status": "shipped", "status_label": "Shipped", "status_note": null, "status_changed_at": "2026-10-06T09:12:00+00:00",
"votes": 167, "comments": 4, "voted": true, "pinned": false, "approved": true, "locked": true,
"author": { "name": "Priya Raman", "initials": "PR" },
"category": { "name": "Billing", "slug": "billing" }, "tags": [{ "name": "Enterprise", "slug": "enterprise" }],
"entry": { "title": "Usage-based billing is here", "url": "https://app.test/feedback/changelog/usage-based-billing-is-here" },
"merged_into": null, "url": "https://app.test/feedback/requests/3-usage-based-billing",
"created_at": "2026-07-12T10:00:00+00:00",
"can": { "vote": false, "comment": true, "manage": false }
}
Errors are JSON: 401 for guests, 403 when a gate says no, 404 for what the caller can't see, 409 when voting is closed, 422 for validation (with errors), 429 when rate limited.
Events
All in Ruvelo\Feedback\Events:
| Event | When |
|---|---|
RequestPosted |
A request is posted (check $request->isApproved()) |
RequestApproved |
A manager approves a waiting request |
RequestUpdated |
A manager edits it; $changes |
RequestVoted |
A vote is added or taken back: $voter, $added |
StatusChanged |
$from, $to, $note, $by |
RequestsMerged |
$duplicate, $into, $movedVotes, $by |
RequestPinned |
$pinned |
RequestDeleted |
It's gone, with its votes and comments |
CommentPosted, CommentDeleted |
Built-in comments |
EntrySaved |
A changelog entry is created or changed |
EntryPublished |
An entry goes live (once per entry) |
EntryDeleted |
An entry is deleted |
CategorySaved, CategoryDeleted, TagSaved, TagDeleted |
Taxonomy changes |
Exceptions
All extend FeedbackException and render themselves as the right response when thrown in a request: VotingClosed (409), NotAllowed (403), InvalidInput, InvalidStatus, InvalidLabel, InvalidParent, CannotMerge and CategoryNotFound (422).
Notifications
Ruvelo\Feedback\Notifications\RequestStatusChanged is a regular notification. toArray() returns title ("Shipped: Usage-based billing"), body, url (the changelog entry for shipped requests), request_id and status; toMail() is there for the mail channel. Nobody hears about their own change.
It's queued (ShouldQueue) and dispatched after the database transaction commits, so shipping a request with hundreds of voters never sends mail inside the web request. Run a queue worker in production; on the sync queue it sends right away.
In your tests
use Ruvelo\Feedback\Models\Entry; use Ruvelo\Feedback\Models\FeatureRequest; FeatureRequest::factory()->by($user)->create(); FeatureRequest::factory()->status('planned', 'Soon')->votedBy($a, $b)->create(); FeatureRequest::factory()->titled('Dark mode')->pending()->create(); Entry::factory()->labels('new', 'fixed')->create(); Entry::factory()->draft()->create();
Using an AI coding agent?
The package ships Laravel Boost guidelines in resources/boost/guidelines/core.blade.php. With Boost installed (composer require laravel/boost --dev), run php artisan boost:install and pick ruvelo/laravel-feedback from the third-party packages (or php artisan boost:update --discover if Boost is already set up). Your agent then knows how to define the gates, add the widget, use the PHP and JSON APIs, and what not to do.
Contributing
Pull requests are welcome. Clone, composer install, then composer check runs code style (Pint), static analysis (PHPStan level 8) and the tests, exactly as CI does. See CONTRIBUTING.md and the changelog.
The demo and screenshots are built from the package itself: composer demo writes the static demo into build/, and demo/screenshots.sh regenerates art/.
Credits
Built by François Bultez at Ruvelo, and everyone who contributes.
License
MIT. See LICENSE.











