Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Session Replay Laravel Package

packstub/session-replay

Packstub Session Replay captures user session activity in Laravel applications and lets you replay those sessions step by step to reproduce bugs and diagnose issues. Simple to install and configure, it gives developers a clear view of what users experienced without guesswork.

View on GitHub
Deep Wiki
Context7

Getting Started

Prerequisites. PHP 8.3+, Laravel 12 or 13. Nothing else — the browser assets in resources/dist are committed and served by ScriptController, so there is no npm install, no Vite step, and no vendor:publish --tag=assets.

Note the release line. The current tag is v1.0.0-beta.5 — the package is still pre-1.0. On a default minimum-stability: stable project a bare composer require packstub/session-replay will not resolve; install with an explicit stability flag and pin the exact beta so a composer update doesn't move you:

composer require packstub/session-replay:1.0.0-beta.5

Install in four steps.

composer require packstub/session-replay:1.0.0-beta.5
php artisan session-replay:install   # publishes config + a service provider stub
php artisan migrate                  # replay tables on the configured connection

Then, before you record a single page:

  1. Open config/session-replay.php (published by the installer) and point storage.disk at a dedicated disk and storage.connection at the database connection that should hold the index. Keep this off your default connection if you care about backup size — the index is metadata only, but it grows with sessions.
  2. Open the published SessionReplayServiceProvider stub in app/Providers. It ships with the viewSessionReplay gate in it. Define it before you render the recorder, even if the only rule is return $user?->isAdmin();. Until that gate resolves, nobody — including you — can watch a recording, and you will spend twenty minutes wondering why the viewer 403s.
  3. Add the recorder to your layout (usually resources/views/layouts/app.blade.php), high in <head> or at the end of <body>:
{!! \Packstub\SessionReplay\Facades\SessionReplay::recorder() !!}
  1. Schedule the prune command. Nothing deletes anything on its own:
// routes/console.php
Schedule::command('session-replay:prune')->daily();

Where to look first. In this order: the published config file (masking, retention, path, snapshots.share_routes), the published provider stub (the gate — this is the entire security model), src/SessionReplayManager.php (the fluent API you'll actually call), and src/Http/Middleware/AuthorizeViewer.php (to confirm what the gate protects). resources/js/lib/process.js is the pure, node-tested part of the recorder; read it if you need to know precisely what attribute stripping happens before upload, and grep it for livewire:navigating if you need to confirm the snapshot behaviour on your version.

First use case. Don't turn it on everywhere. Wire the recorder behind recordWhen so it only runs on a staging environment or an internal-only tenant, open a page, click around, trigger a 500, then visit the viewer route (default under the package's configured path — check php artisan route:list | grep -i replay) and confirm you can see your own session, the error marker on the timeline, and that form inputs are masked. That ten-minute loop validates installation, the gate, storage, and masking in one pass — and it's the loop you'll repeat every time you change the gate or the config.

If you run Livewire or Filament, add one step to that loop. Trigger a 500, then navigate with a wire:navigate link (or click through a Filament panel) before checking the viewer. Since beta.5 each wire:navigate page lands as its own chunk row and a fresh snapshot; in the viewer you should see the timeline break cleanly per page and be able to jump to the last page without replaying the earlier ones. If you instead see one giant change event swallowing the whole body, you're on an older build.

For local experimentation without touching your app, composer serve boots the Testbench workbench on :8000 with a page to record and the viewer on /session-replay.

Implementation Patterns

Put the recorder in the layout, put the policy in the manager. The script tag reads its configuration from the manager at render time, so the conditional decisions belong in PHP, not in Blade conditionals scattered across views:

// AppServiceProvider::boot()
use Packstub\SessionReplay\Facades\SessionReplay;

SessionReplay::userUsing(fn () => auth()->user());                // identity for ingest
SessionReplay::tenantUsing(fn () => tenant());                   // multi-tenant scoping
SessionReplay::impersonatorUsing(fn () => session('impersonator_id'));
SessionReplay::propertiesUsing(fn () => ['plan' => tenant()->plan]);
SessionReplay::recordWhen(fn () => app()->isProduction() === false);   // or per-tenant opt-in

recordWhen is your sampling and environment switch. The common production shape is a per-tenant or per-user flag: record for accounts that have opted into support, or for a fixed percentage, or only when an admin has flipped a "debug this session" toggle. Because it's a closure evaluated at render, you can drive it from a database flag without any deploy.

Design the gate as a policy artifact, not a lambda. The gate is evaluated with the recording bound as its argument when one exists, on data routes as well as viewer routes. That means authorization is per-recording, and you should treat it as such:

Gate::define('viewSessionReplay', function (User $user, ?ReplaySession $recording = null) {
    if (! $user->hasAnyRole(['support', 'engineer'])) {
        return false;
    }

    // per-tenant isolation: support can only watch their own tenant's sessions
    if ($recording && $recording->tenant_id !== $user->tenant_id) {
        return false;
    }

    return true;
});

