laravel-feature-requests maintained by webmintydotcom
laravel-feature-requests
A Laravel package that adds a Canny/FeatureBase-style feature request portal to your app. Backend only — it exposes a JSON API and you build whatever UI you like (Blade, Inertia, Livewire, a separate SPA).
What you get
Entities
- Posts (title, body, tags, attachments)
- Upvotes (one per user)
- Flat comments with attachments
- Statuses — admin-editable, DB-driven, one marked default
- Tags — admin-editable
- Per-post activity log
Behavior
- Lock a post → it refuses new votes and comments
- Pin posts to the top of listings
- Soft-deletes throughout
- Rate-limited submissions, votes, and uploads
- Stream files through an auth'd route or expose public URLs — your choice per disk
- Events fired after every state change so you can wire your own notifications
- Bodies are stored as plain text; bind a renderer (Markdown, CommonMark, etc.) to produce HTML on the fly
- Polymorphic authors — any User model works
Not included. No UI. No notifications. No emails. Those are yours to wire.
Requirements
- PHP 8.2+
- Laravel 11, 12, or 13
Installation
composer require webmintydotcom/laravel-feature-requests
# Publishes config, migrations, seeder, and translations.
php artisan feature-requests:install
php artisan migrate
php artisan db:seed --class=FeatureRequestStatusSeeder
Add the seeder to database/seeders/DatabaseSeeder.php if you want it to run on fresh installs.
Define the three gates the package consults — auth logic is yours:
// AppServiceProvider::boot()
use Illuminate\Support\Facades\Gate;
Gate::define('featureRequests.moderate', fn ($user) => $user->isAdmin());
Gate::define('featureRequests.manageStatuses', fn ($user) => $user->isAdmin());
Gate::define('featureRequests.manageTags', fn ($user) => $user->isAdmin());
That's it — GET /feature-requests/posts is now live.
If your setup is non-standard
Custom User model namespace. The default config points at \App\Models\User. If yours lives somewhere else, set this in .env before any config:cache:
FEATURE_REQUESTS_USER_MODEL=Domain\\Users\\User
Or publish the config and edit it directly.
Session-cookie auth (Sanctum SPA, Inertia, Breeze, Jetstream). Default route middleware is ['api', 'auth']. Override it in the published config:
'routes' => [
'middleware' => ['web', 'auth'],
'admin_middleware' => ['web', 'auth'],
],
Morph map (recommended). The package stores fully-qualified class names in polymorphic *_type columns. To keep that data intact through future User-model renames, register a morph map:
// AppServiceProvider::boot()
use Illuminate\Database\Eloquent\Relations\Relation;
Relation::enforceMorphMap([
'user' => \App\Models\User::class,
]);
Configuration
After install, edit config/feature-requests.php. The keys most teams touch:
| Key | Purpose |
|---|---|
routes.prefix |
URL prefix, default feature-requests |
routes.middleware / routes.admin_middleware |
Middleware stacks for the two route groups |
pagination.per_page |
Default page size, default 25 |
attachments.disk |
Laravel filesystem disk |
attachments.serve_via |
'stream' (package serves via auth'd route) or 'public_url' (returns Storage::url()) |
attachments.max_size_kb |
Upload size cap, default 5120 (5 MB) |
attachments.mime_whitelist |
e.g. ['png', 'jpg', 'pdf'] to restrict, null to allow any |
rate_limits.* |
Attempts/decay per endpoint |
The config file contains no closures, so php artisan config:cache is safe.
API surface
All routes are JSON. Default prefix is /feature-requests. List/detail endpoints return Laravel paginator JSON (data, links, meta).
Public (auth required)
GET /posts newest first
GET /posts/{post} single post with relations
POST /posts create (throttled)
PATCH /posts/{post} edit (author or moderator)
DELETE /posts/{post} soft-delete (author or moderator)
GET /posts/{post}/comments oldest first
POST /posts/{post}/comments create (throttled)
PATCH /comments/{comment} edit (author or moderator)
DELETE /comments/{comment} soft-delete (author or moderator)
POST /posts/{post}/votes cast (idempotent, throttled)
DELETE /posts/{post}/votes retract
POST /posts/{post}/attachments upload (throttled)
POST /comments/{comment}/attachments upload (throttled)
DELETE /attachments/{attachment} remove (uploader or moderator)
GET /attachments/{attachment} download (stream mode only)
GET /tags full list
GET /statuses ordered list
GET /posts/{post}/activity paginated activity log
Admin (gate-protected)
PATCH /admin/posts/{post}/status can:featureRequests.moderate
POST /admin/posts/{post}/pin
DELETE /admin/posts/{post}/pin
POST /admin/posts/{post}/lock
DELETE /admin/posts/{post}/lock
PATCH /admin/posts/{post}/tags
POST /admin/statuses can:featureRequests.manageStatuses
PATCH /admin/statuses/reorder
PATCH /admin/statuses/{status}
DELETE /admin/statuses/{status}
POST /admin/tags can:featureRequests.manageTags
PATCH /admin/tags/{tag}
DELETE /admin/tags/{tag}
Customizing IDs (hashids, sqids, etc.)
By default, the package exposes integer primary keys in URLs and API responses ("id": 42, /posts/42). To swap in hashids, sqids, or any other ID scheme, implement IdCodec and bind it in your service provider:
namespace App\Support;
use Webminty\FeatureRequests\Contracts\IdCodec;
final class SqidsIdCodec implements IdCodec
{
public function __construct(private readonly \Sqids\Sqids $sqids) {}
public function encode(int $id): string
{
return $this->sqids->encode([$id]);
}
public function decode(string $value): ?int
{
$decoded = $this->sqids->decode($value);
return count($decoded) === 1 ? $decoded[0] : null;
}
}
// AppServiceProvider::register()
$this->app->singleton(\Webminty\FeatureRequests\Contracts\IdCodec::class, function () {
return new \App\Support\SqidsIdCodec(new \Sqids\Sqids(minLength: 8));
});
After that, /feature-requests/posts/42 becomes /feature-requests/posts/Xy3kQa9p and the same encoded form appears as "id" in JSON responses. The package handles encoding on the way out and decoding (with automatic 404 on invalid input) on the way in. Database PKs stay as integers — encoding is API-layer only.
The same hook is used for every route binding: frPost, frComment, frAttachment, frStatus, frTag. (Route params are prefixed fr to avoid colliding with any {post} / {comment} / etc. bindings your host app may already define. The public URL paths are unchanged — /feature-requests/posts/42 still works.) If you generate URLs with the route() helper, use the prefixed names:
route('feature-requests.posts.show', ['frPost' => $post->id]);
route('feature-requests.attachments.show', ['frAttachment' => $attachment->id]);
Customizing the author payload
The author / voter / uploader shape in API responses is produced by AuthorPayloadResolver. Default: ['id' => key, 'name' => $author->name]. To add fields (avatar URL, hashed handle, anything), bind your own:
namespace App\Support;
use Illuminate\Database\Eloquent\Model;
use Webminty\FeatureRequests\Contracts\AuthorPayloadResolver;
final class AvatarAuthorPayloadResolver implements AuthorPayloadResolver
{
public function resolve(?Model $author): ?array
{
if ($author === null) {
return null;
}
return [
'id' => $author->getKey(),
'name' => $author->name,
'avatar' => $author->avatar_url,
];
}
}
// AppServiceProvider::register()
$this->app->singleton(
\Webminty\FeatureRequests\Contracts\AuthorPayloadResolver::class,
\App\Support\AvatarAuthorPayloadResolver::class,
);
Rendering post bodies
Bodies are stored as raw text. To turn them into HTML for the body_html field in API responses, bind your renderer to the BodyRenderer contract:
use Webminty\FeatureRequests\Contracts\BodyRenderer;
$this->app->bind(BodyRenderer::class, MyMarkdownRenderer::class);
Your renderer implements render(string $body): string and must return sanitized HTML. The default PlainTextRenderer escapes HTML and converts newlines to <br />.
Events
Dispatched after the DB transaction commits — listen to any of these without touching package internals:
| Event | Payload |
|---|---|
PostCreated, PostUpdated, PostDeleted |
Post |
PostStatusChanged |
Post, Status $from, Status $to |
PostPinned, PostUnpinned, PostLocked, PostUnlocked |
Post |
VoteCast |
Vote |
VoteRetracted |
Post, Authenticatable $voter |
CommentCreated, CommentUpdated, CommentDeleted |
Comment |
AttachmentAdded, AttachmentRemoved |
Attachment |
Example listener wiring:
// EventServiceProvider
protected $listen = [
\Webminty\FeatureRequests\Events\PostStatusChanged::class => [
\App\Listeners\NotifyVotersOfStatusChange::class,
],
];
Translations
User-facing strings (authorization errors and the two custom-exception responses) are translated through the feature-requests::messages namespace. To localize:
php artisan vendor:publish --tag=feature-requests-translations
Files land in lang/vendor/feature-requests/{locale}/messages.php. Add new locale folders and Laravel resolves them based on app()->getLocale().
Internal RuntimeException messages (misconfiguration, storage failures, race conditions) are intentionally not translated — they exist for developers and logs, not end users.
Client examples
Submit a post:
await fetch('/feature-requests/posts', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
Accept: 'application/json',
},
body: JSON.stringify({ title: 'Dark mode', body: 'Please add dark mode.' }),
});
Vote / unvote:
await fetch(`/feature-requests/posts/${postId}/votes`, { method: 'POST', credentials: 'include' });
await fetch(`/feature-requests/posts/${postId}/votes`, { method: 'DELETE', credentials: 'include' });
Troubleshooting
- 403 on every admin route — the package registers
falsedefaults for its three gates. Define them inAppServiceProvider::boot()(see Installation). - 404 on
GET /attachments/{id}—attachments.serve_viais set to'public_url'. Either switch it to'stream'or use theurlfield returned in the API response. Class "App\Models\User" not foundat boot — setFEATURE_REQUESTS_USER_MODELin.env(see "If your setup is non-standard").- Reorder request rejected — the
orderarray onPATCH /admin/statuses/reordermust list every status ID exactly once; partial reorders aren't supported.
License
MIT
Built fresh by Webminty.