Notification subscriptions & multi-channel delivery framework - #1959
Merged
Conversation
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>
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
force-pushed
the
feat/notification-subscriptions
branch
from
August 11, 2026 13:43
9a40369 to
f628c8d
Compare
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>
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>
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>
Contributor
There was a problem hiding this comment.
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::$typeIdisNotBlank, 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 afterinfo, 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.
…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>
|
martineiber
approved these changes
Aug 20, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



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
notifications.typeisVARCHAR(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.NotificationMinimalgains additivepopup+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