Write an integration test that asserts 403 on every replay data route for a user without the role. This is the single most important test in your integration; a permissive gate is a total-disclosure failure, not a UI bug.

Correlate logs to recordings. The package's global middleware pushes the active recording id and viewer URL into Laravel's Context, so any log line written during a recorded request carries a link to the replay. That is the killer day-to-day affordance: an exception report or a support ticket arrives, you open the log, the replay link is in the context, you watch what the user saw. Lean on it — make sure your logging stack actually renders Context (Sentry, Flare, and the JSON/Telescope channels do; a bare single channel does not).

Link recordings to domain objects. Two hooks exist for this and they solve different problems:

  • ReplaySessionStarted event — listen and attach the replay to whatever the session was about at creation time (a ticket opened during the session, a checkout started).
  • Concerns/HasSessionReplays trait — put it on Order, Ticket, Account, and query recordings from the domain side rather than "search by timestamp."
Event::listen(ReplaySessionStarted::class, function ($event) {
    SupportTicket::where('session_id', session()->getId())
        ->update(['replay_session_id' => $event->session->id]);
});

Combined with the Context link, this gets you "open the order, see the replay of the person who couldn't pay."

Let the Livewire integration do the navigation work, and build on the chunk-per-page shape. Since beta.5 the recorder pauses on livewire:navigating and resumes on livewire:navigated, so a wire:navigate swap is recorded as a fresh snapshot rather than one enormous DOM mutation. Practically, that means:

  • A session in a Livewire or Filament panel is now a series of pages, one chunk row each, alongside the page loads. Querying "which page was the user on when this blew up" is much cheaper than it used to be, because the player can seek straight to that page instead of replaying everything before it.
  • Markers, SessionReplay.mark() calls, and console output emitted during the swap are preserved, and the console stays patched across it — a wrapper another script put around console.error is not ripped out by navigation. So SessionReplay.mark('checkout.submitted') fired from a Livewire wire:click handler still lands on the timeline next to whatever error followed.
  • The last page before a tab closes is no longer lost when the swap was too large to flush in the closing request. If you've been chasing "the crash happens on the final page and I can never see it," this is the fix you were waiting for.

Use markers as your debugging index. The recorder already marks errors, console output, failed Livewire requests, web vitals, rage clicks, and navigation. Custom markers are the extension point for domain moments — checkout.submitted, plan.upgrade_clicked — and they land on the timeline next to the error that followed. Add them from your own JS at the point of the domain event, not by trying to infer them server-side: the server never parses events, and it doesn't want to.

Pick your viewer strategy up front. Core alone gives you the Blade viewer behind the gate — sufficient for an engineering-first rollout and the right choice if your ops tooling isn't Filament. If your team already lives in a panel, add packstub/filament-session-replay, which requires this package and reuses the same gate. Decide once; installing both later means pinning them together (see Gotchas).

Operate the storage lifecycle deliberately. Recordings are gzip chunks on a disk (sessions/{id}/{seq}.json.gz) with stylesheets content-addressed under assets/{aa}/{hash}.css.gz, plus an index in the database. That gives you three levers: the retention window in config, the prune command (schedule it, and alert if it fails), and the disk itself. Since beta.5, stylesheets that finish loading after the page was serialized (a page-specific stylesheet appended by wire:navigate, or a slow one at page load) are also stored once by hash instead of being inlined on every view — if you're upgrading, expect your per-session asset growth to drop without any config change. If you're multi-tenant with contractual deletion obligations, per-tenant retention is a PruneCommand wrapper or a scheduled job of your own that deletes by tenant — the recording is a row plus files on a disk, so it's tractable in a way a vendor's data isn't.

Gotchas and Tips

The gate is the security boundary; everything else is convenience. It wraps the viewer and every data route, with the recording bound as the argument. A permissive rule exposes every screen of every session to every user who can reach the route. Deny by default, write the 403 test, and review the gate in the same PR review as your auth changes. The package does not appear to ship an access audit trail — if "who watched whom" is a compliance question, log gate decisions yourself.

The app key is in the ingest trust chain. Ingest identity is a ContextToken — user, tenant, impersonator, properties — signed with APP_KEY at render time, which is why the upload endpoint needs no session, cookie, or CSRF token. Consequences: rotating APP_KEY invalidates in-flight tokens (fine — sessions in progress just stop uploading); the recorder must render server-side per page load (full-page-cached pages will serve stale or expired tokens — exclude recorded routes from full-page cache); and the ingest route must stay reachable without a session, which means your web middleware assumptions may need a carve-out.

wire:navigate snapshots are deliberately not shared. Only a full page load is eligible for snapshots.share_routes; a page reached via wire:navigate never is, even when you started recording there. Do not budget storage on the assumption that an SPA-style panel deduplicates its pages the way a classic multi-page app does. The beta.5 win is smaller chunks, less main-thread blocking, seekable playback, and no lost final page — not snapshot sharing across navigations. Corollary for testing: if you validate dedupe by clicking through a Filament panel, you'll conclude it's broken. Test it with hard page loads.

