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¶
MessageDeliveryLogrows for the specific message (or the user/conversation if noMessagerow exists at all).- The relevant
MessageBatch.metadata—chunk_errors,channel_distribution,skipped— if the send was part of a bulk batch. - Slack's
system_healthchannel forBatchJobFailedNotificationorBatchCompletedWithFailuresNotificationalerts, which fire automatically on chunk failure [@process-message-batch]. - 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
varcharcolumn 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.