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

By Dinesh Uprety
Published: 2026-10-29
Updated: 2026-10-29
Canonical: https://laranepal.com/blog/laravel-1339-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:

```bash
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:

```php
$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:

```php
$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":

```php
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:

```php
use Illuminate\Support\Facades\Schedule;

Schedule::command('session:garbage-collect')->hourly();
```

Then turn the lottery off so requests stop doing it:

```php
'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.

![](https://laranepal.com/storage/am6sRdbZJYHx9xXQukLZtvZa4UlIgoiSXRhd9K0f.png)

## 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()`:

```php
// 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:

```php
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

- Laravel 13.39 release summary (13.39.0, 27 Oct 2026; PR references for each change): https://laravel-vuejs.space/laravel-13-39-keyless-models-restore-or-create
- Laravel News releases archive (latest listed at check time was 13.38, 21 Oct 2026): https://laravel-news.com/tag/releases
- Laravel News 13.39 release notes (checked 29 Oct 2026 — now lists 13.39 as the latest release, dated 28 Oct 2026, covering session:garbage-collect, keyless models and JobAttempted durations; this closes the earlier archive gap): https://laravel-news.com/laravel-13-39-0
- Packagist mirror listing v13.39.0 as the latest laravel/framework version (checked 28 Oct 2026; a mirror listing, not Laravel's own release page): https://intel.aikido.dev/packages/packagist/laravel/framework
