campaignmonitor/createsend-php
Official PHP client for Campaign Monitor’s email marketing and transactional API. Manage campaigns, lists, subscribers, templates, and webhooks, or send transactional email. Install via Composer and authenticate with your API key.
campaignmonitor/createsend-php is a plain PHP SDK, not a Laravel package. There is no service provider, facade, config file, or auto-discovery. You wire it up yourself, which is usually 15 minutes of work and then a thin service class you use everywhere.
1. Install
composer require campaignmonitor/createsend-php
Since the last release was October 2021, also add a platform constraint and verify it on your PHP version early:
"config": { "platform": { "php": "8.1" } }
2. Put credentials in config
// config/services.php
'campaign_monitor' => [
'api_key' => env('CAMPAIGN_MONITOR_API_KEY'),
'client_id' => env('CAMPAIGN_MONITOR_CLIENT_ID'),
'list_id' => env('CAMPAIGN_MONITOR_LIST_ID'),
],
The API key is account-wide, so treat it like a database password: .env only, never in a frontend or a log line.
3. Wrap the library once
namespace App\Services\CampaignMonitor;
use CS_REST_Clients;
use CS_REST_General;
use CS_REST_Subscribers;
class Client
{
public function __construct(private array $config) {}
private function auth(): array
{
return ['api_key' => $this->config['api_key']];
}
public function general(): CS_REST_General
{
return new CS_REST_General($this->auth());
}
public function client(): CS_REST_Clients
{
return new CS_REST_Clients($this->config['client_id'], $this->auth());
}
public function subscribers(?string $listId = null): CS_REST_Subscribers
{
return new CS_REST_Subscribers(
$listId ?? $this->config['list_id'],
$this->auth()
);
}
}
// app/Providers/AppServiceProvider.php
$this->app->singleton(\App\Services\CampaignMonitor\Client::class, fn () => new \App\Services\CampaignMonitor\Client(
config('services.campaign_monitor')
));
4. Smoke test before building anything
$result = app(\App\Services\CampaignMonitor\Client::class)->general()->get_clients();
if (! $result->was_successful()) {
throw new RuntimeException($result->response->Message ?? 'Auth failed');
}
dump($result->response); // confirms key + account access
5. First real use case: subscribe on registration
$result = app(\App\Services\CampaignMonitor\Client::class)->subscribers()->add([
'EmailAddress' => $user->email,
'Name' => $user->name,
'CustomFields' => [['Key' => 'signup_source', 'Value' => 'web']],
'Resubscribe' => true,
]);
if (! $result->was_successful()) {
report(new \App\Exceptions\CampaignMonitorException(
$result->response->Message ?? 'subscribe failed',
$result->http_status_code
));
}
Where to look first: the package README (it contains the canonical examples), then the class/ directory. Every resource is a CS_REST_* class taking ($resourceId, $auth) and returning a CS_REST_Wrapper_Result.
Always route through your own wrapper, never call CS_REST_* from controllers. The library has no DI, no events, and no logging hooks. Your wrapper is the seam for testing, retries, and exception mapping.
Convert result objects into exceptions once.
namespace App\Exceptions;
use CS_REST_Wrapper_Result;
use RuntimeException;
class CampaignMonitorException extends RuntimeException
{
public static function fromResult(string $operation, CS_REST_Wrapper_Result $result): self
{
$message = $result->response->Message ?? 'Unknown Campaign Monitor error';
return new self("Campaign Monitor [$operation] failed: {$message}", $result->http_status_code);
}
}
Then every wrapper method ends with:
if (! $result->was_successful()) {
throw CampaignMonitorException::fromResult('subscribe', $result);
}
return $result->response;
Push writes to the queue, not the request. Signup and profile-update flows should dispatch a job. Campaign Monitor rate-limits per account and a slow API call in a registration request is a bad time.
class SyncSubscriberToCampaignMonitor implements ShouldQueue
{
use Queueable, SerializesModels;
public int $tries = 3;
public array $backoff = [10, 60, 300];
public function __construct(public string $email, public string $name) {}
public function handle(Client $cm): void
{
$result = $cm->subscribers()->add([
'EmailAddress' => $this->email,
'Name' => $this->name,
'Resubscribe' => true,
]);
// Treat "already subscribed" as success; it's the normal idempotent path.
if (! $result->was_successful() && $result->http_status_code !== 400) {
throw CampaignMonitorException::fromResult('subscribe', $result);
}
}
}
Use model observers for lifecycle sync, jobs for delivery.
User::created(fn (User $user) => SyncSubscriberToCampaignMonitor::dispatch($user->email, $user->name));
User::updated(fn (User $user) => $user->wasChanged('email')
? SyncSubscriberToCampaignMonitor::dispatch($user->email, $user->name)
: null);
Transactional email: use Smart Email, not Mail::send. If your marketing team builds templates in Campaign Monitor, send them from the app via the Transactional API so the template stays in one place.
use CS_REST_Transactional_SmartEmail;
$smart = new CS_REST_Transactional_SmartEmail($smartEmailId, ['api_key' => $apiKey]);
$result = $smart->send([
'To' => [$user->email],
'ConsentToTrack' => 'Yes',
'Data' => [
'first_name' => $user->name,
'reset_link' => $signedUrl,
],
]);
if (! $result->was_successful()) {
throw CampaignMonitorException::fromResult('transactional.send', $result);
}
Smart Email IDs are per-client and per-environment, so keep them in config keyed by template name, not hardcoded in services.
Lists and segments. There is no "get all lists" on CS_REST_Lists; use clients()->get_lists(). Fetching subscribers is paginated, so loop until you get a short page and be explicit about page size to avoid hammering the API.
$page = 1;
do {
$result = $cm->subscribers($listId)->get([], 1000, $page);
if (! $result->was_successful()) { throw CampaignMonitorException::fromResult('subscribers.get', $result); }
$batch = $result->response->Results ?? [];
// process $batch
$page++;
} while (count($batch) === 1000);
Webhooks. Configure them in the Campaign Monitor UI and handle them in a queued controller. Campaign Monitor does not sign payloads (historically), so treat the endpoint as untrusted: use an unguessable path, verify by re-fetching the subscriber, and dedupe on the event's timestamp/email pair so retries don't double-process.
Multi-tenant/OAuth. For a SaaS where each tenant has their own Campaign Monitor account, store the OAuth access/refresh token per tenant and pass them in the auth array instead of api_key. Do not use one shared key with client_id switching unless you are intentionally centralizing billing and access.
Testing. Because the SDK uses raw cURL, Http::fake() will not intercept it. That is the single biggest testing gotcha. Extract an interface (CampaignMonitorClient) with your typed methods, bind the real implementation in production, and bind a fake in tests:
$this->mock(CampaignMonitorClient::class, function ($mock) {
$mock->shouldReceive('subscribe')->once()->with('[email protected]', 'Jane');
});
For integration tests, use a separate Campaign Monitor account with test lists and clean up afterwards; do not point tests at the production key.
Scheduled campaigns. CS_REST_Campaigns supports creating and sending campaigns programmatically, but for app-driven mail (password resets, receipts, alerts) you want Transactional, not the campaign send endpoint. Campaigns are for segmentation and broadcasts owned by marketing.
CS_REST_Wrapper_Result. If you forget was_successful(), failures disappear silently. Enforce the exception-mapping pattern above in code review.Http::fake() does nothing here. The transport is internal cURL. Wrap the library or you cannot test your integration logic.stdClass, not arrays. JSON is decoded as objects, so use $result->response->Results, not $result->response['Results']. This bites when you copy examples from an associative-array mindset.CS_REST_Subscribers, CS_REST_Lists, etc., loaded through the package's own autoloader. IDE completion is poor and class name collisions are possible; import explicitly and run composer dump-autoload -o on deploy.$backoff = [10, 60, 300]) rather than retrying immediately.Key returns an error for the whole subscribe call, not just that field. Validate field keys against lists()->get_custom_fields() at deploy time or in a console command.YYYY-MM-DD. Multi-select fields accept arrays, and clearing a value has different semantics than setting an empty string. Look at the field's DataType before mapping.Carbon::parse(...)->setTimezone(config('app.timezone')); don't strtotime and assume UTC.unsubscribe marks the subscriber inactive (what compliance wants); delete is for GDPR erasure requests. Pick deliberately per flow.print_r($result) in production. Responses contain subscriber PII (emails, names, custom fields). Log the HTTP status and Campaign Monitor Message only.CS_REST_* internals.general()->get_clients() and asserts success will catch rotated or expired keys before your signup flow does.How can I help you explore Laravel packages today?