0% прочитано

Laravel Reverb: чому після оновлення в реальному часі зникали canEdit, canDelete та isOwn

У ICanUp початкові дані містили не тільки поля сутності, а й canEdit, canDelete та isOwn, обчислені для поточного користувача. Після події Laravel Reverb клієнтська частина замінювала об’єкт коротшими даними події, і ці поля зникали. Розбираю, чому HTTP-ресурс і WebSocket-подія мають різний контекст та як не втрачати похідний стан.

19 вересня 2026 р. 10 хв читанняЛаравель

У ICanUp я зіткнулася з дивним багом: після оновлення даних через Laravel Reverb сама сутність оновлювалася правильно, але в інтерфейсі раптом зникали canEdit, canDelete та isOwn.

До події все працювало. Кнопки редагування й видалення показувалися правильно, власний запис визначався правильно. Потім приходила WebSocket-подія, об’єкт оновлювався, і частина стану просто зникала.

Проблема виявилася не в Reverb і не в авторизації Laravel.

Я змішала два різні контракти даних: повний ресурс для поточного користувача і загальну подію про зміну сутності.

Якщо дані події містять менше полів, ніж початковий ресурс, повна заміна об’єкта може видалити коректний локальний стан, якого в події взагалі не було.
Laravel Reverb data flow where a full HTTP resource contains canEdit canDelete and isOwn while a smaller WebSocket event updates only shared entity fields
Початковий ресурс містить дані сутності й поля, обчислені для поточного користувача. Подія Reverb може містити лише спільні зміни сутності. Повна заміна одного другим втрачає користувацький контекст.

Початковий HTTP-ресурс містив більше, ніж поля моделі

Коли сторінка завантажувалася звичайним HTTP-запитом, сервер уже знав поточного користувача.

Тому ресурс міг повернути не тільки дані сутності, а й похідні поля:

JSON - Повний ресурс для поточного користувача
{    "id": 42,    "body": "Updated text",    "authorId": 7,    "canEdit": true,    "canDelete": true,    "isOwn": true}

canEdit, canDelete та isOwn не є звичайними колонками цієї сутності.

Вони залежать від контексту запиту та користувача, який зараз дивиться сторінку.

Для іншого користувача ті самі дані можуть мати інші значення

Одна сутність, різний користувацький контекст
Entity 42 User A:canEdit   = truecanDelete = trueisOwn     = true User B:canEdit   = falsecanDelete = falseisOwn     = false

Це важлива властивість таких полів.

Вони описують не тільки сам запис. Вони описують відношення поточного користувача до цього запису.

Подія Reverb вирішує іншу задачу

Коли сутність змінюється, подія в реальному часі повинна повідомити підписаним клієнтам про зміну.

Для цього часто достатньо передати спільні дані:

JSON - Дані події про зміну сутності
{    "id": 42,    "body": "New text",    "updatedAt": "2026-09-18T07:15:00+03:00"}

Тут немає canEdit, canDelete та isOwn.

І це саме по собі не помилка.

Загальна подія може надсилатися кільком користувачам, а права кожного з них відрізняються.

Помилка була у повній заміні об’єкта

Критичний момент з’являється вже на клієнтській частині.

Якщо після події зробити щось на кшталт:

JavaScript - Повна заміна на дані події
const index = items.findIndex(    item => item.id === event.item.id); items[index] = event.item;

попередній об’єкт повністю зникає.

Разом із ним зникають усі поля, яких немає в event.item.

Поля не стали false, вони були видалені разом зі старим об’єктом
Before: {    id: 42,    body: "...",    canEdit: true,    canDelete: true,    isOwn: true} Reverb event: {    id: 42,    body: "changed"}

Це не означає, що права на сервері змінилися

Саме ця деталь спочатку може заплутати.

Коли кнопка Edit зникає, легко почати перевіряти policy, permission або авторизацію WebSocket-каналу.

Але в цьому випадку серверне право нікуди не зникло.

Клієнтська частина просто втратила поле, яке раніше використовувала для відображення кнопки.

Відсутність canEdit у стані клієнтської частини не є доказом зміни серверного дозволу. Це може бути лише наслідок неповної заміни даних.

HTTP-ресурс і подія Reverb мають різний контекст

HTTP-ресурс

Подія Reverb

Формується для конкретного запиту

Повідомляє про зміну

Знає поточного користувача

Може бути спільною для багатьох користувачів

Може містити похідні права

Не повинна вгадувати права кожного отримувача

