If your Laravel app uses the database or file session driver, expired sessions stay in storage until Laravel deletes them. On Laravel 13.39 or later, you can delete them with the session:garbage-collect Artisan command on a schedule:
// routes/console.phpuse Illuminate\Support\Facades\Schedule; Schedule::command('session:garbage-collect')->hourly();
The command runs the same cleanup Laravel already runs during some web requests. With the command on a schedule and the lottery turned off, no web request waits for the cleanup.
Note: The redis, memcached, dynamodb, apc, and cookie drivers expire sessions without a cleanup step, so apps that use them don't need the command.
Turn Off the Session Lottery
Without a scheduled command, Laravel deletes expired sessions during web requests. The lottery option in config/session.php sets how often:
'lottery' => [2, 100],
The two numbers are odds. The default [2, 100] gives each request that starts a session a 2 in 100 chance of running the cleanup. That request runs the delete before it reaches your route and waits for it to finish.
The odds stay the same whatever your traffic. At 1,000 requests a minute, about 20 requests a minute run the delete, and some of them can overlap. At 40 requests a day, cleanup runs less than once a day, and expired sessions stay in storage until it does.
Once the scheduler deletes expired sessions, set the first number to 0. A 0 in 100 chance means no web request runs the cleanup:
// config/session.php'lottery' => [0, 100],
If you leave it at [2, 100], the lottery and the command delete the same expired sessions, and the only cost is the extra queries.
Which Session Drivers Need Cleanup
| Driver | What cleanup does |
|---|---|
database |
Deletes rows where last_activity is older than the lifetime |
file |
Deletes session files older than the lifetime |
redis, memcached, dynamodb, apc |
Nothing. The cache store expires each session with a TTL |
cookie |
Nothing. The session lives in the browser |
New Laravel 13 applications use the database driver by default, and the default sessions migration indexes last_activity, which is the column the delete query filters on.
Users can't load an expired session before cleanup runs, because both drivers check a session's age when they read it. Cleanup frees the storage that expired sessions take up.
Running session:garbage-collect
Run the command by hand to check it before you schedule it:
php artisan session:garbage-collect
INFO Session garbage collector ran successfully.
The command uses your default session driver and session.lifetime, with no options to change either. It doesn't print how many sessions it deleted. With a cache-based driver, it prints the same success message and deletes nothing.
Hourly is a reasonable schedule for the default 120-minute lifetime. An expired session then stays in storage for an hour at most.
Run Session Cleanup on Multiple Servers
With the database driver, every server that runs the scheduler runs the same delete. Add onOneServer() so only one server runs it. It needs a shared cache store for its lock:
Schedule::command('session:garbage-collect') ->hourly() ->onOneServer();
If your app calls Schedule::alwaysOnOneServer(), you don't need to add it. See Running Laravel's Scheduler on Multiple Servers for that setup.
With the file driver, each server keeps its own session files, so run the command on every server. Only add onOneServer() if the sessions directory is on shared storage.
Delete Expired Sessions Before Laravel 13.39
On Laravel 13.38 and earlier, the command fails with Command "session:garbage-collect" is not defined. Schedule a closure that makes the same call as the command:
// routes/console.phpuse Illuminate\Support\Facades\Schedule; Schedule::call(function () { app('session')->driver()->getHandler()->gc((int) config('session.lifetime') * 60);})->name('delete-expired-sessions')->hourly()->onOneServer();
The closure gets the handler for your default session driver and calls its gc() method. The session.lifetime value is in minutes and gc() expects seconds, which is why the lifetime is multiplied by 60.
This works on Laravel 11, 12, and 13. A scheduled closure needs name() before onOneServer(), or Laravel throws an exception. On Laravel 10, add the closure to the schedule() method in app/Console/Kernel.php with $schedule->call().
Then set 'lottery' => [0, 100] as shown above.
Further Reading
- Scheduled Session Garbage Collection in Laravel 13.39
- A Deep Dive into Sessions in Laravel
- Stateful Scaling: Laravel Session Management in Kubernetes Clusters
Andrew Brown contributed session:garbage-collect in #62104, and Volodya Kurshudyan registered it with Artisan in #62150.