<? phpukraine СТАТТІ
Пошук по платформі
LARAVEL 3 вересня 2026 · 5 хв читання

Laravel 13: що змінилось для щоденного коду

Laravel 13 вийшов 17 березня 2026 року і в офіційному гайді оцінений у десять хвилин апгрейду. Це майже правда: ламких змін мало, але серед них перейменований CSRF-мідлвар, кеш і сесія, які більше не десеріалізують що завгодно, і кілька перейменованих властивостей у подіях черги. Розбираємо за кодом v13.30.1, що саме доведеться поправити і що нового реально стане в пригоді.

РP
Редакція phpukraine
Редакція платформи

Мажорні релізи 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, і зламається воно не на деплої, а на першому попаданні в теплий кеш. Варіанти: перелічити класи явно або (краще) класти в кеш масиви й збирати об'єкт на виході.

Із сесією простіше й жорсткіше: зміна phpjson знеструмлює всі активні сесії. Якщо ви не зберігаєте в сесії об'єкти й готові до разового розлогінення — переходьте; якщо ні — лишіть 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 зі скелетом — бо саме ці два рядки, а не перейменування класів, здатні зіпсувати день після деплою.

ПИШЕТЕ ПРО PHP?Опублікуйте розбір або історію з проєкту на платформіРедактор із чеклістом, редактура, авторська сторінка. Републікація з блогу отримує canonical на оригінал. Відкрити редактор →
РP
Редакція phpukraine
Редакція платформи
Матеріали, які готує команда платформи на основі власних даних: каталогу вакансій, зарплатного звіту й банку питань. Кожна цифра в них рахується з бази, а не береться з голови.
оновлено 3 вересня 2026 · ліцензія CC-BY-SA-4.0
ДАЛІ ПО ТЕМІ
ЧИТАТИ ДАЛІ
← Усі статті