Back to Journal

Slack notifications for recipient updates

Today's task: notify the admin team via Slack when a freelancer updates their invoice recipient. Sounds simple - detect a change, send a message. The implementation touched event dispatching, message handlers, HTTP clients, and AWS parameter management.

The Request

GitHub ticket #941 was straightforward:

When a user updates where the invoice should be sent, notify via Slack with:

  • Client name (Uppdragsgivarens namn)
  • User's name and email
  • The updated email address

But as we dug in, the scope expanded. Why only email? What about address changes, contact name updates, VAT number corrections? The team wanted visibility into all recipient modifications.

The Architecture

The platform already had a pattern for this: Symfony Messenger with event-driven handlers. Here's how it works:

Event-Driven Notification FlowUser PATCH/recipients/{id}RecipientListenerPOST_READ priorityEvent Bus[sync, workqueue]SQSWorker Lambda (Async Processing)Message HandlerfromTransport: workqueueSlackClientHTTP POST to Slack APISlack Channel#recipient-notifications

The dual routing [sync, workqueue] means events process synchronously first (for immediate handlers), then get queued to SQS for async handlers. The Slack notification runs async - no reason to block the API response waiting for a third-party HTTP call.

The Event

First, we needed an event to carry the change data. The initial version only tracked email:

class RecipientEmailChanged
{
    public function __construct(
        public readonly Uuid $recipientId,
        public readonly ?string $previousEmail,
        public readonly string $newEmail,
    ) {}
}

But we expanded it to track all fields:

class RecipientFieldsChanged
{
    /**
     * @param array<string, array{old: mixed, new: mixed}> $changedFields
     */
    public function __construct(
        public readonly Uuid $recipientId,
        public readonly array $changedFields,
    ) {}
}

The changedFields array uses Swedish labels as keys, making the Slack message human-readable without translation lookups at notification time.

Detecting Changes

The existing RecipientListener already hooked into recipient PATCH requests. We added a detectChangedFields method:

private function detectChangedFields(Recipient $recipient, array $requestContent): array
{
    $changedFields = [];
 
    // Define mappings: request key => [getter method, human-readable label]
    $fieldMappings = [
        'name' => ['getName', 'Namn'],
        'email' => ['getEmail', 'E-post'],
        'alternateEmail' => ['getAlternateEmail', 'Alternativ e-post'],
        'contactName' => ['getContactName', 'Kontaktperson'],
        'governmentId' => ['getGovernmentId', 'Organisationsnummer'],
        'vatNumber' => ['getVatNumber', 'Momsnummer'],
        'invoiceLanguage' => ['getInvoiceLanguage', 'Fakturaspråk'],
        'requiresEInvoice' => ['getRequiresEInvoice', 'Kräver e-faktura'],
        'reverseVat' => ['isReverseVat', 'Omvänd moms'],
    ];
 
    foreach ($fieldMappings as $requestKey => [$getter, $label]) {
        if (!\array_key_exists($requestKey, $requestContent)) {
            continue;
        }
 
        $newValue = $requestContent[$requestKey];
        $oldValue = $recipient->$getter();
 
        // Normalize boolean comparisons
        if (\is_bool($oldValue)) {
            $newValue = (bool) $newValue;
        }
 
        if ($oldValue !== $newValue) {
            $changedFields[$label] = [
                'old' => $oldValue,
                'new' => $newValue,
            ];
        }
    }
 
    return $changedFields;
}

The key insight: we compare against the request content, not the entity state after Doctrine hydration. The listener runs at POST_READ priority - the entity still has its old values.

Listener Timing: Why POST_READ WorksRequest Arrives{email: "new@..."}POST_READEntity has OLD valuesDeserializationValues mergedPOST_WRITEEntity has NEW valuesAt POST_READ: $recipient->getEmail() returns OLD value$requestContent['email'] contains NEW value - perfect for comparison

We also handle nested address fields:

// Handle nested address fields
if (\array_key_exists('address', $requestContent) && \is_array($requestContent['address'])) {
    $address = $recipient->getAddress();
    $addressMappings = [
        'streetName' => ['getStreetName', 'Gatuadress'],
        'postalCode' => ['getPostalCode', 'Postnummer'],
        'city' => ['getCity', 'Stad'],
        'country' => ['getCountry', 'Land'],
        'phoneNumber' => ['getPhoneNumber', 'Telefon'],
    ];
 
    foreach ($addressMappings as $requestKey => [$getter, $label]) {
        if (!\array_key_exists($requestKey, $requestContent['address'])) {
            continue;
        }
 
        $newValue = $requestContent['address'][$requestKey];
        $oldValue = $address->$getter();
 
        if ($oldValue !== $newValue) {
            $changedFields[$label] = [
                'old' => $oldValue,
                'new' => $newValue,
            ];
        }
    }
}

The Slack Client

Following the existing pattern (the codebase already had a MondayClient for another integration), we created a SlackClient:

class SlackClient
{
    public function __construct(
        #[Autowire(service: 'http_client.slack_api')]
        private readonly HttpClientInterface $httpClientSlackApi,
        private readonly LoggerInterface $logger,
        private readonly string $slackBotToken,
        private readonly string $slackDefaultChannel,
    ) {}
 
    public function sendMessage(string $message, ?string $channel = null): void
    {
        if ('' === $this->slackBotToken) {
            throw new SlackApiDisabledException();
        }
 
        $response = $this->httpClientSlackApi->request('POST', '/api/chat.postMessage', [
            'json' => [
                'channel' => $channel ?? $this->slackDefaultChannel,
                'text' => $message,
                'mrkdwn' => true,
            ],
        ]);
 
        $data = $response->toArray(false);
        if (!($data['ok'] ?? false)) {
            throw new \RuntimeException(sprintf(
                'Slack API error: %s',
                $data['error'] ?? 'Unknown error'
            ));
        }
    }
}

