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.
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
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 composer require amjadiqbal/filament-audience-sync
- 2 php artisan vendor:publish --tag=filament-audience-sync-migrations, then php artisan migrate
- 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 Implement Subscribable on your model and use the SyncsWithAudience trait.
- 5 Add ->plugin(AudienceSyncPlugin::make()) to your panel provider, and the column and actions to the resource.
- 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.
How "never resubscribe" works
The rules live in the manager, not in the drivers, so a driver you add gets them too. An unsubscribe recorded here holds until the provider itself reports the person subscribed or pending again, for example after they rejoin through the provider's own signup form.
- Blocking statuses are unsubscribed, cleaned (bounced) and archived. On Mailchimp they come from the member's status. On Brevo a contact counts as unsubscribed when it is blocklisted or its listUnsubscribed includes your list; the Brevo driver has no cleaned, archived or pending state.
- Before Subscribe calls the provider, the plugin checks the local table for a row with this driver, list and email whose status is unsubscribed. That blocks the address on every record, not only the one that was unsubscribed.
- Unsubscribe stores status unsubscribed and the time in unsubscribed_at.
- A later read keeps the local unsubscribe when the provider reports anything other than subscribed or pending, including a contact it no longer knows. When the provider reports subscribed or pending, the row follows it. Subscribe never makes that read for a locally unsubscribed address; Sync details or refresh() does.
- Double opt-in (MAILCHIMP_DOUBLE_OPT_IN=true): new contacts are created as pending and the provider sends its confirmation email. Pending counts as a member: Subscribe updates it instead of re-adding it, and Sync details updates its fields. The column moves to Subscribed on the next operation that reads the member after they confirm, since there are no webhooks.
- On Mailchimp, a new contact is created with a POST, which cannot change an existing member. A transactional contact (known to the audience but never opted in) is opted in with a PUT. On Brevo, adding to a list never lifts a blocklisting.
- The README has consent and GDPR notes: keep a lawful basis on record, prefer double opt-in, and on an erasure request delete the contact in the provider and the record's rows in audience_sync_members, since the plugin deletes neither.
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
RankForge Lite
Adds meta, Open Graph and X fields to any Filament resource, scores the content for SEO and readability as you type, and previews the Google result before you publish.
Change Requests
Holds edits to Filament records as change requests that a reviewer checks field by field, then approves, rejects or rolls back.
Deleted Records
Lists the records spatie/laravel-deleted-models kept when they were deleted, previews their stored attributes, and restores or permanently deletes them.
Stat Cards
Adds progress bars, icon tiles, card and text colours, trends, badges and bar charts to the stats overview widget you already have, by changing one import.
Uptime Monitor
Lists the sites spatie/laravel-uptime-monitor watches with their uptime and certificate status, and lets you add, edit, check, turn on and off and delete them from your panel.
XLSX Import
A drop-in replacement for Filament's ImportAction that also reads Excel (.xlsx) and OpenDocument (.ods) workbooks, with a sheet picker, through the Importer classes you already have.
Billing Console
A free, read-only billing console for apps that bill with Laravel Cashier: customers, subscriptions, invoices and payments, with an MRR and churn summary, in Filament 4 and 5.
Record Settings
A free way to edit per-user, per-team and per-tenant settings in Filament 4 and 5: a settings page, a settings modal on table rows and settings fields inside resource forms, stored with glorand/laravel-model-settings or in a JSON column.