Підходить для початкового стану сторінки

Підходить для передачі змінених даних

Одна й та сама модель не означає один і той самий контракт для HTTP-відповіді та WebSocket-події.

Небезпечно просто додати canEdit у загальну подію

Після виявлення проблеми може виникнути інша проста ідея: додати відсутні поля до події.

Але якщо одна подія надсилається кільком користувачам, постає питання: для кого саме обчислити canEdit?

Одне значення не підходить усім отримувачам
Same event   |   +--> User A: canEdit = true   |   +--> User B: canEdit = false

Загальне значення похідного права в такій події може бути концептуально неправильним.

Якщо потрібні користувацькі дані в реальному часі, вони мають формуватися в контексті конкретного отримувача або отримуватися іншим способом.

Для мого випадку важливо було не втрачати вже відомий користувацький стан

Якщо подія змінює лише спільні поля сутності, логічніше оновити саме їх, а не викидати весь існуючий об’єкт.

JavaScript - Об’єднати нові дані з наявним станом
items[index] = {    ...items[index],    ...event.item,};

Тоді поля, які прийшли з подією, оновляться, а локальні похідні поля залишаться:

Оновлюються лише дані, які справді прийшли
Existing:idbodycanEditcanDeleteisOwn Event:idbody Result:idbody           <- updatedcanEdit        <- preservedcanDelete      <- preserved

Але merge теж не є універсальним рішенням

Об’єднання об’єктів добре працює, коли подія справді є частковим оновленням.

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

Тому потрібно визначити сам контракт події, а не просто всюди додати spread.

Я розділяю три типи даних

Тип даних

Приклади

Спільні поля сутності

id, текст, статус, дата зміни

Похідний стан поточного користувача

canEdit, canDelete, isOwn

Тимчасовий стан інтерфейсу

loading, expanded, selected

WebSocket-подія не повинна випадково знищувати другий або третій тип даних лише тому, що оновила перший.

Права для кнопок і справжня авторизація - не одне й те саме

canEdit та canDelete потрібні клієнтській частині, щоб правильно показати інтерфейс.

Але вони не повинні бути єдиним захистом операції.

UI-підказка не замінює серверну авторизацію
Client:canEdit = true    |    vshow Edit button Server:request arrives    |    vPolicy / authorization    |    vallow or reject

Навіть якщо клієнт помилково покаже кнопку, сервер повинен окремо перевірити право на зміну.

І навпаки, якщо поле випадково зникло після події Reverb, це не означає, що серверне право теж зникло.

isOwn теж є проєкцією для поточного користувача

Поле isOwn здається простішим за policy.

Але воно теж залежить від того, хто саме дивиться на запис:

PHP - Приклад користувацької проєкції
'isOwn' => $request->user()?->id    === $model->user_id,

Тому воно так само не є універсальною властивістю сутності, яку можна один раз записати в загальну подію для всіх.

Іноді після події правильніше повторно отримати ресурс

Є зміни, після яких старі похідні права можуть стати неактуальними.

Наприклад, якщо змінився власник, статус або інша умова, від якої залежить policy.

У такому випадку просте збереження старого canEdit теж може бути неправильним.

Рішення залежить від змісту події
Realtime event    |    vDoes the change affect user-specific projection?    |    +-- no  -> merge changed shared fields    |    +-- yes -> refresh canonical user-specific resource

Можна мати різні події для різних контекстів

Ще один варіант - окремий користувацький канал, де дані формуються саме для конкретного отримувача.

Але це вже інший контракт і його не варто змішувати із загальною подією сутності.

Спільний і користувацький контексти
Shared entity event:entity.updated User-specific event:user.42.entity.updated

Я б використовувала такий підхід лише тоді, коли користувацькі дані справді потрібно доставляти в реальному часі, а не просто для того, щоб компенсувати невдалу заміну об’єкта.

Подія повинна мати явний контракт

Після цього кейсу я не хочу трактувати будь-які дані Reverb як повну копію ресурсу.

Подія повинна чітко відповідати на питання: це повний стан чи лише зміни?

JSON - Явне часткове оновлення
{    "entityId": 42,    "changes": {        "body": "New text"    }}

Такий формат важче випадково сприйняти як готову заміну всього об’єкта.

Назви полів теж можуть підказувати призначення

Якщо подія передає лише змінені поля, назва changes або patch чесніша за item, якщо item не є повним ресурсом.

Це невелика деталь, але вона зменшує ризик неправильного використання даних на клієнтській частині.

