a2zwebltd / laravel-feature-voting
A portable Laravel engine for feature requests, voting, and status updates — with optional Blade and Livewire UI layers.
Package info
github.com/a2zwebltd/laravel-feature-voting
pkg:composer/a2zwebltd/laravel-feature-voting
Requires
- php: ^8.2
- laravel/framework: ^12.0|^13.0
Requires (Dev)
- laravel/pint: ^1.25
- livewire/livewire: ^3.0|^4.0
- orchestra/testbench: ^10.6
- pestphp/pest: ^3.0|^4.0
- pestphp/pest-plugin-laravel: ^3.0|^4.0
Suggests
- livewire/livewire: Enables the reactive Livewire UI layer (Board, RequestShow, VoteButton components).
Provides
None
Conflicts
None
Replaces
None
README
A portable Laravel engine for feature requests, one-vote-per-user voting, and status updates — with optional Blade and Livewire UI layers.
Drop a feature-voting board into any Laravel app. Users submit ideas, vote (one vote per user, toggleable), and comment. Admins update the status (under_review → planned → in_progress → completed or declined) and submitters are emailed when it changes.
Three layers, each opt-in from the one below:
- Engine — models, migrations, service, events, policies, mailables. Always works.
- HTTP + Blade — controllers, routes, Tailwind-styled views. Works in any Laravel + Tailwind app.
- Livewire — reactive vote button, inline commenting, sortable board. Auto-registers only when
livewire/livewireis installed.
Screenshots
| Board | Submit a request |
|---|---|
![]() |
![]() |
| Admin list | Status-change email |
|---|---|
![]() |
![]() |
Features
- ✅ One-vote-per-user enforced at the database level (unique index).
- ✅ Submission, voting (toggle), commenting — all through a single
FeatureVotingService. - ✅ Industry-standard status workflow:
under_review,planned,in_progress,completed,declined. - ✅ Admin response field on each request + styled admin comment thread.
- ✅ Email the configured admin on every new submission.
- ✅ Email the submitter every time the status changes — the "closed loop" that research shows is the #1 retention driver for feedback tools.
- ✅ Optional moderation: new requests stay private until an admin approves them.
- ✅ Every user-facing string goes through
__(): translate the board and the mails from your app'slang/*.json. - ✅ Mails to users go out in their own locale (
HasLocalePreference). - ✅ Pluggable admin role — the consumer app defines a Gate; no hard-coded
is_admincolumn. - ✅ Events you can listen to:
FeatureRequestCreated,FeatureRequestVoted,FeatureRequestCommented,FeatureRequestStatusChanged,FeatureRequestPublished. - ✅ Publishable config, migrations, views, and mail templates.
- ✅ Portable: no assumptions about the User model, theme, or UI library.
Requirements
- PHP
^8.2 - Laravel
^12.0 | ^13.0 - Tailwind CSS in the consumer app (if using the shipped Blade views — not required if you build your own UI)
livewire/livewire^3.0 | ^4.0(optional — only for the Livewire layer)
Installation
composer require a2zwebltd/laravel-feature-voting
Publish and migrate:
php artisan vendor:publish --tag=feature-voting-config php artisan vendor:publish --tag=feature-voting-migrations php artisan migrate
Publish the migration once. Each publish writes a new file stamped with the current time, so publishing again (or running vendor:publish --all) creates a duplicate create_feature_voting_tables migration that fails with "table already exists". If your app already has one, skip this tag when upgrading.
Upgrading from v1.0 / v1.1
v1.2 adds a published_at column (moderation). Your existing create_feature_voting_tables migration stays as it is. Publish the new, separate migration and run it:
php artisan vendor:publish --tag=feature-voting-moderation-migration php artisan migrate
It adds the column and sets published_at = created_at on every existing request, so your board stays exactly as it was. It checks for the column first, so running it on a fresh install (where the create migration already has the column) does nothing. Until you run it, keep moderation off: with moderation off the package never touches the column.
Optionally publish the views if you want to restyle them:
php artisan vendor:publish --tag=feature-voting-views
Configuration
config/feature-voting.php:
'admin_email' => env('FEATURE_VOTING_ADMIN_EMAIL'), 'admin_gate' => 'manage-feature-requests', 'routes' => [ 'enabled' => true, 'prefix' => 'feature-requests', 'middleware' => ['web', 'auth'], 'admin_middleware' => [], // extra middleware for the admin-only routes ], 'views' => [ 'layout' => 'feature-voting::layouts.default', // point at your own layout 'section' => 'content', 'component' => null, // or a component layout, e.g. 'layouts.app' ], 'livewire' => ['register' => true], 'moderation' => ['enabled' => env('FEATURE_VOTING_MODERATION', false)],
.env:
FEATURE_VOTING_ADMIN_EMAIL=admin@yourcompany.com
FEATURE_VOTING_MODERATION=false
Define who is an admin
The package delegates the "is this user an admin?" decision to a Gate the consumer app defines:
// app/Providers/AppServiceProvider.php — boot() use Illuminate\Support\Facades\Gate; use App\Models\User; Gate::define('manage-feature-requests', function (User $user) { return in_array($user->email, ['dawid@a2zweb.co']); // or $user->is_admin, or a role check, etc. });
If no gate is registered, the package falls back to comparing $user->email to config('feature-voting.admin_email'). If neither is set, nobody is an admin (fail-closed).
Point the views at your own layout
The shipped pages render inside a layout you control. In your published config/feature-voting.php, pick the style your layout uses.
A classic layout that @yields a section:
'views' => [ 'layout' => 'layouts.app', // your existing layout 'section' => 'content', // the @yield name inside it ],
A Blade component layout that renders {{ $slot }} (e.g. <x-layouts.app>):
'views' => [ 'component' => 'layouts.app', // the name after "x-" ],
Namespaced anonymous components work too: with Blade::anonymousComponentPath(resource_path('views/layouts'), 'layouts'), set 'component' => 'layouts::app'.
When component is set, layout and section are ignored. If you published index.blade.php / show.blade.php before v1.1, update their @extends(...) line from the package views, or set 'layout' => 'feature-voting::layouts.component' alongside component.
Extra middleware for admin routes
routes.middleware wraps every route. routes.admin_middleware is added on top for the two admin-only routes (PATCH {slug}/admin, DELETE {slug}):
'routes' => [ 'middleware' => ['web', 'auth'], 'admin_middleware' => ['can:manage-feature-requests'], ],
The policy still checks the admin Gate, so this is an extra layer, not a replacement.
Moderation
One board shared by many customers can leak a detail someone typed into a title. Turn on moderation and every new request stays private until an admin approves it:
FEATURE_VOTING_MODERATION=true
With moderation on:
- A new request gets
published_at = null. Its author still sees it on the board and on its page, marked "Waiting for approval — only you can see it". Everybody else gets a 404. - Admins see every request. Pending ones carry an Approve button (on the card and in the admin panel), next to the status form and Delete request.
- Nobody but an admin can vote on or comment on a pending request, not even its author.
- The admin mail for a new request says it awaits approval and links to the review page.
- Approving goes through
FeatureVotingService::publish(), firesFeatureRequestPublishedand emails the submitter (notifications.notify_submitter_on_publish, defaulttrue).
With moderation off (the default) new requests get published_at = now() and nothing else changes. The package does not read the column at all then, so apps that never ran the v1.2 migration keep working.
Translations and mail locale
The package ships no lang files. Every string in the views, flash messages, status labels and mail subjects goes through __() with the English text as the key, so you translate it in your app's lang/<locale>.json:
{
"Feature Requests": "Propozycje funkcji",
"Waiting for approval — only you can see it": "Czeka na akceptację — widzisz ją tylko Ty",
"Your feature request is now :status": "Twoja propozycja ma teraz status: :status",
":count vote|:count votes": ":count głos|:count głosy|:count głosów",
":count comment|:count comments": ":count komentarz|:count komentarze|:count komentarzy"
}
Counts use trans_choice(). Placeholders (:title, :status, :count) keep word order up to the translator. To list every key, grep the package for __(' and trans_choice('.
Mails to a submitter are sent to the user model (Mail::to($user)), so if your User implements Illuminate\Contracts\Translation\HasLocalePreference, the status and publish mails go out in that user's language. The admin mail goes to the admin_email address in the app locale.
Usage
Drop-in: use the shipped pages
After install you get a working board at /feature-requests. Add a link from your app's nav:
<a href="{{ route('feature-voting.index') }}">Feature Requests</a>
Embed Blade components into your own pages
<x-feature-voting::board /> {{-- full board --}} <x-feature-voting::submit-form /> <x-feature-voting::request-card :request="$r" /> <x-feature-voting::vote-button :request="$r" /> <x-feature-voting::status-badge :status="$r->status" /> <x-feature-voting::comments :request="$r" /> <x-feature-voting::admin-panel :request="$r" /> {{-- renders only for admins --}}
Use the Livewire components
<livewire:feature-voting.board /> <livewire:feature-voting.request-show :request="$r" /> <livewire:feature-voting.vote-button :request="$r" />
Call the engine directly
use A2ZWeb\FeatureVoting\Services\FeatureVotingService; use A2ZWeb\FeatureVoting\Enums\FeatureRequestStatus; $service = app(FeatureVotingService::class); $request = $service->submit($user, 'Add Slack export', 'Would love this'); $service->toggleVote($user, $request); $service->comment($user, $request, 'Same here!'); $service->updateStatus($admin, $request, FeatureRequestStatus::Planned, 'Targeting Q3'); $service->publish($request); // moderation: approve a pending request $service->isAdmin($user);
Listen for events
use A2ZWeb\FeatureVoting\Events\FeatureRequestCreated; use Illuminate\Support\Facades\Event; Event::listen(FeatureRequestCreated::class, function ($event) { // ping Slack, create a JIRA ticket, etc. });
Data model
feature_requests — id, user_id (nullable), title, slug unique, description, status, votes_count, comments_count, admin_response, admin_responded_at, metadata (json), published_at (nullable, since v1.2), timestamps, soft deletes.
feature_request_votes — id, feature_request_id, user_id, timestamps. Unique (feature_request_id, user_id).
feature_request_comments — id, feature_request_id, user_id (nullable), body, is_admin_response, timestamps, soft deletes.
Disabling the HTTP layer
If you want to wire your own routes:
// config/feature-voting.php 'routes' => ['enabled' => false, /* ... */],
Then use the service directly and/or mount the Blade components into your own controllers.
AI agents (Laravel Boost)
This package ships a Laravel Boost guideline and a feature-voting-integration skill (admin Gate, layouts, events, custom routes, view overrides). Boost 2 or newer is required. In your app:
composer require laravel/boost --dev php artisan boost:install # first time php artisan boost:update --discover # already installed
Select a2zwebltd/laravel-feature-voting when asked. Boost only scans packages your app requires directly in composer.json.
Testing
composer install vendor/bin/pest
Security Vulnerabilities
If you discover a security vulnerability, please report it responsibly through private communication with the maintainers.
License
MIT. See LICENSE.
Credits
Developed and maintained by the A2Z WEB crew:
- Dawid Makowski
- Website: https://a2zweb.co/
- GitHub: https://github.com/a2zwebltd/