Custom navigation frameworks get none of this for free. Turbo, htmx, Inertia, or a hand-rolled fetch()-and-swap router does not fire livewire:navigating. The recorder has a five-second fallback that resumes recording if livewire:navigated never arrives, so you won't lose the recording outright — but a long swap leaves a gap of up to five seconds with no events, and a non-Livewire swap is still recorded as one giant mutation. If you drive swaps yourself and care about this, dispatch the two events around your own navigation boundary; that's the extension point, and it's cheaper than post-processing.

CSP. recorder() emits an inline script tag carrying your config and the signed token. Under a strict script-src, you'll need a nonce or hash. Verify on the actual page — this is the most common reason a recorder appears to be installed and records nothing. Note that a wire:navigate swap re-executes head scripts on the new page; if your nonce is generated per request rather than per response, a nonce mismatch after navigation is a plausible cause of "the second page of the panel records nothing."

Verify masking on a real page, not on the docs. Defaults mask inputs, and resources/js/lib/process.js does attribute stripping and redaction, but masking is a config you own and a security reviewer will read line by line. Open a recorded session of a page containing a password field, an API key in a hidden input, and a card number, and watch the replay. Then maintain a hard exclusion list — auth, checkout, admin, health checks — via recordWhen. Privacy defaults only tighten within a major, but that only protects you from the package's changes, not from your own routes.

Never expect server-side event parsing. The browser sends the index ("meta") alongside the payload. All markers, all properties, all custom events are computed client-side. If you want something correlated, emit it from JS or from the domain hooks — asking the server to inspect rrweb events is against the design.

Gzip handling is safe by construction, but volume is still yours. Uploads are inflated exactly once with a ceiling, and stylesheets are verified against their SHA-256 — those risks are handled. What isn't handled is a heavy SPA producing long sessions. Watch chunk counts per session, keep retention honest, and prune before the disk, not after. Post-beta.5 chunk counts go up per session (one per wire:navigate page) while total bytes go down — if you alert on chunk-row count rather than bytes, retune the threshold alongside the upgrade.

You are on a beta; the surface is not frozen. Earlier notes assumed a frozen 1.x — the shipping tag is v1.0.0-beta.5, so treat config keys, migration names, route names, and the chunk format as still in motion between betas. Pin the exact version, read the changelog for every bump, and re-run your own integration tests (especially the 403 test and the masking spot-check) rather than assuming a drop-in. Chunk-format changes are the ones that strand you: recordings made by an older beta are explicitly supported for playback in beta.5 ("recordings made before keep playing as they are"), but don't assume that courtesy holds across every future bump — if retention is long, verify playback after each upgrade before pruning your ability to roll back.

If you install the Filament sibling, pin both together. The two packages merge PSR-4 directories under the shared Packstub\SessionReplay\ namespace. Mismatched versions can collide on class names in ways Composer won't warn you about. On betas this is materially more likely than on stables — upgrade them as an exact pair, run both test suites after doing so, and don't let a floating ^1.0@beta constraint resolve them independently.

Don't publish or rebuild assets. resources/dist is committed and served by the package; your app never builds anything. That also means the bundle executing on your recorded pages is opaque unless you look — at pin time, diff resources/dist against resources/js, and note the pinned rrweb version and license. Re-diff on upgrade, and specifically re-diff after this beta if you care where the livewire:navigating / livewire:navigated listeners live and what the five-second fallback does in your browser.

Localisation is a contract. Strings live in resources/lang/{en,de,es,ro,ru}/viewer.php and player.php; the player receives them as data-labels. ViewerTest compares key sets across all five, so a new string goes into all five or CI fails. Forking a language is the wrong move — contribute it.

Maturity. 14 stars, 0 dependents, single maintainer, no SLA, and still pre-1.0. The engineering signals are good (Pest + Testbench, Pint, node --test over the pure JS, changelog per change — this release is a good example: a behavioural fix, a storage fix, and explicit notes on what did and didn't change), and MIT gives you a fork escape hatch. But budget for reading the source: composer test, composer test:filter <name>, composer lint, and npm test are all available and are the fastest way to answer "what does this actually do when X". Pilot on an internal admin surface where the blast radius is your own team, and where the gate naturally limits the audience — a Filament panel on staging is the ideal first tenant, since it exercises both the new navigation recording and the gate in one sitting.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
elmogy/larafast
xeviant/laravel-paystack
gorilladash/laravel-website-sdk
martialbe/flysystem-oss
chondal/3312client
jeroen-g/flickr
opscale-co/nova-genbi
itsjustvita/laravel-retention
tequilarapido/trackit
gregsphotography/site-builder-filament
cabaoctavio/laravel-query-log
gregsphotography/site-builder-components
io-digital/clickatell
snowsoft/laravel-admin
oxygen/auth
relicsoftware/barakah-payment-gateway
tobya/work-with-composer
equipc/laravel-email-validator
equipc/laravel-email-database-log
adedsec/nova-translated-labels