Application · Adapter
SMS
Describe a text or media message in application code, then choose Twilio, logging, or null delivery at the boundary.
SMS separates message composition from provider delivery. Application code owns recipient, sender,
body, and media intent through SmsMessage, SmsFactory, and SmsTransport; Twilio, logging, and null
adapters stay at the boundary. No framework-specific SMS provider is shipped.
A transport-abstraction layer for sending SMS and MMS messages. Messages are built via a
fluent SmsMessage DTO and sent through any SmsTransport implementation. An SmsService
facade combines transport + factory into a single dependency.
Application\Sms
├── SmsService — Facade: SmsTransport + SmsFactory
├── Message\
│ ├── SmsMessage — Mutable message DTO (fluent builder)
│ └── SmsFactory (interface) — createMessage(), createMediaUrl()
├── Transport\
│ └── SmsTransport (interface) — send(SmsMessage): void
└── Exception\
└── SmsException — extends SystemException
Adapter\Sms
├── Twilio\
│ └── TwilioSmsTransport — SmsTransport → Twilio REST API
├── Logging\
│ └── LoggingSmsTransport — Decorator: logs metadata then delegates
└── Null\
└── NullSmsTransport — No-op (tests / dev)
Table of Contents¶
SmsMessage¶
Fight\Common\Application\Sms\Message\SmsMessage
A mutable, fluent DTO for building SMS/MMS messages. Constructed with to and from
phone numbers; body and media are optional.
use Fight\Common\Application\Sms\Message\SmsMessage;
$message = SmsMessage::create('+15550001234', '+15559998765')
->setBody('Your verification code is 123456');
Fields¶
| Method | Signature | Description |
|---|---|---|
getTo |
(): string |
Recipient phone number |
getFrom |
(): string |
Sender phone number |
setBody |
(string $body): static |
Message body text |
getBody |
(): ?string |
Returns null if not set |
addMedia |
(Url $url): static |
Adds a media URL for MMS |
getMedia |
(): array<int, Url> |
Returns all attached media URLs |
addMedia() accepts a Fight\Common\Domain\Value\Internet\Url value object. Use
SmsService::createMediaUrl() to build one from a plain string.
SmsService (Facade)¶
Fight\Common\Application\Sms\SmsService
Implements both SmsTransport and SmsFactory, wrapping a SmsTransport delegate.
This is the recommended single dependency for application services.
final readonly class SmsService implements SmsTransport, SmsFactory
{
public function __construct(private SmsTransport $transport) {}
}
class VerificationService
{
public function __construct(private SmsService $sms) {}
public function sendCode(string $phone, string $code): void
{
$message = $this->sms->createMessage(
to: $phone,
from: '+15550000000',
body: "Your code is {$code}"
);
$this->sms->send($message);
}
}
SmsTransport¶
Fight\Common\Application\Sms\Transport\SmsTransport
interface SmsTransport
{
/** @throws SmsException */
public function send(SmsMessage $message): void;
}
Implementations¶
| Implementation | Namespace | Purpose |
|---|---|---|
TwilioSmsTransport |
Adapter\Sms\Twilio |
Production — wraps Twilio REST client |
LoggingSmsTransport |
Adapter\Sms\Logging |
Dev — logs metadata then delegates |
NullSmsTransport |
Adapter\Sms\Null |
Test — silent no-op |
TwilioSmsTransport¶
Fight\Common\Adapter\Sms\Twilio\TwilioSmsTransport
Wraps the Twilio Client SDK. Maps SmsMessage to $client->messages->create().
Wraps any Throwable from the Twilio SDK in a SmsException.
use Twilio\Rest\Client;
use Fight\Common\Adapter\Sms\Twilio\TwilioSmsTransport;
$transport = new TwilioSmsTransport(new Client($accountSid, $authToken));
LoggingSmsTransport¶
Fight\Common\Adapter\Sms\Logging\LoggingSmsTransport
Decorator that logs message metadata (to, from, body, media_count) via PSR-3
before calling the inner transport:
Phone numbers and message bodies are personal data and may contain authentication codes or other secrets. Do not enable this decorator in production without a consumer-owned redaction and retention policy. A log entry proves only that delegation was attempted.
$transport = new LoggingSmsTransport(
new TwilioSmsTransport($client),
$logger,
LogLevel::INFO // default DEBUG
);
NullSmsTransport¶
Fight\Common\Adapter\Sms\Null\NullSmsTransport
Silent no-op. send() does nothing and throws no exceptions.
This suppresses delivery and records no message. Use a consumer-owned spy when a test must assert recipient, content, or media rather than merely prove that no provider call occurs.
Twilio returning successfully means the provider accepted the request; it is not proof that a handset received the message. Delivery receipts, opt-out handling, sender registration, regional policy, rate limits, retry, and reconciliation belong to the consuming application and its Twilio operations.
SmsFactory¶
Fight\Common\Application\Sms\Message\SmsFactory
interface SmsFactory
{
public function createMessage(
string $to,
string $from,
?string $body = null,
array $mediaUrls = [] // Url objects or plain strings
): SmsMessage;
public function createMediaUrl(string $url): Url;
}
createMessage() accepts $mediaUrls as a mixed array of Url objects or plain URL
strings — it coerces strings via createMediaUrl() automatically.
SmsService is the sole implementation.
$message = $smsService->createMessage(
to: '+15550001234',
from: '+15559998765',
body: 'Here is your photo',
mediaUrls: ['https://example.com/photo.jpg']
);
Symfony Configuration¶
# config/packages/common_sms.yaml
services:
_defaults:
autowire: true
autoconfigure: true
# --- Transport ---
Fight\Common\Adapter\Sms\Twilio\TwilioSmsTransport:
arguments:
- '@twilio.client' # Twilio\Rest\Client
Fight\Common\Adapter\Sms\Logging\LoggingSmsTransport:
decorates: Fight\Common\Adapter\Sms\Twilio\TwilioSmsTransport
arguments:
- '@.inner'
- '@logger'
- 'info'
Fight\Common\Adapter\Sms\Null\NullSmsTransport: ~
# --- Facade ---
Fight\Common\Application\Sms\SmsService:
arguments:
- '@Fight\Common\Adapter\Sms\Twilio\TwilioSmsTransport'
# --- Interface aliases ---
Fight\Common\Application\Sms\Transport\SmsTransport:
alias: Fight\Common\Adapter\Sms\Twilio\TwilioSmsTransport
Environment overrides:
# config/packages/test/common_sms.yaml
services:
Fight\Common\Application\Sms\Transport\SmsTransport:
alias: Fight\Common\Adapter\Sms\Null\NullSmsTransport
Usage Examples¶
Sending a Text Message¶
use Fight\Common\Application\Sms\SmsService;
class OrderShippedNotifier
{
public function __construct(private SmsService $sms) {}
public function notify(Order $order): void
{
$message = $this->sms->createMessage(
to: $order->customerPhone(),
from: '+15550000000',
body: sprintf('Your order #%d has shipped!', $order->id())
);
$this->sms->send($message);
}
}
Sending MMS with Media¶
$message = $this->sms->createMessage(
to: $recipient,
from: '+15550000000',
body: 'Here is your receipt.',
mediaUrls: ['https://example.com/receipts/1234.pdf']
);
$this->sms->send($message);
Building a Message Manually¶
use Fight\Common\Application\Sms\Message\SmsMessage;
$mediaUrl = $this->sms->createMediaUrl('https://example.com/image.jpg');
$message = SmsMessage::create('+15550001234', '+15550000000')
->setBody('Check this out')
->addMedia($mediaUrl);
$this->sms->send($message);
Testing with NullSmsTransport¶
use Fight\Common\Adapter\Sms\Null\NullSmsTransport;
use Fight\Common\Application\Sms\SmsService;
$service = new SmsService(new NullSmsTransport());
$service->send($message); // no-op, no exception