xavicabot/filament-activecampaign
Connect ActiveCampaign to your Filament admin panel. Manage contacts, lists, tags, and automations directly from Laravel—no separate dashboard required. Simple setup, native Filament UI, and a smooth workflow for your team.
Match the branch to your Filament version before anything else. This package ships two maintenance lines and they are not interchangeable:
| Your Filament | Package line | Latest |
|---|---|---|
^3.0 |
1.x |
v1.4.0 (this release) |
^4.0 | ^5.0 |
2.x (main) |
v2.1.0 |
composer require xavicabot/filament-activecampaign:^1.4
Then publish and configure:
php artisan vendor:publish --tag="activecampaign-config"
ACTIVECAMPAIGN_API_URL=https://youraccount.api-us1.com
ACTIVECAMPAIGN_API_KEY=your-key
Register the plugin on your panel (only if you want the Filament-side UI/settings it exposes):
// app/Providers/Filament/AdminPanelProvider.php
public function panel(Panel $panel): Panel
{
return $panel
->plugin(ActiveCampaignPlugin::make());
}
Where to look first: the published config/activecampaign.php. Everything you will touch day-to-day — API credentials, the async flag, async_tries, async_backoff, and any field mappings — lives there. Read it before writing code; most "why isn't this firing" questions are answered in that file.
First use case: trigger an automation when a user signs up. The most direct path is the service/facade:
use Xavicabot\FilamentActiveCampaign\Facades\ActiveCampaign;
ActiveCampaign::trigger(
user: $user,
automationId: config('activecampaign.automations.welcome'),
);
Or with an email attached, which is the common case for contact-form style flows:
ActiveCampaign::triggerWithEmail($user, $automationId);
Verify it worked by checking the automation's contact activity in ActiveCampaign, not just your logs — a 200 from /contact/sync does not guarantee an automation enrolled the contact.
The natural integration point is right after a record is persisted. Prefer model events or Filament's afterCreate, not the form's save() return value:
TextInput::make('email')->required(),
// in the resource's CreateRecord / EditRecord
protected function afterCreate(): void
{
ActiveCampaign::triggerWithEmail($this->record, $this->automationId);
}
For anything broader than a single resource, use an observer so the automation fires regardless of whether the change came from Filament, an artisan command, or an importer.
Since v1.4.0, trigger() and triggerWithEmail() honour the activecampaign.async config flag. Set it to true in production and every call is pushed to the queue — you get the same public API with no code changes. Use the explicit triggerAsync / triggerWithEmailAsync methods when you want a specific call queued regardless of the global flag (e.g. a bulk import that must never block the request).
// config/activecampaign.php
'async' => env('ACTIVECAMPAIGN_ASYNC', true),
'async_tries' => 3,
'async_backoff' => [10, 60, 300],
Backoff as an array gives you exponential-ish retries for the transient 429/5xx cases ActiveCampaign is prone to. Keep async_tries low — a retried job re-runs the whole automation trigger, so a long retry chain can mean duplicate enrolments if the failure happened after the API accepted the write.
You no longer need to sequence field writes yourself. A single automation execution now issues one POST /contact/sync carrying every custom field (fieldValues[]) and every system field (firstName, phone, …). The release notes' example — 4 custom fields + 3 system fields — drops from ~5 requests to 2.
Day-to-day this means: do not hand-roll loops of updateContact calls around a trigger. Just map your fields in config and let the runner batch them. It also means one failing field fails the whole sync, so validate your mappings once rather than assuming partial success.
v1.4.0 fills the gap that used to force people into raw HTTP calls. The service layer is what you want in application code:
// Read
$contact = ActiveCampaign::getContactByEmail($user->email); // ?array
$tags = ActiveCampaign::getContactTags($contact['id']); // array
// Write — signature widened, string or array both work
ActiveCampaign::addTagToContact($contact['id'], ['trial', 'newsletter']);
ActiveCampaign::removeTagFromContact($contact['id'], 'trial');
removeTagFromContact() is the ergonomic one: it takes a tag name, resolves the association for you, and calls the underlying detachContactTag($associationId). The existing addTagToContact($contactId, string $tagName) calls still work unchanged.
Drop to ActiveCampaignClient only when you need the raw shape:
$client = app(ActiveCampaignClient::class);
$client->listContactTags($contactId); // GET /contacts/{id}/contactTags
$client->detachContactTag($assocId); // DELETE /contactTags/{id}
A good pattern is tag-as-state-machine: trial → onboarding → customer, driven from your own model events, with removeTagFromContact in the transition. Keeps segmentation in ActiveCampaign while the source of truth stays in your app.
Because triggers are cheap now, wrap them in a small helper so a given automation only fires once per meaningful event:
if (! $user->hasBeenTriggered('welcome')) {
ActiveCampaign::triggerWithEmailAsync($user, $automationId);
$user->markTriggered('welcome');
}
This is the single most effective guard against the duplicate-enrolment problem, more so than queue configuration.
^1.4 for Filament 3, ^2.1 otherwise. This release is only the v3 maintenance line — nothing here is a feature you get on 2.x that you didn't already have.{user.*} / {ctx.*} and resolves to an empty string are now skipped instead of being sent. This mirrors existing system-field behaviour and prevents silently blanking values in ActiveCampaign — but it is a real change. If you relied on a null-then-blank reset, you must now clear the field explicitly (a direct client call), not via a template that happens to resolve empty.TriggerActiveCampaignAutomationJob stores primitive data and re-resolves the user from the database inside handle(). Consequences:
async => true and no worker, everything silently queues forever. This is the most common "the plugin does nothing" report.$contactId. If all you have is an email, getContactByEmail() first — and handle null, since a contact that has never synced will not exist. Same for getContactTags(), which returns [] for an unknown contact rather than throwing./contact/sync payload (with the API key redacted) to see the batched fieldValues[] array. Since the N+1 calls are gone, one log line now shows you every field in the execution — much easier to reason about than the old sequence.async_tries/async_backoff control the queue, not ActiveCampaign's idempotency. A job that partially succeeded before failing will re-trigger on retry.Upgrade verdict: no breaking changes vs v1.3.0, safe drop-in. Update, then immediately audit any template-based field mappings that can resolve to empty, and confirm a queue worker is running before flipping activecampaign.async to true.
How can I help you explore Laravel packages today?