Skip to main content

Configuration

Every option is documented inline in config/visual-feedback.php. The blocks:

KeyWhat it governs
enabledmaster switch
categoriesthe 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
fieldshow 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
guestsnothing 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
attachmentsdisk, directory, counts, size caps, accepted types, image bomb guards
screenshotthe capture cascade, scale, caps, redaction attribute
metadatawhich browser metadata is collected
abusethe abuse driver, rate limits, the time trap and the challenge view
privacythe privacy notice guests acknowledge
channelswhich delivery channels run, and their queue tuning
mail, webhook, databaseper-channel settings
retentionhow long reports and orphaned files are kept — only once you schedule the two commands yourself, see Privacy and retention
uitrigger surface, floating button position, asset base URL
context_providersyour 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:

RetiredSay this instead
VISUAL_FEEDBACK_FIELD_SUBJECT=falseVISUAL_FEEDBACK_FIELD_SUBJECT_MODE=off
VISUAL_FEEDBACK_FIELD_PHONE=trueVISUAL_FEEDBACK_FIELD_PHONE_MODE=optional
VISUAL_FEEDBACK_GUEST_REQUIRE_NAME=trueVISUAL_FEEDBACK_FIELD_NAME_MODE=required
VISUAL_FEEDBACK_GUEST_REQUIRE_EMAIL=trueVISUAL_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.

