Laravel 13.39.0 вийшов 27 жовтня і вмістив близько 55 змін. Попередній тиждень ми підсумували оглядом 13.36-13.38 про репліки бази даних, а цього разу спільної теми немає - зате є кілька невеликих API, які закривають давні незручності в Eloquent, чергах і тестах. Нижче - лише нове, з прикладами, і короткий список виправлень, які варто знати.
Моделі без первинного ключа
Атрибут #[Table] уміє задавати ім'я таблиці, ключ, тип ключа й часові мітки. Але key: null означає «бери з властивості», а властивість за замовчуванням дорівнює id. Тож для таблиці без первинного ключа (журнал подій, проміжна таблиця, представлення в базі) доводилося поруч з атрибутом лишати protected $primaryKey = null.
Тепер достатньо key: false (#62113):
use Illuminate\Database\Eloquent\Attributes\Table;
#[Table(name: 'activity_log', key: false)]
class Activity extends Model
{
}
getKeyName() такої моделі повертає null. У першій версії модель лишалася автоінкрементною, тож Activity::create() вставляв рядок і одразу падав з ArgumentCountError, намагаючись записати згенерований ключ. У межах того самого випуску це виправили (#62205): з key: false модель тепер і неінкрементна. Якщо ви явно передасте incrementing в атрибуті, ваше значення має пріоритет.
Пам'ятайте, що без ключа не працює все, що спирається на getKey(): find(), save() для вже наявного запису, delete() на екземплярі, прив'язка моделей до маршрутів. Для журналів, куди лише пишуть і з яких читають запитами, цього й не треба.
session:garbage-collect замість лотереї
Драйвери сесій file і database чистять прострочені сесії за лотереєю: на кожному запиті з ймовірністю з session.lottery (за замовчуванням 2 зі 100) фреймворк запускає збирач сміття. Платить за це користувач, якому «пощастило», вночі прибирання майже не відбувається, а під піковим навантаженням воно може запускатися кілька разів паралельно.
Нова команда (#62104) викликає той самий gc() поточного драйвера з session.lifetime і дозволяє винести прибирання в планувальник:
// routes/console.php
use Illuminate\Support\Facades\Schedule;
Schedule::command('session:garbage-collect')->hourly();
А лотерею вимкнути в config/session.php:
'lottery' => [0, 100],
Команду спершу забули зареєструвати, тож php artisan session:garbage-collect відповідав «Command is not defined»; реєстрацію додали ще до випуску (#62150). Для Redis і Memcached команда нічого не змінює: там сесії й так зникають за TTL.
Тривалість у JobAttempted
JobProcessed уже мав властивість duration, але лише для успішних завдань. Подія JobAttempted, яка спрацьовує після кожної спроби, успішної чи ні, тепер теж її має (#62118): час виконання в мілісекундах як float, виміряний у finally, тобто і тоді, коли завдання кинуло виняток. Друга зміна (#62155) рахує тривалість і для sync-черги, тож dispatchSync() теж передає duration в обидві події.
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) на методі чи класі тесту знімає диспетчер подій зі з'єднань на час міграції:
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). Після того як у 13.36 конструктори правил отримали майже всі правила, розмір масиву все ще доводилося дописувати рядком 'min:1'. Тепер є min(), max(), between() і exactly(), як у Rule::string():
'photos' => [Rule::required(), Rule::array()->min(1)->max(10)],
'answers' => [Rule::array(['id', 'value'])->exactly(3)],
Зверніть увагу, що метод називається exactly(), а в рядок він перетворюється на size:3.
forceCreate() і forceCreateQuietly() для BelongsToMany (#62136). Раніше $team->users()->forceCreate([...]) проходив до конструктора запитів Eloquent: модель створювалася, але до команди не прив'язувалася. Тепер запис у проміжній таблиці з'являється, як і з create().
restoreOrCreate() і createOrRestore() на зв'язках (#62146, #62204). Це були макроси конструктора запитів, тож на зв'язку вони його ігнорували: на hasMany створювали модель без зовнішнього ключа, на belongsToMany не прив'язували нову модель, а на hasManyThrough могли відновити чужий запис, бо id приходив із проміжної таблиці. Тепер обидва методи є на HasOneOrMany, BelongsToMany, HasOneThrough і HasManyThrough і працюють через сам зв'язок. Якщо ви колись обходили це вручну, обхід можна прибрати.
Фейки, які нарешті фейкові
Три виправлення в тестових фейках з тих, що непомітно ламають тести:
Mail::html() і Mail::plain() під Mail::fake() ішли повз фейк до справжнього mailer і надсилали реальні листи, а assertNothingOutgoing() при цьому проходив (#62162). Якщо ви користуєтеся цими методами, перевірте, куди йшла пошта з CI.
Process::fake(['ls' => 1]) - скорочення з кодом виходу чи винятком - працювали лише з run(), а з start() і pool() кидали LogicException (#62200).
Bus::withoutDispatchingAfterResponses() тепер є і на BusFake (#62130): можна сфейкати частину завдань і водночас виконати те, що запускається через dispatchAfterResponse().
Помітні виправлення
- Атомарний запис кешу (#62117).
config:cache, route:cache і event:cache спершу обрізали файл, потім заповнювали його, тож запит посеред розгортання міг прочитати порожній конфіг. З opcache.validate_timestamps=0 OPcache запам'ятовував порожній файл до перезапуску PHP-FPM. Тепер файл пишеться в тимчасовий і перейменовується.
RefreshDatabase відкочує всі рівні транзакцій (#62194). Тест, що лишив відкриту транзакцію, більше не валить усі наступні з cannot start a transaction within a transaction на SQLite у пам'яті.
phar:// у Filesystem::exists() і isFile() (#62164) тепер повертає false, а get() кидає FileNotFoundException. Це прибирає ризик десеріалізації через phar, коли шлях приходить від користувача.
queue:retry з видаленою моделлю (#62124). Раніше одне завдання, чия модель зникла, зупиняло всю команду, і решта лишалася у failed_jobs. Тепер таке завдання повертається в чергу як є, а воркер уже видаляє його чи знову позначає як невдале.
- Попередній маршрут в Octane (#62123). На 404
Route::currentRouteName() і Route::input() повертали маршрут і параметри попереднього запиту того самого воркера - разом із прив'язаною моделлю іншого користувача.
Чи варто оновлюватися
Так, і без особливих приготувань: нові можливості опційні, а виправлення не змінюють поведінку робочого коду. Першочергово - якщо ви кешуєте конфіг під час деплою без зупинки трафіку, працюєте на Octane або тестуєте листи через Mail::html(). Після оновлення варто зробити три речі: перенести прибирання сесій у планувальник, якщо у вас драйвер file чи database; прибрати protected $primaryKey = null там, де вже стоїть #[Table]; і перевірити слухачі JobAttempted, якщо хочете метрики тривалості для невдалих спроб.