Install, configure, and apply the Visual Feedback for Laravel package in a Laravel application.
php artisan boost:add-skill pushery/visual-feedback-for-laravel
Save this content to: .claude/skills/visual-feedback-for-laravel/SKILL.md
# Visual Feedback for Laravel
Use this skill when a Laravel application installs or integrates the
`pushery/visual-feedback-for-laravel` package. The full reference is at
<https://docs.pushery.com/visual-feedback-for-laravel/>.
## Primary Goal
Apply the package's public API in the smallest correct way for the consuming
application.
## Workflow
### 1. Install
```bash
composer require pushery/visual-feedback-for-laravel
```
The service provider is registered automatically through package discovery.
### 2. Configure
Publish the ordinary set, or only the configuration file:
```bash
php artisan vendor:publish --tag="visual-feedback"
php artisan vendor:publish --tag="visual-feedback-config"
```
The first is **not** everything, and reading it that way is the mistake this line exists to
prevent. It covers the config, the translations, the views and the bundles, and each of those
has a tag of its own: `visual-feedback-config`, `visual-feedback-lang`, `visual-feedback-views`
and `visual-feedback-assets`. Two tags stand outside it on purpose, because each is a decision
rather than a default:
`visual-feedback-migrations` (the reports table — see the `database` channel below) and
`visual-feedback-wirekit` (the WireKit view tree, which is selected by configuration and does
not need publishing at all).
Every option in `config/visual-feedback.php` is documented inline.
Re-run `php artisan vendor:publish --tag="visual-feedback-assets" --force` after each
package update. The bundles under `public/vendor/visual-feedback` are copies, and
a stale copy is the one failure this setup produces on its own.
There are **four** of them, and they fail differently.
`visual-feedback-widget.iife.js` is under 12 KB, always ships, and registers the Alpine
components the templates bind to — so a page missing that one renders a widget whose
every control does nothing, with only the browser console to show for it.
`visual-feedback.iife.js` is under 40 KB and ships while `screenshot.strategy` is not `off`.
It is the capture state machine, not the renderer.
`visual-feedback-renderer.iife.js` is ~276 KB and is the DOM renderer. Nothing loads it
until a screenshot is actually taken, and the URL it is fetched from is derived from the
script that loads it — so it has to sit beside `visual-feedback.iife.js` under the same
base. `vendor:publish` puts all four there for you. If you serve the bundles from your
own origin or a CDN instead, upload this one too: without it the capture button gets all
the way to taking the picture and then reports a failure. A Content-Security-Policy needs
to allow it under the same `script-src` source as the other two.
`visual-feedback-editor.iife.js` is the screenshot editor, under 32 KB, fetched the same way
the first time a reporter presses Edit in the preview to crop or mark a capture. It sits beside the
capture bundle too. Missing, the Edit button says the editor could not be loaded and the
capture can still be attached as it is. `screenshot.editor = false`
(`VISUAL_FEEDBACK_SCREENSHOT_EDITOR`) takes the button away, and the file is never fetched.
An edited screenshot arrives with `screenshot_edited: true` in the report's technical
details.
`php artisan about` says whether that was needed: under **Visual Feedback** it carries a
**Published bundle** line reading `up to date`, `OUT OF DATE` with the command to run, or
`not published`. With `APP_DEBUG=true` a stale copy also writes a warning to the log.
### 3. Apply the package
Three things go in the layout: the stylesheet, the widget, the script tag.
```blade
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
@include('visual-feedback::style')
@livewireStyles
</head>
<body>
<main>{{ $slot }}</main>
@livewire('visual-feedback.report-widget')
@livewireScripts
<x-visual-feedback::scripts />
</body>
```
That puts the widget on the page: a floating button appears and opens a modal. It does not
yet deliver anything.
**Give the mail channel a recipient.** It is on by default and ships without an address,
because none could be guessed. Until one is set the channel reports itself unavailable and is
skipped, and the reporter is told the submission did not go through rather than thanked for it.
The reason goes to the log, never to them: `an enabled channel reported itself unavailable and
was skipped`, then `a report was accepted but no channel was enabled and available to deliver
it`.
```dotenv
[email protected]
```
**And check `MAIL_MAILER` while you are there.** `log` and `array` let `Mailer::send()`
succeed with the message going nowhere, and Laravel's own default is `env('MAIL_MAILER', 'log')`
— so an application that never set it is in that state. The mail channel refuses those (and a
`failover` chain that can fall through to one) instead of reporting a delivery that did not
happen. `VISUAL_FEEDBACK_MAIL_REQUIRE_DELIVERABLE_TRANSPORT=false` allows it on purpose; the
check is never applied while the application runs its tests. An application whose own mail
stays on `array` on purpose, in a demo tenant for instance, names a real mailer for reports
alone with `VISUAL_FEEDBACK_MAIL_MAILER`; the check then reads that one.
The `log` transport writes the whole message — free text, the reporter's address, the
screenshot as base64 — into the log in plaintext, and from there into every error tracker the
log stack feeds.
**Deciding what the form asks for.** Every field answers one question with one word —
`off`, `optional` or `required` — and each has an environment variable:
```dotenv
VISUAL_FEEDBACK_FIELD_SUBJECT_MODE=optional # off | optional | required
VISUAL_FEEDBACK_FIELD_NAME_MODE=optional
VISUAL_FEEDBACK_FIELD_EMAIL_MODE=optional
VISUAL_FEEDBACK_FIELD_PHONE_MODE=off
```
A value that is none of the three words shows the field as `optional`, so a typo never removes an
input; `true` and `false` read as `optional` and `off`.
`message` has no mode: a feedback form without a message is not a feedback form. Name, email and
phone are asked of GUESTS only — an authenticated reporter's identity comes from the auth guard,
so those three are neither shown to them nor revalidated.
A field set to `off` is dropped, not merely hidden: a value for it does not reach the report even
if one was posted. A field set to `required` is marked required in the markup and a submission
without it is refused, in this package's own wording and in all seven shipped locales.
One page can differ from the rest without touching the environment:
```blade
<livewire:visual-feedback.report-widget :fields="['email' => 'required', 'subject' => 'off']" />
```
**When the reporter leaves the subject empty** — the normal case, since it is optional — the mail
subject becomes `Category - the first words of the message`. `VISUAL_FEEDBACK_MAIL_SUBJECT_EXCERPT`
sets how much of the message goes in (default 60 characters); `0` gives the bare category.
**Naming the sender.** `VISUAL_FEEDBACK_MAIL_FROM_NAME` defaults to `APP_NAME`, so reports arrive
from the product rather than from a bare address. `VISUAL_FEEDBACK_MAIL_FROM_ADDRESS` overrides
the envelope sender.
**The rest of the surface.** Every variable is listed with its default at
`https://docs.pushery.com/visual-feedback-for-laravel/configuration`, together with a block you
can paste into `.env` whole. The ones a consuming application reaches for most:
| Variable | Default | Effect |
|---|---|---|
| `VISUAL_FEEDBACK_ENABLED` | `true` | master switch; off refuses every submission before it touches a disk |
| `VISUAL_FEEDBACK_UI_VARIANT` | `auto` | `auto`, `plain` or `wirekit` |
| `VISUAL_FEEDBACK_UI_TRIGGER` | `fab` | `fab`, `inline` or `none` when you place the trigger yourself |
| `VISUAL_FEEDBACK_UI_POSITION` | `bottom-end` | which corner the floating button sits in, read in the writing direction |
| `VISUAL_FEEDBACK_UI_DARK_MODE` | `media` | what turns the plain tree dark: `media` follows the operating system, `class` the application's `VISUAL_FEEDBACK_UI_DARK_SELECTOR`, `.dark` by default |
| `VISUAL_FEEDBACK_SCREENSHOT_STRATEGY` | `auto` | `auto`, `native`, `dom` or `off` |
| `VISUAL_FEEDBACK_ATTACHMENTS_DISK` | `local` | keep it private: screenshots carry whatever was on screen |
| `VISUAL_FEEDBACK_CHANNEL_DATABASE` | `false` | also store reports in the optional table |
| `VISUAL_FEEDBACK_CHANNEL_WEBHOOK` | `false` | also POST them to `VISUAL_FEEDBACK_WEBHOOK_URL` |
| `VISUAL_FEEDBACK_RETENTION_DAYS` | none | how long reports are kept, once you schedule the prune command |
| `VISUAL_FEEDBACK_PRIVACY_URL` | none | the privacy notice a guest acknowledges |
Three defaults are worth knowing before changing anything:
- `screenshot.strategy` is `auto`. The browser's own screen capture runs first
(pixel-exact, asks permission once per capture) and falls back to a DOM renderer
when it is unavailable or declined. Set it to `dom` when the permission prompt is
unwanted, `off` to drop screenshots entirely.
- Only the mail channel is on. `database` and `webhook` are opt-in under `channels`.
**Switching `database` on needs a table, and it does not arrive with the package.** The
migration is not auto-loaded and is not part of the umbrella publish tag:
`php artisan vendor:publish --tag="visual-feedback-migrations"`, then `migrate`. Without it
the channel accepts a report and the queued job fails on the missing table — far from the
switch that caused it.
- `attachments.disk` must be a **private** disk. Screenshots contain whatever the
reporter had on screen.
Four things the package deliberately does NOT do for the host. None of them stops the
widget from working, which is why they get missed.
**Run a queue worker.** Every channel queues its own job, and a fresh Laravel application
resolves `queue.default` to `database` unless `QUEUE_CONNECTION` says otherwise — so with no
worker consuming the queue the job waits in the `jobs` table and no report is ever delivered,
while the reporter sees the success screen either way. `QUEUE_CONNECTION=sync` delivers
inline instead, which is fine for a small application and poor for a public form: the
reporter then waits on the mail transport.
**Bound Livewire's upload endpoint.** Attachments and screenshots ride Livewire's global
upload endpoint. A file is written to the temporary disk *before* any of this package's code
runs, so the package's caps bound what is accepted, never what is written, and the abuse
floor (which starts at submit) is not in front of it. Livewire's untouched defaults allow
12 MB per file at 60 calls per minute per IP — on a page that is usually public.
```php
// config/livewire.php
'temporary_file_upload' => [
'rules' => ['required', 'file', 'max:8192'],
'middleware' => 'throttle:20,1',
],
```
Size `rules` to the largest upload in the whole application; the key is app-global. Use
8192 KB rather than 5120, because screenshots have their own 8 MB cap and a 5 MB rule would
reject a legitimate capture at the endpoint. `throttle:60,1` is Livewire's own default, so
writing that value hardens nothing.
**Schedule the housekeeping commands.** The package registers no schedule — a package does
not write into the host's scheduler. Until these entries exist, `retention.reports_days`
deletes nothing and orphaned attachments accumulate:
```php
// routes/console.php
use Illuminate\Support\Facades\Schedule;
Schedule::command('visual-feedback:prune')->daily()->withoutOverlapping()->onOneServer();
Schedule::command('visual-feedback:sweep-orphans')->daily()->withoutOverlapping()->onOneServer();
```
Both are safe to add before a retention window is chosen: with `reports_days` unset, prune
is a clean no-op, and a value under one day is refused rather than deleting every report. The sweep is needed even then — the ordinary attachment cleanup runs off a
cache-backed reference count, and a `cache:clear` on deploy loses it; the age-based sweep is
the only thing that collects the residue, and it works without the optional reports table.
**Run `visual-feedback:forget` by hand.** It answers one person's erasure request and does
not belong on a schedule. For a signed-in member, erase by id rather than by address, because
addresses move between accounts: `visual-feedback:forget --reporter=<id>` takes that account's
reports under every address it used, `--guest-email=<address>` adds the guest reports sent under
an address, and `visual-feedback:forget <address> --guests-only` takes only those. An address
alone erases every report under it, another account's included. Pass it as it was submitted:
MySQL ignores its case, PostgreSQL and SQLite do not, and no engine matches across accents.
Besides deleting the stored reports and their files, it records what it erased so that a
delivery still queued for one of those reports stores, mails and posts nothing. It cannot reach
a mail already delivered, a job in the queue's failed jobs (remove those with `queue:forget`),
or the webhooks platform's delivery log, and it says so in its output.
**If the application runs a content security policy**, it needs five allowances, and two of the
failures are silent rather than loud:
- `style-src` for `@include('visual-feedback::style')`, which renders an inline `<style>` — without
a nonce, a hash or `'unsafe-inline'` the widget renders completely unstyled.
- `img-src blob:` for the preview. **This one is the quiet one:** without it the preview box is
empty while the buttons under it still offer attach, discard and retake, so the reporter attaches
a screenshot they were never shown — and the documentation promises they review it first.
- `img-src data:` for the DOM capture stage, which redraws inline `<svg>` through a data URL.
Without it the shot comes back with holes where the icons were.
- `connect-src` and `font-src` for the origins of any inlined SVG or webfont inside the captured
region.
**Under a nonce-based policy there is nothing to wire** if the application already calls
`Vite::useCspNonce()`: the script and style tags carry that nonce, the one Livewire uses for its
own tags, and the renderer the DOM stage loads at capture time carries it too. To pass a nonce
explicitly, write `<x-visual-feedback::scripts :nonce="$nonce" />` and
`@include('visual-feedback::style', ['nonce' => $nonce])`.
**You do not need `unsafe-eval`.** That is worth saying because Alpine normally does: its
standard build evaluates every directive expression by constructing a function at runtime.
Alpine ships a CSP build that parses expressions against a small grammar instead, and every
expression this package renders is a method call on a component registered in the bundle —
which parses under both builds. So `livewire.csp_safe => true` needs nothing configured here.
Two consequences worth knowing:
- If your `script-src` lists paths rather than a directory, list **all four** files:
`visual-feedback-widget.iife.js`, `visual-feedback.iife.js`,
`visual-feedback-renderer.iife.js` and `visual-feedback-editor.iife.js`. Missing the widget
bundle is the silent failure above.
Missing the renderer is a louder one that arrives later: the capture button works right up to
the moment somebody uses it, then reports a failure, because the renderer is fetched at capture
time rather than on page load.
- If you publish the view tree and edit it, keep the rule: **put logic in a component and call
a method from the template.** An arrow function, a template literal, an optional chain or a
bare `document` in a directive is what the CSP build refuses — and it refuses by not
evaluating, so the page renders and the control is dead.
- **The one exception to "call a method": a method named after a JavaScript keyword.** A `wire:`
action expression meets the same grammar, because Livewire rewrites it to `$wire.<expression>`
first. An operator name (`delete in instanceof new typeof void`) then fails to parse — write
`$wire['delete'](…)` instead. A literal name (`true false null undefined`) is never rewritten,
parses, and calls the literal at runtime — `$wire['true'](…)` fixes that one. **Index access is
the cure for both**; they differ only in where they fail. This package shipped the operator case
in 0.5.0 and fixed it in 0.5.1; both look identical from the outside, which is a control that
renders and does nothing.
A policy that forbids style **attributes** (`style-src-attr 'none'`, or any `style-src` without
`'unsafe-inline'`, which it falls back to) drops the honeypot's inline concealment, and a visible
honeypot is filled in by real people whose report is then discarded. Both trees carry the class as
a second mechanism, and `@include('visual-feedback::style')` is what supplies its rule — including
on the WireKit tree, where that include renders a layout-only block rather than nothing. The
reasoning is in the
[integration contract](https://docs.pushery.com/visual-feedback-for-laravel/integration-contract).
### 4. Choose where the trigger lives
`ui.trigger` is `fab` by default (the package places the floating button). Use `none`
and place your own, anywhere on the page:
```blade
<x-visual-feedback::trigger class="your-button-class">Report a problem</x-visual-feedback::trigger>
```
The trigger ships **unstyled** in the plain view tree — a bare
`<button class="visual-feedback-trigger">`, and the bundled stylesheet has no rule for that
class. That is on purpose (it should wear the host's buttons, not this package's), so pass
the application's own button classes. With browser defaults alone it renders around 21px
tall; give it at least 44×44 CSS px and a 16px font. The WireKit tree needs none of this —
its trigger is a WireKit button and inherits the design system's sizes.
Any code can open the widget without the component:
```js
window.dispatchEvent(new Event('visual-feedback:open'))
```
On a page with two modal widgets, give the second an `instance` name and aim its trigger at it,
`<x-visual-feedback::trigger instance="project">`, or name it in the event:
`new CustomEvent('visual-feedback:open', { detail: { instance: 'project' } })`. An event that
names an instance opens that widget alone, and one that names none opens the widget without a name.
### 5. Override the configuration per widget
Eight props set one widget apart from the configuration, so two pages in the same application
can offer different forms. The browser cannot change or widen any of them after mount:
`categories`, `context`, `fields`, `withScreenshot` and `notice` are sealed and restored on every request,
`recipient` must stay an address the configuration permits, `mode` one the widget renders, and
`instance` a name or none.
A request that breaks the seal makes the widget refuse to send until the page is reloaded.
| Prop | Type | Default | What it does |
|---|---|---|---|
| `mode` | `?string` | from `ui.trigger` | `modal` or `inline` (form in the page, no modal) |
| `categories` | `array` | from `categories` | the category list this widget offers |
| `context` | `array` | `[]` | context entries attached to this widget's reports |
| `fields` | `array` | from `fields` | per-field visibility, e.g. `['subject' => false]` |
| `recipient` | `?string` | from `mail.to` | where this widget's reports are mailed |
| `withScreenshot` | `?bool` | `null` | `null` follows the default (capture in modal mode only); `false` removes it, `true` adds it |
| `notice` | `?string` | `null` | your sentence above the fields, such as who reads a report and who gets a copy; rendered as text and named as the dialog's description |
```blade
@livewire('visual-feedback.report-widget', [
'mode' => 'inline',
'categories' => ['bug', 'billing'],
'fields' => ['subject' => false],
'recipient' => '[email protected]',
'withScreenshot' => false,
])
```
Three things to get right:
- The mount prop is **`categories`**, not `availableCategories` — the longer name is the
resolved property on the component, not the argument.
- `withScreenshot` may narrow, never widen: with `screenshot.strategy` set to `off` the
capture stays off whatever a widget asks for.
- **`recipient` is visible in the page source.** Livewire serializes public properties into
the `wire:snapshot` attribute, and `#[Locked]` governs writes, not visibility. An address
mounted here is readable by any visitor or crawler. That is fine for one you would print
in a footer; for an internal alias, register a channel and pick the address server-side
instead.
### 6. Redact sensitive regions
No CSS effect hides anything from a screenshot. `content-visibility: hidden` is not
reproduced by the DOM renderer at all. `filter: blur()` is reproduced now, and blurred text can
still be read back out of an image; an earlier renderer returned it sharp. `filter: grayscale()`
is reproduced in Blink, and gray text is still perfectly readable. Which properties survive also changes between renderer
versions. Mark the region instead; the attribute blacks it out in both capture stages and
clears input values as it goes. It works on a wrapper and directly on an image, a canvas, an
inline SVG, a video, an iframe, a checkbox or a custom element:
```blade
<div data-visual-feedback-redact>
<x-account-balance />
</div>
```
A password field needs no marker, also once a show-password toggle such as WireKit's has switched
it to `text`: the DOM stage draws it with bullets and the native stage covers it. That holds for a
field whose `type` left `password` after the page loaded and for one whose `autocomplete` is
`current-password` or `new-password`. A secret in a field that never was a password field, an API
key in a plain text input, needs the marker.
### 7. Know the capture limits that touch design work
The DOM stage reconstructs the page rather than photographing it, so a few deviations are worth
knowing before you style anything. The one most likely to reach you is **stacking and clipping**:
an element layered behind another, or clipped by a parent's `overflow`, can come back drawn on top
of it and extending past the edge. Decorative background art overlapping a heading is the usual
way this shows up, and it looks like a rendering bug in the reporter's browser rather than in the
capture.
Two smaller ones: an underline from `text-decoration` is not always reproduced, and an image that
failed to load leaves no trace at all — on screen the browser draws its broken-image affordance
and the `alt` text, in the capture that area is simply empty. A report about a missing image
therefore shows a gap rather than the evidence.
Everything else on a normal page — gradients, `oklch` fills, `box-shadow` and Tailwind's `ring-*`,
sticky headers, tables, web fonts — is reproduced faithfully, an `inset` ring on a rounded element
included.
**Which properties survive changes with the renderer version, so treat any list as dated.** This
section used to say the opposite about `box-shadow`: it was painted across the whole element
instead of as a ring, so a `ring-1 ring-*/30` came back with its entire fill tinted, and this text
told you to reach for `outline` or `border` instead. That is no longer true and the workaround is
no longer needed. The current list lives in the
[integration contract](https://docs.pushery.com/visual-feedback-for-laravel/integration-contract),
which is the copy that gets corrected when a dependency moves.
### 8. Use a WireKit-styled widget (optional)
**Nothing to do — an application with WireKit 2.69+ installed already gets it.** The shipped
`ui.variant` is `auto`, which serves the WireKit tree when a new enough WireKit is present and
the framework-free one otherwise. Force either tree when the automatic choice is not what the
application wants:
```php
// config/visual-feedback.php — or VISUAL_FEEDBACK_UI_VARIANT
'ui' => ['variant' => 'wirekit'], // auto|plain|wirekit
```
**Do not publish the tree to select it.** `vendor:publish --tag="visual-feedback-wirekit"` still
works and is the right move when the application genuinely wants to **edit** the templates — but
publishing to merely choose between them leaves the host maintaining a copy of the package's
views, which every update then silently leaves behind. That is what `ui.variant` was added to
replace. The stylesheet needs no attention either way: it carries the same switch and renders
nothing under the WireKit tree.
**Needs WireKit 2.69 or newer**, the oldest release this version supports — the soft buttons
read as buttons on the surface they stand on from 2.66, every field explains itself beside its label from 2.64, the
report browser's pager turns pages inside Livewire from 2.51, and from 2.50 the report dialog, a `<x-wirekit::modal>`, keeps its header and close button
in reach when it is taller than the screen. `auto` serves the plain tree below 2.69; check the installed version before
forcing or publishing the WireKit tree. One thing differs from the plain tree by design: the trigger is an **icon** button
rather than a text one (its accessible name is the widget heading either way). The corner does
not differ: `ui.position` follows the writing direction in both trees, so `bottom-end` mirrors
in a right-to-left application, and the physical spellings such as `bottom-right` still work as
their left-to-right corner.
### 9. Ask guests to acknowledge a privacy notice (optional)
Guests get an acknowledgment checkbox as soon as `privacy.url` points somewhere. That URL
is what decides it — the reporter has to be able to open the full text, or the tick means
nothing.
```php
// config/visual-feedback.php
'privacy' => [
'source' => 'url',
'url' => 'https://example.com/privacy',
],
```
If the application uses `pushery/legal-consent`, set `source` to `legal-consent` and the
checkbox carries the sentence that package actually published, in the reporter's locale,
instead of this one's built-in line:
```php
'privacy' => [
'source' => 'legal-consent',
'url' => 'https://example.com/privacy', // still required — see above
'document_key' => 'privacy',
],
```
Keep in mind when adopting that:
- It **never writes to the consent ledger** — that API needs a model subject and a guest has
none. It does record which document was displayed, in reserved `privacy_notice_*` metadata
keys (key, locale, version, acceptance fingerprint), written server-side and stripped from
client input unconditionally. That is provenance with the report's lifetime, not a consent
and not a durable proof: `visual-feedback:prune` and `:forget` remove it with everything
else. If you need a real consent record, the reporter has to be authenticated and you record
it in legal-consent yourself.
- A locale with nothing published is normal, not broken: this package ships seven locales,
legal-consent publishes two by default. The built-in sentence is used and the log says so.
- It falls back to the built-in sentence, loudly, if the document is not a privacy notice,
if its notice mode gates, or if the published sentence is empty. Watch the log after wiring
it — a fallback means the configuration is not doing what it looks like it is doing.
- **Needs legal-consent 0.45 or newer.** The bridge reads the published version through
`ConsentManager::publishedVersion()`, which legal-consent serves from its own cache and drops
when a version is published, and checks the tenant on it, which arrived in 0.45. On an older
release the read fails and the checkbox carries the built-in sentence, with a warning in the
log. Composer cannot refuse for you, because the package is a suggest rather than a
requirement, so check the installed version before switching `privacy.source` to
`legal-consent`.
- Multi-tenant installs are served: each tenant gets its own published sentence. Before 0.7
this source declined to read at all when tenancy was on, because nothing on the returned
document said whose it was.
### 10. Add your own abuse gate (optional)
The built-in floor — honeypot, time trap, per-user and per-guest-IP rate limits — always runs, and
no `abuse.driver` value removes it. An additional gate layers ON TOP of it, never instead:
```php
use Pushery\VisualFeedback\Contracts\AbuseGate;
use Pushery\VisualFeedback\Facades\VisualFeedback;
VisualFeedback::extendAbuse('acme', fn (): AbuseGate => new AcmeGate(...));
```
Then set `abuse.driver` to `acme`. Registering alone activates nothing, and a driver name with no
gate registered logs a warning rather than silently protecting nothing — the floor still carries
the request either way.
For an **interactive** challenge (Turnstile, proof-of-work), point `abuse.challenge_view` at a
Blade view. It renders inside the form in both view trees, wrapped in `wire:ignore` — required,
because a challenge widget is third-party DOM with its own JavaScript and Livewire's morphing
would otherwise destroy it on the next update. Bind whatever it produces into `challenge`
(`wire:model="challenge.token"`); it reaches your gate on `ReportAttempt::$challenge`, verbatim
and untrusted.
A rejection is silent by default — a bot must learn nothing. Pass `visible: true` when a human
could have failed it:
```php
use Pushery\VisualFeedback\Abuse\AbuseDecision;
use Pushery\VisualFeedback\Events\RejectionReason;
return AbuseDecision::reject(RejectionReason::ChallengeFailed, visible: true);
```
`RejectionReason` lives under `Events`, not under `Abuse` — it is the enum the
`ReportRejected` event carries, so a gate and a listener name the same value.
**If your gate THROWS, the submission goes through by default.** The floor still judged it, so
this is a defensible default and it is what ships — an outage at a challenge provider should not
take a host's feedback form offline. It is also the host's call now, per driver, under the name
they registered you as:
```php
// config/visual-feedback.php
'abuse' => ['drivers' => ['acme' => ['on_error' => 'closed']]],
```
Under `closed` the submission is refused instead, with `RejectionReason::GateUnavailable` — kept
apart from `ChallengeFailed` because they say opposite things: one means the submission was judged
and refused, the other that nobody was there to judge it. Either way an `error` line names your
gate class and the driver name beside it.
Write defensively rather than relying on the mode. The client controls the `challenge` payload
completely, so `{}` or `{"token": ["x"]}` reaches you as a missing key or an array where you
expected a string — both raise in PHP, and a malformed field must not decide whether a form
accepts or refuses. Return a rejection instead of letting anything throw.
## Examples
Attach application context to every report — the thing that turns "it's broken" into a
diagnosable ticket. Implement the context provider contract and register the class under
`context_providers`:
```php
namespace App\Feedback;
use Pushery\VisualFeedback\Contracts\ReportContextProvider;
use Pushery\VisualFeedback\Data\ReportContextEntry;
final class SubscriptionContext implements ReportContextProvider
{
/** @return list<ReportContextEntry> */
public function entries(): array
{
$team = auth()->user()?->currentTeam;
if ($team === null) {
return [];
}
return [
new ReportContextEntry(key: 'team', label: 'Team', value: $team->name),
new ReportContextEntry(key: 'plan', label: 'Plan', value: $team->plan),
];
}
}
```
```php
// config/visual-feedback.php
'context_providers' => [
App\Feedback\SubscriptionContext::class,
],
```
Return `ReportContextEntry` objects only: anything else in the list is skipped with a warning in
the log, and the report goes out without it.
Deliver reports somewhere else with a class implementing
`Pushery\VisualFeedback\Contracts\ReportChannel` — `key()`, `isAvailable()` and
`dispatch(Report $report)`. Register the factory in a service provider's `boot()`:
```php
use Pushery\VisualFeedback\Facades\VisualFeedback;
VisualFeedback::extend('slack', fn () => new App\Feedback\SlackReportChannel());
```
If that channel STORES the report for someone to open later, implement
`Pushery\VisualFeedback\Contracts\RetainsReport` alongside it — a marker with no methods.
The package deletes a report's attachments once every channel that had it is transient
(mail, a webhook: they need the file only long enough to send it). A storing channel without
the marker counts as transient, so the files are deleted and nothing fails on the way: the
delivery succeeds, the row is written, and the screenshot is gone when somebody opens it.
React to a report without writing a channel by listening to the events — `ReportSubmitting`,
`ReportSubmitted`, `ReportRejected`, `ReportDelivered`, `ReportDeliveryFailed`,
`ScreenshotAttached`, all in the `Events` namespace:
```php
use Illuminate\Support\Facades\Event;
use Pushery\VisualFeedback\Events\ReportSubmitting;
Event::listen(function (ReportSubmitting $event): void {
if (str_contains($event->report->message, 'http://')) {
$event->reject('links are not accepted here'); // the submission stops here
}
});
```
`ReportSubmitting` is the only one a listener may act on rather than observe, and two things
decide whether the veto works: only a SYNCHRONOUS listener can cancel (the pipeline reads the
decision on the next line, so a queued listener runs and is ignored), and the report is not
validated yet, so treat `$event->report->message` as untrusted input. The string you pass
reaches your logs and `ReportRejected::$detail`, never the reporter's screen.
A listener on `ReportDelivered` or `ReportDeliveryFailed` that throws is reported and does not
fail the delivery, so it is not retried either: queue it (`ShouldQueue`) when it calls a service
that can be down.
Then enable it in the config. **The switch is the `enabled` key inside the channel's own
array, never a bare boolean** — `'slack' => true` reads as a missing `enabled` key, which
means off, silently:
```php
// config/visual-feedback.php
'channels' => [
// …
'slack' => [
'enabled' => true,
],
],
```
`enabled` is the only key the package reads for a channel of yours; the `connection`, `queue`, `tries` and
`backoff` entries beside it are read by the built-in channels for their own jobs. Channels
are isolated from each other: one failing never stops the rest.
## Anti-Patterns
- Do not store attachments on a public disk, and do not point `attachments.disk` at the
same disk the application serves user uploads from.
- Do not rely on any CSS effect — `filter`, `mask`, `content-visibility` — to hide sensitive
content from a screenshot; use `data-visual-feedback-redact`. The report browser marks what it
shows of a reporter itself, and a published copy of its views keeps `$redact` on those elements.
- Do not publish the WireKit tree in order to **select** it. `ui.variant` chooses the tree, and
publishing to choose leaves the application maintaining a copy of the package's templates that
every update silently leaves behind. Publish it when you want to edit it, not otherwise.
- Do not remove `@include('visual-feedback::style')` from the layout on the assumption that the
WireKit tree makes it dead. It is not dead there: it withholds the palette and renders a
layout-only block — spacing, the flex row under the screenshot preview, and the honeypot's
concealment rule. Strip it and a strict `style-src` leaves that field visible.
- Do not write a channel that stores reports without `RetainsReport`. Nothing fails when you
forget it; the attachments are simply gone by the time anyone opens the report.
- Do not assume a green submit means a delivered report. A missing recipient, an idle queue
and a rejected webhook signature all end in the same success screen; the log is where the
difference is.
Use this skill when a Laravel application installs or integrates the
pushery/visual-feedback-for-laravel package. The full reference is at
https://docs.pushery.com/visual-feedback-for-laravel/.
Apply the package's public API in the smallest correct way for the consuming application.
composer require pushery/visual-feedback-for-laravel
The service provider is registered automatically through package discovery.
Publish the ordinary set, or only the configuration file:
php artisan vendor:publish --tag="visual-feedback"
php artisan vendor:publish --tag="visual-feedback-config"
The first is not everything, and reading it that way is the mistake this line exists to
prevent. It covers the config, the translations, the views and the bundles, and each of those
has a tag of its own: visual-feedback-config, visual-feedback-lang, visual-feedback-views
and visual-feedback-assets. Two tags stand outside it on purpose, because each is a decision
rather than a default:
visual-feedback-migrations (the reports table — see the database channel below) and
visual-feedback-wirekit (the WireKit view tree, which is selected by configuration and does
not need publishing at all).
Every option in config/visual-feedback.php is documented inline.
Re-run php artisan vendor:publish --tag="visual-feedback-assets" --force after each
package update. The bundles under public/vendor/visual-feedback are copies, and
a stale copy is the one failure this setup produces on its own.
There are four of them, and they fail differently.
visual-feedback-widget.iife.js is under 12 KB, always ships, and registers the Alpine
components the templates bind to — so a page missing that one renders a widget whose
every control does nothing, with only the browser console to show for it.
visual-feedback.iife.js is under 40 KB and ships while screenshot.strategy is not off.
It is the capture state machine, not the renderer.
visual-feedback-renderer.iife.js is ~276 KB and is the DOM renderer. Nothing loads it
until a screenshot is actually taken, and the URL it is fetched from is derived from the
script that loads it — so it has to sit beside visual-feedback.iife.js under the same
base. vendor:publish puts all four there for you. If you serve the bundles from your
own origin or a CDN instead, upload this one too: without it the capture button gets all
the way to taking the picture and then reports a failure. A Content-Security-Policy needs
to allow it under the same script-src source as the other two.
visual-feedback-editor.iife.js is the screenshot editor, under 32 KB, fetched the same way
the first time a reporter presses Edit in the preview to crop or mark a capture. It sits beside the
capture bundle too. Missing, the Edit button says the editor could not be loaded and the
capture can still be attached as it is. screenshot.editor = false
(VISUAL_FEEDBACK_SCREENSHOT_EDITOR) takes the button away, and the file is never fetched.
An edited screenshot arrives with screenshot_edited: true in the report's technical
details.
php artisan about says whether that was needed: under Visual Feedback it carries a
Published bundle line reading up to date, OUT OF DATE with the command to run, or
not published. With APP_DEBUG=true a stale copy also writes a warning to the log.
Three things go in the layout: the stylesheet, the widget, the script tag.
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
@include('visual-feedback::style')
@livewireStyles
</head>
<body>
<main>{{ $slot }}</main>
@livewire('visual-feedback.report-widget')
@livewireScripts
<x-visual-feedback::scripts />
</body>
That puts the widget on the page: a floating button appears and opens a modal. It does not yet deliver anything.
Give the mail channel a recipient. It is on by default and ships without an address,
because none could be guessed. Until one is set the channel reports itself unavailable and is
skipped, and the reporter is told the submission did not go through rather than thanked for it.
The reason goes to the log, never to them: an enabled channel reported itself unavailable and was skipped, then a report was accepted but no channel was enabled and available to deliver it.
[email protected]
And check MAIL_MAILER while you are there. log and array let Mailer::send()
succeed with the message going nowhere, and Laravel's own default is env('MAIL_MAILER', 'log')
— so an application that never set it is in that state. The mail channel refuses those (and a
failover chain that can fall through to one) instead of reporting a delivery that did not
happen. VISUAL_FEEDBACK_MAIL_REQUIRE_DELIVERABLE_TRANSPORT=false allows it on purpose; the
check is never applied while the application runs its tests. An application whose own mail
stays on array on purpose, in a demo tenant for instance, names a real mailer for reports
alone with VISUAL_FEEDBACK_MAIL_MAILER; the check then reads that one.
The log transport writes the whole message — free text, the reporter's address, the
screenshot as base64 — into the log in plaintext, and from there into every error tracker the
log stack feeds.
Deciding what the form asks for. Every field answers one question with one word —
off, optional or required — and each has an environment variable:
VISUAL_FEEDBACK_FIELD_SUBJECT_MODE=optional # off | optional | required
VISUAL_FEEDBACK_FIELD_NAME_MODE=optional
VISUAL_FEEDBACK_FIELD_EMAIL_MODE=optional
VISUAL_FEEDBACK_FIELD_PHONE_MODE=off
A value that is none of the three words shows the field as optional, so a typo never removes an
input; true and false read as optional and off.
message has no mode: a feedback form without a message is not a feedback form. Name, email and
phone are asked of GUESTS only — an authenticated reporter's identity comes from the auth guard,
so those three are neither shown to them nor revalidated.
A field set to off is dropped, not merely hidden: a value for it does not reach the report even
if one was posted. A field set to required is marked required in the markup and a submission
without it is refused, in this package's own wording and in all seven shipped locales.
One page can differ from the rest without touching the environment:
<livewire:visual-feedback.report-widget :fields="['email' => 'required', 'subject' => 'off']" />
When the reporter leaves the subject empty — the normal case, since it is optional — the mail
subject becomes Category - the first words of the message. VISUAL_FEEDBACK_MAIL_SUBJECT_EXCERPT
sets how much of the message goes in (default 60 characters); 0 gives the bare category.
Naming the sender. VISUAL_FEEDBACK_MAIL_FROM_NAME defaults to APP_NAME, so reports arrive
from the product rather than from a bare address. VISUAL_FEEDBACK_MAIL_FROM_ADDRESS overrides
the envelope sender.
The rest of the surface. Every variable is listed with its default at
https://docs.pushery.com/visual-feedback-for-laravel/configuration, together with a block you
can paste into .env whole. The ones a consuming application reaches for most:
| Variable | Default | Effect |
|---|---|---|
VISUAL_FEEDBACK_ENABLED |
true |
master switch; off refuses every submission before it touches a disk |
VISUAL_FEEDBACK_UI_VARIANT |
auto |
auto, plain or wirekit |
VISUAL_FEEDBACK_UI_TRIGGER |
fab |
fab, inline or none when you place the trigger yourself |
VISUAL_FEEDBACK_UI_POSITION |
bottom-end |
which corner the floating button sits in, read in the writing direction |
VISUAL_FEEDBACK_UI_DARK_MODE |
media |
what turns the plain tree dark: media follows the operating system, class the application's VISUAL_FEEDBACK_UI_DARK_SELECTOR, .dark by default |
VISUAL_FEEDBACK_SCREENSHOT_STRATEGY |
auto |
auto, native, dom or off |
VISUAL_FEEDBACK_ATTACHMENTS_DISK |
local |
keep it private: screenshots carry whatever was on screen |
VISUAL_FEEDBACK_CHANNEL_DATABASE |
false |
also store reports in the optional table |
VISUAL_FEEDBACK_CHANNEL_WEBHOOK |
false |
also POST them to VISUAL_FEEDBACK_WEBHOOK_URL |
VISUAL_FEEDBACK_RETENTION_DAYS |
none | how long reports are kept, once you schedule the prune command |
VISUAL_FEEDBACK_PRIVACY_URL |
none | the privacy notice a guest acknowledges |
Three defaults are worth knowing before changing anything:
screenshot.strategy is auto. The browser's own screen capture runs first
(pixel-exact, asks permission once per capture) and falls back to a DOM renderer
when it is unavailable or declined. Set it to dom when the permission prompt is
unwanted, off to drop screenshots entirely.database and webhook are opt-in under channels.
Switching database on needs a table, and it does not arrive with the package. The
migration is not auto-loaded and is not part of the umbrella publish tag:
php artisan vendor:publish --tag="visual-feedback-migrations", then migrate. Without it
the channel accepts a report and the queued job fails on the missing table — far from the
switch that caused it.attachments.disk must be a private disk. Screenshots contain whatever the
reporter had on screen.Four things the package deliberately does NOT do for the host. None of them stops the widget from working, which is why they get missed.
Run a queue worker. Every channel queues its own job, and a fresh Laravel application
resolves queue.default to database unless QUEUE_CONNECTION says otherwise — so with no
worker consuming the queue the job waits in the jobs table and no report is ever delivered,
while the reporter sees the success screen either way. QUEUE_CONNECTION=sync delivers
inline instead, which is fine for a small application and poor for a public form: the
reporter then waits on the mail transport.
Bound Livewire's upload endpoint. Attachments and screenshots ride Livewire's global upload endpoint. A file is written to the temporary disk before any of this package's code runs, so the package's caps bound what is accepted, never what is written, and the abuse floor (which starts at submit) is not in front of it. Livewire's untouched defaults allow 12 MB per file at 60 calls per minute per IP — on a page that is usually public.
// config/livewire.php
'temporary_file_upload' => [
'rules' => ['required', 'file', 'max:8192'],
'middleware' => 'throttle:20,1',
],
Size rules to the largest upload in the whole application; the key is app-global. Use
8192 KB rather than 5120, because screenshots have their own 8 MB cap and a 5 MB rule would
reject a legitimate capture at the endpoint. throttle:60,1 is Livewire's own default, so
writing that value hardens nothing.
Schedule the housekeeping commands. The package registers no schedule — a package does
not write into the host's scheduler. Until these entries exist, retention.reports_days
deletes nothing and orphaned attachments accumulate:
// routes/console.php
use Illuminate\Support\Facades\Schedule;
Schedule::command('visual-feedback:prune')->daily()->withoutOverlapping()->onOneServer();
Schedule::command('visual-feedback:sweep-orphans')->daily()->withoutOverlapping()->onOneServer();
Both are safe to add before a retention window is chosen: with reports_days unset, prune
is a clean no-op, and a value under one day is refused rather than deleting every report. The sweep is needed even then — the ordinary attachment cleanup runs off a
cache-backed reference count, and a cache:clear on deploy loses it; the age-based sweep is
the only thing that collects the residue, and it works without the optional reports table.
Run visual-feedback:forget by hand. It answers one person's erasure request and does
not belong on a schedule. For a signed-in member, erase by id rather than by address, because
addresses move between accounts: visual-feedback:forget --reporter=<id> takes that account's
reports under every address it used, --guest-email=<address> adds the guest reports sent under
an address, and visual-feedback:forget <address> --guests-only takes only those. An address
alone erases every report under it, another account's included. Pass it as it was submitted:
MySQL ignores its case, PostgreSQL and SQLite do not, and no engine matches across accents.
Besides deleting the stored reports and their files, it records what it erased so that a
delivery still queued for one of those reports stores, mails and posts nothing. It cannot reach
a mail already delivered, a job in the queue's failed jobs (remove those with queue:forget),
or the webhooks platform's delivery log, and it says so in its output.
If the application runs a content security policy, it needs five allowances, and two of the failures are silent rather than loud:
style-src for @include('visual-feedback::style'), which renders an inline <style> — without
a nonce, a hash or 'unsafe-inline' the widget renders completely unstyled.img-src blob: for the preview. This one is the quiet one: without it the preview box is
empty while the buttons under it still offer attach, discard and retake, so the reporter attaches
a screenshot they were never shown — and the documentation promises they review it first.img-src data: for the DOM capture stage, which redraws inline <svg> through a data URL.
Without it the shot comes back with holes where the icons were.connect-src and font-src for the origins of any inlined SVG or webfont inside the captured
region.Under a nonce-based policy there is nothing to wire if the application already calls
Vite::useCspNonce(): the script and style tags carry that nonce, the one Livewire uses for its
own tags, and the renderer the DOM stage loads at capture time carries it too. To pass a nonce
explicitly, write <x-visual-feedback::scripts :nonce="$nonce" /> and
@include('visual-feedback::style', ['nonce' => $nonce]).
You do not need unsafe-eval. That is worth saying because Alpine normally does: its
standard build evaluates every directive expression by constructing a function at runtime.
Alpine ships a CSP build that parses expressions against a small grammar instead, and every
expression this package renders is a method call on a component registered in the bundle —
which parses under both builds. So livewire.csp_safe => true needs nothing configured here.
Two consequences worth knowing:
script-src lists paths rather than a directory, list all four files:
visual-feedback-widget.iife.js, visual-feedback.iife.js,
visual-feedback-renderer.iife.js and visual-feedback-editor.iife.js. Missing the widget
bundle is the silent failure above.
Missing the renderer is a louder one that arrives later: the capture button works right up to
the moment somebody uses it, then reports a failure, because the renderer is fetched at capture
time rather than on page load.document in a directive is what the CSP build refuses — and it refuses by not
evaluating, so the page renders and the control is dead.wire:
action expression meets the same grammar, because Livewire rewrites it to $wire.<expression>
first. An operator name (delete in instanceof new typeof void) then fails to parse — write
$wire['delete'](…) instead. A literal name (true false null undefined) is never rewritten,
parses, and calls the literal at runtime — $wire['true'](…) fixes that one. Index access is
the cure for both; they differ only in where they fail. This package shipped the operator case
in 0.5.0 and fixed it in 0.5.1; both look identical from the outside, which is a control that
renders and does nothing.A policy that forbids style attributes (style-src-attr 'none', or any style-src without
'unsafe-inline', which it falls back to) drops the honeypot's inline concealment, and a visible
honeypot is filled in by real people whose report is then discarded. Both trees carry the class as
a second mechanism, and @include('visual-feedback::style') is what supplies its rule — including
on the WireKit tree, where that include renders a layout-only block rather than nothing. The
reasoning is in the
integration contract.
ui.trigger is fab by default (the package places the floating button). Use none
and place your own, anywhere on the page:
<x-visual-feedback::trigger class="your-button-class">Report a problem</x-visual-feedback::trigger>
The trigger ships unstyled in the plain view tree — a bare
<button class="visual-feedback-trigger">, and the bundled stylesheet has no rule for that
class. That is on purpose (it should wear the host's buttons, not this package's), so pass
the application's own button classes. With browser defaults alone it renders around 21px
tall; give it at least 44×44 CSS px and a 16px font. The WireKit tree needs none of this —
its trigger is a WireKit button and inherits the design system's sizes.
Any code can open the widget without the component:
window.dispatchEvent(new Event('visual-feedback:open'))
On a page with two modal widgets, give the second an instance name and aim its trigger at it,
<x-visual-feedback::trigger instance="project">, or name it in the event:
new CustomEvent('visual-feedback:open', { detail: { instance: 'project' } }). An event that
names an instance opens that widget alone, and one that names none opens the widget without a name.
Eight props set one widget apart from the configuration, so two pages in the same application
can offer different forms. The browser cannot change or widen any of them after mount:
categories, context, fields, withScreenshot and notice are sealed and restored on every request,
recipient must stay an address the configuration permits, mode one the widget renders, and
instance a name or none.
A request that breaks the seal makes the widget refuse to send until the page is reloaded.
| Prop | Type | Default | What it does |
|---|---|---|---|
mode |
?string |
from ui.trigger |
modal or inline (form in the page, no modal) |
categories |
array |
from categories |
the category list this widget offers |
context |
array |
[] |
context entries attached to this widget's reports |
fields |
array |
from fields |
per-field visibility, e.g. ['subject' => false] |
recipient |
?string |
from mail.to |
where this widget's reports are mailed |
withScreenshot |
?bool |
null |
null follows the default (capture in modal mode only); false removes it, true adds it |
notice |
?string |
null |
your sentence above the fields, such as who reads a report and who gets a copy; rendered as text and named as the dialog's description |
@livewire('visual-feedback.report-widget', [
'mode' => 'inline',
'categories' => ['bug', 'billing'],
'fields' => ['subject' => false],
'recipient' => '[email protected]',
'withScreenshot' => false,
])
Three things to get right:
categories, not availableCategories — the longer name is the
resolved property on the component, not the argument.withScreenshot may narrow, never widen: with screenshot.strategy set to off the
capture stays off whatever a widget asks for.recipient is visible in the page source. Livewire serializes public properties into
the wire:snapshot attribute, and #[Locked] governs writes, not visibility. An address
mounted here is readable by any visitor or crawler. That is fine for one you would print
in a footer; for an internal alias, register a channel and pick the address server-side
instead.No CSS effect hides anything from a screenshot. content-visibility: hidden is not
reproduced by the DOM renderer at all. filter: blur() is reproduced now, and blurred text can
still be read back out of an image; an earlier renderer returned it sharp. filter: grayscale()
is reproduced in Blink, and gray text is still perfectly readable. Which properties survive also changes between renderer
versions. Mark the region instead; the attribute blacks it out in both capture stages and
clears input values as it goes. It works on a wrapper and directly on an image, a canvas, an
inline SVG, a video, an iframe, a checkbox or a custom element:
<div data-visual-feedback-redact>
<x-account-balance />
</div>
A password field needs no marker, also once a show-password toggle such as WireKit's has switched
it to text: the DOM stage draws it with bullets and the native stage covers it. That holds for a
field whose type left password after the page loaded and for one whose autocomplete is
current-password or new-password. A secret in a field that never was a password field, an API
key in a plain text input, needs the marker.
The DOM stage reconstructs the page rather than photographing it, so a few deviations are worth
knowing before you style anything. The one most likely to reach you is stacking and clipping:
an element layered behind another, or clipped by a parent's overflow, can come back drawn on top
of it and extending past the edge. Decorative background art overlapping a heading is the usual
way this shows up, and it looks like a rendering bug in the reporter's browser rather than in the
capture.
Two smaller ones: an underline from text-decoration is not always reproduced, and an image that
failed to load leaves no trace at all — on screen the browser draws its broken-image affordance
and the alt text, in the capture that area is simply empty. A report about a missing image
therefore shows a gap rather than the evidence.
Everything else on a normal page — gradients, oklch fills, box-shadow and Tailwind's ring-*,
sticky headers, tables, web fonts — is reproduced faithfully, an inset ring on a rounded element
included.
Which properties survive changes with the renderer version, so treat any list as dated. This
section used to say the opposite about box-shadow: it was painted across the whole element
instead of as a ring, so a ring-1 ring-*/30 came back with its entire fill tinted, and this text
told you to reach for outline or border instead. That is no longer true and the workaround is
no longer needed. The current list lives in the
integration contract,
which is the copy that gets corrected when a dependency moves.
Nothing to do — an application with WireKit 2.69+ installed already gets it. The shipped
ui.variant is auto, which serves the WireKit tree when a new enough WireKit is present and
the framework-free one otherwise. Force either tree when the automatic choice is not what the
application wants:
// config/visual-feedback.php — or VISUAL_FEEDBACK_UI_VARIANT
'ui' => ['variant' => 'wirekit'], // auto|plain|wirekit
Do not publish the tree to select it. vendor:publish --tag="visual-feedback-wirekit" still
works and is the right move when the application genuinely wants to edit the templates — but
publishing to merely choose between them leaves the host maintaining a copy of the package's
views, which every update then silently leaves behind. That is what ui.variant was added to
replace. The stylesheet needs no attention either way: it carries the same switch and renders
nothing under the WireKit tree.
Needs WireKit 2.69 or newer, the oldest release this version supports — the soft buttons
read as buttons on the surface they stand on from 2.66, every field explains itself beside its label from 2.64, the
report browser's pager turns pages inside Livewire from 2.51, and from 2.50 the report dialog, a <x-wirekit::modal>, keeps its header and close button
in reach when it is taller than the screen. auto serves the plain tree below 2.69; check the installed version before
forcing or publishing the WireKit tree. One thing differs from the plain tree by design: the trigger is an icon button
rather than a text one (its accessible name is the widget heading either way). The corner does
not differ: ui.position follows the writing direction in both trees, so bottom-end mirrors
in a right-to-left application, and the physical spellings such as bottom-right still work as
their left-to-right corner.
Guests get an acknowledgment checkbox as soon as privacy.url points somewhere. That URL
is what decides it — the reporter has to be able to open the full text, or the tick means
nothing.
// config/visual-feedback.php
'privacy' => [
'source' => 'url',
'url' => 'https://example.com/privacy',
],
If the application uses pushery/legal-consent, set source to legal-consent and the
checkbox carries the sentence that package actually published, in the reporter's locale,
instead of this one's built-in line:
'privacy' => [
'source' => 'legal-consent',
'url' => 'https://example.com/privacy', // still required — see above
'document_key' => 'privacy',
],
Keep in mind when adopting that:
privacy_notice_* metadata
keys (key, locale, version, acceptance fingerprint), written server-side and stripped from
client input unconditionally. That is provenance with the report's lifetime, not a consent
and not a durable proof: visual-feedback:prune and :forget remove it with everything
else. If you need a real consent record, the reporter has to be authenticated and you record
it in legal-consent yourself.ConsentManager::publishedVersion(), which legal-consent serves from its own cache and drops
when a version is published, and checks the tenant on it, which arrived in 0.45. On an older
release the read fails and the checkbox carries the built-in sentence, with a warning in the
log. Composer cannot refuse for you, because the package is a suggest rather than a
requirement, so check the installed version before switching privacy.source to
legal-consent.The built-in floor — honeypot, time trap, per-user and per-guest-IP rate limits — always runs, and
no abuse.driver value removes it. An additional gate layers ON TOP of it, never instead:
use Pushery\VisualFeedback\Contracts\AbuseGate;
use Pushery\VisualFeedback\Facades\VisualFeedback;
VisualFeedback::extendAbuse('acme', fn (): AbuseGate => new AcmeGate(...));
Then set abuse.driver to acme. Registering alone activates nothing, and a driver name with no
gate registered logs a warning rather than silently protecting nothing — the floor still carries
the request either way.
For an interactive challenge (Turnstile, proof-of-work), point abuse.challenge_view at a
Blade view. It renders inside the form in both view trees, wrapped in wire:ignore — required,
because a challenge widget is third-party DOM with its own JavaScript and Livewire's morphing
would otherwise destroy it on the next update. Bind whatever it produces into challenge
(wire:model="challenge.token"); it reaches your gate on ReportAttempt::$challenge, verbatim
and untrusted.
A rejection is silent by default — a bot must learn nothing. Pass visible: true when a human
could have failed it:
use Pushery\VisualFeedback\Abuse\AbuseDecision;
use Pushery\VisualFeedback\Events\RejectionReason;
return AbuseDecision::reject(RejectionReason::ChallengeFailed, visible: true);
RejectionReason lives under Events, not under Abuse — it is the enum the
ReportRejected event carries, so a gate and a listener name the same value.
If your gate THROWS, the submission goes through by default. The floor still judged it, so this is a defensible default and it is what ships — an outage at a challenge provider should not take a host's feedback form offline. It is also the host's call now, per driver, under the name they registered you as:
// config/visual-feedback.php
'abuse' => ['drivers' => ['acme' => ['on_error' => 'closed']]],
Under closed the submission is refused instead, with RejectionReason::GateUnavailable — kept
apart from ChallengeFailed because they say opposite things: one means the submission was judged
and refused, the other that nobody was there to judge it. Either way an error line names your
gate class and the driver name beside it.
Write defensively rather than relying on the mode. The client controls the challenge payload
completely, so {} or {"token": ["x"]} reaches you as a missing key or an array where you
expected a string — both raise in PHP, and a malformed field must not decide whether a form
accepts or refuses. Return a rejection instead of letting anything throw.
Attach application context to every report — the thing that turns "it's broken" into a
diagnosable ticket. Implement the context provider contract and register the class under
context_providers:
namespace App\Feedback;
use Pushery\VisualFeedback\Contracts\ReportContextProvider;
use Pushery\VisualFeedback\Data\ReportContextEntry;
final class SubscriptionContext implements ReportContextProvider
{
/** @return list<ReportContextEntry> */
public function entries(): array
{
$team = auth()->user()?->currentTeam;
if ($team === null) {
return [];
}
return [
new ReportContextEntry(key: 'team', label: 'Team', value: $team->name),
new ReportContextEntry(key: 'plan', label: 'Plan', value: $team->plan),
];
}
}
// config/visual-feedback.php
'context_providers' => [
App\Feedback\SubscriptionContext::class,
],
Return ReportContextEntry objects only: anything else in the list is skipped with a warning in
the log, and the report goes out without it.
Deliver reports somewhere else with a class implementing
Pushery\VisualFeedback\Contracts\ReportChannel — key(), isAvailable() and
dispatch(Report $report). Register the factory in a service provider's boot():
use Pushery\VisualFeedback\Facades\VisualFeedback;
VisualFeedback::extend('slack', fn () => new App\Feedback\SlackReportChannel());
If that channel STORES the report for someone to open later, implement
Pushery\VisualFeedback\Contracts\RetainsReport alongside it — a marker with no methods.
The package deletes a report's attachments once every channel that had it is transient
(mail, a webhook: they need the file only long enough to send it). A storing channel without
the marker counts as transient, so the files are deleted and nothing fails on the way: the
delivery succeeds, the row is written, and the screenshot is gone when somebody opens it.
React to a report without writing a channel by listening to the events — ReportSubmitting,
ReportSubmitted, ReportRejected, ReportDelivered, ReportDeliveryFailed,
ScreenshotAttached, all in the Events namespace:
use Illuminate\Support\Facades\Event;
use Pushery\VisualFeedback\Events\ReportSubmitting;
Event::listen(function (ReportSubmitting $event): void {
if (str_contains($event->report->message, 'http://')) {
$event->reject('links are not accepted here'); // the submission stops here
}
});
ReportSubmitting is the only one a listener may act on rather than observe, and two things
decide whether the veto works: only a SYNCHRONOUS listener can cancel (the pipeline reads the
decision on the next line, so a queued listener runs and is ignored), and the report is not
validated yet, so treat $event->report->message as untrusted input. The string you pass
reaches your logs and ReportRejected::$detail, never the reporter's screen.
A listener on ReportDelivered or ReportDeliveryFailed that throws is reported and does not
fail the delivery, so it is not retried either: queue it (ShouldQueue) when it calls a service
that can be down.
Then enable it in the config. The switch is the enabled key inside the channel's own
array, never a bare boolean — 'slack' => true reads as a missing enabled key, which
means off, silently:
// config/visual-feedback.php
'channels' => [
// …
'slack' => [
'enabled' => true,
],
],
enabled is the only key the package reads for a channel of yours; the connection, queue, tries and
backoff entries beside it are read by the built-in channels for their own jobs. Channels
are isolated from each other: one failing never stops the rest.
attachments.disk at the
same disk the application serves user uploads from.filter, mask, content-visibility — to hide sensitive
content from a screenshot; use data-visual-feedback-redact. The report browser marks what it
shows of a reporter itself, and a published copy of its views keeps $redact on those elements.ui.variant chooses the tree, and
publishing to choose leaves the application maintaining a copy of the package's templates that
every update silently leaves behind. Publish it when you want to edit it, not otherwise.@include('visual-feedback::style') from the layout on the assumption that the
WireKit tree makes it dead. It is not dead there: it withholds the palette and renders a
layout-only block — spacing, the flex row under the screenshot preview, and the honeypot's
concealment rule. Strip it and a strict style-src leaves that field visible.RetainsReport. Nothing fails when you
forget it; the attachments are simply gone by the time anyone opens the report.How can I help you explore Laravel packages today?