italix / session
Sessions as records about a subject and a tenant: device lists, per-account revocation, replay detection, step-up re-authentication, concurrency caps and pluggable stores
Requires
- php: >=7.4
- ext-json: *
Requires (Dev)
- italix/auth: ^2.0
- italix/testing: ^2.0
Suggests
- ext-pdo: Required by PdoSessionStore, the only store that can list or revoke by subject or tenant
- ext-session: Required by NativeSessionStore, the adapter used while migrating off $_SESSION
This package is not auto-updated.
Last update: 2026-08-30 22:43:18 UTC
README
A session is a record about a subject — and about the organisation they are inside — not a blob
under a key. Zero dependencies, not even italix/contracts.
php src/Libs/Italix/Session/tests/SessionTest.php php src/Libs/Italix/Session/tests/StoreTest.php
Why this exists
Every application eventually has to answer five questions:
- this account was suspended — sign it out now, not in 24 minutes
- the password changed — invalidate the other sessions
- which devices am I signed in on?
- sign me out everywhere
- who was signed in when that happened?
None of them is expressible against PHP's native session, Laravel's, or Symfony's, because in all three a session is an opaque payload under an identifier that only the browser holding the cookie knows. The server cannot find a user's sessions. So all five get re-implemented per project, usually late and usually wrong.
Anything sold to organisations rather than to individuals has a sixth and a seventh:
- this subscription lapsed — sign out every person in the account
- who from this account is working right now?
The difference here is one design decision — the stored record has a subject_c and a tenant_c,
both indexed — and it is the only decision in the library that is expensive to change later.
The shortest useful example
use Italix\Session\Session; use Italix\Session\Stores\PdoSessionStore; use function Italix\Session\{cookie_spec, session_policy}; // Once, in configuration. $store = new PdoSessionStore($dm->get_connection()); $cookie = cookie_spec('__Host-session')->secure()->http_only()->same_site('Lax')->path('/'); $policy = session_policy()->idle_ttl(7200)->absolute_ttl(28800); // Once per request, in one middleware. Session::begin($store, $cookie, $request->getCookieParams()['__Host-session'] ?? null, $policy); try { $response = $handler->handle($request); } finally { $outcome = Session::end(); } return $outcome->has_cookie() ? $response->withAddedHeader('Set-Cookie', $outcome->cookie_header()) : $response;
The finally is not decoration. It is what makes the library safe under a resident worker: the
static state is reset even when the controller threw, which is exactly what
if (session_status() === PHP_SESSION_NONE) does not do.
Everywhere else:
Session::current()->get('locale_c'); Session::current()->set('locale_c', 'it'); Session::authenticate('account:12'); // rotates the identifier, keeps the data Session::forget(); // sign out
The five questions, in code
$sessions = new SessionRegister($store, null, $policy); // or Session::register() $sessions->of_subject('account:12'); // device list $sessions->revoke_subject('account:12', 'suspended'); // sign out everywhere $sessions->revoke_others('account:12', Session::token()); // everywhere but here $sessions->revoke($id_c); // one device
revoke_subject() and revoke_others() return a count, so the message to the user can be true
rather than generic.
And the same two questions about an organisation
A session belongs to a person; a person belongs to an account. Anything selling seats needs both lists, and they are not the same list:
$sessions->of_tenant('tenant:7'); // who is working in this account right now $sessions->revoke_tenant('tenant:7', 'unpaid'); // suspend the subscription — everybody, now
The case that settles why these are two indexed fields rather than one clever string is support
impersonation. A staff member helping a customer keeps their own subject_c and takes the
customer's tenant_c:
Session::set_tenant('tenant:7'); // subject_c stays 'staff:1' Session::regenerate(); // the privileges changed, so the identifier does
That session must fall when the customer is suspended, must not fall when one of the customer's users changes their password, stays attributable to the staff member in an audit, and does not count against a seat the customer is paying for. One field collapsed into the other makes all four wrong at once.
set_tenant() deliberately does not touch authenticated_t: stepping into an account is not proving
an identity, so a step-up window must not silently restart. Pass null to leave.
Not every store can do this, and the ones that cannot say so instead of pretending:
if (!$sessions->can_find_by_subject()) { return $this->show('account/devices_unavailable'); }
An empty device list reads as "you are signed in on one device", which is a lie the person reading
it cannot detect. FileSessionStore and NativeSessionStore therefore throw rather than return
[]. can_find_by_tenant() is asked separately, because a store built over a key→value service
maintains each index by hand and having one and not the other is an ordinary thing to have to say.
Six states, not a boolean
Session::state(); // active | absent | expired_idle | expired_absolute | revoked | rotated
"Signed out for inactivity" and "your access was revoked by an administrator" are two different messages to the user and two different lines in the log. Almost every application collapses both into the same mute login page.
rotated is the one worth the whole design. An identifier that has already been rotated coming
back is something the legitimate browser cannot do — it was handed the new one and threw the old
away. It is the signature of a copied cookie, and the only moment a server ever gets to see the
theft.
case 'rotated': // Killing the session that presented it is not enough: the thief may be // holding the current one. $sessions->revoke_subject((string) Session::refused_subject(), 'replay_detected'); break;
A short grace window (SessionPolicy::rotation_grace(), 60s by default) exists because a browser
with requests in flight can legitimately present the old identifier for a moment after a login. Set
it to zero and every login becomes a self-inflicted alarm.
Three times, not one
| means | drives | |
|---|---|---|
created_t |
when the session began — for a storefront, long before anybody signed in | "signed in since" |
last_seen_t |
last request | the idle window, "active now" |
authenticated_t |
when identity was last proved | step-up |
if (!Session::authenticated_within(900)) { return $this->redirect_to('/step-up'); } // after the password or the OTP is verified again: Session::reauthenticate();
authenticate() rotates the identifier because privileges changed. reauthenticate() does not,
because they did not — rotating on every step-up would discard the device list every time somebody
pays. And authenticated_within() returns false for a session that never authenticated: the null
fails closed.
Concurrency: the guarantee that disappears silently
PHP's native session locks, so a read-modify-write cannot lose data. The moment the store is a database or Redis, that lock is gone without a word: two AJAX calls read the same payload, both write it back, and the second erases the first. It surfaces months later as "the cart occasionally loses an item".
// set() is right for a scalar the caller owns. Session::current()->set('locale_c', 'it'); // merge() is right for a structure two requests can touch. The closure is // replayed against the stored value at write time, inside the store's lock. Session::current()->merge('cart', static function (array $cart) use ($sku_c): array { $cart[$sku_c] = ($cart[$sku_c] ?? 0) + 1; return $cart; });
The rule where they meet: a key that has ever been set() or forget() in this request is written
wholesale, and a merge() on it applies locally only. Once you assert the whole value you have
taken responsibility for it. There is a test for both halves.
Seats, which are a product decision and not a security one
session_policy()->idle_ttl(7200)->max_concurrent(2);
At most two live sessions per subject; a third login evicts the least recently used and
authenticate() returns how many fell, so the page can say "you were signed out on 1 other
device" rather than leaving it to be discovered on a phone.
Evicting rather than refusing is the deliberate half: a cap that refuses locks somebody out of their own account over a browser they closed on a machine they no longer have, and they cannot fix it themselves. An application that genuinely wants to refuse counts first and owns the message:
if (count($sessions->of_subject($subject_c)) >= $limit_n) { … }
Off by default, because a limit on simultaneous sessions is how seat-based pricing stops one login being shared by an office — and a library that shipped it switched on would be making a pricing decision for everybody who adopted it.
A cap the store cannot enforce is refused at Session::begin(), not ignored per login. Accepting
it would mean "at most two sessions" configured, any number allowed, and nothing ever saying so.
And the line the library will not cross: it takes no view on seats. How many people an
organisation may have, and which of them keep access after a downgrade, is a pricing rule that lives
in the application. revoke_tenant() signs out an account; deciding who deserved to stay is not a
session library's business.
Typed payloads instead of loose keys
Session::put(new AuthContext('staff', 12)); Session::typed(AuthContext::class); // ?SessionPayload, or null
The argument is one call site rather than taste. Reading a bag, get('tenant_di') is a typo that
returns null — and a null fails closed in an authentication check and fails open in a tenant
scope. The same missing value, read two metres apart, once harmless and once a data leak.
to_array() is the boundary, and that is the point: it is where a class decides what does not get
serialised. Concretely, Italix\Auth\Identity already exists and is the wrong thing to store,
because it carries password_hash().
Session::typed(...) === null means nobody — never "the other kind of actor". Inferring a role
from the absence of a value is the failure mode this library exists to remove.
The library never learns what an actor is. Who they are, what roles exist and whether there is a
tenant are one application's shape, not every application's — the same argument Auth\Identity
makes when it declines to know about tenants.
The cookie, described once
cookie_spec('__Host-session')->secure()->http_only()->same_site('Strict')->path('/');
Defaults are the safe ones: HttpOnly on, SameSite=Lax, a session cookie rather than a persistent
one. This class exists because of a measurement — a production application was running on PHP's
shipped defaults, where cookie_httponly, cookie_samesite and cookie_secure were all empty and
nothing had ever called session_set_cookie_params(). There was nowhere for the decision to live.
A description a browser would discard is refused, not accepted: SameSite=None without
Secure, a __Host- name with a domain or a path other than /. Validation runs in
Session::begin(), so a contradiction lands at the start of a request with a message naming it
rather than as a cookie that silently never arrives.
Strict mode is structural. PHP's session.use_strict_mode exists because PHP will create a
session for any identifier a client invents. Here an identifier the store never issued simply finds
no record — there is no setting to forget. The one exception is NativeSessionStore, where PHP's own
handler is doing the work, so the setting is turned on explicitly when a CookieSpec is applied.
The four stores
| shared between servers | by subject & by tenant | locks | notes | |
|---|---|---|---|---|
ArraySessionStore |
no | yes | n/a | tests |
FileSessionStore |
no | no | flock |
one file per session, 0600, atomic writes |
PdoSessionStore |
yes | yes | SELECT … FOR UPDATE |
install() creates ix_session |
NativeSessionStore |
no | no | PHP's own | the migration path |
PdoSessionStore takes a raw PDO rather than Italix\Orm\DataManager, so this package stays a
leaf; $dm->get_connection() hands over exactly what is needed.
Migrating off $_SESSION without moving the data
Session::begin_native($cookie, $policy);
NativeSessionStore wears this library's interface over PHP's own session. Scattered $_SESSION
accesses and session_status() guards become one middleware and one facade while the storage
stays where it is: no migration, no signed-out users, no new table. It writes through to
$_SESSION, so untouched code keeps working during the transition, and it keeps its metadata in one
reserved key.
Then swapping in PdoSessionStore is a line of configuration, and by then every call site is
already correct.
The cookie hardens on day one, before anything else moves. PHP owns the cookie here, so the spec
is pushed into session_name() and session_set_cookie_params() before the start, and
session.use_strict_mode is turned on — the one place in this library where strict mode has to be
asked for rather than being structural. A spec that cannot be applied is refused, not ignored:
arriving after the session started, or after output has begun, throws with the reason. The single
exception is session.use_cookies being off, where there is no cookie to misrepresent and the spec
is inert.
Measured on the application this was written for, this one call changed the live response from:
Set-Cookie: PHPSESSID=…; path=/
Set-Cookie: PHPSESSID=…; path=/; HttpOnly; SameSite=Lax
CSRF and flash live on the facade under the same key names Italix\Mvc\Session already used
(_csrf_token, _flash), so adopting begin_native() keeps every signed-in user's existing token
rather than failing their next form post.
Housekeeping: a sweep that has to be told what stale means
Expiry is decided on read, so nothing here is load-bearing: a session past its idle window is
refused whether or not gc() has ever run. What a table nobody sweeps does is grow.
$sessions = new SessionRegister($store, null, $policy); $removed_n = $sessions->gc(); // run it from cron, a queue, a scheduler — this library takes no view
Build the register with the policy. Without one, gc() sweeps on the absolute deadline alone —
and expires_t is only written when an absolute cap is configured, which most applications never
set. The result is a sweep that runs, reports a count, and collects nothing. That is not
hypothetical: it is what the first deployment of PdoSessionStore did here, and the measurement was
57 rows, none of them collectable, growing by one per anonymous visitor, because every page with
a form mints a CSRF token, minting a token dirties the session, and a dirty session is written.
The sweep uses the same comparison as the read path — over when (now - last_seen) > ttl — so it can
only delete sessions that were already being refused. It cannot sign anybody out. The suite asserts
that in the costly direction, on all four stores: a record last seen exactly ttl ago is still
being served and must survive.
Deliberately not
No stateless signed-cookie or JWT mode. A signed token is valid until it expires, by construction, and cannot answer any of the five questions. Every system that adopts one ends up building a blacklist beside it — which is the state it removed, reintroduced worse. A library that starts from the indexed record can add a short-lived token later as an optimisation; the converse is not available.
Also: no cross-domain session sharing, no "remember me" as a second kind of session (it is an
absolute_ttl_n, not a mechanism), and no session-based flash for API responses.
Tests
138 assertions across two suites. Twenty-four deliberate mutations have been introduced across the
three releases to check the assertions bite: no rotation at login, a revoked session accepted,
SameSite=None allowed without Secure, merge closures dropped at persist time, end() leaving the
record behind, the CSRF field unescaped, the absolute cap restarting on rotation, the session id
interpolated into a filesystem path, revoked sessions listed as devices, a store claiming an indexing
capability it lacks, the tenant dropped across a rotation or from the SQL upsert, the concurrency cap
off by one or evicting the newest, a cap accepted over a store that cannot enforce it, the idle
sweep off by one in each of the three stores that implement it, and a register that drops the policy
on the way to gc().
Twenty-two were caught immediately. The two that were not were worth more than the eighteen:
- one showed a docblock claiming the absolute cap held because
created_tis carried across a rotation, when the mechanism is actuallyexpires_t; - the other showed an assertion checking the right outcome through the wrong path — leaving an organisation and rotating writes the local record either way, so the flag that carries a cleared tenant to a row that already exists was never exercised.
Both comments were corrected and both missing assertions added. Both sweeps now run clean.
On the >=7.4 claim. Syntax-checking every file on 7.4 proves nothing about running on it, so
the library was also run there — rotation, replay detection, revocation, merge() reconciliation,
step-up and the array and file stores all pass under 7.4.33 as well as 8.1.31. PdoSessionStore was
exercised on 8.1 only, because the 7.4 build available here has no pdo_sqlite. That is a gap in the
evidence, not a known incompatibility, and it is written down rather than rounded up.