Introduction
Symfony PostgreSQL Session Bridge
A Symfony session handler backed by Flow PHP's native PostgreSQL library. Replaces PdoSessionHandler without requiring PDO or Doctrine DBAL — sessions are stored directly in PostgreSQL using Flow's query builder and client.
Installation
composer require flow-php/symfony-postgresql-session-bridge:~0.36.0
For Symfony framework integration (config-driven handler registration, automatic migrations, purge command) use this bridge through the Symfony PostgreSQL Bundle. The page below covers standalone usage.
How It Works
FlowPostgreSqlSessionHandler extends Symfony's AbstractSessionHandler. Sessions are stored in a single PostgreSQL table whose schema is byte-compatible with PdoSessionHandler's default layout: sess_id, sess_data (bytea), sess_lifetime (int, absolute Unix expiry), sess_time (int).
- Reads select
sess_dataandsess_lifetimefor the requested session id; rows whosesess_lifetime < now()are treated as misses. - Writes are issued as
INSERT … ON CONFLICT (sess_id) DO UPDATE SET …withsess_lifetime = now() + ttl. gc()records the request's expiry cutoff and defers the actualDELETE FROM sessions WHERE sess_lifetime < now()untilclose()— same contract asPdoSessionHandler, so the deletion does not run while the session row is locked.purgeExpired()/purgeAll()are explicit maintenance methods that bypass the request lifecycle.
Connection Ownership
The handler accepts either ConnectionParameters or a Client:
- Pass
ConnectionParameters(recommended default) — the handler opens (and owns) its ownpg_connect. The connection is created lazily on first use;close()releases it along with the rest of the per-request state. Required for safeLOCK_TRANSACTIONALsemantics. - Pass a
Client— the handler reuses the supplied connection and never closes it (the caller owns the lifetime). Use this only when you know what you are doing (tight connection budget, explicit need to share state).
Usage
use Flow\Bridge\Symfony\PostgreSQLSession\{FlowPostgreSqlSessionHandler, SessionCatalogProvider};
use function Flow\PostgreSql\DSL\{pgsql_client, pgsql_connection_dsn};
$params = pgsql_connection_dsn(getenv('DATABASE_URL'));
// Create the table once (or manage it via your migration tool of choice).
// The setup client below is unrelated to the connection the handler owns.
$setup = pgsql_client($params);
foreach ((new SessionCatalogProvider())->get()->get('public')->table('sessions')->toSql() as $sql) {
$setup->execute($sql);
}
$setup->close();
$handler = new FlowPostgreSqlSessionHandler($params, [
'lock_mode' => FlowPostgreSqlSessionHandler::LOCK_TRANSACTIONAL,
'ttl' => 86400,
]);
session_set_save_handler($handler, true);
Constructor
new FlowPostgreSqlSessionHandler(
ConnectionParameters|Client $connection,
array $options = [],
);
$options accepts:
| Key | Default |
|---|---|
db_table |
sessions |
db_schema |
public |
db_id_col |
sess_id |
db_data_col |
sess_data |
db_lifetime_col |
sess_lifetime |
db_time_col |
sess_time |
lock_mode |
LOCK_TRANSACTIONAL |
ttl |
null (falls back to ini_get('session.gc_maxlifetime')) |
Locking Modes
Three constants control how concurrent requests for the same session id are serialized. They mirror PdoSessionHandler:
LOCK_TRANSACTIONAL(default) —doReadopens a transaction withSELECT … FOR UPDATEon the row;doWrite/doDestroycommits it. Strongest guarantee, scoped to the row.LOCK_ADVISORY— acquirespg_advisory_lock(<session_id_as_bigint>)before reading, releases it on write/destroy/close. The session id is converted to a bigint using the same first-8-bytes-as-little-endian scheme asPdoSessionHandler, so applications mixing both handlers will collide on the same key.LOCK_NONE— no locking. Last write wins. Use only when you can prove serial access.
Cleaning Up Expired Rows
PHP triggers SessionHandlerInterface::gc() probabilistically based on session.gc_probability. The handler defers the actual delete to close(), so PHP's normal GC cycle works without further wiring.
For deterministic cleanup (e.g. on a cron) call purgeExpired() directly. To wipe everything, call purgeAll() — it runs TRUNCATE and returns 0 because PostgreSQL does not report row counts for that operation.
Table Schema
SessionCatalogProvider produces this layout:
| Column | Type | Description |
|---|---|---|
sess_id |
varchar(128) PRIMARY KEY |
PHP session id |
sess_data |
bytea NOT NULL |
Serialized session payload |
sess_lifetime |
int NOT NULL |
Absolute Unix timestamp at which the row expires |
sess_time |
int NOT NULL |
Unix timestamp of the last write |
A single index on sess_lifetime keeps gc() / purgeExpired() cheap.