Newsletter Management: Technical details
Architecture, plugin structure, tables and entities, migrations, design decisions, tests and development workflow of the Sylius newsletter management plugin
Newsletter Management: Technical details
This documentation always describes the latest published version.
This page describes the structure and internals of the plugin for integrators and developers: how signup and sending flow, the layout, the tables and the most important design decisions.
Architecture
Signups go through exactly one entry point, the NewsletterSubscriptionManager. Whether it is the storefront form, the registration checkbox or the Shop API, all call the same methods to subscribe, confirm, update and unsubscribe. The footer teaser does not call the manager itself; it hands over to the form on /newsletter via GET. There is no second business logic for the API.
graph TD
A["Storefront: form, registration"] --> M["NewsletterSubscriptionManager"]
B["Shop API: processors and provider"] --> M
M --> E{"PRE_SUBSCRIBE event"}
E -->|"rejected"| R["Rejected: flash or 422"]
E -->|"allowed"| P["Save subscription"]
P --> ML["NewsletterMailer: confirmation, welcome, already subscribed"]
P --> S["Sync of the customer newsletter flag"]
P --> T["Tracking: consent and deletion"]
Sending runs separately, driven by cron:
graph LR
C["Cron: mmd:newsletter:send"] --> SV["NewsletterSendingService"]
SV --> D["Claim deliveries"]
D --> MS["NewsletterMailSender"]
MS --> RE["NewsletterRenderer"]
RE --> TH["Theme of the channel"]
RE --> LT["NewsletterLinkTracker"]
MS --> TR["Symfony Mailer"]
ADM["Admin: send, schedule, cancel"] --> SV
Plugin structure
sylius-newsletter-management/
βββ assets/ admin and shop assets
βββ config/
β βββ config.yaml
β βββ mailer.yaml e-mail codes of the three subscriber mails
β βββ services.php all services in one file
β βββ grids/ admin grids
β βββ routes/ admin.yaml, shop.yaml
β βββ twig_hooks/ admin.yaml, shop.yaml
βββ src/
β βββ Api/ Shop API: processors, provider, query extension
β βββ Command/ setup, send, subscription:purge, tracking:purge
β βββ Controller/ storefront, dashboard, preview, sending, tracking, export
β βββ DependencyInjection/
β βββ Entity/ entities, interfaces and enums
β βββ Event/ PRE_SUBSCRIBE
β βββ EventListener/ customer checkbox, delete guard
β βββ Factory/
β βββ Form/ form types and models
β βββ Mailer/ subscriber mails
β βββ Manager/ NewsletterSubscriptionManager
β βββ Menu/ admin menu
β βββ Migrations/ 11 Doctrine migrations
β βββ Newsletter/ placeholders, renderer, text generator, theme, URLs
β βββ Repository/
β βββ Sender/ newsletter sending per recipient
β βββ Sending/ sending orchestration
β βββ Settings/ per-channel settings, privacy link, consent text
β βββ Statistics/ numbers for the dashboard and newsletter detail
β βββ Sync/ sync with the customer newsletter flag
β βββ Tracking/ link tracker, recorder, signer
β βββ Twig/ Twig extension
βββ templates/ admin, emails, shop
βββ tests/ Unit, Integration, Functional, TestApplication
βββ translations/ messages, flashes, validators (de, en)
Entities and tables
All tables carry the prefix mmd_sylius_newsletter_management_.
| Entity | Table | Content |
|---|---|---|
NewsletterSubscription |
..._subscription |
One subscription per address and channel (unique). Status, source, tokens, timestamps, tracking consent, channel and customer (optional). |
NewsletterCategory |
..._category |
Category with code, position and an enabled switch |
NewsletterCategoryTranslation |
..._category_translation |
Translatable name and description |
NewsletterChannelConfiguration |
..._channel_configuration |
Per-channel settings (exactly one row per channel) |
Newsletter |
..._newsletter |
Subject, preheader, HTML, text, status, schedule, recipient count |
NewsletterDelivery |
..._delivery |
Delivery log: one row per newsletter and address (unique) |
NewsletterLink |
..._link |
Tracked links of a newsletter: code and URL |
NewsletterTrackingHit |
..._tracking_hit |
Anonymous counter per newsletter, day and target |
NewsletterTrackingEvent |
..._tracking_event |
A single event (open or click) of a consenting subscriber |
There are also three join tables: ..._subscription_category (the categories of a subscription), ..._newsletter_category (a newsletter's audience) and ..._category_channel (the channels a category is offered in; empty = all).
erDiagram
SUBSCRIPTION }o--o{ CATEGORY : "chooses"
NEWSLETTER }o--o{ CATEGORY : "audience"
CATEGORY ||--o{ CATEGORY_TRANSLATION : "translated by"
NEWSLETTER ||--o{ DELIVERY : "delivery log"
SUBSCRIPTION |o--o{ DELIVERY : "receives"
NEWSLETTER ||--o{ LINK : "tracks"
NEWSLETTER ||--o{ TRACKING_HIT : "counts"
NEWSLETTER ||--o{ TRACKING_EVENT : "records"
SUBSCRIPTION ||--o{ TRACKING_EVENT : "triggers"
Enums (PHP backed enums, no tables of their own):
| Enum | Values |
|---|---|
NewsletterStatus |
draft, ready, scheduled, sending, sent, cancelled |
NewsletterSubscriptionStatus |
pending, confirmed, unsubscribed |
NewsletterSubscriptionSource |
storefront, api, registration, admin |
NewsletterDeliveryStatus |
pending, sending, sent, failed, skipped |
NewsletterTrackingMode |
off, anonymous, personal |
NewsletterTrackingEventType |
open, click |
Migrations
The plugin ships 11 migrations. They are applied with bin/console mmd:newsletter-management:setup (see Installation).
| No. | Purpose |
|---|---|
| 1 | Tables for subscriptions, categories (with translation), channel settings and the category assignment |
| 2 | First and last name on the subscription |
| 3 | Privacy policy page per channel |
| 4 | last_notified_at on the subscription (mail throttle) |
| 5 | Newsletter table with its category assignment |
| 6 | Channel restriction of categories |
| 7 | Sending: schedule and progress columns, delivery log, mailer transport per channel |
| 8 | Anonymous tracking: mode and retention, link table, daily counters |
| 9 | Personal tracking: consent, event table, consent text and retention per channel |
| 10 | Retention of unsubscribed subscriptions per channel |
| 11 | Retention of unconfirmed signups per channel |
Design decisions
- One entry point for subscriptions. The
NewsletterSubscriptionManageris the only place that subscribes, confirms, updates and unsubscribes. Storefront and Shop API differ only in how they take input and produce output. Before it saves or sends anything, it dispatches thePRE_SUBSCRIBEevent (see Extending). - Cron instead of Messenger. Sending is a console command. A worker for an async queue is infrastructure the plugin cannot assume in someone else's shop, and an unrouted Messenger message would run inside the admin request. A run can safely overlap with another.
- Recipients are fixed at the start. There is exactly one delivery per newsletter and address (unique key). Nobody gets the same newsletter twice, whatever runs concurrently.
- Atomic claiming. Every delivery is claimed before sending. A claim that is never finished goes back to waiting after 15 minutes, and a failed send is tried up to three times.
- Counters without database-specific features. The recorder increments counters portably: first an
UPDATE, anINSERTwhen it hit zero rows, and anotherUPDATEif a concurrent request created the row in between. There is noON DUPLICATE KEY. - Signed tracking URLs.
TrackingSignerbuilds an HMAC-SHA256 overkernel.secretand uses the first 32 characters. It is meant to stop probing, not to authenticate. Links carry only a code (the first 16 characters of the URL's SHA-256); the target lives in the database. - A foreign key that protects the log. A delivery log row's reference to the subscription is set to
SET NULL, notCASCADE. If a subscription is deleted, the log and its address stay. Tracking events, in contrast, hang off the subscription withCASCADEand disappear with it. The subscription's references to channel and customer areSET NULLas well. - Placeholders instead of Twig. Placeholders are a fixed list filled in by plain string substitution. That does not hand authors the whole Twig runtime.
- The channel's theme. A decorator of the Sylius theme context switches to the newsletter channel's theme for the duration of one render and restores it afterwards. Otherwise the cron job, which has no request, would render the bare Sylius layout.
Tests
| Directory | Content |
|---|---|
tests/Unit/ |
Unit tests: DependencyInjection, Entity, EventListener, Manager, Newsletter, Settings, Sync, Twig |
tests/Integration/ |
Integration test of the setup command (Command) |
tests/Functional/ |
Functional tests, for example signup flow, Shop API, sending, tracking, statistics, theme, cleanup commands |
tests/TestApplication/ |
The Sylius test application (config, src, templates) |
Development
Development happens exclusively inside the container (PHP 8.3, MySQL 8.4, nginx). A local PHP is not intended.
| Command | Purpose |
|---|---|
make init |
Create the compose override, composer install, build the test application's frontend, start the containers |
make database-init |
Create the database and run the migrations |
make load-fixtures |
Load the Sylius fixtures (the functional tests need them) |
make phpunit |
Unit and functional tests |
make phpstan |
Static analysis |
make ecs |
Code style |
make up, make down, make clean |
Start and stop the containers, or remove them with their volumes |
make php-shell |
Shell in the PHP container |
These commands are for developing the plugin itself. You do not need them to run it in your shop.