VariableDefaultEffect
VISUAL_FEEDBACK_ENABLEDtruemaster switch — see Turning it off
VISUAL_FEEDBACK_REQUIRE_AUTHENTICATIONfalsesign-in only: no trigger, no form and no accepted submission from a guest session — see Sign-in only
VISUAL_FEEDBACK_UI_VARIANTautowhich view tree renders: auto (WireKit when it is installed, plain otherwise), plain or wirekit
VISUAL_FEEDBACK_UI_TRIGGERfabfab, inline or none (you place the trigger)
VISUAL_FEEDBACK_UI_POSITIONbottom-rightwhich corner the floating button sits in
VISUAL_FEEDBACK_UI_FAB_ICONmessagethe 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_ASSETSpublished pathbase URL of all three files, if you serve them yourself — see the note below
VISUAL_FEEDBACK_UI_ASSETS_INTEGRITYfalseadd 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_MODEoptional, or what the retired variable saysoff, optional or required for the subject field
VISUAL_FEEDBACK_FIELD_NAME_MODEoptional, or what the retired variable sayssame three values for a guest's name
VISUAL_FEEDBACK_FIELD_EMAIL_MODEoptional, or what the retired variable sayssame three values for a guest's email
VISUAL_FEEDBACK_FIELD_PHONE_MODEoff, or what the retired variable sayssame three values for a guest's phone
VISUAL_FEEDBACK_FIELD_SUBJECT_MAX_LENGTH150ceiling for the subject, in code points. Anything unreadable keeps the shipped value rather than dropping to zero
VISUAL_FEEDBACK_FIELD_MESSAGE_MAX_LENGTH50000ceiling for the message body, in code points
VISUAL_FEEDBACK_FIELD_NAME_MAX_LENGTH150ceiling for a guest's name
VISUAL_FEEDBACK_FIELD_EMAIL_MAX_LENGTH254ceiling for a guest's address — the longest an address may be
VISUAL_FEEDBACK_FIELD_PHONE_MAX_LENGTH32ceiling for a guest's phone number
VISUAL_FEEDBACK_CONTEXT_MAX_VALUE_LENGTH2000ceiling for one collected metadata value; longer ones are truncated
VISUAL_FEEDBACK_FIELD_SUBJECTtrueretired — say VISUAL_FEEDBACK_FIELD_SUBJECT_MODE=off instead. Still read
VISUAL_FEEDBACK_FIELD_PHONEfalseretired — say VISUAL_FEEDBACK_FIELD_PHONE_MODE=optional instead. Still read
VISUAL_FEEDBACK_GUEST_REQUIRE_NAMEfalseretired — say VISUAL_FEEDBACK_FIELD_NAME_MODE=required instead. Still read
VISUAL_FEEDBACK_GUEST_REQUIRE_EMAILfalseretired — say VISUAL_FEEDBACK_FIELD_EMAIL_MODE=required instead. Still read
VISUAL_FEEDBACK_ATTACHMENTS_DISKlocalthe disk attachments are stored on — keep it private
VISUAL_FEEDBACK_ATTACHMENTS_DIRvisual-feedbackdirectory on that disk
VISUAL_FEEDBACK_ATTACHMENTS_MAX_FILES5how many files one report may carry. 0 refuses attachments entirely
VISUAL_FEEDBACK_ATTACHMENTS_MAX_FILE_SIZE5242880bytes per file — 5 MB
VISUAL_FEEDBACK_ATTACHMENTS_MAX_TOTAL_SIZE15728640bytes for all uploads of one report — 15 MB. The screenshot is bounded separately and does not count towards this
VISUAL_FEEDBACK_ATTACHMENTS_MAX_IMAGE_DIMENSION15000px on either edge, a decompression-bomb guard
VISUAL_FEEDBACK_ATTACHMENTS_MAX_IMAGE_PIXELS100000000total pixels, the second half of that guard — 100 MP
VISUAL_FEEDBACK_SCREENSHOT_STRATEGYautoauto, native, dom or off — see the capture cascade
VISUAL_FEEDBACK_SCREENSHOT_SCALE2render scale, or device for the device pixel ratio
VISUAL_FEEDBACK_SCREENSHOT_DARK_FALLBACK#111827background for a page that declares none and is dark
VISUAL_FEEDBACK_SCREENSHOT_DEBUGfalsethe renderer's own logging — off in production
VISUAL_FEEDBACK_SCREENSHOT_MAX_BYTES8388608bytes 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_DRIVERbuiltinwhich abuse gate runs
VISUAL_FEEDBACK_ABUSE_GLOBAL_RATE_LIMIT1000the 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_LIMIT30per authenticated user, per hour. 0 switches it off
VISUAL_FEEDBACK_ABUSE_GUEST_RATE_LIMIT5per guest IP, per hour — IPv6 counted per /64. 0 switches it off
VISUAL_FEEDBACK_ABUSE_MIN_FILL_SECONDS3how fast a form may be submitted before it reads as a bot. 0 switches the trap off
VISUAL_FEEDBACK_ABUSE_ON_ERRORopenwhat the builtin floor does when its own limiter errors — open lets the submission through, anything else refuses
VISUAL_FEEDBACK_ABUSE_DRIVER_ON_ERRORopenthe 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_SOURCEnoneurl, legal-consent, or a class name
VISUAL_FEEDBACK_PRIVACY_URLnoneyour policy page — required for url and for legal-consent; a class-name source supplies its own
VISUAL_FEEDBACK_PRIVACY_DOCUMENT_KEYprivacywhich legal-consent document the wording is read from
VISUAL_FEEDBACK_RETENTION_DAYSnonehow 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_AGE1440minutes 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_MAILtrueenable the mail channel
VISUAL_FEEDBACK_CHANNEL_DATABASEfalseenable the database channel
VISUAL_FEEDBACK_CHANNEL_WEBHOOKfalseenable the webhook channel
VISUAL_FEEDBACK_MAIL_TOnonewhere reports are mailed — without it the mail channel stays idle
VISUAL_FEEDBACK_MAIL_SUBJECT_EXCERPT60how 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_TRANSPORTtrueskip 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_ADDRESSapp defaultsender address
VISUAL_FEEDBACK_MAIL_FROM_NAMEAPP_NAMEsender 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_LOCALEapp localea fixed locale, or reporter for theirs
VISUAL_FEEDBACK_MAIL_CONNECTIONdefault connectionqueue connection for the mail job — the worker that carries it, not a database connection
VISUAL_FEEDBACK_MAIL_QUEUEdefault queuequeue for the mail job
VISUAL_FEEDBACK_DATABASE_CONNECTIONdefault connectionqueue connection for the database job
VISUAL_FEEDBACK_DATABASE_QUEUEdefault queuequeue for the database job
VISUAL_FEEDBACK_WEBHOOK_URLnoneendpoint for the webhook channel
VISUAL_FEEDBACK_WEBHOOK_SECRETnoneHMAC key the payload is signed with — required, or the channel is skipped
VISUAL_FEEDBACK_WEBHOOK_INCLUDE_REPORTERtrueinclude reporter details in the payload
VISUAL_FEEDBACK_WEBHOOK_CONNECTIONdefault connectionqueue connection for the webhook job
VISUAL_FEEDBACK_WEBHOOK_QUEUEdefault queuequeue for the webhook job
VISUAL_FEEDBACK_WEBHOOK_TIMEOUT5seconds 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".

SettingDefaultWhat it does
screenshot.dark_fallback_color#111827the 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_elementstrueflatten custom elements before capture
screenshot.iframe_placeholdertruedraw a same-size placeholder naming the host for a cross-origin iframe
metadata.max_value_length2000the cap the metadata sanitizer applies to one collected value
metadata.user_agent_max512the 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.