Skip to main content

Delivery channels

A report is handed to every enabled channel independently: one failing channel never stops another, and each has its own queue connection, queue, retry count and backoff.

Every channel queues its job, so nothing is delivered in the request the reporter submitted — and nothing is delivered at all until a worker consumes the queue. On the database connection a fresh Laravel application resolves to, the job simply waits in the jobs table while the widget goes on reporting success. See Installation.

  • Mail (on by default) — a formatted report to your maintainer address, with the screenshot and attachments included and Reply-To set to the reporter. The body names who reported it — name, guest or signed-in member, email and phone where they gave them, the submission time and the widget mode — which matters most in exactly this configuration: with the database channel off, the mail is the only copy of the report that exists. Both of those are defaults with a switch behind them, which is worth knowing before you work around them: mail.attach_files (true) decides whether the files ride along or the mail carries only the text, and mail.reply_to_reporter (true) decides whether replying goes to the reporter or to your own sender address. Turning the first off is the usual answer to a mailbox quota or a scanner that quarantines attachments — the files stay on the disk either way, so nothing is lost by it.

  • Database (opt-in) — publish visual-feedback-migrations and migrate. Reports stored here are kept until you prune them — and pruning has one condition worth reading before you build a deletion policy on it, in Privacy and retention. The table and its columns are public API — there is no Eloquent model, deliberately, so nothing here decides what yours should be called or which traits it carries. Point your own model at the table, or query it directly; both are supported and the recipe below uses the query builder.

  • Webhook (opt-in) — a minimized, HMAC-signed payload. It carries no file paths and no binaries. If pushery/webhooks-for-laravel 2.0 or newer is installed the payload goes through it; otherwise the package posts it itself to the configured URL.

    Posting it itself needs both a url and a secret, and gives up after webhook.timeout seconds (5 by default) — the queue then retries the job, so a slow receiver costs a retry rather than a lost report. Without a secret the channel reports itself unavailable and is skipped, with a line in the log — because an empty HMAC key does not produce a broken signature, it produces one anybody who knows your endpoint URL can reproduce. If your receiver ignores the signature header entirely — Zapier, n8n, a Slack incoming webhook, anything that authenticates by unguessable URL — then any string satisfies this; set one and move on.

    The version matters and the failure is quiet. That package renamed its facade in 2.0, and this bridge looks the class up by its 2.0 name — so with 1.x installed the bridge simply does not see it. Nothing is logged and nothing throws: the report falls back to being posted directly, which is correct only if you set both a url and a secret. With either missing, the channel reports itself unavailable and is skipped — no job is queued and nothing fails, so the only trace is an enabled channel reported itself unavailable and was skipped in the log. Either upgrade to 2.0, or configure a url and a secret.

Verifying a webhook

The request carries three headers:

HeaderWhat it is
X-Visual-Feedback-Signaturehex HMAC-SHA256, keyed with your secret
X-Visual-Feedback-TimestampUnix timestamp, and part of the signed input
X-Visual-Feedback-Idthe report UUID — your idempotency key

The signed input is {timestamp}.{rawBody}, not the body on its own. The timestamp is inside the MAC so a captured request cannot be replayed later with its own header rewritten, and you must recompute over the raw bytes you received: re-encoding the decoded JSON changes key order and whitespace, and the MAC will not match.

$secret = config('services.visual_feedback.secret');

// Check the key BEFORE using it. hash_hmac() returns the same digest for a null key and an
// empty one, so an unconfigured secret does not break verification — it makes it accept
// anything a stranger who knows this URL can compute.
abort_unless(is_string($secret) && $secret !== '', 500);

$raw = $request->getContent();
$timestamp = $request->header('X-Visual-Feedback-Timestamp', '');
$expected = hash_hmac('sha256', $timestamp.'.'.$raw, $secret);

// hash_equals, never ===: a plain comparison returns early on the first differing byte, and
// the timing tells an attacker how much of a forged signature was right.
abort_unless(hash_equals($expected, (string) $request->header('X-Visual-Feedback-Signature')), 403);

// Reject anything older than your tolerance, or the replay protection above buys nothing.
abort_if(abs(time() - (int) $timestamp) > 300, 403);

Write your own channel by implementing Pushery\VisualFeedback\Contracts\ReportChannel — three methods: key(): string, isAvailable(): bool and dispatch(Report $report): void. Register the factory in a service provider's boot():

use Pushery\VisualFeedback\Facades\VisualFeedback;

VisualFeedback::extend('slack', fn () => new SlackReportChannel());

Then enable it in config/visual-feedback.php. A channel is enabled by its enabled key, not by a bare boolean — every channel is an array so it gets its own queue tuning, and 'slack' => true reads as "no enabled key", which means off:

'channels' => [
// …
'slack' => [
'enabled' => true,
],
],

enabled is the only key the package reads for a channel of yours. The connection, queue, tries and backoff entries next to it belong to the built-in channels, which read their own block when they build their own job — your dispatch() decides how, or whether, it queues.

If your channel STORES the report, implement RetainsReport

use Pushery\VisualFeedback\Contracts\ReportChannel;
use Pushery\VisualFeedback\Contracts\RetainsReport;

final class ArchiveReportChannel implements ReportChannel, RetainsReport
{
// …
}

RetainsReport is a marker with no methods, and skipping it costs you the attachments.

The package refcounts a report's files against its channels. A transient channel — mail, a webhook — needs the screenshot only long enough to send it, so once the last transient delivery settles, the files are deleted. That is the right default: nothing should keep a stranger's screenshot on disk for no reason.

A channel that stores the report for someone to open later is not transient, and the marker is how the package can tell. Implement it and the automatic delete is switched off for that report entirely — the files then belong to retention, which removes the row and its files together when the report ages out. The built-in database channel is the example.

Get this wrong and nothing fails: the delivery succeeds, the row is written, and the screenshot is gone by the time anybody opens it.

Other seams

Two more interfaces a host can implement, both resolved from the container:

  • ResolvesReporter — who the reporter is. The default reads the auth guard; bind your own to enrich the Reporter from team, tenant or account context. The guest form fields are passed in for the unauthenticated path.
  • PrivacyNoticeSource — where the guest privacy notice comes from. This is the contract behind the FQCN form of privacy.source: it returns the URL a guest acknowledges. PrivacyNoticeWordingSource extends it for a source that can supply the acknowledgment sentence as well as a link — which is what the legal-consent bridge uses, because that package deliberately has no notice URL to give. Supplying a wording is a capability rather than a duty, which is why it is a second interface: a host with nothing but a URL implements the base one and is done.

Attaching your own context

ReportContextProvider is what fills config('visual-feedback.context_providers'). It has one method, and every provider registered there runs on every report:

use Pushery\VisualFeedback\Contracts\ReportContextProvider;
use Pushery\VisualFeedback\Data\ReportContextEntry;

final class TenantContext implements ReportContextProvider
{
/** @return list<ReportContextEntry> */
public function entries(): array
{
return [new ReportContextEntry(
key: 'tenant',
label: 'Tenant',
value: (string) auth()->user()?->tenant_id,
)];
}
}

⚠️ Authorizing what you expose is YOUR duty, and it has no second line of defense. A report travels to a mailbox, a webhook and possibly a database row, so an entry naming something the current user may not see is a leak with a long tail. The package cannot judge that for you: it does not know what your entries mean.

What it does guarantee is that there is only ONE way in. The context path is server-side only — no client-callable action sets context — so entries() is the single trusted source. That is deliberate and it is a fix rather than a design note: there used to be two context-setting paths, one authorized and one not.

Per-instance context is a different thing and does not go through this interface: pass it as mount props on the widget.

The registration and the config key are two halves of one switch: registering alone delivers nothing, and enabling a key nobody registered does nothing at all. isAvailable() is the third — return false while the channel's own settings are incomplete and it is skipped with a line in the log, exactly as the built-in ones are.

Events

Events are dispatched around the whole lifecycle, so you can hook in without touching a channel. They all sit in the package's Events namespace. See Testing for asserting on them.

EventCarriesDispatched
ReportSubmitting$report — and it can be vetoed, see belowbefore anything is stored or delivered
ReportSubmitted$report, the whole thingafter the report is accepted
ReportRejected$reason (a RejectionReason), $detail (nullable)when a submission is refused
ReportDelivered$reportUuid, $channelper channel, on success
ReportDeliveryFailed$reportUuid, $channel, $exceptionClass, $messageper channel, on failure
ScreenshotAttached$reportUuid, $pathwhen a capture is stored

Everything except ReportSubmitting is readonly — a listener observes, it does not edit.

RejectionReason is an enum with six cases: Honeypot, RateLimited, Validation, ChallengeFailed, ListenerRejected and Disabled.

use Illuminate\Support\Facades\Event;
use Pushery\VisualFeedback\Events\ReportDeliveryFailed;

