Skip to content

Notification subscriptions & multi-channel delivery framework - #1959

Merged
markus-moser merged 40 commits into
2026.xfrom
feat/notification-subscriptions
Aug 20, 2026
Merged

Notification subscriptions & multi-channel delivery framework#1959
markus-moser merged 40 commits into
2026.xfrom
feat/notification-subscriptions

Conversation

@markus-moser

@markus-moser markus-moser commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

What this adds

A 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. An email channel ships with it.

It pays off even with no contributing bundle: every notification Pimcore writes today is untyped and falls into a built-in catch-all type, turning today's unconditional toasting into a per-user pop-up choice — for workflow, user-to-user and any direct producer — without changing a single producer.

Key decisions

  • A type declares only whether it may leave the app, not through which channel — so a new channel (Teams, Slack, …) lights up for existing types automatically.
  • The in-app pop-up is a preference, not a transport, stored as a channel id so the subscription schema stays stable as channels are added.
  • Transport channels register only when a type can use them. A core-only install (just the catch-all, which allows no external delivery) ships no active channel; installing a bundle with an externally-deliverable type brings email back.
  • Type ids are capped at 20 chars (notifications.type is VARCHAR(20)), enforced at container build — no core schema change.

API

GET/PUT /notifications/subscriptions — the caller's effective preferences (merged with descriptor defaults) and a bulk store. NotificationMinimal gains additive popup + payload.

Related

Frontend lives in pimcore/studio-ui-bundle#3913 (draft) — it consumes these endpoints and should merge after a studio-backend release containing this change.

Draft · 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 4 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>
Add the email channel's greeting, CTA and footer for de/es/fr/it/no/sv, sitting
inline with the other keys, and drop the section comment across every catalog
(incl. en) so the keys read like the rest of the file. The title and message
still come from the notification; en is the fallback for any missing locale.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@markus-moser
markus-moser force-pushed the feat/notification-subscriptions branch from 9a40369 to f628c8d Compare August 11, 2026 13:43
markus-moser and others added 11 commits August 11, 2026 16:08
In a core-only install the sole notification type is the built-in "info"
catch-all, which never allows external delivery — so the email channel would be
dead weight: an extra column on the preferences screen and an instantiated mailer
no notification could ever reach. The dispatch compiler pass now evaluates the
registered descriptors and, when none allow external delivery, drops the tagged
transport channels entirely (the in-app "popup" substrate is always available and
is not a tagged channel). Installing a bundle that contributes an
externally-deliverable type — Collab's mention/task/discussion types — brings the
channels back automatically, so nothing changes for a real Studio install.

A descriptor wired with service references or that fails to construct is assumed
external-capable, so a channel a bundle actually wants is never stripped on a
false negative.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Symfony 8 removes the #[TaggedIterator] attribute in favour of #[AutowireIterator],
which has an identical constructor. A drop-in rename in the two notification
registries; behaviour is unchanged on Symfony 7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extract the definition-skip check and the channel apply/remove loop out of
process(), bringing its cognitive complexity back under the threshold. No
behaviour change — the gating and tagging are identical.

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

