Skip to content

Notification subscriptions & multi-channel delivery framework - #1959

Draft
markus-moser wants to merge 5 commits into
2026.xfrom
feat/notification-subscriptions
Draft

Notification subscriptions & multi-channel delivery framework#1959
markus-moser wants to merge 5 commits into
2026.xfrom
feat/notification-subscriptions

Conversation

@markus-moser

Copy link
Copy Markdown
Contributor

What this adds

A generic, extensible notification framework for Studio. Bundles contribute notification types and delivery channels as tagged services, and each user chooses, per type, whether they are notified and through which channels.

The immediate win needs no contributing bundle at all. Every notification Pimcore writes today is untyped, so it falls into a built-in catch-all type whose pop-up preference is honoured when the notification is published over Mercure. That turns today's unconditional toasting into a choice — for workflow transitions, user-to-user messages, and anything a bundle writes directly through the notification model — without touching a single producer.

Key design decisions

  • A type declares only whether it may leave the application, never through which channel. Supported channels are derived from that capability, so a bundle contributing (say) a Teams channel lights up for existing types automatically, without those bundles being edited.
  • The in-app pop-up is modelled as a channel from the user's point of view but is not a transport — it's a preference read at publish time. Storing it in the same JSON set is what keeps the subscription schema stable when channels are added.
  • No channel implementation ships here. The only type present is the catch-all, which deliberately allows no external delivery — a bucket of unclassified notifications is not something to email. Whichever bundle first contributes an externally-deliverable type contributes the channel alongside it.
  • Type ids are capped at 20 characters because notifications.type is VARCHAR(20) and MySQL truncates silently outside strict mode; the registry rejects violations at container build. No core schema change is required.
  • The catch-all relabels itself ("All notifications" when it is the only registered type, "Everything else" once a bundle adds others), resolved server-side so the frontend renders what it is given.

API

