Configuration
Every option is documented inline in config/visual-feedback.php. The blocks:
| Key | What it governs |
|---|---|
enabled | master switch |
categories | the category list offered to the reporter. The first one is the default — the picker shows it and an untouched report carries it, so order the list to choose that default |
fields | how you want each field, and how long it may be. fields.<field>.mode is off, optional or required — one word per field, and the only place that question is answered. fields.<field>.max_length caps it: subject 150, name 150, email 254, message 50 000, phone 32. The limits are counted in code points, not bytes, so message at 50 000 admits roughly 200 KB of UTF-8; size your own column accordingly if you write reports somewhere other than the shipped table. message has no mode: a feedback form without a message is not a feedback form |
guests | nothing any more. Name, email and phone are asked of guests only, and how much of it they have to give is set per field under fields above — require_name and require_email moved there as mode. Their environment variables still work. There is no key that turns guest reporting off: a widget on a public page accepts a guest report, and refusing one is your page's decision about where the widget goes |
attachments | disk, directory, counts, size caps, accepted types, image bomb guards |
screenshot | the capture cascade, scale, caps, redaction attribute |
metadata | which browser metadata is collected |
abuse | the abuse driver, rate limits, the time trap and the challenge view |
privacy | the privacy notice guests acknowledge |
channels | which delivery channels run, and their queue tuning |
mail, webhook, database | per-channel settings |
retention | how long reports and orphaned files are kept — only once you schedule the two commands yourself, see Privacy and retention |
ui | trigger surface, floating button position, asset base URL |
context_providers | your own classes that attach application context to a report — they implement ReportContextProvider, and authorizing what they expose is your duty, see Delivery channels |
Paste this into your .env
Every variable this package reads, commented out at the value it already uses, grouped by the job you are doing rather than alphabetically. Uncomment the lines you want. Nothing here is required — an install works with none of it.
# ── Is it on at all ────────────────────────────────────────────────────────────
# VISUAL_FEEDBACK_ENABLED=true
# VISUAL_FEEDBACK_REQUIRE_AUTHENTICATION=false
# ── Where reports go ───────────────────────────────────────────────────────────
# VISUAL_FEEDBACK_MAIL_TO=
# VISUAL_FEEDBACK_MAIL_FROM_ADDRESS=
# VISUAL_FEEDBACK_MAIL_FROM_NAME="${APP_NAME}"
# VISUAL_FEEDBACK_MAIL_LOCALE=
# VISUAL_FEEDBACK_MAIL_SUBJECT_EXCERPT=60
# VISUAL_FEEDBACK_MAIL_REQUIRE_DELIVERABLE_TRANSPORT=true
# VISUAL_FEEDBACK_CHANNEL_MAIL=true
# VISUAL_FEEDBACK_CHANNEL_DATABASE=false
# VISUAL_FEEDBACK_CHANNEL_WEBHOOK=false
# VISUAL_FEEDBACK_WEBHOOK_URL=
# VISUAL_FEEDBACK_WEBHOOK_SECRET=
# VISUAL_FEEDBACK_WEBHOOK_TIMEOUT=5
# VISUAL_FEEDBACK_WEBHOOK_INCLUDE_REPORTER=true
# ── Which queue carries each channel ───────────────────────────────────────────
# VISUAL_FEEDBACK_MAIL_CONNECTION=
# VISUAL_FEEDBACK_MAIL_QUEUE=
# VISUAL_FEEDBACK_DATABASE_CONNECTION=
# VISUAL_FEEDBACK_DATABASE_QUEUE=
# VISUAL_FEEDBACK_WEBHOOK_CONNECTION=
# VISUAL_FEEDBACK_WEBHOOK_QUEUE=
# ── What the form asks for: off | optional | required ──────────────────────────
# VISUAL_FEEDBACK_FIELD_SUBJECT_MODE=optional
# VISUAL_FEEDBACK_FIELD_NAME_MODE=optional
# VISUAL_FEEDBACK_FIELD_EMAIL_MODE=optional
# VISUAL_FEEDBACK_FIELD_PHONE_MODE=off
# ── How much text one report may carry ─────────────────────────────────────────
# VISUAL_FEEDBACK_FIELD_SUBJECT_MAX_LENGTH=150
# VISUAL_FEEDBACK_FIELD_MESSAGE_MAX_LENGTH=50000
# VISUAL_FEEDBACK_FIELD_NAME_MAX_LENGTH=150
# VISUAL_FEEDBACK_FIELD_EMAIL_MAX_LENGTH=254
# VISUAL_FEEDBACK_FIELD_PHONE_MAX_LENGTH=32
# VISUAL_FEEDBACK_CONTEXT_MAX_VALUE_LENGTH=2000
# ── How it looks ───────────────────────────────────────────────────────────────
# VISUAL_FEEDBACK_UI_VARIANT=auto
# VISUAL_FEEDBACK_UI_TRIGGER=fab
# VISUAL_FEEDBACK_UI_POSITION=bottom-right
# VISUAL_FEEDBACK_UI_FAB_ICON=message
# VISUAL_FEEDBACK_UI_ASSETS=
# VISUAL_FEEDBACK_UI_ASSETS_INTEGRITY=false
# ── Screenshots ────────────────────────────────────────────────────────────────
# VISUAL_FEEDBACK_SCREENSHOT_STRATEGY=auto
# VISUAL_FEEDBACK_SCREENSHOT_SCALE=2
# VISUAL_FEEDBACK_SCREENSHOT_DARK_FALLBACK=#111827
# VISUAL_FEEDBACK_SCREENSHOT_DEBUG=false
# VISUAL_FEEDBACK_SCREENSHOT_MAX_BYTES=8388608
# ── Files ──────────────────────────────────────────────────────────────────────
# VISUAL_FEEDBACK_ATTACHMENTS_DISK=local
# VISUAL_FEEDBACK_ATTACHMENTS_DIR=visual-feedback
# VISUAL_FEEDBACK_ATTACHMENTS_MAX_FILES=5
# VISUAL_FEEDBACK_ATTACHMENTS_MAX_FILE_SIZE=5242880
# VISUAL_FEEDBACK_ATTACHMENTS_MAX_TOTAL_SIZE=15728640
# VISUAL_FEEDBACK_ATTACHMENTS_MAX_IMAGE_DIMENSION=15000
# VISUAL_FEEDBACK_ATTACHMENTS_MAX_IMAGE_PIXELS=100000000
# ── Abuse, privacy, retention ──────────────────────────────────────────────────
# VISUAL_FEEDBACK_ABUSE_DRIVER=builtin
# VISUAL_FEEDBACK_ABUSE_GLOBAL_RATE_LIMIT=1000
# VISUAL_FEEDBACK_ABUSE_RATE_LIMIT=30
# VISUAL_FEEDBACK_ABUSE_GUEST_RATE_LIMIT=5
# VISUAL_FEEDBACK_ABUSE_MIN_FILL_SECONDS=3
# VISUAL_FEEDBACK_ABUSE_ON_ERROR=open
# VISUAL_FEEDBACK_ABUSE_DRIVER_ON_ERROR=open
# VISUAL_FEEDBACK_PRIVACY_SOURCE=
# VISUAL_FEEDBACK_PRIVACY_URL=
# VISUAL_FEEDBACK_PRIVACY_DOCUMENT_KEY=privacy
# VISUAL_FEEDBACK_RETENTION_DAYS=
# VISUAL_FEEDBACK_RETENTION_ORPHAN_ATTACHMENTS_MIN_AGE=1440
Four older variables are not in that block and still work, because they are published and sit in
existing .env files. Prefer the _MODE form above in anything new:
| Retired | Say this instead |
|---|---|
VISUAL_FEEDBACK_FIELD_SUBJECT=false | VISUAL_FEEDBACK_FIELD_SUBJECT_MODE=off |
VISUAL_FEEDBACK_FIELD_PHONE=true | VISUAL_FEEDBACK_FIELD_PHONE_MODE=optional |
VISUAL_FEEDBACK_GUEST_REQUIRE_NAME=true | VISUAL_FEEDBACK_FIELD_NAME_MODE=required |
VISUAL_FEEDBACK_GUEST_REQUIRE_EMAIL=true | VISUAL_FEEDBACK_FIELD_EMAIL_MODE=required |
Recipes
The table further down answers what exists. These answer what do I write to get X.
Send reports somewhere other than the default mailbox.
VISUAL_FEEDBACK_MAIL_TO=support@example.com
A single page can override that on the widget itself — <livewire:visual-feedback.report-widget recipient="billing@example.com" /> — so a docs page and a billing page can reach different teams.
The address has to be declared first. A recipient decides where a report is delivered, and the widget's state travels through the browser, so the permitted set is configuration rather than something the page asserts. It is a list, not an environment variable, so it lives in the published config file:
// config/visual-feedback.php
'mail' => [
'allowed_recipients' => ['billing@example.com'],
],
mail.to itself is always permitted, so an application that never uses the prop needs no list.
Mounting the widget with an address that is not permitted fails immediately, with a message naming
this key — that is a wiring mistake a developer can fix. An address that arrives from the browser
instead is answered with mail.to, silently and by the same rule.
Make the mail come from a name other than your application's.
VISUAL_FEEDBACK_MAIL_FROM_NAME="Acme Feedback"
VISUAL_FEEDBACK_MAIL_FROM_ADDRESS=feedback@example.com
Without the first one the sender is APP_NAME, the way Laravel's own mail config does it.
Insist on an email address, so you can reply.
VISUAL_FEEDBACK_FIELD_EMAIL_MODE=required
The box is then marked required in the markup and a submission without it is refused, in this package's own wording and in all seven shipped locales.
Ask for nothing but the report itself.
VISUAL_FEEDBACK_FIELD_NAME_MODE=off
VISUAL_FEEDBACK_FIELD_EMAIL_MODE=off
VISUAL_FEEDBACK_FIELD_SUBJECT_MODE=off
The form is then a category and a message. A field that is off is not merely hidden: a value for it is dropped on the way in, so a crafted request cannot smuggle one back.
Read rendered reports in laravel.log while developing.
MAIL_MAILER=log
VISUAL_FEEDBACK_MAIL_REQUIRE_DELIVERABLE_TRANSPORT=false
Since 0.8.0 the mail channel refuses a transport that accepts a message and drops it, because that produced a delivered receipt for a mail that never left. The second line says you mean it.
Turn screenshots off entirely.
VISUAL_FEEDBACK_SCREENSHOT_STRATEGY=off
No capture button, and the larger of the two bundles stops being shipped to the page.
Switch the widget off in one environment.
VISUAL_FEEDBACK_ENABLED=false
The widget renders nothing and the pipeline refuses every submission before it touches a rate limiter, a cache or a disk.
Environment variables
Everything below has a working default, so an install needs none of them. They exist for the settings that differ per environment — where reports go, which disk holds attachments, whether the capture asks for permission.
| Variable | Default | Effect |
|---|---|---|
VISUAL_FEEDBACK_ENABLED | true | master switch — see Turning it off |
VISUAL_FEEDBACK_REQUIRE_AUTHENTICATION | false | sign-in only: no trigger, no form and no accepted submission from a guest session — see Sign-in only |
VISUAL_FEEDBACK_UI_VARIANT | auto | which view tree renders: auto (WireKit when it is installed, plain otherwise), plain or wirekit |
VISUAL_FEEDBACK_UI_TRIGGER | fab | fab, inline or none (you place the trigger) |
VISUAL_FEEDBACK_UI_POSITION | bottom-right | which corner the floating button sits in |
VISUAL_FEEDBACK_UI_FAB_ICON | message | the glyph on the floating button in the WireKit tree: any icon alias WireKit declares, plus for the old one. A name WireKit does not know, or a WireKit older than 2.27, keeps the plus |
VISUAL_FEEDBACK_UI_ASSETS | published path | base URL of all three files, if you serve them yourself — see the note below |
VISUAL_FEEDBACK_UI_ASSETS_INTEGRITY | false | add a Subresource Integrity digest to both script tags — only meaningful with VISUAL_FEEDBACK_UI_ASSETS, and it can refuse the page: see the note below |
VISUAL_FEEDBACK_FIELD_SUBJECT_MODE | optional, or what the retired variable says | off, optional or required for the subject field |
VISUAL_FEEDBACK_FIELD_NAME_MODE | optional, or what the retired variable says | same three values for a guest's name |
VISUAL_FEEDBACK_FIELD_EMAIL_MODE | optional, or what the retired variable says | same three values for a guest's email |
VISUAL_FEEDBACK_FIELD_PHONE_MODE | off, or what the retired variable says | same three values for a guest's phone |
VISUAL_FEEDBACK_FIELD_SUBJECT_MAX_LENGTH | 150 | ceiling for the subject, in code points. Anything unreadable keeps the shipped value rather than dropping to zero |
VISUAL_FEEDBACK_FIELD_MESSAGE_MAX_LENGTH | 50000 | ceiling for the message body, in code points |
VISUAL_FEEDBACK_FIELD_NAME_MAX_LENGTH | 150 | ceiling for a guest's name |
VISUAL_FEEDBACK_FIELD_EMAIL_MAX_LENGTH | 254 | ceiling for a guest's address — the longest an address may be |
VISUAL_FEEDBACK_FIELD_PHONE_MAX_LENGTH | 32 | ceiling for a guest's phone number |
VISUAL_FEEDBACK_CONTEXT_MAX_VALUE_LENGTH | 2000 | ceiling for one collected metadata value; longer ones are truncated |
VISUAL_FEEDBACK_FIELD_SUBJECT | true | retired — say VISUAL_FEEDBACK_FIELD_SUBJECT_MODE=off instead. Still read |
VISUAL_FEEDBACK_FIELD_PHONE | false | retired — say VISUAL_FEEDBACK_FIELD_PHONE_MODE=optional instead. Still read |
VISUAL_FEEDBACK_GUEST_REQUIRE_NAME | false | retired — say VISUAL_FEEDBACK_FIELD_NAME_MODE=required instead. Still read |
VISUAL_FEEDBACK_GUEST_REQUIRE_EMAIL | false | retired — say VISUAL_FEEDBACK_FIELD_EMAIL_MODE=required instead. Still read |
VISUAL_FEEDBACK_ATTACHMENTS_DISK | local | the disk attachments are stored on — keep it private |
VISUAL_FEEDBACK_ATTACHMENTS_DIR | visual-feedback | directory on that disk |
VISUAL_FEEDBACK_ATTACHMENTS_MAX_FILES | 5 | how many files one report may carry. 0 refuses attachments entirely |
VISUAL_FEEDBACK_ATTACHMENTS_MAX_FILE_SIZE | 5242880 | bytes per file — 5 MB |
VISUAL_FEEDBACK_ATTACHMENTS_MAX_TOTAL_SIZE | 15728640 | bytes for all uploads of one report — 15 MB. The screenshot is bounded separately and does not count towards this |
VISUAL_FEEDBACK_ATTACHMENTS_MAX_IMAGE_DIMENSION | 15000 | px on either edge, a decompression-bomb guard |
VISUAL_FEEDBACK_ATTACHMENTS_MAX_IMAGE_PIXELS | 100000000 | total pixels, the second half of that guard — 100 MP |
VISUAL_FEEDBACK_SCREENSHOT_STRATEGY | auto | auto, native, dom or off — see the capture cascade |
VISUAL_FEEDBACK_SCREENSHOT_SCALE | 2 | render scale, or device for the device pixel ratio |
VISUAL_FEEDBACK_SCREENSHOT_DARK_FALLBACK | #111827 | background for a page that declares none and is dark |
VISUAL_FEEDBACK_SCREENSHOT_DEBUG | false | the renderer's own logging — off in production |
VISUAL_FEEDBACK_SCREENSHOT_MAX_BYTES | 8388608 | bytes for the capture — 8 MB. A SERVER-side cap: the browser is not told, so a larger capture is uploaded and then refused. Lower it and a reporter can loop |
VISUAL_FEEDBACK_ABUSE_DRIVER | builtin | which abuse gate runs |
VISUAL_FEEDBACK_ABUSE_GLOBAL_RATE_LIMIT | 1000 | the ceiling for the whole application, per hour. 0 switches it off; anything unreadable keeps the shipped cap rather than removing it |
VISUAL_FEEDBACK_ABUSE_RATE_LIMIT | 30 | per authenticated user, per hour. 0 switches it off |
VISUAL_FEEDBACK_ABUSE_GUEST_RATE_LIMIT | 5 | per guest IP, per hour — IPv6 counted per /64. 0 switches it off |
VISUAL_FEEDBACK_ABUSE_MIN_FILL_SECONDS | 3 | how fast a form may be submitted before it reads as a bot. 0 switches the trap off |
VISUAL_FEEDBACK_ABUSE_ON_ERROR | open | what the builtin floor does when its own limiter errors — open lets the submission through, anything else refuses |
VISUAL_FEEDBACK_ABUSE_DRIVER_ON_ERROR | open | the same question for the additional driver, when abuse.drivers.<name>.on_error says nothing about it. That map is keyed by a name you choose and no environment variable can express a map, so this is how a host that does not publish the configuration hardens the one driver it runs |
VISUAL_FEEDBACK_PRIVACY_SOURCE | none | url, legal-consent, or a class name |
VISUAL_FEEDBACK_PRIVACY_URL | none | your policy page — required for url and for legal-consent; a class-name source supplies its own |
VISUAL_FEEDBACK_PRIVACY_DOCUMENT_KEY | privacy | which legal-consent document the wording is read from |
VISUAL_FEEDBACK_RETENTION_DAYS | none | how long a stored report is kept before visual-feedback:prune removes it. The package schedules nothing, so this deletes nothing until you schedule that command — see Privacy and retention |
VISUAL_FEEDBACK_RETENTION_ORPHAN_ATTACHMENTS_MIN_AGE | 1440 | minutes an unattached upload survives before pruning removes it — 24 h. Keep it above your queue's retry horizon, or a retry will look for a file that is gone |
VISUAL_FEEDBACK_CHANNEL_MAIL | true | enable the mail channel |
VISUAL_FEEDBACK_CHANNEL_DATABASE | false | enable the database channel |
VISUAL_FEEDBACK_CHANNEL_WEBHOOK | false | enable the webhook channel |
VISUAL_FEEDBACK_MAIL_TO | none | where reports are mailed — without it the mail channel stays idle |
VISUAL_FEEDBACK_MAIL_SUBJECT_EXCERPT | 60 | how much of the message goes into the subject line when the reporter left the subject empty. 0 keeps the category alone |
VISUAL_FEEDBACK_MAIL_REQUIRE_DELIVERABLE_TRANSPORT | true | skip the mail channel when MAIL_MAILER resolves to a transport that accepts a message and drops it (log, array, null, or a failover chain that can fall through to one). Never applied while your application runs its tests |
VISUAL_FEEDBACK_MAIL_FROM_ADDRESS | app default | sender address |
VISUAL_FEEDBACK_MAIL_FROM_NAME | APP_NAME | sender name — without it the From header would be a bare address, which reads like an unattended relay rather than like your product |
VISUAL_FEEDBACK_MAIL_LOCALE | app locale | a fixed locale, or reporter for theirs |
VISUAL_FEEDBACK_MAIL_CONNECTION | default connection | queue connection for the mail job — the worker that carries it, not a database connection |
VISUAL_FEEDBACK_MAIL_QUEUE | default queue | queue for the mail job |
VISUAL_FEEDBACK_DATABASE_CONNECTION | default connection | queue connection for the database job |
VISUAL_FEEDBACK_DATABASE_QUEUE | default queue | queue for the database job |
VISUAL_FEEDBACK_WEBHOOK_URL | none | endpoint for the webhook channel |
VISUAL_FEEDBACK_WEBHOOK_SECRET | none | HMAC key the payload is signed with — required, or the channel is skipped |
VISUAL_FEEDBACK_WEBHOOK_INCLUDE_REPORTER | true | include reporter details in the payload |
VISUAL_FEEDBACK_WEBHOOK_CONNECTION | default connection | queue connection for the webhook job |
VISUAL_FEEDBACK_WEBHOOK_QUEUE | default queue | queue for the webhook job |
VISUAL_FEEDBACK_WEBHOOK_TIMEOUT | 5 | seconds before the delivery attempt is abandoned. Never 0 — most clients read that as wait forever |
Settings that live only in the config file
Most of what you will change has an environment variable. A handful does not, either because it is a value rather than a switch or because it is fine-tuning nobody reaches for twice. They are listed here so that "not in the table above" never has to mean "does not exist".
| Setting | Default | What it does |
|---|---|---|
screenshot.dark_fallback_color | #111827 | the color the DOM stage paints behind a page whose background is transparent while color-scheme is dark. Any background your page actually sets wins through the normal cascade; this is only the last resort |
screenshot.flatten_custom_elements | true | flatten custom elements before capture |
screenshot.iframe_placeholder | true | draw a same-size placeholder naming the host for a cross-origin iframe |
metadata.max_value_length | 2000 | the cap the metadata sanitizer applies to one collected value |
metadata.user_agent_max | 512 | the cap for the user-agent string specifically, which is the one that gets long |
Both screenshot booleans are real switches and do exactly what the table says: the
configuration is handed to the capture bundle, which skips the corresponding pass whenever one
of them is false. Open <dialog> elements are hidden unconditionally and need no key.
An <img src="*.svg"> is not fetched and inlined. It goes through the renderer's ordinary
image path, so a cross-origin SVG served without CORS headers is missing from the capture like
any other cross-origin image, and one served from your own origin is captured like any other
image. That is worth stating because an inlining pass is the thing people assume, and assuming
it hides a CORS problem behind a file format.
The attachments caps and the mail and webhook switches also have no environment variable,
and they are documented where the decision gets made rather than here — see
Integration contract and
Delivery channels.
Serving the bundles yourself
There are three files, and the base URL has to serve all of them.
visual-feedback-widget.iife.js is the small one that registers the Alpine components the
templates bind to, and it is requested on every page the widget renders on.
visual-feedback.iife.js is the capture state machine, requested only while
screenshot.strategy is not off. visual-feedback-renderer.iife.js is the DOM renderer —
about 246 KB, and fetched by the second file at capture time, so a visitor who never takes a
screenshot never downloads it.
The renderer's URL is derived from the script that loads it, so it has to sit beside
visual-feedback.iife.js under the same base. Upload all three, or the capture button reaches
the point of taking the picture and reports a failure.
Upload only the capture bundle and the widget's own file 404s — which does not look like a
missing file from the outside. The components are simply never registered, so every control in
the widget silently does nothing. And nothing reports it: setting ui.assets tells the
package the bundles are served externally, so the staleness check that php artisan about runs
against the published copy is skipped by design. Verify all three URLs yourself after uploading; the
package cannot reach them.
VISUAL_FEEDBACK_UI_ASSETS takes a base URL, without a query string. Each rendered <script>
tag appends a cache-busting ?id= token of its own, derived from the installed package version,
so the URL in your page source is not byte-identical to the one you configured. That is expected
and nothing to correct — a Content-Security-Policy script-src source expression matches on the
path and ignores the query, so nothing needs changing there either.
The token solves one of the two ways a bundle goes wrong, and it is worth knowing which. It
handles republished but cached: after vendor:publish --force replaces the bytes on disk, the
URL changes, so a browser or CDN holding an old file under a long max-age fetches the new one.
It does not handle forgot to republish. If the copy under public/vendor/visual-feedback/
is older than the one in vendor/, the token busts the cache and loads the same stale file again.
Republishing after every upgrade of this package is still the only thing that prevents it — see
Installation.
Verifying what the foreign origin sends
VISUAL_FEEDBACK_UI_ASSETS_INTEGRITY=true adds a Subresource Integrity digest and
crossorigin="anonymous" to both script tags. The digest is sha384 over the bundles inside
the installed package, which is what makes it worth anything: it is the value a divergence is
measured against, so taking it from the copy under suspicion would prove nothing.
All three files are covered, including the one no tag loads. The renderer is appended by the
capture bundle rather than rendered as a tag, so it cannot carry an attribute of its own; its
digest travels to it on the capture tag instead and is applied when the renderer is fetched. That
means the foreign origin has to allow CORS for the renderer as well, because a digest is only
checked on a request made with crossorigin.
It does nothing while the bundles come from your own public/ — those are same-origin already,
and the digest would be checking a file against itself. It applies only where ui.assets points
at an origin you do not serve.
It is off by default because it can refuse the page, and that trade is yours to make. Turning it on is a statement that the origin mirrors this release's bundles byte for byte. A CDN that re-minifies, or that is still carrying the previous version, fails the check — the browser drops the script, and a widget whose bundle never loaded renders perfectly and does nothing. That failure is silent in exactly the way this package works hardest to prevent everywhere else, so it is not something to acquire by upgrading.
Turn it on after an upload, then reload the page once and check the console. If the bundle was refused the browser says so there, by name.