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

Marketing Laravel Package

mailchimp/marketing

The official PHP client library for the Mailchimp Marketing API. Install via Composer (mailchimp/marketing), configure with an API key or OAuth2 token plus server prefix, then call endpoints like ping, audiences, and campaigns through a fluent client. Requires PHP 7.2+.

View on GitHub
Deep Wiki
Context7

Getting Started

Install

composer require mailchimp/marketing

Requires PHP 7.2+. The package pulls in Guzzle, so expect a handful of transitive dependencies.

Find your server prefix first. The server value is not something you configure in the Mailchimp UI — it's the suffix of your API key (abc123...-us19 → us19). Derive it rather than duplicating it in config:

// config/services.php
'mailchimp' => [
    'key'    => env('MAILCHIMP_API_KEY'),
    'server' => env('MAILCHIMP_SERVER_PREFIX', substr(env('MAILCHIMP_API_KEY'), strrpos(env('MAILCHIMP_API_KEY'), '-') + 1)),
],

Smoke-test before writing any feature code. ping is the cheapest way to prove credentials, server prefix, and outbound connectivity all work:

$client = new MailchimpMarketing\ApiClient();
$client->setConfig(['apiKey' => config('services.mailchimp.key'), 'server' => config('services.mailchimp.server')]);

$client->ping->get(); // throws ApiException on 401

Bind it once, as a singleton. In AppServiceProvider::register():

$this->app->singleton(\MailchimpMarketing\ApiClient::class, function () {
    $client = new \MailchimpMarketing\ApiClient();
    $client->setConfig([
        'apiKey' => config('services.mailchimp.key'),
        'server' => config('services.mailchimp.server'),
    ]);
    return $client;
});

First real use case: subscribing someone to a list. This is what 90% of integrations actually do, and it is the thing to build first because it exercises auth, resource access, error handling, and the subscriber_hash quirk all at once.

$listId = config('services.mailchimp.list_id');
$hash   = md5(strtolower($email)); // lowercased AND trimmed

$client->lists->setListMember($listId, $hash, [
    'email_address' => $email,
    'status_if_new' => 'pending',    // use 'subscribed' only with consent
    'merge_fields'  => ['FNAME' => $firstName],
]);

Where to look first: the generated resource properties on ApiClient ($client->lists, ->campaigns, ->reports, ->ecommerce, ->batches, ->templates, ->automations) mirror the API reference's tag groups one-to-one. Method names are long and literal (getListMembersInfo, addListMember, setListMember, updateList). The Mailchimp API reference is the real documentation; the PHPDoc in src/Api/*.php gives you the exact parameter order.


Implementation Patterns

Wrap the SDK — don't sprinkle it through controllers

The ApiClient is a generated, positional-argument, array-in/object-out blob. Put your own interface in front of it so the rest of the app speaks domain language:

interface Newsletter
{
    public function subscribe(string $email, array $mergeFields = [], string $status = 'pending'): void;
    public function unsubscribe(string $email): void;
    public function isSubscribed(string $email): bool;
}

final class MailchimpNewsletter implements Newsletter
{
    public function __construct(private ApiClient $client, private string $listId) {}

    public function subscribe(string $email, array $mergeFields = [], string $status = 'pending'): void
    {
        try {
            $this->client->lists->setListMember($this->listId, md5(strtolower(trim($email))), [
                'email_address'   => $email,
                'status_if_new'   => $status,
                'merge_fields'    => $mergeFields,
            ]);
        } catch (ApiException $e) {
            if ($e->getCode() === 400 && str_contains((string) $e->getResponseBody(), 'looks fake')) {
                throw new InvalidEmail($email, previous: $e);
            }
            throw $e;
        }
    }
}

This is the single highest-leverage thing you can do. It gives you a mock point for tests, a place to normalise errors, and insulation from the SDK's generated signatures.

Prefer set* (PUT) over add* (POST) for anything idempotent

Intent Method Semantics
Add member, fail if exists lists->addListMember($listId, $body) POST, 400 on duplicate
Add-or-update member lists->setListMember($listId, $hash, $body) PUT, upsert
Update existing only lists->updateListMember($listId, $hash, $body) PATCH

For signup flows and webhook-triggered syncs, PUT is almost always what you want — retries and double-submits stop being a problem. Note the asymmetry: addListMember takes the hash in the body, setListMember/updateListMember take it as an argument.

Batch operations for anything bulk

Don't loop 5,000 single calls. $client->batches->start([...]) accepts up to 500 operations per batch, runs them async server-side, and is the intended path for imports and migrations:

$chunks = collect($subscribers)->chunk(500);

foreach ($chunks as $chunk) {
    $batch = $client->batches->start(['operations' => $chunk->map(fn ($s) => [
        'method'      => 'PUT',
        'path'        => "/lists/{$listId}/members/" . md5(strtolower($s['email'])),
        'body'        => json_encode(['email_address' => $s['email'], 'status_if_new' => 'subscribed']),
    ])->values()->all()]);

    $batchIds[] = $batch->id;
}

Then poll $client->batches->getStatus($id) from a queued job. Batch responses come back as newline-delimited JSON in a file — plan a reconciliation step, because partial failures do not throw.

Campaign flow is a state machine, in order

