Skip to content

Email marketing · Open source

Audience Sync

A free plugin that subscribes, unsubscribes, tags and updates your app's records in an email-marketing audience or list, with queued bulk sync, a status column, list and campaign stats, and consent rules that never resubscribe anyone who opted out, in Filament 4 and 5.

Filament 4 and 5 Laravel 12 and 13 PHP 8.2+ v1.0.0 Free · MIT
Audience Sync: A free plugin that subscribes, unsubscribes, tags and updates your app's records in an email-marketing audience or list, with queued bulk sync, a status column, list and campaign stats, and consent rules that never resubscribe anyone who opted out, in Filament 4 and 5.

What it solves

Your app knows who your users and customers are; your email-marketing tool knows who is on the list.

Keeping the two in step usually means CSV exports or a subscribe call buried in a controller. Nothing in the panel tells an admin whether a user is subscribed, waiting to confirm or unsubscribed, and a careless bulk import can add back someone who already opted out.

Audience Sync adds record actions, bulk actions, a status column and two dashboard widgets that work with one email-marketing list. It ships two drivers: a Mailchimp audience (Marketing API 3.0) and a Brevo contact list (API v3). Those are the only providers implemented at v1.0.0; Kit and MailerLite are named in the README only as examples of drivers you could write yourself and register with AudienceSync::extend(). Your model implements Subscribable (a trait sends the email attribute and splits name into first and last name) and may add fields and tags. Each operation reads the contact from the provider first, then subscribes, updates, tags or unsubscribes it, and stores the result in a local audience_sync_members table, so the status column never calls the API. Anyone the provider reports as unsubscribed, cleaned or archived, and any address unsubscribed from your panel, is skipped by Subscribe. Bulk actions are queued in jobs of 50 records. List stats and recent campaigns are cached for 5 minutes. The plugin sends no email, builds no campaigns, receives no webhooks, deletes no contacts and never copies provider data into your own models. An independent plugin, not made or endorsed by Filament, Intuit Mailchimp or Brevo.

  • Record actions in a "Mailing list" group: Subscribe, Sync details, Edit tags and Unsubscribe, the last behind a confirmation
  • Bulk subscribe, sync details and unsubscribe, queued as one job per 50 records, with a "records queued" notification
  • A Mailing list column and infolist entry read from a local table: Subscribed, Pending confirmation, Unsubscribed, Cleaned (bounced), Archived in provider, Not subscribed, or Not synced when there is no row yet
  • A red icon on a record whose last sync failed, with the provider's message (keys removed) on hover
  • Subscribe is hidden for members and disabled, with an explanation, for anyone unsubscribed, cleaned or archived
  • Subscribe skips anyone the provider reports as unsubscribed, cleaned or archived and any address unsubscribed from your panel, on any record
  • Optional double opt-in for a Mailchimp audience: new contacts are added as pending and get the provider's confirmation email
  • Field mapping from neutral keys (first_name, last_name, phone) to merge fields or attributes, with any other key sent as is
  • A list stats widget (members, unsubscribed, average open and click rate) and a recent campaigns table (the latest five sent), cached for 5 minutes
  • Retries on 429, 5xx and connection errors with Retry-After or exponential backoff; a rate-limited queued job is released and tried again later
  • Access through authorizeUsing(), a manageAudience gate, or open to every panel user when neither is set
  • An audience-sync:lists command that prints the audiences or lists your key can see, with their ids
