Appearance
Vroum Platform — Communications Service (comms)
Architecture design for extracting email + SMS out of the vroum-app monolith.
- Status: Decided — ready to implement
- Date: 2026-08-13 (revised after open-question review)
- Scope: Outbound + inbound SMS and email, plus the platform conventions every future Vroum microservice will reuse.
1. Decisions
| # | Decision | Choice | Consequence |
|---|---|---|---|
| 1 | Data ownership | Hybrid — service owns delivery, app owns threads | communications / messages stay in the app; provider state, attempts, raw inbound payloads move out |
| 2 | Transport | Broker only for commands and events; HTTP allowed for queries | RabbitMQ carries everything that changes state. Reads that change nothing may use HTTP — see §4.7 |
| 3 | Stack | Laravel 13 slim (PHP 8.4) | Same conventions: Horizon, spatie/laravel-data, lorisleiva/laravel-actions, PHPUnit, Pint |
| 4 | Hosting | Compose now, revisit later | Design stays hosting-agnostic; §11 covers the options |
| 5 | Email sending domain | One platform domain + optional per-shop custom domain | Needs a domain-verification workflow, DNS record generation, per-domain warm-up |
| 6 | Body retention in service | 7 days, then nulled | App is the system of record for content; the service keeps just enough to debug |
| 7 | Providers | Twilio only (SMS + Mailgun for email); no second SMS provider | Keep the driver interface (it's the test seam), but do not build provider-selection logic |
| 7b | Which mail goes through the service | Customer mail only; system mail keeps a direct Mailgun path | comms is a named mailer, not the default. A password reset must not depend on the broker — §6.4 |
| 8 | RCS | Design for it now, integrate later | capabilities modelled on sender identities from day one; enabling RCS later is a service-internal change, zero contract churn |
| 9 | Live data | None — app is not in production | No dual-run, no feature flags, no backfill. Clean cutover, edit migrations in place |
| 10 | Event sourcing | No | Plain Eloquent + an append-only delivery_events table for audit. See §12 |
2. Where you are today
Everything below currently lives inside vroum-app:
| Concern | Current location |
|---|---|
| Thread + message storage | App\Models\Communication, App\Models\Message |
| Sender inventory | App\Models\PhoneNumber, App\Models\ShopPhoneProvider, App\Models\PhoneProvider |
| Provider SDK | App\Services\Sms\Twilio (implements SmsContract), App\Facades\Sms |
| Outbound orchestration | App\Actions\Communication\CreateOutboundSms → SendSms (queued) |
| Inbound SMS | routes/webhook.php → App\Webhook\Twilio\ProcessIncomingWebhookJob |
| Delivery status | App\Webhook\Twilio\ProcessStatusWebhookJob |
| Read models | GetInbox, GetCommunication, GetCommunicationMessages, GetInboxUnreadCount |
| Realtime | MessageCreated → Reverb private channel models.communication.{id} |
App\Mail\WorkOrderApprovalRequestMail, 2 notifications, symfony/mailgun-mailer. No inbound email at all. | |
| Infra | Postgres 17, Redis, Horizon, Reverb, Sail/Compose |
Four properties shape the design:
- Multi-tenant by
shop_id. Every phone number, communication and provider credential is scoped to a shop (HasShop,Shop::current()). The tenant ID must ride along in every broker message. MessageStatusis a state machine (queued → accepted → sent → delivered/undelivered/failed → read) with anisAfter()guard — which is currently broken in two ways (§6.2).- The schema already anticipates email.
messagescarriessubject,cc,bcc,excerpt, andCommunicationType::EMAILexists. Phases 3–5 need very little new app-side modelling. - Nothing is in production. This is the single biggest lever in the whole plan — see §8.
3. Bounded context — who owns what
┌─────────────────────────────── vroum-app (monolith) ───────────────────────────────┐
│ │
│ OWNS DOES NOT OWN │
│ • communications (threads) • Twilio / Mailgun credentials │
│ • messages (content, direction, read_at, • provider message SIDs beyond a ref │
│ client_read_at, user_id, shop_id) • delivery attempts & retry state │
│ • inbox queries + unread counts • raw webhook payloads │
│ • Reverb broadcasting • suppression / opt-out list │
│ • template rendering (Blade/Mailables) • provider rate limiting │
│ • notification preferences • email domain verification / DNS │
│ • sender_identities as a READ-ONLY │
│ projection (to render pickers) │
└─────────────────────────────────────────────────────────────────────────────────────┘
▲ │
events (comms.*) │ │ commands (comms.*.send)
│ ▼
┌──────────────────────────────────── RabbitMQ ──────────────────────────────────────┐
│ exchange: vroum.commands (direct) exchange: vroum.events (topic) │
│ exchange: vroum.dlx (fanout) │
└─────────────────────────────────────────────────────────────────────────────────────┘
▲ │
│ ▼
┌──────────────────────────────── comms-service (Laravel slim) ──────────────────────┐
│ │
│ OWNS DOES NOT OWN │
│ • sender_identities (numbers, addresses) • what a "work order" is │
│ • email_domains + DNS verification • who a client/user is (only a ref) │
│ • provider_credentials (encrypted) • the inbox UI │
│ • deliveries + delivery_events • message threading semantics │
│ • inbound_messages (raw + normalized) • Blade templates │
│ • suppressions (STOP, bounce, complaint) │
│ • provider webhooks (HTTP ingress) │
│ • per-tenant + per-provider throttling │
│ • channel selection (RCS→SMS fallback) │
└─────────────────────────────────────────────────────────────────────────────────────┘The one-line rule: the app decides what to say and to whom; the service decides how it physically gets there and whether it arrived.
3.1 What "hybrid" means concretely
messages.message_id (the Twilio SID, string, nullable, indexed) is today the app's only handle on delivery. After the split:
- Add
messages.ulid(unique) — the table isbigint-keyed with no stable public identifier, and auto-increment IDs must not leak onto a message bus. With no production data this is an edit to the existing0002_01_01_000016_create_communications_table.phpmigration, not a new one. - App writes a
Messagerow and publishescomms.sms.sendcarryingdelivery_ref = messages.ulid. - Service creates a
deliveriesrow keyed by thatdelivery_refand owns everything about the send from there. - Service publishes
comms.message.status_changed; the app applies it tomessages.statusbehind a fixed monotonic guard. messages.message_idis gone. An earlier draft kept it as a denormalized copy of the provider SID "for support and debugging"; in practice it was a second copy of a value the service owns, with nothing keeping it true, and every event already arrives keyed ondelivery_ref. Support questions about a specific SID are answered by the service, which has the fulldelivery_eventstrail behind it. Removing it is what retired the app's status webhook job, and §8 phase 2 is what puts statuses back.
3.2 Sender inventory moves out entirely
phone_numbers, shop_phone_provider and phone_providers currently live in the app. Since there is no live data, delete them from the app rather than migrating them — in phase 2, once the inbound webhook that reads them has moved to the service. The service creates sender_identities / provider_credentials fresh, and the app gets a slim read-only projection table kept current by comms.sender_identity.* events:
php
// app: sender_identities (projection — never written by app code)
ulid, shop_id, channel, value, display_name, capabilities jsonb, statusThat is enough to render "send from (514) 555-0123" or a from-address picker without asking anyone. Editing, verifying and releasing identities happens in the service.
Since phase 4c the service is not a single Twilio account. Each shop has its own subaccount and its own row in provider_credentials; the platform account is the parent and the fallback. The app holds shops.comms_subaccount_ref so a support conversation can name the account, and a status so the number settings page can explain itself — but never the auth token, which has no use on this side and every hop of which is somewhere it can be logged (§5.7).
Platform-level identities are not projected. A sender_identities row with shop_id = null belongs to no shop, and §4.3 requires tenant.shop_id on every message — there is no honest value to put there, and inventing one is how cross-tenant leaks start. Those senders still send; they are simply absent from the app's projection, so a picker shows a shop only what it owns. Making them visible needs a contract answer (a tenant-less broadcast, or fan-out per shop) and belongs with self-service provisioning in phase 4, not as a hole punched in the envelope invariant now.
4. Transport design
4.1 Why RabbitMQ
Broker-only means the broker is your API. That rules out Redis lists (no routing, no per-consumer ack semantics, no DLQ without hand-rolling). RabbitMQ gives you topic routing, per-queue DLX, publisher confirms, and a management UI you will want at 2am.
Redis Streams is a defensible cheaper alternative (consumer groups, acks, XAUTOCLAIM), but you build routing and DLQ yourself. Recommendation: RabbitMQ.
Laravel driver: vladimir-yuldashev/laravel-queue-rabbitmq for the queue-shaped path, plus a thin publisher/consumer of your own for the event bus (§4.5) — so events are not tied to Laravel's job serialization format, which would couple every future service to PHP.
4.2 Topology
vroum.commands (direct) ── comms.sms.send ──▶ q: comms.commands.sms
── comms.email.send ──▶ q: comms.commands.email
vroum.events (topic) ── comms.message.* ──▶ q: app.comms (binding comms.#)
── comms.inbound.* ──▶ q: app.comms
── comms.contact.* ──▶ q: app.comms
── comms.sender_*.* ──▶ q: app.comms
──▶ q: <future-service>.comms (own binding)
vroum.dlx (fanout) ──────────────────────▶ q: <origin-queue>.dlqRules:
- One queue per consuming service, never a shared queue. A future billing service binds its own queue with its own routing keys; nothing else changes.
- Every queue declares
x-dead-letter-exchange: vroum.dlxandx-dead-letter-routing-key: <queue>.dlq. - Commands are
direct(exactly one logical consumer). Events aretopic(N consumers, publisher doesn't know or care). - Queues durable, messages persistent, publisher confirms on.
- Topology is declared in code (
bus:declare, idempotent) — never clicked into the management UI.
4.3 The envelope (platform-wide standard)
Every message on the bus, from any service, forever:
json
{
"id": "01K2F7Q9YQ3M8N4T5R6V7W8X9Z",
"type": "comms.sms.send",
"version": 1,
"occurred_at": "2026-08-13T14:03:11.482Z",
"producer": "vroum-app@2026.08.13",
"tenant": { "shop_id": 42 },
"correlation_id": "01K2F7Q0000000000000000000",
"causation_id": "01K2F7P0000000000000000000",
"actor": { "type": "user", "id": 1183 },
"idempotency_key": "message:01K2F7Q9YQ3M8N4T5R6V7W8X9Z",
"payload": { }
}| Field | Why it exists |
|---|---|
id | ULID, unique per message. The idempotency anchor. |
type + version | <context>.<entity>.<action> + integer. Never mutate a version's shape. |
tenant.shop_id | Every consumer scopes on this. Non-negotiable for a multi-tenant platform. Tenant::platform() (shop_id: 0) is the one exception, for a fact about the platform rather than a shop — the platform's own sending domain. No shop has id 0, so a scoped query correctly finds nothing, and such a message repeats the real ownership as a nullable shop_id in its payload. |
correlation_id | Same value across the whole causal chain. Grep one ID, see the full story in both Sentry projects. |
causation_id | The id of the message that caused this one. Builds the tree. |
idempotency_key | Business-level dedupe key. Distinct from id because a retry-with-new-id must still dedupe. |
producer | Which service + release emitted it. Priceless during a rollout. |
Payload is versioned per type. Additive changes only within a version; a breaking change means v2 published alongside v1 until consumers migrate.
4.4 Message catalogue (v1)
Commands — app → comms
| Type | Payload (abridged) |
|---|---|
comms.sms.send | delivery_ref, to, body, media[], sender_identity_ref | sender_pool, preferred_capabilities[] (default ["rcs","sms"]), options{ validity_period } |
comms.email.send | delivery_ref, from{ address, name } | sender_identity_ref, to[], cc[], bcc[], reply_to, subject, html, text, attachments[]{ filename, content_type, storage_ref }, headers{}, tags[] |
comms.delivery.cancel | delivery_ref (best-effort, only while queued) |
comms.number.provision | shop_id, phone_number (E.164), request_ref, capabilities[], sender_pool |
comms.number.release | sender_identity_ref, reason |
Events — comms → everyone
| Type | When | Key payload |
|---|---|---|
comms.message.accepted | Provider took it | delivery_ref, provider, provider_message_id, channel_used |
comms.message.status_changed | Any provider status webhook | delivery_ref, status, status_at, provider_message_id, channel_used, error{ code, message } |
comms.message.failed | Terminal failure after retries | delivery_ref, reason, attempts, last_error |
comms.inbound.sms.received | Inbound SMS/RCS | provider_message_id, from, to, body, media[], channel_used, received_at |
comms.inbound.email.received | Inbound email | inbound_ref, from{ email, name }, to, subject, message_id, text, html, stripped_text, in_reply_to, references[], attachments[]{ filename, content_type, size, url }, spam_verdict, received_at, provider_message_ref |
comms.contact.opted_out | STOP / unsubscribe | channel, address, reason, occurred_at |
comms.contact.opted_in | START / resubscribe | channel, address |
comms.address.suppressed | Hard bounce, spam complaint | channel, address, reason, expires_at |
comms.sender_identity.upserted | Number/address added or changed | ref, channel, value, display_name, capabilities[], status |
comms.sender_identity.released | Identity retired | ref |
comms.number.provisioned | Number bought and configured | request_ref, phone_number, sender_identity_ref, status, provisioned_at |
comms.number.provisioning_failed | Could not buy or configure | request_ref, reason, provider_message |
comms.email_domain.verification_changed | A domain's status moved — and only then | shop_id (null for the platform's own), domain, domain_ref, status, dns_records[], checked_at, verified_at |
comms.number.provisioned overlaps comms.sender_identity.upserted on purpose. The latter is a fact about inventory that any service may care about; the former carries request_ref so the app can close the loop on one specific request it is still showing a spinner for. Both are emitted, and provisioning_failed has no sender_identity equivalent because nothing came into existence.
channel_used is the RCS hook (§5.4): the command asks for a capability preference, the service reports what actually carried the message. Present in v1 even though RCS ships later — it costs nothing now and avoids a v2 later.
The app never asks "what's my status?" — it is told. That is what broker-only buys you, and it is why messages.status must be treated as an eventually-consistent projection.
4.5 Shared contracts package
Create vroumtech/platform-contracts, namespace VroumTech\PlatformContracts\, required by both sides. It lives in a private GitHub repository, pulled in through a vcs entry in each consumer's composer.json rather than Packagist:
src/
├── Envelope.php # spatie/laravel-data object
├── Bus/
│ ├── Publisher.php # interface
│ ├── RabbitMqPublisher.php
│ └── Consumer.php
├── Comms/
│ ├── Commands/{SendSmsCommand,SendEmailCommand,CancelDeliveryCommand}.php
│ ├── Events/{MessageAccepted,MessageStatusChanged,MessageFailed,InboundSmsReceived,InboundEmailReceived,ContactOptedOut,AddressSuppressed,SenderIdentityUpserted,EmailDomainVerificationChanged}.php
│ └── Enums/{Channel,Capability,DeliveryStatus,SuppressionReason,DomainStatus}.php
└── Testing/FakePublisher.phpThis is the single most important artifact of the whole project. It is what makes "future microservice" cheap: a new service composer-requires it and immediately speaks the platform language. Semver; a major bump means a new message version.
Keep it dependency-light — spatie/laravel-data + illuminate/contracts only. No models, no app code.
A private repository has a price, and it is paid in every other repository. GitHub Actions' default GITHUB_TOKEN only reaches the repository the workflow runs in, so composer install 404s on the package until a token is supplied — vroum-app passes a fine-grained PAT (read-only Contents, scoped to this package alone) through COMPOSER_AUTH. The deploy server needs the same token in its auth.json, and so will every future service. Weigh that against making the package public: it holds DTOs and enums, no secrets, and going public removes the token, the rotation and the failure mode entirely.
php
// src/Comms/Commands/SendSmsCommand.php
final class SendSmsCommand extends Data
{
public const TYPE = 'comms.sms.send';
public const VERSION = 1;
/**
* @param array<int, MediaData> $media
* @param array<int, Capability> $preferredCapabilities
*/
public function __construct(
public string $deliveryRef,
public string $to,
public string $body,
public ?string $senderIdentityRef = null,
public ?string $senderPool = null,
public array $media = [],
public array $preferredCapabilities = [Capability::RCS, Capability::SMS],
) {}
}4.6 Reliability rules
Transactional outbox (app side). Broker-only means a lost publish is a lost SMS with no HTTP error to catch. Message::create() and "publish comms.sms.send" must be atomic:
php
DB::transaction(function () use ($communication, $data) {
$message = $communication->messages()->create([...]);
Outbox::create([
'ulid' => (string) Str::ulid(),
'exchange' => 'vroum.commands',
'routing_key' => SendSmsCommand::TYPE,
'envelope' => $envelope->toArray(),
]);
});A relay (scheduled every second, or a long-running outbox:relay command) publishes rows with confirms and stamps published_at. Cost: one table, ~80 lines. Benefit: you never explain to a shop why a customer never got their appointment reminder.
The comms service needs the same outbox for its outbound events.
Idempotency (consumer side). At-least-once delivery is guaranteed; exactly-once is not. Every consumer wraps handling in:
php
if (ProcessedMessage::where('message_id', $envelope->id)->exists()) {
return; // ack, drop
}Insert the ProcessedMessage row in the same transaction as the side effect. Prune after 30 days.
Ordering. RabbitMQ preserves order per queue but retries break it, and Twilio status webhooks arrive out of order anyway. Never assume order:
- Statuses carry
status_at; the consumer applies a monotonic guard and ignores regressions (see §6.2 for the two defects to fix in the current implementation first). - Inbound messages are ordered by
received_at, not arrival time.
Retries. Consumer-side exponential backoff (5s, 30s, 2m, 10m, 1h), max 5 attempts, then DLQ:
| Failure | Action |
|---|---|
| Provider 5xx / timeout | Retry with backoff |
| Provider 429 | Retry, honour Retry-After, back-pressure the tenant |
| Provider 4xx (bad number, blocked) | No retry — emit comms.message.failed immediately |
| Suppressed address | Never dial the provider — emit comms.message.failed with reason: suppressed |
| Unverified email domain | No retry — emit comms.message.failed with reason: domain_unverified |
| Unknown tenant / sender | DLQ + alert (a bug, not a transient) |
DLQ handling. A comms:dlq:replay artisan command plus an alert when DLQ depth > 0. A DLQ you don't watch is a data-loss queue.
4.7 The one exception: queries
Broker-only is a rule about writes. Every reason it exists — nothing lost, durable retry, idempotent redelivery, no synchronous coupling on a path that must not fail — is a property of commands and events.
A query is none of those things. Searching Twilio for available phone numbers changes no state, has nothing to lose, needs no dedupe, and produces a result that is stale within minutes. So the rule, stated precisely:
Queries may go over HTTP. Commands and events go over the broker, always.
The corollary is the part that keeps this from becoming a slippery slope: if you ever want to POST to a query endpoint, it is not a query. It is a command, and it belongs on the bus. Buying a number sits directly next to searching in the UI and is still a command.
Why not push queries through the broker for consistency? Because delivering a result over a bus means persisting it. You would add a number_searches table, rows for candidates, a pruner, a Reverb broadcast, and a timeout mechanism for when the reply never comes — five moving parts and a table holding data you actively do not want to keep. When a transport forces you to invent storage for something ephemeral, the transport is wrong for the job. HTTP also fails better here: a timeout is "search failed, try again" immediately, rather than a spinner plus a separate mechanism to decide when to give up.
Constraints on any query endpoint:
- Read-only verbs. No side effects, ever.
- Short timeout (2–3s) and an explicit failed state in the UI.
- Authenticated service-to-service — a shared secret header to start, mTLS if it ever leaves a private network.
- No caching of provider inventory; it is stale by the time you store it.
- Availability coupling is acceptable here because the feature is already dead without the service: you cannot buy a number from something that is not running.
Revisit if a query ever becomes slow enough that a synchronous request is a bad experience regardless — multi-provider fan-out, regulatory pre-checks. At that point it is genuinely a long-running job and earns the command-plus-event shape.
5. The comms service internals
5.1 Layout (ports & adapters)
comms-service/
├── app/
│ ├── Actions/
│ │ ├── Outbound/{DispatchDelivery,RecordDeliveryEvent,ApplyProviderStatus}.php
│ │ ├── Inbound/{IngestInboundSms,IngestInboundEmail}.php
│ │ ├── Domains/{RegisterEmailDomain,GenerateDnsRecords,VerifyEmailDomain}.php
│ │ ├── Numbers/{SearchAvailableNumbers,ProvisionNumber,ReleaseNumber}.php
│ │ └── Suppression/{Suppress,Release,IsSuppressed}.php
│ ├── Channels/
│ │ ├── Contracts/{SmsDriver,EmailDriver,SenderResolver}.php
│ │ ├── Sms/TwilioDriver.php
│ │ └── Email/MailgunDriver.php
│ ├── Consumers/{SendSmsConsumer,SendEmailConsumer,CancelDeliveryConsumer}.php
│ ├── Http/Controllers/Webhooks/{TwilioController,MailgunController}.php
│ ├── Http/Controllers/Queries/AvailableNumbersController.php ← read-only, §4.7
│ ├── Models/{SenderIdentity,EmailDomain,ProviderCredential,Delivery,DeliveryEvent,InboundMessage,Suppression,ProcessedMessage,Outbox}.php
│ └── Support/{TenantContext,ProviderThrottle}.php
├── config/comms.php
└── routes/{webhook,query}.php ← the only HTTP surface (+ /up)On the driver interfaces with one provider each: keep them, but keep them honest. SmsDriver earns its place as the test seam (FakeSmsDriver in every consumer test) and as the boundary that stops Twilio SDK types leaking into your actions. What you should not build: provider-selection logic, a provider registry, per-provider config abstraction, or a second stub driver. The provider column on deliveries and sender_identities exists as a discriminator so adding one later is a migration-free change — that's the whole hedge, and it costs nothing.
SmsDriver is essentially your existing SmsContract minus the PhoneNumber model dependency — that model won't exist here. Pass a SenderIdentity.
5.2 Schema
sql
-- who can send
sender_identities (
id, ulid, shop_id nullable, -- null = platform-level identity
channel, -- sms | email
value, -- +15145550123 | notifications@vroum.app
display_name,
provider, -- twilio | mailgun
provider_credential_id,
email_domain_id nullable, -- email only
external_ref, -- messaging_service_sid | mailgun domain
rcs_agent nullable,
capabilities jsonb, -- ["sms","mms","rcs"]
pool nullable, -- "default" | "marketing" | ...
status, released_at, timestamps
)
email_domains (
id, ulid, shop_id nullable, -- null = the platform domain
domain, -- vroum.app | garage-x.com
provider, provider_domain_ref,
status, -- pending | verifying | verified | failed | disabled
dkim_selector,
dns_records jsonb, -- what the shop must publish (SPF/DKIM/DMARC/MX)
last_checked_at, verified_at,
warmup_started_at, daily_send_cap, -- null = uncapped
timestamps
)
UNIQUE (domain)
provider_credentials (
id, shop_id nullable, -- null = platform-level fallback
provider, account_ref, secret encrypted, meta jsonb, timestamps
)
-- the delivery envelope (service-owned truth)
deliveries (
id, ulid,
delivery_ref unique, -- the app's messages.ulid
shop_id, channel, direction,
sender_identity_id, from_value, to_value,
subject, body_text, body_html, -- NULLED after config('comms.body_retention_days')
body_purged_at,
status, status_at,
provider, provider_message_id, channel_used, provider_error jsonb,
attempts, scheduled_at, sent_at, delivered_at, opened_at, failed_at,
correlation_id, idempotency_key unique, timestamps
)
-- append-only audit trail (see §12)
delivery_events (
id, delivery_id,
type, -- requested | suppressed | provider_called | accepted
-- | status_reported | retried | failed | published
payload jsonb, -- request/response, error, status, attempt no.
occurred_at, recorded_at
)
INDEX (delivery_id, occurred_at)
-- inbound
inbound_messages (
id, ulid, shop_id, channel,
provider, provider_message_id unique,
from_value, to_value, subject, body_text, body_html,
headers jsonb, raw_payload jsonb, attachments jsonb,
in_reply_to, references jsonb,
body_purged_at, -- same 7-day rule
received_at, timestamps
)
-- compliance
suppressions (id, shop_id nullable, channel, address, reason, source, expires_at, timestamps)
UNIQUE (shop_id, channel, address)
-- plumbing
processed_messages (message_id pk, type, processed_at)
outbox (id, ulid, exchange, routing_key, envelope jsonb, published_at, attempts, created_at)Opens are reported as Read, not as a new vocabulary. DeliveryStatus::Read already ranks above Delivered for exactly this, and the app already stamps messages.client_read_at from a Read status event — so a Mailgun opened webhook sets deliveries.opened_at, applies Read through the normal monotonic guard, and arrives in the inbox with no contract change. The guard is what stops an open resurrecting a hard bounce. opened_at keeps the earliest open, because every open fires its own webhook and they arrive out of order.
Clicks stay off the lifecycle — there is no status above Read — and are recorded as a delivery_events row only. Both engagement payloads nest under raw, so the retention pruner already scrubs the recipient IP and geolocation they carry. Treat an open as a hint rather than proof: Apple Mail Privacy Protection pre-fetches the tracking pixel whether or not anyone looked, and a client blocking remote content reports nothing at all.
inbound_messages carried a published_at in an earlier draft. It does not: the outbox already records when a message reached the broker, and a second column tracking the same fact is a second thing to keep true. The email-shaped columns (subject, body_html, stripped_text, headers, in_reply_to, references, attachments, spam_verdict) landed with phase 5 rather than sitting empty until then — migrations are edited in place, so waiting cost nothing. Phase 5 also added unmatched_inbound beside it, for everything that arrived for nobody.
Own database (comms), own migrations, own Postgres user. No cross-database joins, ever. shop_id is an opaque integer here — the service must never learn what a Shop is.
5.3 Body retention — 7 days
config('comms.body_retention_days') = 7. A nightly scheduled command nulls body_text / body_html / subject on deliveries, and body_text / body_html / raw_payload on inbound_messages, stamping body_purged_at.
What survives forever: to_value (hashed or truncated after the window if you want to go further), status history in delivery_events, provider IDs, error codes, timestamps. That's everything you need to answer "did it arrive, when, and why not" without holding a second copy of customer conversations.
delivery_events.payload must be scrubbed too — the provider_called event holds the request body. Write the pruner to walk it, not just the parent columns. This is the easy thing to forget.
Attachments are never stored in the service; the command carries an S3 storage_ref and the driver streams from there.
5.4 Sender resolution — and the RCS hook
Today CreateOutboundSms does provider selection in a controller:
php
$phoneProvider = $shop->phoneProviders()->twilio()->first();
$phoneNumber = $phoneProvider?->pivot->phoneNumbers()->first();Move it. The command carries either an explicit sender_identity_ref (user picked a number) or a sender_pool hint, and SenderResolver picks — applying capability matching, health, and round-robin within a pool.
RCS, designed now and dark until you want it:
resolve(shop_id, channel, preferred_capabilities, pool?)
→ candidates = sender_identities for shop (fallback: platform) matching channel + status=active
→ intersect candidates' capabilities with preferred_capabilities, in preference order
→ if config('comms.rcs.enabled') is false, drop "rcs" from consideration entirely
→ pick highest-preference capability with a healthy identity; round-robin within it
→ return SenderIdentity + chosen capabilityThe identity the resolver returns also decides which Twilio account the message goes out on: TwilioDriver builds its client from $from->credential, so a shop's messages are sent by the shop's own subaccount and fall back to the platform account only when there is no credential (§5.7).
The driver then does what your current Twilio service already hints at: prefer messagingServiceSid when present (Twilio's messaging service handles RCS→SMS fallback natively), fall back to a bare from number. The service records channel_used from the provider response — rcs or sms — and reports it on every status event.
For inbound, keep the Str::remove('rcs:', ...) normalization your webhook job already does, but do it in one place and set channel_used accordingly.
What ships in phase 1: the capabilities column, the resolver with rcs filtered out by a config flag, and channel_used on the wire. What ships later: flip comms.rcs.enabled, register the RCS agent, done. No contract change, no app change.
5.5 Email domains — platform + per-shop
Two tiers:
- Platform domain (
email.vroum.solutions) —shop_id = null, verified once by you, warm, always available. Every shop sends from its own address on it:"Garage Côté" <garage-cote-42@email.vroum.solutions>(§14 Q11, reversed in phase 5). This is the default and most shops will never leave it. - Per-shop custom domain (
garage-x.com) — registered for the shop at onboarding (§14 Q10), not by the shop itself. The service calls Mailgun to create the domain, stores the records Mailgun returns, and publishescomms.email_domain.verification_changed. A scheduled job re-checks pending domains on a widening interval.
Per-shop addresses on the platform domain
Each shop gets one sender_identity on the platform domain, formed from its slug and its id: garage-cote-42@email.vroum.solutions. The domain accepts all mail, so an inbound message's recipient identifies the shop directly — no lookup, and it works for a customer writing in cold rather than only for replies (§6.5).
Three rules follow, and all three are load-bearing:
- The numeric suffix is the authoritative key. The slug is cosmetic. Shops rename; customers keep old addresses in their mail history for years. Resolution reads the suffix and ignores the slug entirely, so every address a shop has ever sent from keeps working. The slug is never matched on, not even as a fallback — a renamed shop would silently capture another shop's mail the day two slugs collide, and a customer's reply appearing in the wrong inbox is the worst shape this can take. The address is therefore written once and never recomputed; only the display name follows a rename.
- Slugs are normalised for Quebec names. Lowercase, accents transliterated, every non-alphanumeric run collapsed to one hyphen:
Garage Côté→garage-cote-42,L'Atelier d'André→l-atelier-d-andre-7. A name that slugs to nothing still needs a local part, so it becomesshop-{id}. Pinned by a data-provider test inShopEmailIdentityTest. - Catch-all invites junk. Dictionary spam to invented local-parts arrives constantly. The suffix is resolved first and unresolvable recipients are refused before anything else happens — no parsing, no storing bodies, no writing files, no publishing.
The identity is created by comms.subaccount.provision, alongside the Twilio subaccount, rather than by a command of its own. Deliberate: a shop is either fully provisioned or it is not, and two independent commands mean four states instead of two. It runs before the subaccount work, so a Twilio failure is never the reason a shop has no address, and re-running the command backfills a shop that predates this — the subaccount half short-circuits on the existing credential and the email half still lands.
Rules:
- A delivery whose resolved identity belongs to an unverified domain fails fast with
reason: domain_unverified— never retried, never sent from a fallback domain silently. Silent fallback destroys deliverability reporting and confuses the shop. - MX records on the platform domain, which is new in phase 5: SPF and DKIM say nothing about receiving. They live beside the DKIM records for the same domain. Verified live —
email.vroum.solutionsresolves tomxa.mailgun.org/mxb.mailgun.org. - Never generate a DNS record. Mailgun returns
sending_dns_recordscarrying the DKIM public key it will actually sign with; a record composed here is a record that does not match the signature, and the failure mode is silent — the mail sends, DKIM fails at the recipient, deliverability quietly degrades and nothing on the sending side says so. The provider's response is stored verbatim and printed to be pasted into an email to the shop. - Registration is idempotent by adoption: a create Mailgun refuses is followed by a fetch, and a domain that can be fetched is one this account already holds — which is the state the caller wanted. Deliberately not driven by parsing the provider's error string.
- Only the sending records are kept. Inbound stays on the platform domain regardless of whose name is in the
From(§6.5), so asking a shop to repoint its MX would be asking for a change we do not want made. reply_tois where inbound threading happens (§6.5) — that stays on your inbound-enabled domain regardless of the shop's From.
Polling backoff and cutoff. Five minutes for the first hour, hourly for the rest of the first day, daily after that — a shop that publishes its records does it in the first hour, and everything past that is a person being chased. After one week (comms.domains.verification.cutoff_days) the domain moves to failed, which does not mean broken but "we stopped asking": by then something outside this system has gone wrong and nobody is watching a queue for it, so a human re-triggers. The interval lives on the row, so the schedule entry stays dumb and the decision stays testable. The scheduled command takes withoutOverlapping(), same as the pruner — two of these racing to publish the same transition is exactly what the "publish only on change" rule exists to prevent.
The event is published only on a change. A poller that emits on every check produces one message per pending domain per interval forever and makes "verification changed" stop meaning anything changed. The transition is written in one place (RecordDomainState) so this cannot be got wrong per caller.
Warm-up caps are a guard, not a gate — decided, not half-finished. warmup_started_at is set when a domain verifies, daily_send_cap is an operator override with the ramp (50 → 200 → 1000 → uncapped over ~2 weeks) as the default, and both are checked before every send. A breach logs loudly and writes a warmup_exceeded event on the delivery. It does not reject the message.
The reason is that TTL retry queues were deliberately skipped (§4.6), so there is nowhere to defer a message that exceeds a cap — the only alternative to sending it is losing it, and failing a shop's estimate because its domain is three days into warm-up is the wrong trade at this volume. Real enforcement means deferring rather than dropping, which is the same machinery as the per-tenant throttles already scheduled for phase 6. It gets built once, there. Until then the cap is a tripwire that makes reputation damage attributable after the fact instead of mysterious.
Return-Path follows the sending domain, not the platform's. This reverses what earlier drafts of this section and §10 asked for, and the reason is that Mailgun cannot do otherwise: the envelope sender comes from the domain you post the message to, there is no override, and that same domain's DKIM key signs the message. Routing a shop's mail through the platform domain would sign garage-x.com mail as email.vroum.solutions, so neither DKIM nor SPF would align with the visible From and any shop publishing a DMARC policy would have its mail rejected — the exact failure registering a custom domain exists to prevent. Keeping them together is also what makes the remaining mitigations mean anything: bounces stay attributable per domain, so a shop's bad list burns its own reputation rather than the platform's, and per-domain bounce-rate alerting has something to measure. Commented at the call site, because it looks like a bug to anyone who assumes both should be the platform's.
The verification workflow is genuinely the largest piece of net-new machinery in this whole project. It is also entirely contained in the service: with domains provisioned manually at onboarding (§14 Q10) it needs no UI at all, only two artisan commands and the polling schedule.
5.6 Runtime processes
| Process | Command |
|---|---|
| Consumers | php artisan bus:consume sms (one container per queue) |
| Internal jobs / retries | php artisan horizon |
| Outbox relay | php artisan bus:outbox:relay |
| Webhook HTTP | php-fpm + nginx, /webhook/* + /up only |
| Scheduler | domain verification polling, body retention pruning, suppression expiry, DLQ alerting |
Lifecycle contract
Both bus daemons — in this service and in vroum-app — behave identically, and every way out finishes the message in flight, exits 0, and leaves the supervisor to start a fresh process:
| Way out | Trigger |
|---|---|
| Signal | SIGTERM/SIGINT/SIGQUIT set a stop flag; the current message completes, then basic_cancel |
| Self-termination | --max-messages, --max-time, --memory; defaults in config/bus.php under worker |
bus:restart | writes a timestamp to the cache that workers compare between messages |
An idle consumer must notice all three, which is why the consume loop passes a timeout to wait() and catches AMQPTimeoutException to re-check. A bare wait() blocks until the next message, so a consumer on a quiet queue would ignore a SIGTERM until the queue moved and be SIGKILLed instead — the exact failure this exists to prevent.
bus:restart therefore requires a cache store every replica can see. vroum-app uses Redis; comms-service uses its own Postgres. A file store restarts only the container the command ran in, which looks like it worked.
Horizon is not used for these, and this is settled. Horizon supervises Laravel's Redis queue driver; these workers consume versioned platform envelopes off AMQP bindings and are not Laravel jobs. Horizon still runs in both services for their genuine queued work.
Concurrency: prefetch=1 plus --scale consumer=N gives competing consumers. comms.commands.numbers is pinned to one replica — see §6.6; concurrency safety there rests on an undocumented Twilio behaviour plus a friendly-name comparison, and provisioning is rare enough that a second worker buys nothing.
5.7 Provider tenancy — one subaccount per shop
Every shop gets its own Twilio subaccount. Numbers are bought into it, messages are sent from it, and its credentials are what sign the webhooks its numbers generate. The platform's own account remains the parent, and stays the fallback for anything with no shop of its own.
What lives where. provider_credentials is the whole of it, unchanged in shape from phase 1:
| column | meaning |
|---|---|
shop_id | the shop. null is the platform-level fallback |
account_ref | the Twilio subaccount SID |
secret | the subaccount auth token, cast encrypted |
meta | friendly name, status, shop_ref, provisioned-at |
sender_identities.provider_credential_id links a number to the account that holds it, which is what makes the send path tenant-aware.
Sending was already ready for this. TwilioDriver has resolved $from->credential?->account_ref and ?->secret since phase 1, falling back to config. Phase 4c populates the credential row; the driver did not change.
The webhook signature is keyed on AccountSid, and that is the part that mattered. Twilio signs with the auth token of the account that owns the resource, so a number in a shop's subaccount is signed with that subaccount's token, not the parent's. Verified live in 4c: with the number moved into a subaccount and VerifyTwilioSignature still validating against the parent token, Twilio's alert log recorded httpResponse=403 for every status callback. VerifyTwilioSignature therefore reads AccountSid from the payload, looks up provider_credentials.account_ref, and validates with that row's secret — falling back to the parent token only when the SID is the parent's.
Selecting a key by an unauthenticated field is safe, but only under one condition: an attacker who names someone else's account still cannot produce a signature for a token they do not hold, so the worst a forged AccountSid achieves is validation against the wrong key. That argument collapses the moment an unknown SID falls back to the parent token — it would hand the attacker the choice of key. So an unknown AccountSid is a 403 with a named reason, never a fallback.
Reconciliation before create. POST /Accounts with a FriendlyName that already exists creates a second account and returns 201 — verified live in 4c, it does not return the existing one and does not refuse. So ProvisionSubaccountConsumer lists by FriendlyName = shop_ref first. The failure it prevents is quiet: a duplicate subaccount costs nothing and holds no numbers, so nothing errors — it simply becomes the credential the webhook middleware selects, and the shop's inbound traffic starts 403ing.
shop_ref is shop_<id>, derived from the id rather than the name so a rename cannot strand the account and silently create a second.
Searching stays on the parent account. Available-number inventory is global, so the answer does not depend on who asks, and keeping the query independent of provisioning state means the search box works while a subaccount is still being created. Buying is the step that has to land in the right account.
Suspension, never closure. comms:subaccount:status moves an account between active and suspended. Closing is irreversible at Twilio and releases every number the account holds, so a shop suspended over an unpaid invoice would permanently lose the number its customers have been texting. Nothing triggers suspension automatically yet; the command exists so that when shop suspension is wired up there is one obvious thing to call.
external_ref stays null for subaccount numbers. It means "messaging service SID" to TwilioDriver, and a messaging service created in the parent account will not resolve from a subaccount — the same class of mistake as the phase 4a bug that put a PN... SID in that column. Messaging Services remain optional: with no A2P registration to attach one to, nothing regulatory requires one, and their remaining value is RCS-to-SMS fallback.
6. Flows
6.1 Outbound SMS
mermaid
sequenceDiagram
autonumber
participant UI as Inertia UI
participant App as vroum-app
participant MQ as RabbitMQ
participant Svc as comms-service
participant Tw as Twilio
UI->>App: POST /communications/{id}/sms
App->>App: TX: Message(status=queued) + Outbox(comms.sms.send)
App-->>UI: 200 MessageData (optimistic)
App->>App: Reverb broadcast message.created
App->>MQ: relay publishes envelope (confirms)
MQ->>Svc: comms.commands.sms
Svc->>Svc: dedupe · resolve sender + capability · check suppressions · throttle
Svc->>Tw: POST /Messages (messagingServiceSid when present)
Tw-->>Svc: 201 { sid, status: queued }
Svc->>Svc: Delivery(accepted) + DeliveryEvent(provider_called, accepted)
Svc->>MQ: comms.message.accepted { provider_message_id, channel_used }
MQ->>App: app.comms
App->>App: messages.message_id = sid, status = accepted
App->>App: Reverb broadcast message.updatedThe user gets a synchronous 200 from their own app; only the delivery is async. UX is unchanged.
6.2 Delivery status
mermaid
sequenceDiagram
participant Tw as Twilio
participant Svc as comms-service
participant MQ as RabbitMQ
participant App as vroum-app
Tw->>Svc: POST /webhook/twilio/status (X-Twilio-Signature)
Svc->>Svc: verify signature · store raw · ack 204 fast
Svc->>Svc: ApplyProviderStatus (explicit rank map, terminal states sticky)
Svc->>Svc: DeliveryEvent(status_reported)
Svc->>MQ: comms.message.status_changed
MQ->>App: app.comms
App->>App: monotonic guard ? apply : ignore
App->>App: Reverb broadcastSignatureTwilioValidator's logic moves to the service as a VerifyTwilioSignature middleware; RespondsToTwilio becomes a 204 from the controller.
Correction (phase 2). An earlier version of this section had
spatie/laravel-webhook-clientcoming along too. It does not. Itswebhook_callstable would hold raw provider payloads on its own 30-day clock, besideinbound_messages.raw_payloadanddelivery_events.payload, which §5.3 purges at 7 — two copies of customer PII expiring on different days, which is precisely the risk §10 lists. The service already has raw storage, an audit trail and dedupe; what the package actually contributed was aSignatureValidatorinterface around a one-lineRequestValidator::validate()call. So the validation ports and the wrapper does not.The middleware rebuilds the signed URL from
config('comms.public_url')rather than reading it off the request. Twilio signs the URL it was configured with, and behind a proxy that terminates TLS the request reportshttp— every signature then fails for a reason that takes an afternoon to find. The same config value is handed to Twilio asstatusCallback, so the two cannot drift.
⚠️ Two bugs to fix before porting this logic, found while writing this doc:
ProcessStatusWebhookJobreadsif (! $status || $status->isAfter($message->status)) { return; }.IsBackedEnum::isAfter()returnstruewhen$thisis declared later in the enum — so the job bails out precisely when the status should advance, and only ever applies regressions. The condition needs a!.MessageStatusorders its casesQUEUED, ACCEPTED, SENT, FAILED, DELIVERED, UNDELIVERED, READ, andisAfter()compares declaration order. That makesFAILED(3) "before"DELIVERED(4) andUNDELIVERED(5), so a terminal failure can be overwritten by a later straggler webhook.In the service, model this as an explicit rank map with sticky terminal states rather than enum declaration order. In the app, fix the guard — it is a small standalone PR worth doing before any of this starts.
6.3 Inbound SMS
mermaid
sequenceDiagram
participant Cust as Customer
participant Tw as Twilio
participant Svc as comms-service
participant MQ as RabbitMQ
participant App as vroum-app
Cust->>Tw: SMS to shop number
Tw->>Svc: POST /webhook/twilio/incoming
Svc->>Svc: verify · normalize rcs: prefix · InboundMessage · resolve shop_id from sender_identity
Svc->>Svc: STOP/START keyword? → suppression + comms.contact.opted_out
Svc-->>Tw: 204 (empty TwiML)
Svc->>MQ: comms.inbound.sms.received
MQ->>App: app.comms
App->>App: match contact by phone within shop
App->>App: firstOrCreate Communication + INBOUND Message
App->>App: Reverb + notify shop usersNote the split: the service resolves to → shop_id (it owns the number inventory). The app resolves from → contact (it owns users). Neither reaches into the other's data.
Trap found while porting. Laravel's
TrimStringsandConvertEmptyStringsToNullare global middleware, and they rewrite the request before any route middleware sees it. Twilio signs the bytes it sent, so a customer replying"Yes "with a trailing space arrives trimmed, fails signature verification, and gets a 403 — the reply is gone and no log says why. Both are exempted forwebhook/*inbootstrap/app.php. Any future provider webhook inherits the same exemption and needs the same care.
STOP is matched against the whole trimmed message, never searched for inside it: "please don't stop texting me" is not an opt-out, and treating it as one silences a customer who asked for the opposite. Keywords cover the carrier standard plus the French a Quebec customer will actually type (ARRÊT, ARRET, DÉBUT). Suppression is scoped per shop, because STOP is a carrier concept attached to the number it was sent to — opting out of one garage is not opting out of another.
Today ProcessIncomingWebhookJob silently returns when the contact is unknown. In the new world the message is durably stored in the service either way, so dropping it in the app becomes a visible inconsistency — the app should create an unmatched-sender thread or an "unknown sender" record instead. Worth fixing during the port.
6.4 Outbound email — two paths, on purpose
Supersedes an earlier draft. This section used to say
MAIL_MAILER=commsand "removesymfony/mailgun-mailer", so that everyMailableandNotificationin the application went through the service untouched. That is wrong, and the reason is availability rather than architecture — see "why not everything" below.
The application sends two kinds of email, and they have opposite failure requirements.
| Customer mail | System mail | |
|---|---|---|
| Examples | estimate approval requests, appointment reminders, anything a shop says to a client | password reset, email verification, login notifications, internal notifications to staff |
| Path | Mail::mailer('comms') → outbox → RabbitMQ → comms-service → Mailgun | Laravel's default mailer → Mailgun, directly |
| Depends on | the broker, the outbox relay, and a second service | Mailgun alone |
| Delivery tracking | full — deliveries, delivery_events, status events back onto messages | none beyond Mailgun's own dashboard |
| Suppression | enforced, before the provider is called | never applied |
| Appears in the inbox | yes, as an EMAIL thread | no |
| Acceptable failure | arrives ten minutes late | there is none |
Why not everything. A customer marketing email that arrives ten minutes late is fine. A password reset that never arrives because the relay is down is an outage the user cannot route around — they cannot get into the account to report it. Routing system mail through the bus would add three components between a user and their own account recovery, in exchange for delivery tracking nobody reads on a password reset.
Suppression must not govern system mail. A client who unsubscribed from a shop's communication still gets their password reset; suppression is a rule about a shop talking to a customer, not about an account acting on its owner's behalf. Because system mail never touches the comms service, this falls out of the architecture for free rather than needing a carve-out — which is exactly why it must not be "simplified" later by pointing everything at comms.
The rule for a new mail class
If the recipient cannot complete an account action without it, it is system mail.
Password reset, email verification, magic links, MFA codes, security alerts: the recipient is blocked until it arrives, so it goes on the direct path. Everything else — anything a shop is saying to a customer, anything that could be resent tomorrow — is customer mail and goes through comms. When it is genuinely ambiguous, ask what happens if it arrives an hour late: an inconvenience means comms, a locked-out user means direct.
How this codebase is classified
| Class | Path |
|---|---|
App\Mail\WorkOrderApprovalRequestMail | customer — Mail::mailer('comms') |
App\Notifications\ScheduleRequestSubmitted | system — via() is ['database'] today, so it sends no mail at all; if a mail channel is added it stays on the direct path |
App\Notifications\ScheduleRequestDecided | system — same |
| Laravel/Fortify auth notifications (password reset, verification) | system — direct |
symfony/mailgun-mailer therefore stays in the application, and MAIL_MAILER keeps pointing at Mailgun in production (smtp → mailpit locally, unchanged).
The transport
comms is registered as a named mailer, so reaching it means naming it:
php
// AppServiceProvider::boot()
Mail::extend('comms', fn (array $config) => app(CommsBusTransport::class));php
// config/mail.php
'comms' => ['transport' => 'comms'],doSend() writes rather than publishes. It converts the Symfony message, then hands it to CreateOutboundEmail — the email twin of CreateOutboundSms — which writes a Message row and an Outbox row in one transaction. The relay puts it on the broker afterwards. Publishing inline would mean a broker hiccup silently loses an email, with no row to retry and no HTTP error to catch.
Three consequences worth stating, because each has already bitten:
- Call
send(), notqueue(), and dropShouldQueuefrom the mailable.Mailer::send()re-routes a queueable mailable onto the queue, which would run the transport in a worker — outside the transaction that made the state change the mail is about. Queueing earned its place when sending meant an SMTP connection; it costs atomicity now that it means one insert. - The
Fromis not passed through. The service resolves the shop's verified sending domain, because it is the only side that knows whether a domain has passed DNS verification. AFromthe application picked and the service cannot send as fails the delivery rather than silently falling back — silent fallback destroys deliverability reporting. Shop::current()must be set. A consumer or a queued job has no current shop, and every envelope carries a tenant. The transport throws rather than guessing; a guess would put one shop's mail on another shop's thread. System mail never hits this because it never uses this mailer.
Attachments: written to storage first and carried as URLs, never base64. A broker message is logged, replayed and dead-lettered, and a base64 invoice inside one is a copy of a customer's document in every one of those places.
deliveryRef is messages.ulid, and the idempotency key is comms.email.send:{ulid} — keyed on the message, so a republish can never become a second email. That is also what lets MessageAccepted and MessageStatusChanged land back on the right row and give email threads the same live status SMS already has.
Found during phase 3, worth knowing before phase 5.
messagesbroadcasts on create, and the default payload is the whole model. An SMS body fits in a Reverb frame; a rendered HTML email does not, and the rejected broadcast threw inside the send transaction — rolling back the message and its outbox row, so the mail never left and no log said why.Message::broadcastWith()now sends identifiers only, which is all the inbox ever read: it treats the event as a signal and refetches. Any model that starts carrying email-sized content needs the same check.
6.5 Inbound email
A Mailgun route (catch_all() → forward()) posts a parsed message to /webhook/mailgun/inbound, signed with the same key as the events webhook — VerifyMailgunSignature covers both, because an inbound route posts the three signature values flat where the events API nests them under signature. The service attributes it, stores it, and publishes comms.inbound.email.received.
Two separate questions, answered in two different places. Which shop a message belongs to is attribution, and it happens in the service. Which conversation it belongs to is threading, and it happens in the app. Conflating them is what the three-tier list this section used to carry got wrong.
Attribution — by address, in the service
- Parse the recipient's numeric suffix → shop (§5.5). This is the path essentially all mail takes. The suffix is authoritative and the slug is ignored, so an address a customer kept from before a rename still resolves.
- Confirm the shop is one this service sends for, so a local part that merely ends in digits does not resolve.
- Otherwise the message is unmatched. It is not published and not guessed at: it goes to a service-side
unmatched_inboundtable with a reason, is logged, and is counted. Spam and typos both land there, and it is the only way to notice a routing bug rather than infer it from silence.
Sender-address matching is not an attribution mechanism. An earlier version of this section listed it as a last resort. It is trivially spoofable — anyone can send mail claiming a customer's address — so it may inform which contact the app files a message under, never which shop receives it.
Threading — by Message-ID, in the app
In-Reply-To, thenReferencesnewest-first, matched againstmessages.message_id, scoped to the shop the service attributed the mail to. Exact. The app generates<{messages.ulid}@{platform domain}>on every outbound email and stores it, which is the entire reason part A had to ship before part B: Mailgun invents aMessage-IDwhen you do not set one, and you cannot match against a value you never saw. The shop scope matters — a Message-ID is guessable, and an unscoped match would let a craftedIn-Reply-Todrop a stranger's mail into another shop's thread.- No match → a new
Communicationfor that shop, with the sender matched to a contact where one exists and keyed on the address where it does not. A customer writing in cold is a normal case, not an error.
Plus-addressing is gone. It existed as a fallback for the case where a Message-ID did not survive the round trip, and per-shop addressing makes a Reply-To that differs from the From exactly the second-address problem this phase set out to avoid.
The message is stored with stripped_text — the reply with the quoted history removed, which Mailgun parses — as what the inbox displays, and the full text/html kept beside it, because a reply that quotes selectively is a reply whose meaning lives in the quote.
Spam is reported, not decided — except at the top. The verdict Mailgun supplies (score, SPF, DKIM) travels on the event, because whether a middling score is a real customer is a judgement the app is better placed to make. Above comms.inbound.spam_threshold (10.0) the service refuses outright: an obvious-spam message should not reach a shop's inbox at all. Refused mail is still stored and counted in unmatched_inbound, so moving that line is a decision with evidence behind it.
Attachments are stored to object storage the service owns and travel as URLs with a size, never as inline bytes. The app takes its own copy on consumption. The service's copies fall under the same 7-day retention and the pruner deletes the files, not only the columns — otherwise retention is a policy about database columns while the customer's photo sits in a bucket forever. Accepted size is capped in config; above it the message is refused with a logged reason.
Inbound always lands on the platform domain regardless of the shop's custom sending domain — that keeps MX configuration entirely in your control and out of the shop's DNS.
6.6 Provisioning a phone number
Two halves that the UI presents as one screen, and that the architecture treats as opposites: a query, then a command.
mermaid
sequenceDiagram
autonumber
participant UI as Shop settings
participant App as vroum-app
participant Svc as comms-service
participant MQ as RabbitMQ
participant Tw as Twilio
Note over UI,Tw: Search — a query, over HTTP (§4.7)
UI->>App: GET /settings/phone-numbers/search?area_code=514
App->>Svc: GET /query/numbers/available (signed, 3s timeout)
Svc->>Tw: AvailablePhoneNumbers
Tw-->>Svc: candidates
Svc-->>App: candidates
App-->>UI: candidates — nothing stored anywhere
Note over UI,Tw: Purchase — a command, over the bus
UI->>App: POST /settings/phone-numbers { phone_number }
App->>App: authorize · TX: NumberRequest(pending) + Outbox(comms.number.provision)
App-->>UI: 202 pending
App->>MQ: relay publishes
MQ->>Svc: comms.commands.numbers
Svc->>Tw: list owned numbers where friendly_name = request_ref
alt already bought (a previous attempt died mid-purchase)
Tw-->>Svc: the number — adopt it, do not buy again
else not yet
Svc->>Tw: buy, friendly_name = request_ref
end
Svc->>Tw: point status + incoming webhooks at this service
alt the number is ours
Svc->>Svc: SenderIdentity(provisioning → active | pending_regulatory)
Svc->>MQ: comms.number.provisioned + comms.sender_identity.upserted
else taken, over the ceiling, or refused
Svc->>MQ: comms.number.provisioning_failed
end
MQ->>App: app.comms
App->>App: resolve NumberRequest, upsert the sender_identities projection
App->>App: Reverb broadcast → the settings page updates itselfIdempotency is harder here than for a send. A duplicate command must not buy two numbers, and unlike an SMS the failure window is expensive: Twilio may have completed the purchase before the consumer died. A unique key on the request is not enough on its own, because the money was already spent outside your database. Setting friendly_name to the request_ref and searching owned numbers before buying turns a crash-mid-purchase into a recoverable adoption rather than a double charge. This is the reconciliation-before-write pattern that any provisioning command needs — expect the same shape for email domains.
A failure needs an event of its own. A purchase fails for mundane reasons — most often the number was bought by someone else in the seconds between the search and the confirmation — and with only a success event the request sits pending forever and the settings page spins. comms.number.provisioning_failed carries a ProvisioningFailureReason, of which number_unavailable must stay distinct: it is the only one where the right thing to offer is "pick another number" rather than "ask someone".
Twilio does not refuse a number the account already owns. Verified live in phase 4a: IncomingPhoneNumbers.create for a number already on the account answers 201 with the existing record and silently drops the friendly_name you sent, instead of the 21422 it returns for a number someone else holds. So a provider success is not proof that this request bought anything, and the returned friendly name is the only thing that separates the two. Since the identity row is keyed on the number, skipping that check moves an existing number to whichever shop asked last — and a number routes inbound SMS to exactly one shop, so that is a customer's reply landing in another shop's inbox.
This spends money, so it needs two independent guards. Who may request a number is an app-side policy question, enforced where the user is. Separately, the service enforces its own ceiling on numbers per shop, because a bug in the app should not be able to buy forty numbers. Neither guard substitutes for the other.
A purchased number is often not immediately usable. US A2P 10DLC registration and Canadian regulatory bundles mean the carrier may take days. That is why sender_identities.status is a real state machine — provisioning → pending_regulatory → active — and why SenderResolver filters on active. The app's projection then shows "awaiting carrier registration" instead of offering a number that would silently fail to deliver.
The service must know its own public URL to configure webhooks on the number it just bought. One more piece of environment config, and a real answer per environment for how the service is reachable from the provider.
On the app side, number_requests is worth persisting — unlike search results. A command in flight with a pending outcome is exactly the kind of state a user will refresh the page to check on.
7. Cross-cutting concerns
Multi-tenancy. tenant.shop_id in every envelope; the consumer sets it into a TenantContext before doing anything, and every query scopes on it. In the app, consumers must not rely on Shop::current() — there is no HTTP request. Set the tenant explicitly from the envelope. This is the #1 source of cross-tenant leaks in extracted services; write an architecture test that forbids Shop::current() inside App\Consumers.
Secrets. TWILIO_AUTH_TOKEN leaves the app entirely at the end of phase 2: a compromised web container can no longer send SMS as any shop. The Mailgun secret does not leave, because the app keeps a direct sending path for system mail (§6.4) — that is the price of not making a password reset depend on the broker. The two sides should hold separate Mailgun keys so one can be rotated without the other, and the webhook signing key belongs only to the service.
Observability. Both services report to Sentry with correlation_id on the scope. Structured logs carry correlation_id, delivery_ref, shop_id, type. Metrics that matter: queue depth per queue, DLQ depth (alert > 0), consumer lag, delivery latency p95 by channel, failure rate by provider by shop, domain verification failures, provider spend by shop.
Rate limiting. Per-provider (Twilio's ~1 msg/s per long code) and per-tenant, plus daily_send_cap for warming domains. Redis token bucket in the service. Back-pressure = nack with requeue-after-delay, not a tight loop.
Compliance. STOP/UNSUBSCRIBE/ARRÊT handling and the suppression list are legal requirements and must live in exactly one place — the service. Same for hard bounces and spam complaints. Given CASL, also plan per-shop quiet hours by timezone and an auditable consent trail (delivery_events gives you the latter for free).
Testing.
| Level | What |
|---|---|
| Contract | The contracts package ships fixture JSON per message type; both sides assert against it in CI. This is what stops silent breakage. |
| App unit | FakePublisher asserts the right command with the right payload was published. |
| Service unit | Http::fake() driver tests; consumer tests fed raw envelopes. |
| Integration | Compose spins up RabbitMQ; publish → consume → assert DB + emitted event. |
| Idempotency | Feed every consumer the same envelope twice, assert one side effect. |
| Ordering | Feed delivered then sent, assert no regression; feed failed then delivered, assert the terminal state sticks. |
| Retention | Assert the pruner nulls delivery_events.payload bodies, not just parent columns. |
Local dev. Add rabbitmq:4-management to docker-compose.yml on the sail network, plus comms.test and comms-consumer containers. Expose/ngrok for provider webhooks, same as now.
Mail goes to the same mailpit the application uses: COMMS_EMAIL_DRIVER=smtp selects SmtpDriver behind the EmailDriver port, with MAIL_HOST=mailpit. Both services then write to one inbox, so an approval email can be opened and read rather than inferred from a provider that answered 200. The local driver applies the same From rules as the Mailgun one — a local path that lets you send as an unverified domain teaches the wrong thing — and it carries an X-Delivery-Ref header so a message in mailpit can be traced back to its Delivery. What it cannot do is report back: an SMTP sink sends no webhooks, so deliveries sit at accepted until a status is posted by hand. An unrecognised driver name falls through to Mailgun, never to a sink, so a typo cannot quietly stop customer mail in production.
8. Migration plan
The whole plan is simpler than it would normally be because there is no production data. No dual-run, no per-shop feature flags, no backfill scripts, no rollback windows. Each phase is a clean cut verified in dev, and "rollback" means git revert. Migrations are edited in place rather than added.
Phase 0 — Foundations (no behaviour change)
- Create
vroumtech/platform-contracts:Envelope,Publisher,FakePublisher, v1 SMS command + event DTOs, fixture JSON. - RabbitMQ in
docker-compose.yml;bus:declarecommand for the topology. - Add
outboxtable + relay to the app. Publish nothing yet. - Fix the two
MessageStatusdefects (§6.2) — standalone PR, unrelated to any of the above. - Exit:
bus:declaregreen in CI; contract fixtures exist; status guard has a passing regression test.
Phase 1 — Outbound SMS through the service
- Edit
0002_01_01_000016_create_communications_table.php: addmessages.ulid(unique). Addprocessed_messagesfor consumer-side dedupe. - The phone tables stay put for now.
phone_numbers,shop_phone_providerandphone_providerscannot be dropped here: the inbound webhook still resolves aPhoneNumberand does not move until phase 2. They go with it. - Scaffold
comms-service. Numbers are seeded from config in this phase; self-service provisioning waits for phase 4. PortApp\Services\Sms\Twilio→TwilioDriver. Addsender_identities,provider_credentials,deliveries,delivery_events,outbox,processed_messages. Seed identities fromconfig/seeder — no data migration needed. SenderResolverwith capability matching,comms.rcs.enabled = false.SendSmsConsumer; emitcomms.message.accepted/status_changed.- App: outbound publishes instead of calling Twilio — and the queued
SendSmsjob goes away rather than being rewritten, because writing the outbox row belongs in the same transaction as the message that justified it.app.commsconsumer applies status events. - App: sender lookups disappear from
CreateOutboundSms,SendWorkOrderApprovalRequestandSendAppointmentConfirmation. A shop with no usable number stops being a validation error and becomes a failed message — checking up front would put an HTTP call on a write path (§4.7). - Exit: outbound SMS works end to end in dev with no Twilio code left on the outbound path in the app.
Phase 2 — Inbound SMS + status webhooks
- Repoint Twilio webhook URLs at the service.
- Drop
phone_numbersfrom the app, now that nothing reads it, along withmessages.phone_number_id; add the read-onlysender_identitiesprojection fed bycomms.sender_identity.*. (shop_phone_providerandphone_providerswere already gone by the end of phase 1.) - Port
SignatureTwilioValidator's logic and the twoProcessWebhookJobs, splitting responsibilities as in §6.3. - STOP/START handling +
suppressionsgo live. - Handle the unmatched-sender case properly in the app.
- Delete
App\Webhook\Twilio\*(the outboundApp\Services\Sms\*andApp\Facades\Smswent in phase 1); droptwilio/sdkandspatie/laravel-webhook-clientfrom the app'scomposer.json; removeTWILIO_*andWEBHOOK_TWILIO_CLIENT_SECRETfrom app env; dropconfig/webhook-client.php,routes/webhook.phpand thewebhook_callstable. The app has no HTTP webhook surface left at all, sopreventRequestForgery(except: ['webhook/*'])goes too. - Exit: app has zero Twilio code and zero Twilio credentials. ✅ Done.
Phase 3 — Outbound email on the platform domain
MailgunDriver,email_domainswith the platform domain verified.CommsBusTransportin the app as the namedcommsmailer (§6.4). The default mailer stays Mailgun, andsymfony/mailgun-mailerstays installed — system mail must not depend on the broker.- Classify every existing mail class as customer or system, and move only the customer ones (§6.4).
- Bounce/complaint webhooks →
suppressions+comms.address.suppressed. - Email delivery events populate
messagesso email threads appear in the existing inbox (CommunicationType::EMAILalready exists). - Body retention pruner + scheduler.
- Exit: a customer email leaves through the bus, comes back as
acceptedthendeliveredon itsmessagesrow, and a system notification still sends with the broker stopped. ✅ Done.
Phase 3.5 — First real provider contact
Everything up to here spoke only to fakes: FakeSmsDriver, FakeEmailDriver, mailpit and frozen fixtures. A phase that buys a real phone number should not be the first time the pipeline meets a real provider. This phase adds no features; it converts assumptions into verified facts.
- The service is publicly reachable. A
webcontainer serves the webhook routes — the service had none, so nothing was listening on the port a provider would post to.cloudflaredprovides the dev tunnel (no account, no auth token, unlike ngrok) andCOMMS_PUBLIC_URLcarries its URL. §6.6 needs that variable to point a freshly bought number's webhooks at this service, so phase 4 could not have started without it. Documented in the service README. - The system-mail invariants (§6.4), previously asserted nowhere. Verified live with RabbitMQ stopped: a password reset arrives, a
ScheduleRequestSubmittednotification is written, and customer mail throughMail::mailer('comms')degrades to an unpublished outbox row rather than throwing — then flushes when the broker returns. Verified again with the recipient suppressed: the reset arrives, and a customer email to the same address is recordedfailedwith asuppressedevent and no provider call. Both now carry regression tests. - The from-address decision implemented (§14 Q11) and observed end to end: two shops sharing one address, each with its own display name, projected onto the app through the real broker.
- Live Twilio, both outcomes. A real SMS reached a real handset:
accepted → sent → delivered, with two genuine status webhooks passingVerifyTwilioSignaturethrough the tunnel and the app'smessagesrow reachingdelivered. A malformed number then failed on the first attempt with no retry, and the app's row reachedfailed. - Live Mailgun, outbound and engagement. Real sends with real message ids, and an
openedwebhook drivingdeliveries.opened_atandmessages.client_read_at.
Two things real providers did that the code did not expect:
- A permanent Twilio rejection outside
TERMINAL_CODES. The malformed number returned 21265 ('To' number cannot be a Short Code), which the four-code list does not contain and never would have.TwilioDriverdecided retryability from that list, so a rejection that is permanent by construction was classed retryable and would have burned five attempts before dead-lettering. Worse,RestException::getCode()is only Twilio's code when the response body carries one — otherwise the SDK substitutes the HTTP status, so a bare 400 arrived as "code 400" and matched nothing either. Now decided on the HTTP status, which is what §4.6 always specified. - A silently refused Mailgun webhook is invisible from both ends. Mailgun reported the message sent and its console showed the hook firing, while deliveries sat at
acceptedand nothing anywhere said why.VerifyMailgunSignaturenow names the check that failed. The usual cause is worth stating once: Mailgun's HTTP webhook signing key is a different secret from the sending API key.
Twilio's magic test numbers need test credentials. +15005550001 is documented as returning 21211, but under live credentials it is accepted like any other number — it came back with a real SID and a delivered status. Provoking a real rejection needs a genuinely malformed number, not a magic one.
Phase 4 — Provisioned inventory: numbers and email domains
- 4a. Phone number search and purchase (§6.6): the read-only query endpoint,
comms.number.provision, reconciliation-before-buy, and thesender_identitiesstate machine. - 4b.
RegisterEmailDomain/VerifyEmailDomainbehindcomms:domain:register, plus thecomms:domain:pollschedule andcomms.email_domain.verification_changed. NoGenerateDnsRecords— that class was in the plan and should not have been. Mailgun returns the records with the DKIM key it will actually sign with, and generating our own would produce a record that does not match the signature, silently (§5.5). - Both are the same shape — an external purchase or registration that can half-succeed and needs reconciling — and they share their idempotency approach: ask the provider what it already has, adopt it, never parse an error string to decide.
- No Inertia settings page. Custom domains are provisioned manually at onboarding (§14 Q10): you run the command, read the DNS records out of the event, and send them to the shop. A settings page is real product surface — a form, a records table, a polling status widget, an authorization story — bought for a flow that runs a handful of times a year and always with you in the loop.
- The state machine, the events and the DNS generation are unchanged by that choice; only the trigger differs. Adding self-service later is a controller and a page over machinery that already works, not a redesign — which is the whole reason it is safe to defer.
- Fail-fast on unverified; warm-up caps as an advisory guard rather than a block, and
Return-Pathon the sending domain rather than the platform's. Both of those are decisions with reasons, written up in §5.5 — the cap is half-implemented on purpose and the Return-Path reverses an earlier draft. - 4c. Per-shop Twilio subaccounts (§5.7):
comms.subaccount.provisionpublished from inside the shop-creation transaction, reconciliation-before-create onFriendlyName, numbers bought and released in the shop's own account, andVerifyTwilioSignaturekeyed on the payload'sAccountSid. That last one is the only change in phase 4 that breaks live traffic rather than merely failing to work — before it, every subaccount webhook was validated against the parent token and refused. - 4c carries no regulatory work. Shops are Canadian, their customers are Canadian, and they send from local Canadian long codes, which A2P 10DLC does not cover (§14 Q12).
- Exit: a shop can be given its own number, and can send as
@garage-x.comonce its DNS records are published and verified.
Phase 5 — Inbound email
Split in two on purpose, and the order is not negotiable: threading depends on headers set on the way out, so every message sent before part A landed is permanently unthreadable.
5a — outbound, shipped first.
- Per-shop email
sender_identityon the platform domain, created bycomms.subaccount.provisionrather than a command of its own (§5.5). Re-running that command is the backfill. Message-IDgenerated by the app as<{messages.ulid}@{platform domain}>, stored onmessages.message_idand ondeliveries.message_id.Fromis the shop's address with the shop's name.Reply-Tois dropped when it only repeats theFrom.In-Reply-To/Referencesride the command's existingheadersfield — no new contract field was needed.
5b — inbound.
- MX on the platform domain (new: 4b's SPF and DKIM say nothing about receiving), a Mailgun
catch_all()route,InboundEmailReceivedin the contracts package at v0.10.0. - Attribution by address suffix in the service;
unmatched_inboundfor everything else; a spam threshold that refuses outright above it; attachments stored to the service's own disk and pruned with the bodies. - Threading by
Message-IDin the app, scoped to the tenant; a newCommunicationwhen nothing matches. Shops reply from the inbox throughMail::mailer('comms')with the threading headers set from the message being answered.
What this reversed: §14 Q11's shared from-address. What it corrected: §6.5's three-tier threading list, and the claim that sender-address matching is a fallback for attribution — it is not one at all.
- Exit: a shop sends from its own address with a
Message-IDwe chose, a customer's reply threads onto theCommunicationit answers, an address whose slug is out of date still resolves, cold mail opens a new thread, an invented local part is stored unmatched and published nowhere, and a shop can answer from the inbox. ✅ Done.
Phase 6 — Pre-launch hardening
- DLQ replay tooling and alerting, per-tenant throttles, spend + deliverability dashboards, load test on the consumer path.
- Runs after phase 7, not before it. Alerting needs somewhere to alert from and a load test needs something real to measure, so the deployment moved ahead of it. The runbook it was going to start already exists (runbook.md); phase 6 extends it rather than creating it.
- The load test measures the production machine directly, now that the broker is self-hosted on it (§11) — contending with the real Postgres and PHP-FPM rather than with a rented node's other tenants. Watch the memory watermark while it runs: a blocked publisher is the failure it is most likely to provoke.
Phase 7 — Production deployment
Not in the original plan, and its absence was the gap: phases 0–6 build the system and harden it, but nothing deployed the service anywhere. Sequenced before phase 6 in practice, because phase 6 needs somewhere to alert and something real to load-test — a load test against a laptop measures the laptop.
- The decision (§11): isolated Forge site with its own Linux user, same server as the app, own Postgres database and database user. RabbitMQ self-hosted on the same server, bound to loopback — reversing the plan's managed-broker call, for reasons recorded in §11. Not Docker in production;
compose.yamland theDockerfileare labelled local-only at the top of each file. - CI, first. The repository had no
.githubat all — every commit verified by local runs alone. Ports the app's workflow, runs against Postgres rather than sqlite, installspcntland asserts it is callable, and gates on two new tests:BusHandlerCoverageTest(a command with no consumer fails the build) andContractFixtureParityTest(the installed contracts package still matches its frozen fixtures). comms:number:rewebhook— numbers carry the webhook URLs they were bought with, so changingCOMMS_PUBLIC_URLdoes nothing to existing ones. The failure is asymmetric and quiet: sending keeps working, receiving silently stops.comms:credentials:reencrypt— written while there are two rows rather than during the incident that needs it. RotatingAPP_KEYwithout it makes every shop's subaccount token unrecoverable.- The runbook (runbook.md) — deploying, rolling back, reading queue depth, the DLQ, re-pointing webhooks, why the numbers daemon is pinned at one process, and what breaks if
APP_KEYis lost. - Exit: CI green and provably able to fail; the site reachable on its own domain with
/query/refused from outside; a real SMS and a real email end to end against the deployed site; a deploy performed mid-message with nothing redelivered; a restored backup whose subaccount token still decrypts.
9. Deliberately NOT extracted
| Thing | Why it stays |
|---|---|
Inbox UI + queries (GetInbox, unread counts) | Joins users, clients, shops, work orders. Extracting means a distributed join or a fat read model. No payoff. |
| Reverb broadcasting | Broadcast auth needs the session + Shop::current(). The app broadcasts on event receipt — fine. |
| Blade/Mailable rendering | Templates need domain data (work orders, vehicles). Render in the app, ship HTML over the bus. Revisit only for shop-editable templates. |
UserNotificationPreference | "Should we notify this user" is a business rule, not a delivery rule. |
| Threading semantics | The app decides what belongs to which conversation. |
Resist the urge to move these "for consistency." A service that owns delivery mechanics and nothing else is a service you can reason about.
10. Risks
| Risk | Mitigation |
|---|---|
| Lost publish = lost message | Transactional outbox + publisher confirms |
| Duplicate delivery = duplicate SMS to a customer | idempotency_key unique index on deliveries, checked before the provider call |
| Out-of-order status regressions | status_at + explicit rank map with sticky terminal states |
| Cross-tenant leak in consumers | Explicit TenantContext from envelope; arch test forbidding Shop::current() in consumer code |
| Broker becomes a SPOF | Durable queues + confirms; app degrades to "queued, not sent" rather than erroring; alert on outbox age |
| Contract drift | Shared package + fixture-based contract tests in both CI pipelines |
| Custom-domain deliverability damage | Fail-fast on unverified, provider-generated DKIM records only, advisory warm-up caps, per-domain bounce-rate alerting. Return-Path stays on the sending domain rather than the platform's — see §5.5; the alternative breaks DKIM alignment, and per-domain attribution is what makes the rest of this row measurable |
| PII in two places | 7-day retention with a pruner that walks delivery_events.payload and the inbound attachment files — files are not a column, so a pruner that walks only the database reports success while a customer's photo stays in a bucket forever |
| Inbound mail silently going nowhere | A catch-all domain hears dictionary spam and hand-typed typos, and a broken suffix rule looks exactly like a quiet week. Everything unattributable is stored in unmatched_inbound with a reason, logged and counted, so "no mail arrived" and "mail arrived and we lost it" are distinguishable. Watch the rate of unknown_recipient: a step change is a routing bug, not a spam wave |
| Obvious spam reaching a shop's inbox | comms.inbound.spam_threshold (10.0) refuses outright — well above the ~5 where Mailgun trips its own flag, because everything in between is a judgement the app is better placed to make. Refusals are counted in unmatched_inbound under spam, which is what makes the threshold tunable with evidence rather than by feel |
| Two repos, one developer | Keep the service genuinely small; share conventions via the contracts package and a copied pint.json / .github workflow |
| Carrier filtering of A2P traffic on Canadian long codes | Accepted, not mitigated. Twilio recommends short codes or verified toll-free for application traffic in Canada, because carriers filter it on local long codes. Shops send from local long codes anyway: they are what a garage's customers recognise, they need no registration, and volumes are low. Revisit if delivery rates fall — the fix is toll-free verification, which is what keeps pending_regulatory in the sender state machine (§5.7, §14 Q12) |
| A shop's subaccount webhooks refused after a provisioning failure | Signature failures log a named reason; comms:subaccount:show distinguishes no credential, a credential with no secret, and a duplicated account, which look identical from the UI (§5.7) |
| Debugging gets harder | correlation_id end-to-end from day one, not retrofitted |
11. Hosting — decided in phase 7
Requirements regardless of choice: a private network between app / service / broker; a Postgres database the service owns; a public HTTPS endpoint for provider webhooks only; long-running consumer processes.
The decision: comms-service runs as its own isolated Forge site, with its own system user, on the same server as vroum-app. The broker moves to managed CloudAMQP.
Why isolated-site-on-the-same-server
Site isolation is the part that earns its keep. Forge gives an isolated site its own Linux user, so the service's .env — Twilio parent credentials, the Mailgun signing key, APP_KEY — is unreadable by the application's user. §7 says a service's provider credentials never appear in another service's environment; isolation turns that from a convention someone can quietly break into an OS guarantee.
Same server keeps the private network at localhost and the ops cost near zero. The query endpoint (§4.7) needs the app to reach the service and nobody else to; on one box that is allow 127.0.0.1; deny all plus the shared secret, rather than a VPC or a WireGuard tunnel to maintain.
Not Docker in production. compose.yaml and the Dockerfile stay for local development and are labelled as such at the top of each file, so nobody later reasons about the deployed topology from them. They are the fastest way to run the whole service locally and they describe nothing about the server.
| Option | Verdict |
|---|---|
| Isolated Forge site, same server | Chosen. OS-level credential isolation, localhost private network, near-zero ops |
| Same Docker host / Compose | Rejected for production. It is what dev uses, and keeping it would mean the container topology and the deployed one drift silently |
| Separate VPS | Deferred. True blast-radius isolation, but it needs a real private network for the broker and doubles the servers to patch. Revisit when the service has earned it — see the triggers below |
| Kubernetes | Overkill. Real value at 3–4 services with independent autoscaling needs, which is not this |
What would trigger revisiting same-server hosting
Written down now, because "we should probably split this" is otherwise a feeling rather than a decision:
- The service's consumers competing with the app's web traffic for CPU under normal load — visible as delivery latency p95 rising with app traffic rather than with message volume.
- A second consuming service arriving, at which point "same server as the app" stops being a meaningful default.
- A compliance requirement that provider credentials not share a kernel with the application.
- The app needing a restart cadence the service should not inherit, or vice versa.
None of these is true today, and the first is measurable rather than a matter of taste.
The broker: self-hosted on the same server
Reversed in phase 7, deliberately. The plan going in — and §11's own earlier recommendation — was managed CloudAMQP, on the reasoning that the broker is the one component here with no operational experience behind it, so it is the piece to hand to someone else. That argument is real and it lost to three better ones.
Everything is already on this box. The app, the service and Postgres share a server; putting the broker somewhere else is the only thing that would introduce a network hop, and it would introduce it on the path that carries every customer message. Self-hosting keeps the whole bus on 127.0.0.1.
Localhost removes a class of risk rather than adding one. A managed broker means AMQP credentials and message payloads crossing the public internet, which means TLS to configure, a certificate to renew, and a broker password in two .env files that is useful to anyone who obtains it. Bound to loopback, the broker is unreachable from off the box at all — no TLS, no AMQP exposure, and the credential only matters to something already running as a user on the server.
A load test has to measure the machine you will run on. This was the argument for a dedicated managed plan, and self-hosting satisfies it better: phase 6's numbers come from the actual production hardware, contending with the actual Postgres and PHP-FPM, rather than from a rented node whose contention profile is someone else's.
The plan sizes trivially either way — about seven long-lived connections and eight queues counting DLQs — so cost was never the deciding factor. For reference if this is revisited: CloudAMQP (August 2026) is free for 20 connections / 100 queues / 1M messages a month, $19/mo shared, $50/mo for the smallest dedicated node.
What this costs, stated plainly. Upgrades, memory and disk alarms, and 2am debugging are now ours. Two defaults are the ones that bite, and both are set explicitly rather than left alone:
vm_memory_high_watermarkdefaults to 40% of total RAM. On a box also running Postgres and PHP-FPM that is far too generous, and the failure mode is not a crash: RabbitMQ blocks publishing connections, so the outbox relay stalls and the platform degrades to "queued, not sent" — exactly as §10 promises, and silently. It is set to an absolute figure so the broker cannot starve the database.disk_free_limitdefaults to 50 MB, which is close enough to zero to be useless. Publishers block when it trips, with the same silence.
Both are why the runbook alerts on outbox age and not only on DLQ depth: a blocked publisher produces no dead letters and no errors, just rows that stop moving.
What is not recoverable from a backup. The durable state is the outbox in Postgres, not the broker. Losing the broker loses messages in flight and the contents of the dead-letter queues; unpublished outbox rows are republished by the relay on its own. Topology is not in any backup either — bus:declare (owned by the app) re-creates exchanges, queues and bindings, and must be re-run after a broker rebuild or the consumers fail loudly on startup, which is the behaviour they were given for this reason.
This stays reversible. Nothing in either codebase knows where the broker is: config/bus.php reads host, port, user, password and vhost from the environment, with no TLS assumption anywhere. Moving to CloudAMQP later is a change to five environment variables and a firewall rule, not a redesign — which is what makes self-hosting the right thing to try first rather than a commitment.
Recommendation, restated: isolated Forge site alongside the app; RabbitMQ self-hosted on the same server, bound to loopback, with the two watermarks set explicitly; revisit against a managed broker if operating it starts costing real attention, not on instinct.
12. Event sourcing — considered and declined
Decision: no. Recorded here so it doesn't get re-litigated in six months.
spatie/laravel-event-sourcing was evaluated. It is an intra-service persistence pattern (aggregates replay their history, projectors rebuild read models, reactors fire side effects) — it is not a transport, so it replaces nothing in §4. The genuine fit was the Delivery lifecycle: append-only, short-lived, strict state machine, audit-worthy.
Why not, for now:
- The hard, irreversible work in this project is the boundary and the contracts, not the persistence style. Spending the complexity budget in two places at once is how solo extractions stall.
- It brings aggregates, replay discipline, event upcasting, and a second versioning axis on top of the wire contracts you already have to version.
- Nothing in the delivery lifecycle requires replaying history to make a decision, which is event sourcing's actual differentiator. The audit requirement is real; the decision-from-history one isn't.
What you get instead: the append-only delivery_events table in §5.2. Every state change appends a row (requested, suppressed, provider_called, accepted, status_reported, retried, failed, published) with a jsonb payload, and deliveries is the derived summary — always written from the event, never independently. No package, no aggregates, no replay machinery. You get the audit trail, the carrier-dispute evidence, and the CASL consent history for maybe 40 lines of code.
If you ever do adopt event sourcing inside the service, this table is the migration path, and — because the boundary is a versioned wire contract — it would be a non-event for the rest of the platform: zero changes in the app, zero in any future service.
One rule that would apply if that day comes: never put a stored event on the wire. Internal domain events are PHP classes free to change weekly; platform messages are versioned contracts. Translate between them in a reactor, always.
13. Conventions for future Vroum microservices
Everything above generalizes. Codify these once:
- One database per service. No shared tables, no cross-DB joins, no reading another service's schema.
- Broker-only for commands and events, one queue per consuming service, topic exchange for events. Queries may use HTTP — and wanting to POST to one means it was never a query (§4.7).
- The standard envelope with
tenant.shop_idandcorrelation_id— always. <context>.<entity>.<action>naming, integer-versioned, additive-only within a version.- Contracts live in
vroumtech/platform-contracts, not in either service. - Publish via outbox, consume idempotently. Both non-negotiable.
- Own your secrets. A service's provider credentials never appear in another service's env.
- Every service exposes
/up, reports to Sentry with the correlation ID, and declares its topology in code. - Events describe facts that happened, commands request work. Never a command dressed as an event.
- The monolith is a peer, not a parent. It gets no special access.
- Persistence style is an internal decision. Consistency is required at the boundary, nowhere else.
14. Resolved questions
| # | Question | Answer |
|---|---|---|
| 1 | Per-shop email domains? | Both — one platform domain as the default, plus optional per-shop custom domains with a verification workflow (§5.5, phase 4) |
| 2 | Body retention in the service | 7 days, then nulled — including delivery_events.payload (§5.3) |
| 3 | Second SMS provider? | No — Twilio only. Keep the driver interface as a test seam; build no provider-selection machinery (§5.1) |
| 4 | RCS fallback | Design now, integrate later. capabilities + resolver + channel_used ship in phase 1 behind comms.rcs.enabled = false (§5.4) |
| 5 | MessageStatus defects | Fix first, standalone PR in phase 0 (§6.2) |
| 6 | phone_numbers migration source of truth | Moot — no production data. Delete the tables from the app, create fresh in the service, app keeps a read-only projection (§3.2) |
| 7 | Event sourcing | No. Append-only delivery_events instead (§12) |
| 8 | Number search over the bus or HTTP? | HTTP. Broker-only is a rule about writes; a query changes nothing, loses nothing, and returns something you do not want to store (§4.7) |
| 9 | Does all mail go through the service? | No — customer mail only. comms is a named mailer; the default stays a direct Mailgun connection so account recovery never depends on the broker, the relay and a second service (§6.4) |
| 10 | Custom-domain self-service or manual? | Manual provisioning at onboarding. A shop does not add its own domain; you register it for them with an artisan command. The state machine and events are unchanged, so self-service UI is additive whenever it earns its place (§8 phase 4) |
| 12 | A2P registration for shop numbers? | None in this phase. A2P 10DLC is a US carrier programme; Canadian local long codes are listed with no registration step and provisioning time "N/A". So there is no per-shop brand or campaign registration, no vetting flow, no approval state machine, and no new shop data to collect. sender_identities.status = pending_regulatory stays in the state machine although nothing sets it today — it becomes live the moment toll-free numbers are adopted, and is not dead code. The deliverability trade-off this accepts is recorded in §10 (§5.7, §8 phase 4c) |
| 13 | One Twilio account or one per shop? | One subaccount per shop, with the platform account as parent and fallback. It is what makes a shop's inbound replies routable to exactly that shop, and what lets a shop be switched off without touching anyone else. The cost is that webhook signature validation stops being a single-key operation (§5.7) |
| 11 | Platform from-address format | Reversed in phase 5. Each shop sends from its own address on the shared platform domain — "Garage Côté" <garage-cote-42@email.vroum.solutions> — not one shared notifications@ with a per-shop display name (§5.5) |
Question 11 was answered twice, and the second answer is the live one.
The original answer was a single shared address with a per-shop display name, chosen on reputation grounds: one reputation to warm, and per-shop isolation judged not worth the warm-up cost at that volume.
Phase 5 reversed it, because inbound email needs the recipient address to identify the shop. The reversal turned out to be cheap, and the reason is that the original concern did not apply: sending reputation is overwhelmingly domain- and IP-level, not local-part. Distinct local-parts on one verified domain do not fragment warm-up the way separate domains would. That concern was real for per-shop domains — which is why §5.5's custom-domain tier still carries a warm-up ramp — and largely imaginary for per-shop addresses.
What the reversal bought: attribution with no lookup, working for a customer writing in cold as well as for a reply; a Reply-To that never has to differ from the From; and no plus-addressing scheme to keep alive. What it cost: the numeric-suffix rule in §5.5, which is now the thing most likely to be implemented and then quietly broken.
Two details from the original answer survive it. Registration is still keyed on (shop_id, channel, value) rather than value alone — that was fixed when two shops shared one address, and it stays correct now that they do not. And one platform-level identity for everyone still cannot work: shop_id = null identities are deliberately not projected to the app (§3.2), and the display name has to come from the side that knows what a shop is called.
The asymmetry that falls out: email addresses are shared, phone numbers are not. An inbound SMS is routed to a shop by looking up the number it arrived on, so two shops behind one number means a customer's reply lands in whichever thread the database returned first. comms:sender:add refuses that outright.
Still open
- Quiet hours — per-shop timezone-aware send windows for SMS. Enforced in the service (it owns delivery timing) or the app (it owns business rules)? Suggest the service, driven by a
shop_id → timezone + windowconfig synced by event.
15. Suggested first PRs
vroum-app— fix theProcessStatusWebhookJobguard and theMessageStatusordering, with regression tests. Independent of everything else; ship it today.vroumtech/platform-contracts—Envelope,Publisher,FakePublisher,SendSmsCommand,MessageAccepted,MessageStatusChanged, fixtures, tests.vroum-app—outboxtable +OutboxRelay+RabbitMqPublisherbinding +bus:declare; RabbitMQ indocker-compose.yml.comms-service— Laravel 13 skeleton,sender_identities/provider_credentials/deliveries/delivery_events,TwilioDriverported fromApp\Services\Sms\Twilio,SenderResolver,SendSmsConsumer.vroum-app—messages.ulid, drop the phone tables, add thesender_identitiesprojection,SendSmspublishes,app.commsconsumer.