Every other Messenger message in the bundle declares its transport in a YAML file
(execution_engine.yaml, config/prepend/*.yaml); only the notification email routing
was inline PHP in the Extension. Move it to config/prepend/notification.yaml,
loaded like the other prepend configs. Transport is unchanged (pimcore_core — a
fire-and-forget delivery, not a job, so not pimcore_generic_execution_engine).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The template was a bare <div>. Wrap it in <!DOCTYPE html> + <html lang> +
<head><meta charset> + <body>, matching Pimcore's own workflow notification email
(Pimcore\Mail does not wrap fragments). Content is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add doc/03_Extending page: contributing a notification type descriptor and a
delivery channel, with the dispatch flow, the 20-char type-id cap, the config
envelope and the frontend-renderer pointer. Linked from the chapter index.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The <head> needs a title; use the notification's title. Fixes the SonarCloud
"Add a <title> tag to this page" reliability bug on the email template.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…g the runtime failure

The docs, the PR description and the exception text all promised that an over-long or
duplicated type id would fail at container build. It did not: both checks lived in
NotificationTypeRegistry's constructor, so a bad id compiled, deployed and passed CI, and
only surfaced as a 500 the first time anyone opened the preferences screen.

The failure was also lopsided. NotificationSavedSubscriber wraps the same resolution in
catch (Exception) and falls back to showing the toast, so the bell kept working while the
preferences screen was dead — and nothing was logged.

Move both checks into NotificationDispatchPass, which already materialises descriptors to
answer allowsExternalDelivery(). The check is best effort by construction: a descriptor
wired with service arguments cannot be read at compile time, so the registry keeps both
checks and stays authoritative. The docs now say that rather than overclaiming, and note
the consequence contributors need to know — a descriptor's constructor runs during
container compilation and must be side-effect free.

The swallowed exception in the subscriber is now logged, so a misconfigured descriptor is
no longer visible only as one screen failing in isolation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…he whole request

updateSubscriptions() validated every requested channel against the available set and threw,
which meant an administrator disabling a channel while the preferences screen was open cost
the user every other row in their bulk save — for something they could not influence.

It was also inconsistent with itself: a channel the type structurally cannot use was already
dropped silently a few lines further down, and resolveChannels' own docblock argued for
dropping while the loop above it rejected. The two mechanisms fought each other.

Both cases now drop. The endpoint returns the stored state so a dropped channel is visible
to the client rather than silent, and it is logged for anyone debugging one.

An unknown type id is still rejected, because that is not a race an administrator could have
caused and returning state cannot repair it — but as InvalidArgumentException (400) rather
than the registry's NotFoundException (404 ELEMENT_NOT_FOUND). It is a bad field in a request
body, not a missing resource, and 404 was undocumented in the endpoint's responses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The backend decides these key strings — GeneralNotificationDescriptor hardcodes the four
type keys, and SubscriptionService composes the channel keys from CHANNEL_TRANSLATION_PREFIX
and the channel id — but the values lived in studio-ui-bundle, in English only.

That put ownership in the wrong place: renaming a channel id here would break a label there
with no test failing in either repository, and a backend release would render raw keys until
the frontend caught up.

Verified that backend-owned keys do reach the UI before moving them: 22 keys in this file
are already consumed by the frontend and defined nowhere else (the studio_ee_job_* family),
served through the studio domain catalogue by getAllTranslationsByLocale.

studio-ui-bundle#3913 must drop these six keys when it lands; the ~20 notifications.settings.*
keys stay there, as the frontend composes those itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SonarCloud php:S1488 on the temporary $descriptor introduced by the previous commit. The
variable only existed to carry a /** @var */ annotation, because newInstanceArgs() returns
`object` while the method returns ?NotificationTypeDescriptorInterface.

Replace the annotation with a real instanceof narrowing. The check is a formality — the
caller only reaches materialise() for a class that already passed is_a() — but it narrows
the type honestly rather than asserting something PHPStan cannot verify, and it removes the
immediate-return Sonar flagged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
markus-moser and others added 10 commits August 18, 2026 11:38
Several of them restated the commit-message rationale in the source, or narrated what the
code used to do. Kept the why, dropped the archaeology.
* Gate transport channels by clearing the tag, not removing the definition

The gate removed the channel's service definition when no registered type allows external
delivery. Aliases and references are not rewritten by removeDefinition(), so the first bundle
to alias ChannelInterface to its own channel — exactly what the extending doc shows — got a
ServiceNotFoundException at compile time as soon as the gate closed.

Untagging achieves the same thing without touching the graph: the registry collects by tag,
and an untagged private service nothing references is dropped by Symfony's own unused-
definition pass, so no dead mailer is instantiated either way.

The new test aliases the interface to a gated channel and compiles the container; it fails
with removeDefinition() and passes with clearTag().

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

* Isolate per-recipient failures in the dispatcher, and make its decisions testable

Two problems with one cause: NotificationDispatcher::write() called Notification::save(),
which goes through a Dao, so the dispatcher could not be unit tested at all — and the write
was the only step in the fan-out that was not isolated.

The untestability was not theoretical. testBrokenChannelDoesNotPreventOtherChannelsFrom-
Delivering never called dispatch(); it built a TestChannel with throwOnSend and then asserted
on ChannelRegistry. TestChannel::$sent was written by the fixture and asserted nowhere in the
suite. The resilience guarantee ChannelInterface::send() documents in capitals was unverified,
as were the permission skip, the unsubscribed skip and the unknown-recipient skip.

Extract NotificationWriterInterface so the dispatcher holds only routing decisions, then wrap
the per-recipient body so a failed write is logged and the fan-out continues. Previously a
failure part-way through delivered to the recipients before it, silently skipped everyone
after it, and surfaced as an exception the producer could do nothing with — while deliver()
immediately below already logged and continued. The interface promised the latter behaviour.

Eight dispatcher tests now call dispatch() and assert on what the writer and the channels
actually received. Both fixes are mutation-checked: removing the guard fails the fan-out test,
and the pre-existing behaviour fails the isolation test.

The EffectiveSubscription and DispatchableNotification cases move to their own files, so
NotificationDispatcherTest is about the dispatcher.

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

* Trim comments

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
NotificationDispatchPass classified every definition in the container with
class_exists() plus a fresh ReflectionClass. That is ~6k classes on a real
install, and it read the definition's class name raw — so a descriptor or
channel registered as class: '%some.parameter%' was silently never tagged, and
a class with a missing parent would have raised an uncatchable fatal mid-build.
ContainerBuilder::getReflectionClass() resolves the parameter, contains the
fatal, and reuses the reflection the rest of the compilation already did.

Unsubscribing no longer wipes the stored channels. The switches say nothing
while a type is muted, and the resolver ignores them for an unsubscribed type
anyway, so overwriting the set only meant that turning a type off and on again
left the user subscribed to something that delivered nowhere — not even the
pop-up, because a stored empty set reads as a deliberate "none". Storing null
where nothing was ever chosen keeps the descriptor defaults reachable too.

The producer deep link accepted "//host", which is protocol-relative once the
host prefix is empty — and resolveHostUrl() legitimately returns an empty
string in a worker with no configured domain, the one case the host-relative
guarantee was written for.

Also: ChannelInterface still said the bundle ships no channel implementation,
which EmailChannel has since contradicted; Installer's new method landed
between an @throws docblock and createMcpAccessTokenTable(), leaving one method
with two docblocks and the other with none; the extending doc imported
UserInterface from the wrong namespace; and UpdateSubscriptionItem accepted
non-string channel ids that reached a string-typed closure as a 500.

Both behaviour changes are mutation-checked: the three new tests fail against
the previous code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The four keys the general descriptor emits were the only notification keys under
a singular notification.* prefix; everything else in the domain — the channel and
email chrome added here, and the notifications.* family the frontend has shipped
for as long as the bell has existed — is plural.

Renamed now because these are a one-way door: once released they are in POEditor
and in whatever a customer has overridden, and the descriptor's key is what the
API hands the frontend to render.

notification.type.general.* -> notifications.type.general.*, in the descriptor
and all seven catalogues. studio-ui-bundle#3913 still has to drop its copies of
these keys; it reads the key from the API response, so nothing there changes
beyond the removal already planned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
getGroup() reads as self-contained, but the preferences screen composes the
heading as notifications.settings.group.<group> rather than taking it from the
API the way the row label and description are taken. studio-ui-bundle ships only
the "general" key, so a contributed group renders its raw key as the heading.

Headings are hidden while there is one group, so the first bundle to contribute a
type is exactly the one that surfaces this — including the acme_crm example on
this page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four things, found by wiring #1959 into the demo app with Collab contributing four
externally-deliverable types and driving the preferences screen through Playwright.

resolveChannels() no longer keeps the stored channels when a type is switched off. My
earlier change did, on the reasoning that re-subscribing should restore them — but the
preferences screen clears its own channel set on mute ("Mirrors the server, which clears
channels when a type is switched off"), so the client sends an empty set on re-enable and
the stored one is overwritten anyway. The change bought nothing and left the two repos
describing opposite behaviour. What is worth keeping is narrower and now applies to both
branches: a channel id this installation does not offer was never on screen, so neither a
save nor a mute is entitled to clear it.

The update endpoint documented 400 for its two rejections. Both are
InvalidArgumentException, which this bundle maps to 422, so the generated client typed the
error wrong. Documented as 422; no behaviour change.

The email greeting is "Hi %name%," fed from getFullName(), and a Pimcore user need not have
a first or last name — a seeded user produced "Hi ,". Falls back to the username.

An email dispatched with no request and no pimcore.general.domain gets a host-relative
link, which in a mail client is a dead button. Nothing better can be emitted, but the
previous comment called it "a host-relative link rather than a broken absolute one" as
though that were fine. It is logged now.

Verified against the running app: 22 Playwright specs (11 API + 3 UI, plus setup), the
email observed in the mail catcher, and the channel gate confirmed to close in a core-only
install and reopen when Collab's descriptors return.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The email switch stores fine and then delivers nothing when the user has no email
address on their account, which is indistinguishable from a broken channel — it
cost a real debugging session to work out that was all it was.

ChannelInterface gains unavailableReasonFor(): a translation key when the channel
cannot reach that user, null when it can. It is the channel's own question to
answer, so a chat channel can say "no linked account" without the framework
knowing what an account is. EmailChannel answers it for a missing address, and the
skip in send() is logged rather than silent.

The reason travels on AvailableChannel so the preferences screen can explain the
column instead of hiding it: the preference is real and starts working the moment
an address exists, so hiding the switch would be the wrong fix.

Adding the method now costs nothing — the framework is unreleased and EmailChannel
is its only implementer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The compiler pass materialised descriptors during container compilation to
decide whether any type allows external delivery, and untagged the channels
when none did — plus duplicated the type-id validation the registry already
performs authoritatively. That was ~120 lines of the trickiest code in the
framework (constructors running mid-compilation, best-effort semantics with
an assumed-external fallback) for a decision a 5-line runtime check makes
with full accuracy.

The pass now only tags implementers. The registry keeps validation (its
tests already covered duplicate, overlong and at-limit ids independently);
SubscriptionService narrows the offered channel columns via the new
NotificationTypeRegistry::hasExternallyDeliverableType(). Delivery never
depended on the compile-time gate: with no externally-deliverable type the
resolver already narrows every subscription to the pop-up.

Trade-off: a duplicate or overlong type id now fails on first use of the
registry rather than at container build. Also drops the never-called
ChannelRegistry::getEnabledChannels().

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Interface and class docblocks carried multi-paragraph essays; several said
the same thing in three places (the null-vs-empty channels rule lived in the
entity, the migration and the installer). Comments now state the one
non-obvious fact and point elsewhere for the rest. No code changes beyond
removed comment lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
markus-moser and others added 10 commits August 19, 2026 12:38
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Lead with what to do (send / add a type / add a channel) instead of how the
dispatcher works internally. The 8-row method table is gone — the example
plus two sentences carry it. Mercure/pop-up internals dropped except where
they change what an extender must do (the payload privacy warning).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… hierarchy

A notification type had no behavior — the descriptor interface was eight
getters, and the abstract base class existed only to supply default values
and to insure the interface against evolution. Both jobs belong to a final
value object with constructor defaults: named arguments read like the
configuration they are, the shape is sealed, and a later addition is a new
constructor default instead of a BC break.

Bundles now register a NotificationTypeProviderInterface returning their
NotificationType instances (one provider per bundle, typically). The general
catch-all is built by the registry itself from GeneralNotificationType and
may not be claimed by a provider; its solo labels move to constants, which
also removes the instanceof special-casing in SubscriptionService.

Channels deliberately stay an interface: send() and unavailableReasonFor()
are real behavior with real dependencies.

Registry surface renamed to match (getTypes/getType/hasType/
hasOnlyGeneralType); tag renamed to
pimcore.studio_backend.notification_type_provider.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
EmailChannel::studioElementType() had its own Asset/Document/DataObject →
ElementTypes match — a second copy of ElementProviderTrait::getElementType().
Delegate to the trait instead, so the mapping lives in one place and the email
deep link can't silently diverge if it ever changes. The trait throws on an
unsupported type where the deep link wants "no segment", so the one behavioural
difference is preserved with a narrow catch. Drops four now-unused imports.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EmailChannel::resolveHostUrl() hand-rolled request-host-else-domain from the
RequestStack + getHostname()/getRequestScheme(). That reimplements — less
completely (it missed the localhost and non-standard-port handling) — the
Tool::getHostUrl() that core's own workflow-notification mail uses.

getHostUrl() is already exposed on ToolResolverInterface (via the contract it
extends), so delegate to it: same helper, testable, and it internally resolves
the current request. The RequestStack dependency is now unused and dropped;
the studio-specific "cannot make links absolute" warning stays.

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds user-configurable notification subscriptions, extensible delivery channels, email delivery, and notification payload/pop-up support.

Changes:

  • Adds notification type/channel registries and subscription APIs.
  • Adds asynchronous email delivery and persistence.
  • Adds documentation, translations, migrations, and unit tests.

Reviewed changes

Copilot reviewed 74 out of 74 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
translations/studio.sv.yaml Adds Swedish notification translations.
translations/studio.no.yaml Adds Norwegian notification translations.
translations/studio.it.yaml Adds Italian notification translations.
translations/studio.fr.yaml Adds French notification translations.
translations/studio.es.yaml Adds Spanish notification translations.
translations/studio.en.yaml Adds English notification translations.
translations/studio.de.yaml Adds German notification translations.
translations/studio_api_docs.en.yaml Documents subscription endpoints.
tests/Unit/Notification/Service/SubscriptionServiceTest.php Tests preference updates and channels.
tests/Unit/Notification/Dispatch/SubscriptionResolverTest.php Tests effective preference resolution.
tests/Unit/Notification/Dispatch/NotificationTypeRegistryTest.php Tests type registration and validation.
tests/Unit/Notification/Dispatch/NotificationDispatchPassTest.php Tests automatic service tagging.
tests/Unit/Notification/Dispatch/NotificationDispatcherTest.php Tests recipient dispatch behavior.
tests/Unit/Notification/Dispatch/Fixture/TestTypes.php Provides test notification types.
tests/Unit/Notification/Dispatch/Fixture/TestNotificationWriter.php Provides a test writer.
tests/Unit/Notification/Dispatch/Fixture/TestNotificationTypeProvider.php Provides a test type provider.
tests/Unit/Notification/Dispatch/Fixture/TestChannel.php Provides a test channel.
tests/Unit/Notification/Dispatch/EffectiveSubscriptionTest.php Tests channel selection behavior.
tests/Unit/Notification/Dispatch/DispatchableNotificationTest.php Tests dispatch DTO defaults.
tests/Unit/Notification/Dispatch/ChannelRegistryTest.php Tests channel registration.
tests/Unit/Notification/Dispatch/Channel/EmailChannelTest.php Tests email message creation.
templates/notification/email.html.twig Adds the notification email template.
src/PimcoreStudioBackendBundle.php Registers the notification compiler pass.
src/Notification/Service/SubscriptionServiceInterface.php Defines subscription operations.
src/Notification/Service/SubscriptionService.php Implements subscription APIs.
src/Notification/Schema/Subscription/UpdateSubscriptionsParameters.php Defines bulk update input.
src/Notification/Schema/Subscription/UpdateSubscriptionItem.php Defines one preference update.
src/Notification/Schema/Subscription/SubscriptionCollection.php Defines the preference response.
src/Notification/Schema/Subscription/SubscriptionChannel.php Defines per-type channel state.
src/Notification/Schema/Subscription/SubscribableType.php Defines subscription type metadata.
src/Notification/Schema/Subscription/AvailableChannel.php Defines available channel metadata.
src/Notification/Schema/NotificationMinimal.php Adds pop-up and payload fields.
src/Notification/Hydrator/SubscriptionHydratorInterface.php Defines subscription hydration.
src/Notification/Hydrator/SubscriptionHydrator.php Hydrates subscription responses.
src/Notification/Hydrator/NotificationHydratorInterface.php Extends minimal hydration contract.
src/Notification/Hydrator/NotificationHydrator.php Hydrates pop-up and payload data.
src/Notification/EventSubscriber/NotificationSavedSubscriber.php Resolves pop-up preferences for Mercure.
src/Notification/Event/SubscriptionCollectionEvent.php Adds a pre-response extension event.
src/Notification/Dispatch/Type/NotificationTypeProviderInterface.php Defines type providers.
src/Notification/Dispatch/Type/NotificationType.php Defines notification descriptors.
src/Notification/Dispatch/Type/GeneralNotificationType.php Adds the catch-all type.
src/Notification/Dispatch/Subscription/SubscriptionResolverInterface.php Defines preference resolution.
src/Notification/Dispatch/Subscription/SubscriptionResolver.php Merges stored and default preferences.
src/Notification/Dispatch/Subscription/SubscriptionRepositoryInterface.php Defines persistence operations.
src/Notification/Dispatch/Subscription/SubscriptionRepository.php Persists subscription rows.
src/Notification/Dispatch/Subscription/EffectiveSubscription.php Models resolved preferences.
src/Notification/Dispatch/Registry/NotificationTypeRegistryInterface.php Defines type registry operations.
src/Notification/Dispatch/Registry/NotificationTypeRegistry.php Collects and validates types.
src/Notification/Dispatch/Registry/ChannelRegistryInterface.php Defines channel registry operations.
src/Notification/Dispatch/Registry/ChannelRegistry.php Collects enabled channels.
src/Notification/Dispatch/NotificationWriterInterface.php Defines bell-entry persistence.
src/Notification/Dispatch/NotificationWriter.php Writes notification records.
src/Notification/Dispatch/NotificationDispatcherInterface.php Defines producer dispatch API.
src/Notification/Dispatch/NotificationDispatcher.php Routes notifications per recipient.
src/Notification/Dispatch/DispatchableNotification.php Defines producer notification data.
src/Notification/Dispatch/Channel/Messenger/SendNotificationEmailMessage.php Defines queued email data.
src/Notification/Dispatch/Channel/Messenger/SendNotificationEmailHandler.php Renders and sends email.
src/Notification/Dispatch/Channel/EmailChannel.php Queues notification emails.
src/Notification/Dispatch/Channel/ChannelInterface.php Defines transport channels.
src/Notification/Controller/UpdateSubscriptionsController.php Adds the subscription PUT endpoint.
src/Notification/Controller/GetSubscriptionsController.php Adds the subscription GET endpoint.
src/Notification/Attribute/Request/UpdateSubscriptionsRequestBody.php Documents the update request body.
src/Migrations/Version20260720120000.php Creates the subscription table.
src/Installer.php Manages the table for installations.
src/Exception/InvalidNotificationTypeException.php Adds type configuration errors.
src/Exception/InvalidNotificationChannelException.php Adds channel configuration errors.
src/Entity/Notification/NotificationSubscription.php Maps stored user preferences.
src/DependencyInjection/PimcoreStudioBackendExtension.php Wires notification configuration.
src/DependencyInjection/Configuration.php Adds notification configuration options.
src/DependencyInjection/CompilerPass/NotificationDispatchPass.php Tags notification extensions.
doc/03_Extending/README.md Links notification documentation.
doc/03_Extending/14_Extending_Notifications.md Documents framework extension points.
config/prepend/notification.yaml Routes email jobs to Messenger.
config/notifications.yaml Registers notification services.
Suppressed comments (2)

src/Notification/Dispatch/Registry/NotificationTypeRegistry.php:108

  • An empty type ID currently passes this validation. That type cannot be managed through the API because UpdateSubscriptionItem::$typeId is NotBlank, and saved notifications with an empty type are resolved to the general bucket, so dispatch and pop-up preferences disagree. Reject empty IDs when collecting providers.
                if (strlen($typeId) > self::MAX_TYPE_ID_LENGTH) {

src/Notification/Dispatch/Registry/NotificationTypeRegistry.php:134

  • The catch-all is not always last as documented. A contributed type may use PHP_INT_MAX; if its ID sorts after info, this comparator places it after the catch-all. Sort the general type explicitly after all contributed types rather than relying only on its sort order.
        uasort(
            $types,
            static fn (NotificationType $a, NotificationType $b): int
                => [$a->getSortOrder(), $a->getTypeId()] <=> [$b->getSortOrder(), $b->getTypeId()]
        );

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/Notification/EventSubscriber/NotificationSavedSubscriber.php
Comment thread src/Notification/Dispatch/Channel/EmailChannel.php Outdated
Comment thread src/Notification/Dispatch/Registry/NotificationTypeRegistry.php
Comment thread src/Notification/Dispatch/Subscription/SubscriptionResolver.php
Comment thread src/Notification/Dispatch/Type/NotificationType.php Outdated
Comment thread tests/Unit/Notification/Service/SubscriptionServiceTest.php Outdated
markus-moser and others added 3 commits August 19, 2026 18:34
…d one

NotificationSavedSubscriber published to Topics::STUDIO — the topic every
Studio client subscribes to (StudioTopicProvider) — so a notification's title,
message, payload and the recipient's unread count rode the wire to all
connected users, with only the frontend's client-side recipient check keeping
them out of view. That check cannot prevent reading the data off the socket.

Publish to UserTopicService::getUserTopic($recipientId) instead. The recipient
already subscribes to their own topic (UserTopicProvider grants only the
current user's), so delivery is unchanged while no other client receives it.
Guards a null recipient (nothing to deliver to a user topic).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- EmailChannel: resolve the Studio base path from pimcore_studio_ui.url_path
  instead of hard-coding /pimcore-studio, so a customised url_path yields
  correct email links. Wired in the extension with a fallback default since
  studio-ui is not a hard dependency.
- SubscriptionResolver: array_unique the effective channels so a type
  declaring duplicate default channels can't make the dispatcher send the
  same email twice.
- NotificationType: drop @internal — it is the object contributing bundles
  construct via NotificationTypeProviderInterface, i.e. a public extension point.
- SubscriptionServiceTest: the rejected-unknown-type comment said 400; the
  mapped status is 422.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment-only: trim the Mercure-topic, EmailChannel $studioPath, resolver
dedup and extension comments to the load-bearing line.

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

Copy link
Copy Markdown

@markus-moser
markus-moser marked this pull request as ready for review August 20, 2026 08:07
@markus-moser markus-moser added this to the 2026.3.0 milestone Aug 20, 2026
@markus-moser
markus-moser merged commit 89b3ed9 into 2026.x Aug 20, 2026
25 of 26 checks passed
@markus-moser
markus-moser deleted the feat/notification-subscriptions branch August 20, 2026 11:57
@github-actions github-actions Bot locked and limited conversation to collaborators Aug 20, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants