Jess — signed-URL state switching for manual demo/verification in Laravel apps. Recipe-based state, per-session SQLite isolation, zero host-app pollution.
sparrowhawk-labs/jess is a Laravel package for jess — signed-url state switching for manual demo/verification in laravel apps. recipe-based state, per-session sqlite isolation, zero host-app pollution..
It currently has 0 GitHub stars and 7 downloads on Packagist (latest version v0.3.0).
Install it with composer require sparrowhawk-labs/jess.
Discover more Laravel packages by sparrowhawk-labs
or browse all Laravel packages to compare alternatives.
Last updated
Signed-URL state switching for manual demo/verification in Laravel apps. Hit a link, land on the target screen already in the exact state you asked for — zero pollution of host app code.
jess's isolation trick (instant, cheap, per-session copies) works by
literally copy()-ing a SQLite file and repointing database.default at the
copy. That means while a jess demo session is active, the whole app runs
against a SQLite connection — not whatever your production database engine
is.
enabled defaults to false, and even when on, it only
swaps the connection during an active demo session).FULLTEXT search,
stored procedures, engine-specific collations — will not work through
jess as-is.composer require sparrowhawk-labs/jess
php artisan jess:install
jess:install publishes config + runs migrations + publishes docs, in two
layers meant to make jess legible to LLM coding agents as well as humans:
AGENTS.md (created if
missing). If the host app has a CLAUDE.md, an @AGENTS.md import
line is ensured there too, so Claude Code picks up the same core section
without duplicating it (Claude Code reads CLAUDE.md, not AGENTS.md,
unless it's imported this way — other AGENTS.md-convention tools read
AGENTS.md directly).docs/jess/README.md
in the host app. Everything below "Status" is the reference layer:
full API, config, internals, fragment design patterns, and
troubleshooting.Both steps are idempotent (safe to re-run jess:install) and can be
skipped with --no-docs.
Phase 1〜5 complete (package skeleton, state recipe / DB isolation core,
signed-URL layer, cleanup command, demo-app verification). See PLAN.md in
this repository for the full development log and verification history.
2026-07-31 — fixed a session-ledger bug found in real-world use: hitting a
fresh signed link while an older jess_session cookie was still live wrote
the new session's bookkeeping row into a demo SQLite copy instead of the
host DB (the middleware had already swapped the default connection), so the
next request silently fell back to the real DB — an alternating
works/doesn't pattern. Ledger reads/writes are now pinned to the host
connection captured at construction; regression-tested at both the manager
and HTTP level.
2026-09-10 (v0.3.0) — the demo banner now defaults to the bottom-right
corner (jess.banner_position), and exit returns to the page the demo
link was pressed on, even on another origin: the Referer of the
signed-link hit is recorded per session (new return_to column — run
php artisan migrate when upgrading, and re-publish --tag=jess-views if
you had published the banner) and consumed only by that session's exit.
See "Return-to-origin on exit" below for the referrer-policy caveat.
// app/Providers/AppServiceProvider.php
use SparrowhawkLabs\Jess\Facades\Jess;
use Database\Seeders\UserSeeder;
use Database\Seeders\PurchaseSeeder;
public function boot(): void
{
// Fragment order in a link's recipe must respect FK dependencies —
// see "Fragment design" below.
Jess::fragment('user:new', fn () => UserSeeder::asNewUser());
Jess::fragment('user:repeat', fn () => UserSeeder::asRepeatCustomer());
Jess::fragment('purchase:completed', fn () => PurchaseSeeder::afterCompleted());
}
// wherever you build the link to hand someone (a route, a controller, tinker, …)
use SparrowhawkLabs\Jess\Facades\Jess;
$url = Jess::link(
fragments: ['user:repeat', 'purchase:completed'],
redirectTo: '/shop',
userId: UserSeeder::DEMO_USER_ID, // logs this user in after provisioning
);
Hitting $url:
$userId if given.$redirectTo, with a cookie binding this browser to this
demo session for the rest of its lifetime.config/jess.php)| Key | Env var | Default | Meaning |
|---|---|---|---|
| enabled | JESS_ENABLED | false | Master switch. No route, no middleware, no exception handling registered at all unless true. Set only in local/staging/demo. |
| signed_url_ttl | JESS_SIGNED_URL_TTL | 60 | Minutes a Jess::link() URL stays valid before Laravel's signed-URL check rejects it. |
| demo_db_storage_path | JESS_DEMO_DB_STORAGE_PATH | app/jess/demo-sessions | Where per-session SQLite copies are written. Relative to storage_path() unless absolute. |
| template_db_path | JESS_TEMPLATE_DB_PATH | (none — required) | Absolute path to a migrated, empty-of-demo-data SQLite file. You own keeping this in sync with your schema (see below). |
| connection | JESS_CONNECTION | jess_demo | The DB connection name jess registers and repoints at each session's file. Kept out of your own connection names — fragments/Seeders never reference it. |
| banner | JESS_BANNER | true | Inject the demo banner (and the in-app origin recorder/restorer scripts) into full-page HTML responses. false disables all injection. |
| banner_position | JESS_BANNER_POSITION | bottom-right | Corner the banner and its collapsed dot sit in: bottom-right, bottom-left, top-right, top-left. Bottom-right by default so it never covers top-right app chrome (edit buttons, lightbox ✕). |
| exit_redirect | JESS_EXIT_REDIRECT | / | Last-resort landing for the banner's exit — used only when no Referer was recorded at landing and nothing was recorded in-app (see "Return-to-origin on exit"). |
| cleanup_after_hours | JESS_CLEANUP_AFTER_HOURS | 24 | Sessions older than this are eligible for jess:cleanup. |
jess does not own your migrations — it copies a file you prepare:
# after `php artisan migrate` against a normal empty DB (sqlite example)
cp database/database.sqlite storage/app/jess/template.sqlite
Re-copy it any time your schema changes. There's no automatic drift detection — this is a manual step, documented here rather than automated, since "automatically re-migrate a file in production" is exactly the kind of implicit magic jess avoids.
A fragment is a name plus a closure (Jess::fragment($name, $callback))
that mutates the demo DB — typically by delegating to your own Seeder's
static methods. Jess never decides what a fragment does; it only decides
when (during provisioning, against the isolated copy) and in what order
(the order given in a recipe's $fragments array).
Two rules follow directly from that design:
Order fragments to respect foreign keys. If cart:with_items
inserts a row with user_id, whatever fragment creates that user must be
listed first in the recipe: ['user:repeat', 'cart:with_items'], not the
reverse.
Use a fixed, deterministic ID for any user you intend to log in as. Every demo session starts from a fresh copy of the template DB, so an auto-increment ID is only predictable if you force it:
class UserSeeder
{
public const DEMO_USER_ID = 1;
public static function asNewUser(): User
{
return User::updateOrCreate(
['id' => self::DEMO_USER_ID],
['name' => '…', 'email' => '…', 'password' => Hash::make('…')],
);
}
}
Jess::link(fragments: [...], redirectTo: '/shop', userId: UserSeeder::DEMO_USER_ID)
then reliably logs in as the user the fragment just created, because the
ID is forced rather than left to auto-increment.
Jess runs fragments inside Model::unguarded(), so the forced id
survives even on models that don't list id in $fillable (i.e. every
ordinarily-guarded Laravel model — without this, Laravel would silently
drop the id and create an auto-increment row, and the userId login
would fail with no error). Fragments only ever write to the isolated
demo copy, so the relaxed guard never touches real data.
A worked example of this pattern (3-axis EC demo — user type × purchase
status × cart contents) lives in the sibling jess-demo-app project built
during Phase 5 verification.
Signed link → recipe. Jess::link() doesn't look anything up
server-side; it encodes the whole recipe (fragment names, $userId,
$redirectTo) as the token itself
(SparrowhawkLabs\Jess\Support\StateRecipe::toToken()/fromToken()), then wraps
it in a Laravel temporarySignedRoute. Laravel's own HMAC-over-the-URL is
what makes tampering detectable — the token's opacity is not a security
boundary by itself.
Provisioning. Hitting the link (JessStateController) copies
template_db_path to a new UUID-named file under
demo_db_storage_path, points the jess.connection (default
jess_demo) at that copy, runs the recipe's fragments against it, then
records a jess_demo_sessions row (uuid, recipe label, file path,
expires_at) — all via SparrowhawkLabs\Jess\Services\DemoSessionManager.
The connection switch itself. DB::purge($connection) +
config(['database.connections.<connection>.database' => $path]) +
config(['database.default' => $connection]), restored to whatever was
default before once fragments finish running. This was verified (Phase 2,
see PLAN.md and spike/) against both a bare Illuminate\Database\Capsule
and a full HTTP request cycle through a real Laravel Kernel, across 6
consecutive switches in the same process (a simplified stand-in for
Octane-style persistent workers). Host app code never needs to reference
the connection name.
Staying switched across requests. The controller sets a
jess_session cookie holding the session UUID. A SwitchDemoConnection
middleware (appended to the web group, only while jess.enabled)
reads that cookie on every subsequent request and re-activates the same
session's connection — so a redirect chain, or simply browsing around
after landing, keeps seeing the demo data instead of snapping back to the
host app's real DB.
Middleware priority: the switch happens before Authenticate. Group
membership alone isn't enough — Laravel's middleware priority sorts
Authenticate ahead of any unlisted group middleware, so on an
auth-protected landing route the auth check would run while
database.default still points at the host's real DB; a demo-only
userId can never resolve there and every request would bounce to
/login. Jess therefore inserts SwitchDemoConnection into the kernel's
middleware priority list just before the auth middleware (and after
EncryptCookies/StartSession), automatically at boot. Hosts need no
$middleware->priority([...]) declaration; one that already lists
SwitchDemoConnection is left as-is.
The demo banner and one-click exit. While a demo session is active,
full-page HTML responses get a small corner chip injected before
</body>: an amber dot + "demo state" label + the active recipe + an
exit link. It auto-collapses to just the dot after 3 seconds (hover
re-expands) so it never gets in the way of the screen being demoed, and
sits in the bottom-right corner by default (jess.banner_position) so
top-right app chrome — edit buttons, a lightbox's ✕ — stays reachable.
The exit link hits GET /jess/exit, which drops the jess_session
cookie — the connection switch is cookie-driven, so the very next request
is back on the host's real DB, no other teardown needed.
Return-to-origin on exit. Exit lands, in this order:
Referer is recorded on the session (return_to) — any
absolute http(s) URL, including another origin, so a link from a
review/gate page hosted elsewhere returns you to that page. It is bound
to that session and consumed only by that session's exit; it is never
taken from a query parameter, so this cannot become an open redirect.
javascript:, data:, protocol-relative and relative referers are
ignored; a same-origin referer is left to step 2 (which also knows
the scroll position); a bare origin (https://gate.example/, no
path or query) is treated as unknown. Referrer-policy caveat:
browsers default to strict-origin-when-cross-origin, which sends
only that bare origin to another origin and nothing on an https→http
hop. For a cross-origin gate page to round-trip, that page must send
its full URL — e.g. <meta name="referrer" content="no-referrer-when-downgrade"> (same-scheme) or
referrerpolicy="unsafe-url" on the link (also https→http).jess.enabled), jess injects a ~1 KB recorder script that keeps
the current URL + scroll position in sessionStorage. With no recorded
Referer, the banner routes through /jess/exit?to=<that url> (same-app
relative paths only — anything else is dropped), and on landing the
scroll position is restored with a brief confirmation toast.config('jess.exit_redirect') (default /) when neither exists —
typed URL, referrer stripped, fresh tab.Set jess.banner => false to disable the banner and both scripts;
publish --tag=jess-views to restyle them.
Injection only touches successful, full-page text/html GET responses —
JSON, redirects, streams and Livewire partial updates pass through
untouched. (Strict-CSP hosts may block the inline style/script: the
banner simply won't render; nothing else breaks.)
Expired/tampered links. Laravel's InvalidSignatureException is
caught and replaced, scoped only to the jess.state route, with a plain
Japanese explanation page (403) instead of the framework default.
If the host app's SESSION_DRIVER is database, the session table lookup
happens through config('database.default') at whatever point in the
middleware pipeline StartSession runs. Because SwitchDemoConnection runs
after StartSession (it sorts between StartSession and
Authenticate — see middleware priority above), a request can start reading its
session against the host's real DB and finish writing it against the demo
DB (or vice-versa on the next request) — the two can desync across
requests. This was found empirically during Phase 5's jess-demo-app
verification. Fix: use a non-DB session driver (file is the simplest)
in every environment where jess.enabled is true. Jess itself does not
enforce or auto-switch this — it's a host-app config choice.
Carried over from the original design risk log (PLAN.md), still open:
Expired demo sessions (SQLite copy + jess_demo_sessions row) are removed by:
php artisan jess:cleanup # deletes what's past expires_at
php artisan jess:cleanup --dry-run # lists what would be removed, deletes nothing
Nothing runs this automatically — wire it into your own schedule. Two common ways:
// bootstrap/app.php (or routes/console.php) — host app's own Schedule
use Illuminate\Support\Facades\Schedule;
Schedule::command('jess:cleanup')->hourly();
# or, outside the app entirely (launchd/cron), if you'd rather not touch the host's schedule:
0 * * * * cd /path/to/app && php artisan jess:cleanup >> storage/logs/jess-cleanup.log 2>&1
No route is registered at all in production unless JESS_ENABLED is
explicitly set to true (disabled by default, opt-in only). Signed URLs otherwise carry no
authentication of their own — anyone who obtains a link's URL can use it
(impersonation risk is the tradeoff for zero host-app-code integration).
Treat links as sensitive as the state they grant, and keep signed_url_ttl
tight in anything reachable outside a trusted network.
The expired/tampered-link 403 page follows the host app's own locale —
app()->getLocale(), i.e. config('app.locale') / APP_LOCALE — the same
setting that drives every other Laravel translation. English and Japanese
are shipped (resources/lang/{en,ja}/messages.php); an unset or unshipped
locale falls back to config('app.fallback_locale') per normal Laravel
behavior. To add a language or override wording:
php artisan vendor:publish --tag=jess-lang
# edit lang/vendor/jess/{locale}/messages.php
SQLSTATE[23000]: … FOREIGN KEY constraint failed during provisioning
— a fragment referencing a not-yet-created row ran before the fragment
that creates it. Fix the order in the recipe's $fragments array (see
"Fragment design" above).userId set — the ID doesn't match what the fragment actually created.
Force a deterministic ID in the Seeder rather than relying on
auto-increment (see "Fragment design" above).SESSION_DRIVER; see "The session-driver gotcha" above.signed_url_ttl or the URL was
edited/truncated (breaks the HMAC signature). Generate a new link.FragmentExecutionException: … threw while running against the demo SQLite connection — the wrapped original error names the real cause.
If it also says "looks like a MySQL/PostgreSQL-specific SQL feature",
a fragment called something SQLite doesn't implement (RAND(),
MATCH/AGAINST FULLTEXT search, a JSON operator, a stored procedure
CALL, …) — see "Requirements" above; that fragment's query needs a
SQLite-compatible rewrite (e.g. RAND() → RANDOM()) to run through
jess at all. Without that specific signature, treat it as an ordinary
bug in the fragment — the exception's getPrevious() has the original
exception if you need to inspect it further.