Privacy and retention
Guests can be asked to acknowledge a privacy notice before submitting; set
privacy.source to url and give privacy.url your policy page. The wording is
deliberately acknowledgment, not consent — consent is a different legal act and needs
recording, which this package does not do on its own.
The legal-consent bridge
privacy.source = legal-consent puts the sentence pushery/legal-consent actually
published on that checkbox, in the reporter's locale, instead of this package's own line —
so the text a guest reads is the text your legal documents say, and it changes when you
publish a new version. Pick the document with privacy.document_key (default privacy).
privacy.url stays required with this source. legal-consent registers no public route
that displays a document — deliberately, since the page belongs to your app — and a
required checkbox whose full text cannot be opened is not an informed acknowledgment. So
this source improves the label and never decides whether a notice is needed; that stays
your URL's job.
It never writes to legal-consent's ledger — that API takes a model subject on every call and a guest has none. What it does record, with the report, is which published document the acknowledgment belongs to: key, locale, version, and legal-consent's own acceptance fingerprint (a hash over the document body and its wording; the bare content hash would cover the body while the guest reads the wording).
Four things about that record, because a provenance trail that is misread is worse than none:
- It is server-side and unforgeable. The keys are reserved (
privacy_notice_*), written after metadata sanitization from a server-side read, and stripped from client input unconditionally — including when your ownmetadata.collectallowlist names them. - It is read at submit, not at render. Component state travels through the browser, and provenance the client could hand back is not provenance. The answer is kept for a minute (see below), so a submit records the version the widget showed, and the newer one when you published it and that minute has passed since the answer was read.
- It records what was DISPLAYED, not a consent. The guest acknowledged a notice; they did not give consent under Art. 6(1)(a), and nothing here should be presented as one.
- It lives and dies with the report.
visual-feedback:pruneandvisual-feedback:forgetremove it along with everything else, and there is no chain that would reveal a gap. Treat it as provenance with the report's lifetime, not as a durable legal proof — for that, the reporter has to be authenticated and the record belongs in legal-consent's own ledger.
The bridge refuses rather than improvises, and every refusal falls back to this package's own sentence with a line in the log — never to another locale's text, and never to leaving the checkbox out. It refuses when the document is not a privacy notice, when its notice mode gates (a passive tick cannot express "the old text applies until you agree"), when the published sentence is empty, and when the table is missing. A locale with nothing published is not a refusal but the ordinary case: this package ships seven locales and legal-consent publishes two by default.
The sentence comes from legal-consent's cache, not from a read per render. The widget renders on every page that carries it and again on every round trip. The bridge asks legal-consent for the published version without its text, which legal-consent serves from its own cache of the active versions and drops when a version is published, so a version you publish reaches the checkbox at once. A refusal and a locale with nothing published are logged at most once a minute per tenant, document key and locale.
A floor, not a preference. The bridge reads the published version through
ConsentManager::publishedVersion(), which arrived in 0.44, 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 rather than failing the page. Composer will not stop you —
pushery/legal-consent-for-laravel is a suggest, because a package that does not use this bridge
must not drag a consent ledger in — so check the installed version before switching the source
over. The suite runs against the floor as well as the newest release.
Multi-tenancy is served, not refused — from legal-consent 0.7.0. Before it, this source declined to read at all whenever tenancy was enabled, because the published-document read path did not scope by tenant and there was no way to tell whose sentence had come back; a tenant installation therefore never saw the text it had configured. 0.7.0 confines reads to the current tenant and puts the tenant on the returned document, so each tenant now gets its own sentence. The one remaining refusal is a document that reports a different tenant than the active one — unreachable through the supported path, and kept because the failure it guards against (a guest reading another tenant's legal text) is silent.
What the widget collects
This is the list, because you need it to write your own privacy notice and reading config/visual-feedback.php to get it is work this page should have saved you. Everything here is the shipped default of metadata.collect; it is an allowlist, so removing an entry stops that field being stored, and the widget keeps working without it.
| Field | What it is | Where it comes from |
|---|---|---|
url | the page the report was written on, without its fragment, its query or a token in its path (see below) | location.origin, location.pathname and location.search |
title | that page's title | document.title |
user_agent | the browser and platform string | the request's own header, not the page. A client can spoof its UA, the transport cannot, so the server overwrites whatever arrived |
viewport_width | width of the visible area | window.innerWidth |
viewport_height | height of the visible area | window.innerHeight |
screen_width | width of the whole screen | window.screen.width |
screen_height | height of the whole screen | window.screen.height |
device_pixel_ratio | how many device pixels make one CSS pixel | window.devicePixelRatio |
scroll_y | how far down the page the reporter was | window.scrollY |
language | the browser's preferred language | navigator.language |
languages | the full preference list, comma separated | navigator.languages |
timezone | the IANA zone, e.g. Europe/Vienna | Intl.DateTimeFormat().resolvedOptions().timeZone |
platform | the operating system as the browser reports it | navigator.platform |
touch | whether this is a touch device | ontouchstart or navigator.maxTouchPoints |
online | whether the browser thought it was online | navigator.onLine |
cookies_enabled | whether cookies are enabled | navigator.cookieEnabled |
dark_mode | whether the page was rendered dark | .dark or .light on <html>, and the OS preference only when neither is set |
Measured fresh at open and again at submit, never frozen at page load — a resize or a scroll in between would otherwise describe a page the reporter is no longer looking at.
Say it plainly to your own users, because together this is identifying. No single line above is unusual, and user_agent, platform, timezone, languages and the two screen_* values are also the ingredients of a browser fingerprint. That is not an accident of this package — it is the context that makes a bug report reproducible — but it is a choice you are making on your users' behalf, and metadata.collect is where you narrow it.
Who reported it is not part of that list. A guest's name, email and phone come from the form, where they gave them. For a signed-in member the report carries the id the auth guard returns and the name and email attributes of that user, read from the user itself: $hidden and $visible shape your model's JSON, not this, so a model that hides email from its responses still puts the address on the report. Bind your own ResolvesReporter to record something else.
The page address keeps its path, not its secrets
url is the report's subject, so its path is kept: it says where the problem is. What can be
somebody's credential is not kept:
- The fragment never leaves the browser. A browser never sends it to a server, and an implicit OAuth flow or a magic link puts its token there.
- The query is dropped unless you set
metadata.url_querytotrue. A signed URL, an?email=and a search term live there. - User info in the address is dropped.
- A route whose path is the token is masked, so a report filed from a password reset page is
stored with
https://app.test/reset-password/{token}.metadata.url_token_pathslists the routes. The default covers Laravel's password reset and email verification, Laravel UI's password reset, Jetstream's team invitations and pushery's magic links, and a route matches wherever it sits in the path, behind a locale prefix too. Add your own application's token routes, with*for each segment to mask:
// config/visual-feedback.php
use Pushery\VisualFeedback\Metadata\MetadataSanitizer;
'metadata' => [
'url_token_paths' => [...MetadataSanitizer::TOKEN_PATHS, 'invitations/*/accept'],
],
The referrer is deliberately not in that list
document.referrer is not collected, and the widget cuts it to scheme and host before it is even sent.
The reason is worth knowing, because the obvious assumption points the other way. Under Referrer-Policy: strict-origin-when-cross-origin — the browsers' default — a same-origin navigation still hands over the full URL including its path; the "strict origin" half is about the cross-origin case. So the referrer on the page after a password reset is a working reset-password/{token}, and the same goes for team invitations, magic links and signed verification URLs. It is the one field in this area that can carry another system's secret.
Adding referrer back to your own metadata.collect gives you the origin, not the path.
Data lifecycle
Three commands handle the data lifecycle:
php artisan visual-feedback:prune # drop reports past their retention window
php artisan visual-feedback:forget --reporter=42 # erase a signed-in member's reports, by id
php artisan visual-feedback:sweep-orphans # remove files no report references any more
You have to schedule them
The package registers no schedule, and nothing runs on its own. That is deliberate — a
package does not get to write entries into your application's scheduler — but it means
retention.reports_days is inert until you add them yourself. Setting
VISUAL_FEEDBACK_RETENTION_DAYS on its own deletes nothing.
Two of the three belong on a schedule. visual-feedback:forget does not: it answers a
specific person's erasure request and is run by hand.
For a signed-in member, erase by id rather than by address. Addresses move between accounts. A member who changed address has reports under the old one, and a new account can register with an address somebody else used before. Erasing by address alone misses the first and takes the second account's signed-in reports with it. The package stores the id of a signed-in reporter, so the command can use it:
php artisan visual-feedback:forget --reporter=42 # every report of member 42, under any address
php artisan visual-feedback:forget --reporter=42 --guest-email=[email protected] # ... and the guest reports under that address
php artisan visual-feedback:forget [email protected] --guests-only # only the guest reports under an address
The id is matched exactly, as your guard reports it. A guest has no id, so the reports somebody
sent before signing in are reachable only through the address they gave, which is what
--guest-email is for. The command prints one count for the member's reports and one for the
guest reports, and refuses a call that names nobody, or an address and an id at once.
With the reports table in your database, the erasure by id uses an index on reporter_id, which
the second migration in the visual-feedback-migrations tag adds. If you published that tag
when it carried only the first, copy
0001_01_01_000001_add_reporter_id_index_to_visual_feedback_reports_table.php from
vendor/pushery/visual-feedback-for-laravel/database/migrations/optional/ into
database/migrations/ with today's date in place of 0001_01_01_000001, and run
php artisan migrate. Publishing the tag again would write every migration of it a second time
under a new date where database.migrations.update_date_on_publish is on, as it is in the
Laravel skeleton since version 11.
It matches reporter_email exactly, except that MySQL ignores case. MySQL's default
collations ignore case, PostgreSQL and SQLite do not, so on those two [email protected]
erases nothing while the row stored as [email protected] stays. No engine matches across
accents or spelled-out letters: the same MySQL collations take josé@example.com for
[email protected] and straß[email protected] for [email protected], and the command erases a
report only when its address equals the requested one up to case, so another person's reports
never go with it. The command prints how many reports it erased, and a count of zero is worth
reading rather than assuming: use the spelling the report was submitted with.
It also stops the deliveries that are still on their way. A report this person sent shortly
before the request may still be in your queue, backing off after a failed attempt, or sitting in
the failed jobs where somebody could retry it. Every delivery job carries the whole report, so
any of them would store, mail or post it again after the command reported the erasure. The
command records what it erased, with or without the reports table: the address, the guest
reports under an address, or the id of a signed-in member. Each delivery job checks that record
first: a report submitted before the erasure is withheld, and its files go with it. The record is
a SHA-256 of the address or the id, an address compared without case, kept in your default cache
store for retention.reports_days, ninety days when it is unset, the same window as the delivery
receipts. A report the person sends after the erasure is delivered as usual.
A withheld delivery settles as failed, so ReportDeliveryFailed fires for it, with
Pushery\VisualFeedback\Privacy\ReporterWasErased as its exceptionClass. Filter on that class
if you alert on delivery failures.
What it cannot reach, it names in its output, every time it runs:
- a mailed copy sits in your mail provider, and a webhook copy sits in the receiving system;
- a delivery that failed for good keeps the report in the queue's failed jobs until somebody
removes it, with
php artisan queue:failedto list andphp artisan queue:forgetto remove. The delivery jobs are queued encrypted with your application key, so that copy is not readable as stored; - with the webhooks platform (
pushery/webhooks-for-laravel) installed, its delivery log keeps every payload it delivered for the platform's own retention period. The payload names the reporter unless you setwebhook.include_reportertofalse.
See the Integration contract.
// 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();
withoutOverlapping() keeps a slow run from starting a second time, and onOneServer() keeps
several application servers from running the same command at once. Both need a cache store with
atomic locks, such as database, redis or memcached.
Daily is the right cadence for both: retention.reports_days is counted in days, and the
sweep only touches files older than retention.orphan_attachments_min_age (24 hours by
default, deliberately past the queue's retry horizon so it can never delete a file a pending
delivery still needs).
Both are safe to schedule before you have decided on a retention window. With
reports_days unset, visual-feedback:prune deletes nothing and exits cleanly.
The smallest window is one day. A value under that, 0, a negative number or a fraction such
as 0.5, would put the cutoff at the moment of the run and delete every report, so the command
refuses it: it names the value, deletes nothing and exits with a failure, which your scheduler's
log shows.
The window is a minimum, not a maximum
retention.prune_delivered_only ships true, and it is worth knowing before you write a
deletion policy around a number. It holds a report back while one of its channels has not settled
— the point being that a report should not be deleted out from under a delivery that is still in
flight. A report whose webhook is mid-retry survives the sweep that would otherwise have taken it,
and is collected on the next run once the delivery lands.
So reports_days is the age at which a report becomes eligible for deletion, not the age at
which it is guaranteed gone. The gap is normally seconds. It is not bounded: a queue nobody
consumes, a queue:flush, or a job lost in an incident leaves a receipt pending, and the report
with it.
Two things make that manageable:
- The row's own database channel is deliberately excluded from the test, so the table does not hold back every one of its own rows. Only a sibling channel that has not settled does.
visual-feedback:forgetignores the switch entirely. A subject erasure request is answered whatever the delivery state is, which is the behavior that has to be unconditional.
Set it to false with VISUAL_FEEDBACK_RETENTION_PRUNE_DELIVERED_ONLY=false if your policy needs
the window to be a hard ceiling. The cost is real and
should be weighed rather than assumed away: a report can then be deleted while a delivery is
still being retried, and that delivery simply never arrives.
The sweep is not optional even if you never set a retention window, and this is the part
that is easy to skip. The ordinary attachment cleanup runs off a cache-backed reference
count, and a cache is allowed to lose entries — a php artisan cache:clear on deploy is
enough. When that happens the files stay on the disk with nothing pointing at them, and the
age-based sweep is the only thing that ever collects them. It works without the optional
reports table, on age alone, so it matters to mail-only and webhook-only installations too.
Attachments live on the disk you configure. Use a private disk. Screenshots routinely contain whatever the reporter had on screen, which is exactly the material you do not want on a public URL.
A reporter can crop and mark a capture in the screenshot editor before attaching it. The picture
they apply replaces the capture: only that picture is uploaded, and the capture as it was taken
never leaves their browser, so what lay outside the crop is stored nowhere. The report then
carries screenshot_edited: true in its technical details, beside capture_method. Both are
the browser's statement about the attached picture, both are added only when a screenshot is
attached, and neither is part of metadata.collect.
What happens to the reporter's IP address
No raw IP is ever stored, and none reaches the report, its metadata, the database or any
delivery channel — the optional migration ships no ip_address column at all.
The address is read at submit, for one purpose: the guest rate limit keys on a SHA-256 of it, which lives in your cache for the length of the window and nowhere else. That is written out rather than left implicit because you may need it for your own processing record — a hashed IP is pseudonymous, not anonymous, and the IPv4 space is small enough to walk.
Nothing here is a retention question: the key expires with the rate-limit window, so neither
visual-feedback:prune nor visual-feedback:forget has anything to reach.