thinwrap/notifications
Composer 安装命令:
composer require thinwrap/notifications
包简介
Unified PHP facade over 35 notification providers across Email / SMS / Push / Chat — baseline-coverage discipline.
关键字:
README 文档
README
Unified PHP facade over 35 notification providers across Email (10), SMS (10), Push (6), and Chat (9). Stateless. Zero vendor SDKs. Bring your own PSR-18 HTTP client.
Install
composer require thinwrap/notifications
Requires PHP ≥8.2. PSR-18 HTTP client + PSR-17 factories are auto-discovered via
php-http/discovery — if you don't already have one installed:
composer require guzzlehttp/guzzle guzzlehttp/psr7
End-to-end example — 2-minute send
use Thinwrap\Notifications\Email; use Thinwrap\Notifications\Enum\NotificationProviderId; use Thinwrap\Notifications\Providers\Sendgrid\SendgridConfig; use Thinwrap\Notifications\DTO\Email\EmailSendInput; use Thinwrap\Notifications\Exception\ConnectorError; $email = new Email( NotificationProviderId::Sendgrid, new SendgridConfig(apiKey: getenv('SG_KEY')), ); try { $result = $email->send(new EmailSendInput( to: 'recipient@example.com', from: 'sender@example.com', subject: 'Hello from Thinwrap', text: 'A short plain-text body.', )); echo $result->success; // bool echo $result->providerMessageId; // vendor message id, if returned } catch (ConnectorError $e) { error_log($e->providerCode->value . ': ' . ($e->providerMessage ?? '')); }
Switching providers
Change the NotificationProviderId case and config class; the EmailSendInput /
SmsSendInput / PushSendInput / ChatSendInput shape stays identical.
use Thinwrap\Notifications\Sms; use Thinwrap\Notifications\Providers\Twilio\TwilioConfig; use Thinwrap\Notifications\Providers\Vonage\VonageConfig; use Thinwrap\Notifications\DTO\Sms\SmsSendInput; $twilio = new Sms(NotificationProviderId::Twilio, new TwilioConfig( accountSid: getenv('TWILIO_SID'), authToken: getenv('TWILIO_TOKEN'), )); $vonage = new Sms(NotificationProviderId::Vonage, new VonageConfig( apiKey: getenv('VONAGE_KEY'), apiSecret: getenv('VONAGE_SECRET'), )); $sameInput = new SmsSendInput(to: '+14155550100', from: '+14155550199', body: 'Hello'); $twilio->send($sameInput); $vonage->send($sameInput);
Bring your own PSR-18 client
Inject any PSR-18 client through the client parameter on the *Config DTO — useful
for tracing, mocking, or proxying through symfony/http-client.
composer require guzzlehttp/guzzle
use GuzzleHttp\Client; use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; $tracingClient = new class(new Client()) implements ClientInterface { public function __construct(private Client $inner) {} public function sendRequest(RequestInterface $req): ResponseInterface { error_log('→ ' . $req->getMethod() . ' ' . (string) $req->getUri()); return $this->inner->sendRequest($req); } }; $email = new Email( NotificationProviderId::Sendgrid, new SendgridConfig(apiKey: getenv('SG_KEY'), from: 'noreply@example.com'), $tracingClient, // ?ClientInterface — third constructor arg );
php-http/discovery auto-detects an installed PSR-18 client (Guzzle, Symfony HttpClient,
Buzz, etc.) when no $client is passed to the facade constructor.
The wrapper holds no state — no token cache, no connection pool, no retry buffer. The
optional tokenCache hook on FcmConfig and ApnsConfig (the only two connectors
with short-lived signed tokens) lets the consumer amortize signing cost; the hook owns
the state, not the wrapper. See
src/Providers/Fcm/README.md and
src/Providers/Apns/README.md for hook shape.
Error handling
Every failure surfaces as ConnectorError with a typed ProviderCode. Compose your own
retry strategy from $e->providerCode and $e->cause (which carries the raw
Retry-After header where the vendor sets one). The wrapper performs no automatic retry.
use Thinwrap\Notifications\Exception\ConnectorError; use Thinwrap\Notifications\Enum\ProviderCode; try { $email->send($input); } catch (ConnectorError $e) { match ($e->providerCode) { ProviderCode::RateLimited => /* respect Retry-After in $e->cause['retryAfter'] */ null, ProviderCode::AuthFailed => /* rotate credentials */ null, ProviderCode::InvalidRequest => /* fix payload */ null, ProviderCode::InvalidRecipient => /* bad destination address */ null, ProviderCode::ProviderUnavailable => /* transient 5xx — your retry strategy */ null, ProviderCode::Unknown => /* fallback */ null, }; }
The 6 ProviderCode cases are byte-exact across the TypeScript and PHP packages.
ConnectorError extends \RuntimeException — catch (\Throwable $e) works too.
cause shape
$e->cause is a uniform array across every connector:
[
'raw' => mixed, // the parsed vendor error body (or transport-failure detail), null when absent
'retryAfter' => string|int|null, // the RAW Retry-After value (header string, or body int for Telegram/Discord) — never normalized
'retryAfterSeconds' => int|null, // the PARSED Retry-After in seconds (null when no Retry-After present)
]
There is no top-level structured retryAfterSeconds field on ConnectorError — the
wrapper performs no retry. The parsed seconds live inside $e->cause['retryAfterSeconds']
and are also echoed in $e->providerMessage (… (Retry-After: N seconds)). retryAfter
carries the raw value verbatim (string|int|null) and is intentionally not normalized.
Discord additionally exposes its body-sourced retryAfterBody (float) in cause.
Transport-layer failures (the PSR-18 client throwing before any HTTP response) are
surfaced with a generic providerMessage (Upstream transport error) and only the
exception class name in cause — never the raw client-exception message. That
message can embed the full request URL, which for some providers (Telegram bot token in
the path; Slack/Discord/Google Chat/Mattermost/MS Teams webhook URLs) is the credential.
Do not log the raw HTTP-client exception directly for the same reason.
_passthrough escape valve
When the normalized input doesn't expose a vendor-specific field, forward arbitrary keys
via the _passthrough parameter on the send input. Body merges deep, headers and
query merge shallow, consumer values win on conflict. Keys are forwarded verbatim.
$email->send(new EmailSendInput( to: 'recipient@example.com', from: 'sender@example.com', subject: 'Hi', text: 'Hello', _passthrough: [ 'body' => [ // SendGrid-specific — forwarded into v3/mail/send body verbatim 'dynamic_template_data' => ['firstName' => 'Alice'], 'mail_settings' => ['sandbox_mode' => ['enable' => true]], ], ], ));
Each per-connector README documents vendor-specific _passthrough examples.
Bring your own connector
When _passthrough isn't enough — the provider isn't shipped at all — implement the
channel's Contract\*ConnectorInterface (a single send() method over the normalized
DTOs) and build the facade with fromConnector(). You keep the normalized input/result
shapes and the uniform ConnectorError path; only the wire call is yours.
use Thinwrap\Notifications\Push; use Thinwrap\Notifications\Contract\PushConnectorInterface; use Thinwrap\Notifications\DTO\Push\PushSendInput; use Thinwrap\Notifications\DTO\Push\PushSendResult; use Thinwrap\Notifications\DTO\Push\PushStatus; final class NtfyPushConnector implements PushConnectorInterface { public function __construct(private \Psr\Http\Client\ClientInterface $client) {} public function send(PushSendInput $input): PushSendResult { // your wire call — e.g. POST https://ntfy.sh/{$input->to} $response = $this->client->sendRequest(/* ... */); return new PushSendResult( success: true, status: PushStatus::Sent, providerMessageId: null, raw: (string) $response->getBody(), ); } } $push = Push::fromConnector(new NtfyPushConnector($client)); $push->send(new PushSendInput(to: 'deploys', title: 'Deploy', body: 'v1.0 is live'));
Throw ConnectorError from send() for hard failures so consumers keep a single
error-handling path; return success: false for HTTP-2xx-but-rejected soft-rejects,
matching the built-in connectors.
Language constraints (PHP)
- PHP 8.2 minimum; CI matrix runs on 8.2, 8.3, and 8.4 (Linux only at v1.0).
- PSR-18 HTTP client is BYO —
php-http/discoveryauto-detects Guzzle, Symfony HttpClient, Buzz, etc., when no$clientis passed to the facade constructor. - Zero runtime dependencies beyond
psr/http-client+psr/http-factory+psr/http-message+php-http/discovery. - 35 providers across 4 channels — same normalized facade surface as the TypeScript
sibling
@thinwrap/notifications. Novu drop-in compatibility (IEmailProvider/ISmsProvider/IPushProvider/IChatProvider) is a TypeScript-only feature; the PHP package does not implement Novu's provider interfaces.
Per-connector documentation
Each per-connector README documents auth, endpoints (regional / sandbox), narrowed input
augmentations, error-code mappings, and _passthrough examples.
Email (10)
| Provider | README |
|---|---|
ses |
src/Providers/Ses/README.md |
resend |
src/Providers/Resend/README.md |
mailgun |
src/Providers/Mailgun/README.md |
sendgrid |
src/Providers/Sendgrid/README.md |
postmark |
src/Providers/Postmark/README.md |
mailersend |
src/Providers/Mailersend/README.md |
mailtrap |
src/Providers/Mailtrap/README.md |
brevo |
src/Providers/Brevo/README.md |
sparkpost |
src/Providers/Sparkpost/README.md |
scaleway |
src/Providers/Scaleway/README.md |
SMS (10)
| Provider | README |
|---|---|
vonage |
src/Providers/Vonage/README.md |
twilio |
src/Providers/Twilio/README.md |
plivo |
src/Providers/Plivo/README.md |
sns |
src/Providers/Sns/README.md |
sinch |
src/Providers/Sinch/README.md |
telnyx |
src/Providers/Telnyx/README.md |
infobip |
src/Providers/Infobip/README.md |
messagebird |
src/Providers/Messagebird/README.md |
textmagic |
src/Providers/Textmagic/README.md |
d7networks |
src/Providers/D7networks/README.md |
Push (6)
| Provider | README |
|---|---|
fcm |
src/Providers/Fcm/README.md |
expo |
src/Providers/Expo/README.md |
apns |
src/Providers/Apns/README.md |
one-signal |
src/Providers/OneSignal/README.md |
pusher-beams |
src/Providers/PusherBeams/README.md |
wonderpush |
src/Providers/Wonderpush/README.md |
Chat (9)
| Provider | README |
|---|---|
telegram |
src/Providers/Telegram/README.md |
slack |
src/Providers/Slack/README.md |
whatsapp-business |
src/Providers/WhatsappBusiness/README.md |
discord |
src/Providers/Discord/README.md |
msteams |
src/Providers/Msteams/README.md |
google-chat |
src/Providers/GoogleChat/README.md |
mattermost |
src/Providers/Mattermost/README.md |
rocket-chat |
src/Providers/RocketChat/README.md |
line |
src/Providers/Line/README.md |
Baseline-coverage discipline
The unified facade surface includes only features ≥90% of providers in each channel
support natively. Sub-baseline fields are accessible via per-provider narrowed input
DTOs (<Provider>NarrowedInput) and the _passthrough escape hatch.
Migrating
From a vendor PHP SDK
// Before — twilio/sdk $twilio = new \Twilio\Rest\Client($sid, $token); $twilio->messages->create('+14155550100', ['from' => '+14155550199', 'body' => 'Hi']); // After use Thinwrap\Notifications\Sms; use Thinwrap\Notifications\Providers\Twilio\TwilioConfig; use Thinwrap\Notifications\DTO\Sms\SmsSendInput; $sms = new Sms(NotificationProviderId::Twilio, new TwilioConfig(accountSid: $sid, authToken: $token)); $sms->send(new SmsSendInput(to: '+14155550100', from: '+14155550199', body: 'Hi'));
// Before — sendgrid/sendgrid-php $sg = new \SendGrid(getenv('SG_KEY')); $mail = new \SendGrid\Mail\Mail(); $mail->setFrom('from@example.com'); $mail->addTo('to@example.com'); $mail->setSubject('Hi'); $mail->addContent('text/plain', 'Hello'); $sg->send($mail); // After $email = new Email(NotificationProviderId::Sendgrid, new SendgridConfig(apiKey: getenv('SG_KEY'))); $email->send(new EmailSendInput(to: 'to@example.com', from: 'from@example.com', subject: 'Hi', text: 'Hello'));
Vendor-SDK conveniences (auto-retry, telemetry, idempotency-key generation) are intentionally absent — compose your own.
From hand-rolled HTTP / Guzzle
If you've been hand-rolling vendor HTTP calls with Guzzle, the facade collapses the
boilerplate to one line per call. Error handling and retry composition stay yours; the
PSR-18 client passes through unchanged via the facade constructor's $client argument.
From a previous Thinwrap PHP version
Not applicable at v1.0; thinwrap/notifications has not previously published. Forward
looking: when v2.0 ships with breaking changes, this section will carry the v1→v2
recipe.
For AI agents and contributors
.ai/guidelines.md— contributor entry point: how to add a connector..ai/ARCHITECTURE.md— facade-dispatch-base pattern + invariants..ai/CONVENTIONS.md— naming, file layout, test patterns.
AI agents working with this package should consult .ai/guidelines.md first.
Security
See SECURITY.md for private vulnerability disclosure. Releases are
cosign-signed via GitHub Actions OIDC (no static signing keys); maintainer accounts
require two-factor authentication (TOTP via an authenticator app) on GitHub. Packagist consumes the package via webhook
auto-sync — no long-lived Packagist API token is stored anywhere.
License
MIT — see LICENSE.
Contributing
See CONTRIBUTING.md.
统计信息
- 总下载量: 0
- 月度下载量: 0
- 日度下载量: 0
- 收藏数: 0
- 点击次数: 3
- 依赖项目数: 0
- 推荐数: 0
其他信息
- 授权协议: MIT
- 更新时间: 2026-07-04