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.

Maintainers

Package info

github.com/orboto/orboto-mail-php

Homepage

pkg:composer/orboto/mail

Statistics

Installs: 5

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

0.5.0 2026-05-19 04:49 UTC

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