PHPackages                             sparrowhawk-labs/jess - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Utility &amp; Helpers](/categories/utility)
4. /
5. sparrowhawk-labs/jess

ActiveLibrary[Utility &amp; Helpers](/categories/utility)

sparrowhawk-labs/jess
=====================

Jess — signed-URL state switching for manual demo/verification in Laravel apps. Recipe-based state, per-session SQLite isolation, zero host-app pollution.

v0.2.0(3w ago)00MITPHP ^8.2

Since Jul 8Compare

[ Source](https://github.com/sparrowhawk-labs/jess)[ Packagist](https://packagist.org/packages/sparrowhawk-labs/jess)[ Docs](https://sparrowhawk-labs.dev)[ RSS](/packages/sparrowhawk-labs-jess/feed)WikiDiscussions Synced 1w ago

READMEChangelogDependencies (8)Versions (3)Used By (0)

jess
====

[](#jess)

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.

Stack
-----

[](#stack)

- PHP 8.2+ / Laravel 12+
- SQLite — **required for the demo/QA environment jess runs in**, regardless of what your production database is. See "Requirements" below before assuming this fits your app.

Requirements
------------

[](#requirements)

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.

- Your **production** database can be MySQL, PostgreSQL, anything — jess never touches it (`enabled` defaults to `false`, and even when on, it only swaps the connection during an active demo session).
- Your **demo/QA environment's schema and queries must run correctly on SQLite**. Ordinary Eloquent + standard migrations usually do (this is the same reason Laravel's own test tooling defaults to SQLite). Code that relies on engine-specific SQL — MySQL JSON operators, `FULLTEXT` search, stored procedures, engine-specific collations — will not work through jess as-is.
- There is currently no MySQL/PostgreSQL-native provisioning backend (e.g. clone-database-and-switch-connection). If your demo data path genuinely can't run on SQLite, jess isn't a fit yet.

Installation
------------

[](#installation)

```
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:

- **Core layer** — the essentials (API, ordering rules, footguns) are appended directly into the host app's **`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).
- **Reference layer** — this file, published as **`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`.

Status
------

[](#status)

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.

---

Quick start
-----------

[](#quick-start)

```
// 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`:

1. Provisions a fresh, isolated SQLite copy of your template DB.
2. Runs each fragment's closure against that copy, in order.
3. Logs in as `$userId` if given.
4. Redirects to `$redirectTo`, with a cookie binding this browser to this demo session for the rest of its lifetime.

Config reference (`config/jess.php`)
------------------------------------

[](#config-reference-configjessphp)

KeyEnv varDefaultMeaning`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.`cleanup_after_hours``JESS_CLEANUP_AFTER_HOURS``24`Sessions older than this are eligible for `jess:cleanup`.### Preparing the template DB

[](#preparing-the-template-db)

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.

Fragment design
---------------

[](#fragment-design)

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:

1. **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.
2. **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.

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.

How it works (internals)
------------------------

[](#how-it-works-internals)

- **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..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 (pushed onto 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.
- **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.

### The session-driver gotcha

[](#the-session-driver-gotcha)

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` is appended to the *end* of the `web` group, 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.

### Known unverified edges

[](#known-unverified-edges)

Carried over from the original design risk log (`PLAN.md`), still open:

- Behavior inside queued jobs (a job dispatched while the demo connection is active, then processed by a separate worker process). Jess targets ordinary synchronous request/response demo/QA — job queues are out of scope for now.
- A real Octane (persistent-worker) deployment. The connection-switch mechanism was verified against a *simplified* multi-switch simulation in the same process, not an actual Octane server.

Cleanup
-------

[](#cleanup)

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
```

Safety
------

[](#safety)

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.

Localization
------------

[](#localization)

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
```

Troubleshooting
---------------

[](#troubleshooting)

- **`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).
- **Logged in as the wrong user, or not logged in at all, after a link with `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).
- **Login doesn't stick past the first request / random logouts while demoing** — check `SESSION_DRIVER`; see "The session-driver gotcha" above.
- **Link 403s immediately** — either past `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.

###  Health Score

35

—

LowBetter than 77% of packages

Maintenance95

Actively maintained with recent releases

Popularity0

Limited adoption so far

Community2

Small or concentrated contributor base

Maturity37

Early-stage or recently created project

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~23 days

Total

2

Last Release

23d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/0d0d13d86fafedefd34c2b31dd9b85e4080fb156cbf15742200129c1d0e63ff6?d=identicon)[akihikotakai](/maintainers/akihikotakai)

---

Tags

laravelqademosparrowhawk-labsstate-switchingjess

###  Code Quality

TestsPest

### Embed Badge

![Health badge](/badges/sparrowhawk-labs-jess/health.svg)

```
[![Health](https://phpackages.com/badges/sparrowhawk-labs-jess/health.svg)](https://phpackages.com/packages/sparrowhawk-labs-jess)
```

###  Alternatives

[markwalet/nova-modal-response

A Laravel Nova asset for Modal responses on an action.

17930.8k](/packages/markwalet-nova-modal-response)[api-platform/laravel

API Platform support for Laravel

58190.1k21](/packages/api-platform-laravel)[ronasit/laravel-helpers

Provided helpers function and some helper class.

2087.0k31](/packages/ronasit-laravel-helpers)[team-nifty-gmbh/tall-datatables

Server-side rendered datatables for Laravel and Livewire

1422.0k5](/packages/team-nifty-gmbh-tall-datatables)[crumbls/layup

A visual page builder plugin for Filament 5 — Divi-style grid layouts with extensible widgets.

604.0k2](/packages/crumbls-layup)[tomshaw/electricgrid

A feature-rich Livewire package designed for projects that require dynamic, interactive data tables.

119.8k](/packages/tomshaw-electricgrid)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