A Users table in a light Filament panel with a Mailing list bulk action button above it and Name, Email and Mailing list columns. Eight users are listed: Admin User, Ada Lovelace, Alan Turing, Katherine Johnson and Tim Berners-Lee show Subscribed in green, Grace Hopper shows Unsubscribed and Linus Pauling Archived in provider, both in gray, and Margaret Hamilton shows Pending confirmation in blue. Each row has a View link and a Mailing list action button, and the footer reads Showing 1 to 8 of 8 results.
A Users table in a light Filament panel with a Mailing list bulk action button above it and Name, Email and Mailing list columns. Eight users are listed: Admin User, Ada Lovelace, Alan Turing, Katherine Johnson and Tim Berners-Lee show Subscribed in green, Grace Hopper shows Unsubscribed and Linus Pauling Archived in provider, both in gray, and Margaret Hamilton shows Pending confirmation in blue. Each row has a View link and a Mailing list action button, and the footer reads Showing 1 to 8 of 8 results.

Install

Requires PHP 8.2+, Filament 4 and 5, and Laravel 12 and 13.

composer require amjadiqbal/filament-audience-sync

Usage

use AmjadIqbal\FilamentAudienceSync\Actions\AudienceActions;
use AmjadIqbal\FilamentAudienceSync\AudienceSyncPlugin;
use AmjadIqbal\FilamentAudienceSync\Tables\AudienceStatusColumn;

// Panel provider
$panel->plugin(AudienceSyncPlugin::make());

// Resource table
public static function table(Table $table): Table
{
    return $table
        ->columns([
            TextColumn::make('name'),
            TextColumn::make('email'),
            AudienceStatusColumn::make(),
        ])
        ->recordActions([AudienceActions::group()])
        ->toolbarActions([AudienceActions::bulkGroup()]);
}

Setup

Install the package, publish and run its one migration, and set the driver, API key and list id in .env. The default driver is mailchimp. For a Mailchimp audience the data centre (such as us21) is read from the end of the API key, or from MAILCHIMP_SERVER. For a Brevo list, use an API v3 key and the numeric list id. php artisan audience-sync:lists prints the audiences or lists the key can see, with their ids. Bulk actions need a queue worker; the sync connection works for small lists.

  1. 1 composer require amjadiqbal/filament-audience-sync
  2. 2 php artisan vendor:publish --tag=filament-audience-sync-migrations, then php artisan migrate
  3. 3 Set AUDIENCE_SYNC_DRIVER (mailchimp or brevo) and MAILCHIMP_API_KEY with MAILCHIMP_LIST_ID, or BREVO_API_KEY with BREVO_LIST_ID.
  4. 4 Implement Subscribable on your model and use the SyncsWithAudience trait.
  5. 5 Add ->plugin(AudienceSyncPlugin::make()) to your panel provider, and the column and actions to the resource.
  6. 6 Decide who may use it (see "Who can use it"); out of the box every user who can open the panel can.
use AmjadIqbal\FilamentAudienceSync\Concerns\SyncsWithAudience;
use AmjadIqbal\FilamentAudienceSync\Contracts\Subscribable;

class User extends Authenticatable implements Subscribable
{
    use SyncsWithAudience;

    public function audienceFields(): array
    {
        return ['first_name' => $this->first_name, 'last_name' => $this->last_name, 'COMPANY' => $this->company];
    }

    public function audienceTags(): array
    {
        return $this->is_customer ? ['customer'] : [];
    }
}

Fields and tags

The trait's defaults: audienceEmail() is the trimmed email attribute, audienceFields() splits name at the first space into first_name and last_name, and audienceTags() is empty. Override any of them. A record without a valid email address is skipped with "No valid email address".

  • Each driver maps neutral keys in config/audience-sync.php: first_name, last_name and phone become FNAME, LNAME and PHONE for Mailchimp, and FIRSTNAME, LASTNAME and SMS for Brevo. Any other key is sent unchanged, so you can return provider field names such as COMPANY.
  • Null and empty values are not sent. Mailchimp rejects merge fields that do not exist in the audience; its message, with the field name, is stored on the record.
  • Tags are added on subscribe and on sync and never removed automatically. Edit tags adds and removes tags by hand.
  • Tags work only with the Mailchimp driver. The Brevo driver sends no tags: Edit tags is hidden for it, and audienceTags() is ignored.
  • Every action and facade method takes an optional driver name, such as AudienceActions::subscribe('brevo'), to use a driver other than the default. The status column and entry always show the default driver's list.

What each action does

Every operation first reads the contact from the provider, then stores the result on the record's row in audience_sync_members (one row per record, driver and list). The same operations are on the AudienceSync facade, which returns a SyncResult with the outcome and status.

  • Subscribe: skipped without calling the provider if this address is recorded as unsubscribed here. Skipped if the provider reports unsubscribed, cleaned or archived. A subscribed or pending member has its fields and tags updated instead. Anyone else is added: subscribed, or pending with double opt-in on Mailchimp; added to the list on Brevo.
  • Sync details: for a subscribed or pending member, updates fields and adds tags, then stores the status. It never subscribes anyone ("Not in the list") and never touches someone unsubscribed, cleaned or archived.
  • Edit tags (Mailchimp only): adds and removes tags on any contact the audience knows, in one request, then re-reads the member. It does not change the status.
  • Unsubscribe: on Mailchimp, sets the member to unsubscribed. On Brevo, blocklists the contact by default (it then gets no marketing email from that account), or with BREVO_UNSUBSCRIBE_MODE=unlink only removes it from the list. The choice is recorded locally even if the provider did not know the address. Contacts are never deleted.
  • AudienceSync::refresh() reads the status from the provider and stores it without sending anything; it has no panel action but can be queued.
  • AudienceSync::queue($records, $operation) queues subscribe, update, unsubscribe or refresh for many records.

The status column and table

AudienceStatusColumn and AudienceStatusEntry read the audienceMembership relation that the trait adds: the row for the default driver and its configured list. Nothing is read from the provider when the table renders.

  • Labels: Subscribed (green), Pending confirmation (blue), Unsubscribed, Cleaned (bounced), Archived in provider and Not subscribed (gray), and Not synced when the record has no row.
  • A record whose last operation failed shows a red icon; its tooltip is the error. Otherwise the tooltip says when it was last synced.
  • The infolist entry adds the last sync time, the stored tags and the last error under the status.
  • Columns of audience_sync_members: the record (subscribable type and id), driver, list_id, email (lowercased), status, tags (JSON), provider_id, last_error, synced_at, unsubscribed_at and timestamps, unique per record, driver and list.
  • The status is only as current as the last operation on that record. Changes made in the provider show up after the next Subscribe, Sync details, Edit tags or refresh().

Bulk actions, queues and rate limits

Bulk actions never call the provider during the request. They group the selected records by model, split them into chunks of 50 (queue.chunk) and dispatch one SyncAudienceChunk job per chunk on queue.connection and queue.name.

  • A job loads its records and runs the operation on each one in turn. A record that fails has the error stored on its row, and the job carries on.
  • Every HTTP request is retried up to 3 times on a 429, a 5xx or a dropped connection. The wait is the Retry-After (or x-sib-ratelimit-reset or X-RateLimit-Reset) header when it is a number of seconds, else 0.5, 1 and 2 seconds. A single wait longer than max_wait_seconds (20) ends the retries at once.
  • A queued job still rate limited after the retries is released and tried again after the provider's wait, at least 30 seconds, starting the chunk over; operations are safe to repeat. A job is tried at most 6 times, with a backoff of 30 seconds up to 10 minutes when it fails.
  • In the panel, a rate limit after the retries shows as a failed sync with "rate limiting requests"; it is not stored on the record.
  • Requests time out after 15 seconds. Tune http.timeout, http.retries, http.backoff_ms and http.max_wait_seconds in the config.

Dashboard widgets

