Мажорні релізи Laravel виходять раз на рік, і 13-й — з тих, де читати release notes цікавіше, ніж upgrade guide: список breaking changes короткий, зате половина з них знаходиться не при апгрейді, а через тиждень у продакшені. Нижче — те, що я перевірив по коду встановленої версії v13.30.1, з посиланнями на release notes і upgrade guide.
Ціна апгрейду
Мінімальна версія PHP — 8.3 (підтримувані: 8.3–8.5). У composer.json фреймворку тепер nesbot/carbon: ^3.8.4 без гілки 2.x і symfony/*: ^7.4.0 || ^8.0.0, тож пакети, що тримаються за Carbon 2 або Symfony 6, доведеться оновити або викинути. Сам список залежностей із гайду: laravel/framework до ^13.0, laravel/tinker до ^3.0, laravel/boost до ^2.0, phpunit/phpunit до ^12.0, pestphp/pest до ^4.0.
Одна пастка, яка не має нічого спільного з Laravel. З'явилась залежність symfony/polyfill-php85, а вона на PHP < 8.5 оголошує глобальні array_first() і array_last(). Якщо у проєкті живий laravel/helpers або власний хелпер із такою назвою — буде або фатальна помилка, або, гірше, тихо інша семантика: історичний array_first() приймав колбек і повертав перший елемент, що йому відповідає, а поліфіл повертає просто перший елемент масиву. Лікування одне — Arr::first($array, fn ($v) => ...).
CSRF став PreventRequestForgery
Найпомітніша зміна для будь-якого застосунку з формами. VerifyCsrfToken перейменовано на Illuminate\Foundation\Http\Middleware\PreventRequestForgery; старі імена лишились як задепрековані аліаси. Крім токена мідлвар тепер дивиться на заголовок Sec-Fetch-Site:
protected function hasValidOrigin($request)
{
$secFetchSite = $request->header('Sec-Fetch-Site');
if ($secFetchSite === 'same-origin') {
return true;
}
if ($secFetchSite === 'same-site' && static::$allowSameSite) {
return true;
}
if (static::$originOnly) {
throw new OriginMismatchException('Origin mismatch.');
}
return false;
}
У handle() порядок такий: читальний метод → тести → виключені шляхи → перевірка origin → звірка токенів. За замовчуванням це м'яко: запит із Sec-Fetch-Site: same-origin проходить одразу, решта падає на звичну перевірку _token.
Налаштування переїхало в bootstrap/app.php:
->withMiddleware(function (Middleware $middleware) {
$middleware->preventRequestForgery(
except: ['webhooks/*'],
originOnly: false,
allowSameSite: false,
);
})
validateCsrfTokens() працює, але позначений @deprecated. Ризикований тут originOnly: true: у цьому режимі запит без Sec-Fetch-Site (старий браузер, curl, нативний клієнт, деякі проксі) отримує OriginMismatchException і 403 — до звірки токена справа не доходить взагалі, навіть якщо токен валідний. Для порівняння, TokenMismatchException мапиться на звичні 419.
У тестах і виключеннях мідлварів шукайте старе ім'я по всьому репозиторію: ->withoutMiddleware([PreventRequestForgery::class]).
Кеш і сесія більше не десеріалізують що завгодно
Скелет застосунку тепер має в config/cache.php рядок 'serializable_classes' => false, а в config/session.php — 'serialization' => 'json'. Це захист від gadget chain при витоку APP_KEY, і він має цілком матеріальні наслідки. Значення serializable_classes доходить до стору й лягає прямо в третій аргумент unserialize():
if ($this->serializableClasses !== null) {
return unserialize($value, ['allowed_classes' => $this->serializableClasses]);
}
Тобто якщо ви кешували DTO або value object, після апгрейду з кешу повернеться __PHP_Incomplete_Class, і зламається воно не на деплої, а на першому попаданні в теплий кеш. Варіанти: перелічити класи явно або (краще) класти в кеш масиви й збирати об'єкт на виході.
Із сесією простіше й жорсткіше: зміна php → json знеструмлює всі активні сесії. Якщо ви не зберігаєте в сесії об'єкти й готові до разового розлогінення — переходьте; якщо ні — лишіть php на час апгрейду і переїжджайте окремим релізом.
Сюди ж дрібниця з низьким впливом: дефолтні префікси кешу й імені session-куки стали дефісними (-cache-, -database-, -session замість _cache_). Це б'є лише по застосунках без явних CACHE_PREFIX, REDIS_PREFIX, SESSION_COOKIE у конфізі — але б'є непомітно, порожнім кешем після деплою.
Атрибути замість властивостей
Laravel 13 продовжує розкладати конфігурацію по атрибутах. Для контролерів з'явився неймспейс Illuminate\Routing\Attributes\Controllers: Middleware, Authorize, WithoutMiddleware.
use Illuminate\Routing\Attributes\Controllers\Authorize;
use Illuminate\Routing\Attributes\Controllers\Middleware;
#[Middleware('auth')]
class CommentController
{
#[Middleware('subscribed')]
#[Authorize('create', [Comment::class, 'post'])]
public function store(Post $post)
{
// ...
}
}
Магії тут немає: Route::controllerMiddleware() через рефлексію збирає атрибути з класу й із конкретного методу, піднімаючись по батьківських класах, тому файл маршрутів чіпати не треба. Атрибути IS_REPEATABLE і приймають only/except, як старий інтерфейс HasMiddleware.
Черги отримали Illuminate\Queue\Attributes: Tries, Backoff, Timeout, FailOnTimeout, Queue, Connection, Delay, DeleteWhenMissingModels, UniqueFor, MaxExceptions. Усі — TARGET_CLASS, тобто це заміна публічним властивостям джоби:
#[Tries(5)]
#[Backoff([10, 60, 300])]
#[Timeout(120)]
#[FailOnTimeout]
class ImportJobFeed implements ShouldQueue
{
// ...
}
Окремо — маршрутизація джобів у одному місці, без правок у самих класах:
Queue::route(ImportJobFeed::class, queue: 'sync', connection: 'redis');
Queue::forward('default', to: 'low', connection: 'redis');
Сигнатура — route(array|string $class, $queue = null, $connection = null), тож позиційно другим іде черга, а не з'єднання; іменовані аргументи тут читабельніші, але пам'ятайте, що на них не поширюються гарантії зворотної сумісності Laravel.
І ще одна маленька, але щоденна річ: Cache::touch($key, $ttl) продовжує TTL без get() + put(). Реалізація чесна: $ttl <= 0 перетворюється на forget(), решта йде в новий метод контракту Store::touch($key, $seconds) — якщо ви пишете власний стор, метод доведеться додати.
Вектори в query builder
Для проєктів на PostgreSQL це найпрактичніша новинка. У схемі з'явився тип колонки, а ->index() на ньому автоматично перемикається на vectorIndex:
Schema::create('documents', function (Blueprint $table) {
$table->id();
$table->vector('embedding', 1536)->index();
});
У query builder — whereVectorSimilarTo($column, $vector, $minSimilarity = 0.6, $order = true). Усередині це whereVectorDistanceLessThan($column, $vector, 1 - $minSimilarity) плюс сортування за відстанню, а Postgres-граматика компілює вираз у ("embedding" <=> ?) — косинусна відстань pgvector. Тобто розширення pgvector має бути встановлене в базі, фреймворк його не замінює.
Друга умова важливіша. Якщо передати рядок замість масиву float, білдер викликає (new Stringable($vector))->toEmbeddings(cache: true). Самого toEmbeddings() у фреймворку немає — його додає окремий AI SDK. Без нього приклад із release notes упаде на BadMethodCallException, а от готовий масив ембедингів працює на чистому laravel/framework.
Дрібниці, які знайдуть тільки тести
Решта змін дешеві, але кожна коштує однієї помилки в проді, якщо не прочитати список:
JobAttemptedбільше не має$exceptionOccurred; замість булевого прапорця —$exceptionз об'єктом винятку абоnull.- У події
QueueBusyвластивість$connectionперейменована на$connectionName. Container::call()тепер поважає nullable-дефолти:function (?Carbon $date = null)отримаєnull, а не свіжийCarbon, якщо біндингу немає.Js::from()(і директива@js) виводить ізJSON_UNESCAPED_UNICODE. Для українського інтерфейсу це радше подарунок, але снапшот-тести з\u0443доведеться переписати.- Кастомні фабрики
Str(UUID, ULID, random) скидаються між тестами — задавайте їх уsetUp(), а не один раз на весь клас. upsert()із порожнімuniqueByкидаєInvalidArgumentExceptionнавіть на MySQL/MariaDB, які цей аргумент ігнорують.DELETE ... JOINпід MySQL тепер компілюєORDER BYіLIMITзамість того, щоб мовчки їх викидати. Замість «випадково видалили більше, ніж просили» ви отримаєтеQueryException— це покращення, але воно ламає запити, які «працювали».- Bootstrap-пагінація живе у
pagination::bootstrap-3іpagination::simple-bootstrap-3. - Маршрути з явним доменом тепер матчаться раніше за без-доменні, незалежно від порядку реєстрації.
- Створення екземпляра моделі всередині її ж
boot()тепер кидаєLogicException.
Практичний порядок дій виглядає так: спершу підняти PHP і залежності, потім прогнати пошук по VerifyCsrfToken та exceptionOccurred, і лише третім кроком синхронізувати config/cache.php і config/session.php зі скелетом — бо саме ці два рядки, а не перейменування класів, здатні зіпсувати день після деплою.