tui/session-bundle
Symfony bundle that streamlines session management with configurable session storage and integration with Symfony's session subsystem. It helps centralize session configuration and supports custom session handling for applications that need more control over PHP sessions.
This bundle solves one specific problem: terminating a session after a period of inactivity (a sliding window), which Symfony's native gc_maxlifetime only approximates via PHP's probabilistic garbage collector. It does this by storing a last activity timestamp in the session and comparing it on each request.
Before you install anything, check the archive status. The repository is archived and has no dependents. Read composer.json first and confirm the Symfony version constraints — if the last tag predates Symfony 4.3, the listener will still be typed against Symfony\Component\HttpKernel\Event\GetResponseEvent, which was renamed to RequestEvent and will throw a TypeError at runtime. In that case skip to the "roll your own" subscriber in Gotchas and Tips; it is roughly 40 lines and removes the dependency entirely.
If the constraints do line up:
composer require tui/session-bundle
Register the bundle (Symfony 4+/Flex, or AppKernel::registerBundles() on older apps):
// config/bundles.php
return [
// ...
Tui\SessionBundle\TuiSessionBundle::class => ['all' => true],
];
Configure the idle window:
# config/packages/tui_session.yaml
tui_session:
max_idle_time: 1800 # seconds; verify the exact key in DependencyInjection/Configuration.php
Where to look first. The whole package is a handful of classes. Read them in this order:
DependencyInjection/Configuration.php — the authoritative list of config keys and defaults (don't trust the README, which is likely stale).Resources/config/services.xml (or .yml) — the service ID and any constructor arguments you may want to override.First use case. Lock down the admin area: an operator who walks away from a workstation should be logged out after 15–30 minutes. Set max_idle_time to your policy value, then verify end-to-end: log in, manually set the session timestamp into the past ($session->set('...', time() - 3600) via a throwaway controller or the profiler), hit a protected route, and confirm you land on the login page with the session destroyed. Do this before wiring anything else — if the event priority is wrong the check silently never fires and you'll waste an afternoon debugging the wrong layer.
Scope the check to authenticated users. An idle timeout on anonymous sessions just churns session files and annoys people filling in a contact form. If the bundle doesn't expose a firewall filter, wrap the behaviour in your own subscriber that delegates to the bundle's service only when $this->security->getToken()?->isAuthenticated().
Route allow/deny lists. Health checks, the profiler, _wdt, and webhook endpoints should never refresh or expire a user session. If the bundle has no pattern option, exclude them with a guard clause keyed on $request->attributes->get('_route').
Respond differently to XHR than to full page loads. A 302 to the login page returned inside a fetch() is confusing UX. Either decorate the bundle's listener service in your container and override the response factory, or set a higher-priority listener that clears the session before the bundle's runs:
if ($request->isXmlHttpRequest()) {
return new JsonResponse(['error' => 'session_expired'], 401);
}
Pair it with a "you were timed out" flash. session->invalidate() wipes flash data, so write the flash after invalidation on the freshly created session, not before.
Treat it as the sliding layer, not the only layer. Keep an absolute maximum lifetime alongside it (a _session_started_at attribute checked by the same subscriber). Idle expiry alone means an attacker who polls every 20 minutes holds a session open indefinitely.
Testing. In a KernelBrowser functional test, reach into the session between requests:
$client->loginUser($user);
$session = $client->getContainer()->get('session');
$session->set('_last_activity', time() - 4000);
$session->save();
$client->request('GET', '/dashboard');
$this->assertResponseRedirects('/login');
Because the bundle reads a session attribute, this is far more deterministic than trying to fake time() or sleep through the timeout.
Shared storage. If you run more than one app server, the timestamp must live in Redis/Memcached/PDO session storage, not the default filesystem handler — otherwise a user bouncing between nodes gets inconsistent results.
gc_maxlifetime must exceed your idle timeout. PHP will garbage-collect the session file before your listener ever sees it, and the user gets a brand-new empty session instead of the "you were logged out" flow. Set session.gc_maxlifetime and framework.session.gc_maxlifetime to at least your idle window plus a margin.cookie_lifetime: 0 (browser-session cookie), closing the tab already ends the session and your idle timeout only covers long-lived tabs. Set a cookie lifetime equal to or greater than the idle window if you want the sliding behaviour to actually be observable.session->invalidate() is destructive. It regenerates the ID and clears data, including CSRF tokens. Any form the user had open will fail on submit anyway, but make sure the logout response carries the new session cookie and that the redirect is GET-safe._last_activity serialises on the session file. Asset requests under a session can queue behind each other. Only update the timestamp for main requests, and consider read_and_close semantics for routes that don't need writes.isMainRequest() / isMasterRequest() guard. The listener will otherwise run for sub-requests rendered via {{ render(controller(...)) }}, which can reset the clock or fire the expiry twice. Note the method was renamed in Symfony 5.3 (isMasterRequest() → isMainRequest()).HttpCache or a Varnish layer, cached responses never reach kernel.request, so a session can outlive its timeout at the edge. Expire aggressively on Cache-Control for authenticated routes, or accept that this is a server-side check only.time() consistently, not new \DateTime() with mixed timezones, or you'll get off-by-hours behaviour that only shows up on hosts with a non-UTC date.timezone.bin/console debug:event-dispatcher kernel.request and confirm the listener is registered with a priority below the session listener (128) and above whatever consumes the session. bin/console debug:config tui_session (adjust the extension alias) reveals whether your YAML was actually merged.Sec-Fetch-Mode: navigate.final class IdleSessionSubscriber implements EventSubscriberInterface
{
public function __construct(
private int $idleSeconds,
private UrlGeneratorInterface $urls,
) {}
public static function getSubscribedEvents(): array
{
return [KernelEvents::REQUEST => ['onRequest', 8]];
}
public function onRequest(RequestEvent $event): void
{
if (!$event->isMainRequest() || !$event->getRequest()->hasSession()) {
return;
}
$session = $event->getRequest()->getSession();
$now = time();
$last = $session->get('_last_activity');
if ($last !== null && $now - $last > $this->idleSeconds) {
$session->invalidate();
$event->setResponse(new RedirectResponse($this->urls->generate('app_login')));
return;
}
$session->set('_last_activity', $now);
}
}
This gives you version-proof typing, full control over the redirect, and one fewer unmaintained dependency — while keeping the exact behaviour the bundle was written for.
How can I help you explore Laravel packages today?