Integrate and extend a2zwebltd/laravel-feature-voting (feature request board with voting, comments and status updates). Use when touching FeatureVotingService, the FeatureRequest/FeatureRequestVote/FeatureRequestComment models, config/feature-voting.php, the manage-feature-requests Gate, the feature-voting.* routes or views, listeners for the FeatureRequestCreated/Voted/Commented/StatusChanged/Published events, moderation (published_at, approving requests), or translating the board and its mails.
php artisan boost:add-skill a2zwebltd/laravel-feature-voting
Save this content to: .claude/skills/feature-voting-integration/SKILL.md
# Feature Voting integration
## When to use this skill
- Installing the package or wiring it into a host layout.
- Deciding who is an admin (`manage-feature-requests` Gate).
- Writing code that creates requests, votes, comments or changes status.
- Reacting to package events (Slack pings, comment notifications).
- Adding host routes next to the package routes, overriding views, styling with Tailwind.
- Turning on moderation or approving requests (`published_at`, `publish()`).
- Translating the board and mails into the host's languages.
## Install / wiring checklist
1. `composer require a2zwebltd/laravel-feature-voting`
2. `php artisan vendor:publish --tag=feature-voting-config`
3. Publish the migration **once**: `php artisan vendor:publish --tag=feature-voting-migrations`, then `php artisan migrate`.
The stub is copied as `database/migrations/<now>_create_feature_voting_tables.php` with the current timestamp, so every re-publish (including `vendor:publish --all` or `--provider`) adds a second copy that fails with "table already exists". If the host already has a `*_create_feature_voting_tables.php`, never publish this tag again. The package does not `loadMigrationsFrom()`.
4. Upgrading a host that published the migration before v1.2: publish the separate moderation migration once, `php artisan vendor:publish --tag=feature-voting-moderation-migration`, then migrate. It adds `published_at` (only if missing) and backfills it from `created_at`, so existing requests stay public. Fresh installs already get the column from step 3; publishing this tag there too is harmless.
5. Define the admin Gate in `AppServiceProvider::boot()` and/or set `FEATURE_VOTING_ADMIN_EMAIL` (see below).
6. Point the pages at the host layout (`views.layout`/`views.section` or `views.component`).
7. If the host builds Tailwind from sources, add an `@source` for the package views (see Recipes).
8. Optional: moderation (`FEATURE_VOTING_MODERATION=true`) and host translations in `lang/*.json` (see Recipes).
9. Optional: `use A2ZWeb\FeatureVoting\Traits\HasFeatureVoting;` on the User model.
## API & config reference
### Admin check: Gate only
`FeatureVotingService::isAdmin(Authenticatable $user): bool` returns true only when a Gate named `config('feature-voting.admin_gate')` (default `manage-feature-requests`) is defined **and** allows the user. No Gate means nobody is admin (fail closed). `admin_email` never grants admin rights (it did before 1.3.0); don't base the Gate on an email match either, base it on a role or flag. The policy (`FeatureRequestPolicy`: `update`, `delete`, `changeStatus`, `replyAsAdmin`) delegates to `isAdmin()`.
```php
use App\Models\User;
use Illuminate\Support\Facades\Gate;
Gate::define('manage-feature-requests', fn (User $user): bool => $user->isAdmin());
```
`admin_email` is also the **only** recipient of the new-request email (`NotifyAdminOfNewRequest`). With a Gate but no `admin_email`, no admin email is sent.
### `config/feature-voting.php`
| Key | Default | Notes |
| --- | --- | --- |
| `user_model` | `null` | null resolves `auth.providers.<web guard provider>.model` |
| `admin_gate` | `'manage-feature-requests'` | Gate name used by `isAdmin()` |
| `admin_email` | `env('FEATURE_VOTING_ADMIN_EMAIL')` | fallback admin + new-request mail recipient |
| `routes.enabled` | `env('FEATURE_VOTING_ROUTES', true)` | false = register your own routes |
| `routes.prefix` | `'feature-requests'` | URL prefix |
| `routes.name_prefix` | `'feature-voting.'` | route names: `index`, `store`, `show`, `vote`, `comment`, `admin.update`, `admin.publish`, `admin.destroy` |
| `routes.middleware` | `['web', 'auth']` | wraps every package route |
| `routes.admin_middleware` | `[]` | added on top of `middleware` for `admin.update`, `admin.publish` and `admin.destroy` only (since v1.1) |
| `views.layout` / `views.section` | `'feature-voting::layouts.default'` / `'content'` | `@extends` layout + the section it `@yield`s |
| `views.component` | `null` | component layout name (since v1.1), e.g. `layouts.app` or a namespaced `layouts::app`; when set, `layout`/`section` are ignored |
| `moderation.enabled` | `env('FEATURE_VOTING_MODERATION', false)` | since v1.2; new requests wait for admin approval |
| `livewire.register` | `true` | registers Livewire components only if Livewire is installed |
| `tables.prefix` | `''` | prefix for the three tables (used by models and the migration) |
| `queue` | `env('FEATURE_VOTING_QUEUE')` | queue name for the package mailables |
| `notifications.notify_admin_on_new_request` | `true` | |
| `notifications.notify_submitter_on_status_change` | `true` | |
| `notifications.notify_submitter_on_publish` | `true` | since v1.2; mail on approval |
| `pagination.per_page` | `15` | |
### Layout options
The full pages `feature-voting::index` and `feature-voting::show` support two host layouts:
```php
// Classic layout: resources/views/layouts/app.blade.php with @yield('content')
'views' => ['layout' => 'layouts.app', 'section' => 'content', 'component' => null],
// Component layout: <x-layouts.app> rendering {{ $slot }}
'views' => ['layout' => 'feature-voting::layouts.default', 'section' => 'content', 'component' => 'layouts.app'],
```
A component layout cannot be used through `layout`/`section`: `@extends` never fills `$slot`. Use `component`.
If the host has **published** `index.blade.php`/`show.blade.php` from v1.0, their first line still reads `@extends(config('feature-voting.views.layout'))`, so `views.component` is ignored by them. Either copy the new first line from the package views, or set `'layout' => 'feature-voting::layouts.component'` together with `component`.
### `FeatureVotingService` (singleton, resolve via DI or `app()`)
```php
submit(Authenticatable $user, string $title, string $description, array $metadata = []): FeatureRequest
toggleVote(Authenticatable $user, FeatureRequest $request): bool // true = added, false = removed
userHasVoted(Authenticatable $user, FeatureRequest $request): bool
comment(Authenticatable $user, FeatureRequest $request, string $body): FeatureRequestComment
updateStatus(Authenticatable $admin, FeatureRequest $request, FeatureRequestStatus $status, ?string $response = null): void
publish(FeatureRequest $request): FeatureRequest // since v1.2; approve a pending request
isAdmin(Authenticatable $user): bool
```
Always write through the service. `votes_count` and `comments_count` on `feature_requests` are denormalized counters that only the service maintains (`toggleVote` locks the vote row and increments/decrements, `comment` increments), and the events are dispatched only there. `FeatureRequestVote::create()` or `FeatureRequestComment::create()` from host code leaves the counters wrong and fires no events. `comment()` sets `is_admin_response` from `isAdmin()`. `updateStatus()` does not authorize; check `$user->can('changeStatus', $request)` first. It fires `FeatureRequestStatusChanged` only when the status actually changes. `publish()` does not authorize either (check `can('publish', $request)`); it sets `published_at` and fires `FeatureRequestPublished` only if the request was pending.
Status enum `A2ZWeb\FeatureVoting\Enums\FeatureRequestStatus`: `UnderReview` (`under_review`), `Planned`, `InProgress`, `Completed`, `Declined`; helpers `label()`, `color()` (Tailwind colour token), `isOpen()`, `options()`.
Model scopes on `FeatureRequest`: `top()`, `newest()`, `withStatus(FeatureRequestStatus|string)`, `published()`, `visibleTo(?Authenticatable $user)`. The last two are no-ops while moderation is off. Helpers: `isPublished()` (always true with moderation off), `isPendingApproval()`, `isVisibleTo($user)`, `isOwnedBy($user)`, static `moderationEnabled()`. Policy abilities: `view` (= `isVisibleTo`), `vote` and `comment` (published, or admin), `publish` (admin). Route key is `slug`. `FeatureRequest` and `FeatureRequestComment` use soft deletes.
### Events (`A2ZWeb\FeatureVoting\Events\*`)
| Event | Public properties |
| --- | --- |
| `FeatureRequestCreated` | `FeatureRequest $featureRequest` |
| `FeatureRequestVoted` | `FeatureRequest $featureRequest`, `Authenticatable $user`, `bool $voted` |
| `FeatureRequestCommented` | `FeatureRequestComment $comment` |
| `FeatureRequestStatusChanged` | `FeatureRequest $featureRequest`, `FeatureRequestStatus $previousStatus`, `FeatureRequestStatus $currentStatus` |
| `FeatureRequestPublished` | `FeatureRequest $featureRequest` (since v1.2) |
Built-in listeners: `NotifyAdminOfNewRequest` (Created), `NotifySubmitterOfStatusChange` (StatusChanged) and `NotifySubmitterOfPublish` (Published). Nothing listens to Voted or Commented: comment notifications are the host's job.
## Recipes
### Moderation for a board shared by many customers
```dotenv
FEATURE_VOTING_MODERATION=true
[email protected]
```
1. Make sure `feature_requests.published_at` exists (install step 4). With moderation on and no column, `submit()` throws a `LogicException` naming the tag to publish.
2. New requests get `published_at = null`. The author sees them marked "Waiting for approval — only you can see it"; other users get a 404 on the page, vote and comment routes. Admins see everything, with Approve on the card and in the admin panel.
3. The admin mail says the request awaits approval and links to it. Approve from the UI, or in code:
```php
use A2ZWeb\FeatureVoting\Services\FeatureVotingService;
abort_unless($admin->can('publish', $featureRequest), 403);
app(FeatureVotingService::class)->publish($featureRequest); // emails the submitter
```
Rejecting is deleting: `DELETE route('feature-voting.admin.destroy', $request)` (admin panel button).
Any host query that lists requests (dashboards, widgets, sitemaps, API) must use `FeatureRequest::query()->visibleTo($user)` or `->published()`. A bare `FeatureRequest::query()` includes pending requests.
### Translating the board and mails
No lang files ship. Keys are the English strings, so the host adds them to `lang/<locale>.json`:
```json
{
"Feature Requests": "Funktionswünsche",
"Waiting for approval — only you can see it": "Wartet auf Freigabe – nur Sie sehen ihn",
"Under Review": "In Prüfung",
"Your feature request is now :status": "Ihr Wunsch hat jetzt den Status :status",
":count vote|:count votes": ":count Stimme|:count Stimmen",
":count comment|:count comments": ":count Kommentar|:count Kommentare"
}
```
Find every key with `grep -rhoE "(__|trans_choice)\('[^']+'" vendor/a2zwebltd/laravel-feature-voting/{resources,src}`. Status labels come from `FeatureRequestStatus::label()` (translated), mail subjects use `:title`/`:status` placeholders. Mails to submitters go to the user model, so a User implementing `HasLocalePreference` (e.g. returning `$this->locale`) gets them in their language. The new-request mail goes to the `admin_email` string in the app locale.
### Email the admin on every new comment (brandgeo pattern)
```php
namespace App\Listeners;
use A2ZWeb\FeatureVoting\Events\FeatureRequestCommented;
use App\Mail\NewFeatureRequestCommentMail;
use Illuminate\Support\Facades\Mail;
class NotifyAdminOfNewComment
{
public function handle(FeatureRequestCommented $event): void
{
$adminEmail = config('feature-voting.admin_email');
if (! $adminEmail || $event->comment->is_admin_response) {
return;
}
$mail = new NewFeatureRequestCommentMail($event->comment);
if ($queue = config('feature-voting.queue')) {
$mail->onQueue($queue);
}
Mail::to($adminEmail)->send($mail);
}
}
```
Event discovery picks it up from `app/Listeners`; otherwise `Event::listen(FeatureRequestCommented::class, NotifyAdminOfNewComment::class)`.
### Custom route next to the package routes (sharpapi self-delete pattern)
Package routes are registered in the provider's `boot()`. Put host routes in `routes/web.php` with the same prefix, the same middleware and a `feature-voting.` name, and do the authorization in the controller:
```php
Route::middleware(['web', 'auth', 'verified'])
->delete('/feature-requests/{featureRequest:slug}/mine', FeatureRequestSelfDestroyController::class)
->name('feature-voting.self.destroy');
```
```php
public function __invoke(Request $request, FeatureRequest $featureRequest): RedirectResponse
{
abort_unless(
$featureRequest->user_id === $request->user()->getAuthIdentifier()
&& $featureRequest->votes_count === 0
&& $featureRequest->status === FeatureRequestStatus::UnderReview,
403,
);
$featureRequest->delete();
return redirect()->route('feature-voting.index');
}
```
Use a distinct URI (here `/mine`): the package already owns `DELETE {prefix}/{slug}` (admin only).
### Extra admin-route middleware
```php
'routes' => [
'middleware' => ['web', 'auth', 'verified'],
'admin_middleware' => ['can:manage-feature-requests'],
],
```
### View overrides and Tailwind
`php artisan vendor:publish --tag=feature-voting-views` copies everything to `resources/views/vendor/feature-voting/`; delete the files you don't change so package updates still reach them. Mail templates are in `mail/`.
The views use Tailwind utility classes (the default layout loads the Tailwind CDN). A host that compiles Tailwind v4 must scan the package views, or the classes are purged:
```css
@source '../../vendor/a2zwebltd/laravel-feature-voting/resources/views/**/*.blade.php';
```
Published overrides under `resources/views/vendor/feature-voting` are covered by an existing `@source '../views'`.
### Blade and Livewire components
```blade
<x-feature-voting::board />
<x-feature-voting::request-card :request="$r" />
<x-feature-voting::admin-panel :request="$r" /> {{-- renders only for admins --}}
<livewire:feature-voting.board />
<livewire:feature-voting.request-show :request="$r" />
<livewire:feature-voting.vote-button :request="$r" />
```
### Optional `HasFeatureVoting` trait
`A2ZWeb\FeatureVoting\Traits\HasFeatureVoting` adds `featureRequests()`, `featureRequestVotes()` and `featureRequestComments()` (all `HasMany` on `user_id`) to the User model. The package works without it.
## Gotchas
- Re-publishing `feature-voting-migrations` duplicates the migration (new timestamp each time).
- Never write votes/comments with the models; counters drift and events don't fire.
- `admin_email` doubles as the new-request mail recipient; a Gate alone sends no admin mail.
- `updateStatus()` has no authorization inside; the controller and Livewire component check `changeStatus` before calling it.
- Keep `routes.name_prefix` at `feature-voting.`: the package controllers and views call `route('feature-voting.…')` by that literal name.
- `admin_middleware` only adds middleware; the policy still decides who is admin.
- Two migration tags: `feature-voting-migrations` (create tables, publish once ever) and `feature-voting-moderation-migration` (v1.2 column, idempotent). Upgrading hosts need only the second.
- With moderation on, a bare `FeatureRequest::query()` leaks pending requests; use `visibleTo($user)`/`published()`. The package pages, components and Livewire already do.
- With moderation on, even the author cannot vote on or comment on their own pending request; only admins can.
- Published view overrides from before v1.2 keep hard-coded English and have no moderation markers or Approve button; re-copy them.
- `x-dynamic-component` (used for `views.component`) caches its tag compiler in a static. Register component namespaces (`Blade::anonymousComponentPath(..., 'layouts')`) in a service provider's `boot()`, not mid-request or mid-test.
## Testing
Factories ship with the package: `FeatureRequest::factory()`, `FeatureRequestVote::factory()`, `FeatureRequestComment::factory()`.
```php
use A2ZWeb\FeatureVoting\Events\FeatureRequestCommented;
use A2ZWeb\FeatureVoting\Models\FeatureRequest;
use A2ZWeb\FeatureVoting\Services\FeatureVotingService;
use Illuminate\Support\Facades\Event;
it('counts votes through the service', function () {
$user = User::factory()->create();
$request = FeatureRequest::factory()->create();
expect(app(FeatureVotingService::class)->toggleVote($user, $request))->toBeTrue()
->and($request->fresh()->votes_count)->toBe(1);
});
it('notifies on comments', function () {
Event::fake([FeatureRequestCommented::class]);
// ... call FeatureVotingService::comment() or POST route('feature-voting.comment', $request)
Event::assertDispatched(FeatureRequestCommented::class);
});
```
For the admin path define the Gate in the test (`Gate::define('manage-feature-requests', fn ($u) => true)`) or set `config(['feature-voting.admin_email' => $admin->email])`. Use `Mail::fake()` to assert the package mailables (`NewFeatureRequestMail`, `FeatureRequestStatusChangedMail`, `FeatureRequestPublishedMail`; the last two carry `$mail->locale` from `HasLocalePreference`).
For moderation, `config(['feature-voting.moderation.enabled' => true])` and build requests with `FeatureRequest::factory()->pending()` or `->published()`. Without a state, the factory follows the config (pending when moderation is on).
manage-feature-requests Gate).published_at, publish()).composer require a2zwebltd/laravel-feature-votingphp artisan vendor:publish --tag=feature-voting-configphp artisan vendor:publish --tag=feature-voting-migrations, then php artisan migrate.
The stub is copied as database/migrations/<now>_create_feature_voting_tables.php with the current timestamp, so every re-publish (including vendor:publish --all or --provider) adds a second copy that fails with "table already exists". If the host already has a *_create_feature_voting_tables.php, never publish this tag again. The package does not loadMigrationsFrom().php artisan vendor:publish --tag=feature-voting-moderation-migration, then migrate. It adds published_at (only if missing) and backfills it from created_at, so existing requests stay public. Fresh installs already get the column from step 3; publishing this tag there too is harmless.AppServiceProvider::boot() and/or set FEATURE_VOTING_ADMIN_EMAIL (see below).views.layout/views.section or views.component).@source for the package views (see Recipes).FEATURE_VOTING_MODERATION=true) and host translations in lang/*.json (see Recipes).use A2ZWeb\FeatureVoting\Traits\HasFeatureVoting; on the User model.FeatureVotingService::isAdmin(Authenticatable $user): bool returns true only when a Gate named config('feature-voting.admin_gate') (default manage-feature-requests) is defined and allows the user. No Gate means nobody is admin (fail closed). admin_email never grants admin rights (it did before 1.3.0); don't base the Gate on an email match either, base it on a role or flag. The policy (FeatureRequestPolicy: update, delete, changeStatus, replyAsAdmin) delegates to isAdmin().
use App\Models\User;
use Illuminate\Support\Facades\Gate;
Gate::define('manage-feature-requests', fn (User $user): bool => $user->isAdmin());
admin_email is also the only recipient of the new-request email (NotifyAdminOfNewRequest). With a Gate but no admin_email, no admin email is sent.
config/feature-voting.php| Key | Default | Notes |
|---|---|---|
user_model |
null |
null resolves auth.providers.<web guard provider>.model |
admin_gate |
'manage-feature-requests' |
Gate name used by isAdmin() |
admin_email |
env('FEATURE_VOTING_ADMIN_EMAIL') |
fallback admin + new-request mail recipient |
routes.enabled |
env('FEATURE_VOTING_ROUTES', true) |
false = register your own routes |
routes.prefix |
'feature-requests' |
URL prefix |
routes.name_prefix |
'feature-voting.' |
route names: index, store, show, vote, comment, admin.update, admin.publish, admin.destroy |
routes.middleware |
['web', 'auth'] |
wraps every package route |
routes.admin_middleware |
[] |
added on top of middleware for admin.update, admin.publish and admin.destroy only (since v1.1) |
views.layout / views.section |
'feature-voting::layouts.default' / 'content' |
@extends layout + the section it @yields |
views.component |
null |
component layout name (since v1.1), e.g. layouts.app or a namespaced layouts::app; when set, layout/section are ignored |
moderation.enabled |
env('FEATURE_VOTING_MODERATION', false) |
since v1.2; new requests wait for admin approval |
livewire.register |
true |
registers Livewire components only if Livewire is installed |
tables.prefix |
'' |
prefix for the three tables (used by models and the migration) |
queue |
env('FEATURE_VOTING_QUEUE') |
queue name for the package mailables |
notifications.notify_admin_on_new_request |
true |
|
notifications.notify_submitter_on_status_change |
true |
|
notifications.notify_submitter_on_publish |
true |
since v1.2; mail on approval |
pagination.per_page |
15 |
The full pages feature-voting::index and feature-voting::show support two host layouts:
// Classic layout: resources/views/layouts/app.blade.php with @yield('content')
'views' => ['layout' => 'layouts.app', 'section' => 'content', 'component' => null],
// Component layout: <x-layouts.app> rendering {{ $slot }}
'views' => ['layout' => 'feature-voting::layouts.default', 'section' => 'content', 'component' => 'layouts.app'],
A component layout cannot be used through layout/section: @extends never fills $slot. Use component.
If the host has published index.blade.php/show.blade.php from v1.0, their first line still reads @extends(config('feature-voting.views.layout')), so views.component is ignored by them. Either copy the new first line from the package views, or set 'layout' => 'feature-voting::layouts.component' together with component.
FeatureVotingService (singleton, resolve via DI or app())submit(Authenticatable $user, string $title, string $description, array $metadata = []): FeatureRequest
toggleVote(Authenticatable $user, FeatureRequest $request): bool // true = added, false = removed
userHasVoted(Authenticatable $user, FeatureRequest $request): bool
comment(Authenticatable $user, FeatureRequest $request, string $body): FeatureRequestComment
updateStatus(Authenticatable $admin, FeatureRequest $request, FeatureRequestStatus $status, ?string $response = null): void
publish(FeatureRequest $request): FeatureRequest // since v1.2; approve a pending request
isAdmin(Authenticatable $user): bool
Always write through the service. votes_count and comments_count on feature_requests are denormalized counters that only the service maintains (toggleVote locks the vote row and increments/decrements, comment increments), and the events are dispatched only there. FeatureRequestVote::create() or FeatureRequestComment::create() from host code leaves the counters wrong and fires no events. comment() sets is_admin_response from isAdmin(). updateStatus() does not authorize; check $user->can('changeStatus', $request) first. It fires FeatureRequestStatusChanged only when the status actually changes. publish() does not authorize either (check can('publish', $request)); it sets published_at and fires FeatureRequestPublished only if the request was pending.
Status enum A2ZWeb\FeatureVoting\Enums\FeatureRequestStatus: UnderReview (under_review), Planned, InProgress, Completed, Declined; helpers label(), color() (Tailwind colour token), isOpen(), options().
Model scopes on FeatureRequest: top(), newest(), withStatus(FeatureRequestStatus|string), published(), visibleTo(?Authenticatable $user). The last two are no-ops while moderation is off. Helpers: isPublished() (always true with moderation off), isPendingApproval(), isVisibleTo($user), isOwnedBy($user), static moderationEnabled(). Policy abilities: view (= isVisibleTo), vote and comment (published, or admin), publish (admin). Route key is slug. FeatureRequest and FeatureRequestComment use soft deletes.
A2ZWeb\FeatureVoting\Events\*)| Event | Public properties |
|---|---|
FeatureRequestCreated |
FeatureRequest $featureRequest |
FeatureRequestVoted |
FeatureRequest $featureRequest, Authenticatable $user, bool $voted |
FeatureRequestCommented |
FeatureRequestComment $comment |
FeatureRequestStatusChanged |
FeatureRequest $featureRequest, FeatureRequestStatus $previousStatus, FeatureRequestStatus $currentStatus |
FeatureRequestPublished |
FeatureRequest $featureRequest (since v1.2) |
Built-in listeners: NotifyAdminOfNewRequest (Created), NotifySubmitterOfStatusChange (StatusChanged) and NotifySubmitterOfPublish (Published). Nothing listens to Voted or Commented: comment notifications are the host's job.
FEATURE_VOTING_MODERATION=true
[email protected]
feature_requests.published_at exists (install step 4). With moderation on and no column, submit() throws a LogicException naming the tag to publish.published_at = null. The author sees them marked "Waiting for approval — only you can see it"; other users get a 404 on the page, vote and comment routes. Admins see everything, with Approve on the card and in the admin panel.use A2ZWeb\FeatureVoting\Services\FeatureVotingService;
abort_unless($admin->can('publish', $featureRequest), 403);
app(FeatureVotingService::class)->publish($featureRequest); // emails the submitter
Rejecting is deleting: DELETE route('feature-voting.admin.destroy', $request) (admin panel button).
Any host query that lists requests (dashboards, widgets, sitemaps, API) must use FeatureRequest::query()->visibleTo($user) or ->published(). A bare FeatureRequest::query() includes pending requests.
No lang files ship. Keys are the English strings, so the host adds them to lang/<locale>.json:
{
"Feature Requests": "Funktionswünsche",
"Waiting for approval — only you can see it": "Wartet auf Freigabe – nur Sie sehen ihn",
"Under Review": "In Prüfung",
"Your feature request is now :status": "Ihr Wunsch hat jetzt den Status :status",
":count vote|:count votes": ":count Stimme|:count Stimmen",
":count comment|:count comments": ":count Kommentar|:count Kommentare"
}
Find every key with grep -rhoE "(__|trans_choice)\('[^']+'" vendor/a2zwebltd/laravel-feature-voting/{resources,src}. Status labels come from FeatureRequestStatus::label() (translated), mail subjects use :title/:status placeholders. Mails to submitters go to the user model, so a User implementing HasLocalePreference (e.g. returning $this->locale) gets them in their language. The new-request mail goes to the admin_email string in the app locale.
namespace App\Listeners;
use A2ZWeb\FeatureVoting\Events\FeatureRequestCommented;
use App\Mail\NewFeatureRequestCommentMail;
use Illuminate\Support\Facades\Mail;
class NotifyAdminOfNewComment
{
public function handle(FeatureRequestCommented $event): void
{
$adminEmail = config('feature-voting.admin_email');
if (! $adminEmail || $event->comment->is_admin_response) {
return;
}
$mail = new NewFeatureRequestCommentMail($event->comment);
if ($queue = config('feature-voting.queue')) {
$mail->onQueue($queue);
}
Mail::to($adminEmail)->send($mail);
}
}
Event discovery picks it up from app/Listeners; otherwise Event::listen(FeatureRequestCommented::class, NotifyAdminOfNewComment::class).
Package routes are registered in the provider's boot(). Put host routes in routes/web.php with the same prefix, the same middleware and a feature-voting. name, and do the authorization in the controller:
Route::middleware(['web', 'auth', 'verified'])
->delete('/feature-requests/{featureRequest:slug}/mine', FeatureRequestSelfDestroyController::class)
->name('feature-voting.self.destroy');
public function __invoke(Request $request, FeatureRequest $featureRequest): RedirectResponse
{
abort_unless(
$featureRequest->user_id === $request->user()->getAuthIdentifier()
&& $featureRequest->votes_count === 0
&& $featureRequest->status === FeatureRequestStatus::UnderReview,
403,
);
$featureRequest->delete();
return redirect()->route('feature-voting.index');
}
Use a distinct URI (here /mine): the package already owns DELETE {prefix}/{slug} (admin only).
'routes' => [
'middleware' => ['web', 'auth', 'verified'],
'admin_middleware' => ['can:manage-feature-requests'],
],
php artisan vendor:publish --tag=feature-voting-views copies everything to resources/views/vendor/feature-voting/; delete the files you don't change so package updates still reach them. Mail templates are in mail/.
The views use Tailwind utility classes (the default layout loads the Tailwind CDN). A host that compiles Tailwind v4 must scan the package views, or the classes are purged:
@source '../../vendor/a2zwebltd/laravel-feature-voting/resources/views/**/*.blade.php';
Published overrides under resources/views/vendor/feature-voting are covered by an existing @source '../views'.
<x-feature-voting::board />
<x-feature-voting::request-card :request="$r" />
<x-feature-voting::admin-panel :request="$r" /> {{-- renders only for admins --}}
<livewire:feature-voting.board />
<livewire:feature-voting.request-show :request="$r" />
<livewire:feature-voting.vote-button :request="$r" />
HasFeatureVoting traitA2ZWeb\FeatureVoting\Traits\HasFeatureVoting adds featureRequests(), featureRequestVotes() and featureRequestComments() (all HasMany on user_id) to the User model. The package works without it.
feature-voting-migrations duplicates the migration (new timestamp each time).admin_email doubles as the new-request mail recipient; a Gate alone sends no admin mail.updateStatus() has no authorization inside; the controller and Livewire component check changeStatus before calling it.routes.name_prefix at feature-voting.: the package controllers and views call route('feature-voting.…') by that literal name.admin_middleware only adds middleware; the policy still decides who is admin.feature-voting-migrations (create tables, publish once ever) and feature-voting-moderation-migration (v1.2 column, idempotent). Upgrading hosts need only the second.FeatureRequest::query() leaks pending requests; use visibleTo($user)/published(). The package pages, components and Livewire already do.x-dynamic-component (used for views.component) caches its tag compiler in a static. Register component namespaces (Blade::anonymousComponentPath(..., 'layouts')) in a service provider's boot(), not mid-request or mid-test.Factories ship with the package: FeatureRequest::factory(), FeatureRequestVote::factory(), FeatureRequestComment::factory().
use A2ZWeb\FeatureVoting\Events\FeatureRequestCommented;
use A2ZWeb\FeatureVoting\Models\FeatureRequest;
use A2ZWeb\FeatureVoting\Services\FeatureVotingService;
use Illuminate\Support\Facades\Event;
it('counts votes through the service', function () {
$user = User::factory()->create();
$request = FeatureRequest::factory()->create();
expect(app(FeatureVotingService::class)->toggleVote($user, $request))->toBeTrue()
->and($request->fresh()->votes_count)->toBe(1);
});
it('notifies on comments', function () {
Event::fake([FeatureRequestCommented::class]);
// ... call FeatureVotingService::comment() or POST route('feature-voting.comment', $request)
Event::assertDispatched(FeatureRequestCommented::class);
});
For the admin path define the Gate in the test (Gate::define('manage-feature-requests', fn ($u) => true)) or set config(['feature-voting.admin_email' => $admin->email]). Use Mail::fake() to assert the package mailables (NewFeatureRequestMail, FeatureRequestStatusChangedMail, FeatureRequestPublishedMail; the last two carry $mail->locale from HasLocalePreference).
For moderation, config(['feature-voting.moderation.enabled' => true]) and build requests with FeatureRequest::factory()->pending() or ->published(). Without a state, the factory follows the config (pending when moderation is on).
How can I help you explore Laravel packages today?