fewchurs / laravel
Drop a Fewchurs feature-request board into any Laravel app.
Requires
- php: ^8.2
- guzzlehttp/guzzle: ^7.5|^8.0
- illuminate/contracts: ^10.0|^11.0|^12.0|^13.0
- illuminate/http: ^10.0|^11.0|^12.0|^13.0
- illuminate/support: ^10.0|^11.0|^12.0|^13.0
- illuminate/view: ^10.0|^11.0|^12.0|^13.0
Requires (Dev)
- larastan/larastan: ^2.9|^3.0
- laravel/pint: ^1.18
- livewire/livewire: ^3.0|^4.0
- orchestra/testbench: ^8.0|^9.0|^10.0|^11.0
- pestphp/pest: ^2.34|^3.0|^4.0
- pestphp/pest-plugin-laravel: ^2.0|^3.0|^4.0
- phpstan/phpstan: ^1.12|^2.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A feature-request board in any Laravel app: install, add one env var, drop one Blade tag. Server-rendered by default, reactive with Livewire, and a plain PHP client for fully custom UIs.
composer require fewchurs/laravel php artisan fewchurs:install
FEWCHURS_API_KEY=fr_live_…
<x-fewchurs::board />
That's it — the board renders, votes and comments work with JavaScript disabled, and your API key never reaches the browser.
Add your domain to your app in the Fewchurs dashboard. The API key only works there.
The package sends the domain each page was opened on (the request's host) as the app ID, so
APP_URL doesn't matter. Artisan commands have no visitor, so they use the APP_URL host. To
always send one fixed ID instead, set FEWCHURS_APP_ID.
localhost always works, so you can test with just the key. While you open the board on
localhost with APP_DEBUG=true, it shows a small reminder to add your domain before going live.
Visitors never see it.
What you get
<x-fewchurs::board /> |
The board, anywhere in your own layout. |
<x-fewchurs::sheet /> |
A floating button that slides the board in from the side or bottom. |
/feedback |
A ready-made page, rendered inside your app's layout. |
<livewire:fewchurs-board /> |
The same board, reactive, if you use Livewire. |
Fewchurs::features() |
The PHP client, for a UI you build yourself. |
php artisan fewchurs:check |
Answers "is my key right?" in one command. |
Requirements
PHP 8.2+, Laravel 10, 11, 12 or 13. No dependencies beyond Laravel and Guzzle (which Laravel already ships with), and no build step — the board ships its own scoped CSS, so it does not assume Tailwind.
As a sheet
Put the board behind a floating button instead of on the page. Add it once, for example to your main layout, and it shows on every page:
<x-fewchurs::sheet /> {{-- slides in from the right --}} <x-fewchurs::sheet side="left" /> <x-fewchurs::sheet side="bottom" />
<x-fewchurs::sheet label="Suggest a feature" /> <x-fewchurs::sheet>💡 Ideas?</x-fewchurs::sheet>
Use your own button instead of the floating one: hide it with :trigger="false" and add
data-fewchurs-open to any element.
<x-fewchurs::sheet :trigger="false" /> <button data-fewchurs-open>Feedback</button>
The board (in the sheet and on the page) opens on the list of requests. A "New request" button next to the Top and New tabs switches it to the form, and "Cancel" returns to the list. If submissions are off, the button doesn't show. Comments and Notify me open in a popup that closes with the × button, Escape or a click outside.
The sheet loads the board from /feedback?embed=1 in a frame, and only the first time it opens,
so pages where nobody opens it make no API calls. It needs the /feedback route (so
route.prefix must not be null), takes app and tab like the board, and uses the colours
from config('fewchurs.theme'). Without JavaScript the button is a link to the full page.
On phones it fills the screen. While the board loads, the sheet shows a
shimmer shaped like the board, and if the page inside fails it shows a message with
"Try again" and a link to the full page. If your app sends X-Frame-Options: DENY or a CSP
frame-ancestors 'none', allow your own site to frame /feedback.
Loading and errors
When the API can't be reached (or rejects the key) and there is no recent copy to show, the board shows a title, a message and a "Try again" button inside the board, under the tabs and the New request button, and the rest of your page keeps working. The failure itself is still logged.
| Failure | Title | APP_DEBUG=false |
APP_DEBUG=true |
|---|---|---|---|
| Wrong key | Invalid API key | Copy the key from your Fewchurs dashboard (Apps → API keys) and set FEWCHURS_API_KEY in your .env file. |
The same |
| Wrong app ID | Invalid API key | This API key is not set up for other.com. Add it to your app in the Fewchurs dashboard. |
The same |
| No connection | Couldn't load requests | Couldn't reach the feedback service. |
Could not reach the Fewchurs API. Check this server's internet connection and FEWCHURS_BASE_URL. [connection] |
| Anything else | Couldn't load requests | Something went wrong. Try again. |
The server's message, its code and the HTTP status |
Except for the wrong app ID, which comes from the server and names the ID, the APP_DEBUG=false
messages and the titles come from the errors and error_titles groups in the lang file, so you can reword or translate them.
Keep APP_DEBUG off in production, as Laravel already asks.
The plain board arrives with your page, already loaded, so it has no loading state. For a
shimmer, use the sheet, or Livewire with lazy, which also shimmers while switching tabs:
<livewire:fewchurs-board lazy />
Using the client
use Fewchurs\Laravel\Facades\Fewchurs; $page = Fewchurs::features()->top(limit: 20); $page = Fewchurs::features()->latest(limit: 20, cursor: $page->nextCursor); $feature = Fewchurs::features()->submit( title: 'Add dark mode', description: 'Would love a dark theme.', email: auth()->user()?->email, // the server follows this email for you ); Fewchurs::features()->vote($feature->id); Fewchurs::features()->unvote($feature->id); Fewchurs::features()->follow($feature->id, email: 'a@b.com'); Fewchurs::comments()->list($feature->id, limit: 50); Fewchurs::comments()->create($feature->id, text: '+1', authorName: 'Sam'); Fewchurs::config()->showBranding;
Every call throws a typed exception on failure — RateLimitedException, NotFoundException,
InvalidApiKeyException, ConnectionException and friends, each with a
userMessage() that is safe to show a visitor while APP_DEBUG is off.
A key used from an app ID it is not set up for gets a wrong_app error from the server. This
throws InvalidApiKeyException, is never retried, and getMessage() names the ID to add.
Identity
The server needs an X-Device-Id per visitor. The package resolves one for you:
'identity' => ['mode' => env('FEWCHURS_IDENTITY', 'hybrid')], // cookie | user | hybrid
- cookie — a random value in a signed,
httpOnly,SameSite=Laxcookie. - user / hybrid — the signed-in user's id, so votes follow them across browsers and devices; guests fall back to the cookie.
Everything is HMAC-hashed with your APP_KEY before it leaves your server — the raw cookie,
the user id and the email never reach Fewchurs.
Multi-tenant or team-level identity:
Fewchurs::resolveDeviceIdUsing(fn (Request $request) => hash('sha256', $request->user()->team_id));
Always hash. Passing an email or a raw id throws.
Privacy policy note: the cookie is functional — it records which requests this visitor voted on — so most consent setups class it as necessary rather than tracking. It is still a first-party identifier and belongs in your privacy policy. Sample wording:
We set a
fewchurs_didcookie so we can remember which feature requests you have voted on or followed. It contains a random identifier, not your name or email, and expires after a year.
Customising
1. Config — config/fewchurs.php covers the route prefix, tabs, per-page count, whether
submissions, comments and follows are open, whether the email field is hidden, optional or
required, and whether writes require a signed-in user.
2. Theme tokens
<x-fewchurs::board :theme="['primary' => '#0F766E', 'radius' => '4px']" />
Height. The board fills the height of the element it sits in and never grows taller than
the screen. The heading, the tabs and New request stay in place, and only the list scrolls.
Give its parent a height to size it, for example <div style="height: 600px">. Change the
limit with max_height, or use none to let the board grow with its list as before:
<x-fewchurs::board :theme="['max_height' => '80vh']" />
3. Publish the views and the strings
php artisan vendor:publish --tag=fewchurs-views php artisan vendor:publish --tag=fewchurs-lang
The views are small, single-purpose Blade files meant to be edited. Translations are ordinary
Laravel lang files, so lang/vendor/fewchurs/fr/messages.php just works.
4. Slots
<x-fewchurs::board> <x-slot:header> <h1>Help shape {{ config('app.name') }}</h1> </x-slot:header> <x-slot:empty> <x-my-empty-illustration /> </x-slot:empty> </x-fewchurs::board>
5. Headless — set route.prefix to null, use the client, and build whatever you like.
More than one board
'apps' => ['docs' => ['api_key' => env('FEWCHURS_DOCS_KEY')]],
A board sends the main app_id unless you give it its own app_id in this array.
<x-fewchurs::board app="docs" />
Fewchurs::app('docs')->features()->top();
Events
Event::listen(FeatureSubmitted::class, fn ($e) => Slack::send("New request: {$e->feature->title}"));
FeatureSubmitted, Voted, Unvoted, Commented, Followed, Unfollowed, RequestFailed.
Testing your app
$fake = Fewchurs::fake([['id' => 'feat1', 'title' => 'Add dark mode']]); // … exercise your app … $fake->assertSubmitted(fn ($feature) => $feature->title === 'Add dark mode'); $fake->assertVoted('feat1'); $fake->assertNothingSubmitted();
The fake sits behind the real client, so your tests exercise the same headers, validation and error mapping as production.
Security
The key is a real secret here, and the package keeps it that way.
- The key never reaches the browser. All calls are server-to-server; the browser talks only to your app's own routes. A test asserts the rendered HTML and JSON never contain it, and CI fails if a key-shaped string is committed anywhere in the repo.
- It never reaches your logs either. Every log line and exception message is scrubbed of
bearer tokens and
fr_…keys before it is written. - CSRF on every write route (they run in the
webgroup). - Throttling per visitor, not per IP — a whole site shares one server IP, so writes are
limited by the resolved device id (
route.throttle, 30/min by default). - Honeypot plus a signed minimum-time check on the submit and comment forms, so basic bots are dropped without a CAPTCHA. The timestamp is encrypted, so it cannot be forged or replayed.
- Output is escaped. Titles, descriptions and comments are written by strangers. The views
use
{{ }}throughout and say so in a comment — if you publish them, keep it that way. - Ids are validated before they are interpolated into an API path, so a crafted id cannot reach a different endpoint.
- Redirects are built from the package's own route, never from the
Refererheader. - Device ids are stripped from everything handed back to the page; features and comments
carry
isMineinstead. - Theme values are allowlisted before being inlined into a
styleattribute. - HTTPS is required for the API base URL outside local development, with certificate verification on.
Set board.require_auth to true if the board should only accept writes from signed-in users.
Commands
php artisan fewchurs:install # publish config, print next steps php artisan fewchurs:check # verify the key, connectivity and plan php artisan fewchurs:warm # pre-warm the config cache after a deploy
Caching
/config is cached app-wide for 5 minutes (cache.config_ttl), so plan changes and the
"Hide vote counts" switch show up quickly. Feature pages are cached per visitor for 15 seconds,
because hasVoted and isFollowing are per visitor and a shared entry would hand one
visitor's vote state to everyone. A longer-lived stale copy is kept so an unreachable API
degrades into "showing recent data" instead of an error page.
License
Proprietary. You may use the package in your own apps to connect them to Fewchurs, and edit the config, views and translations you publish. The source is public for transparency, not open source. See LICENSE.