Sessions and CSRF

Gustav provides server-side sessions for browser applications. A conventional project stores them under storage/sessions. Inject the Session service wherever request state is needed:

use GustavPHP\Gustav\Attribute\{Controller, Get};
use GustavPHP\Gustav\Session;

#[Controller('/account')]
final readonly class AccountController
{
    public function __construct(private Session $session)
    {
    }

    #[Get]
    public function show(): array
    {
        $visits = (int) $this->session->get('visits', 0) + 1;
        $this->session->put('visits', $visits);

        return ['visits' => $visits];
    }
}

Reading a missing value does not create a session. Gustav opens storage only when an existing session is read, state is written, an ID is requested, or a CSRF token is created. Untouched requests therefore create neither a storage file nor a cookie.

Session data

The session API covers ordinary values and destructive reads:

$session->put('user_id', 42);
$session->put('filters', ['archived' => false]);

$userId = $session->get('user_id');
$exists = $session->has('user_id');
$removed = $session->remove('user_id');
$filters = $session->pull('filters', []);

$all = $session->all();
$session->clear();

Keys are names such as user_id, checkout.step, or csrf-token. Values can contain null, booleans, integers, finite floats, UTF-8 strings, and arrays of those values. Objects and resources are rejected instead of being serialized implicitly. This keeps records portable between storage implementations and prevents an object-unserialization boundary inside the request pipeline.

Flash data

Flash data is available when the session is next opened and committed, then it expires automatically:

$session->flash('notice', 'Profile saved');

$notice = $session->getFlash('notice');
$notice = $session->pullFlash('notice');

Use hasFlash(), allFlash(), or keepFlash('notice') when a message needs to survive another request. Calling keepFlash() without keys retains all current flash entries.

An unexpected exception or 5xx response does not commit session mutations or consume loaded flash data. The storage lease is released when the request finishes.

Regeneration and invalidation

Regenerate the opaque session ID after a privilege change such as login while preserving the current values:

$session->regenerate();

Invalidate the record on logout or when all state must be discarded:

$session->invalidate();

Invalidation deletes the server-side record and returns an expired browser cookie. The old ID no longer restores the session.

Protecting routes from CSRF

Cookie-authenticated state-changing routes should declare #[Csrf]. Put it on one handler or on a controller class:

use GustavPHP\Gustav\Attribute\{Controller, Csrf, Post};

#[Controller('/account')]
#[Csrf]
final class AccountController
{
    #[Post('/email')]
    public function updateEmail(): array
    {
        // The token was checked before body binding and controller execution.
    }
}

Class-level protection applies only to unsafe HTTP methods. GET, HEAD, OPTIONS, and TRACE remain unprotected; they must not change application state. A protected route without session configuration fails during startup.

Generate a token through the injectable manager and escape it in a native PHP form:

use GustavPHP\Gustav\Security\CsrfTokenManager;

public function __construct(private CsrfTokenManager $csrf)
{
}

return new View('account/edit', [
    'csrfToken' => $this->csrf->token(),
]);
<form method="post" action="/account/email">
    <input
        type="hidden"
        name="_token"
        value="<?= $view->escape($csrfToken) ?>"
    >
    <!-- fields -->
    <button type="submit">Save</button>
</form>

Gustav accepts the same token in X-CSRF-Token, which is convenient for JSON and JavaScript requests:

await fetch("/account/email", {
    method: "POST",
    headers: {
        "Content-Type": "application/json",
        "X-CSRF-Token": csrfToken,
    },
    body: JSON.stringify({ email: "ada@example.com" }),
});

The hidden _token field is removed before #[Body] binding, so it is not an unknown DTO field and is not passed to controller input. A missing or invalid token returns a safe JSON 403:

{
    "error": {
        "status": 403,
        "message": "CSRF token is invalid"
    }
}

When Gustav must parse the body to find _token, malformed JSON still returns 400 and a non-empty unsupported body returns 415. Supplying the header lets CSRF validation happen without requiring body parsing.

Conventional projects use an HTTP-only, SameSite=Lax cookie named gustav_session, scoped to /, with a two-hour lifetime. Secure is enabled automatically for HTTPS requests. Override the options in the shared project configuration only when the application needs a different policy:

use GustavPHP\Gustav\Configuration;
use GustavPHP\Gustav\Session\{SameSite, SessionOptions};

return Configuration::forProject(
    namespace: 'App',
    root: dirname(__DIR__),
    session: new SessionOptions(
        directory: dirname(__DIR__) . '/storage/sessions',
        cookieName: '__Host-session',
        lifetime: 3600,
        secure: true,
        sameSite: SameSite::Strict,
    ),
);

SameSite=None and cookie names using the __Secure- prefix require secure: true. A __Host- cookie additionally requires path / and no cookie domain. Invalid combinations fail when configuration is created.

Pass session: null when constructing Configuration directly to disable sessions. Configuration::forProject() enables the conventional file store.

Storage and deployment

The default store uses one file per opaque ID and an exclusive lease while the session is open. It works across multiple RoadRunner workers on one host and prevents concurrent requests for the same session from overwriting each other. Expired and abandoned empty records are removed probabilistically.

Do not use host-local files behind a load balancer with multiple application replicas. Use a shared store with an equivalent per-session lease, or guarantee sticky routing and shared storage whose locking semantics you have verified.

Register a singleton implementation of SessionStoreInterface to replace the default file store:

namespace App\Services;

use GustavPHP\Gustav\Attribute\Service;
use GustavPHP\Gustav\Service\Lifetime;
use GustavPHP\Gustav\Session\{SessionLeaseInterface, SessionStoreInterface};

#[Service(
    as: SessionStoreInterface::class,
    lifetime: Lifetime::Singleton,
)]
final class RedisSessionStore implements SessionStoreInterface
{
    public function acquire(
        string $id,
        bool $create = false,
    ): ?SessionLeaseInterface {
        // Acquire one distributed lock and return a RedisSessionLease.
    }
}

Store implementations are singletons and must not retain request-specific state. acquire($id, create: false) returns null for an unknown ID and must not create storage from arbitrary client cookies. The returned SessionLeaseInterface owns all state for that one ID until release().