Тести мають відтворювати саме послідовність HTTP плюс Reverb

Якщо тестувати лише початкове завантаження або лише окремий обробник WebSocket-події, цей баг легко пропустити.

Потрібно перевірити повний сценарій:

Регресійний сценарій
1. Load resource through HTTP2. Receive canEdit / canDelete / isOwn3. Receive Reverb event4. Apply realtime update5. Verify shared fields changed6. Verify user-specific state is still correct

Особливо важливий випадок - подія від іншого користувача

Саме тут добре видно різницю контекстів.

Користувач A бачить власний запис і має isOwn = true. Користувач B змінює спільні дані або викликає подію, яку отримує A.

Оновлення від B не повинно перетворити користувацьку проєкцію A на дані, обчислені для B або на неповний об’єкт без цих полів.

Перевірка

Очікування

Текст змінився

Нове значення показане без reload

canEdit

Залишається коректним для поточного користувача

canDelete

Залишається коректним для поточного користувача

isOwn

Не перезаписується чужим контекстом

Серверна авторизація

Все одно виконується окремо

Не всі події повинні містити Eloquent model

Цей випадок також показав перевагу невеликих явних даних події.

Якщо передавати цілу Eloquent-модель з випадково завантаженими зв’язками, межа події стає менш зрозумілою.

Для оновлення в реальному часі я віддаю перевагу конкретним ID і полям, які справді змінилися.

PHP - Приклад компактних даних події
public function broadcastWith(): array{    return [        'id' => $this->id,        'changes' => [            'body' => $this->body,        ],    ];}

Версія або час зміни допомагають із порядком подій

Коли оновлень стає багато, з’являється ще одна проблема: WebSocket-події можуть оброблятися не в тому порядку, якого очікує інтерфейс.

Тому корисно мати хоча б час зміни або іншу ознаку актуальності.

JSON - Подія з ознакою актуальності
{    "id": 42,    "updatedAt": "2026-09-18T07:15:00+03:00",    "changes": {        "body": "New text"    }}

Це вже інша проблема, але вона виникає поруч із тією самою архітектурною межею: подія має бути не випадковою копією моделі, а визначеним контрактом синхронізації.

Що я не роблю після цього бага

  • Не вважаю дані кожної Reverb-події повною заміною HTTP-ресурсу.
  • Не передаю одне значення canEdit усім користувачам, якщо воно залежить від поточного користувача.
  • Не використовую canEdit або canDelete як заміну серверній авторизації.
  • Не перезаписую цілий об’єкт частковими даними без явного контракту.
  • Не зберігаю старі похідні права, якщо зміна сутності могла зробити їх неактуальними.
  • Не передаю зайву Eloquent-модель в подію лише заради зручності.
  • Не тестую HTTP-завантаження і Reverb окремо, ігноруючи їхню послідовність.

Мій короткий checklist для Reverb-оновлень

  • Визначити, які поля є станом сутності, а які залежать від поточного користувача.
  • Не прирівнювати WebSocket-подію до HTTP-ресурсу автоматично.
  • Чітко визначити, чи подія є повною або частковою.
  • Для часткової події оновлювати тільки передані поля.
  • Не втрачати похідний користувацький стан без причини.
  • Якщо зміна впливає на права, повторно отримати або перерахувати користувацьку проєкцію.
  • Не транслювати однакові похідні права користувачам із різними дозволами.
  • Залишати остаточну авторизацію на сервері.
  • Передавати компактні й явні дані події.
  • Додавати ознаку актуальності, якщо можливі конкурентні оновлення.
  • Тестувати повний сценарій HTTP -> Reverb -> оновлений інтерфейс.

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

Висновок

У випадку з Laravel Reverb у ICanUp проблема виглядала як зникнення прав після оновлення в реальному часі.

Насправді права на сервері не зникали. Початковий HTTP-ресурс містив canEdit, canDelete та isOwn, обчислені для поточного користувача, а подія Reverb передавала коротший набір спільних даних.

Клієнтська частина замінювала повний об’єкт даними події й разом зі старим об’єктом втрачала поля, яких у події не було.

Після цього я чіткіше розділяю стан самої сутності, похідний стан поточного користувача та тимчасовий стан інтерфейсу.

Головний урок для мене: HTTP-ресурс і WebSocket-подія можуть описувати одну сутність, але вони живуть у різних контекстах. Тому один не можна автоматично використовувати як повну заміну іншого.