Yo for Craft CMS

The API

Yo is a plugin for plugins. Everything it does to a message is something another plugin can do too, at the same seam, with the same result.

Making Yo an optional dependency

The whole API is safe to call when Yo is installed but switched off — every method returns false rather than throwing. To keep it optional entirely, guard the call site:

use justinholtweb\yo\Yo;

if (class_exists(Yo::class) && Yo::isReady()) {
    Yo::success('Saved.');
}

Put justinholtweb/craft-yo in your suggest, not your require, and your plugin gains a panel on the sites that have one and loses nothing on the sites that don't.

Send-time events

Messages::EVENT_BEFORE_SEND

Fired before anything is written. The message is mutable, and $event->isValid = false drops it.

use justinholtweb\yo\services\Messages;
use justinholtweb\yo\events\MessageEvent;

Event::on(Messages::class, Messages::EVENT_BEFORE_SEND, function(MessageEvent $event) {
    // A site that has decided one chatty plugin has said enough.
    if ($event->message->plugin === 'someplugin' && $event->message->type === 'notice') {
        $event->isValid = false;
    }
});

This is the seam that makes Yo bearable on a site with thirty plugins: the site owner gets one place to turn something down, rather than thirty settings screens.

Messages::EVENT_AFTER_SEND

Fired once it has gone. $event->channels is where it went; $event->deliveries is how many people it was queued for, or -1 for a broadcast worked out on read.

Messages::EVENT_DEFINE_DELIVERY

Fired with the resolved channel list, before anything is written. Add a handle to send a copy somewhere else; remove one to hold it back.

use justinholtweb\yo\events\DefineDeliveryEvent;

Event::on(Messages::class, Messages::EVENT_DEFINE_DELIVERY, function(DefineDeliveryEvent $event) {
    if ($event->message->type === 'error') {
        $event->channels[] = 'slack';
    }
});

Receipt events

All four carry a DeliveryEvent with $event->message, $event->delivery and $event->userId. The message carries the context array you set when you sent it, which is how you find whatever you were doing at the time.

EventWhen
EVENT_MESSAGE_SHOWNThe first time this person's copy reached a screen
EVENT_MESSAGE_READThey opened it
EVENT_MESSAGE_DISMISSEDThey closed it
EVENT_MESSAGE_ACTIONEDThey pressed a button — $event->actionKey says which
EVENT_MESSAGE_EXPIREDIt ran out of time before anybody looked (a MessageEvent)

EVENT_MESSAGE_ACTIONED is the one worth building on. It turns a message into a control surface:

Event::on(Messages::class, Messages::EVENT_MESSAGE_ACTIONED, function(DeliveryEvent $event) {
    match ($event->actionKey) {
        'retry' => $this->retry($event->message->context['jobId']),
        'ignore' => $this->suppress($event->message->context['ruleId'], $event->userId),
        default => null,
    };
});

Registering a message type

use justinholtweb\yo\services\Types;
use justinholtweb\yo\events\RegisterMessageTypesEvent;
use justinholtweb\yo\models\MessageType;

Event::on(Types::class, Types::EVENT_REGISTER_MESSAGE_TYPES, function(RegisterMessageTypesEvent $e) {
    $e->types['deployed'] = new MessageType([
        'handle' => 'deployed',
        'label' => 'Deployed',
        'color' => '#2f9e6b',
        'icon' => '<svg viewBox="0 0 24 24">…</svg>',
        'sticky' => true,
        'priority' => 15,
    ]);
});

Then Yo::say('Live on production')->type('deployed')->send().

The icon is rendered verbatim in the control panel and never on the front end — what a warning looks like on somebody's site is theirs to decide. priority sorts it in the panel; sticky says whether a message of this type waits to be dismissed when the sender did not say.

A type registered without a key is re-keyed on its own handle, so a listener that appends rather than assigns still works.

Registering a channel

A channel is somewhere a message can end up. Yo ships three; the fourth is yours.

use justinholtweb\yo\channels\BaseChannel;
use justinholtweb\yo\models\Message;

class SlackChannel extends BaseChannel
{
    public function handle(): string
    {
        return 'slack';
    }

    public function label(): string
    {
        return 'Slack';
    }

    /** Wait to be asked for by name — do not take everything by default. */
    protected function wantsByDefault(): bool
    {
        return false;
    }

    public function deliver(Message $message, array $userIds): void
    {
        MyPlugin::getInstance()->slack->post($message->title, $message->body);
    }
}
use justinholtweb\yo\services\Channels;
use justinholtweb\yo\events\RegisterChannelsEvent;

Event::on(Channels::class, Channels::EVENT_REGISTER_CHANNELS, function(RegisterChannelsEvent $e) {
    $e->channels[] = SlackChannel::class;
});

Every plugin already sending Yos now reaches Slack, and none of them changed.

isQueued() is the other method worth knowing. It is false by default and means "I hand the message on and have nothing left to track". The control panel and front-end channels return true because Yo keeps their queue and their receipts. A transport almost never wants that.

A channel that throws is logged and stepped over — one broken transport cannot lose a message everywhere else. A registration that is not a channel at all is logged and skipped.

Template hooks

The panel invokes three, so a plugin can put its own markup inside it:

Craft::$app->getView()->hook('yo.panel.footer', function(array &$context) {
    return '<a href="/admin/transport">Transport</a>';
});
HookWhere
yo.panel.headerIn the panel's bar, after the count
yo.panel.footerIn the panel's footer, right-aligned
yo.message.metaOn every message, next to the sender and the time

Twig

craft.yo.init()Registers the assets and outputs the front-end container
craft.yo.messages(drain = true)The waiting messages, as arrays
craft.yo.count()How many are waiting, without taking them
craft.yo.types()Every registered message type
craft.yo.list(messages = null)Renders the list without the container
craft.yo.say(title, body, type)Sends one to the front-end channel

Console

php craft yo/say "Deploy finished" --type=success --to=group:editors --body="v4.2.0"
php craft yo/say "Read this" --to=everyone --stick
php craft yo/channels
php craft yo/types
php craft yo/purge

The wire shape

What the panel, the front-end template and every server patch all read:

uid, plugin, title, body, type, typeLabel, color, icon,
sticky, priority, dedupeKey, timestamp,
actions[] { label, url, action, params, key, primary, dismisses }