orboto / mail
Official PHP SDK for the Orboto Mail Service. Drop-in transactional-mail client with auto-quota-tracking, retry-with-backoff, quota lifecycle events, typed DTOs, and a Laravel Service-Provider out of the box. EU-hosted, GDPR-compliant.
Requires
- php: ^8.2
- php-http/discovery: ^1.19
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.1 || ^2.0
Requires (Dev)
- guzzlehttp/guzzle: ^7.8
- guzzlehttp/psr7: ^2.7
- illuminate/contracts: ^11.0
- illuminate/notifications: ^11.0
- phpstan/phpstan: ^1.11
- phpunit/phpunit: ^11.0
Suggests
- guzzlehttp/guzzle: Default PSR-18 HTTP client (any PSR-18 implementation works)
- illuminate/support: Required when using the Laravel Service-Provider (Laravel 11+)
This package is auto-updated.
Last update: 2026-05-19 04:49:39 UTC
README
Official PHP SDK for the Orboto Mail Service. Drop-in transactional-mail client with auto-quota-tracking, retry-with-backoff, quota lifecycle events, typed DTOs, and a Laravel Service-Provider out of the box. EU-hosted, GDPR-compliant.
Same API surface as the TypeScript SDK @orboto/mail — release versions stay in lockstep.
Install
composer require orboto/mail
Requires PHP 8.2+ and any PSR-18 HTTP client (Guzzle is auto-discovered if present; see HTTP client below if you want to plug in a different one).
Quick start
use Orboto\Mail\OrbotoMail; $mail = new OrbotoMail(['apiKey' => $_ENV['OMS_API_KEY']]); $result = $mail->send([ 'from' => 'noreply@acme.orbo.to', 'to' => 'user@example.com', 'subject' => 'Welcome', 'html' => '<h1>Welcome!</h1>', ]); echo $result->messageId; // SES-issued message-id echo $result->status; // 'queued' at success-time echo $result->remainingQuota->percentUsed; // 0.0 .. 1.x
Get an API key at account.orboto.io/mail/api-keys.
Send a batch
$batch = $mail->sendBatch([ 'messages' => [ ['from' => 'a@x.com', 'to' => 'u1@example.com', 'subject' => 'Hi 1', 'text' => 'Hello 1'], ['from' => 'a@x.com', 'to' => 'u2@example.com', 'subject' => 'Hi 2', 'text' => 'Hello 2'], ], ]); foreach ($batch->results as $item) { if (!$item->ok) { error_log("Send #{$item->index} failed: {$item->reason}"); } } echo "{$batch->summary->queued} queued, {$batch->summary->suppressed} suppressed.";
Capped at 100 messages per call. Per-item processing — the call as a whole always returns 200; inspect $batch->summary and per-item ok flags to decide whether to retry indices.
Templates
$tpl = $mail->templates->create([ 'name' => 'welcome', 'subject' => 'Welcome to {{company}}', 'bodyHtml' => '<p>Hi {{name}}!</p>', 'variablesSchema' => [ 'type' => 'object', 'required' => ['name', 'company'], 'properties' => [ 'name' => ['type' => 'string'], 'company' => ['type' => 'string'], ], ], ]); $mail->sendTemplate([ 'templateId' => $tpl->id, 'to' => 'user@example.com', 'variables' => ['name' => 'Ada', 'company' => 'ACME'], ]);
Quota lifecycle events
use Orboto\Mail\Dto\QuotaState; use Orboto\Mail\Dto\ConnectionRevokedEvent; $mail->on('quota-warning', fn (QuotaState $q) => error_log("80%: {$q->current}/{$q->total}")); $mail->on('quota-low', fn (QuotaState $q) => error_log("95%: {$q->current}/{$q->total}")); $mail->on('quota-exhausted', fn (QuotaState $q) => error_log("100%: tier cap hit")); $mail->on('connection-revoked', fn (ConnectionRevokedEvent $e) => error_log("disabled: {$e->message}"));
Each threshold event fires once per quota-reset period — a customer who lingers at 81 % doesn't get a quota-warning for every send.
Errors
Every non-2xx response surfaces as a typed exception:
use Orboto\Mail\Exception\OrbotoMailException; use Orboto\Mail\Exception\QuotaExhaustedException; use Orboto\Mail\Exception\SuppressedRecipientException; use Orboto\Mail\Exception\ConnectionRevokedException; try { $mail->send([...]); } catch (QuotaExhaustedException $e) { // Render "upgrade your plan" — $e->getRemainingQuota() has the snapshot } catch (SuppressedRecipientException $e) { // Skip + log — recipient on suppression list } catch (ConnectionRevokedException $e) { // Disable the integration UX } catch (OrbotoMailException $e) { // Generic fallback if ($e->isRetryable()) { /* server-side transient */ } }
| HTTP status | reason value | Exception |
|---|---|---|
| 400 | recipient_suppressed |
SuppressedRecipientException |
| 400 | from_domain_not_authorized |
OrbotoMailException — add domain at account.orboto.io/mail/sender-domains |
| 401 | connection_revoked |
ConnectionRevokedException |
| 402 | base_quota / quota_exhausted_daily / overage_cap / etc. |
QuotaExhaustedException |
| 502 / 503 / 504 | any | OrbotoMailException (auto-retried with backoff) |
Configuration
Constructor options (all optional except apiKey):
$mail = new OrbotoMail([ 'apiKey' => 'oms_live_xxx', // or set OMS_API_KEY env var 'baseUrl' => 'https://mail.orboto.io/api', // default 'timeout' => 10.0, // seconds per request 'maxRetries' => 3, // transient-error retry budget 'httpClient' => $myPsr18Client, // override the auto-discovered HTTP client 'requestFactory' => $myPsr17RequestFactory, 'streamFactory' => $myPsr17StreamFactory, ]);
Environment variables (used when the corresponding option is omitted):
| Variable | Default |
|---|---|
OMS_API_KEY |
(required) |
OMS_BASE_URL |
https://mail.orboto.io/api |
HTTP client
The SDK uses PSR-18 + PSR-17. By default it auto-discovers an installed client via php-http/discovery. If you have Guzzle installed (composer require guzzlehttp/guzzle) it picks Guzzle. If you prefer Symfony's HTTP client or anything else PSR-18-compatible, install that package and discovery routes to it — or inject explicitly via the httpClient / requestFactory / streamFactory options.
Laravel
The SDK ships a Service-Provider + Facade + Notification Channel for Laravel 11+. Auto-discovery wires them in on composer require.
Publish the config:
php artisan vendor:publish --tag=orboto-mail-config
Then in code:
use Orboto\Mail\Laravel\OrbotoMailFacade as OrbotoMail; OrbotoMail::send([ 'from' => config('mail.from.address'), 'to' => $user->email, 'subject' => 'Welcome', 'html' => view('mail.welcome', ['user' => $user])->render(), ]);
Or as a Notification Channel:
use Orboto\Mail\Laravel\OrbotoMailChannel; class Welcome extends Notification { public function via($notifiable): array { return [OrbotoMailChannel::class]; } public function toOrbotoMail($notifiable): array { return [ 'from' => 'noreply@acme.orbo.to', 'to' => $notifiable->email, 'subject' => 'Welcome to ACME', 'html' => view('mail.welcome', ['user' => $notifiable])->render(), ]; } }
API reference
All resources are accessible as public properties on the OrbotoMail instance:
| Resource | Methods |
|---|---|
$mail->suppression |
check, add, remove, list |
$mail->templates |
list, get, create, update, remove |
$mail->webhooks |
list, get, create, update, remove, rotateSecret |
$mail->sends |
list, get |
$mail->inbound |
list, get |
$mail->senderDomains |
cloudflareDetect, cloudflareAutoSetup |
$mail->apiKeys |
list, get, create, revoke, rotate |
Plus top-level send methods: send, sendBatch, sendTemplate, getQuota.
Full DTO list in src/Dto/. Every type is a readonly class with a fromArray() constructor for round-tripping over the wire.
Versioning
Releases stay in lockstep with the TypeScript SDK and the API backend — same vX.Y.Z tag bumps @orboto/mail (npm) + orboto/mail (Packagist) + the API container together. Patch releases are pure bug-fix / docs; minor releases add API surface; major releases imply a breaking change to the public method signatures (none yet).
License
MIT (see LICENSE.md). The PHP SDK is open-source; the OMS API backend it talks to is under the Sustainable Use License (see the repo root).
Support
- Docs: https://mail.orboto.io
- Customer portal: https://account.orboto.io/mail
- Issues: https://github.com/orboto/orboto-mail-php/issues