Search by

rudolfbruder / laravel-snip

rudolfbruder

A dd()-style snipping tool for Laravel — capture values during a request and view them on-page in an admin-only panel.

Package info

github.com/rudolfbruder/laravel-snip

pkg:composer/rudolfbruder/laravel-snip

Statistics

Installs: 388

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.3.6 2026-10-16 07:41 UTC

README

laravel-snip

laravel-snip

A dd() that does not die.

Drop a snip($order) anywhere in your Laravel app and the value shows up in an admin-only on-page panel — no log files to grep, no debugger to attach, no broken page. Works the same on full HTML responses, Livewire components, and Inertia SPA navigations.

snip($order, 'before-payment');

Open the page as an authorised user, hit Cmd+K (or Ctrl+K), and you have a Horizon-style panel pinned to the corner of the page with everything you captured during the request — values, timings, milestones, dataLayer events, cache keys, and queue state.

Safe to leave in production. Gated, redacted, never cached.

Why not just dd() or Debugbar?

vs. dd() / dump()

dd() dump() snip()
Breaks the page Yes — terminates the response Sometimes — prints into the HTML No — value rendered in a side panel
Survives a redirect No — the response is killed before the redirect runs No — output lost on the next request Yes — captures are flashed across the redirect and shown on the landing page
Works in API / JSON / Inertia XHR responses No — corrupts the response body No — same Yes — shipped via Inertia shared prop, or carried across redirects until a panel-renderable response
Multiple values per request One, then dead Yes, inline Yes — and grouped by label, with timings and milestones in their own tabs
Inspect later No — gone after the page No — gone after the page Yes — pin the panel open, navigate freely, keep capturing
Safe to leave in prod Never Never Yes — gated, redacted, no-store cached

vs. Laravel Debugbar / Telescope / Clockwork

Debugbar / Telescope / Clockwork snip()
Production-safe Discouraged or off-limits — they capture for every request and store a lot Yes — recording short-circuits for non-gated users before any backtrace, dump, or serialise runs
Per-user gating Coarse (env / IP / config flag) Fine-grained Laravel Gate::define('viewSnip', …) — pick any user predicate
Performance hit on the hot path Always-on instrumentation Zero cost for non-gated users; ~one gate check per request for gated users
What gets recorded The whole request (queries, jobs, mail, views, etc.) — opinionated Whatever you write snip(…) against — surgical, intent-driven
Storage Database / files / external services to clean up Session flash + in-memory; nothing to migrate, nothing to expire
Setup Migrations, providers, dashboards, retention composer require + one gate closure

The trade is deliberate. Debugbar / Telescope answer "what happened in this request?". snip() answers "what is this value right now?" — the same question dd() answers, with none of the costs of dd().

What it carries over from a production-safe tool like Horizon: scoped per-request state, an admin-only gate, private cache headers on every injected response, and zero impact on users who do not match the gate.

Installation

composer require rudolfbruder/laravel-snip
php artisan snip:install

The installer publishes config/snip.php, copies the frontend bundle to public/vendor/snip/, and creates app/Providers/SnipServiceProvider.php where you define who can see the panel.

Register the provider:

// Laravel 10 — config/app.php
App\Providers\SnipServiceProvider::class,

// Laravel 11+ — bootstrap/providers.php
App\Providers\SnipServiceProvider::class,

After every composer update rudolfbruder/laravel-snip, refresh the bundle:

php artisan snip:publish

Who can see the panel

Edit app/Providers/SnipServiceProvider.php:

protected function gate(): void
{
    Gate::define('viewSnip', function ($user = null) {
        return $user?->is_admin === true;
    });
}

Without an override, only app()->environment('local') sees the panel. Unauthorised users never receive captured data — the middleware never injects, the Inertia share callback returns null, and the recording API short-circuits before any backtrace or serialisation runs.

Guest links

To check a page as a visitor who is not logged in (a phone, an incognito window, a colleague), open Settings → Guest access → Create guest link in the panel and open the copied URL in that browser. It sets a cookie and redirects to /; from then on that browser sees the panel too, cache and queue tabs included.

  • One link is active at a time. New link replaces it, Revoke removes it — either way every guest cookie stops working at once, because the cookie is only checked against the token kept in the cache.
  • Links expire after guest_links.ttl minutes (default 120, SNIP_GUEST_LINK_TTL).
  • Guests can view but cannot create, replace or revoke links.
  • The panel shows who created the active link and when. Creating, opening and revoking a link are also logged (info, default log channel) with the user, IP and, for opens, the browser's user agent. The token itself is never logged — only a 12-character fingerprint to tell links apart.
  • Switch the feature off with SNIP_GUEST_LINKS=false.

Laravel support

Three top-level helpers cover most use:

snip($value, 'label');     // capture a value
snip_time('elastic');      // record an elapsed-time entry
snip_here('reached-step-2'); // drop a milestone breadcrumb

Or use the facade:

use RudolfBruder\LaravelSnip\Facades\Snip;

Snip::add($order, 'order');
Snip::start('payment');
$result = $gateway->charge($order);
Snip::timing('payment');
Snip::milestone('payment-confirmed');

