Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message
visual-feedback-for-laravel
pushery/visual-feedback-for-laravel
claude-code
laravel
screenshot
feedback
bug-reports
livewire
in-app-feedback

Install, configure, and apply the Visual Feedback for Laravel package in a Laravel application.

Install
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.

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

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:

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.

<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.
  • 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.

// 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:

  • 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.

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:

<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.

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
@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:

<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, 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:

// 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.

// 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:

  • 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:

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.

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:

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.

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.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
mrnewport/laravel-plaid
mrnewport/laravel-repo
mrnewport/laravel-docsign
mrnewport/laravel-stow
mrnewport/laravel-flow
mrnewport/laravel-priceable
mrnewport/laravel-nestedterms
fredmeng/laravel-apidoc-generator
itstructure/laravel-multilingual-tools
pdustdar/elasticsearch
markupai/php-sdk
rlacerda83/lumem-microservice-shipment
uni-method/symfony-lib
tacticmedia/scim-server-bundle
neika-ai/symfony-bundle
mattleads/agent-publishing-bundle
glavweb/framework-bundle
glavweb/extra-bundle
glavweb/api-bundle
glavweb/admin-bundle