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

Початковий HTTP-ресурс містив більше, ніж поля моделі
Коли сторінка завантажувалася звичайним HTTP-запитом, сервер уже знав поточного користувача.
Тому ресурс міг повернути не тільки дані сутності, а й похідні поля:
{ "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 вирішує іншу задачу
Коли сутність змінюється, подія в реальному часі повинна повідомити підписаним клієнтам про зміну.
Для цього часто достатньо передати спільні дані:
{ "id": 42, "body": "New text", "updatedAt": "2026-09-18T07:15:00+03:00"}Тут немає canEdit, canDelete та isOwn.
І це саме по собі не помилка.
Загальна подія може надсилатися кільком користувачам, а права кожного з них відрізняються.
Помилка була у повній заміні об’єкта
Критичний момент з’являється вже на клієнтській частині.
Якщо після події зробити щось на кшталт:
const index = items.findIndex( item => item.id === event.item.id); items[index] = event.item;попередній об’єкт повністю зникає.
Разом із ним зникають усі поля, яких немає в event.item.
Before: { id: 42, body: "...", canEdit: true, canDelete: true, isOwn: true} Reverb event: { id: 42, body: "changed"}Це не означає, що права на сервері змінилися
Саме ця деталь спочатку може заплутати.
Коли кнопка Edit зникає, легко почати перевіряти policy, permission або авторизацію WebSocket-каналу.
Але в цьому випадку серверне право нікуди не зникло.
Клієнтська частина просто втратила поле, яке раніше використовувала для відображення кнопки.
HTTP-ресурс і подія Reverb мають різний контекст
HTTP-ресурс | Подія Reverb |
|---|---|
Формується для конкретного запиту | Повідомляє про зміну |
Знає поточного користувача | Може бути спільною для багатьох користувачів |
Може містити похідні права | Не повинна вгадувати права кожного отримувача |
Підходить для початкового стану сторінки | Підходить для передачі змінених даних |
Одна й та сама модель не означає один і той самий контракт для HTTP-відповіді та WebSocket-події.
Небезпечно просто додати canEdit у загальну подію
Після виявлення проблеми може виникнути інша проста ідея: додати відсутні поля до події.
Але якщо одна подія надсилається кільком користувачам, постає питання: для кого саме обчислити canEdit?
Same event | +--> User A: canEdit = true | +--> User B: canEdit = falseЗагальне значення похідного права в такій події може бути концептуально неправильним.
Якщо потрібні користувацькі дані в реальному часі, вони мають формуватися в контексті конкретного отримувача або отримуватися іншим способом.
Для мого випадку важливо було не втрачати вже відомий користувацький стан
Якщо подія змінює лише спільні поля сутності, логічніше оновити саме їх, а не викидати весь існуючий об’єкт.
items[index] = { ...items[index], ...event.item,};Тоді поля, які прийшли з подією, оновляться, а локальні похідні поля залишаться:
Existing:idbodycanEditcanDeleteisOwn Event:idbody Result:idbody <- updatedcanEdit <- preservedcanDelete <- preservedАле merge теж не є універсальним рішенням
Об’єднання об’єктів добре працює, коли подія справді є частковим оновленням.
Але воно може бути неправильним, якщо відсутність поля означає його навмисне видалення або якщо зміна сутності повинна призвести до перерахунку користувацьких прав.
Тому потрібно визначити сам контракт події, а не просто всюди додати spread.
Я розділяю три типи даних
Тип даних | Приклади |
|---|---|
Спільні поля сутності |
|
Похідний стан поточного користувача |
|
Тимчасовий стан інтерфейсу | loading, expanded, selected |
WebSocket-подія не повинна випадково знищувати другий або третій тип даних лише тому, що оновила перший.
Права для кнопок і справжня авторизація - не одне й те саме
canEdit та canDelete потрібні клієнтській частині, щоб правильно показати інтерфейс.
Але вони не повинні бути єдиним захистом операції.
Client:canEdit = true | vshow Edit button Server:request arrives | vPolicy / authorization | vallow or rejectНавіть якщо клієнт помилково покаже кнопку, сервер повинен окремо перевірити право на зміну.
І навпаки, якщо поле випадково зникло після події Reverb, це не означає, що серверне право теж зникло.
isOwn теж є проєкцією для поточного користувача
Поле isOwn здається простішим за policy.
Але воно теж залежить від того, хто саме дивиться на запис:
'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 як повну копію ресурсу.
Подія повинна чітко відповідати на питання: це повний стан чи лише зміни?
{ "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 |
| Залишається коректним для поточного користувача |
| Залишається коректним для поточного користувача |
| Не перезаписується чужим контекстом |
Серверна авторизація | Все одно виконується окремо |
Не всі події повинні містити Eloquent model
Цей випадок також показав перевагу невеликих явних даних події.
Якщо передавати цілу Eloquent-модель з випадково завантаженими зв’язками, межа події стає менш зрозумілою.
Для оновлення в реальному часі я віддаю перевагу конкретним ID і полям, які справді змінилися.
public function broadcastWith(): array{ return [ 'id' => $this->id, 'changes' => [ 'body' => $this->body, ], ];}Версія або час зміни допомагають із порядком подій
Коли оновлень стає багато, з’являється ще одна проблема: WebSocket-події можуть оброблятися не в тому порядку, якого очікує інтерфейс.
Тому корисно мати хоча б час зміни або іншу ознаку актуальності.
{ "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-подія можуть описувати одну сутність, але вони живуть у різних контекстах. Тому один не можна автоматично використовувати як повну заміну іншого.