The SlackApiDisabledException is important - it lets us gracefully disable Slack in environments where it's not configured (local dev, test). The handler catches it and logs instead of failing:

try {
    $this->slackClient->sendMessage($message);
} catch (SlackApiDisabledException $e) {
    $this->logger->info('Slack API is disabled, skipping notification');
} catch (\Throwable $e) {
    $this->logger->error('Failed to send Slack notification', [
        'error' => $e->getMessage(),
    ]);
}

The Message Handler

#[AsMessageHandler(fromTransport: 'workqueue')]
class SendSlackNotificationWhenRecipientFieldsChanged
{
    public function __invoke(RecipientFieldsChanged $event): void
    {
        $recipient = $this->recipientRepo->get($event->recipientId);
        $owner = $recipient->getOwner();
 
        $message = sprintf(
            "📝 *Mottagare uppdaterad*\n\n".
            "*Uppdragsgivare:* %s\n".
            "*Användare:* %s (%s)\n\n".
            "*Ändrade fält:*",
            $recipient->getName(),
            $owner?->getFullName() ?? 'Okänd',
            $owner?->getEmail() ?? 'Okänd'
        );
 
        foreach ($event->changedFields as $fieldName => $change) {
            $oldValue = $this->formatValue($change['old']);
            $newValue = $this->formatValue($change['new']);
            $message .= sprintf("\n• %s: %s → %s", $fieldName, $oldValue, $newValue);
        }
 
        $this->slackClient->sendMessage($message);
    }
 
    private function formatValue(mixed $value): string
    {
        if (null === $value || '' === $value) {
            return '_(tom)_';
        }
        if (\is_bool($value)) {
            return $value ? 'Ja' : 'Nej';
        }
        return (string) $value;
    }
}

The fromTransport: 'workqueue' attribute ensures this only runs from the async worker, not synchronously during the API request.

Configuration

HTTP client configuration in http_client.yaml:

http_client.slack_api:
    base_uri: 'https://slack.com'
    headers:
        'Accept': 'application/json'
        'Content-Type': 'application/json; charset=utf-8'
        'Authorization': 'Bearer %env(SLACK_BOT_TOKEN)%'

Messenger routing in messenger.yaml:

App\Messenger\Event\Recipient\RecipientFieldsChanged: [sync, workqueue]

Environment variables come from AWS SSM Parameter Store in production:

# serverless.yml
SLACK_BOT_TOKEN: ${ssm:/prod.worknode-api/slack-bot-token}
SLACK_DEFAULT_CHANNEL: ${ssm:/prod.worknode-api/slack-default-channel}

The Debugging Journey

Deployed to production. Updated a recipient. Nothing happened.

First stop: CloudWatch logs for the worker Lambda.

Debugging the Silent FailureCheck Website LambdaEvent dispatched? ✓Check Worker LambdaHandler executed? ✓Slack API Response"channel_not_found"Solution: Invite the bot to the channel/invite @WorknodeBot in #recipient-change-notification

The channel ID was correct (C0A7JEKMRS6). The bot token was valid. But Slack apps need explicit channel membership - they can't post to channels they haven't joined.

After inviting the bot:

📝 Mottagare uppdaterad

Uppdragsgivare: Example Company AB
Användare: John Doe (john@example.com)

Ändrade fält:
• Gatuadress: Old Street 1 → New Street 2
• Postnummer: 111 22 → 333 44

What We Didn't Break

The implementation reused existing patterns. Important to verify nothing broke:

Existing Flows - UnchangedFortnox SyncUpdateFortnoxCustomerStill dispatched on changesEmail VerificationverificationEmailSentAt = nullStill reset on email changeE-Invoice NotificationRecipientChangedInvoiceSendMethodStill dispatched separatelyAll existing handlers continue to workThe new Slack notification is purely additive - no existing behavior modified

Files Created/Modified

Created:
├── src/Service/SlackApi/SlackClient.php
├── src/Service/SlackApi/SlackApiDisabledException.php
├── src/Messenger/Event/Recipient/RecipientFieldsChanged.php
└── src/Messenger/EventSubscriber/Recipient/SendSlackNotificationWhenRecipientFieldsChanged.php

Modified:
├── src/EventSubscriber/RecipientListener.php    - detectChangedFields() method
├── config/packages/http_client.yaml             - Slack API client config
├── config/packages/messenger.yaml               - Event routing
├── config/services.yaml                         - SlackClient service
├── serverless.yml                               - SSM parameter references
└── .env                                         - Local placeholder vars

Lessons

  1. Event-driven architecture pays off - Adding a new notification meant creating a handler, not modifying existing code. The listener just dispatches; handlers decide what to do.

  2. Graceful degradation - The SlackApiDisabledException pattern lets the feature be "off" without breaking anything. Empty token = no notification, no error.

  3. Timing matters in listeners - POST_READ vs POST_WRITE determines what state the entity has. We needed old values for comparison, so POST_READ was correct.

  4. Slack bots need explicit access - Even with the right channel ID and valid token, bots can't post where they're not members. The error message "channel_not_found" is misleading - the channel exists, the bot just can't see it.

  5. CloudWatch is your friend - The worker Lambda logs showed exactly what failed. Without that visibility, we'd still be guessing.

Now every recipient change triggers a Slack notification. The admin team has visibility they didn't have before - and the implementation follows patterns the codebase already established.