esanj / notification-client
Laravel client package for Esanj Notification Microservice
Requires
- php: ^8.2|^8.3|^8.4
- ext-json: *
- ext-mbstring: *
- guzzlehttp/guzzle: ^7.5
- illuminate/cache: ^12.0|^13.0
- illuminate/contracts: ^12.0|^13.0
- illuminate/log: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- nesbot/carbon: ^2.72|^3.0
- psr/http-message: ^1.1|^2.0
- psr/log: ^1.0|^2.0|^3.0
Requires (Dev)
- orchestra/testbench: ^10.0|^11.0
- phpunit/phpunit: ^11.0|^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Laravel client package for the Esanj Notification Microservice. Handles OAuth 2.0 token acquisition, automatic caching, token refresh, and retry logic out of the box.
Supports: Laravel 12 & 13 · PHP 8.2+
Installation
composer require esanj/notification-client
Publish the config file:
php artisan vendor:publish --tag=notification-config
Configuration
Add the following variables to your .env file:
NOTIFICATION_SERVICE_URL=https://notification.your-domain.com NOTIFICATION_CLIENT_ID=your-client-id NOTIFICATION_CLIENT_SECRET=your-client-secret # Optional NOTIFICATION_TOKEN_CACHE_STORE=redis # default: your app's default cache store # use a store every process shares — see Token Management NOTIFICATION_TOKEN_CACHE_KEY=notif_token # default: esanj_notification_access_token NOTIFICATION_TOKEN_ENCRYPT=true # default: false — encrypt the cached token with APP_KEY NOTIFICATION_LOG_CHANNEL=stack # default: your app's default log channel NOTIFICATION_TIMEOUT=30 # default: 30 — seconds to wait for a full response NOTIFICATION_CONNECT_TIMEOUT=10 # default: 10 — seconds to wait for the connection
The first three are required. If any is missing when the client is resolved, you get a
ConfigurationException naming the variable — not a TypeError from inside the package. NOTIFICATION_SERVICE_URL
must be a full URL, and must use https:// when the app runs in production: the client-credentials call carries
your client_secret, and plain HTTP hands it to anyone on the network path.
Full config reference (config/esanj/notification.php):
return [ 'base_url' => env('NOTIFICATION_SERVICE_URL', 'http://localhost'), 'client_id' => env('NOTIFICATION_CLIENT_ID'), 'client_secret' => env('NOTIFICATION_CLIENT_SECRET'), 'token' => [ 'cache_store' => env('NOTIFICATION_TOKEN_CACHE_STORE', null), 'cache_key' => env('NOTIFICATION_TOKEN_CACHE_KEY', 'esanj_notification_access_token'), 'buffer_seconds' => 60, // refresh this many seconds before the reported expiry 'encrypt' => env('NOTIFICATION_TOKEN_ENCRYPT', false), ], 'retry' => [ 'attempts' => 3, // total attempts including the first 'sleep_ms' => 1000, // base delay; doubles per attempt, half of it randomised ], 'idempotency' => [ 'enabled' => env('NOTIFICATION_IDEMPOTENCY', false), // service honours `Idempotency-Key` ], 'timeout' => env('NOTIFICATION_TIMEOUT', 30), 'connect_timeout' => env('NOTIFICATION_CONNECT_TIMEOUT', 10), 'logging' => [ 'channel' => env('NOTIFICATION_LOG_CHANNEL', null), ], ];
Token Management
Token handling is fully automatic:
- On the first request the package fetches a token via the OAuth 2.0 client-credentials flow (
POST /api/v1/oauth/token). - The response is validated before anything is cached — it must carry a usable
access_tokenand a lifetime the client can read.expires_atis preferred overexpires_in: the service caches its own tokens and keeps reporting the original lifetime, so a cached token would otherwise look far fresher than it is and get used past its real expiry. A response carrying neither, or one that has already expired, is rejected with anAuthenticationException. - The token is stored in your configured cache store with a TTL equal to its remaining life minus
buffer_seconds. A token with less life left than the buffer is still used for one call rather than thrown away. SetNOTIFICATION_TOKEN_ENCRYPT=trueto encrypt that cache entry with yourAPP_KEY— worth doing when the store is shared with anything you don't fully trust. - A fast in-memory copy avoids cache I/O on subsequent calls within the same process.
- Fetching happens behind a cache lock. When the cache is cold — a deploy, a Redis restart, an invalidation — one process fetches the token while the others wait and then read its result, instead of twenty workers hitting the throttled token endpoint at once.
- If a request receives an
HTTP 401, the package invalidates the cached token, fetches a fresh one, and replays the request once. A second401means the credentials themselves are wrong, so it throws instead of hammering the token endpoint. - An
HTTP 403is left alone: the token is valid, the client simply has no permission for that endpoint. Refreshing would drop a healthy token for nothing — see$e->isForbidden(). - If all retries fail, an
ApiException(orAuthenticationException) is thrown and the error is logged.
Retry & Idempotency
A lost response does not mean the service ignored the request. The service creates the notification and queues the
job before it answers, so replaying a POST /api/v1/send that timed out sends the message a second time. The
client therefore retries by method:
| Situation | Retried? |
|---|---|
GET on a 5xx or connection error |
Yes, up to retry.attempts. |
401 on any method |
Once — the token is refreshed and the call replayed immediately (a rejected token proves the request was never processed). A second 401 throws. |
429 on any method |
Yes — waits for the server's Retry-After (capped at 30s) and replays. The throttle rejects before any processing happens, so this is safe for sends too. |
send / sendBatch on a 5xx or timeout |
Only when idempotency.enabled is true. |
400, 403, and every other 4xx |
Never — an ApiException is thrown at once. |
Waits grow exponentially and carry jitter: retry.sleep_ms doubles per attempt (1s, 2s, 4s… capped at 10s) with
half of each delay randomised, so clients that fail at the same moment don't all retry at the same moment. A 429
keeps the server's Retry-After as the floor and adds up to a second of spread on top.
With idempotency.enabled = false (the default) a failed send throws after a single attempt. Handle it yourself —
usually by letting the queued job retry with a key of your own, see below.
⚠️ The Esanj Notification service does not implement
Idempotency-Keytoday. There is no middleware, config or route handling for it, so the header is accepted and ignored. LeaveNOTIFICATION_IDEMPOTENCYat its default offalse; turning it on would make the client retry sends that the service treats as brand new ones, and your users would get the message twice. The parameter below is here for when the service adds support.
Set NOTIFICATION_IDEMPOTENCY=true only once the service honours the Idempotency-Key header and replays the
original response for a repeated key. The client then sends a fresh key with every send and reuses it across that
call's retries, so a duplicate never reaches your users.
For a send that your application itself can issue twice — a queued job that gets retried, a form the user double-submits — pass a stable key so both attempts collapse into one notification:
Notifier::send(new SendNotificationData( recipient: $user->mobile, payload: SmsPayload::fromMessage('Your code is 12345'), channel: 'sms', idempotencyKey: "otp:{$user->id}:{$otp->id}", ));
The same parameter exists on SendBatchNotificationData.
Usage
Dependency Injection (recommended)
use Esanj\NotificationClient\Contracts\NotificationClientInterface; class OrderService { public function __construct( private readonly NotificationClientInterface $notifier ) {} }
Facade
use Esanj\NotificationClient\Facades\Notifier; Notifier::send($data);
Sending Notifications
SMS — plain message
use Esanj\NotificationClient\DTOs\SendNotificationData; use Esanj\NotificationClient\DTOs\Payloads\SmsPayload; $notification = $notifier->send(new SendNotificationData( recipient: '+989123456789', payload: SmsPayload::fromMessage('Your OTP is 1234'), channel: 'sms', priority: 'high', )); echo $notification->uuid; // "550e8400-e29b-..." echo $notification->status; // "pending"
SMS — pattern (template code)
use Esanj\NotificationClient\DTOs\Payloads\SmsPatternPayload; $notification = $notifier->send(new SendNotificationData( recipient: '+989123456789', payload: SmsPatternPayload::make('otp_pattern', ['code' => '1234', 'name' => 'John']), channel: 'sms', ));
use Esanj\NotificationClient\DTOs\Payloads\EmailPayload; $notification = $notifier->send(new SendNotificationData( recipient: 'user@example.com', payload: EmailPayload::make() ->subject('Welcome to our platform') ->html('<h1>Hello, John!</h1><p>Your account is ready.</p>') ->text('Hello, John! Your account is ready.') ->from('no-reply@example.com', 'Example') ->replyTo('support@example.com') ->cc(['manager@example.com']) ->bcc(['archive@example.com']), channel: 'email', ));
Push Notification
use Esanj\NotificationClient\DTOs\Payloads\PushPayload; $notification = $notifier->send(new SendNotificationData( recipient: 'device-fcm-token', payload: PushPayload::make() ->title('New Order') ->body('Your order #1234 has been confirmed.') ->url('https://app.example.com/orders/1234') ->data(['order_id' => 1234]), channel: 'push', ));
Using a Template
A template renders the message body on the service, so you send variables instead of text.
On SMS, the template is the whole payload:
use Esanj\NotificationClient\DTOs\Payloads\TemplatePayload; $notification = $notifier->send(new SendNotificationData( recipient: '+989123456789', payload: TemplatePayload::make('otp_sms') ->variables(['code' => '1234']) ->language('fa'), channel: 'sms', ));
On email and push the template replaces the body only — the service still requires a
subject (email) or a title (push). Hand the template to those builders instead of sending it on
its own:
use Esanj\NotificationClient\DTOs\Payloads\EmailPayload; use Esanj\NotificationClient\DTOs\Payloads\TemplatePayload; $notification = $notifier->send(new SendNotificationData( recipient: 'user@example.com', payload: EmailPayload::make() ->subject('Welcome to our platform') ->template( TemplatePayload::make('welcome_email') ->variables(['name' => 'John', 'plan' => 'Pro']) ->language('fa') ) ->from('no-reply@example.com', 'Example'), channel: 'email', ));
PushPayload::template() works the same way alongside ->title(...). A bare TemplatePayload on
the email or push channel is rejected with a 422 naming the missing payload.subject /
payload.title.
variables() is optional — a template with no variables still sends the key, as the service expects.
Targeting a Specific Provider
$notification = $notifier->send(new SendNotificationData( recipient: '+989123456789', payload: SmsPayload::fromMessage('Hello!'), providerId: 3, // channel is inferred from the provider ));
Channels, Priorities and Statuses
channel, priority and a filter's status accept either a plain string or the matching enum. Whichever you
pass, an unknown value is rejected at construction with an InvalidInputException that lists the accepted ones —
you find out on the line that made the mistake, not after a round trip:
use Esanj\NotificationClient\Enums\NotificationChannel; use Esanj\NotificationClient\Enums\NotificationPriority; $notification = $notifier->send(new SendNotificationData( recipient: '+989123456789', payload: SmsPayload::fromMessage('Hello!'), channel: NotificationChannel::SMS, priority: NotificationPriority::HIGH, ));
| Enum | Values |
|---|---|
NotificationChannel |
SMS · EMAIL · PUSH |
NotificationPriority |
LOW · MEDIUM · HIGH |
NotificationStatus |
PENDING · QUEUED · PROCESSING · SENT · FAILED · DELIVERED · UNDELIVERED |
BatchStatus |
PENDING · PROCESSING · CANCELED · COMPLETED |
Leaving priority unset sends nothing, so the service applies its own default — medium for a single send,
low for a batch.
Adding Tags
$notification = $notifier->send(new SendNotificationData( recipient: '+989123456789', payload: SmsPayload::fromMessage('Promotion!'), channel: 'sms', tags: ['marketing', 'summer-campaign'], ));
Batch Notifications
use Esanj\NotificationClient\DTOs\SendBatchNotificationData; use Esanj\NotificationClient\DTOs\Payloads\SmsPayload; $batch = $notifier->sendBatch(new SendBatchNotificationData( recipients: ['+989111111111', '+989222222222', '+989333333333'], payload: SmsPayload::fromMessage('Hello everyone!'), channel: 'sms', priority: 'low', batchName: 'Summer Campaign 2025', tags: ['marketing'], )); echo $batch->uuid; // "batch-uuid" echo $batch->totalNotifications; // 3 echo $batch->progressPercentage(); // 0.0 (just queued)
Querying Notifications
List with filters
use Esanj\NotificationClient\DTOs\NotificationFilter; $result = $notifier->listNotifications(new NotificationFilter( perPage: 20, status: 'sent', recipients: ['+989123456789'], page: 2, )); foreach ($result as $notification) { echo $notification->uuid . ': ' . $notification->status . PHP_EOL; } echo "Page {$result->currentPage} of {$result->lastPage}, total: {$result->total}";
Walk every page
eachNotification() pulls one page at a time and yields the items lazily, so the whole result set never has to fit
in memory:
foreach ($notifier->eachNotification(new NotificationFilter(status: 'failed')) as $notification) { echo $notification->uuid . PHP_EOL; }
Driving the loop by hand works too — advance the filter, otherwise you keep re-fetching page 1:
$filter = new NotificationFilter(perPage: 50); do { $result = $notifier->listNotifications($filter); // ... use $result->items $filter = $filter->nextPage(); } while ($result->hasMorePages());
Get single notification
$notification = $notifier->getNotification('550e8400-e29b-41d4-a716-446655440000'); if ($notification->isSent()) { echo "Sent at: " . $notification->sentAt->toDateTimeString(); }
Batches
// List batches $result = $notifier->listBatches(perPage: 10, page: 1); // Get single batch $batch = $notifier->getBatch('batch-uuid'); echo $batch->progressPercentage() . '%'; echo $batch->isCompleted() ? 'Done' : 'In progress';
Providers & Tags
// Every configured provider — the endpoint is paginated, and this walks all of it $providers = $notifier->listProviders(); foreach ($providers as $provider) { echo "{$provider->providerName} ({$provider->providerChannel})" . PHP_EOL; } // One page, with the pagination metadata (per_page is capped at 100 by the service) $page = $notifier->listProvidersPage(perPage: 25, page: 2); echo "{$page->total} providers in total"; // List available tags $tags = $notifier->listTags(perPage: 50); foreach ($tags->items as $tag) { echo "{$tag->name}: used {$tag->usedCount} times" . PHP_EOL; }
Error Handling
All exceptions extend Esanj\NotificationClient\Exceptions\NotificationClientException.
use Esanj\NotificationClient\Exceptions\ApiException; use Esanj\NotificationClient\Exceptions\AuthenticationException; use Esanj\NotificationClient\Exceptions\NotificationClientException; use Esanj\NotificationClient\Exceptions\RateLimitException; try { $notification = $notifier->send($data); } catch (RateLimitException $e) { // The OAuth token endpoint is throttled (10/min per IP) — the credentials are fine $this->release($e->retryAfter ?? 60); } catch (AuthenticationException $e) { // OAuth credentials are invalid or the service is unreachable Log::critical('Notification auth failed', ['error' => $e->getMessage()]); } catch (ApiException $e) { if ($e->isClientInputError()) { // 400 or 422 — the service can't act on what was sent. Fix the request, don't retry. $errors = $e->getErrors(); // 422 only: ['recipient' => ['The recipient format is invalid.']] $reason = $e->getMessage(); // 400: "No active provider found for this client and channel." } if ($e->isRateLimited()) { // Still throttled after backing off — come back later instead of hammering $this->release($e->retryAfter ?? 60); } Log::error('Notification API error', [ 'status' => $e->statusCode, 'response' => $e->responseBody, ]); } catch (NotificationClientException $e) { // Catch-all for any package exception }
| Exception | When thrown |
|---|---|
AuthenticationException |
Cannot fetch/refresh OAuth token (bad credentials, service unreachable) |
RateLimitException |
The token endpoint returned 429. Credentials are valid — you're just asking for tokens too often |
ApiException |
Non-retriable HTTP error (4xx, persistent 5xx, or a 429 that survived the back-off) |
InvalidInputException |
The call is wrong before it leaves the process: an unknown channel / priority / status, more than 100 recipients in a filter, or providerId passed alongside a pattern payload — a pair the service rejects outright |
ConfigurationException |
The package isn't configured — a missing NOTIFICATION_* env var, a base_url that isn't a URL, or plain HTTP in production. Thrown when the client is resolved, and the message names the variable to set |
UnexpectedResponseException |
HTTP 200, but the body isn't usable JSON (a proxy or WAF page), or the payload is missing a field the contract guarantees. The message quotes the body or names the field |
NotificationClientException |
Base class — all exceptions above extend this |
Fields the service may legitimately leave empty are typed nullable rather than blowing up: providerName and
providerChannel (null when the underlying provider record is gone), and updatedAt on notifications, batches and
tags (null instead of being parsed into a fake "now").
RateLimitException and ApiException::isRateLimited() both carry $e->retryAfter — the server's Retry-After
in seconds, or null when it didn't send one. It's exactly what $job->release() wants.
What the status codes mean
ApiException has a helper for each one, so you never have to compare $e->statusCode by hand:
| Status | Helper | What happened |
|---|---|---|
0 |
isConnectionError() |
No response at all — timeout or refused connection |
400 |
isBadRequest() |
The request is well-formed but unusable: no active provider for this channel, or the chosen provider doesn't support it. Not a field error, so getErrors() is empty — read $e->getMessage() |
401 |
isUnauthorized() |
The token was rejected. The client already refreshed and retried once |
403 |
isForbidden() |
The token is fine; this client lacks the service permission for that endpoint. Grant it on the service |
404 |
isNotFound() |
No notification, batch, tag or provider with that identifier |
422 |
isValidationError() |
Field-level validation failed — getErrors() returns the map |
429 |
isRateLimited() |
Throttled, and still throttled after the client backed off. $e->retryAfter holds the server's hint |
5xx |
isServerError() |
The service failed, and every allowed retry was used |
isClientInputError() covers 400 and 422 — the two cases where the fix is in what you sent, not in
retrying. Checking only isValidationError() silently misses the "no active provider" case.
Testing
The package integrates cleanly with Guzzle's MockHandler. In your feature tests:
use GuzzleHttp\Client; use GuzzleHttp\Handler\MockHandler; use GuzzleHttp\HandlerStack; use GuzzleHttp\Psr7\Response; use Esanj\NotificationClient\Auth\TokenManager; use Esanj\NotificationClient\Http\ApiClient; use Esanj\NotificationClient\NotificationClient; $mock = new MockHandler([ // 1st call: token endpoint new Response(200, [], json_encode([ 'access_token' => 'test-token', 'token_type' => 'Bearer', 'expires_in' => 3600, ])), // 2nd call: send notification new Response(202, [], json_encode([ 'data' => [ 'uuid' => 'test-uuid', 'status' => 'pending', 'channel' => 'sms', 'recipient' => '+989123456789', 'batch_uuid' => null, 'sent_at' => null, 'created_at' => now()->toIso8601String(), 'updated_at' => now()->toIso8601String(), ], ])), ]); $client = new Client(['handler' => HandlerStack::create($mock)]); // Build dependencies manually $tokenManager = new TokenManager( httpClient: $client, cache: app(\Illuminate\Contracts\Cache\Repository::class), logger: app(\Psr\Log\LoggerInterface::class), clientId: 'test-id', clientSecret: 'test-secret', tokenEndpoint: 'http://test/api/v1/oauth/token', cacheKey: 'test_token', bufferSeconds: 60, ); $apiClient = new ApiClient( httpClient: $client, tokenManager: $tokenManager, logger: app(\Psr\Log\LoggerInterface::class), baseUrl: 'http://test', retryAttempts: 3, retrySleepMs: 0, ); $notifier = new NotificationClient($apiClient);
Available Payload Classes
| Class | Channel | Factory |
|---|---|---|
SmsPayload |
SMS | SmsPayload::fromMessage('text') |
SmsPatternPayload |
SMS | SmsPatternPayload::make('key', ['var' => 'val']) |
EmailPayload |
EmailPayload::make()->subject(...)->html(...) or ->template(...) |
|
PushPayload |
Push | PushPayload::make()->title(...)->body(...) or ->template(...) |
TemplatePayload |
SMS alone; email/push via their ->template() |
TemplatePayload::make('key')->variables([...])->language('fa') |
Resource Properties
NotificationResource
| Property | Type | Description |
|---|---|---|
uuid |
string |
Unique notification identifier |
status |
string |
pending | queued | processing | sent | failed | delivered | undelivered |
channel |
string |
sms | email | push |
recipient |
string |
Recipient address / token |
batchUuid |
string|null |
Parent batch UUID if sent as part of a batch |
sentAt |
CarbonImmutable|null |
When the message was sent |
createdAt |
CarbonImmutable |
|
updatedAt |
CarbonImmutable|null |
Null when the service didn't send one |
isSent() / isDelivered() / isFailed() / isPending() |
bool |
isFailed() covers failed and undelivered; isPending() covers pending, queued and processing |
BatchResource
| Property | Type | Description |
|---|---|---|
uuid |
string |
Unique batch identifier |
status |
string |
pending | processing | canceled | completed |
totalNotifications |
int |
Number of notifications in the batch |
processedNotifications |
int |
Notifications processed so far |
createdAt |
CarbonImmutable |
|
updatedAt |
CarbonImmutable|null |
Null when the service didn't send one |
progressPercentage() |
float |
Computed progress 0–100 |
isPending() / isProcessing() / isCanceled() / isCompleted() |
bool |
One per batch state |
ProviderResource
| Property | Type | Description |
|---|---|---|
id |
int |
Client-provider row id |
providerName |
string|null |
Null if the provider record no longer exists |
providerChannel |
string|null |
Null for the same reason |
providerId |
int |
The provider this row points at |
orderColumn |
int |
Selection order |
createdAt |
CarbonImmutable |
TagResource
| Property | Type | Description |
|---|---|---|
id |
int |
|
name |
string |
|
description |
string|null |
|
color |
string|null |
|
usedCount |
int |
How many notifications carry the tag |
createdAt |
CarbonImmutable |
|
updatedAt |
CarbonImmutable|null |
Null when the service didn't send one |
Documentation
For a complete, beginner-friendly, step-by-step walkthrough — installing, sending your first notification, building custom payloads, swapping the client implementation, testing, and troubleshooting — see docs/GUIDE.md.
License
MIT — © Esanj