---
title: "Laravel 13.36-13.38: репліки бази даних без застарілих даних"
url: https://laravelukraine.com/blog/laravel-1336-1338-repliki-bazi-danix-bez-zastarilix-danix
date: 2026-10-27
---

# Laravel 13.36-13.38: репліки бази даних без застарілих даних

Три випуски Laravel за тиждень, з [13.36](https://laravel-news.com/laravel-13-36-0) 13 жовтня до [13.38](https://laravel-news.com/laravel-13-38-0) 20 жовтня, мають спільну тему: роботу з репліками бази даних. Хто розносить читання й запис по різних серверах, давно бореться із застарілими даними після запису й тримав для цього власні макроси. Тепер ці інструменти є у фреймворку. Головні з них написав Джек Бейліс (Jack Bayliss), який, за його словами, саме впроваджує розділення читання й запису у себе.

### Звідки береться проблема

Розділене з'єднання в `config/database.php` виглядає так:

```php
'mysql' => [
    'driver' => 'mysql',
    'read' => [
        'host' => ['10.0.0.11', '10.0.0.12'],
    ],
    'write' => [
        'host' => ['10.0.0.10'],
    ],
    'sticky' => true,
    // ...
],
```

Опція `sticky` гарантує, що після запису наступні читання йдуть на write-сервер. Але лише до кінця поточного запиту. Форма відправляється `PUT`-запитом і робить редирект на `GET` - це вже два запити, і другий знову читає з репліки, яка може ще не мати змін. Автор PR пише, що в них затримка реплікації зазвичай до секунди, але буває і 15. Результат - користувач оновлює сторінку, бачить старий заголовок і йде писати баг-репорт.

### PreventStaleDatabaseReads (13.38)

Новий middleware ([#62078](https://github.com/laravel/framework/pull/62078)) розв'язує саме цей сценарій. Підключіть його до групи `web` у `bootstrap/app.php`:

```php
use Illuminate\Foundation\Http\Middleware\PreventStaleDatabaseReads;

->withMiddleware(function (Middleware $middleware) {
    $middleware->web(append: [
        PreventStaleDatabaseReads::class,
    ]);
})
```

Або лише до потрібних маршрутів, з власним вікном у секундах:

```php
Route::middleware(PreventStaleDatabaseReads::for(15))->group(function () {
    Route::get('/posts/{post}', [PostController::class, 'show']);
    Route::put('/posts/{post}', [PostController::class, 'update']);
});
```

Механіка проста. Якщо запит щось записав, middleware зберігає в сесії мітку часу `_database_modified_at`. На наступних запитах, поки мітка в межах вікна (2 секунди за замовчуванням), він вмикає `useWriteConnectionWhenReading()`, і всі читання цього користувача йдуть на write-сервер. Вікно варто ставити не менше за найдовшу затримку, яку показують ваші метрики реплікації: коротше вікно пропустить старі дані.

Обмеження варто знати заздалегідь:

- потрібна сесія, тож для stateless API middleware не підходить;
- відстежується лише з'єднання за замовчуванням;
- захищено лише того, хто писав: інші користувачі й далі можуть читати старі дані з репліки.

### DB::usingWritePdo() (13.38)

Для коду, якому свіжі дані потрібні незалежно від користувача, є `usingWritePdo()` ([#62099](https://github.com/laravel/framework/pull/62099)). Він закриває пастку, на яку легко наступити з `onWriteConnection()`: той діє лише на запит, який сам починає, а зв'язок, завантажений з результату, - це вже новий запит, і він іде на репліку.

```php
// posts() усе одно читає з репліки
$posts = User::onWriteConnection()
    ->where('email', $email)
    ->first()
    ?->posts()
    ->whereNull('published_at')
    ->get();

// тут усі читання в колбеку йдуть на write-сервер
$posts = DB::usingWritePdo(fn () => User::query()
    ->where('email', $email)
    ->first()
    ?->posts()
    ->whereNull('published_at')
    ->get());
```

Метод повертає результат колбеку і відновлює попередній стан з'єднання, навіть якщо колбек кинув виняток. Усередині `DB::transaction()` він не потрібен: запити в транзакції й так ідуть на write-сервер.

### Як це протестувати

Кожен запис у query log має ключ `readWriteType` зі значенням `read` або `write`. За ним можна перевірити, куди пішов запит після оновлення, - [приклад тесту](https://laravel-news.com/laravel-prevent-stale-database-reads) є в Laravel News:

```php
DB::forgetRecordModificationState();
DB::enableQueryLog();

$this->actingAs($user)->get("/posts/{$post->id}")->assertOk();

$this->assertSame('write', DB::getQueryLog()[0]['readWriteType']);
```

Головна пастка: `RefreshDatabase` для такого тесту не годиться. Він загортає кожен тест у транзакцію, а в транзакції всі запити й так ідуть на write-сервер, тож тест пройде навіть без middleware. Використовуйте `DatabaseMigrations` або `DatabaseTruncation`.

### Sticky-з'єднання у воркерах черг (13.36)

Воркер черги - це один довгоживучий процес, який тримає те саме з'єднання між джобами. Якщо `sticky` увімкнено, одна джоба, що щось записала, переводить на write-сервер читання всіх наступних джоб цього воркера - аж до його перезапуску. Тобто репліки для черг фактично простоюють.

Laravel 13.36 додав прапорець ([#61915](https://github.com/laravel/framework/pull/61915)), з яким воркер скидає цей стан перед кожною джобою:

```php
// AppServiceProvider::boot()
use Illuminate\Queue\Worker;

Worker::$flushStickyConnections = true;
```

За замовчуванням прапорець вимкнено, бо якась джоба може покладатися на те, що прочитає дані, записані попередньою джобою в тому самому воркері. Правильно таке оформлювати ланцюжком чи батчем, але перед увімкненням перевірте такі місця.

Нюанс із драйвером черги `database`: щоб зарезервувати джобу, він оновлює таблицю `jobs`. Якщо вона на тому самому з'єднанні, що й дані застосунку, цей `UPDATE` знову вмикає sticky-стан одразу після скидання, і прапорець нічого не дає. Як [пояснює Laravel News](https://laravel-news.com/laravel-queue-sticky-database-connections), рішення - винести чергу на окреме з'єднання через `DB_QUEUE_CONNECTION=queue`. Драйверів Redis і SQS це не стосується.

### Інше корисне

- **`DB::olap()` для PlanetScale і Vitess** ([#61932](https://github.com/laravel/framework/pull/61932), 13.36). Перемикає з'єднання в OLAP-режим на час колбеку, щоб обійти обмеження OLTP-режиму (на PlanetScale - не більше 100 000 рядків на запит), і ставить режим і на read-, і на write-з'єднання. На звичайному MySQL просто виконує колбек. Поруч з'явився `isVitess()`.
- **`Rule::`-білдери для всіх правил валідації** ([#61833](https://github.com/laravel/framework/pull/61833), 13.36): `Rule::requiredWithout('email')`, `Rule::url()->protocols(['https'])`, `Rule::uuid()->version(4)`, `Rule::distinct()->ignoreCase()`. Автодоповнення в IDE замість рядків, а значення з комою більше не розбивається на два.
- **`#[CountCrashesAsExceptions]`** тепер працює на слухачах у черзі, mailables і сповіщеннях, а не лише на джобах (13.38). Раніше слухач, що падав від нестачі пам'яті, з `$tries = 0` повторювався без кінця.
- **`#[DeleteWhenMissingModels]` і `#[FailOnTimeout]`** більше не ігноруються на mailables у черзі (13.36): лист для видаленого замовлення не потрапить у `failed_jobs`, а лист, що впав за таймаутом, не прийде двічі.
- **`lazy()` і `lazyById()`** звільняють попередній чанк перед завантаженням наступного - пікове споживання пам'яті приблизно вдвічі менше (13.38).
- **Екранування в Markdown-листах** (13.36): значення користувача в тексті Markdown-посилання могло через зворотну скісну риску змінити адресу посилання. Якщо ви підставляєте введення користувачів у Markdown-листи, це привід оновитися.

13.37 вийшов здебільшого випуском виправлень, але кілька речей помітні: підтримка JPEG XL в Image API, опція `strict` у `Rule::numeric()`, виправлення N+1 при вкладених `include` у JSON:API-ресурсах і звільнення unique-локу, коли постановка джоби в чергу не вдалася.

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

Так, якщо у вас розділені read/write-з'єднання: оновіться до 13.38 і почніть з `PreventStaleDatabaseReads` на маршрутах, де користувачі редагують дані, підібравши вікно за метриками реплікації. `Worker::$flushStickyConnections` вмикайте свідомо, після перевірки джоб, що читають дані одна одної, а з драйвером `database` - лише разом з окремим `DB_QUEUE_CONNECTION`. Усі нові можливості опційні, тож саме оновлення нічого не змінить, доки ви їх не ввімкнете.
