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

Mailchimp Bundle Laravel Package

focusly-app/mailchimp-bundle

A PHP bundle for integrating Mailchimp into your application. It provides convenient configuration and services for working with Mailchimp audiences, subscribers, and email marketing workflows through the API.

View on GitHub
Deep Wiki
Context7

Getting Started

First, confirm the mismatch. This is a Symfony Bundle, not a Laravel package. Before writing any code, verify it can even install:

composer require focusly-app/mailchimp-bundle --dry-run

Check composer.json for the PHP constraint, the Symfony component constraints, the license, and what HTTP client it wraps. If it pins symfony/* versions Laravel already uses at a different major, stop — no amount of wiring will make it install cleanly on PHP 8.1+.

Where to look first in the repo (if it installs):

  1. composer.json — PHP/Symfony constraints, transitive deps, license.
  2. DependencyInjection/ — the Extension and Configuration files tell you the service IDs and the config keys. These are what you must reproduce manually in Laravel.
  3. Services/ or Client/ — find the concrete class that talks to the API. This is the only thing you actually want. It's the piece you'll vendor if you go that route.
  4. Resources/config/services.xml|yaml — reveals constructor dependencies you'll need to bind yourself.

Your first use case should be the smallest one: subscribe a single member to a list, or update their tags. A php artisan tinker-level call:

app(\App\Contracts\NewsletterClient::class)->subscribe(
    email: '[email protected]',
    listId: config('services.mailchimp.list_id'),
    tags: ['beta-user'],
);

Do not plan to composer require this and use it. There is no MailchimpServiceProvider, no facade, no config/mailchimp.php, and Laravel's container will not read a Symfony Extension tree. If you want to proceed, you are forking the client class into app/Support/Mailchimp/ and owning it. The realistic getting-started path for a Laravel app is the official mailchimp/mailchimp-marketing SDK behind your own interface — treat this repo as reference material (Services/ and DTOs/) at most.


Implementation Patterns

The adapter pattern (recommended)

Define your own contract so the ESP is swappable:

// app/Contracts/NewsletterClient.php
interface NewsletterClient
{
    public function subscribe(string $email, string $listId, array $tags = [], array $mergeFields = []): Member;
    public function unsubscribe(string $email, string $listId): void;
    public function updateTags(string $email, string $listId, array $tags): void;
    public function getMember(string $email, string $listId): ?Member;
    public function batchUpsert(string $listId, array $members): BatchResult;
}

Bind it in a provider that also publishes the config stub:

// app/Providers/MailchimpServiceProvider.php
public function register(): void
{
    $this->mergeConfigFrom(__DIR__.'/../../config/mailchimp.php', 'mailchimp');

    $this->app->singleton(NewsletterClient::class, fn ($app) => new MailchimpClient(
        apiKey: $app['config']->get('mailchimp.key'),
        client: new \MailchimpMarketing\ApiClient(),
    ));
}

public function boot(): void
{
    $this->publishes([
        __DIR__.'/../../config/mailchimp.php' => config_path('mailchimp.php'),
    ], 'mailchimp-config');
}

Config sourced from .env, which in production comes from your secrets manager, not disk:

// config/mailchimp.php
return [
    'key'     => env('MAILCHIMP_API_KEY'),
    'list_id' => env('MAILCHIMP_LIST_ID'),
    'server'  => env('MAILCHIMP_SERVER'), // e.g. us21 — the DC is embedded in the key
    'enabled' => env('MAILCHIMP_ENABLED', false),
    'timeout' => 10,
];

Push the writes off the request path

Writes to Mailchimp are never something a user should wait on. Model the sync as a queued job and keep the controller thin:

final class SyncMemberToMailchimp implements ShouldQueue
{
    use InteractsWithQueue, Queueable;

    public int $tries = 5;
    public array $backoff = [10, 30, 60, 120, 300];

    public function __construct(
        private readonly string $email,
        private readonly string $listId,
        private readonly array $tags = [],
    ) {}

    public function handle(NewsletterClient $client): void
    {
        try {
            $client->subscribe($this->email, $this->listId, $this->tags);
        } catch (ValidationError $e) {
            // 400 — bad email, missing merge field. Unrecoverable; log and drop.
            Log::warning('Mailchimp validation failed', ['email' => $this->email, 'msg' => $e->getMessage()]);
            $this->fail($e);
        }
        // 429 and 5xx bubble up and let the queue's backoff/retry handle them.
    }
}

Fire it from your domain events, not from controllers

protected $listen = [
    Registered::class  => [PushNewUserToNewsletter::class],
    OrderPaid::class   => [ApplyPaidCustomerTags::class], // 'paid-customer', 'plan:pro'
];

This keeps the marketing integration decoupled from your checkout/auth flows — exactly the boundary the PM assessment calls out.

Map to tags/merge fields deliberately

Tags are cheap and flexible; merge fields are schema. Use tags for lifecycle state (beta-user, plan:pro, churn-risk) and merge fields only for genuinely per-contact data (FNAME, LNAME, company). Don't invent merge fields for things that belong as a tag — schema changes in Mailchimp are a manual, UI-bound task.

Idempotent upserts

Use the PUT /lists/{list_id}/members/{subscriber_hash} semantics (which the official SDK exposes as setListMember) rather than POST create. The hash is MD5 of the lowercased email — you can compute it yourself for testing. This makes sync retries safe and lets the same job run from a webhook, a form, and a backfill without duplicating contacts.

Feature-flag the whole thing

if (! config('mailchimp.enabled')) {
    return; // job no-ops
}

You will want to turn it off in staging, in local tests, and the moment Mailchimp has an outage. A single guard in the adapter method is cheaper than remembering to mock it in every test.


Gotchas and Tips

The Symfony Bundle packaging is the first and biggest gotcha. No autodiscovery, no config/app.php fallback, no mergeConfigFrom. If you find yourself adding "extra": {"laravel": {...}} or manually registering a Symfony Extension, stop — you're rebuilding the adapter you should have just written. Pull the class out, delete the DI scaffolding, add a service provider, move on.

Symfony Extension config trees are not Laravel config. The bundle's Configuration.php validates and defaults semantic config keys at container compile time. In Laravel these become plain config() values with no validation. Add your own guard:

if (! config('mailchimp.key') || ! config('mailchimp.list_id')) {
    throw new \RuntimeException('Mailchimp is enabled but not configured.');
}

The API key contains the data-center prefix. abc123-us21 means your base URL is https://us21.api.mailchimp.com/3.0. If the bundle hardcodes us1 or parses this wrong, you'll get 401s or 404s on a valid key. Verify by hand with curl before blaming your code.

429 is the default state of Mailchimp under load. API v3 is roughly 10 concurrent connections and ~10 req/s burst. Every batch call is capped at 500 operations (and the batch body has a ~4MB payload limit). Your queue worker concurrency must be tuned — running 20 workers all syncing contacts will spend their lives backing off. Either:

  • throttle the queue (dedicated connection, --max-jobs, or a rate limiter), or
  • lean into the batch endpoint with Bus::batch and chunk to 500.

Respect the Retry-After header on 429 — a naive fixed backoff will hammer the API for minutes.

Batch responses lie by default. POST /batches returns 200 while individual operations inside the batch fail. You must poll /batches/{id} and inspect errored_operations per-item. A batch that "succeeded" can silently contain 40 rejected emails. Log the per-item errors with the subscriber hash so you can reconcile.

Unsubscribes are permanent-ish. status: 'unsubscribed' sticks, and a later "subscribe" for the same email will not re-subscribe them (they have to opt back in). If you sync a user status field to subscribed, you'll silently overwrite legitimate opt-outs and create compliance problems. Never write status: subscribed for an existing member — use the tags API for state changes; only write subscribed on genuine opt-in flows.

PII path — this matters for review. The adapter holds email addresses and merge fields. That means: no logging of full request bodies, no dumping member payloads on exception, no committing the API key. Use Log::withContext(['subscriber_hash' => $hash]) rather than the raw email. If your org has a third-party-package review gate, an unmaintained, single-maintainer, zero-dependent Symfony bundle in the PII path is a near-certain rejection — budget the time to write your own ~200-line adapter instead.

config:cache in production. Any env() call outside the config files returns null after php artisan config:cache. If the extracted client reads env() directly (a Symfony %env()% habit), it will silently break in production and work fine in dev. Grep for env( in the vendored code and move everything into config/mailchimp.php.

Extension point worth keeping from the bundle. If the repo ships DTOs for members/campaigns, port them — typed DTOs that map raw API arrays into your domain objects are the genuinely useful part and keep the rest of your app from seeing Mailchimp's snake_case payload. Drop the Symfony serializer config and map explicitly in the adapter constructor.

Contract tests against a sandbox audience. Write feature tests that hit Mailchimp's sandbox list, tagged @mailchimp, and skipped in CI by default. You want coverage for: valid subscribe, duplicate subscribe (idempotency), 400 validation error, 404 member-not-found, and a 429 simulation (mock the HTTP client). Without this, an API drift or a bad constraint bump shows up as a silent sync outage.

Exit criteria if you're on the fence. If the "half-day spike" (per the PM/TPM notes) shows the bundle is only thin cURL wrappers, don't fork it — the official SDK is the same amount of work and someone else maintains it.

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.
elsayed85/laravel-api-exceptions
ouhssini/laravel-demo-mode
daworks/ncloud-outbound-mailer-driver
gp247/cms
ignibyte/aic-gates
nexa-merchant/blog
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
cgv/localized-resource