Skip to content

Debug A Failed Or Silent Outbound Message

A message that "should have sent" but didn't reach the user can fail at three different layers in this codebase: the single-message send job, the bulk-batch chunking layer above it, or — in one documented incident — a database column silently rejecting the row that would have proven the send happened at all. Work through these in order rather than guessing, because each layer records failure differently.

Start with the message and its delivery log

Every outbound send, whether triggered directly or from a batch, ends up in SendChannelMessage::handle(). It calls $channel->send($user, $content, $options), and on both success and failure it writes a MessageDeliveryLog row via createDeliveryLog(), recording status, error_code, error_message, error_category, and whether the failure was considered retryable [@send-channel-message]. If a Message row exists for the recipient, look up its MessageDeliveryLog entries first — this tells you whether the channel provider actually rejected the send, or whether the message never got dispatched at all.

If the failure is retryable (per WhatsAppErrorHandler::categorize()) and the job has attempts left, SendChannelMessage throws so the queue retries it with backoff; the message's external_status only flips to failed once retries are exhausted [@send-channel-message]. A message stuck at external_status: queued with no matching delivery log usually means the job is still retrying, has silently died from an unhandled exception, or was never dispatched in the first place — check the queue worker logs and the failed_jobs table before assuming the channel itself is broken.

For bulk sends, check the batch's chunk_errors, not just individual messages

Broadcast and alert sends go through BatchMessagingService::createBatch(), which splits the recipient list into chunks of config('messaging.batch_chunk_size', 500) and dispatches one ProcessMessageBatch job per chunk [@batch-messaging-service]. This chunking exists because of a prior incident where two real alerts delivered only 17-25% of their recipients under a single monolithic queue job that exceeded the queue connection's 90-second retry_after — see Chunk Batch Message Processing for the full incident. ProcessMessageBatch itself documents this constraint directly: its $timeout is fixed at 60 seconds, "must be shorter than the queue connection's retry_after (90s) to prevent the queue from re-dispatching while the job is still running" [@process-message-batch].

Because each chunk is its own job, a failure in one chunk does not fail the whole batch. If a chunk's handle() throws, ProcessMessageBatch::failed() calls $batch->recordChunkError($exception->getMessage()), appending to the batch's chunk_errors metadata array, and sends a BatchJobFailedNotification to Slack's system_health channel [@process-message-batch]. If a batch reports fewer delivered messages than expected, read MessageBatch.metadata.chunk_errors before assuming every recipient in the batch was attempted — a specific chunk may have failed outright while others succeeded. Also check the channel_distribution and skipped counters that ProcessMessageBatch writes into the batch's metadata after every run; a high skipped count usually means recipients had no reachable channel (see ChannelResolver in Add A New Messaging Channel) rather than a delivery failure.

For messages that need per-recipient template interpolation (SMS/Telegram alerts with needs_interpolation), ProcessMessageBatch::resolveContent() falls back to a default hazard-type message and logs a warning if the expected sms_template is empty [@process-message-batch] — check for this warning in the logs if a batch of alert messages all went out with generic text instead of the expected personalized content.

Watch for a caught exception that is only logged generically

The most subtle failure mode on record here is not a queue or channel problem at all: a message can be marked as sent while never actually being recorded, because a database write inside the send path threw and was swallowed. This happened in production with post-alert retargeting SMS: the command reported "Sent 1 retargeting SMS message(s)" and user_alerts.retargeting_sms_sent_at was set, but zero rows existed in messages with that type, and no message_delivery_logs entry was created either [@varchar-truncation-doc]. The root cause was messages.message_type being varchar(20) while the value being written, 'post_alert_retargeting', was 22 characters — Postgres raised SQLSTATE[22001]: String data, right truncated, but the catch block around the delivery-log write logged only a generic string, not $e->getMessage() [@varchar-truncation-doc]:

} catch (Throwable $e) {
    Log::warning('Failed to record magic link delivery log', [
        'error' => 'Delivery log recording failed. Check application logs for details.',
        // $e->getMessage() was NOT logged!
    ]);
}

If you hit a case where a send is reported successful by its caller but leaves no trace in messages or message_delivery_logs, suspect this same pattern: a caught exception with only a generic log message. Search the relevant service for catch (Throwable blocks that don't include $e->getMessage(), and check the actual column width of any new or changed varchar column against every string literal being written into it — the fix in this incident was widening the column to varchar(50) and logging the full exception detail instead of the generic string [@varchar-truncation-doc].

Summary of where to look, in order

  1. MessageDeliveryLog rows for the specific message (or the user/conversation if no Message row exists at all).
  2. The relevant MessageBatch.metadatachunk_errors, channel_distribution, skipped — if the send was part of a bulk batch.
  3. Slack's system_health channel for BatchJobFailedNotification or BatchCompletedWithFailuresNotification alerts, which fire automatically on chunk failure [@process-message-batch].
  4. Application logs for a caught exception logged with only a generic message — a sign of the truncation-style silent failure above — and cross check any varchar column lengths involved.

For the wider inbound/outbound job architecture these pieces sit in, see Messaging Pipeline. For the Slack and notification-routing layer referenced above, see Notifications.