pulsenote/pulsenote-php

Official PHP SDK for the Pulsenote email API.

Maintainers

Package info

github.com/Pulsenote/pulsenote-php

Homepage

pkg:composer/pulsenote/pulsenote-php

Transparency log

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

0.1.0 2026-07-29 07:47 UTC

This package is auto-updated.

Last update: 2026-07-29 16:38:11 UTC


README

Official PHP SDK for the Pulsenote email API. Published to Packagist as pulsenote/pulsenote-php.

Framework-agnostic (PSR-18), with first-class Laravel support.

Install

composer require pulsenote/pulsenote-php

The SDK talks PSR-18, so it uses whatever HTTP client you already have (Guzzle, Symfony HttpClient, …) via php-http/discovery. If you have none:

composer require guzzlehttp/guzzle

Requires PHP 8.2+.

Usage

use Pulsenote\Pulsenote;

$pulsenote = new Pulsenote(getenv('PULSENOTE_API_KEY'));

$res = $pulsenote->notifications->send(
    to: 'greg@example.com',
    subject: 'Welcome',
    html: '<b>Hello from Pulsenote</b>',
);

echo $res->id, ' ', $res->status->value; // "<uuid> QUEUED"

Pulsenote::fromEnvironment() does the same from PULSENOTE_API_KEY (and optional PULSENOTE_BASE_URL).

The client exposes three groups matching the API's data plane:

Group Methods
$pulsenote->notifications send, list, all, get, stats
$pulsenote->templates list, get, create, update, delete, render, listLocales
$pulsenote->domains list, add, verify, dnsRecords, zoneFile, delete

Everything is named arguments in, typed objects out — statuses are enums (NotificationStatus, DomainStatus, DnsRecordType), timestamps are DateTimeImmutable, and unset optional arguments are omitted from the request rather than sent as null.

Paginating

list() returns one page and is directly iterable; all() walks every page lazily.

$page = $pulsenote->notifications->list(page: 1, limit: 50);
echo $page->meta->total, ' total, ', count($page), ' on this page';

foreach ($pulsenote->notifications->all(status: NotificationStatus::Bounced) as $n) {
    echo $n->recipient, '', $n->failureReason, PHP_EOL;
}

Errors

Non-2xx responses throw a typed exception. All of them extend PulsenoteException, so one catch covers everything, including transport failures.

use Pulsenote\Exception\RateLimitException;
use Pulsenote\Exception\ValidationException;

try {
    $pulsenote->notifications->send(to: $to, subject: $subject, html: $html);
} catch (RateLimitException $e) {
    sleep($e->retryAfter ?? 60);   // from the Retry-After header
} catch (ValidationException $e) {
    logger()->warning($e->apiMessage());  // field errors, joined
}
Status Exception
400 / 422 ValidationException
401 / 403 AuthenticationException
404 NotFoundException
409 ConflictException
429 RateLimitException (->retryAfter)
5xx ServerException
other non-2xx ApiException
no response / bad JSON TransportException

The SDK does not retry for you — back-off policy belongs to your queue or job runner, not to a request helper.

Laravel

The service provider is auto-discovered. Set the key and go:

PULSENOTE_API_KEY=pk_live_...
php artisan vendor:publish --tag=pulsenote-config   # optional

Inject the client (bound as a singleton):

public function __construct(private readonly Pulsenote $pulsenote) {}

Or use the facade:

use Pulsenote\Laravel\Facades\Pulsenote;

Pulsenote::notifications()->send(to: 'greg@example.com', subject: 'Hi', html: '<b>Hi</b>');

Or the notification channel:

public function via(object $notifiable): array
{
    return ['pulsenote'];
}

public function toPulsenote(object $notifiable): PulsenoteMessage
{
    return PulsenoteMessage::make()
        ->subject('Your order is on its way')
        ->template('order-shipped', locale: 'pl')
        ->data(['tracking' => $this->code]);
}

The recipient resolves from the notifiable's pulsenote route, then its mail route, then an email attribute — so existing Notifiable models work unchanged. Add routeNotificationForPulsenote() to override. API failures propagate, so a queued notification retries on Laravel's normal path.

See examples/laravel-notification.php.

Custom HTTP client

Pass any PSR-18 client — useful for middleware, custom timeouts, or tests:

$pulsenote = new Pulsenote(
    apiKey: $key,
    baseUrl: 'https://staging.example.test',
    headers: ['X-Trace-Id' => $traceId],
    httpClient: $myPsr18Client,
    requestFactory: $myPsr17Factory,
    streamFactory: $myPsr17Factory,
);

Scope

v1 covers the data plane — the endpoints authenticated with your X-API-Key (notifications, templates, domains). Account-management endpoints (team, billing, auth), which use JWT auth, are intentionally out of scope, matching pulsenote-node.

Staying in sync with the API

Unlike the Node SDK this client is hand-written, so nothing regenerates when the API changes. Instead, openapi/pulsenote-api.json is committed and tests/SpecCoverageTest.php diffs it against the #[Operation] attributes on the resource classes. If the API grows, moves, or renames an endpoint, CI goes red with the exact list.

make spec    # refresh the spec from the live API, then show the drift

A red SpecCoverageTest is a to-do list, not a bug: add the method, tag it with #[Operation(...)], and it goes green.

Development

composer install
make check    # phpstan (level 8) + phpunit

See CONTRIBUTING.md and CHANGELOG.md.

License

MIT © GP IT-Tech