Installation
Requirements
- PHP 8.4+, with the
intl,fileinfo,filter,hash,jsonandmbstringextensions - Laravel 12+
- Livewire 4.3+
Composer refuses the install if an extension is missing and names it, so this list is a
heads-up rather than something to verify. intl is the one worth checking first — it is the
only one that is regularly absent from a stock PHP build.
Tested against SQLite, PostgreSQL, and MySQL 8.4, so it runs on Laravel Cloud (serverless Postgres and MySQL 8.4 LTS) out of the box.
Installation
composer require pushery/visual-feedback-for-laravel
The service provider is registered automatically through package discovery.
Publish the configuration, translations, views and the JS bundles:
php artisan vendor:publish --tag=visual-feedback
Then place the widget once in your layout, and the script tag before </body>:
<!doctype html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@include('visual-feedback::style')
@livewireStyles
</head>
<body>
<main>
<h1>Your application</h1>
{{ $slot }}
</main>
@livewire('visual-feedback.report-widget')
@livewireScripts
<x-visual-feedback::scripts />
</body>
</html>
That is the whole integration on the page: a floating button appears in the corner and opens the modal. One more setting decides whether a report goes anywhere.
Keep the @include('visual-feedback::style') line whichever view tree you serve. Under the
WireKit tree it renders a smaller block rather than nothing: the rhythm between field groups,
the row under the screenshot preview, the box around a refusal and the concealment rule for
the honeypot.
Give the mail channel a recipient
The mail channel is on by default and ships without an address — nothing sensible could be guessed there. Until you set one, the channel reports itself unavailable and is skipped:
VISUAL_FEEDBACK_MAIL_TO=support@example.com
Set it before you send the first test report. Until something can carry a report, the widget
tells the reporter the submission did not go through rather than thanking them for it — the log
carries the reason, and the reporter is not shown it:
an enabled channel reported itself unavailable and was skipped, then
a report was accepted but no channel was enabled and available to deliver it.
MAIL_MAILER=log, array and null all make Mailer::send() succeed while the message goes
nowhere. Laravel's own default is env('MAIL_MAILER', 'log'), so an application that has never
set it is in exactly that state — and the mail channel used to report a delivered report for
every one of them.
It now refuses instead, says so in the log, and the reporter sees a failure rather than a
thank-you. Set MAIL_MAILER to a real transport, or set
VISUAL_FEEDBACK_MAIL_REQUIRE_DELIVERABLE_TRANSPORT=false if reading rendered reports in
laravel.log is what you actually want. The check is never applied while your application runs
its tests, where array is the correct answer.
⚠️ And the log transport writes the whole message — the reporter's free text, their address
in Reply-To, the screenshot as base64. One measured report was 117 kB of plaintext in
laravel.log, and from there in every error tracker the log stack feeds.
The other two channels are opt-in and have their own required settings — see Delivery channels.
Four things the package cannot do for you
None of these stops the widget from working, which is exactly why they are easy to miss.
1 · Bound Livewire's upload endpoint. Attachments and screenshots ride Livewire's global
upload endpoint, and a file is written to the temporary disk before this package runs — so
this package's caps bound what is accepted, never what is written. Livewire's untouched
defaults allow 12 MB per file at 60 calls per minute per IP, on a page that is usually
public. In 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. The
Integration contract explains both numbers.
2 · Schedule the housekeeping commands. The package registers no schedule, on purpose: a
package does not get to write into your scheduler. Nothing is pruned or swept until you add
the entries yourself. In routes/console.php:
use Illuminate\Support\Facades\Schedule;
Schedule::command('visual-feedback:prune')->daily();
Schedule::command('visual-feedback:sweep-orphans')->daily();
Without them retention.reports_days deletes nothing and orphaned files accumulate. See
Privacy and retention.
3 · Keep the attachment disk private. attachments.disk defaults to local. Screenshots
routinely contain whatever the reporter had on screen, so a public disk here is a public URL
to somebody's session — and it looks identical to a correct setup from the inside.
4 · Run a queue worker. Every channel queues its own job, so delivery happens on your
queue and not in the request the reporter submitted. A fresh Laravel application resolves
queue.default to database unless QUEUE_CONNECTION says otherwise, which means the job
is written to the jobs table and waits there. With no worker consuming it, no report is
ever delivered — and, again, the reporter sees the success screen either way.
QUEUE_CONNECTION=sync runs every delivery inline instead, which is a reasonable choice for
a small application and a bad one for a public form: the reporter then waits for your mail
transport, and a slow provider becomes a slow submit.
When the widget renders and nothing responds
The panel draws, the buttons look right, and clicking them does nothing. That shape has one usual cause and it is not a bug in your application: one of the two JavaScript bundles did not reach the page.
It used to be completely silent. The markup is rendered by the server, so the widget appears
whether or not its script arrived — and under a Content-Security-Policy without unsafe-eval
the browser runs Alpine's CSP build, where a component that was never registered leaves an
empty scope instead of raising an error. Nothing is thrown, nothing is logged, and every
control on the panel is inert. One team spent an afternoon on it and removed the widget.
Since 0.8.0 each bundle checks the other one and says so:
visual-feedback: visual-feedback.iife.js did not load, so the screenshot controls are INERT.
The message names the three ways it happens, in the order worth checking:
- The file was never published. Run
php artisan vendor:publish --tag=visual-feedback-assets. - The configured assets base URL does not serve it.
ui.assetspoints somewhere else — a CDN, a versioned build directory — and one of the two files is missing there. - An integrity digest does not match. If you set one, it is checked against the bytes actually served, and a re-publish that changed the file invalidates it.
The check only speaks when Alpine has walked the element and found no component, so it does not fire during the ordinary page load while registration is still ahead of it.
It cannot cover one case, and saying so is more useful than implying otherwise: if both
bundles are missing, none of this package's JavaScript runs and nothing in the browser can
report it. The server side covers that instead — PublishedBundle logs an error when
public/vendor/visual-feedback/ is empty — but only for a copy you serve yourself.
Publish tags
| Tag | What it writes |
|---|---|
visual-feedback | the umbrella: config, translations, views and the JS bundles |
visual-feedback-config | config/visual-feedback.php only |
visual-feedback-lang | the seven bundled locales, for editing |
visual-feedback-views | the plain Blade view tree, for restyling |
visual-feedback-assets | the compiled JS bundles into public/vendor/visual-feedback |
visual-feedback-wirekit | the WireKit view tree over the plain one (see below) |
visual-feedback-migrations | the optional reports table, for the database channel |
Publish visual-feedback-assets again after every package update — the bundles in
public/ are copies, and a stale copy is the one bug this setup can produce. You no longer
have to remember it unprompted: php artisan about carries a Published bundle line under
Visual Feedback with one of four states.
The package ships three JavaScript files, and the difference matters if you serve them yourself or list script paths in a Content-Security-Policy.
| File | Size | Requested |
|---|---|---|
visual-feedback-widget.iife.js | ~4 KB | every page the widget renders on |
visual-feedback.iife.js | ~16 KB | while screenshot.strategy is not off |
visual-feedback-renderer.iife.js | ~246 KB | only when a screenshot is actually taken |
The first registers the Alpine components the templates bind to — a page without it renders a widget whose every control silently does nothing. The second is the capture state machine. The third is the DOM renderer, and it is fetched by the second at capture time rather than by a tag, so a visitor who never opens the widget never downloads it.
That third file has to be reachable from the same base URL as the second: the loader derives its URL from the script it is running in. Publishing copies the whole directory, so this is only something to check if you upload the files by hand.
| State | Meaning |
|---|---|
current | the published copy matches the installed version |
stale | it is from an earlier release — re-run the publish with --force |
not-published | there is no copy in public/ yet — run the publish once |
served-externally | ui.assets points somewhere else, so there is nothing here to check |
With APP_DEBUG=true a stale copy also writes a warning to the log on any page that
renders the widget. The check reads the two files on the server, so it is right on the very
first request after an upgrade — anything shipped inside the bundle would be running from
the outdated copy itself.
The state is available in code too, if you would rather surface it in your own health
check: app(\Pushery\VisualFeedback\Support\PublishedBundle::class)->status() returns a
PublishedBundleStatus with exactly those four cases.
Upgrading later? Every release is described in the changelog, and anything that needs a change on your side carries an Upgrade note there. Problems and feature requests belong in the issue tracker — a security issue does not, and the security policy says where to send it instead.