jacobbennett/sendyphp
SendyPHP is a simple PHP client for the Sendy email newsletter API, making it easy to manage subscribers, lists, and campaigns from your Laravel or PHP application with clean, readable methods.
This is a thin, framework-agnostic wrapper around Sendy's form-POST API — one class, no service provider, no facades. Treat it as a convenience layer over curl, not as an SDK.
Install and inspect
composer require jacobbennett/sendyphp
Before writing any code, open vendor/jacobbennett/sendyphp/src/ and read the single class file. The public surface is small (constructor plus a handful of methods), and confirming the exact method names and argument order there will save you more time than any docs will. Check composer.json for the declared PHP constraint — the last release was January 2021, so verify it installs cleanly on your PHP version before committing to it.
First use case: subscribe a user to a list
The canonical day-one call is adding an email to a list from a signup form or a post-purchase hook:
use JacobBennett\SendyPhp\Sendy;
$sendy = new Sendy(
config('services.sendy.url'), // e.g. https://newsletter.example.com
config('services.sendy.api_key')
);
$response = $sendy->subscribe(
config('services.sendy.list_id'),
$request->email,
$request->name ?? '',
['plan' => 'pro'] // custom fields, flat key => value
);
In Laravel, register it as a singleton so the client is constructed once per request:
// AppServiceProvider::register()
$this->app->singleton(Sendy::class, fn () => new Sendy(
config('services.sendy.url'),
config('services.sendy.api_key'),
));
Put SENDY_URL, SENDY_API_KEY, and SENDY_LIST_ID in config/services.php and .env. The list ID and API key are per-brand in Sendy, not per-installation — if you run multiple brands off one Sendy install, you need multiple client instances, each with its own key.
Wrap it, don't sprinkle it. The raw class returns Sendy's plain-text responses, which are hostile to application logic. Add one domain-level service that owns the client, normalizes responses into booleans/enums, and logs failures. Everything else in your app talks to that service.
class Newsletter
{
public function __construct(private Sendy $sendy) {}
public function subscribe(string $email, string $name = '', array $fields = []): bool
{
$result = trim((string) $this->sendy->subscribe(
config('services.sendy.list_id'), $email, $name, $fields
));
return $result === '1' || $result === 'true';
}
}
Always call it from a queued job. Sendy is a remote HTTP call to a self-hosted box that may be slow, rate-limited, or briefly down. User-facing requests should dispatch a job:
dispatch(new SyncSubscriberToSendy($user))->onQueue('marketing');
This also lets you retry with backoff, which the package does not do for you.
Use subscriptionStatus for reconciliation, not for gating. Sendy's status endpoint is the right tool for a nightly sync job or a webhook backfill, but do not call it inline on page loads — it's an extra round trip to your own server for every request. Cache the result with a short TTL if you must expose it.
Custom fields require pre-provisioning. Sendy silently drops custom field values that don't match a custom field defined in the list's settings. Decide the field schema in the Sendy UI first, then pass a flat associative array. Nested arrays are not serialized — flatten them yourself (['utm_source' => $utm], not ['utm' => ['source' => ...]]).
Testing. There's no HTTP abstraction to swap, so don't try to mock the class internals. Instead, bind a fake Sendy subclass (or a small interface you own that the real client implements) in the container during tests, and assert your job/service called it with the right arguments. Use Http::fake() only if you fork the package to use Laravel's HTTP client — see below.
Fork-friendly extension point. Because the class is small and unmaintained, the pragmatic move for a long-lived project is to copy it into app/Support/Sendy.php and replace the transport with Illuminate\Support\Facades\Http. You keep the method signatures, gain Http::fake(), timeouts, retries, and better exceptions, and you stop depending on a package whose last release predates PHP 8.1.
1, true, false, Already subscribed., Invalid email address., Some fields are missing., etc. (bool) "false" is true in PHP — this is the single most common bug with wrappers like this. Always compare trimmed strings explicitly.1 response means "confirmation email sent," not "subscribed." Make sure your UI copy matches the list setting./subscribe, /unsubscribe, etc. A trailing slash produces //subscribe, which some reverse proxies reject.Bus::batch() and a small delay between chunks.curl -X POST against the same endpoint and payload. If curl works and the package doesn't, the difference is almost always a missing boolean=true parameter, a trailing slash, or a custom field name mismatch. Log the full outgoing payload before the call."jacobbennett/sendyphp": "1.0.1") rather than a caret constraint, and add a test that asserts your wrapper's expected response mapping — that test is your real upgrade safety net if the package is ever abandoned or replaced.How can I help you explore Laravel packages today?