nztim/mailchimp
A Laravel wrapper for the Mailchimp API v3 that simplifies managing lists, subscribers, campaigns, and related resources. Includes configuration, service provider integration, and an expressive PHP interface for quick Mailchimp operations.
nztim/mailchimp is a thin, DrewM-style wrapper around the Mailchimp API v3 (GET/POST/PATCH/DELETE verb methods) with a Laravel service provider and facade. The wrapper does very little magic — most of your day-to-day work is knowing Mailchimp's REST endpoints and payload shapes.
Install and wire up:
composer require nztim/mailchimp
php artisan vendor:publish --provider="Nztim\Mailchimp\MailchimpServiceProvider"
On Laravel 5.5+ the provider/facade are auto-discovered. On older versions register Nztim\Mailchimp\MailchimpServiceProvider and alias Mailchimp in config/app.php.
Set the two things that matter in .env:
MAILCHIMP_API_KEY=0123456789abcdef-us14
MAILCHIMP_LIST_ID=abc123def4
The API key must include the data-center suffix (-us14); the wrapper derives the base URL from it.
First use case — subscribe a user to your default list:
use Nztim\Mailchimp\Mailchimp;
Mailchimp::post("lists/{$listId}/members", [
'email_address' => '[email protected]',
'status' => 'subscribed', // or 'pending' for double opt-in
'merge_fields' => ['FNAME' => 'Jane', 'LNAME' => 'Doe'],
]);
Where to look first:
config/mailchimp.php — confirm the key names and whether a default list is exposed.Mailchimp class — this is the source of truth for available helper methods (if any) versus the raw get/post/patch/delete verbs.Always route writes through the queue. A subscription call is a remote HTTP request; do it in a ShouldQueue job triggered by Registered/ProfileUpdated events rather than inline in the controller.
class SyncToMailchimp implements ShouldQueue
{
public function handle(Mailchimp $mailchimp): void
{
$hash = md5(strtolower(trim($this->user->email)));
$mailchimp->put("lists/{$listId}/members/{$hash}", [
'email_address' => $this->user->email,
'status_if_new' => 'subscribed',
'merge_fields' => ['FNAME' => $this->user->first_name],
]);
}
}
Use PUT for upserts, POST for strict creates. POST /lists/{id}/members returns 400 Member Exists when the address is already there. PUT /lists/{id}/members/{subscriber_hash} is idempotent and is the correct verb for any "create or update" flow (registration, profile sync, nightly backfill).
Type-hint the class, don't reach for the facade inside services. Resolving Nztim\Mailchimp\Mailchimp from the container keeps it swappable and testable; use the facade only at controller/console boundaries.
Bulk work belongs in /batches. Syncing thousands of contacts one-by-one will hit Mailchimp's 10-concurrent-connection limit and the 429 handler. Build the operations array and POST a batch:
Mailchimp::post('batches', [
'operations' => $ops, // [['method' => 'PUT', 'path' => 'lists/.../members/'.$hash, 'body' => json_encode($body)], ...]
]);
Webhooks for inbound state. Register a Mailchimp webhook to a Laravel route, then reconcile the member on subscribe/unsubscribe/cleaned events. The package doesn't help with verification — Mailchimp doesn't sign payloads, so either fetch the member back before trusting the event or gate the route behind a secret path/token.
Testing. The wrapper issues real Guzzle requests, so Http::fake() won't intercept it. Bind a mock of the Mailchimp class in the container (or the facade) and assert on get/post/patch calls, or extract a small Newsletter interface around it and fake that. Doing the latter also gives you an escape hatch if the package is eventually abandoned.
Multi-list setups. Treat the configured list_id as a default, not a constraint — pass the list id explicitly into each call so audience selection is a parameter, not global state.
xxx-us14 is required in full. A key missing the suffix (or copied from a different data center) yields a terse 401/404 from Guzzle with no hint — confirm the suffix first before debugging anything else.getLastError() / getLastResponse() are instance state. Read them immediately after each call; the next request overwrites them. Under Octane, treat the client as request-scoped, not a singleton.PATCH merges partial fields; PUT is create-or-replace. A PUT that omits a populated merge_fields key can blank it — send the full contact body on upserts.strtolower(trim($email)) before md5(), or you'll create duplicate members.fname fails with 400 Invalid Resource; FNAME works. Error bodies include the offending field, so log the full response JSON.DELETE archives, it doesn't erase. For GDPR/compliance removal use POST /lists/{id}/members/{hash}/actions/delete-permanent.status, not a delete. Same for cleaned and transactional.GET /lists/{id}/members caps at low page sizes; pass count (max 1000) and offset, or use fields/exclude_fields to shrink payloads.retry() with exponential backoff, or push bulk work into the queue/batch endpoint..env, run php artisan config:cache — a cached config silently ignores the edited key.composer.json and consider wrapping the class in your own Newsletter service so swapping the transport later is a one-file change.How can I help you explore Laravel packages today?