Skip to main content

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.

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 own metadata.collect allowlist 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. Publish a new version while a widget is open and the recorded version is the newer one.
  • 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:prune and visual-feedback:forget remove 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.

legal-consent 0.10 or newer

A floor, not a preference. The bridge reads the acceptance fingerprint and the tenant from the document legal-consent returns, and both arrived in 0.7 — but the floor is 0.10, because that is where ui_wording became nullable and this bridge publishes documents that carry no acceptance sentence. On 0.9 the insert fails outright. The suite now runs against the floor as well as the newest release, which is how that was found: a document that carries no acceptance sentence, which 0.10 made possible for pages that bind nobody, falls back to the built-in wording instead of failing. 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 on an older version privacy.source = legal-consent fails on a page a guest is looking at. Check the installed version before switching the source over.

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.

Data lifecycle

Three commands handle the data lifecycle:

php artisan visual-feedback:prune # drop reports past their retention window
php artisan visual-feedback:forget [email protected] # erase one person's reports (subject request)
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.

It matches reporter_email exactly, and exactness is engine-dependent. MySQL's default collation ignores case, PostgreSQL and SQLite do not — so on those two, [email protected] erases nothing while the row stored as [email protected] stays. 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 only reaches the optional reports table, which is the only store this package keeps — a mailed copy sits in your mail provider and a webhook copy sits in the receiving system, and neither command can reach either. See the Integration contract.

// routes/console.php
use Illuminate\Support\Facades\Schedule;

Schedule::command('visual-feedback:prune')->daily();
Schedule::command('visual-feedback:sweep-orphans')->daily();

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 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:forget ignores 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 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.

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.