Search by

a2zwebltd / laravel-feature-voting

dawid-makowski

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

Statistics

Installs: 233

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.2.0 2026-09-28 09:53 UTC

This package is auto-updated.

Last update: 2026-09-28 09:55:52 UTC


README

A portable Laravel engine for feature requests, one-vote-per-user voting, and status updates — with optional Blade and Livewire UI layers.

Packagist Version Downloads PHP Laravel

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:

  1. Engine — models, migrations, service, events, policies, mailables. Always works.
  2. HTTP + Blade — controllers, routes, Tailwind-styled views. Works in any Laravel + Tailwind app.
  3. Livewire — reactive vote button, inline commenting, sortable board. Auto-registers only when livewire/livewire is installed.

Screenshots

Board Submit a request
Feature voting board Request submitted
Admin list Status-change email
Requests list Status update 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's lang/*.json.
  • ✅ Mails to users go out in their own locale (HasLocalePreference).
  • ✅ Pluggable admin role — the consumer app defines a Gate; no hard-coded is_admin column.
  • ✅ 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(), fires FeatureRequestPublished and emails the submitter (notifications.notify_submitter_on_publish, default true).

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: