Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Mailcoach Sdk Laravel Package

spatie/laravel-mailcoach-sdk

The PHP SDK for working with the Mailcoach API in Laravel apps, supporting both self-hosted Mailcoach (v6+) and Mailcoach Cloud. Create campaigns, send tests, and manage email lists and subscribers with a fluent interface and simple pagination via next().

View on GitHub
Deep Wiki
Context7

Getting Started

Install and publish the config, then add the two env values from the API tokens screen in Mailcoach (Settings → API tokens). Both the token and the endpoint live there.

composer require spatie/laravel-mailcoach-sdk
php artisan vendor:publish --tag="mailcoach-sdk-config"
MAILCOACH_API_TOKEN=your-token
MAILCOACH_API_ENDPOINT=https://mail.yourdomain.com/api/

The endpoint must be the full API base URL including the scheme and the /api/ segment. A missing /api or a missing trailing slash is the single most common first-run failure — you'll get 404s that look like bad credentials.

Where to look first. Ignore the README's prose and read the three resource classes, because they are the API surface:

  • Spatie\MailcoachSdk\Resources\EmailList
  • Spatie\MailcoachSdk\Resources\Subscriber
  • Spatie\MailcoachSdk\Resources\Campaign

Each one lists the attributes you can read and write, and each has save() and delete(). Everything else in the package (Mailcoach facade methods + paginated result objects) is just transport.

First use case. Run a read-only smoke test in tinker before writing any feature code:

use Spatie\MailcoachSdk\Facades\Mailcoach;

Mailcoach::emailLists()->first()->name;   // proves token + endpoint are correct

Then do the smallest useful thing: take an existing list's UUID, create a draft campaign against it, and sendTest() to yourself. If that round-trips, the credentials, the list UUID, and the template fields are all validated — and you've done it without risking a real send.

The facade is backed by a container singleton built from the two config values, so app(\Spatie\MailcoachSdk\Mailcoach::class) and constructor injection work the same as the facade. Prefer injection; see below.

Implementation Patterns

Wrap the facade in your own service, immediately

Don't scatter Mailcoach:: calls through controllers and jobs. Put one class between your app and the SDK:

final class MailcoachClient
{
    public function findSubscriber(string $listUuid, string $email): ?Subscriber
    {
        return app(Mailcoach::class)->emailList($listUuid)->subscriber($email);
    }
}

Two payoffs: you get one place to add logging, retries, and exception mapping, and you get a seam to swap for a fake in tests. Given the package's size (42 stars, zero dependents), you do not want its API touching fifty call sites.

Store Mailcoach UUIDs as your external key

Never re-derive identity by name or email. Add a nullable mailcoach_uuid column to the models that mirror a Mailcoach entity, plus a mailcoach_synced_at. This lets you do Mailcoach::subscriber($uuid) (cheap, unambiguous) and makes reconciliation after a failed job trivial.

Subscriber sync: check, then create

createSubscriber() is a POST, not an upsert. The safe pattern for a signup form or a nightly user sync is:

$subscriber = $list->subscriber($email);

if ($subscriber === null) {
    $subscriber = Mailcoach::createSubscriber(
        emailListUuid: $listUuid,
        attributes: ['email' => $email, 'first_name' => $user->first_name, 'tags' => ['App']],
    );
}

// Respect prior opt-outs — see gotchas
if ($subscriber->unsubscribedAt === null) {
    $subscriber->tags = ['App', 'Plan-Pro'];
    $subscriber->save();
}

This is also the pattern for updates: mutate the attribute on the resource, call save(). There is no partial-update helper, so treat save() as "send this resource back."

Campaigns: draft → test → human gate → send

The only safe workflow for send():

  1. createCampaign([...]) produces a draft. Persist its UUID locally with a status column.
  2. $campaign->sendTest('[email protected]') after any fields change.
  3. A human action (button, artisan command, approval record) flips your local status and calls send().
  4. Record that you sent it, keyed by the campaign UUID, before or atomically-with the call.

send() is irreversible and fans out to the entire list. Never let it live behind a POST that a retry, a double-click, or a queued job's $tries can hit twice. Use an idempotency guard on your side.

Pagination as an iterator, not a do/while

The README's do { } while ($subscribers = $subscribers->next()) works but is awkward to break out of and easy to get wrong. Wrap it:

function eachSubscriber(string $listUuid, callable $fn): void
{
    $page = Mailcoach::subscribers($listUuid);

    while ($page !== null) {
        foreach ($page as $subscriber) {
            $fn($subscriber);
        }
        $page = $page->next();
    }
}

next() returns null at the end — guard on !== null rather than truthiness, and guard previous() the same way. total() is available for progress reporting, but it's a count across all pages; don't call it inside a loop.

Push work to the queue