Reload the page. The panel pill appears bottom-right (move it to another corner under Settings → Position); click it or press Cmd+K / Ctrl+K.

That is the entire surface area. No bootstrapping, no middleware to register, no <script> tags to add — the package wires everything into the framework on install.

Inertia support

Works out of the box. The package detects Inertia and registers a shared _snip prop so every visit — initial HTML and subsequent SPA XHR navigations — carries the current request's captures.

// Inertia controller
public function show(Order $order)
{
    snip($order, 'order');
    snip_time('elastic-query');

    return Inertia::render('Orders/Show', [...]);
}

Captures appear in the panel without a full page reload. When the panel is already open and you navigate to another page via <Link>, the panel re-renders with the new request's data.

For Inertia POST → 303 → GET XHR flows (form submissions, destroy actions), captures recorded in the POST handler are flashed to the session and replayed on the next request, so nothing gets lost across the redirect.

The panel also gets an Inertia tab showing the current page's props.

Tabs

The panel has eight tabs. Each tab is independent — you only see the ones with data, or all of them when display_mode = always.

Snips

Inspect any PHP value as a typed, collapsible tree.

snip($order, 'order');
snip(['user' => $user, 'cart' => $cart], 'checkout-state');
snip(Order::with('items')->find($id));

Returns the value unchanged so it can be inlined inside expressions:

return snip(User::find($id), 'lookup')->email;

Eloquent models, collections, arrays, objects, and primitives all render with type badges, child counts, and a memory size estimate. Keys matching password, token, secret, etc. are auto-redacted.

Timings

Profile blocks of code with start/stop marks.

Snip::start('elasticsearch');
$products = $repo->search($query);
Snip::timing('elasticsearch');

snip_time('build-payload');         // measured from request start

Each timing shows the elapsed duration in milliseconds plus where in the request lifecycle it ran, so you can see at a glance which block ate the budget.

Milestones

Confirm a code path executed without breaking the page.

snip_here('discount-applied');
snip_here('admin-fallback-path');

A non-fatal alternative to dd(). Useful for confirming a feature flag fired, a guard clause passed, or a fallback branch took over — anywhere you would have reached for dump() and a page reload.

Profiler

Find out where a slow request spends its time. Switch it on with SNIP_PROFILER=true, then wrap the steps you suspect:

use RudolfBruder\LaravelSnip\Facades\Snip;

// First thing in the controller: names the request (the tab then always shows)
// and marks where the controller starts, splitting middleware from controller time.
Snip::surface('category', ['categoryId' => $categoryId]);

$products = Snip::measure('load products', fn () => $repo->search($query));

Every report also says who it ran for (user <id> or guest). The less common calls hang off Snip::profiler(): addContext(), measuredPipes(), markControllerStarted() (to mark the start somewhere other than surface()), recordCall().

