The notifications lifecycle
This page is about the in-app notification feed — the bell / updates list an agent sees inside the
broker or sales-man app. A domain event (a lead is verified, a sale is invoiced, an EOI changes state)
writes a row in the notifications table, and the app polls for it and marks it read. That
is a different thing from the outbound push / SMS / email delivery documented on
Notifications (integration): outbound channels push a message out to a
person, whereas this feed is a stored, pull-based inbox the app queries. Everything here is read from
the current backend.
What the agent sees
Section titled “What the agent sees”From the sales-man (or broker) app, the agent opens the notifications list — each row is one row from the
notifications table, newest first, with a read / unread state:

The data behind one notification
Section titled “The data behind one notification”A feed row is App\Models\Notification (app/Models/Notification.php:10), backed by the standard Laravel
notifications table (database/migrations/2023_09_20_152852_create_notifications_table.php:22-34):
id— auto-increment integer (the app marks rows read by this id, not a UUID).type— a plain string; Laravel’s database channel stores the notification class name here (e.g.App\Notifications\Broker\VerifiedLead). This is not the business type the app routes on — see the gotcha below.notifiable_type/notifiable_id— the polymorphic recipient (Broker,SalesMan,Shopper, or a baseUser), written by$table->morphs('notifiable').data— a JSON blob. Its shape is alwaysNotification::createJsonMessage($title, $body, $redirectData)(app/Models/Notification.php:64-76), i.e.{ message: { title, body, redirect_data } }.read_at— nullable timestamp.nullmeans unread.deleted_at/ timestamps — the model usesSoftDeletes.
Two accessors read back out of data: message returns data['message']['body']
(app/Models/Notification.php:39-42) and redirect_data returns data['message']['redirect_data']
(:55-58). The app uses redirect_data.type — a NotificationTypeEnums value — to decide where a tap
deep-links (the lead, the invoice, the EOI).
The whole trip, in one diagram
Section titled “The whole trip, in one diagram”sequenceDiagram participant EV as Domain event (CRM webhook / in-app) participant API as Backend (Laravel) participant CH as database channel participant DB as notifications table participant ML as Mail channel (OrascomMail) actor APP as Agent app (Broker / Sales-Man) EV->>API: lead / meeting / invoice / EOI status change API->>API: recipient calls notify(new XNotification) Note over API,CH: via() returns database plus OrascomMailNotification API->>CH: database channel CH->>DB: INSERT row - data JSON, read_at null, notifiable = recipient API->>ML: send email (separate outbound channel) APP->>API: GET user/notifications (poll, per_page 10) API->>DB: where notifiable_id and notifiable_type, order by created_at desc DB-->>API: one page of rows API-->>APP: NotificationResource list with is_read APP->>API: POST user/notifications/read - ids plus is_read API->>DB: set read_at = now() for those ids API-->>APP: Notifications updated successfully
Step by step
Section titled “Step by step”-
An event fires and a recipient is notified. The most common trigger is a
crms-middlewarewebhook — the CRM pushes a status change and the portal turns it into a feed row:POST brokers/user/notifications(general),POST leads/{leadSourceId}/notifications,POST meetings/{meetingSourceId}/notifications,POST eois/{eoiSourceId}/notifications(routes/api/crms-middleware.php:13-17). The controller resolves the recipient and calls$notifiable->notify(new XNotification(...))(app/Http/Controllers/Api/CrmsMiddleware/NotificationController.php:23-93). For leads,LeadSubmissionNotificationmaps the CRM status/decision to the right class —VerifiedLead,NotVerifiedLead,ReservedLead,ContractedLead,RejectedLead(app/Actions/User/Broker/LeadSubmissionNotification.php:16-55). A few events are raised in-app, not from the CRM: a shopper reservation firesSaleStatusDone(app/Http/Controllers/Api/Shopper/UnitSaleController.php:212), a duplicate lead firesDuplicateLead(app/Http/Controllers/Api/SalesMan/LeadController.php:101), and an approved invoice firesSalesInvoiceStatusApprovedfrom an Eloquent observer (app/Observers/SalesInvoiceObserver.php:56). -
The
databasechannel writes the row. Becausevia()includes'database', Laravel’s built-in database channel persists through the recipient’snotifications()morphMany (app/Models/User.php:64-67, enabled by theNotifiabletrait at:11,16). It stores thetoArray()payload intodataand setsread_at = null.toArray()is always built withNotification::createJsonMessage($title, $body, $redirectData)and stampsredirect_data.typewith aNotificationTypeEnumsvalue (e.g.app/Notifications/Broker/VerifiedLead.php:62-77, type at:70). -
The app polls the feed.
GET user/notificationsis registered per persona (routes/api/broker.php:98,routes/api/sales-man.php:39) behindauth:sanctum+user-provider:<persona>.NotificationController@indexresolves the session agent and callsGetNotifications::handle($id, <Persona>::class, $filters)(app/Http/Controllers/Api/Broker/NotificationController.php:14-25,app/Http/Controllers/Api/SalesMan/NotificationController.php:14-25). The action queries bynotifiable_id+notifiable_type, optionallywhereNull('read_at')whenunreadis set, orders bycreated_at desc, and paginates (default 10 per page) (app/Actions/Notification/GetNotifications.php:15-28). Query params areunread(bool) andper_page(int) (app/Http/Requests/Notification/IndexRequest.php:14-20). Each row is serialized byNotificationResourceto{ id, message, redirect_data, created_at, is_read }, wheremessageis the body,created_atis a unix timestamp, andis_read = (bool) read_at(app/Http/Resources/NotificationResource.php). -
The app marks rows read.
POST user/notifications/read(routes/api/broker.php:99,routes/api/sales-man.php:40) hitsNotificationController@update, inherited fromNotificationBaseController(app/Http/Controllers/Api/Base/NotificationBaseController.php:12-17). It runsMarkNotificationsAsRead, which doesNotification::whereIn('id', $notification_ids)->update(['read_at' => $is_read ? now() : null])(app/Actions/Notification/MarkNotificationsAsRead.php:10-14). The request body isnotification_ids(array) +is_read(bool);MarkAsReadRequestvalidates that every id exists and belongs to the calling agent —notifiable_type = <caller class>andnotifiable_id = <caller id>(app/Http/Requests/Notification/MarkAsReadRequest.php:17-25).
Notification types
Section titled “Notification types”redirect_data.type carries an App\Enums\NotificationTypeEnums value (string-backed,
app/Enums/NotificationTypeEnums.php:9-15). This is the value the app switches on to deep-link a tap — it
is separate from the type column (which holds the class name). Not every enum case is actually
emitted into the feed today:
| Enum value | Case | Emitted into the feed by | Actually produced? |
|---|---|---|---|
lead |
LEAD |
VerifiedLead, NotVerifiedLead, ReservedLead, ContractedLead, RejectedLead, AcceptedMeeting, RejectedMeeting |
Yes |
sales_invoice |
SALES_INVOICE |
SalesInvoiceStatusApproved |
Yes |
eoi |
EOI |
SuccessEOISubmission, ConfirmedEOIPayment, ExpiredEOI, EOIChequeSubmitted, EOIBankTransferSubmitted |
Yes |
sale |
SALE |
(none — no notification sets this value) | No |
unit |
UNIT |
(none — UnitCreationNotification is email-only, its via() returns only OrascomMailNotification) |
No |
