Skip to content

Notifications

zeython.notifications gives you a Notification class: describe what happened and which channels (mail, database, broadcast) should carry it to one recipient, then fire it with notify(). Mirrors Laravel's Notification system.

Why this exists

A "you have a new comment" message often needs to reach a user more than one way: an email, a row in an in-app notification list, maybe a live WebSocket push if they're online right now. Without this, that's three separate call sites to keep in sync every time the content changes. A Notification describes its own rendering per channel once; notify() fires whichever channels it asks for.

This is deliberately separate from Events -- an event is "this happened, react however you like" (any number of independent listeners, no fixed shape); a notification is "tell this one recipient, on these channels, in this format."

Setup

# main.py
from zeython import Application, DatabaseServiceProvider, NotificationServiceProvider

from app.Models.notification import Notification  # your own record model, below

app = Application()
app.register(DatabaseServiceProvider)
app.register(NotificationServiceProvider(app, record_model=Notification))

A generated project already has this wired up -- see app/Models/notification.py, app/Notifications/welcome_notification.py, and the notify() call in AuthController.register.

record_model is only required if you use the database channel; omit it if every notification you write only ever uses mail/broadcast.

Defining a notification

# app/Notifications/invoice_paid_notification.py
from typing import Any

from zeython import Message, Notification


class InvoicePaidNotification(Notification):
    def __init__(self, invoice) -> None:
        self.invoice = invoice

    def via(self, notifiable: Any) -> list[str]:
        return ["mail", "database"]

    def to_mail(self, notifiable: Any) -> Message:
        return Message(
            to=notifiable.email,
            subject="Invoice paid",
            body=f"Thanks! We received ${self.invoice.amount}.",
        )

    def to_database(self, notifiable: Any) -> dict:
        return {"invoice_id": self.invoice.id, "amount": self.invoice.amount}

via() decides the channels per recipient -- nothing stops it from returning something different depending on notifiable (e.g. only email users who opted in). Only implement the to_* methods the channels you chose actually need; zeython make notification InvoicePaid scaffolds this shape in app/Notifications/.

Sending it

from app.Notifications.invoice_paid_notification import InvoicePaidNotification

async def mark_paid(self, request):
    invoice = await Invoice.find(...)
    await notify(request, invoice.customer, InvoicePaidNotification(invoice))

Outside of a request -- a job, a scheduled task -- resolve a NotificationManager directly instead:

from zeython.notifications import NotificationManager

manager = app.container.make(NotificationManager)
await manager.notify(invoice.customer, InvoicePaidNotification(invoice))

Channels

Channel Requires Notes
mail Mailer bound (MailServiceProvider) Sends to_mail(notifiable) through whichever Mailer is bound -- see Mail.
database NotificationServiceProvider(app, record_model=...) Writes one row: notifiable_id, type (the class name), data (to_database(notifiable)), read_at.
broadcast WebSocketHubServiceProvider registered Pushes {"type": ..., "notifiable_id": ..., "data": to_broadcast(notifiable)} to every connected client via WebSocketHub.broadcast() -- see WebSockets. Not scoped to just the recipient's own connection; filter on the client side by notifiable_id, or subscribe recipients to their own channel/room if your app has that concept.

to_broadcast() defaults to whatever to_database() returns, so a notification using both doesn't have to write the payload twice.

Calling a channel that isn't set up (database with no record_model, broadcast with no hub registered) raises immediately, naming the fix. An unrecognized channel name from via() logs a warning instead of raising -- a typo in one channel shouldn't take down the others.

What happens when a channel fails

Each channel in via() runs independently. One failing (a down SMTP server) is logged and reported (see Error Monitoring), not raised -- it doesn't stop the others, the same reasoning Events uses for listeners. A failed email shouldn't also silently swallow the in-app notification that would have told the user something happened.

The record model (database channel)

Not framework-provided -- like AuthServiceProvider's user_model, this is your own Model subclass, generated into a fresh project by default:

# app/Models/notification.py
from datetime import datetime

from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column

from zeython import Model


class Notification(Model):
    __tablename__ = "notifications"

    notifiable_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), index=True)
    type: Mapped[str] = mapped_column(String(255))
    data: Mapped[dict] = mapped_column(JSON)
    read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)

Query it with the ordinary Model API, or the two small helpers this module provides:

from zeython.notifications import mark_as_read, unread_notifications

from app.Models.notification import Notification

unread = await unread_notifications(current_user, Notification)
await mark_as_read(unread[0])

Nothing here forces a mixin onto your notifiable model -- pass the same record_model you registered with NotificationServiceProvider, and any object with an .id can be a recipient.

Notifications vs. background jobs

They compose. notify() runs every channel inline, in the current request -- fine for a fast database write, slower for a real outbound mail send. For a notification that might be slow, dispatch a background job that calls notify() from inside handle(), rather than blocking the request on it directly.