A user deletes their “Home” address, then adds it back. You call $user->addresses()->restoreOrCreate(['label' => 'Home']) and expect the old row to come back. Before 13.39 the relation was ignored: Laravel looked for any “Home” address, and if it had to create one, the user_id was missing. Laravel 13.39 fixes that, and adds keyless models, array size rules, a scheduled session cleanup and job timings for failed jobs.
TL;DR
- 13.39.0 was released on October 27, 2026 (14:06 UTC). No breaking changes and nothing to publish.
#[Table(key: false)]replacesprotected $primaryKey = null;and also turns off incrementing, socreate()works on keyless tables.restoreOrCreate()andcreateOrRestore()now go through the relation (hasMany,belongsToMany,hasManyThrough,hasOneThrough), andbelongsToMany()->forceCreate()finally attaches the model.- Run
session:garbage-collectfrom the scheduler instead of the request lottery.JobAttemptednow carries$duration(ms), even for failed and sync jobs.
What’s new at a glance#
| Change | PR | What it replaces |
|---|---|---|
#[Table(key: false)] | #62113, #62205 | $primaryKey = null plus $incrementing = false |
restoreOrCreate() / createOrRestore() on relations | #62146, #62204 | A builder macro that ignored the relation |
forceCreate() / forceCreateQuietly() on BelongsToMany | #62136 | Creating, then calling attach() yourself |
Size methods on Rule::array() | #62179 | Rule::array(), 'min:1' |
session:garbage-collect | #62104, #62150 | The 2-in-100 request lottery |
$duration on JobAttempted (worker and sync) | #62118, #62155 | Timing only successful worker jobs |
Testing: WithoutMigrationEvents, BusFake, MailFake, Process fakes, RefreshDatabase | #62201, #62130, #62162, #62200, #62194 | Flaky counts and fakes that leaked |
Atomic config:cache / route:cache / event:cache, phar:// blocked | #62117, #62164 | Empty cache files mid-deploy |
Keyless models with #[Table(key: false)]#
Some tables have no primary key: an append-only activity log, an import staging table, a view. Laravel 13 lets you configure a model with the #[Table] attribute, but key: null meant “use the default”, which is id. Now key: false means “there is no key”.
Laravel 13.38, then Laravel 13.39:
use Illuminate\Database\Eloquent\Attributes\Table;
use Illuminate\Database\Eloquent\Model;
#[Table(name: 'activity_log')]
class Activity extends Model
{
protected $primaryKey = null;
public $incrementing = false;
}use Illuminate\Database\Eloquent\Attributes\Table;
use Illuminate\Database\Eloquent\Model;
#[Table(name: 'activity_log', key: false)]
class Activity extends Model
{
//
}The first PR only set the key to null, so Activity::create() inserted the row and then threw an ArgumentCountError while trying to set the new ID. #62205, in the same release, also turns off incrementing. In our lab, getKeyName() is null, getIncrementing() is false and create() saves the row.
Gotcha: No key means no find()
find(), update() on a loaded model, delete() on an 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 row, give the table a key.
restoreOrCreate() that respects the relation#
restoreOrCreate() and createOrRestore() come from the SoftDeletes builder macros. On a relation they skipped the relation completely:
- on
hasMany, a newly created model had no foreign key; - on
belongsToMany, the pivotidcould overwrite the related key, so the wrong model was restored, and new models were never attached; - on
hasManyThrough/hasOneThrough, the intermediate table’sidfrom the join was used, so another parent’s row could be restored.
13.39 adds real restoreOrCreate() and createOrRestore() methods to HasOneOrMany, BelongsToMany and the through relations. They call withTrashed() on the relation query and then the relation’s own firstOrCreate() / createOrFirst().
use App\Http\Requests\StoreAddressRequest;
use Illuminate\Http\RedirectResponse;
public function store(StoreAddressRequest $request): RedirectResponse
{
// Brings back this user's trashed "Home" address, or creates one with user_id set
$request->user()->addresses()->restoreOrCreate(
['label' => $request->validated('label')],
$request->safe()->except('label'),
);
return back();
}We tested it with two users who both had a trashed “Home” address. The call restored the right user’s row and left the other one trashed. createOrRestore() on a new label created a row with the correct user_id.
Use createOrRestore() when the column has a unique index and you expect races: it tries the insert first and falls back to finding and restoring the row on a unique constraint violation.
Heads up: Small side effect on belongsToMany
When firstOrCreate() on a belongsToMany falls back to the related table, it now drops the same global scopes you removed on the relation. So $team->users()->withTrashed()->firstOrCreate([...]) now finds a trashed user who is not attached yet, instead of creating a duplicate.
forceCreate() on belongsToMany now attaches#
BelongsToMany had no forceCreate(), so the call went to the Eloquent builder. The model was saved but never attached, and $team->users()->count() stayed at 0.
Laravel 13.38, then Laravel 13.39:
$team = Team::forceCreate(['name' => 'Core', 'is_internal' => true]);
$user->teams()->attach($team, ['role' => 'owner']);$team = $user->teams()->forceCreate(
['name' => 'Core', 'is_internal' => true], // guarded columns allowed
['role' => 'owner'], // pivot data
);The signature matches create(): attributes, pivot data, then $touch. forceCreateQuietly() does the same without model events.
Array size rules on Rule::array()#
Since 13.36 almost every rule has a fluent builder. Array size still needed a string next to it. Now Rule::array() has min(), max(), between() and exactly() (which compiles to size), like Rule::string().
Laravel 13.38, then Laravel 13.39:
'tags' => ['required', Rule::array(), 'min:1', 'max:5'],
'cover' => ['nullable', Rule::array(['url', 'alt']), 'size:2'],'tags' => ['required', Rule::array()->min(1)->max(5)],
'cover' => ['nullable', Rule::array(['url', 'alt'])->exactly(2)],(string) Rule::array(['url', 'alt'])->between(1, 2) gives array:url,alt|between:1,2, and the validation parser splits it like a normal rule list. With Inertia, the error comes back in form.errors.tags exactly as before, so your Vue form does not change.
Schedule session cleanup with session:garbage-collect#
With the file or database session driver, Laravel deletes expired sessions on 2 in every 100 requests ('lottery' => [2, 100] in config/session.php). The unlucky request pays for the cleanup, quiet sites barely clean up, and busy sites clean up too often. The new command runs the driver’s garbage collector on demand, using session.lifetime.
use Illuminate\Support\Facades\Schedule;
Schedule::command('session:garbage-collect')->hourly();'lottery' => [0, 100], // never clean up during a requestRedis, Memcached and other cache-based drivers expire sessions on their own, so the command is a no-op for them. Note that #62104 added the command and #62150 registered it: both are in 13.39.0.
Job duration on JobAttempted, even for failures#
JobProcessed already had a $duration, but only for successful jobs on a worker. JobAttempted fires for every attempt, success or failure, and now has $duration too, in milliseconds as a float. A second PR adds the same timing to the sync queue, so dispatchSync() jobs report it as well.
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,
]);
}
});This is how you spot the API job that now takes 28 seconds before it times out, instead of only seeing “failed”.
Six testing upgrades#
Small changes, but each one removes a flaky test or a real email sent from CI.
#[WithoutMigrationEvents](#62201): withLazilyRefreshDatabase, migrations run on the first query of the first test, soexpectsDatabaseQueryCount()counted them. In our lab the same test saw 37 queries without the attribute and 1 with it. Put it on the method or the class.RefreshDatabaserolls back every level (#62194): a test that leaves a transaction open no longer breaks every test after it with “cannot start a transaction within a transaction”.Bus::fake()+withoutDispatchingAfterResponses()(#62130): you can fake some jobs and still run thedispatchAfterResponse()one.Mail::html()andMail::plain()are faked (#62162): before,Mail::fake()forwarded them to the real mailer, so they sent real emails whileassertNothingOutgoing()passed.Process::fake()shorthands withstart()andpool()(#62200):['ls' => 1]and['ls' => new RuntimeException]used to throw aLogicExceptionfor async processes.assertSeeText('0')no longer passes when the text is missing (#62126). If a test starts failing after the upgrade, it was probably wrong before.
use Illuminate\Foundation\Testing\Attributes\WithoutMigrationEvents;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
class OrderStatsTest extends TestCase
{
use LazilyRefreshDatabase;
#[WithoutMigrationEvents]
public function test_stats_use_one_query(): void
{
$this->expectsDatabaseQueryCount(1);
$this->assertSame(0, Order::query()->pending()->count());
}
}Safer deploys and a phar:// block#
Atomic cache files (#62117). config:cache and route:cache used put() and event:cache used file_put_contents(). The file was truncated, then filled, so a request booting at that moment could read an empty config. With opcache.validate_timestamps=0, OPcache kept that empty file until PHP-FPM restarted. All three now write to a temp file and rename it, like services.php and compiled views already did. If you run php artisan optimize on a live server, this is a quiet but real fix.
phar:// is blocked in Filesystem::exists() and isFile() (#62164). Those calls go straight to PHP’s file functions, and a phar:// path can trigger object deserialization. They now return false (and File::get() throws FileNotFoundException). Files inside the phar your app runs from, like a Laravel Zero build, are still allowed (#62174).
Pro tip: Still validate user paths
This is defence in depth. Never pass a user-supplied path or filename to File::exists() or storeAs() in the first place. The docs got a new warning about user-controlled filenames in storeAs() this week too.
Fixes worth knowing#
Http::batch()counted requests that failed throughretry()orthrow()as successes, skippedcatch()and ranthen()(#62120).once()could return a cached value for a different object, because PHP reusesspl_object_id()after an object is freed. In aUser::lazy()loop, user 3 could get user 1’s result (#62121).- Octane: a 404 request no longer sees the previous request’s route and bound parameters through
Route::current()(#62123). - PostgreSQL database cache lock: taking a held lock inside a transaction (for example dispatching a
ShouldBeUniquejob) no longer aborts the whole transaction (#62114). - Pagination: counts for grouped
unionqueries (#62131) andsimplePaginate()with a custom page name (#62128). - Queues:
queue:retryno longer stops on a job whose model was deleted (#62124), and database queue jobs don’t fail on concurrency errors while reserving (#62145). - Markdown notifications attach an
embed()ed image once, not twice (#62163). - Also:
withoutMiddleware()with parameterised middleware (#62166),wherePivot()closures qualify columns (#62137), JSON:API includes without N+1 (#62152),install:broadcastingfindsapp.js/app.jsx(#62202), and Laravel Cloud exception reporting follow-ups (#62142, #62189).
Upgrade notes#
composer update laravel/frameworkand you are on 13.39. No config to publish.- If you scheduled
session:garbage-collect, set the lottery to[0, 100]so requests stop doing the same job. - Watch for tests that start failing:
assertSeeText('0'),Mail::html()underMail::fake()andRefreshDatabaserollbacks were hiding real problems. - On Laravel 12? 12.70.2 shipped the same day with only the Laravel Cloud exception reporting changes (#61934, #62186). None of the features above are in 12.x.
Check restoreOrCreate on your relation
it('restores the trashed address of this user only', function () {
[$ana, $bob] = User::factory(2)->create();
$home = $ana->addresses()->create(['label' => 'Home']);
$home->delete();
$bob->addresses()->create(['label' => 'Home'])->delete();
$restored = $ana->addresses()->restoreOrCreate(['label' => 'Home']);
expect($restored->is($home))->toBeTrue()
->and(Address::onlyTrashed()->count())->toBe(1);
});Recap#
#[Table(key: false)]gives you keyless models thatcreate()correctly.restoreOrCreate(),createOrRestore()andbelongsToMany()->forceCreate()now respect the relation.Rule::array()->min()->max()andsession:garbage-collectremove two bits of string config.JobAttempted::$durationtimes every attempt; six testing fixes stop flaky counts and leaking fakes.- Cache files are written atomically and
phar://paths are blocked inFilesystem.
FAQ#
Is Laravel 13.39 safe to upgrade to?
Yes. It is a minor release with no breaking changes. Tests that relied on assertSeeText('0') or on Mail::html() passing through a fake may start failing, because they were wrong before.
Did restoreOrCreate() exist before 13.39?
Yes, on the Eloquent builder for soft-deleting models. On a relation it ignored the relation, so it could restore another parent's row or create a row without the foreign key. 13.39 adds proper relation methods.
Do I need session:garbage-collect with Redis sessions?
No. Cache-based drivers expire sessions with a TTL. The command matters for the file and database drivers.
What unit is JobAttempted::$duration in?
Milliseconds, as a float rounded to two decimals. It is null only if the job never started.
Can I use #[Table(key: false)] with route model binding?
No. Binding, find() and instance updates need a primary key. Keyless models are for inserts and where() queries.
Next steps#
- Laravel 13.38: no more stale reads after a write
- Laravel 13.36: validation rule builders, OLAP and sticky queues
- Soft deleting in the Laravel docs
- Session configuration in the Laravel docs



