Caption: The command file is missing on v13.38.0. It landed in 13.39.0 on 27 Oct 2026. Packagist this morning is 13.40.0, and the command, Table attribute, and Model.php match 13.39.0.
Introduction
I keep a throwaway Laravel 13 app for framework bumps. Two things were sitting in it: the sessions table on the database driver, and an activity_log table with no id column.
The sessions table grows because cleanup is a lottery. config/session.php on the Laravel 13 skeleton ships 'lottery' => [2, 100]. StartSession rolls that on each request that starts a session. On a quiet VPS the delete almost never runs. Expired rows are already unreadable. They still take space. The activity log is the other itch. On 13.38, #[Table(key: null)] does not turn the key off. null means leave the property, and the property defaults to id.
Laravel 13.39.0 shipped 27 Oct 2026. Laravel News covered it 28 Oct 2026, with the cleanup tutorial on 30 Oct. I am writing this on 4 Nov 2026 (2026-W45). The queue row is 2026-W44. Nothing was tagged 2026-W45, so this is the lowest undone priority. Packagist this morning is v13.40.0 (3 Nov 2026). I read the command, the handlers, StartSession, Table, and initializeModelAttributes(). The command file and Table.php match v13.39.0. Model.php is byte-identical. The command file is 404 on v13.38.0. I did not boot the throwaway app.
About the feature
The 13.x session docs, retrieved 4 Nov 2026, still describe drivers and config/session.php. They do not mention session:garbage-collect. The Eloquent docs show #[Table(key: 'flight_id')] and #[Table(key: 'uuid', keyType: 'string', incrementing: false)]. They do not show key: false. Both APIs are the 13.39 release, PRs #62104, #62150, #62113, and #62205, plus the Laravel News pieces above.
Illuminate\Session\Console\SessionGarbageCollectCommand has the signature session:garbage-collect and the description Run the session garbage collector. There are no options. handle() loads the default driver and calls:
$this->laravel['session']->driver()->getHandler()->gc(
(int) $this->laravel['config']->get('session.lifetime') * 60
);
session.lifetime is minutes. gc() wants seconds. The skeleton default is 120, so the call passes 7200. The command then always runs $this->components->info('Session garbage collector ran successfully.'). It does not print the integer gc() returned.
StartSession::collectGarbage() is the old path. configHitsLottery() is random_int(1, $config['lottery'][1]) <= $config['lottery'][0]. With [2, 100], a roll of 1 or 2 runs gc() inside that request. With [0, 100], random_int(1, 100) <= 0 is never true. That does not turn off a cache TTL.
DatabaseSessionHandler::gc() deletes on the write PDO: where('last_activity', '<=', currentTime() - $lifetime). currentTime() is Carbon::now()->getTimestamp(). read() already returns an empty string when last_activity is older than the lifetime, so a user cannot resume that row while it waits. The skeleton migration indexes last_activity. FileSessionHandler::gc() deletes old files. CacheBasedSessionHandler::gc() and CookieSessionHandler::gc() return 0. Redis, Memcached, DynamoDB, and APC use the cache handler. The cookie driver keeps the payload in the browser.
#[Table(key: false)] is the other 13.39 change. On v13.38.0 the constructor parameter is ?string $key. On v13.39.0 it is string|false|null. initializeModelAttributes() only replaces the key when $this->primaryKey === 'id' and $table->key !== null. false becomes null. null does not enter that if, so key: null leaves id in place. A property that is already something other than id wins. The framework test test_primary_key_property_takes_precedence is that case: the attribute says attribute_id, the property says property_id, and getKeyName() is property_id.
#62205, in the same 13.39.0 tag, sets $incrementing = false when key === false and the primary key is already null. Without that, create() inserted the row and then threw ArgumentCountError: Too few arguments to function Model::setAttribute(). I would not see that throw on 13.39.0 or 13.40.0. Both tags include the elseif.
Caption: [2, 100] sometimes deletes inside StartSession. The command always calls gc() and then prints success. [0, 100] makes the request roll fail. It does not expire Redis keys.
Why I picked it
The quiet box is why I want the command. A couple of dozen requests a day means a 2-in-100 lottery cleans sessions less than once a day. The 30 Oct tutorial schedules it hourly against the 120-minute lifetime. key: false is for the activity log: no fake id, and key: null must not silently keep id.
Where it can be used
I would schedule the command for database and file. Those are the handlers whose gc() deletes. New Laravel 13 apps default to SESSION_DRIVER=database. I would not schedule it for Redis, Memcached, DynamoDB, APC, or cookie. Those handlers return 0, and the success line looks the same.
On more than one scheduler, the database driver should use onOneServer(). The scheduling docs, retrieved 4 Nov 2026, require a shared database, memcached, dynamodb, or redis cache. The first server takes an atomic lock. Skip it if the app already calls Schedule::alwaysOnOneServer(). The file driver is per server unless session.files is shared.
key: false is for an append-only log, not User. The docs still require one id and still reject composite keys. This attribute adds neither. JobAttempted::$duration (milliseconds, including failures and the sync driver) is the same release, not this lab.
Benefit
Both APIs live in laravel/framework. v13.39.0 and v13.40.0 composer.json both require php: ^8.3 and replace illuminate/session and illuminate/database with self.version. I do not composer require illuminate/session.
ArtisanServiceProvider registers it as SessionGarbageCollect, in the production command list, not under $devCommands. After the schedule is actually deleting, [0, 100] takes the sweep off the request that won the lottery.
Caption: One package. Database and file return a count the command ignores. Cache and cookie return 0 and still print success. The array driver only matters in tests.
Practical example from a recent application
Lab shape from the queue notes and tag v13.40.0. Confirm composer show laravel/framework is 13.39.0 or newer. I would bump to 13.40.0. Laravel News had not published a 13.40 note as of this morning. The GitHub release is 3 Nov 2026 15:08 UTC. It also adds Queue::whenJobRunsLongerThan, Schedule::whenTaskRunsLongerThan, and a concurrency result-envelope authentication fix (PR #62297). I am not walking through that fix, and I would not pin 13.39.0.
composer show laravel/framework | sed -n '1,8p'
# name : laravel/framework
# versions : * v13.40.0 # Packagist 3 Nov 2026. These APIs landed in 13.39.0.
php artisan session:garbage-collect
If that line still says 13.38.x, stop. Artisan prints Command "session:garbage-collect" is not defined. #62104 added the class. #62150 registered it. Both are in the 13.39.0 tag.
Keep SESSION_DRIVER=database. Insert a row older than session.lifetime and assert the id is gone. The delete uses <=, so five extra minutes is enough. The info line has no count. Assert the table.
use Illuminate\Support\Facades\DB;
$id = 'expired-lab-session';
DB::table('sessions')->insert([
'id' => $id,
'payload' => base64_encode('a:0:{}'),
'last_activity' => now()->subMinutes((int) config('session.lifetime') + 5)->getTimestamp(),
]);
$this->artisan('session:garbage-collect')->assertSuccessful();
$this->assertDatabaseMissing('sessions', ['id' => $id]);
Schedule it in routes/console.php. Zero the lottery only after a planted row disappears. Zeroing it first, with no schedule, means nothing sweeps the table.
use Illuminate\Support\Facades\Schedule;
Schedule::command('session:garbage-collect')
->hourly()
->onOneServer();
'lottery' => [0, 100],
Failure I hit if SESSION_DRIVER=redis. The command still exits 0 and prints Session garbage collector ran successfully. The key stays until its TTL. CacheBasedSessionHandler::gc() returns 0, and handle() ignores that. Memcached, DynamoDB, APC, and cookie do the same. Leave the command off. The success line is not a deleted-row count.
The model, on 13.39.0 or 13.40.0. Drop any $primaryKey property so the default id can be replaced. protected $guarded = [] is only so create() will accept event in the lab.
use Illuminate\Database\Eloquent\Attributes\Table;
use Illuminate\Database\Eloquent\Model;
#[Table(name: 'activity_log', key: false)]
class Activity extends Model
{
protected $guarded = [];
}
$activity = new Activity;
// null key, not incrementing. create() uses insert(), not insertGetId().
assert($activity->getKeyName() === null);
assert($activity->getIncrementing() === false);
Caption: The attribute runs only while primaryKey is still id. key: null does not disable it. On 13.38, false is not stored as false.
Failure I hit if I copy key: false onto 13.38, or onto a model that already names its key. app/Models/User.php on laravel/laravel 13.x has no strict_types, and neither does Table.php on v13.38.0. I reproduced that constructor outside the framework: false coerces to "", and initializeModelAttributes() assigns it because it is not null. A model file with strict_types=1 throws TypeError: Argument #2 ($key) must be of type ?string, false given. The 13.39 union type keeps false as a boolean.
On 13.39 and 13.40 the other miss is a property that is not id:
#[Table(name: 'activity_log', key: false)]
class Activity extends Model
{
protected $primaryKey = 'event_id';
}
// getKeyName() is still event_id. incrementing stays true.
Remove the property. key: null does not disable the key. No CVE ships with this command.
Conclusion
What I would keep: laravel/framework at 13.40.0, SESSION_DRIVER=database, the command hourly with onOneServer() on this multi-box scheduler, and 'lottery' => [0, 100] only after a planted row disappears. Activity uses #[Table(name: 'activity_log', key: false)] with no $primaryKey property. Redis stays on TTL. Next I would give file-driver nodes their own schedule, without onOneServer(), unless the session files are shared.
Did you hit the same wall?
I got stuck on Session garbage collector ran successfully. with SESSION_DRIVER=redis, and on #[Table(key: false)] beside protected $primaryKey = 'event_id'. Did you hit the same thing — Command "session:garbage-collect" is not defined. on 13.38, a lottery set to [0, 100] before the schedule existed, onOneServer() on a local file session disk, or key: false on 13.38 becoming an empty string? Tell me in the comments. I read them.
Need this done on your server?
I deploy and harden Laravel/CodeCanyon apps on cPanel or VPS, and offer monthly Server Watch retainers. Hire for deploy · Care plan
References
- Laravel 13.x session docs, retrieved 4 Nov 2026 (drivers,
config/session.php; nosession:garbage-collectcommand): https://laravel.com/docs/13.x/session - Laravel 13.x Eloquent docs, retrieved 4 Nov 2026 (
#[Table(key: 'flight_id')],incrementing: false; nokey: false): https://laravel.com/docs/13.x/eloquent - Laravel 13.x scheduling docs, retrieved 4 Nov 2026 (
onOneServer()needs thedatabase,memcached,dynamodb, orrediscache driver and a shared cache;Schedule::alwaysOnOneServer()): https://laravel.com/docs/13.x/scheduling - Laravel News, 28 Oct 2026, “Scheduled Session Garbage Collection in Laravel 13.39”: https://laravel-news.com/laravel-13-39-0
- Laravel News, 30 Oct 2026, “Delete Expired Sessions in Laravel With a Scheduled Command”: https://laravel-news.com/laravel-delete-expired-sessions
laravel/laravel13.xconfig/session.php(driverdefaultdatabase,lifetime120,lottery[2, 100]) andapp/Models/User.php(nostrict_types). Sessions migration indexeslast_activity.- PRs #62104 (command), #62150 (register it), #62113 (
key: false), #62205 (non-incrementingcreate()), #62118 and #62155 (JobAttempted::$duration) - Tag
v13.40.0: command, handlers,StartSession,Table.php, andModel.php. Command andTable.phpSHA-256 matchv13.39.0.Model.phpis byte-identical. The command file is absent onv13.38.0. - Packagist
laravel/frameworkv13.39.0 time 27 Oct 2026 14:04 UTC, published-time 14:05 UTC, GitHub release 14:06 UTC. v13.40.0 time 3 Nov 2026 15:07 UTC, published-time 15:08 UTC, GitHub release 15:08 UTC: https://packagist.org/packages/laravel/framework - Both
composer.jsonfiles:php: ^8.3.replacesetsilluminate/sessionandilluminate/databasetoself.version. - Also on v13.40.0, not this lab:
Queue::whenJobRunsLongerThan(#62141),Schedule::whenTaskRunsLongerThan(#62233), concurrency envelope authentication (#62297). Laravel News releases tag still listed 13.39 on 4 Nov 2026.