The basics
HTTP session
HTTP is stateless, so the session keeps what your app knows about a user from one request to the next: who is signed in, a cart, a status message to show once.
Introduction#
Oriel stores sessions in your database by default, and can keep them in files, in a cookie or in Redis instead.
The StartSession middleware, in the web group, loads the session before your route runs and saves it once the
response is ready, so every route in routes/ has one. The api group has no StartSession: a route in
routes/ gets an empty session that is never saved.
Configuration#
The session's settings are in config/. A new app uses the database driver:
import { dirname, join } from 'node:path'import { env, Str } from '@orieljs/framework'const basePath = dirname(import.meta.dirname)export default { driver: env('SESSION_DRIVER', 'database'), lifetime: Number(env('SESSION_LIFETIME', 120)), expire_on_close: false, encrypt: false, files: join(basePath, 'storage/framework/sessions'), connection: env('SESSION_CONNECTION'), table: 'sessions', lottery: [2, 100], cookie: env('SESSION_COOKIE', Str.slug(String(env('APP_NAME', 'oriel')), '_') + '_session'), path: '/', domain: env('SESSION_DOMAIN', null), secure: env('SESSION_SECURE_COOKIE', null), http_only: true, same_site: 'lax',}driver says where the sessions are stored:
database: in thesessionstable of your database.file: one file per session, instorage/(theframework/ sessions filessetting).cookie: the whole session in an encrypted cookie.redis: in Redis.array: in memory, in the app's process. Your tests use it.
lifetime is the number of minutes a session lasts without a request, 120 by default. With expire_on_close, the
cookie lasts until the browser closes. encrypt encrypts the session's data with your APP_KEY before it is stored.
cookie names the session cookie, after your app's name: blog_session for an app called Blog. path, domain,
http_only and same_site are the cookie's attributes. secure sends the cookie over HTTPS only; when it is null,
as in a new app, the cookie is secure when the request came over HTTPS.
lottery cleans up expired sessions: on average, 2 requests in 100 delete the sessions older than lifetime from
the database, the files or the memory. Redis and cookie sessions expire on their own.
Driver prerequisites#
Database#
The database driver needs the sessions table, which a new app's first migration creates, with the users table:
await Schema.create('sessions', (t) => { t.string('id').primary() t.foreignId('user_id').nullable().index() t.string('ip_address', 45).nullable() t.text('user_agent').nullable() t.longText('payload') t.integer('last_activity').index()})Each row also records the signed-in user's id, the IP address and the user agent of the session's last request.
connection names the database connection to use, the default one when it is null, and table the table.
Redis#
The redis driver talks to the Redis server of config/: connection names an entry of
database.redis (default unless you set SESSION_CONNECTION). Its keys start with oriel_session: and expire after
lifetime. Oriel's Redis client, ioredis, comes with the framework, and the connection is closed when your app shuts
down.
Interacting with the session#
Retrieving data#
You reach the session through the request, or with the session() helper. Through the request:
import { request, view } from '@orieljs/framework'import { User } from '../../Models/User'import { Controller } from './Controller'export class UserController extends Controller { async show(id: string) { const value = request().session().get('key') // ... return view('user.profile', { user: await User.findOrFail(id) }) }}get() takes a default, returned when the key isn't in the session, and a type for the value, which is a cast, not
a check:
const value = request().session().get('key', 'default')const theme = request().session().get<string>('theme')Keys take dots: get('user.name') reads the name of the object stored under user.
The global session helper#
session() with a key returns its value, with a default if you give one. With an object, it stores the object's
values. With nothing, it returns the session itself:
Route.get('/home', () => { // Retrieve a piece of data from the session... const value = session('key') // Specifying a default value... const withDefault = session('key', 'default') // Store a piece of data in the session... session({ key: 'value' }) return { value, withDefault, all: session().all() }})The Session facade does the same: Session.get('key'), Session.put('key', 'value'). The three reach
the same session, the current request's, and your tests check them all with assertSessionHas().
Retrieving all session data#
const data = request().session().all()Retrieving a portion of the session data#
only() and except() take a list of keys:
const data = request().session().only(['username', 'email'])const others = request().session().except(['username', 'email'])Determining if an item exists in the session#
has() tells you whether a key is present and not null:
if (request().session().has('users')) { // ...}exists() tells you whether a key is present, even with a null value, and missing() whether it isn't. Both take
several keys:
if (request().session().exists('users')) { // ...}if (request().session().missing('users')) { // ...}Storing data#
Store a value with put(), or with the session() helper. put() also takes an object of values:
// Via the request...request().session().put('key', 'value')// Via the global "session" helper...session({ key: 'value' })The session is stored as JSON: a Date comes back as its ISO string on the next request, and a class instance, a
model for one, as a plain object. Store ids and plain values, and look models up again.
Pushing to array session values#
push() adds a value to the list stored under a key, and starts the list when there is none:
request().session().push('user.teams', 'developers')Retrieving and deleting an item#
pull() returns a value and removes it from the session:
const value = request().session().pull('key', 'default')Incrementing and decrementing session values#
increment() and decrement() change the number stored under a key, by 1 or by the amount you give, and return the
new value. A missing key counts as 0:
request().session().increment('count')request().session().increment('count', 2)request().session().decrement('count')request().session().decrement('count', 2)Flash data#
flash() stores a value for this request and the next one only, which suits a status message shown once after a
redirect:
request().session().flash('status', 'Your post was published.')reflash() keeps all of the flash data for one more request, and keep() keeps only the keys you name:
request().session().reflash()request().session().keep('username', 'email')now() stores a value for the current request only:
request().session().now('status', 'Your post was published.')A redirect flashes data too: redirect('/. The input a failed
validation sends back, which old() reads, and its errors, which errors() reads, are flash data as well (see
Requests and Validation).
Deleting data#
forget() removes keys from the session, and flush() removes everything:
// Forget a single key...request().session().forget('name')// Forget multiple keys...request().session().forget('name', 'status')request().session().flush()remove() removes a key, as forget() does, and returns its value, as pull() does:
const name = request().session().remove<string>('name')forget() and keep() take the keys as separate arguments, where Laravel's take a list too.
Regenerating the session ID#
Regenerating the session's ID stops a session fixation attack, in which an attacker gets a user to sign in with a
session ID the attacker already knows. The starter kits regenerate it when a user signs in. To do it yourself, call
regenerate(), which also gives the session a new CSRF token:
await request().session().regenerate()regenerate(true) also deletes the old session from the store. invalidate() removes all the data from the session
and gives it a new ID, deleting the old one. As it removes the CSRF token with the rest, call regenerateToken()
after it, as the starter kits do when a user signs out:
await request().session().invalidate()request().session().regenerateToken()The CSRF token#
The session holds the CSRF token that protects your forms. token() returns it, as csrfToken() does, and
regenerateToken() replaces it:
const token = request().session().token()CSRF protection explains how the token is sent and checked.
Adding custom session drivers#
Implementing the driver#
A custom driver is a class that implements the SessionHandler contract. A stubbed MongoDB driver looks like this:
import type { SessionHandler } from '@orieljs/framework/contracts'export class MongoSessionHandler implements SessionHandler { async read(id: string): Promise<string> { return '' } async write(id: string, data: string): Promise<void> {} async destroy(id: string): Promise<void> {} async gc(maxLifetimeSeconds: number): Promise<void> {}}Oriel has no folder for such classes: put them where you like, as app/ here. What each method does:
read()returns the session's data, as the stringwrite()stored, or''when there is none or it has expired. Oriel turns the data into JSON and back itself.write()stores the data string under the session's ID.destroy()deletes a session.gc()deletes the sessions not written for more thanmaxLifetimeSecondsseconds. A store that expires its entries on its own can leave it empty.
Registering the driver#
Register the driver with Session.extend(), in a service provider's boot(). The
callback receives the request's container and the session's configuration, and returns a handler; it runs for each
request's session:
import { ServiceProvider, Session } from '@orieljs/framework'import { MongoSessionHandler } from '../Extensions/MongoSessionHandler'export class AppServiceProvider extends ServiceProvider { override boot(): void { Session.extend('mongo', () => new MongoSessionHandler()) }}Then set SESSION_DRIVER=mongo in your .env, or driver in config/.
Testing#
Your tests run with the array driver, and the session carries over from one request of a test to the next.
withSession() puts data in it before a request (HTTP tests covers the rest):
import { test } from 'vitest'import { TestCase } from '../TestCase'const t = TestCase.boot()test('the cart remembers its items', async () => { const response = await t.withSession({ cart: ['apple'] }).post('/cart', { item: 'pear' }) response.assertSessionHas('cart', ['apple', 'pear']).assertSessionMissing('coupon')})flushSession() empties the session: the next request starts with none, so a user who signed in on an earlier request
is signed out. A user you gave actingAs() stays signed in. withSession() called after a request sets its keys over
the ones the stored session holds.
A response's session assertions check the session as the request left it, flash data included:
| Assertion | Passes when |
|---|---|
assertSessionHas(key) | The key is in the session and not null |
assertSessionHas(key, value) | The key holds that value; value may also be a function that returns true |
assertSessionHas([keys]) | Every key is in the session and not null |
assertSessionHasAll({ key: value }) | Every key holds its value |
assertSessionMissing(key) | The key isn't in the session, or holds null |
assertSessionHasInput(key) | The key is in the old input |
assertSessionHasErrors(keys) | The session has validation errors for those keys |
assertSessionHasNoErrors() | The session has no validation errors |
response.session() returns the session itself, for anything else.