awaisjameel / texto
A Laravel package to handle messaging (SMS, MMS) using services Twilio or Telnyx
Fund package maintenance!
awaisjameel
Installs: 27
Dependents: 0
Suggesters: 0
Security: 0
Stars: 0
Watchers: 0
Forks: 0
Open Issues: 0
pkg:composer/awaisjameel/texto
Requires
- php: ^7.4 || ^8.1
- ext-sodium: *
- giggsey/libphonenumber-for-php: ^9.0
- illuminate/contracts: ^10.0||^11.0||^12.0
- illuminate/support: ^10.0||^11.0||^12.0
- spatie/laravel-package-tools: ^1.92.7
Requires (Dev)
- larastan/larastan: ^3.8
- laravel/pint: ^1.14
- nunomaduro/collision: ^8.8
- orchestra/testbench: ^10.0.0||^9.0.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-arch: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
- phpstan/extension-installer: ^1.4
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
README
** Unified, extensible Laravel gateway for sending & receiving SMS/MMS over Twilio & Telnyx. Batteries included: queueing, retries, events, webhooks, polling, typed value objects.**
Texto provides a unified, extensible Laravel package for carrier-grade SMS/MMS messaging. Built for Laravel 10–12 (PHP 7.4 / 8.1+), it abstracts provider complexities (Twilio, Telnyx) through consistent contracts and value objects, enabling seamless integration with enterprise messaging workflows.
Key Features:
- Unified API: Single interface for sending SMS/MMS across multiple providers
- Message Persistence: Automatic storage of sent and received messages with full metadata
- Status Tracking: Real-time delivery status updates via webhooks and fallback polling
- Event-Driven: Rich event system for analytics, notifications, and custom automation
- Advanced Twilio Support: Conversations API with auto-provisioned content templates
- Reliability: Exponential backoff retry, queue-based async processing, and graceful degradation
- Security: Webhook signature validation, rate limiting, and shared secret protection
- Extensibility: Plugin architecture for adding new messaging providers
Table of Contents
- Motivation & Philosophy
- Feature Overview
- Quick Start
- Installation
- Configuration (
config/texto.php) - Usage Examples
- Queueing & Async Flow
- Events & Observability
- Data Model & Persistence
- Webhooks (Inbound + Status)
- Security (Signatures, Secrets, Rate Limiting)
- Status Polling (Adaptive Fallback)
- Retry & Backoff Strategy
- Twilio Conversations & Content Templates
- Extending / Custom Drivers
- Value Objects & Enums
- Console Commands
- Testing, Fakes & Local Development
- Architecture Overview
- Troubleshooting & FAQ
- Roadmap
- Contributing
- Security Policy
- License & Credits
1. Motivation & Philosophy
Building messaging features in Laravel applications often involves wrestling with provider-specific APIs that have inconsistent interfaces, error handling, and webhook formats. Without proper abstraction, code becomes littered with conditional logic that breaks when switching providers or adding new ones.
Texto solves this by providing a clean, consistent interface that:
- Eliminates Provider Lock-in: Switch between Twilio, Telnyx, or custom providers with minimal code changes
- Ensures Type Safety: Strongly typed enums and value objects prevent common mistakes
- Promotes Clean Architecture: Clear separation between sending, persistence, and status tracking
- Enables Observability: Comprehensive events and logging for monitoring and debugging
- Handles Edge Cases: Built-in retry logic, queueing, and fallback polling for reliability
- Prioritizes Security: Webhook validation, rate limiting, and shared secret protection
The philosophy is simple: messaging should be a first-class citizen in your Laravel app, not an afterthought that requires constant maintenance.
2. Feature Overview
Core Messaging
- SMS & MMS Support: Send text messages and media attachments through Twilio and Telnyx
- Unified API: Single
Texto::send()method works across all providers - Phone Number Validation: Automatic E.164 formatting and validation using libphonenumber
- Media Handling: Support for multiple media URLs per message
Reliability & Performance
- Queue Integration: Async message sending with Laravel queues for high-throughput applications
- Retry Logic: Exponential backoff for transient API failures
- Status Polling: Fallback polling when webhooks are delayed or unavailable
- Rate Limiting: Built-in protection against webhook abuse
Advanced Twilio Features
- Conversations API: Rich conversation management with participant tracking
- Content Templates: Auto-provisioning and reuse of SMS/MMS templates
- Template Variables: Dynamic content insertion for personalized messaging
Observability & Events
- Event System: Four key events (
MessageSent,MessageReceived,MessageFailed,MessageStatusUpdated) - Structured Logging: Comprehensive logging for debugging and monitoring
- Metadata Capture: Rich metadata storage including costs, segments, and custom data
Security & Compliance
- Webhook Validation: Signature verification for Twilio, shared secret headers
- Rate Limiting: Configurable per-minute limits on webhook endpoints
- Data Persistence: Optional message storage with configurable retention
Developer Experience
- Type Safety: Strongly typed enums and value objects
- Extensible Architecture: Plugin system for custom providers
- Testing Support: Fake drivers and webhook validation skipping for tests
- Laravel Integration: Service provider auto-discovery and facade registration
3. Quick Start
composer require awaisjameel/texto php artisan texto:install # publishes config + migration & runs migrate php artisan texto:test-send +15551234567 "Hello from Texto"
use Texto; // facade alias configured automatically Texto::send('+15551234567', 'Hello world');
4. Installation
Prerequisites
Before installing Texto, ensure your Laravel application meets these requirements:
- Laravel: 10.0, 11.0, or 12.0
- PHP: 7.4 or higher (8.2+ recommended)
- Database: MySQL, PostgreSQL, SQLite, or SQL Server
- Queue System: Any Laravel-supported queue driver (Database recommended for production)
- PHP Extensions:
ext-sodiumfor Telnyx signature verification
Quick Installation
The fastest way to get started:
composer require awaisjameel/texto php artisan texto:install
This command will:
- Publish the configuration file to
config/texto.php - Publish and run the database migration
- Register the service provider and facade
Manual Installation
For more control over the installation process:
# 1. Install the package composer require awaisjameel/texto # 2. Publish configuration (optional - auto-published by texto:install) php artisan vendor:publish --tag=texto-config # 3. Publish migration (optional - auto-published by texto:install) php artisan vendor:publish --tag=texto-migrations # 4. Run migrations php artisan migrate
Provider Setup
If you're not using package auto-discovery, add the service provider to config/app.php:
'providers' => [ // ... other providers Awaisjameel\Texto\TextoServiceProvider::class, ], 'aliases' => [ // ... other aliases 'Texto' => Awaisjameel\Texto\Facades\Texto::class, ],
Verification
After installation, verify everything is working:
php artisan texto:test-send +15551234567 "Hello from Texto!"
This will send a test message using your configured provider and settings.
Environment Variables
# Core TEXTO_DRIVER=twilio # twilio | telnyx TEXTO_STORE_MESSAGES=true # disable to skip DB persistence TEXTO_QUEUE=false # true => SendMessageJob async TEXTO_RETRY_ATTEMPTS=3 TEXTO_RETRY_BACKOFF_START=200 # ms TEXTO_WEBHOOK_SECRET= # optional shared secret header TEXTO_DEFAULT_REGION=US # for parsing non-E.164 input # Status polling (optional) TEXTO_STATUS_POLL_ENABLED=false TEXTO_STATUS_POLL_MIN_AGE=60 TEXTO_STATUS_POLL_MAX_ATTEMPTS=5 TEXTO_STATUS_POLL_QUEUED_MAX_ATTEMPTS=2 TEXTO_STATUS_POLL_BACKOFF=300 TEXTO_STATUS_POLL_BATCH=100 # Twilio TWILIO_ACCOUNT_SID=... TWILIO_AUTH_TOKEN=... TWILIO_FROM_NUMBER=+15550001111 TWILIO_USE_CONVERSATIONS=true TWILIO_SMS_TEMPLATE_FRIENDLY_NAME=texto_sms_template TWILIO_MMS_TEMPLATE_FRIENDLY_NAME=texto_mms_template TWILIO_CONVERSATION_PREFIX=Texto TWILIO_CONVERSATION_WEBHOOK_URL= # optional override # Telnyx TELNYX_API_KEY=... TELNYX_MESSAGING_PROFILE_ID=... TELNYX_FROM_NUMBER=+15550002222 TELNYX_WEBHOOK_SECRET=base64-encoded-public-key TELNYX_HTTP_TIMEOUT=15 # seconds for outbound API calls
5. Configuration (config/texto.php)
After installation, you'll find the configuration file at config/texto.php. Here's a comprehensive guide to all available options:
Core Settings
| Key | Default | Description |
|---|---|---|
driver |
'twilio' |
Active messaging provider ('twilio' or 'telnyx') |
store_messages |
true |
Whether to persist messages in the database |
queue |
false |
Enable async message sending via Laravel queues |
default_region |
'US' |
Default region for phone number parsing |
Retry Configuration
'retry' => [ 'max_attempts' => env('TEXTO_RETRY_ATTEMPTS', 3), 'backoff_start_ms' => env('TEXTO_RETRY_BACKOFF_START', 200), ],
Controls exponential backoff retry behavior for failed API calls:
max_attempts: Maximum number of retry attempts (default: 3)backoff_start_ms: Initial delay in milliseconds (doubles each retry)
Webhook Security
'webhook' => [ 'secret' => env('TEXTO_WEBHOOK_SECRET'), 'rate_limit' => env('TEXTO_WEBHOOK_RATE_LIMIT', 60), ],
secret: Optional shared secret for webhook authenticationrate_limit: Maximum webhook requests per minute (default: 60)
Status Polling (Fallback)
'status_polling' => [ 'enabled' => env('TEXTO_STATUS_POLL_ENABLED', false), 'min_age_seconds' => env('TEXTO_STATUS_POLL_MIN_AGE', 60), 'max_attempts' => env('TEXTO_STATUS_POLL_MAX_ATTEMPTS', 5), 'queued_max_attempts' => env('TEXTO_STATUS_POLL_QUEUED_MAX_ATTEMPTS', 2), 'backoff_seconds' => env('TEXTO_STATUS_POLL_BACKOFF', 300), 'batch_limit' => env('TEXTO_STATUS_POLL_BATCH', 100), ],
Configures fallback polling for messages stuck in transient states:
enabled: Enable/disable polling (default: false)min_age_seconds: Minimum age before polling startsmax_attempts: Maximum polling attempts per messagebackoff_seconds: Delay between polling attempts
Twilio Configuration
'twilio' => [ 'account_sid' => env('TWILIO_ACCOUNT_SID'), 'auth_token' => env('TWILIO_AUTH_TOKEN'), 'from_number' => env('TWILIO_FROM_NUMBER'), 'use_conversations' => env('TWILIO_USE_CONVERSATIONS', true), 'sms_template_friendly_name' => env('TWILIO_SMS_TEMPLATE_FRIENDLY_NAME', 'texto_sms_template'), 'mms_template_friendly_name' => env('TWILIO_MMS_TEMPLATE_FRIENDLY_NAME', 'texto_mms_template'), 'conversation_prefix' => env('TWILIO_CONVERSATION_PREFIX', 'Texto'), 'conversation_webhook_url' => env('TWILIO_CONVERSATION_WEBHOOK_URL'), ],
Twilio-specific settings for both classic and Conversations API modes.
Telnyx Configuration
'telnyx' => [ 'api_key' => env('TELNYX_API_KEY'), 'messaging_profile_id' => env('TELNYX_MESSAGING_PROFILE_ID'), 'from_number' => env('TELNYX_FROM_NUMBER'), 'webhook_secret' => env('TELNYX_WEBHOOK_SECRET'), 'timeout' => env('TELNYX_HTTP_TIMEOUT', 15), ],
Telnyx API credentials, messaging profile configuration, the base64-encoded public key used to verify webhook signatures, and a transport timeout (seconds) for outbound REST calls.
Testing Configuration
'testing' => [ 'skip_webhook_validation' => env('TEXTO_TESTING_SKIP_WEBHOOK_VALIDATION', false), ],
Settings for testing environments to skip webhook signature validation.
6. Usage Examples
Basic SMS Sending
Send a simple text message:
use Texto; $result = Texto::send('+15551234567', 'Hello from Texto!'); // Returns SentMessageResult with status, provider ID, etc. echo $result->status->value; // 'sent' echo $result->providerMessageId; // 'SM1234567890abcdef'
MMS with Media Attachments
Send messages with images, videos, or other media:
$result = Texto::send('+15551234567', 'Check out this photo!', [ 'media_urls' => [ 'https://example.com/image.jpg', 'https://example.com/video.mp4' ] ]);
Per-Message Driver Override
Temporarily use a different provider for specific messages:
// Send via Telnyx instead of default Twilio $result = Texto::send('+15551234567', 'Via Telnyx', [ 'driver' => 'telnyx' ]);
Custom Sender Number and Metadata
Use different sender numbers and attach custom metadata:
$result = Texto::send('+15551234567', 'Welcome to our service!', [ 'from' => '+15550009999', // Different sender number 'metadata' => [ 'campaign' => 'welcome_series', 'user_id' => 12345, 'priority' => 'high' ] ]);
Asynchronous Queue Processing
For high-throughput applications, enable queuing:
// In .env TEXTO_QUEUE=true // In code $result = Texto::send('+15551234567', 'Queued message'); echo $result->status->value; // 'queued' // Start a queue worker php artisan queue:work
Controller Response
Return messages directly from controllers (auto-converts to JSON):
class NotificationController extends Controller { public function sendAlert(Request $request) { $result = Texto::send( $request->phone, 'Alert: ' . $request->message ); // Automatically returns JSON response return $result; } }
Event-Driven Processing
Listen to messaging events for analytics and automation:
// In EventServiceProvider protected $listen = [ \Awaisjameel\Texto\Events\MessageSent::class => [ \App\Listeners\LogMessageSent::class, ], \Awaisjameel\Texto\Events\MessageStatusUpdated::class => [ \App\Listeners\TrackDeliveryStatus::class, ], ]; // Listener example class TrackDeliveryStatus { public function handle(MessageStatusUpdated $event) { $result = $event->result; // Log delivery metrics Log::info('Message delivered', [ 'provider_id' => $result->providerMessageId, 'delivered_at' => now(), ]); } }
Bulk Messaging
Send multiple messages efficiently:
$recipients = ['+15551234567', '+15559876543', '+15551111111']; $messages = []; foreach ($recipients as $phone) { $messages[] = Texto::send($phone, 'Bulk notification'); } // Process results $successful = collect($messages)->where('status.value', 'sent')->count();
International Number Handling
Texto automatically handles international formatting:
// All of these work automatically $phones = [ '+1-555-123-4567', // US format '555.123.4567', // Local format (uses config region) '+44 20 7123 4567', // UK format '0912345678', // Indian format ]; foreach ($phones as $phone) { Texto::send($phone, 'International hello!'); }
7. Queueing & Async Flow
- In queue mode,
Texto::send()stores a queued row (statusqueued). - Dispatches
SendMessageJobwith deterministic primary key. - Job invokes
Texto::send(... ['queued_job'=>true,'queued_message_id'=>X])to perform real API send. - Repository upgrades the exact queued record (no racey pattern matching).
- Status webhooks or polling complete remaining transitions.
Benefits: immediate API responses, backpressure via Laravel queue, deterministic DB state.
Note: The queued job now includes a snapshot of the active driver configuration (API keys, profile IDs, etc.) so workers and scheduled pollers have the same credentials that were present when the message was enqueued. Ensure your queue transport (e.g., database table, Redis) is appropriately protected since provider secrets travel with the job payload.
8. Events & Observability
| Event | Fired When | Payload |
|---|---|---|
MessageSent |
Successful provider send | SentMessageResult |
MessageFailed |
Send attempt threw TextoSendFailedException |
SentMessageResult, error message |
MessageReceived |
Inbound webhook parsed | WebhookProcessingResult |
MessageStatusUpdated |
Stored message status mutated (webhook) | WebhookProcessingResult |
Subscribe in EventServiceProvider or use listeners/jobs for analytics, billing, triggers.
Structured logging is emitted at info / debug levels for sends, polling promotions, template initialization, and failures.
9. Data Model & Persistence
Table: texto_messages
| Column | Notes |
|---|---|
| direction | sent / received |
| driver | twilio / telnyx |
| from_number / to_number | E.164 formatted |
| body | Nullable for pure media inbound |
| media_urls | JSON array |
| status | Normalized enum (queued, sending, sent, delivered, failed, undelivered, received, ambiguous) |
| provider_message_id | SID / Telnyx ID (nullable until known) |
| error_code | Provider error (if any) |
| segments_count | (Telnyx) part count |
| cost_estimate | (Telnyx) estimated cost |
| metadata | Arbitrary JSON (includes polling counters, conversation info) |
| sent_at / received_at / status_updated_at | Timestamps |
Ambiguous terminal state occurs when polling exhausts attempts without a provider id or final disposition.
10. Webhooks
Auto‑registered routes (POST):
| Purpose | Twilio | Telnyx |
|---|---|---|
| Inbound | /texto/webhook/twilio |
/texto/webhook/telnyx |
| Status | /texto/webhook/twilio (same endpoint) |
/texto/webhook/telnyx (same endpoint) |
Both providers now publish inbound and status callbacks to a single endpoint. Texto inspects each payload to determine whether it is an inbound message or a delivery status update, ensuring identical processing for Twilio and Telnyx.
Each request passes through:
VerifyTextoWebhookSecret– matchesX-Texto-Secret(if configured).RateLimitTextoWebhook– per‑minute throttle (webhook.rate_limit).
Inbound payloads are normalized into WebhookProcessingResult then persisted via EloquentMessageRepository.
11. Security
| Mechanism | Description |
|---|---|
| Twilio Signature | Validated via RequestValidator unless TEXTO_TESTING_SKIP_WEBHOOK_VALIDATION in testing. |
| Telnyx Signature | Validated via Ed25519 signature (Telnyx public webhook key, sodium required). |
| Shared Secret Header | Add TEXTO_WEBHOOK_SECRET and send header X-Texto-Secret. |
| Rate Limiting | Middleware prevents abuse of webhook endpoints. |
| Phone Parsing | All numbers canonicalized using libphonenumber. |
12. Status Polling (Fallback)
Some production networks delay webhooks or they can be transiently disabled. Polling covers that gap.
Enable via TEXTO_STATUS_POLL_ENABLED=true. The service provider auto‑schedules StatusPollJob each minute. Logic:
- Select messages in transient states (
queued|sending|sent) older thanmin_age_seconds. - Skip if attempts exceed caps (
max_attempts, orqueued_max_attemptsfor still‑queued w/out provider id). - Enforce backoff between polls via
last_poll_atmetadata. - Promote forward‑only (e.g., queued -> sent) while avoiding regressions.
- Mark terminal on delivered/failed/undelivered. Mark
ambiguouswhen provider id missing after exhaustion.
Metadata counters (poll_attempts, last_poll_at, flags) are merged into metadata JSON for auditability.
13. Retry & Backoff
Retry::exponential() wraps critical provider API calls (send operations). Configured by retry.max_attempts & retry.backoff_start_ms. Delay doubles each attempt until max attempts reached. Exceptions escalate as TextoSendFailedException leading to MessageFailed event emission and (optionally) DB record with status failed.
14. Twilio Conversations & Content Templates
When TWILIO_USE_CONVERSATIONS=true, Texto:
- Lazily initializes Conversations sub‑client.
- Ensures (or creates) SMS / MMS Content Templates (friendly names configurable).
- Creates (or reuses) a Conversation per send (deduplicates participant collisions & reuses existing).
- Optionally attaches per‑conversation webhook (config
conversation_webhook_urlor metadata override). - Sends message using template variables (splitting long body into up to 5 × 100‑char chunks). Falls back to body variant if template fails.
Captured metadata includes: conversation_sid, conversation_reused, optional conversation_webhook_sid.
Disable by setting TWILIO_USE_CONVERSATIONS=false to revert to classic Messages API.
Credential‑Aware Binding (New)
As of 1.1.0 the package only binds Twilio (and Telnyx) low‑level API adapter singletons when their required credentials are present at boot time. This prevents accidental
TypeErrors in test environments where env vars are intentionally omitted. If you rely on resolving (e.g.) TwilioMessagingApiInterface from the container in tests,
ensure you either:
- Provide fake credentials via env (e.g.
TWILIO_ACCOUNT_SID=AC_TEST,TWILIO_AUTH_TOKEN=test), or - Manually bind a fake implementation in a test service provider.
The HTTP macro Http::twilio() is also credential‑aware; it omits Basic Auth when credentials are missing so generic tests can stub endpoints without failures.
Content Template Creation Robustness
Template creation logic now tolerates varied (mock) response shapes and will parse a sid from either a direct field or nested content record arrays.
No behavioral change is required for production usage; failures still fall back to body‑only send paths.
15. Extending / Custom Drivers
use Awaisjameel\Texto\Contracts\DriverManagerInterface; use Awaisjameel\Texto\Contracts\MessageSenderInterface; use Awaisjameel\Texto\ValueObjects\{PhoneNumber, SentMessageResult}; use Awaisjameel\Texto\Enums\{Driver, Direction, MessageStatus}; app(DriverManagerInterface::class)->extend('custom', function () { return new class implements MessageSenderInterface { public function send(PhoneNumber $to, string $body, ?PhoneNumber $from = null, array $mediaUrls = [], array $metadata = []): SentMessageResult { // ...call provider API... return new SentMessageResult( Driver::Twilio, // or introduce a new driver enum in a fork Direction::Sent, $to, $from, $body, $mediaUrls, $metadata, MessageStatus::Sent, 'custom-123' ); } }; });
Driver requirements:
- Implement
MessageSenderInterface::send()returningSentMessageResult. - Optionally expose
fetchStatus()for polling compatibility. - Throw
TextoSendFailedExceptionfor terminal send failures.
API Reference
Texto Facade
The main entry point for all messaging operations.
Texto::send(string $to, string $body, array $options = []): SentMessageResult
Send an SMS or MMS message.
Parameters:
$to(string): Recipient phone number (E.164 format or local format)$body(string): Message text content$options(array): Optional configuration
Options:
media_urls(array): Array of media URLs for MMSfrom(string): Override sender numberdriver(string): Override provider ('twilio' or 'telnyx')metadata(array): Custom metadata to store with messagedriver_config(array): Optional provider configuration snapshot (API keys, messaging profile IDs, etc.) that temporarily overridesconfig('texto.{driver}')for this send; primarily used by queued jobs or multi-tenant flows.
Note: When supplying
driver_config, remember that any secrets included will travel with the queued job payload and logs you emit. Use encrypted queues or other safeguards appropriate for your environment.
Returns: SentMessageResult object
Example:
$result = Texto::send('+15551234567', 'Hello!', [ 'media_urls' => ['https://example.com/image.jpg'], 'metadata' => ['campaign' => 'welcome'] ]);
Value Objects
PhoneNumber
Represents a validated, E.164 formatted phone number.
class PhoneNumber { public readonly string $e164; public static function fromString(string $raw, ?string $region = null): self }
Methods:
fromString(string $raw, ?string $region = null): Parse and validate phone number__toString(): Returns E.164 formatted number
SentMessageResult
Immutable result object returned after sending a message.
final class SentMessageResult implements Responsable, JsonSerializable { public readonly Driver $driver; public readonly Direction $direction; public readonly PhoneNumber $to; public readonly ?PhoneNumber $from; public readonly string $body; public readonly array $mediaUrls; public readonly array $metadata; public readonly MessageStatus $status; public readonly ?string $providerMessageId; public readonly ?string $errorCode; public function toArray(): array public function jsonSerialize(): array public function toResponse($request): JsonResponse }
WebhookProcessingResult
Result object for webhook processing.
final class WebhookProcessingResult { public readonly Driver $driver; public readonly Direction $direction; public readonly ?PhoneNumber $from; public readonly ?PhoneNumber $to; public readonly ?string $body; public readonly array $mediaUrls; public readonly array $metadata; public readonly ?string $providerMessageId; public readonly ?MessageStatus $status; public static function inbound(Driver $driver, PhoneNumber $from, PhoneNumber $to, ?string $body, array $media, array $metadata, ?string $providerMessageId = null): self public static function status(Driver $driver, ?string $providerMessageId, MessageStatus $status, array $metadata = []): self }
Enums
MessageStatus
Normalized message status values.
enum MessageStatus: string { case Queued = 'queued'; // Message queued for sending case Sending = 'sending'; // Message being sent case Sent = 'sent'; // Message sent successfully case Delivered = 'delivered'; // Message delivered to recipient case Received = 'received'; // Inbound message received case Failed = 'failed'; // Send failed permanently case Undelivered = 'undelivered'; // Message undelivered case Ambiguous = 'ambiguous'; // Status unknown after polling }
Driver
Available messaging providers.
enum Driver: string { case Twilio = 'twilio'; case Telnyx = 'telnyx'; }
Direction
Message direction.
enum Direction: string { case Sent = 'sent'; case Received = 'received'; }
Events
MessageSent
Fired when a message is successfully sent.
class MessageSent { public function __construct(public readonly SentMessageResult $result) {} }
MessageReceived
Fired when an inbound message is received via webhook.
class MessageReceived { public function __construct(public readonly WebhookProcessingResult $result) {} }
MessageStatusUpdated
Fired when a message status is updated via webhook or polling.
class MessageStatusUpdated { public function __construct(public readonly WebhookProcessingResult $result) {} }
MessageFailed
Fired when a message send attempt fails.
class MessageFailed { public function __construct( public readonly SentMessageResult $result, public readonly ?string $reason = null ) {} }
Exceptions
TextoException
Base exception for all Texto-related errors.
TextoSendFailedException
Thrown when message sending fails.
TextoWebhookValidationException
Thrown when webhook validation fails.
Interfaces
MessageSenderInterface
Contract for message sending implementations.
interface MessageSenderInterface { public function send(PhoneNumber $to, string $body, ?PhoneNumber $from = null, array $mediaUrls = [], array $metadata = []): SentMessageResult; }
MessageRepositoryInterface
Contract for message persistence.
interface MessageRepositoryInterface { public function storeSent(SentMessageResult $result): Model; public function storeInbound(WebhookProcessingResult $result): Model; public function storeStatus(WebhookProcessingResult $result): ?Model; public function updatePolledStatus(Message $message, MessageStatus $status, array $extraMetadata = []): Message; public function upgradeQueued(int $id, SentMessageResult $result): ?Model; }
DriverManagerInterface
Contract for driver management.
interface DriverManagerInterface { public function sender(?Driver $driver = null): MessageSenderInterface; public function extend(string $name, callable $factory): void; }
Console Commands
php artisan texto:install
Install and configure Texto.
php artisan texto:test-send {to} {body?} {--driver=}
Send a test message.
Parameters:
to: Recipient phone numberbody: Message body (default: "Test message")--driver: Override provider driver
17. Console Commands
| Command | Description |
|---|---|
texto:install |
Publish config + migration then run migrate. |
texto:test-send {to} {body?} |
Fire a manual test message (optional --driver=). |
texto |
Placeholder sample command. |
18. Testing, Fakes & Local Development
- Uses Pest & Orchestra Testbench for package isolation.
- Static analysis via PHPStan (
composer analyse). - Code style via Pint (
composer format). - Swap drivers with a fake:
app(\Awaisjameel\Texto\Contracts\DriverManagerInterface::class) ->extend('twilio', fn () => new \Awaisjameel\Texto\Drivers\FakeSender());
- Skip webhook signature validation during tests: set
TEXTO_TESTING_SKIP_WEBHOOK_VALIDATION=true.
Run full suite:
composer test
19. Architecture Overview
| Layer | Responsibility |
|---|---|
Texto facade/root |
Orchestrates send workflow, queue placeholder creation, events. |
DriverManager |
Resolves concrete sender implementation (built‑ins + extensions). |
Drivers (TwilioSender, TelnyxSender) |
Provider API invocation + provider‑specific metadata enrichment. |
StatusMapper |
Converts raw provider statuses / events to internal enum. |
EloquentMessageRepository |
Persistence & deterministic queued upgrade + polling updates. |
Jobs (SendMessageJob, StatusPollJob) |
Async send & periodic status reconciliation. |
| Webhook Handlers | Parse & validate inbound/status payloads per provider. |
Support Utilities (Retry, PollingParameterResolver, TwilioContentService) |
Cross‑cutting helpers. |
| Value Objects / Enums | Strongly typed domain primitives. |
Design goals: minimal public API surface (Texto::send), encapsulated provider variance, explicit lifecycle events, observability via logs + metadata.
20. Troubleshooting & FAQ
Common Issues
Q: Messages stuck in queued status
A: This usually indicates queue processing issues.
- Verify
TEXTO_QUEUE=truein your environment - Ensure a queue worker is running:
php artisan queue:work - Check queue connection configuration
- Review Laravel logs for job processing errors
- Enable status polling as fallback:
TEXTO_STATUS_POLL_ENABLED=true
Q: Webhook signature validation fails (401 errors) A: Signature validation ensures webhook authenticity.
- For Twilio: Verify
TWILIO_AUTH_TOKENmatches your Twilio console - Ensure webhook URLs in provider console exactly match your routes (including protocol)
- For local development, use ngrok or similar tunneling service
- Check that webhook URLs don't have trailing slashes or query parameters
Q: Twilio Conversations template creation warnings A: Template auto-provisioning may fail due to permissions.
- This is non-fatal; Texto falls back to direct message sending
- Check Twilio account has Content API permissions
- Verify
TWILIO_ACCOUNT_SIDandTWILIO_AUTH_TOKENare correct - Template creation warnings don't prevent message sending
Q: Telnyx cost/segment data missing A: Cost and segment information is only provided in specific response scenarios.
- Ensure your Telnyx API key has messaging permissions
- Cost data appears only when Telnyx includes it in API responses
- Segment counts depend on message content and provider logic
Q: Messages failing with provider errors A: Check provider account status and configuration.
- Verify API credentials are correct and active
- Ensure sender numbers are verified/purchased in provider console
- Check provider account has sufficient balance/credits
- Review message content for prohibited terms
Q: High memory usage with large message volumes A: Optimize for high-throughput scenarios.
- Enable queuing:
TEXTO_QUEUE=true - Use database queue driver for reliability
- Configure appropriate queue worker settings
- Monitor queue depth and processing rates
Status Definitions
Q: What does ambiguous status mean?
A: Messages reach ambiguous status when polling exhausts all attempts without determining final delivery status.
- Occurs when provider ID is missing and polling can't retrieve status
- Investigate upstream provider logs for root cause
- May indicate provider API issues or message filtering
Q: Difference between failed and undelivered?
A: These represent different failure modes:
failed: Immediate sending failure (invalid number, blocked content, etc.)undelivered: Message sent but delivery failed (phone off, full mailbox, etc.)
Configuration Issues
Q: How to disable message persistence?
A: Set TEXTO_STORE_MESSAGES=false in your environment.
- Events will still fire normally
SentMessageResultobjects are still returned- Useful for testing or when external logging is preferred
Q: Phone number validation too strict A: Adjust the default region for number parsing.
- Set
TEXTO_DEFAULT_REGIONto your primary market (e.g., 'GB' for UK) - This affects how local format numbers are interpreted
- E.164 format (+country code) always works regardless of region
Provider-Specific Issues
Q: Twilio rate limiting A: Twilio enforces sending limits based on account type.
- Free accounts: 100 messages/day
- Trial accounts: Limited sending
- Full accounts: Higher limits based on verification level
- Implement queuing and backoff strategies
Q: Telnyx webhook delays A: Telnyx webhooks may have higher latency than Twilio.
- Enable status polling for critical delivery tracking
- Configure appropriate polling intervals
- Monitor webhook delivery logs
Performance Tuning
Q: Optimizing for high volume A: Several configuration options for performance:
- Use Redis/database queues instead of sync processing
- Configure multiple queue workers
- Enable status polling with appropriate batch sizes
- Monitor database indexes on
texto_messagestable - Consider message archiving for old records
Q: Database performance with many messages
A: The texto_messages table can grow quickly.
- Add database indexes on frequently queried columns
- Implement message archiving/cleanup strategies
- Consider partitioning for very high volume
- Monitor query performance and optimize as needed
Development & Testing
Q: Testing without sending real messages A: Use the fake driver for testing:
app(DriverManagerInterface::class)->extend('twilio', fn() => new FakeSender());
- Skip webhook validation in tests:
TEXTO_TESTING_SKIP_WEBHOOK_VALIDATION=true - Use test credentials or mock HTTP responses
Q: Local development with webhooks A: Webhooks require public URLs for provider access.
- Use ngrok, localtunnel, or similar services
- Configure webhook URLs in provider console
- Consider webhook testing tools like webhook.site for debugging
Extending Texto
Q: Adding a new provider (e.g., Vonage) A: Implement the extension pattern:
app(DriverManagerInterface::class)->extend('vonage', function() { return new class implements MessageSenderInterface { public function send(PhoneNumber $to, string $body, ?PhoneNumber $from = null, array $mediaUrls = [], array $metadata = []): SentMessageResult { // Your implementation } }; });
- Consider contributing back via PR for official support
- Follow existing driver patterns for consistency
Q: Custom webhook handling A: Extend webhook handlers for custom logic:
- Create custom handler class implementing
WebhookHandlerInterface - Register in service provider or route configuration
- Handle provider-specific webhook formats
21. Performance Considerations & Best Practices
Database Optimization
For high-volume applications, optimize the texto_messages table:
-- Add performance indexes CREATE INDEX idx_texto_messages_status_created ON texto_messages (status, created_at); CREATE INDEX idx_texto_messages_provider_id ON texto_messages (provider_message_id); CREATE INDEX idx_texto_messages_from_to ON texto_messages (from_number, to_number); -- Consider partitioning for very high volume -- Partition by month for message archiving ALTER TABLE texto_messages PARTITION BY RANGE (YEAR(created_at)) ( PARTITION p2024 VALUES LESS THAN (2025), PARTITION p2025 VALUES LESS THAN (2026) );
Queue Configuration
For reliable message processing at scale:
// config/queue.php 'connections' => [ 'database' => [ 'driver' => 'database', 'table' => 'jobs', 'queue' => 'default', 'retry_after' => 90, // Increase for messaging jobs ], ],
Run multiple workers for high throughput:
# Multiple workers for parallel processing
php artisan queue:work --queue=texto-high,texto-normal --max-jobs=1000 --sleep=3
php artisan queue:work --queue=texto-bulk --max-jobs=500 --sleep=5
Monitoring & Alerting
Implement monitoring for critical messaging operations:
// Monitor queue health $pendingJobs = DB::table('jobs')->where('queue', 'like', 'texto%')->count(); if ($pendingJobs > 1000) { Log::warning('High texto queue backlog', ['count' => $pendingJobs]); } // Monitor failure rates $failureRate = Message::where('status', 'failed') ->where('created_at', '>', now()->subHour()) ->count() / Message::where('created_at', '>', now()->subHour())->count(); if ($failureRate > 0.1) { // 10% failure rate // Alert or take action }
Cost Optimization
Track and optimize messaging costs:
// Analyze costs by provider and campaign $costs = Message::selectRaw(' driver, SUM(cost_estimate) as total_cost, COUNT(*) as message_count, AVG(cost_estimate) as avg_cost ') ->whereNotNull('cost_estimate') ->where('created_at', '>', now()->subMonth()) ->groupBy('driver') ->get(); // Implement cost thresholds if ($costs->sum('total_cost') > 1000) { // Monthly budget // Send alert or implement throttling }
Security Best Practices
Secure your messaging infrastructure:
// Use environment variables for secrets // Never commit API keys to version control // Implement rate limiting per user/phone RateLimiter::for('texto-send', function (Request $request) { return Limit::perMinute(10)->by($request->user()->id); }); // Validate phone numbers strictly $phone = PhoneNumber::fromString($request->phone, 'US'); // Specify region if (!$phone) { throw new InvalidPhoneNumberException(); }
Error Handling & Resilience
Implement comprehensive error handling:
try { $result = Texto::send($phone, $message, $options); } catch (TextoSendFailedException $e) { // Log detailed error Log::error('Message send failed', [ 'phone' => $phone, 'error' => $e->getMessage(), 'driver' => config('texto.driver') ]); // Implement fallback logic if (config('texto.driver') === 'twilio') { // Try Telnyx as fallback $result = Texto::send($phone, $message, ['driver' => 'telnyx'] + $options); } // Notify user or take alternative action }
Testing Strategies
Comprehensive testing approach:
// Unit tests for drivers class TwilioSenderTest extends TestCase { public function test_sends_message_successfully() { // Mock Twilio client $this->mock(TwilioClient::class, function ($mock) { $mock->shouldReceive('messages->create') ->once() ->andReturn((object)['sid' => 'SM123']); }); $result = app(TwilioSender::class)->send( PhoneNumber::fromString('+15551234567'), 'Test message' ); $this->assertEquals(MessageStatus::Sent, $result->status); } } // Integration tests with fake driver class MessagingIntegrationTest extends TestCase { protected function setUp(): void { parent::setUp(); // Use fake driver for integration tests app(DriverManagerInterface::class)->extend('twilio', fn() => new FakeSender()); } }
Scaling Considerations
For enterprise-level messaging:
- Horizontal Scaling: Deploy across multiple servers with shared queue
- Database Sharding: Split message storage across multiple databases
- CDN for Media: Use CDNs for MMS media to reduce bandwidth
- Provider Redundancy: Implement multi-provider failover logic
- Caching: Cache frequently used phone number validations
- Async Processing: Always use queues for production deployments
Compliance & Data Protection
Handle sensitive messaging data appropriately:
// Implement data retention policies Message::where('created_at', '<', now()->subMonths(6)) ->whereIn('status', ['delivered', 'failed']) ->delete(); // Encrypt sensitive metadata $message->metadata = encrypt(json_encode([ 'ssn' => $sensitiveData, // Encrypted storage 'campaign' => 'public_data' ])); // Implement audit logging Log::channel('messaging-audit')->info('Message sent', [ 'id' => $message->id, 'to' => $message->to_number, // Log for compliance 'timestamp' => now(), 'user_id' => auth()->id() ]);
22. Roadmap
Planned Features
- Multi-provider Routing: Intelligent load balancing and failover across providers
- Additional Providers: Official support for MessageBird, Vonage, AWS SNS, and others
- Template Engine: Unified templating system for all providers
- Bulk Operations: Batch sending with progress tracking and error aggregation
- Advanced Analytics: Built-in reporting and analytics dashboard
- Webhook Enhancements: Improved webhook signature verification and replay protection
- Rate Limiting: Provider-aware rate limiting and throttling
- Geographic Routing: Route messages via local providers for cost optimization
Community Contributions
We welcome contributions! Areas of particular interest:
- New provider implementations
- Performance optimizations
- Enhanced testing utilities
- Documentation improvements
- Integration packages for popular frameworks
Version Compatibility
| Texto Version | Laravel Version | PHP Version | Status |
|---|---|---|---|
| 1.x | 10.0 - 12.x | 7.4 - 8.2 | Active |
Migration Guide
Upgrading from 1.0 to 1.1
No breaking changes. New features:
- Enhanced status polling with configurable backoff
- Improved error handling and logging
- Additional metadata fields for cost tracking
Future Breaking Changes (2.0)
Planned improvements that may require migration:
- Updated configuration structure
- New required environment variables
- Changes to event payloads
- Database schema updates
Monitor release notes for detailed migration instructions.
23. Contributing
We welcome contributions from the community! Here's how to get involved:
Development Setup
# Fork and clone the repository git clone https://github.com/your-username/texto.git cd texto # Install dependencies composer install # Copy environment file and configure cp .env.example .env # Add your Twilio/Telnyx test credentials # Run tests composer test # Run static analysis composer analyse # Format code composer format
Contribution Guidelines
-
Open an Issue First: For significant changes, open a descriptive issue to discuss the proposed changes.
-
Code Quality: All contributions must pass quality checks:
composer analyse # PHPStan static analysis composer format # Laravel Pint code formatting composer test # Pest test suite
-
Testing: Add tests for new features and bug fixes:
- Unit tests for classes and methods
- Integration tests for full workflows
- Use the
FakeSenderfor testing without external APIs
-
Documentation: Update documentation for user-visible changes:
- README.md for new features and usage examples
- Inline code documentation (PHPDoc)
- CHANGELOG.md for version history
-
Type Safety: Keep new public APIs strongly typed using PHP 7.4+ features.
Code Style
Follow Laravel's coding standards with Pint configuration:
// Good: Use type hints and return types public function send(PhoneNumber $to, string $body): SentMessageResult // Good: Use enums for fixed values public function __construct(public readonly MessageStatus $status) // Good: Comprehensive PHPDoc /** * Send an SMS/MMS message using the active driver. * * @param string $to E.164 formatted recipient number * @param string $body Message body text * @param array{media_urls?:string[], metadata?:array} $options */
Testing Strategy
// Unit test example test('phone number validation', function () { $phone = PhoneNumber::fromString('+15551234567'); expect($phone->e164)->toBe('+15551234567'); }); // Integration test example test('message sending workflow', function () { // Use fake driver to avoid external calls app(DriverManagerInterface::class)->extend('twilio', fn() => new FakeSender()); $result = Texto::send('+15551234567', 'Test message'); expect($result->status)->toBe(MessageStatus::Sent); expect($result->providerMessageId)->toBeString(); });
Pull Request Process
-
Branch Naming: Use descriptive branch names:
feature/add-vonage-driverfix/webhook-validation-bugdocs/improve-api-reference
-
Commit Messages: Follow conventional commits:
feat: add Vonage driver supportfix: resolve webhook signature validationdocs: update API reference section
-
PR Description: Include:
- Clear description of changes
- Screenshots for UI changes (if applicable)
- Test coverage information
- Breaking changes (if any)
-
Review Process: All PRs require review and must pass CI checks.
Areas for Contribution
High Priority:
- New provider implementations (MessageBird, Vonage, AWS SNS)
- Performance optimizations for high-volume sending
- Enhanced webhook security features
Medium Priority:
- Additional testing utilities and helpers
- Documentation improvements and translations
- Integration packages for popular Laravel packages
Good for Beginners:
- Bug fixes and small improvements
- Additional code examples and tutorials
- Test coverage improvements
Community Support
- Discussions: Use GitHub Discussions for questions and ideas
- Issues: Report bugs and request features via GitHub Issues
- Discord/Slack: Join our community chat for real-time help
Recognition
Contributors are recognized in:
- CHANGELOG.md for significant contributions
- GitHub's contributor insights
- Social media mentions for major features
Thank you for contributing to Texto! 🎉
23. Security Policy
Report vulnerabilities privately via GitHub Security Advisories. Do not disclose publicly until patched. Avoid sharing live credentials or full raw webhook payloads containing PII in issues.
24. Migration Guide
Upgrading Versions
From 1.0.x to 1.1.x
No breaking changes. New features include:
- Enhanced status polling with configurable backoff strategies
- Improved error handling and structured logging
- Additional metadata fields for cost tracking
- Better webhook validation and security
Migration Steps:
- Update package:
composer update awaisjameel/texto - Review new configuration options in
config/texto.php - Update environment variables if needed
- Test webhook endpoints with new validation
From 0.x to 1.x
Breaking changes in the 1.0 release:
Configuration Changes:
// Old (0.x) 'driver' => env('TEXTO_DRIVER', 'twilio'), // New (1.x) - same, but additional options available 'driver' => env('TEXTO_DRIVER', 'twilio'), 'store_messages' => env('TEXTO_STORE_MESSAGES', true), 'queue' => env('TEXTO_QUEUE', false),
API Changes:
// Old (0.x) Texto::send('+15551234567', 'Hello'); // New (1.x) - same API, enhanced return type $result = Texto::send('+15551234567', 'Hello'); $result->status; // Now returns MessageStatus enum
Migration Steps:
- Backup your database
- Update to 1.x:
composer update awaisjameel/texto - Run
php artisan texto:installto update config and migrations - Update any code using status strings to use
MessageStatusenums - Test thoroughly in staging environment
Environment Variable Changes
| Old Variable | New Variable | Notes |
|---|---|---|
| - | TEXTO_STORE_MESSAGES |
Control message persistence |
| - | TEXTO_QUEUE |
Enable async processing |
| - | TEXTO_WEBHOOK_SECRET |
Shared secret for webhook auth |
| - | TEXTO_STATUS_POLL_ENABLED |
Enable status polling fallback |
Database Schema Changes
Version 1.x adds new columns to texto_messages:
-- New columns in 1.x ALTER TABLE texto_messages ADD COLUMN segments_count INT NULL; ALTER TABLE texto_messages ADD COLUMN cost_estimate DECIMAL(10,4) NULL; ALTER TABLE texto_messages ADD COLUMN status_updated_at TIMESTAMP NULL;
These are nullable and backward compatible.
Webhook URL Changes
Webhook routes remain the same but include enhanced validation:
/texto/webhook/twilio- Twilio webhooks (inbound + status)/texto/webhook/telnyx- Telnyx webhooks (inbound + status)
Ensure your provider console webhook URLs match exactly.
Testing Changes
Update your tests to use the new FakeSender:
// Old approach // Custom mock setup // New approach app(DriverManagerInterface::class)->extend('twilio', fn() => new FakeSender());
25. License & Credits
License
Released under the MIT License. See LICENSE.md for details.
Credits
Created by: awaisjameel
Inspiration & Thanks:
- Spatie Laravel Package Tools - Package skeleton
- Laravel OSS Ecosystem - Best practices and patterns
- Twilio & Telnyx Developer Communities - API insights
Contributors
We'd like to thank all contributors who have helped make Texto better:
Sponsors
Support Texto's development:
Related Projects
- Laravel Notification Channels - Alternative notification approach
- Twilio PHP SDK - Official Twilio library
- Telnyx Messaging API Reference - REST endpoints used by Texto's Telnyx driver
Made with ❤️ for the Laravel community
Quick Reference Cheat Sheet
// Basic SMS $result = Texto::send('+15551234567', 'Hello World!'); // MMS with media $result = Texto::send('+15551234567', 'Check this out!', [ 'media_urls' => ['https://example.com/image.jpg'] ]); // Override provider per message $result = Texto::send('+15551234567', 'Via Telnyx', [ 'driver' => 'telnyx' ]); // Custom sender and metadata $result = Texto::send('+15551234567', 'Promotional message', [ 'from' => '+15550009999', 'metadata' => ['campaign' => 'spring_sale', 'priority' => 'high'] ]); // Async processing (when TEXTO_QUEUE=true) $result = Texto::send('+15551234567', 'Queued message'); // $result->status === MessageStatus::Queued // Event listeners Event::listen(MessageSent::class, function ($event) { Log::info('Message sent', ['id' => $event->result->providerMessageId]); });
Support & Community
- 📖 Documentation: You're reading it! Check the GitHub repository for the latest updates
- 🐛 Bug Reports: Open an issue on GitHub
- 💡 Feature Requests: Start a discussion on GitHub
- 💬 Community Chat: Join our Discord server for real-time help
- ⭐ Show Support: Star the repo if Texto saves you time and effort!
Built with ❤️ for the Laravel community by awaisjameel