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.
Cookie and lifetime configuration
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().