The tab has two views. Structured draws the lifecycle phases as a stacked bar, every measured step as an indented waterfall with the SQL / Redis / HTTP calls made inside it, the calls filterable by kind (failed ones in red), the cache hit/miss/write counts, the heaviest Blade views, Redis keys read more than once, the slowest SQL, and SQL repeated often enough to look like an N+1 (click a query for the full statement). Raw shows the same profile as plain text, and the copy button puts that text on the clipboard. SQL, cache, Redis, outgoing Http:: calls, queued jobs and Blade views are picked up from framework events automatically (Inertia's SSR render shows as its own ssr kind, and a failed one says the page fell back to rendering in the browser). Every job the request pushes, queued listeners, mail and notifications included, shows as a queue call with its connection, queue and delay. A job the sync driver runs inline also becomes a step, so its queries count toward the job and not toward the code that dispatched it. Jobs sent with dispatchAfterResponse() are pushed after the report is built, so they don't show; record anything else (Elasticsearch, third-party SDKs) yourself:

Snip::profiler()->recordCall('elastic', $durationMs, 'search products', "{$hits} hits");

Off by default, because once on it listens to every query and cache event of every request. Only users who pass the gate ever see the report.

DataLayer

Tees every window.dataLayer.push(...) call into a panel tab so GTM events are visible without opening the GTM debugger.

window.dataLayer.push({
    event: 'add_to_cart',
    ecommerce: { items: [...] },
});

The tab shows the event name, the full payload, and the order in which events were pushed during the page lifetime. Site-defined events are highlighted; framework noise (gtm.js, gtm.dom, gtm.load) is collapsed.

Disable with SNIP_DATALAYER=false.

Inertia

Shown on Inertia pages: the props the current page component received, without opening Vue/React devtools. The header names the page component and URL and gives the total size of the props; each prop expands into a tree, and arrays, objects and strings show their size in the response.

The search box filters props by name, nested key or value, opens the path down to each match and highlights it.

Hovering a prop, or any key inside it, shows a copy button that puts its full value on the clipboard: strings as plain text, arrays and objects as indented JSON.

Props are badged by how Inertia loads them:

  • deferred — loaded by a follow-up request after the page rendered (Inertia::defer()); the group is named when it is not default. While the request is still running the prop is listed as pending.
  • merge, prepend, deep merge — merged into the current value on partial reloads (Inertia::merge(), Inertia::deepMerge()); a nested path such as merge data names the merged key, and hovering the badge shows the key items are matched on.

The tab follows SPA navigation, back/forward and partial reloads. It reads the page object Inertia already sends to the browser, so it needs no setup and puts nothing extra in the response. Works with Inertia v1, v2 and v3.

Mail

Every mail the request sends — Mailables, notifications, Mail::raw() — with its sender, recipients (cc and bcc too), subject, the mailer that sent it and the Mailable or Notification class that built it. The body opens as a rendered HTML preview, as plain text, or as HTML source; embedded images show in the preview, and attachments are listed by name and size.

Mail is only read, never held back, and only for users who pass the gate. Mail sent before a redirect shows on the page you land on. Queued mail is sent by a worker, outside the request, so it only shows up with the sync queue driver. Bodies over 512 KB are cut, and at most limits.max_mails_per_request (50) mails are kept.

Off by default; turn it on with SNIP_MAIL=true.

Catching mail instead of sending it

Set SNIP_MAIL_CATCH=true and the app's mail goes to the panel instead of anyone's inbox — like Mailpit or Mailtrap, without running anything. Every mail is caught, whichever mailer sends it (Mail::mailer('shop_sk') included), from every process: requests, php artisan commands and queue workers. The Mail tab gets an Inbox view that lists the newest 50 caught mails (kept for 24 hours in the default cache store) with when and what sent them, checks for new ones every few seconds while it is open, and has a button to empty it.

SNIP_MAIL_CATCH=true

Queue workers read the config once, when they start: restart them afterwards (php artisan queue:restart, or php artisan horizon:terminate with Horizon), or the mail they send still goes out. The workers and the web app must share a cache store other than array.

MAIL_MAILER=snip catches the default mailer's mail only; the snip mailer is added for you, with nothing to put in config/mail.php.

Use either locally or on staging only: caught mail is never delivered, and everyone who passes the gate can read every caught mail, password-reset links included. With the default display_mode, the panel only appears on pages that captured something; set SNIP_DISPLAY_MODE=always to reach the inbox from any page.

When neither is switched on, the Mail tab shows these setup steps.

Cache

Browse the active cache store without a separate tool.

Supported drivers:

Driver Lists keys Inspect value
redis Yes (SCAN with prefix) Yes (handles strings, lists, sets, hashes, zsets)
array Yes Yes
database Yes Yes
file Yes (hashed keys only) No
memcached No (driver not enumerable) —

Click any key to fetch its value on demand — the panel never embeds cache contents in the HTML response. Useful for confirming a Cache::remember(...) actually wrote, or seeing what is currently under user:123:cart.

Disable with SNIP_CACHE=false.

Queue

Inspect jobs across the configured queue driver — failed, pending, scheduled, and (Horizon only) completed.

State Source
failed queue.failer — works on any driver
pending redis / database queues — jobs ready to run
scheduled redis / database queues — jobs delayed to a future time
completed Horizon completed_jobs table

Useful for verifying a job dispatch actually landed, checking why a notification has not gone out yet, or watching a queue drain during a deploy. Each list is paginated and capped (max_scan / per_page) so a 100k-job queue does not stall the request.

Disable with SNIP_QUEUE=false. Name your redis queues with SNIP_QUEUE_NAMES=high,default,low.

Configuration

Publish only when overriding defaults:

php artisan vendor:publish --tag=snip-config

Most-used knobs:

Key Default Purpose
enabled env('SNIP_ENABLED', true) Master kill-switch.
display_mode 'on_capture' 'always' to show the pill on every gated response.
guard env('SNIP_GUARD') Auth guard for the gate. Null = framework default.
pending_redirects true Flash captures across redirect chains.
redact_keys [password, token, secret, …] Keys replaced with ***REDACTED***.
cache.enabled / queue.enabled / datalayer true Toggle individual tabs.
mail.enabled env('SNIP_MAIL', false) Mail tab and the listener behind it.
profiler.enabled env('SNIP_PROFILER', false) Profiler tab and the event listeners behind it.
guest_links.enabled / guest_links.ttl true / 120 Guest links from the settings menu, and how many minutes they last.
limits.* various Depth / array / string / per-kind hard caps.

See config/snip.php for the full reference with inline notes.

Security

  • Captures only reach users who pass the viewSnip gate, or browsers holding a cookie from a guest link that is still active (see Guest links). Anyone you send a guest link to sees everything the panel shows, including cache values and queue payloads, until it expires or is revoked.
  • Common credential keys auto-redacted; extend redact_keys for project-specific PII.
  • Injected responses are forced Cache-Control: private, no-store plus Vary: Cookie so shared caches (Varnish, CDN, response cache) cannot leak captures to other users.
  • Bundle file contains the renderer only — no captured data lives in JS.
  • The recording manager is scoped per request, so Octane and queue workers reset between requests.
  • The cache and queue lookup endpoints are gated by the same check as the panel — a visitor without access hits 403, not the data.

License

MIT.