Two endpoints under /notifications/subscriptions (GET the caller's effective preferences merged with descriptor defaults; PUT to store them in bulk). NotificationMinimal gains popup and payload, both additive and defaulting so a client that has not adopted them behaves exactly as before.

Layers

src/Notification/Dispatch/* (neutral, promotable namespace) — DispatchableNotification, dispatcher, descriptor + channel interfaces, tagged-iterator registries, subscription entity/repository/resolver — plus the subscription API (controllers, service, hydrator, events, schemas), an Installer table + migration, and the Mercure pop-up resolution.

Tests & checks

  • 28 Codeception unit tests covering the registry (id-length + uniqueness guards, catch-all resolution, solo-label), the channel registry (capability derivation, admin disable), the subscription resolver merge matrix (defaults vs. stored, the null-vs-empty-array distinction, channel narrowing), and the dispatcher.
  • Full bundle unit suite green (493 tests, 1129 assertions); PHPStan clean on the new code.
  • Verified end-to-end against a running app over real HTTP: GET/PUT, persistence, validation (locked-type + unknown-channel rejections), and an untyped notification resolving to popup: false in the Mercure payload while its bell entry is still written.

Cross-repo note

The matching frontend lives in pimcore/studio-ui-bundle#feat/notification-subscriptions (also draft). That PR is inert until this one ships the fields, and its preferences screen needs these endpoints; it should merge after a studio-backend release containing this change.

Status

Draft — opened for review of the design and contract. Branch base: 2026.x.

🤖 Generated with Claude Code

markus-moser and others added 2 commits July 20, 2026 17:42
Introduces a generic, extensible notification framework: bundles contribute
notification *types* and delivery *channels* as tagged services, and each user
chooses per type whether they are notified and through which channels.

The immediate win needs no contributing bundle at all. Every notification
Pimcore writes today is untyped, so it falls into a built-in catch-all type
whose pop-up preference is honoured when the notification is published over
Mercure. That turns today's unconditional toasting into a choice for workflow
transitions, user-to-user messages and anything a bundle writes directly,
without touching a single producer.

Design notes worth keeping in mind when extending this:

- A type declares only *whether* it may leave the application, never through
  which channel. Supported channels are derived from that capability, so a
  bundle contributing a Teams channel lights up for existing types without
  those bundles being edited.
- The pop-up is modelled as a channel from the user's point of view but is not
  a transport: it is a preference read at publish time. Storing it in the same
  JSON set is what keeps the schema stable when channels are added.
- No channel implementation ships here. The only type present is the catch-all,
  which deliberately allows no external delivery — a bucket of unclassified
  notifications is not something to email. Whichever bundle first contributes
  an externally-deliverable type contributes the channel alongside it.
- Type ids are capped at 20 characters because notifications.type is
  VARCHAR(20) and MySQL truncates silently outside strict mode. The registry
  rejects violations at boot rather than letting a truncated id match nothing.
- The catch-all reports a different label when it is the only registered type:
  there is nothing for it to be "everything else" to.

NotificationMinimal gains popup and payload. Both are additive and popup
defaults to true, so a client that has not adopted them behaves as before.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The catch-all was expected to arrive through the service tag like any other
descriptor. When the tag was not applied the registry held nothing, so a bare
installation reported no subscribable types and the preferences screen came up
empty — found by calling the endpoint against a running app.

Registering it directly is also the better design regardless of the tag: every
notification ever written falls into this type, and on an installation with no
contributing bundle it is the only one there is. Its presence should not be
something wiring can break.

Also fixes the channel translation key prefix, which did not match the keys
shipped in studio-ui.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
markus-moser and others added 3 commits July 21, 2026 11:47
The module marks its internal service, repository and hydrator interfaces
@internal; the new internal contracts were missing it. Adds it to the internal
registry/subscription interfaces and the internal EffectiveSubscription value
object, so the public surface stays limited to what is genuinely meant for
external use — the descriptor and channel interfaces, the dispatcher, the
DispatchableNotification producers build, the subscription-collection event, and
the API schemas — all of which deliberately keep no @internal.

Docblock only; no behaviour change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The framework's extensibility relies on bundles contributing type descriptors
and delivery channels as tagged services, collected by the registries' tagged
iterators. That tagging was expressed as #[AutoconfigureTag] on the interfaces —
which, it turns out, does not tag implementers in Pimcore's container (the
existing tagged collectors here, e.g. GDPR providers, are all tagged explicitly
in YAML). The result was that no contributed descriptor or channel was ever
collected: the type registry only ever saw the built-in catch-all, which it adds
directly.

Surfaced while wiring collab-bundle's notification types: they registered
cleanly but never appeared. A compiler pass tags every implementer of the
descriptor and channel interfaces. It runs after all bundle extensions load, so
a type or channel from any bundle is picked up without that bundle knowing the
tag name — which is what makes the framework actually extensible. Idempotent, so
a bundle that tags explicitly is not tagged twice, and abstract definitions are
skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The framework shipped the channel seam but no transport. This adds EmailChannel,
the first ChannelInterface implementation, so externally-deliverable types (the
Collab types today) can reach people by email as well as the bell and pop-up.

- EmailChannel resolves the recipient, language and an absolute deep link inside
  the producing request, then hands a fully-resolved SendNotificationEmailMessage
  to the pimcore_core transport. The blocking send happens in the worker, so a
  slow mail server never delays the comment or assignment that triggered it.
- The email mirrors the bell entry — the notification's own title and message plus
  a link, nothing from the payload — except one navigation hint: a producer may
  supply an app-relative deepLink (host-relative only, so a payload can never make
  the button off-site) to point at a better destination than the linked element,
  e.g. a Collab task or discussion in its Overview.
- The body is a Twig template rendered in the recipient's language. It is
  overridable: point notifications.email.template at your own template, or drop a
  file at templates/bundles/PimcoreStudioBackendBundle/notification/email.html.twig.
- Delivery rides the existing pimcore_core messenger transport (routing registered
  in the bundle extension), so the standard messenger:consume worker covers it.

Registering EmailChannel makes the Email column appear in the preferences screen
with no frontend change, respecting each type's allowsExternalDelivery and default
channels. Unit-tested for enqueue-not-inline, message content, deep-link
resolution and the host-relative guard; verified end-to-end into the mail catcher.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant