tahsin000 / snag
Let users point at what's broken. A portable Laravel Blade feedback widget with a drag-to-select screenshot tool that never asks for screen-sharing permission. No Vue, React, Livewire, Alpine, Tailwind, npm or Vite required.
Requires
- php: ^8.2
- illuminate/contracts: ^11.0|^12.0|^13.0
- illuminate/database: ^11.0|^12.0|^13.0
- illuminate/filesystem: ^11.0|^12.0|^13.0
- illuminate/http: ^11.0|^12.0|^13.0
- illuminate/routing: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
- illuminate/validation: ^11.0|^12.0|^13.0
- illuminate/view: ^11.0|^12.0|^13.0
Requires (Dev)
- orchestra/testbench: ^9.0|^10.0|^11.0
- phpunit/phpunit: ^10.5|^11.0|^12.0
This package is not auto-updated.
Last update: 2026-08-23 13:13:13 UTC
README
Let users point at what's broken.
A portable feedback widget for Laravel Blade applications. Install it, add one line to your layout, and every page gets a slim tab on the edge of the screen. People drag a box around the part that is wrong, describe it, and the report lands in your own database - with the page context and that cropped region attached.
No screen-sharing permission. No build step. No frontend framework.
@include('snag::widget')
That is the entire integration.
Features
- Edge tab plus side panel - a slim vertical tab flush against the viewport edge, and a full-height drawer that opens on the same edge. It owns one edge only, so it never lands on top of a chat bubble parked in the corner.
- Point at the problem - an optional crop tool lets the user drag a box around the broken part of the page. The region is attached to the report. No screen-sharing permission is ever requested.
- No build step - plain HTML, CSS and vanilla JavaScript. No Vue, React, Inertia, Livewire, Alpine, Tailwind, Bootstrap, jQuery, Node, npm or Vite. The crop tool's only dependency, html2canvas (MIT), ships inside the package and is fetched lazily the first time someone uses it - never on a normal page load.
- Portable by design - the package never references your
User,ParticipantorCustomermodel. Identity comes through anActorResolveryou control. - Configurable feedback types - stored as a
VARCHAR, so adding a category needs no migration. - Safe page context - URL, title, route name, user agent and viewport, collected from a strict allowlist.
- Optional image attachments - stored through Laravel's filesystem under generated paths.
FeedbackSubmittedevent - wire up Slack, email, GitHub or Jira in your application, not in the package.- Rate limited and CSRF protected out of the box.
- Publishable config, migration and views.
- Works with JavaScript disabled - the form degrades to a normal POST.
- Accessible - semantic controls, focus trap, Escape to close, labelled fields, visible focus states.
Requirements
| Requirement | Version |
|---|---|
| PHP | 8.1+ (whatever your Laravel version already requires) |
| Laravel | 9.x, 10.x, 11.x, 12.x, 13.x |
| Database | Any Laravel-supported driver with JSON column support (MySQL 5.7+, MariaDB 10.2+, PostgreSQL, SQLite) |
Installing Snag never forces you to upgrade Laravel, PHP or any other
dependency. The package depends only on the individual illuminate/*
components it actually uses, pins no Symfony version of its own, and requires
no frontend toolchain.
Installation
composer require tahsin000/snag
The service provider is registered automatically through Laravel package discovery.
Publish and run the migration:
php artisan vendor:publish --tag=snag-migrations php artisan migrate
Optionally publish the configuration:
php artisan vendor:publish --tag=snag-config
Add the widget to your main Blade layout, once:
<!DOCTYPE html> <html> <head> ... </head> <body> @include('partials.navigation') <main> @yield('content') </main> @include('snag::widget') </body> </html>
Every page using that layout now has the widget. That's it.
Configuration
The package works with its defaults; publishing the config is only needed to change them.
return [ 'enabled' => env('SNAG_ENABLED', true), 'table' => env('SNAG_TABLE', 'feedback'), 'route' => [ 'prefix' => '_snag', 'middleware' => ['web', 'throttle:10,1'], ], 'actor_resolver' => \Tahsin000\Snag\Resolvers\DefaultActorResolver::class, 'suggestion_provider' => \Tahsin000\Snag\Suggestions\NullSuggestionProvider::class, 'types' => [ 'bug' => 'Something is broken', 'suggestion' => 'I have a suggestion', 'help' => 'I need help', 'general' => 'General feedback', ], 'validation' => [ 'message_min' => 3, 'message_max' => 5000, ], 'widget' => [ 'position' => 'middle-right', 'theme' => 'auto', 'color' => env('SNAG_COLOR'), // e.g. '#1d4ed8' 'screenshot' => true, 'screenshot_library_url' => null, 'button_text' => 'Feedback', 'title' => 'How can we help?', // ...all other labels ], 'context' => [ 'page_url' => true, 'page_title' => true, 'route_name' => true, 'user_agent' => true, 'viewport' => true, ], 'attachments' => [ 'enabled' => true, 'disk' => env('SNAG_DISK', 'local'), 'directory' => 'feedback', 'max_size_kb' => 5120, 'allowed_mimes' => ['jpg', 'jpeg', 'png', 'webp'], ], ];
| Key | What it does |
|---|---|
enabled |
Master switch. When false the widget renders nothing and the endpoint returns 404. |
table |
Database table name. Change it if feedback collides with an existing table. |
route.prefix |
Endpoint path. Default POST /_snag. |
route.middleware |
Middleware stack for the endpoint. Add auth to require login. |
actor_resolver |
Class implementing ActorResolver. See below. |
suggestion_provider |
Reserved extension point; the default does nothing. |
types |
Key ⇒ label map. Keys are persisted; add your own freely. |
validation.message_min / message_max |
Message length bounds. |
widget.position |
Which edge the tab sits on: middle-right, middle-left, bottom-right or bottom-left. Unknown values fall back to middle-right. The panel opens against the same edge. |
widget.theme |
auto (follows the OS), light or dark. |
widget.color |
Optional accent hex color (#rgb or #rrggbb). Anything else is ignored; the button text color is chosen automatically for contrast. |
widget.screenshot |
Enables the crop tool. Requires attachments.enabled. See below. |
widget.screenshot_library_url |
Where the browser loads html2canvas from. null serves it from the package route, which needs no publishing. |
widget.* labels |
Every string in the UI. |
context.* |
Per-field switches for context collection. Turn any of them off. |
attachments.enabled |
When false the file field disappears and any uploaded file is rejected. |
attachments.disk |
Any configured filesystem disk. |
attachments.directory |
Base directory on the disk. Files land in {directory}/{Y}/{m}/{uuid}.{ext}. |
attachments.max_size_kb |
Maximum upload size in kilobytes. |
attachments.allowed_mimes |
Extension allowlist, verified server side. |
Configuration is cached and serialized by
config:cache, so this file must contain only scalars, arrays and class-strings - never closures. Re-runphp artisan route:cacheafter changingroute.prefixorroute.middleware.
The crop tool
Browsers cannot take a true pixel screenshot from JavaScript. The only native route is getDisplayMedia(), which raises the browser's own "share your screen" prompt - a permission most people decline, for a task as small as reporting a typo.
So this package does not use it. Instead the crop tool renders the page's own DOM into a canvas with html2canvas, entirely in the browser:
- The user opens the panel and presses Select an area of the page.
- The panel steps aside and the page dims.
- They drag a box around the part that is wrong. Escape cancels.
- That region - and only that region - is rendered, attached, and previewed in the panel.
- They describe the problem and submit.
Nothing leaves the browser until the form is submitted, and the whole step is optional: the manual upload field is always there, and skipping the capture says nothing and shows nothing.
What it can and cannot draw. html2canvas re-renders the DOM; it does not photograph the screen. It handles text, layout, backgrounds, borders and same-origin images well. It cannot see inside cross-origin <iframe>s, and cross-origin images need Access-Control-Allow-Origin to appear. The widget itself is excluded from every capture.
Serving the library. By default it is served from GET {prefix}/html2canvas.js, cached for a year and fetched only when someone first uses the tool. Nothing needs publishing. To serve it yourself instead:
php artisan vendor:publish --tag=snag-assets
then point the config at it:
'screenshot_library_url' => asset('vendor/snag/html2canvas.min.js'),
Set 'screenshot' => false to remove the tool, its markup and its route usage entirely.
Environment variables
SNAG_ENABLED=true SNAG_TABLE=feedback SNAG_DISK=local
Authentication
Guest feedback
Works out of the box. With the default middleware anyone who can see the page can submit feedback, and the actor columns are left null.
Authenticated feedback
Nothing to configure - if $request->user() returns something, the default resolver records its class, identifier, name and email when those exist.
Authenticated only
Add auth to the endpoint middleware:
'middleware' => ['web', 'auth', 'throttle:10,1'],
The package core never requires authentication.
Custom Actor Resolver
The package deliberately knows nothing about your identity model. To capture your own shape, implement the contract:
<?php namespace App\Feedback; use Illuminate\Http\Request; use Tahsin000\Snag\Contracts\ActorResolver; use Tahsin000\Snag\Data\ActorData; final class ParticipantActorResolver implements ActorResolver { public function resolve(Request $request): ?ActorData { $participant = $request->user(); if (! $participant) { return null; } return new ActorData( type: 'participant', id: (string) $participant->getKey(), name: $participant->name, email: $participant->email, metadata: ['cohort' => $participant->cohort_id], ); } }
Point the config at it:
'actor_resolver' => \App\Feedback\ParticipantActorResolver::class,
ActorData::$metadata is stored under metadata.actor in the feedback row.
Actor details are a snapshot taken at submission time, not a foreign key. The package never creates a relationship to your users table, so feedback survives user deletion and the package stays installable in any application.
Attachments
Users may attach one image. Files are validated server side against allowed_mimes and max_size_kb, then stored under a generated path - the uploaded filename never influences where the file lands.
$feedback->attachment_disk; // "local" $feedback->attachment_path; // "feedback/2026/08/9f2c....png" Storage::disk($feedback->attachment_disk)->get($feedback->attachment_path);
Disable them entirely:
'attachments' => ['enabled' => false, /* ... */],
Automatic screenshot capture is deliberately not included: it requires a JavaScript dependency and browser-specific handling. It is reserved for a future optional provider.
Events
Every stored submission dispatches FeedbackSubmitted:
<?php namespace App\Listeners; use Tahsin000\Snag\Events\FeedbackSubmitted; final class NotifyTeam { public function handle(FeedbackSubmitted $event): void { $feedback = $event->feedback; // Send a Slack message, create a GitHub issue, send an email... } }
Register it as you normally would (auto-discovery, or in your EventServiceProvider).
Integrations live in your application - the package ships none, so it stays dependency free.
Swapping persistence
FeedbackRepository is a contract. Bind your own implementation to store feedback somewhere else - a remote API, another connection, a queue:
$this->app->bind( \Tahsin000\Snag\Contracts\FeedbackRepository::class, \App\Feedback\ApiFeedbackRepository::class, );
The submission action, validation and widget are unaffected.
Customizing views
Publish them:
php artisan vendor:publish --tag=snag-views
The views land in resources/views/vendor/snag/:
widget.blade.php
partials/styles.blade.php
partials/scripts.blade.php
components/form.blade.php
components/launcher.blade.php
Re-theming without publishing
Every colour, radius, spacing and font value is a CSS custom property scoped to .snag-root. Override them from your own stylesheet - no publishing required:
.snag-root { --snag-color-accent: #7c3aed; --snag-color-accent-fg: #ffffff; --snag-radius: 4px; --snag-font: "Inter", sans-serif; }
All package styles are namespaced with snag-, use no bare element selectors, and are wrapped in @once so including the widget more than once on a page still emits one stylesheet.
Disabling the package
SNAG_ENABLED=false
The widget renders an empty string and the endpoint answers 404 - a disabled package does not advertise its endpoint.
Removing the package
composer remove tahsin000/snag
Then remove @include('snag::widget') from your layout. Optionally delete the published config, the published views, the feedback table and any stored attachments. Nothing else in your application is touched.
Testing
composer install composer test # or: vendor/bin/phpunit
The suite runs against a real Laravel application through Orchestra Testbench, with an in-memory SQLite database.
To run a single older lane locally, pin its Testbench and PHPUnit — Testbench
selects the Laravel branch, because laravel/framework replaces illuminate/*:
# Laravel 9 (needs PHP 8.1 or 8.2) composer update -W "orchestra/testbench:^7.0" "phpunit/phpunit:^9.6" vendor/bin/phpunit -c phpunit9.xml.dist # Laravel 10 composer update -W "orchestra/testbench:^8.0" "phpunit/phpunit:^10.5" vendor/bin/phpunit
Test methods are named test_* and carry #[Test]. The name is what
PHPUnit 9 (the Laravel 9 lane) matches on; the attribute is inert there and
authoritative from PHPUnit 10 on. Keep both when adding tests, and do not use
the /** @test */ annotation — it was removed in PHPUnit 12.
Version compatibility
| Laravel | PHP | Testbench | PHPUnit | PHPUnit config |
|---|---|---|---|---|
| 9.x | 8.1, 8.2 | 7.x | 9.6 | phpunit9.xml.dist |
| 10.x | 8.1, 8.2, 8.3 | 8.x | 10.5 | phpunit.xml.dist |
| 11.x | 8.2, 8.3, 8.4 | 9.x | 10.5 | phpunit.xml.dist |
| 12.x | 8.2, 8.3, 8.4 | 10.x | 11.x | phpunit.xml.dist |
| 13.x | 8.3, 8.4 | 11.x | 12.x | phpunit.xml.dist |
Compatibility is claimed only for combinations covered by CI. Every lane
installs its own dependency set and runs both --prefer-lowest and
--prefer-stable: a green Laravel 13 run proves nothing about Laravel 9, so
each version is exercised independently.
One codebase covers the whole range. There are no per-version classes; the only version-conditional line in the package is the migration publishing guard described below.
Notes for Laravel 9 and 10
- Publishing the migration.
vendor:publish --tag=snag-migrationscopies the migration verbatim on Laravel 9 and 10, keeping its0001_01_01_000000_create_feedback_table.phpfilename. On Laravel 11+ the framework rewrites the name with a current timestamp. Both migrate correctly — the stub name sorts first and the table has no foreign keys to order against — so this only affects how the published file is named. - The default rate limiter. The submission route uses
throttle:10,1, which needs a working cache store. Laravel 11+ skeletons default to thedatabasecache driver, so a fresh application that has not run its own migrations has nocachetable and the endpoint will fail. Run your app's migrations, or pointsnag.route.middlewareat a different limiter. - Partial config publishing.
mergeConfigFromis a shallow merge on every Laravel version. If you publishconfig/snag.phpand delete nested keys such aswidget.*, they are not back-filled from the package defaults. Keep the published file complete.
Security
- CSRF - the form ships a token and the endpoint sits behind the
webmiddleware group. - Rate limiting -
throttle:10,1by default; configurable. - Validation - type is checked against the configured allowlist; attachments are checked against a server-side extension allowlist and a size cap. Nothing unvalidated is persisted.
- File uploads - stored through Laravel's filesystem under a UUID path. The client filename is never used.
- Output escaping - all rendered values go through Blade escaping; the package contains no
{!! !!}. - Privacy - page context is an allowlist. The package never stores request payloads, form values, cookies, session contents, CSRF tokens, passwords or the authenticated model.
Found a vulnerability? See SECURITY.md - please do not open a public issue.
Publishing this package
Tag releases with Git; do not add a version key to composer.json.
composer validate --strict
vendor/bin/phpunit
git tag -a v0.1.0 -m "Initial release"
git push origin v0.1.0
Then submit the public repository URL at packagist.org and enable the GitHub webhook so new tags sync automatically.
Local development against a real application
"repositories": [ { "type": "path", "url": "../snag", "options": { "symlink": true } } ]
composer require tahsin000/snag:@dev
Contributing
See CONTRIBUTING.md.
License
MIT. See LICENSE.