Creating a campaign does not create its content. The minimum viable sequence:

  1. campaigns->create([...]) → capture campaign_id
  2. campaigns->setContent($id, ['html' => ..., 'text' => ...])
  3. campaigns->update($id, ['recipients' => ['list_id' => ..., 'segment_opts' => ...]])
  4. campaigns->getSendChecklist($id) — treat this as a lint pass, not a blocker; it warns about things like missing physical address
  5. campaigns->send($id) or campaigns->schedule($id, ['schedule_time' => ...])

campaigns->send is irreversible. Gate it behind an explicit action, never inside an auto-retry queue with tries > 1.

Pagination: count/offset, and trim with fields

$offset = 0;
do {
    $page = $client->lists->getListMembersInfo($listId, null, 1000, $offset, null, null, null, null, ['total_items', 'members.email_address', 'members.id']);
    foreach ($page->members as $member) { /* ... */ }
    $offset += 1000;
} while (count($page->members) === 1000);

fields/exclude_fields can cut payload size dramatically on list endpoints (useful because responses get large and memory matters when iterating). Parameter position varies per endpoint — check the PHPDoc rather than guessing.

Webhooks in Laravel

Mailchimp webhooks are unsigned. The pragmatic pattern is a shared secret embedded in the route path plus a ping->get() liveness check on receipt of unexpected payloads:

Route::post('/webhooks/mailchimp/{token}', MailchimpWebhookController::class)
    ->where('token', config('services.mailchimp.webhook_token'));

Then dispatch() a job from the controller and return 200 immediately — Mailchimp retries on non-2xx, and you don't want slow handlers causing duplicate deliveries.

Job + rate limiting

Wrap every write in a queued job with $backoff and $tries = 3, and add a Redis rate limiter (a few requests per second, well under Mailchimp's ten-concurrent-connection cap) so a bulk dispatch doesn't trip 429s.


Gotchas and Tips

subscriber_hash is md5(strtolower($email)) — and nothing else. Trim whitespace, lowercase, MD5. Not the raw email, not a UUID, not the member's id. This accounts for a large share of "why does GET return 404 for a member I can see in the UI" bugs. Make it a small helper function and never inline the expression again.

Always lowercase before hashing, not after. MD5(Str::lower($email)) — order matters, and the API will happily 404 rather than tell you.

The server prefix is mandatory, and wrong-server errors look like auth errors. A 401 often means you passed us1 when the key says us19. Extract it from the key rather than trusting an .env copy.

Wrap every call in the service class, not the client, for testing. Because the SDK hits Guzzle directly, Http::fake() and $this->mock(ApiClient::class) are both awkward (the second fails on chained resource properties). Mocking your Newsletter interface is clean and stable. This package has zero dependents, so there's no community test helper to lean on — you build this layer.

Catch MailchimpMarketing\ApiException and read $e->getResponseBody(). The generated exception's message is the HTTP status line, which is useless. The body is a JSON string with title, status, detail, and instance — detail is the human-readable part, and instance (a trace ID) is what Mailchimp support will ask for.

400 is overloaded. Mailchimp returns 400 for invalid email and for a member already existing on a POST. Distinguish by parsing title ("Member Exists" vs "Invalid Resource") rather than assuming.

Merge field keys must be the exact uppercased tag from the audience settings (FNAME, LNAME, PHONE). Unknown keys are silently dropped on pending members and hard-error on subscribed ones. Pull the schema once with lists->getListMergeFields($listId) and cache it.

status: 'subscribed' is the consent-sensitive one. Mailchimp's ToS expects pending (double opt-in) for unsolicited signups. Use subscribed only for explicit opt-ins — and be aware Mailchimp can disable sending on an account with poor list hygiene, which will surface as a confusing error on campaign send, not on signup.

Timeouts default high. For a request-path signup, set a shorter timeout via $client->getConfig()->setTimeout(5) (or 'timeout' => 5 in setConfig) and consider dispatching to a queue. A 30-second Guzzle timeout blocking a web signup is a bad trade.

Field names in responses are snake_case and arrive as objects, not arrays. $response->members[0]->email_address. If you need arrays for collect(), json_decode(json_encode($r), true) is the fastest bridge — don't hand-write recursive converters.

Lists aren't deletable, only archivable. lists->deleteList($id) is a soft archive for most resources; ecommerce resources have delete*Permanent variants. Read the method name literally before building a "delete" button.

Rate limits use X-RateLimit-Remaining and 10 concurrent connections, not requests-per-minute. A single-threaded Laravel worker won't hit it; a Bus::batch of 100 jobs absolutely will. Cap queued jobs to a dedicated mailchimp queue with 2–3 workers.

Pin the version. The package is generated per API release and the CHANGELOG is generated too — method signatures have moved between minor versions (getAllLists param order, for instance). composer.lock plus a ~x.y constraint, and read the changelog before composer update.

License is NOASSERTION in the package metadata. Resolve it before shipping commercially or redistributing — check the LICENSE file in the repo rather than trusting the Packagist field.

Extension point: none, really. This is a generated client, not a framework integration. There's no facade, no config file, no events. Every abstraction you want — logging, retries, metrics, caching of list/merge-field metadata — you write yourself in the wrapper service. Treat the SDK as a transport, not an architecture.

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.
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings
coleus/apps
seampact/laravel
emmyconceptng/laravel-ng-postcode
jjsquady/round-robin