PHPackages                             webmintydotcom/laravel-feature-requests - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [API Development](/categories/api)
4. /
5. webmintydotcom/laravel-feature-requests

ActiveLibrary[API Development](/categories/api)

webmintydotcom/laravel-feature-requests
=======================================

A headless Laravel package providing a Canny/FeatureBase-style feature request portal: posts, votes, comments, tags, statuses, attachments, and an activity log. JSON API only — consumers ship their own UI.

0.0.2(3w ago)024MITPHPPHP ^8.2

Since May 14Pushed 3w agoCompare

[ Source](https://github.com/webmintydotcom/laravel-feature-requests)[ Packagist](https://packagist.org/packages/webmintydotcom/laravel-feature-requests)[ RSS](/packages/webmintydotcom-laravel-feature-requests/feed)WikiDiscussions main Synced 1w ago

READMEChangelog (2)Dependencies (11)Versions (3)Used By (0)

laravel-feature-requests
========================

[](#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
------------

[](#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
------------

[](#requirements)

- PHP 8.2+
- Laravel 11, 12, or 13

Installation
------------

[](#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

[](#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
-------------

[](#configuration)

After install, edit `config/feature-requests.php`. The keys most teams touch:

KeyPurpose`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 endpointThe config file contains no closures, so `php artisan config:cache` is safe.

API surface
-----------

[](#api-surface)

All routes are JSON. Default prefix is `/feature-requests`. List/detail endpoints return Laravel paginator JSON (`data`, `links`, `meta`).

### Public (auth required)

[](#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)

[](#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.)
--------------------------------------

[](#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
------------------------------

[](#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
---------------------

[](#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 ``.

Events
------

[](#events)

Dispatched after the DB transaction commits — listen to any of these without touching package internals:

EventPayload`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
------------

[](#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
---------------

[](#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
---------------

[](#troubleshooting)

- **403 on every admin route** — the package registers `false` defaults for its three gates. Define them in `AppServiceProvider::boot()` (see Installation).
- **404 on `GET /attachments/{id}`** — `attachments.serve_via` is set to `'public_url'`. Either switch it to `'stream'` or use the `url` field returned in the API response.
- **`Class "App\Models\User" not found` at boot** — set `FEATURE_REQUESTS_USER_MODEL` in `.env` (see "If your setup is non-standard").
- **Reorder request rejected** — the `order` array on `PATCH /admin/statuses/reorder` must list every status ID exactly once; partial reorders aren't supported.

License
-------

[](#license)

MIT

Built fresh by [Webminty](https://webminty.com).

###  Health Score

39

—

LowBetter than 84% of packages

Maintenance94

Actively maintained with recent releases

Popularity10

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity37

Early-stage or recently created project

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~0 days

Total

2

Last Release

26d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/6df96861ef914619e7d981dda936319ef29c8fcdf2f47c4a5c2e8f6ec25e3ff0?d=identicon)[webmintydotcom](/maintainers/webmintydotcom)

---

Top Contributors

[![webmintydotcom](https://avatars.githubusercontent.com/u/176715447?v=4)](https://github.com/webmintydotcom "webmintydotcom (3 commits)")

---

Tags

laravelfeedbackvotingfeature-requestsroadmapcannyfeaturebase

###  Code Quality

TestsPest

### Embed Badge

![Health badge](/badges/webmintydotcom-laravel-feature-requests/health.svg)

```
[![Health](https://phpackages.com/badges/webmintydotcom-laravel-feature-requests/health.svg)](https://phpackages.com/packages/webmintydotcom-laravel-feature-requests)
```

###  Alternatives

[roots/acorn

Framework for Roots WordPress projects built with Laravel components.

9732.3M121](/packages/roots-acorn)[psalm/plugin-laravel

Psalm plugin for Laravel

3325.1M337](/packages/psalm-plugin-laravel)[laravel/mcp

Rapidly build MCP servers for your Laravel applications.

76318.2M110](/packages/laravel-mcp)[laravel/pulse

Laravel Pulse is a real-time application performance monitoring tool and dashboard for your Laravel application.

1.7k14.1M120](/packages/laravel-pulse)[laravel/ai

The official AI SDK for Laravel.

9782.1M153](/packages/laravel-ai)[aedart/athenaeum

Athenaeum is a mono repository; a collection of various PHP packages

245.2k](/packages/aedart-athenaeum)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
