genealabs/laravel-caffeine
Laravel Caffeine keeps user sessions alive by pinging your app with a lightweight AJAX heartbeat, preventing session timeouts and lost form data when users are inactive. Easy to install and configure for Laravel apps.
Install and confirm it's live before reading any docs.
composer require genealabs/laravel-caffeine
php artisan vendor:publish --tag=caffeine-config # → config/caffeine.php
The service provider is auto-discovered. On install it does two things: registers a GET /caffeine route returning 204 No Content, and pushes its drip middleware onto the web group so HTML responses get a small inline script that pings that route on a timer.
Where to look first, in this order:
php artisan route:list --path=caffeine — confirms the route exists and shows which middleware wraps it. If this is empty, nothing else matters.config/caffeine.php — the ping interval and the route path are the only knobs you'll actually touch. Read the interval and compare it to config('session.lifetime').First use case: the long form. Pick your worst offender — checkout, multi-step application, admin data entry, anything a user plausibly holds open past session.lifetime (120 minutes by default). Open it, leave it, and watch devtools:
GET /caffeine → 204 at the configured interval.That three-minute check is the whole acceptance test. Everything after this is scoping and hardening.
First config change you should make: don't accept the packaged interval. Derive it from your actual session lifetime so the two can't drift:
// config/caffeine.php
'expiration' => (int) config('session.lifetime') - 10,
A ping interval at or near session.lifetime is a coin flip — any delayed timer loses the race with the garbage collector.
Scope it per route group rather than globally. The web group default means every HTML page in the app starts polling, including high-traffic anonymous marketing pages where there's no session worth preserving. That's a steady drip of session-store writes for zero user benefit. Pull it off the global group and apply it where the failure actually costs money:
Route::middleware(['web', LaravelCaffeineDrip::class])
->group(function () {
Route::get('/checkout', CheckoutController::class);
Route::get('/applications/create', ApplicationController::class);
});
Feature-flag the enablement. Add a config toggle and check it inside a thin wrapper middleware, so enabling or reverting per surface is a config change rather than a deploy. This also keeps functional enablement independently revertible from any framework upgrade.
Handle Inertia and Livewire explicitly. Inertia's initial page load is plain HTML, so the snippet lands and the ping runs during the SPA session — that works. But Inertia navigations return JSON, which the middleware correctly skips; don't expect the script to be re-injected after the first page load. If your app is fully client-side-routed from the first paint and never does a full document request after login, the injected script only survives as long as that first document — verify it, don't assume.
Testing pattern. Two assertions cover it:
$this->get('/caffeine')->assertNoContent();
$this->get('/checkout')->assertSee('/caffeine', false);
Add a test that the middleware is absent on routes you deliberately excluded — that's the regression that actually happens when someone refactors the middleware group.
Exclude the route from observability. Name the route and filter it out of analytics, APM tracing, and error budgets. A 204 every few minutes per open tab will otherwise show up as a spike in request counts and skew your 4xx alerting baseline.
Custom route when you need auth or tenancy. If the endpoint must sit behind auth or a tenant resolver, define your own route and point the config at the same path — the middleware only needs a URL that hits StartSession. Alternatively add middleware via the config's route definition and skip writing your own controller.
Pair it with real draft persistence, not instead of it. Caffeine keeps the session alive; it does not save form state. If the requirement is "user recovers work after a crash or tab close," that's a separate localStorage/server autosave feature. Position Caffeine as preventing the 419, not as protecting the work.
Upgrade runbook entry: pin the package major to the Laravel major — ^12.0.3 on Laravel 11/12, ^13.0 on Laravel 13. Never float. Because the major tracks the framework major, this dependency belongs on every framework upgrade checklist; a lagging release becomes an upgrade blocker, not a nice-to-have.
CSP is the number one silent killer. The snippet is injected inline, so a script-src directive without 'unsafe-inline', a matching hash, or a nonce blocks it. The page still renders perfectly; the ping never fires; the user discovers it by losing their form. Check the browser console for CSP violations on the actual production headers, not just locally.
Full-page caches and CDNs must exclude these responses. A cached HTML page carrying a session-specific script and CSRF token is a correctness bug independent of Caffeine. If you're behind Varnish, Cloudflare "cache everything," or a Rails-style action_controller cache, verify the exclusion rule before enabling broadly.
Verify the snippet re-arms. Watch devtools across two intervals, not one. If the injected code is a single setTimeout rather than a repeating timer, the session gets one refresh and then dies again — and your long-form fix only buys one interval of protection. Confirm empirically against your installed version.
Background tab throttling eats your margin. Browsers throttle timers in inactive tabs to roughly once per minute, and more aggressively under battery saver. Set the interval well below session.lifetime — half or less — so a delayed or skipped ping isn't fatal.
A 302 on /caffeine means it's behind auth. An expired or unauthenticated session redirects the ping to your login page. It may still touch the session store via StartSession, but a redirect in the console is your signal that the route is in the wrong middleware stack. The route should sit in the same session domain as the forms — no auth gate required, and no cross-domain hops.
Validate on the production session driver. file locally behaves nothing like redis, database, or cookie. The cookie driver in particular has no server-side session to expire, making the package pointless there — check which driver prod actually uses before spending a sprint on it.
Session ID regeneration still breaks open forms. If your app regenerates the session (on login, on privilege change, or on a periodic rotation), the CSRF token in an already-rendered form goes stale regardless of how alive the session is. Caffeine extends the session; it doesn't reissue tokens into open documents.
config:cache bakes the interval. Changing expiration without re-running php artisan config:cache in production does nothing. Same for the route path.
Debugging checklist, in order: page source contains the snippet → console has no CSP violation → devtools shows 204 not 302/419 → session cookie expiry advances → submit succeeds past session.lifetime on the prod driver. Whichever step fails first is your bug.
Bus factor. Effectively a single maintainer. Keep the integration thin — one middleware registration, one config file, no published view overrides — so that replacing it with twenty lines of your own middleware is a contained afternoon rather than a migration.
How can I help you explore Laravel packages today?