Event::listen(function (ReportDeliveryFailed $event): void {
logger()->warning('feedback delivery failed', [
'report' => $event->reportUuid,
'channel' => $event->channel,
'exception' => $event->exceptionClass,
]);
});

Note what ReportDelivered and its siblings carry: the report's UUID, not the report. They are dispatched from queued jobs, so a whole object would have to survive serialization — and a report holds the reporter's own words. The uuid is what a listener needs to correlate, and it is the same value the optional table stores.

Vetoing a submission

ReportSubmitting is the one event a listener may act on rather than observe. Call reject() and the submission stops: nothing is stored, no channel runs, and the reporter is answered by the widget's own message.

use Illuminate\Support\Facades\Event;
use Pushery\VisualFeedback\Events\ReportSubmitting;

Event::listen(function (ReportSubmitting $event): void {
if (str_contains($event->report->message, 'http://')) {
$event->reject('links are not accepted here');
}
});

The string is for you — it reaches your logs and ReportRejected::$detail, never the reporter's screen. The reporter sees the package's own generic line, in their locale: a veto happens for reasons a submitter usually may not be told, and a message written for a log is not a message written for a stranger. RejectionReason::ListenerRejected is what the resulting ReportRejected carries.

An empty reject() is a veto too — the argument defaults to '', and isRejected() reads the difference between "not rejected" and "rejected without a note", which a plain string could not.

Two things decide whether your listener works at all, and both are easy to get wrong:

  • Only a SYNCHRONOUS listener can veto. The pipeline dispatches the event and reads isRejected() on the very next line, so a queued listener returns long after the decision was made. It will still run, and it will still be ignored.
  • The report is not validated yet. ReportSubmitting fires at step 3 of the pipeline and validation is step 4, which is deliberate — a veto should not have to wait for rules it does not care about. The consequence is yours to handle: $event->report->message is whatever the reporter typed, at whatever length, and the abuse floor (honeypot, time trap, rate limit) is the only thing that has run before you. Treat it as untrusted input, because it is.

What HAS happened by then is the report's identity: the uuid on $event->report is the final one, so a veto can be correlated with the ReportRejected that follows it.

The Matomo bridge

The package ships one listener of its own on ReportSubmitted, and it needs saying because nothing switches it on: if pushery/matomo-analytics-for-laravel is installed, every accepted report is tracked as a Matomo event — category visual-feedback, action submit, name the report's own category. Without that package the listener is a no-op.

Two deliberate details. It listens on ReportSubmitted only, never on ReportRejected, so a honeypot hit or a rate-limited bot is never counted as real traffic. And there is no configuration key for it. The bridge is Pushery\VisualFeedback\Bridges\MatomoBridge and it is resolved from the container, so an application that wants the analytics package without this event binds a subclass whose isAvailable() returns false.

Browsing stored reports

There is deliberately no admin UI. A list screen is a handful of lines against a table you own, and shipping one would mean shipping opinions about your auth, your layout and your pagination — three things a package cannot know. Here is the whole recipe.

use Illuminate\Support\Facades\DB;

$table = config('visual-feedback.database.table', 'visual_feedback_reports');

$reports = DB::table($table)
->when($category, fn ($q) => $q->where('category', $category))
->when($since, fn ($q) => $q->where('created_at', '>=', $since))
->latest('created_at')
->paginate(25);

Four columns carry JSON — context, metadata, attachments and deliveries — so decode them where you read them:

$context = json_decode($report->context, true, flags: JSON_THROW_ON_ERROR);
$files = json_decode($report->attachments, true, flags: JSON_THROW_ON_ERROR);
@foreach ($reports as $report)
<article>
<h2>{{ $report->subject ?: __('visual-feedback::messages.categories.'.$report->category) }}</h2>
<p>{{ $report->message }}</p>
<small>{{ $report->reporter_email ?? __('Guest') }} · {{ $report->created_at }}</small>
</article>
@endforeach

Two things to get right, because neither is obvious:

  • attachments holds storage PATHS on a private disk, never URLs. Serve a file through your own authorized controller with Storage::disk(config('visual-feedback.attachments.disk'))->download($path) — linking the path directly either 404s or, if the disk is public, hands every screenshot to anyone who guesses a filename. Screenshots contain whatever the reporter had on screen.
  • Deleting a row does not delete its files. visual-feedback:prune and visual-feedback:forget remove both; a hand-written DELETE leaves the attachments behind, and visual-feedback:sweep-orphans is what collects them afterwards.