Skip to main content

Installation

Requirements

  • PHP 8.4+, with the intl, fileinfo, filter, hash, json and mbstring extensions
  • 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.

Your mailer has to be able to send, not just be configured

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:

  1. The file was never published. Run php artisan vendor:publish --tag=visual-feedback-assets.
  2. The configured assets base URL does not serve it. ui.assets points somewhere else — a CDN, a versioned build directory — and one of the two files is missing there.
  3. 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

TagWhat it writes
visual-feedbackthe umbrella: config, translations, views and the JS bundles
visual-feedback-configconfig/visual-feedback.php only
visual-feedback-langthe seven bundled locales, for editing
visual-feedback-viewsthe plain Blade view tree, for restyling
visual-feedback-assetsthe compiled JS bundles into public/vendor/visual-feedback
visual-feedback-wirekitthe WireKit view tree over the plain one (see below)
visual-feedback-migrationsthe 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.

FileSizeRequested
visual-feedback-widget.iife.js~4 KBevery page the widget renders on
visual-feedback.iife.js~16 KBwhile screenshot.strategy is not off
visual-feedback-renderer.iife.js~246 KBonly 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.

StateMeaning
currentthe published copy matches the installed version
staleit is from an earlier release — re-run the publish with --force
not-publishedthere is no copy in public/ yet — run the publish once
served-externallyui.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.