---
title: "Laravel 13.39: моделі без первинного ключа, session:garbage-collect і тривалість спроб завдань"
url: https://laravelukraine.com/blog/laravel-1339-modeli-bez-pervinnogo-kliuca-sessiongarbage-collect-i-trivalist-sprob-zavdan
date: 2026-10-28
---

# Laravel 13.39: моделі без первинного ключа, session:garbage-collect і тривалість спроб завдань

[Laravel 13.39.0](https://github.com/laravel/framework/releases/tag/v13.39.0) вийшов 27 жовтня і вмістив близько 55 змін. Попередній тиждень ми підсумували [оглядом 13.36-13.38 про репліки бази даних](https://laravelukraine.com/blog/laravel-1336-1338-repliki-bazi-danix-bez-zastarilix-danix), а цього разу спільної теми немає - зате є кілька невеликих API, які закривають давні незручності в Eloquent, чергах і тестах. Нижче - лише нове, з прикладами, і короткий список виправлень, які варто знати.

### Моделі без первинного ключа

Атрибут `#[Table]` уміє задавати ім'я таблиці, ключ, тип ключа й часові мітки. Але `key: null` означає «бери з властивості», а властивість за замовчуванням дорівнює `id`. Тож для таблиці без первинного ключа (журнал подій, проміжна таблиця, представлення в базі) доводилося поруч з атрибутом лишати `protected $primaryKey = null`.

Тепер достатньо `key: false` ([#62113](https://github.com/laravel/framework/pull/62113)):

```php
use Illuminate\Database\Eloquent\Attributes\Table;

#[Table(name: 'activity_log', key: false)]
class Activity extends Model
{
}
```

`getKeyName()` такої моделі повертає `null`. У першій версії модель лишалася автоінкрементною, тож `Activity::create()` вставляв рядок і одразу падав з `ArgumentCountError`, намагаючись записати згенерований ключ. У межах того самого випуску це виправили ([#62205](https://github.com/laravel/framework/pull/62205)): з `key: false` модель тепер і неінкрементна. Якщо ви явно передасте `incrementing` в атрибуті, ваше значення має пріоритет.

Пам'ятайте, що без ключа не працює все, що спирається на `getKey()`: `find()`, `save()` для вже наявного запису, `delete()` на екземплярі, прив'язка моделей до маршрутів. Для журналів, куди лише пишуть і з яких читають запитами, цього й не треба.

### session:garbage-collect замість лотереї

Драйвери сесій `file` і `database` чистять прострочені сесії за лотереєю: на кожному запиті з ймовірністю з `session.lottery` (за замовчуванням 2 зі 100) фреймворк запускає збирач сміття. Платить за це користувач, якому «пощастило», вночі прибирання майже не відбувається, а під піковим навантаженням воно може запускатися кілька разів паралельно.

Нова команда ([#62104](https://github.com/laravel/framework/pull/62104)) викликає той самий `gc()` поточного драйвера з `session.lifetime` і дозволяє винести прибирання в планувальник:

```php
// routes/console.php
use Illuminate\Support\Facades\Schedule;

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

А лотерею вимкнути в `config/session.php`:

```php
'lottery' => [0, 100],
```

Команду спершу забули зареєструвати, тож `php artisan session:garbage-collect` відповідав «Command is not defined»; реєстрацію додали ще до випуску ([#62150](https://github.com/laravel/framework/pull/62150)). Для Redis і Memcached команда нічого не змінює: там сесії й так зникають за TTL.

### Тривалість у JobAttempted

`JobProcessed` уже мав властивість `duration`, але лише для успішних завдань. Подія `JobAttempted`, яка спрацьовує після кожної спроби, успішної чи ні, тепер теж її має ([#62118](https://github.com/laravel/framework/pull/62118)): час виконання в мілісекундах як `float`, виміряний у `finally`, тобто і тоді, коли завдання кинуло виняток. Друга зміна ([#62155](https://github.com/laravel/framework/pull/62155)) рахує тривалість і для sync-черги, тож `dispatchSync()` теж передає `duration` в обидві події.

```php
use Illuminate\Queue\Events\JobAttempted;

Event::listen(function (JobAttempted $event) {
    Metrics::histogram('queue.attempt_ms', $event->duration, [
        'job' => $event->job->resolveName(),
        'ok' => $event->successful(),
    ]);
});
```

Тут `Metrics` - умовний клієнт вашої системи метрик. Практична користь: завдання, яке звертається до зовнішнього API і падає за таймаутом, тепер видно в метриках, а не лише в `failed_jobs`. `duration` може бути `null`, якщо завдання впало ще до запуску, тож у слухачі це варто врахувати.

### WithoutMigrationEvents для тестів

З `LazilyRefreshDatabase` міграції запускаються на першому запиті до бази всередині тесту. Тому `DB::listen()` чи `expectsDatabaseQueryCount()` цього тесту бачать і всі запити `migrate:fresh`. Тест проходить, коли до нього хтось уже змігрував базу, і падає, коли запущений сам, через `--filter` або першим у паралельному воркері.

Атрибут `#[WithoutMigrationEvents]` ([#62201](https://github.com/laravel/framework/pull/62201)) на методі чи класі тесту знімає диспетчер подій зі з'єднань на час міграції:

```php
use Illuminate\Foundation\Testing\Attributes\WithoutMigrationEvents;

class UserTest extends TestCase
{
    use LazilyRefreshDatabase;

    #[WithoutMigrationEvents]
    public function test_it_runs_one_query()
    {
        $this->expectsDatabaseQueryCount(1);

        User::first();
    }
}
```

Атрибут читається і з батьківських класів, тож його можна поставити один раз на базовий `TestCase`.

### Валідація і зв'язки

**Розміри в `Rule::array()`** ([#62179](https://github.com/laravel/framework/pull/62179)). Після того як у 13.36 конструктори правил отримали майже всі правила, розмір масиву все ще доводилося дописувати рядком `'min:1'`. Тепер є `min()`, `max()`, `between()` і `exactly()`, як у `Rule::string()`:

```php
'photos' => [Rule::required(), Rule::array()->min(1)->max(10)],
'answers' => [Rule::array(['id', 'value'])->exactly(3)],
```

Зверніть увагу, що метод називається `exactly()`, а в рядок він перетворюється на `size:3`.

**`forceCreate()` і `forceCreateQuietly()` для `BelongsToMany`** ([#62136](https://github.com/laravel/framework/pull/62136)). Раніше `$team->users()->forceCreate([...])` проходив до конструктора запитів Eloquent: модель створювалася, але до команди не прив'язувалася. Тепер запис у проміжній таблиці з'являється, як і з `create()`.

**`restoreOrCreate()` і `createOrRestore()` на зв'язках** ([#62146](https://github.com/laravel/framework/pull/62146), [#62204](https://github.com/laravel/framework/pull/62204)). Це були макроси конструктора запитів, тож на зв'язку вони його ігнорували: на `hasMany` створювали модель без зовнішнього ключа, на `belongsToMany` не прив'язували нову модель, а на `hasManyThrough` могли відновити чужий запис, бо `id` приходив із проміжної таблиці. Тепер обидва методи є на `HasOneOrMany`, `BelongsToMany`, `HasOneThrough` і `HasManyThrough` і працюють через сам зв'язок. Якщо ви колись обходили це вручну, обхід можна прибрати.

### Фейки, які нарешті фейкові

Три виправлення в тестових фейках з тих, що непомітно ламають тести:

- **`Mail::html()` і `Mail::plain()`** під `Mail::fake()` ішли повз фейк до справжнього mailer і надсилали реальні листи, а `assertNothingOutgoing()` при цьому проходив ([#62162](https://github.com/laravel/framework/pull/62162)). Якщо ви користуєтеся цими методами, перевірте, куди йшла пошта з CI.
- **`Process::fake(['ls' => 1])`** - скорочення з кодом виходу чи винятком - працювали лише з `run()`, а з `start()` і `pool()` кидали `LogicException` ([#62200](https://github.com/laravel/framework/pull/62200)).
- **`Bus::withoutDispatchingAfterResponses()`** тепер є і на `BusFake` ([#62130](https://github.com/laravel/framework/pull/62130)): можна сфейкати частину завдань і водночас виконати те, що запускається через `dispatchAfterResponse()`.

### Помітні виправлення

- **Атомарний запис кешу** ([#62117](https://github.com/laravel/framework/pull/62117)). `config:cache`, `route:cache` і `event:cache` спершу обрізали файл, потім заповнювали його, тож запит посеред розгортання міг прочитати порожній конфіг. З `opcache.validate_timestamps=0` OPcache запам'ятовував порожній файл до перезапуску PHP-FPM. Тепер файл пишеться в тимчасовий і перейменовується.
- **`RefreshDatabase` відкочує всі рівні транзакцій** ([#62194](https://github.com/laravel/framework/pull/62194)). Тест, що лишив відкриту транзакцію, більше не валить усі наступні з `cannot start a transaction within a transaction` на SQLite у пам'яті.
- **`phar://` у `Filesystem::exists()` і `isFile()`** ([#62164](https://github.com/laravel/framework/pull/62164)) тепер повертає `false`, а `get()` кидає `FileNotFoundException`. Це прибирає ризик десеріалізації через phar, коли шлях приходить від користувача.
- **`queue:retry` з видаленою моделлю** ([#62124](https://github.com/laravel/framework/pull/62124)). Раніше одне завдання, чия модель зникла, зупиняло всю команду, і решта лишалася у `failed_jobs`. Тепер таке завдання повертається в чергу як є, а воркер уже видаляє його чи знову позначає як невдале.
- **Попередній маршрут в Octane** ([#62123](https://github.com/laravel/framework/pull/62123)). На 404 `Route::currentRouteName()` і `Route::input()` повертали маршрут і параметри попереднього запиту того самого воркера - разом із прив'язаною моделлю іншого користувача.

### Чи варто оновлюватися

Так, і без особливих приготувань: нові можливості опційні, а виправлення не змінюють поведінку робочого коду. Першочергово - якщо ви кешуєте конфіг під час деплою без зупинки трафіку, працюєте на Octane або тестуєте листи через `Mail::html()`. Після оновлення варто зробити три речі: перенести прибирання сесій у планувальник, якщо у вас драйвер `file` чи `database`; прибрати `protected $primaryKey = null` там, де вже стоїть `#[Table]`; і перевірити слухачі `JobAttempted`, якщо хочете метрики тривалості для невдалих спроб.