Every SDK call is a synchronous outbound HTTP request. A request cycle that creates a campaign and sends a test is two round trips to a server you don't control, plus whatever latency Mailcoach's send-test pipeline adds. Wrap all of it in jobs; keep the HTTP layer out of web requests entirely.

Filters are passed through raw

subscribers(['filter[email]=gmail.com']) is just a query string. The SDK does not validate or document what filters exist — that's the Mailcoach API's job. When a filter silently returns everything, check the Mailcoach API docs for the exact filter key rather than assuming the SDK dropped it.

Testing

The SDK speaks to Mailcoach over Laravel's HTTP client, so Http::fake() can stand in for the real API and Http::assertSent() is genuinely useful for asserting the endpoint and payload shape. But the more durable strategy is the MailcoachClient interface above: fake your own class for feature tests, and keep one narrow integration test that exercises the real SDK against Http::fake() so a package upgrade breaks exactly one test.

Gotchas and Tips

Version pinning is not optional. 1.5.0 supports Laravel 13 and drops support below Laravel 12. If you're on Laravel 10 or 11, pin ^1.4. Read the changelog before every composer update — this package's releases are mostly framework-support bumps, and they're breaking on the framework axis.

Config caching catches people. The token and endpoint are read at singleton resolution. After changing .env in a deployed environment, php artisan config:cache again or you'll keep hitting the old endpoint with no error message explaining why.

Missing credentials fail early, but 401/404 do not. A missing token/endpoint throws with a reasonably clear message (that was a deliberate 1.1.0 improvement). A wrong token or a malformed endpoint surfaces as an HTTP error from the API. When debugging, print config('mailcoach-sdk.endpoint') and paste it into curl before touching your code — nine times out of ten the problem is there.

Dates are strings. Resource attributes come back as raw API payload values; subscribedAt, unsubscribedAt, sentAt and friends are ISO strings, not Carbon instances. Wrap with Carbon::parse() at the boundary. Don't assume a cast you didn't write.

Creating a subscriber for a previously-unsubscribed address is a compliance problem. The API may happily (re)attach the record. Always check $list->subscriber($email) first and inspect unsubscribedAt before writing. If you're syncing from your users table, unsubscribedAt in Mailcoach must be the source of truth, never your local newsletter_opted_in flag.

confirm() is a real thing you probably need. If a list uses double opt-in, a subscriber created via the API sits unconfirmed and will not receive campaigns. For transactional or app-driven signups you must call $subscriber->confirm() explicitly. This silently "works" in testing (the record exists, the email is right) and silently fails in production (nobody gets mail). Test with a real send.

save() is state-dependent. Mailcoach won't let you freely edit a campaign that's already sending or sent; you'll get a 422 back. Check the campaign's status before mutating and calling save(), not after.

delete() cascades. Deleting an email list deletes its subscribers. There is no soft-delete, no undo, and no confirmation prompt anywhere in the SDK. Gate it behind your own authorization and consider not exposing it at all.

No retries, no timeouts, no rate limiting, no backoff. The client does exactly one HTTP call per method. Any long-running sync loop against a self-hosted Mailcoach instance will eventually hit a 429 or a 502 and blow up mid-iteration with no resume point. Build retry at the job level ($tries, backoff()), and record your cursor so a failed job can pick up rather than restart.

No webhooks. The SDK is outbound-only. Mailcoach can send webhooks (subscriber confirmed, campaign sent, bounces) but you configure those in Mailcoach and implement the receiving route yourself. Don't go looking for a Mailcoach::webhooks() method.

Pagination defaults to 1000 per page. Your first subscribers() call on a large list is a very large JSON body held in memory. If you only need a subset, filter; if you need everything, process and discard per page rather than collecting into an array.

Multi-tenant means constructing your own instance. The singleton carries one token and one endpoint. If you run two Mailcoach instances (or per-tenant hosted accounts), don't fight the facade — instantiate the client class directly with the tenant's credentials, or rebind it in a scoped container binding. The facade is a convenience for the single-instance case.

Extension points are thin, so extend around it. Resources are hydrated by the client, which makes subclassing them impractical. Do your normalization — Carbon parsing, tag merging, default fields — in your own service or in small DTOs you build from the resources. Given how little code this package contains, patching a fork or vendoring the two classes is a realistic option if you hit an API feature the SDK doesn't model yet.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
oxygen/data
oxygen/core
jengo/base
xiloryx/log-collector
a21ns1g4ts/filament-stripe
la-php/laravel-admin
artisan-build/community-prompts
vleroy/smart
ayles-software/laravel-australian-public-holidays
cboxdk/laravel-id-spatie
cboxdk/laravel-id-billing
cboxdk/laravel-id-billing-bridge
simple-as-fuck/php-performance-log
hsluv/hsluv
seampact/codegen
seampact/hooks
seampact/ir
mehedimi/laravel-amp-mail
happones/laravel-vue-i18n-generator
jwerd/inertia-table