Two widgets are added to the panel dashboard, for users allowed the stats ability. ->dashboardWidgets(false) leaves them off. Both read the default driver's list and cache the result for cache_seconds (300 by default, 0 for none); errors are not cached and show on the widget instead of breaking the dashboard.

  • List stats, Mailchimp: one read of the audience. Members is member_count, Unsubscribed is unsubscribe_count, and the open and click rates are the audience's own averages.
  • List stats, Brevo: one read of the list plus its recent campaigns. Members is uniqueSubscribers (or totalSubscribers), Unsubscribed is the list's blocklisted count (totalBlacklisted), and the open and click rates average up to the 10 latest sent campaigns to that list (unique opens and clicks over delivered).
  • Recent campaigns: the five latest sent campaigns to the list with subject, sent time, recipients, opens and clicks. Mailchimp is asked for campaigns of that audience; for Brevo the plugin reads the 50 latest sent campaigns and keeps the ones sent to your list.

Provider API use

Each driver extends HttpDriver, which adds authentication, retries and key redaction to Laravel's HTTP client.

  • Mailchimp: https://{dc}.api.mailchimp.com/3.0 with HTTP basic auth (API key as the password). Members are addressed by the MD5 hash of the lowercased email: GET to read, POST to the list's members to create, PUT for a transactional contact, PATCH to update merge fields (no status) or to set unsubscribed, and POST to the member's tags.
  • Brevo: https://api.brevo.com/v3 with an api-key header. GET contacts/{email} to read, POST contacts with listIds and updateEnabled to add to the list, PUT contacts/{email} for attributes, emailBlacklisted or unlinkListIds.
  • Stats, campaigns and the lists command use the audience, list, campaign and email campaign list endpoints of each API.
  • There is no webhook endpoint and no scheduled sync. Two-way sync through webhooks is listed in the README as part of a planned Pro version.
  • Adding a provider: implement the AudienceDriver contract (or extend HttpDriver) and register it with AudienceSync::extend(); the consent rules and status storage apply to it.

Who can use it

Every action and widget checks one of five abilities: subscribe, unsubscribe, update, tag and stats. In order:

  • authorizeUsing() on the plugin, if set, decides every ability. The closure receives $ability, $record (or null) and $user.
  • Otherwise, if your app defines a manageAudience gate, it is called with the ability and the record, and a guest is refused.
  • With neither, every user who can open the panel can use the actions and see the widgets.
  • Subscribe, Sync details and Unsubscribe check the ability again when they run. Bulk actions are shown only to users allowed the operation without a record.
Gate::define('manageAudience', fn (User $user, string $ability, ?Model $record) => $user->isAdmin());

AudienceSyncPlugin::make()
    ->authorizeUsing(fn (string $ability, ?Model $record, ?User $user) => $user?->can('marketing'))
    ->dashboardWidgets(false);

API keys and errors

Keys are read from .env through config/audience-sync.php only and are not stored in the database or shown in the panel.

  • A missing key or list id, or a Mailchimp key without a data centre, stops before any request with a message naming the setting. Nothing is stored on the record.
  • A provider error is shown in a notification and stored as last_error on the record, keeping the last known status. Mailchimp field errors are included.
  • Every provider message passes through a redactor that removes the configured key and anything shaped like a Mailchimp key, a Brevo API or SMTP key, or a Bearer or Basic credential, and cuts it to 500 characters.
  • An unexpected exception in a single action shows "Something went wrong" and is reported to your log.

Credits and trademarks

This is an independent project. It is not affiliated with, endorsed by or sponsored by Intuit Mailchimp, Brevo, Kit, MailerLite or Laravel Holdings Inc. Their names are trademarks of their owners and are used here only to say which services the plugin works with. It is also not made or endorsed by Filament.

Hire me

Need this built into your app, or something custom?

I wrote Audience Sync. I can wire it into your Filament panel, extend it for your case, or build the feature around it. Tell me what you need and you'll get a plan and a quote, free.

Support

Found a bug or want a feature? Open an issue on GitHub. For help adding it to your own project, use the quote form above.

More Filament plugins

All plugins

Need something built?

Free quote in 60 seconds