Sessions

Reading and writing session data, flash messages, regeneration, and why PHP's own sessions are not used.

Not PHP's sessions

session_start() and $_SESSION are process-global. Under a worker that means one visitor's data is still in memory when the next request arrives — a session leak with no symptoms until it is a breach. phporbit implements its own, per-request and disposable. The portability test fails the build if the session extension is used anywhere.

Using it

Inject Session; SessionMiddleware publishes it:

PHP
<?php
use PhpOrbit\Session\Session;

final class PreferencesController implements Handler
{
    public function __construct(private readonly Session $session)
    {
    }

    public function handle(ServerRequest $request): Response
    {
        $this->session->set('theme', 'dark');

        return Response::redirect('/');
    }
}

Reading and writing

PHP
<?php
$session->set('theme', 'dark');        // string|int|float|bool
$session->get('theme');                // ?string
$session->getInt('items');             // ?int
$session->getBool('onboarded');        // bool
$session->has('theme');                // bool
$session->remove('theme');
$session->all();                       // array<string, scalar>
Scalars only

Storing objects would mean serialising user-influenced data and unserialising it later, which is a well-known route to remote code execution. Store an id and reload the object.

Flash messages

For the redirect-after-write pattern — write, flash, redirect, show once:

PHP
<?php
$this->session->flash('notice', 'Article published.');

return Response::redirect('/articles');
PHP
<?php
// On the next request — reading also removes it
$notice = $this->session->takeFlash('notice');
Template
@if($notice !== null)
    <p class="notice">{{ $notice }}</p>
@endif

Regeneration

Issue a new session id whenever the privilege level changes:

PHP
<?php
$session->regenerate();   // keeps the data, returns the old id

Without this, an attacker who fixes a victim's session id before login still holds a valid id afterwards. Authenticator::login() does it for you, so calling that instead of writing the user id yourself is the safer habit.

The old session file is deleted, so the previous id stops working immediately.

Destroying

PHP
<?php
$session->destroy();   // empties it and removes the stored file

The response also carries an expired cookie, so the browser drops its copy.

Configuration

PHP
<?php
use PhpOrbit\Session\FileSessionStore;
use PhpOrbit\Session\SessionMiddleware;

$app->middleware(new SessionMiddleware(
    new FileSessionStore($storage . '/sessions'),
    cookieName: 'orbit_session',
    lifetimeSeconds: 7200,
    sameSite: SameSite::Lax,
));
.env
SESSION_LIFETIME=7200
SESSION_COOKIE=orbit_session
Order matters

SessionMiddleware must run before CsrfMiddleware, which reads the token it holds. Register it first.

What the framework does for you

No session file for anonymous visitors

A session is only written once something is actually stored. Someone who reads one page and leaves does not leave a file behind.

Unknown ids are never adopted

A cookie naming a session that does not exist gets a new session, not that id. Honouring it is precisely what makes session fixation possible.

Ids never reach the filesystem unvalidated

Ids are 256 bits of hex and are matched against a strict pattern before any file operation. A crafted cookie cannot steer a read or a write out of the session directory.

Writes are atomic

Data is written to a temporary file and renamed, which is atomic on POSIX filesystems. A reader sees either the old session or the new one, never a half-written file — which matters under the built-in server and FrankenPHP, where requests can touch one session in quick succession. Files are stored at mode 0600.

Corrupt files fail soft

An unreadable or malformed session file is treated as no session at all. The visitor gets a fresh one rather than an error page.

Expiry

PHP
<?php
$store = new FileSessionStore($storage . '/sessions');
$removed = $store->collectGarbage();   // deletes expired files, returns the count

Expiry is stored in the file, so an expired session is refused on read even if the file is still there. Run collectGarbage() from cron for housekeeping.

Another store

PHP
<?php
use PhpOrbit\Session\SessionStore;

final class RedisSessionStore implements SessionStore
{
    public function read(string $id): ?array { /* … */ }

    public function write(string $id, array $data, int $lifetimeSeconds): void { /* … */ }

    public function destroy(string $id): void { /* … */ }

    public function collectGarbage(): int { return 0; }   // Redis expires keys itself
}

Worth doing once you run more than one application server, since file sessions are local to a machine.

Worker safety

PHP
<?php
public function test_sessions_do_not_leak_between_visitors(): void
{
    $app = $this->bootApplication();

    $first = $app->handle(Requests::post('/preferences', 'theme=dark'));
    $cookie = $this->sessionCookieFrom($first);

    // A different visitor, same process.
    $second = $app->handle(Requests::get('/preferences'));

    self::assertStringNotContainsString('dark', $second->body);
}

Tests like this live in tests/Worker/ and boot once, handling several requests — the only way this class of bug is visible. More →