Templates and channels
Needs review
Needs review: The variables class for GreetingSent is a sketch that is not in the repository or tested. Check it against templates-email before you copy it.
The templates package sends messages when domain events happen. A template type is a
triple:
Template::register(string $eventClass, string $channelClass, string $variableClass);- Event: a domain event class, for example
Ulams\Payments\Events\PaymentSuccess. - Channel: how the message is delivered. The channels that exist are
EmailChannel(templates-email, MJML),SmsChannel(templates-sms) andPdfChannel(templates-pdf, which generates certificates). - Variables: which
@Var…tokens the template may use and how to fill them from the event.
Admins write the actual templates per channel in the admin panel
(Templates). A template can be the default for its event and channel, or
assigned to one record, for example one course. GET /api/admin/templates/events lists every
registered event, channel and token.
How sending works
Section titled “How sending works”UlamsTemplatesServiceProviderlistens to every event whose class name starts withUlams:Event::listen('Ulams*', …). Events in other namespaces never reach templates.TemplateEventListenerwraps the event inEventWrapperand continues only when the event has a user: agetUser()method, or any property holding aUser.TemplateEventService::handleEvent()goes through each channel registered for the event. It picks the template assigned to the record ofassignableClass(), or else the default. It skips templates that are not valid, then fills the tokens with$variableClass::variablesFromEvent($event).- It calls
$channelClass::send($event, $sections).
EventWrapper resolves getX() calls through the event’s getX() method, a toArray() key or a
property. So $event->getCourse() works for any event with a course property.
The notifications package runs a similar wildcard listener: every Ulams* event with a user
is stored as a database notification of that user (GET /api/notifications). See
Events and notifications for the list of events.
The contracts
Section titled “The contracts”Ulams\Templates\Contracts\TemplateVariableContract:
public static function variables(): array;public static function mockedVariables(?User $user = null): array;public static function variablesFromEvent(EventWrapper $event): array;public static function assignableClass(): ?string;public static function requiredSections(): array;public static function requiredVariables(): array;public static function requiredVariablesInSection(string $sectionKey): array;public static function defaultSectionsContent(): array;public static function processTemplateAfterSaving(Template $template): Template;Variables classes extend Ulams\Templates\Core\AbstractTemplateVariableClass, which is an
enum. The tokens are class constants, and variables() returns them together with the settings
tokens (SettingsVariables). Each channel package has a base class: EmailVariables adds
@VarAppName and wrapWithMjml(). mockedVariables() fills the preview (GET /api/admin/templates/{id}/preview).
Ulams\Templates\Contracts\TemplateChannelContract:
public static function send(EventWrapper $event, array $sections): bool;public static function preview(User $user, array $sections): bool;public static function sections(): Collection;public static function sectionsRequired(): array;public static function sectionsReadonly(): array;public static function section(string $sectionKey): ?TemplateSectionSchema;public static function sectionExists(string $sectionKey): bool;public static function processTemplateAfterSaving(Template $template): Template;public static function channelAvailable(User $user): bool;AbstractTemplateChannelClass implements all of them except send, preview and sections.
The channels declare these sections:
| Channel | Sections |
|---|---|
EmailChannel |
title (text, required), content (MJML, required), contentHtml (HTML, read-only, rendered from content on save) |
SmsChannel |
content (text, required) |
PdfChannel |
title (text, required), content (a Fabric.js canvas, the certificate layout) |
A template type for your event
Section titled “A template type for your event”Your event must:
- live in the
Ulams\namespace, or the wildcard listener ignores it; - carry the recipient as a
User(auserproperty orgetUser()); - expose what the variables need as public properties or getters. Keep them public if the event
may be queued (
SerializesModels).
Ulams\ExamplePlugin\Events\GreetingSent from the example package
qualifies. The repository does not register a template for it. A variables class following
templates-email/src/Courses/CommonUserAndCourseVariables.php would look like this:
namespace Ulams\ExamplePlugin\Templates;
use Ulams\Core\Models\User;use Ulams\Templates\Events\EventWrapper;use Ulams\TemplatesEmail\Core\EmailVariables;
class GreetingSentVariables extends EmailVariables{ const VAR_USER_NAME = '@VarUserName'; const VAR_GREETING = '@VarGreeting';
public static function mockedVariables(?User $user = null): array { return array_merge(parent::mockedVariables($user), [ self::VAR_USER_NAME => 'Ada Lovelace', self::VAR_GREETING => 'Hello from the example plugin, Ada', ]); }
public static function variablesFromEvent(EventWrapper $event): array { return array_merge(parent::variablesFromEvent($event), [ self::VAR_USER_NAME => $event->getUser()->name, self::VAR_GREETING => $event->getGreeting(), ]); }
public static function requiredVariables(): array { return [self::VAR_GREETING]; }
public static function requiredVariablesInSection(string $sectionKey): array { return $sectionKey === 'content' ? [self::VAR_GREETING] : []; }
public static function assignableClass(): ?string { return null; // one default template, not per record }
public static function defaultSectionsContent(): array { return [ 'title' => 'A greeting for you', 'content' => self::wrapWithMjml('<h1>' . self::VAR_GREETING . '</h1>'), ]; }}Register it in a provider’s boot(), only when the channel package is present:
use Ulams\Templates\Facades\Template;use Ulams\TemplatesEmail\Core\EmailChannel;use Ulams\TemplatesEmail\UlamsTemplatesEmailServiceProvider;
if (class_exists(UlamsTemplatesEmailServiceProvider::class)) { Template::register(GreetingSent::class, EmailChannel::class, GreetingSentVariables::class);}In the repository the registrations sit in the channel packages, one provider per feature, for
example templates-email/src/Providers/PaymentsTemplatesServiceProvider.php:
Template::register(PaymentSuccess::class, EmailChannel::class, PaymentSuccessVariables::class).
Only the first registration of an event and channel pair counts.
Create the default template row with Template::createDefaultTemplatesForChannel(EmailChannel::class)
(the seeders in templates-*/database/seeders do this), or let an admin create it. Test with
Template::fake() and Template::assertEventHandled(...).
A new channel
Section titled “A new channel”- Create a package, for example
templates-push, followingtemplates-sms. - Write the channel: extend
AbstractTemplateChannelClass, implementTemplateChannelContract, declaresections()asTemplateSectionSchemaobjects (TemplateSectionTypeEnum::SECTION_TEXT(),SECTION_MJML(),SECTION_HTML(),SECTION_URL(),SECTION_FABRIC()), and deliver insend(). Returnfalsewhen the user cannot receive the message, for example when there is no phone number, and log failures instead of throwing.channelAvailable(User $user)decides whether the channel is offered for a user. - Write a variables base class for the channel and one variables class per event.
- Register each event with
Template::register(Event::class, YourChannel::class, YourVariables::class)in the provider’sboot(), guarded byclass_existson the feature package. - Register the provider in
api/config/app.phpand add a seeder that callsTemplate::createDefaultTemplatesForChannel(YourChannel::class).