Laravel 13.39: Keyless Models, a restoreOrCreate That Respects the Relation, and Scheduled Session Cleanup

Laravel 13.39: Keyless Models, a restoreOrCreate That Respects the Relation, and Scheduled Session Cleanup

Laravel 13.39 shipped on 27 October 2026, and it is the quiet, useful kind of weekly release: no breaking changes, nothing new to publish, just a handful of fixes for bugs that could quietly give you the wrong row, the wrong user, or a session table that never gets cleaned up. Upgrade with the usual one-liner:

composer update laravel/framework

Here is what actually matters in it, in plain language, with the code you would write.

1. restoreOrCreate() finally stays inside the relation

This is the headline fix, and it is worth understanding because the old behaviour was silently wrong.

Say a user deletes their "Home" address, then adds it back. You would expect this to bring back that user's trashed address, or create a fresh one for them:

$request->user()->addresses()->restoreOrCreate(
['label' => 'Home'],
$request->safe()->except('label'),
);

Before 13.39, calling restoreOrCreate() (or createOrRestore()) on a relation ignored the relation itself. On a hasMany relation, a newly created row could come back without its foreign key. On a belongsToMany relation, the pivot table's id could overwrite the related key, so the wrong model got restored and new models were never attached. On a hasManyThrough relation, the intermediate table's id from the join could restore another parent's row. In other words, two users with a trashed "Home" address could collide.

In 13.39, the relation types (HasOneOrMany, BelongsToMany, and the through relations) have real restoreOrCreate() and createOrRestore() methods. They search with withTrashed() inside the relation — so the user_id constraint stays — restore the matching trashed row if there is one, and create through the relation if there is not.

Which one should you use? restoreOrCreate() looks for a trashed row first, then creates. createOrRestore() tries the insert first and falls back to restoring on a unique-constraint clash, which is the safer choice when two requests can race each other on a uniquely indexed column.

A related fix lands in the same release: belongsToMany finally gets forceCreate() and forceCreateQuietly(). Before, the call fell through to the plain Eloquent builder, so the model was saved but never attached — your count stayed at zero. Now it works like create(), with pivot data as the second argument:

$team = $user->teams()->forceCreate(
['name' => 'Core', 'is_internal' => true], // guarded columns allowed
['role' => 'owner'], // pivot data
);

2. Keyless models: #[Table(key: false)]

Some tables genuinely have no primary key: an append-only activity log, an import staging table, a database view. Laravel 13 already lets you describe a model with the #[Table] attribute, but key: null meant "use the default", which is id. Now key: false means "there is no key":

use Illuminate\Database\Eloquent\Attributes\Table;
use Illuminate\Database\Eloquent\Model;
 
#[Table(name: 'activity_log', key: false)]
class Activity extends Model
{
//
}

That replaces the old two-line workaround (protected $primaryKey = null; plus public $incrementing = false;). The first version of this change only set the key to null, so create() inserted the row and then crashed trying to set the new ID; the follow-up in the same release also turns off incrementing, so create() now saves the row cleanly.

One honest gotcha: no key means no find(). Finding, updating or deleting a loaded model instance, and route model binding, all need a key. Keyless models are for writing rows and querying them with where(). If you need to update one specific row later, that table needs a key.

3. Session cleanup moves from a lottery to the scheduler

If you use the file or database session driver, Laravel has always cleaned up expired sessions by lottery: 2 in every 100 requests ('lottery' => [2, 100] in config/session.php) paid for the cleanup. That means one unlucky visitor's request does the sweeping, quiet sites barely clean up at all, and busy sites clean up far too often.

13.39 adds a command you can schedule instead:

use Illuminate\Support\Facades\Schedule;
 
Schedule::command('session:garbage-collect')->hourly();

Then turn the lottery off so requests stop doing it:

'lottery' => [0, 100], // never clean up during a request

Cache-based drivers (Redis, Memcached) expire sessions on their own, so the command is a no-op for them — this one is only for file and database drivers.

4. Array size rules, without the string

Since 13.36, almost every validation rule has a fluent Rule:: builder — but array sizes still needed a string rule next to it. Now Rule::array() gets min(), max(), between() and exactly() (which compiles to size), matching Rule::string():

// Before
'tags' => ['required', Rule::array(), 'min:1', 'max:5'],
'cover' => ['nullable', Rule::array(['url', 'alt']), 'size:2'],
 
// After
'tags' => ['required', Rule::array()->min(1)->max(5)],
'cover' => ['nullable', Rule::array(['url', 'alt'])->exactly(2)],

Nothing changes for your frontend: the error still lands in form.errors.tags exactly as before.

5. Job timings now cover failures, too

JobProcessed already carried a $duration, but only for successful jobs on a worker. JobAttempted fires for every attempt — success or failure — and it now carries $duration as well, in milliseconds. The same timing was added to the sync queue, so dispatchSync() jobs report it too. That makes a practical difference when you are hunting a slow failure:

use Illuminate\Queue\Events\JobAttempted;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;
 
Event::listen(function (JobAttempted $event) {
if (! $event->successful() && $event->duration > 10_000) {
Log::warning('Slow failing job', [
'job' => $event->job->resolveName(),
'ms' => $event->duration,
]);
}
});

Before this, a job that took 28 seconds and then failed just looked like "failed". Now you can see the slowness itself.

6. The quiet wins: safer deploys and steadier tests

A few smaller changes are easy to miss but worth knowing:

  • Atomic cache files. config:cache, route:cache and event:cache used to truncate the cache file and then fill it, so a request booting mid-deploy could read an empty config — and with opcache.validate_timestamps=0, OPcache could keep that empty file until PHP-FPM restarted. All three now write to a temp file and rename it, like compiled views already did. If you run php artisan optimize on a live server, this is a real fix.
  • phar:// blocked in Filesystem::exists() and isFile(). A phar:// path can trigger object deserialization through PHP's file functions, so those calls now return false instead. Files inside the phar your app itself runs from (like a Laravel Zero build) are still allowed. This is defence in depth, not a reason to pass user-supplied paths to the filesystem — never do that in the first place.
  • Testing fixes. #[WithoutMigrationEvents] stops lazily-run migrations from polluting expectsDatabaseQueryCount(); RefreshDatabase now rolls back every transaction level, so one test leaving a transaction open no longer breaks the tests after it; Mail::fake() now fakes Mail::html() and Mail::plain() too (before, they sent real emails while assertNothingOutgoing() passed); and assertSeeText('0') no longer passes when the text is missing. If a test starts failing after this upgrade, it was probably wrong before.
  • Two sneaky bugs fixed. Http::batch() counted requests that failed through retry() or throw() as successes; and once() could return a cached value for a different object, because PHP reuses object IDs after an object is freed — in a User::lazy() loop, one user could get another user's result.

Should you upgrade?

Yes, in the usual way for a weekly minor: it is opt-in, with no breaking changes. The restoreOrCreate() fix alone is reason enough if you use soft deletes on relations, and the session-cleanup command is a one-line scheduler change for file / database session apps. As always, run your test suite after the bump — especially if you relied on any of the old behaviours above, like Mail::html() slipping past a fake.

Sources

Dinesh Uprety

WRITTEN BY

Senior Software Engineer • Writer @ Laranepal • PHP, Laravel, Livewire, TailwindCSS & VueJS • CEO @ Laranepal & Founder @ laracodesnap

Discussion

Login or register to comment or ask questions

No comments yet

Be the first to share your thoughts or ask a question.

Join the conversation

Sign in to share your thoughts with the community.