Installation
Requirements
| PHP | 8.3 or newer, with ext-zlib |
| Laravel | 12.x or 13.x |
| Browser (recorded pages) | Any current browser. CompressionStream and crypto.subtle are used when present; without them batches upload uncompressed and stylesheets stay inline. |
The package has no front-end build step. The recorder and the player ship prebuilt and are served by a route.
In a Filament panel, install packstub/filament-session-replay instead. It requires this package and registers the recorder per panel. Everything below applies there too.
Install
Recordings and their index go to your default database connection. If they belong somewhere else (a central connection in a multi-tenant app), set SESSION_REPLAY_DB_CONNECTION first, because the install command offers to run the migrations; see Storage.
composer require "packstub/session-replay:^1.0@beta"
php artisan session-replay:install
The install command:
- publishes
config/session-replay.php, - offers to run the migrations (five tables:
replay_sessions,replay_chunks,replay_markers,replay_assets,replay_session_assets), - publishes
app/Providers/SessionReplayServiceProvider.phpwith theviewSessionReplaygate in it and registers it inbootstrap/providers.php.
Migrations also run with a plain php artisan migrate; set run_migrations to false to publish and run them yourself (see Storage).
Record a page
Put the directive before </body> in every layout you want recorded:
@sessionReplay
</body>
</html>
It renders nothing when the request is not recorded: recording turned off, a guest while guests is false, an excluded path, or a recordWhen() callback that said no. See Recording.
Inertia, Vue or React: put it in the root template that every page renders through (resources/views/app.blade.php with Inertia), before </body>. The recorder stays loaded while the router changes pages.
Decide who may watch
Until your app defines the viewSessionReplay gate, the viewer answers only in the local environment. Edit the published provider:
namespace App\Providers;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider;
use Packstub\SessionReplay\Models\ReplaySession;
class SessionReplayServiceProvider extends ServiceProvider
{
public function boot(): void
{
Gate::define('viewSessionReplay', function ($user, ?ReplaySession $session = null): bool {
return in_array($user->email, [
'support@example.com',
], true);
});
}
}
The gate is called without a recording for the list and with it for a single replay and every file behind it. Watching replays has examples.
Schedule the clean-up
// routes/console.php
use Illuminate\Support\Facades\Schedule;
Schedule::command('session-replay:prune')->daily();
It deletes recordings older than retention.days (30 by default), except pinned ones, and the stylesheets nothing references any more.
Try it
Sign in, open a recorded page, click around for a few seconds, then open /session-replay. The recorder uploads every five seconds and when the tab is hidden.
Before production
- A disk every server sees. The default
localdisk is fine on one server. With several servers, Laravel Cloud, Vapor or containers that are replaced on deploy, setSESSION_REPLAY_DISKto a private shared disk (S3, R2); see Storage. - The gate. Outside
localthe viewer answers 403 untilviewSessionReplaylets someone in. - The clean-up is scheduled (above).
- Your privacy policy says what is recorded; Privacy has wording to start from.
When nothing shows up
Work through these in order:
- Is the recorder on the page? View the page source and look for
window.__sessionReplay. If it is missing, the request was not recorded: a guest whileguestsisfalse, a path inexcept, a route inexcept_routes,recordWhen()saying no, orSESSION_REPLAY_ENABLED=false. The directive must also be in the layout that page uses. - Does the browser upload? In the network tab, look for
POST /session-replay/ingesta second or so after the page loads.- No request: check the following.
consentisopt-inand nobody calledSessionReplay.consent(true).- The browser sends Global Privacy Control while
respect_gpcis on. sample_rateis below 1.- The tab is in mode
on_errorand nothing went wrong yet.SessionReplay.isRecording()andisBuffering()in the console tell you which.
- A request the browser blocked (
ERR_BLOCKED_BY_CLIENT): a content blocker. Changepathto something neutral. - 401: the page's token expired or
APP_KEYchanged. Reload. - 419: something added
webtoingest.middleware. The endpoint needs no session or CSRF token; leave the list empty. - 422 "Nothing was uploaded": the batch was over PHP's
upload_max_filesizeorpost_max_size, so PHP dropped it. 413: it was overingest.max_batch_kb. See PHP upload limit. - 429: the per-minute throttle or the person's daily allowance; the recorder backs off. See Storage.
- No request: check the following.
- Does the viewer let you in? A 403 on
/session-replayoutsidelocalmeans the gate said no. A redirect to/loginmeansviewer.middlewarewants a login your app does not have on that guard; see Watching replays.
On a site served over plain http:// (other than localhost), browsers withhold crypto.subtle. Recording still works, with each stylesheet stored inside the snapshot instead of once per content.
The routes
Everything lives under one prefix (path, default session-replay; domain optional):
| Route name | Method and URI | Middleware |
|---|---|---|
session-replay.script |
GET {path}/scripts/{file} (recorder.js, player.js, player.css) |
none |
session-replay.ingest |
POST {path}/ingest |
ingest.middleware, throttle:session-replay |
session-replay.ingest.asset |
POST {path}/ingest/asset |
same |
session-replay.ingest.snapshot |
POST {path}/ingest/snapshot |
same |
session-replay.index |
GET {path} |
viewer.middleware, the gate |
session-replay.show |
GET {path}/{session} |
same |
session-replay.manifest |
GET {path}/{session}/manifest |
same |
session-replay.chunk |
GET {path}/{session}/chunks/{seq} |
same |
session-replay.asset |
GET {path}/{session}/assets/{hash} |
same |
session-replay.snapshot |
GET {path}/{session}/snapshots/{hash} |
same |
The script URLs carry a version, so browsers cache them for a year and pick up a new release on the next page load.
viewer.enabled = false removes index and show and keeps the data routes, which the player component needs.
PHP upload limit
A batch is at most ingest.max_batch_kb (1536 KB by default), which stays under PHP's default upload_max_filesize of 2M. Batches are normally a few dozen kilobytes; if your PHP limit is lower than 2M, lower max_batch_kb to match. See Storage.