jeffersongoncalves/laravel-activecampaign
Laravel ActiveCampaign is a Laravel integration for the ActiveCampaign API, providing a configured client to manage contacts, lists, tags, and automations. It simplifies setup with Laravel-friendly configuration and facades for building email marketing features.
laravel-activecampaign is a thin Laravel wrapper around the official ActiveCampaign PHP SDK: it registers a singleton client in the container, wires credentials from config/env, and (in most builds of this pattern) exposes a facade for terse calls.
Minimal setup:
composer require jeffersongoncalves/laravel-activecampaign
php artisan vendor:publish --tag=activecampaign-config
Then set credentials in .env:
ACTIVECAMPAIGN_URL=https://youraccount.api-us1.com
ACTIVECAMPAIGN_API_KEY=your-api-key
The URL is not https://api.activecampaign.com — it is the account-specific API URL from Settings → Developer in ActiveCampaign. Copy it verbatim or every request 403s.
Where to look first, in order:
config/activecampaign.php — the only file you actually need to understand; it defines the URL/key keys and any default timeout or retry settings.activecampaign) and whether it is a singleton or a factory. This matters for long-running workers.__callStatic proxying to the SDK, so the facade docblock is thin; the real API surface is the SDK's own classes.First use case: tag a user on registration. In an event listener, resolve the client and call the contact sync endpoint, then add a tag. Get that working synchronously first, then move it behind a queue (see below) before it ever touches production traffic.
Because this is the 1.0.0 release, treat it as the first stable API — pin it ("^1.0") rather than dev-main, and skim the changelog link in the release notes for the pre-1.0 iterations, which is where the naming churn will have happened.
Prefer constructor injection over the facade. The facade is convenient in tinker and one-off commands, but injected dependencies make your sync logic testable and make the coupling explicit:
use Illuminate\Contracts\Container\Container;
public function __construct(private Container $app) {}
public function sync(User $user)
{
$client = $this->app->make('activecampaign');
// ...
}
In practice, wrap the client in your own ActiveCampaignService so your application code never depends on SDK object shapes. The SDK's fluent response objects ($response->contact->id) change between major SDK versions; your wrapper absorbs that.
Always queue the outbound call. ActiveCampaign calls are network-bound and rate-limited. The canonical pattern is an event + queued listener:
// app/Listeners/SyncContactToActiveCampaign.php
class SyncContactToActiveCampaign implements ShouldQueue
{
public $tries = 3;
public $backoff = [10, 60, 300];
public function handle(ActiveCampaignService $ac): void
{
$contactId = $ac->syncContact($this->user);
$ac->addTag($contactId, config('marketing.tags.registered'));
}
}
Note that ShouldQueue listeners get a fresh container per job, so a singleton client is fine — but if you use queue:work with a long --timeout, be aware the underlying HTTP client may hold a stale connection pool.
Map Eloquent models to contacts in one place. Contact "sync" is upsert-by-email. Keep a single method that builds the payload from your User so field names and custom field IDs do not drift between registration, profile update, and nightly backfill:
public function contactPayload(User $user): array
{
return [
'email' => $user->email,
'firstName' => $user->first_name,
'lastName' => $user->last_name,
'fieldValues' => [
['field' => config('activecampaign.fields.plan'), 'value' => $user->plan],
],
];
}
Store the remote contact ID. After a successful sync, persist activecampaign_contact_id on the user. Subsequent tag/list operations take the ID, not the email, and you avoid a lookup round-trip (and a rate-limit slot) on every operation.
Use custom field IDs from config, never literals. Custom field IDs are per-account. Hardcoding 'field' => '12' works on your dev account and silently writes to the wrong field in production. Resolve them once via the fields endpoint, put them in config/activecampaign.php, and commit that.
Testing. Do not hit the real API in tests. Because the SDK ultimately uses Guzzle/Laravel's HTTP client, Http::fake() on the ActiveCampaign host is the cheapest route:
Http::fake([
'*.api-us1.com/api/3/contact/sync' => Http::response(['contact' => ['id' => '42']]),
]);
For unit tests of your wrapper, bind a mock into the container rather than faking HTTP — that keeps the assertion at the behaviour level (addTag was called with these args) instead of the wire format.
https://mycompany.api-us1.com), not the generic API host. A 403 with a valid key is nearly always this.RateLimiter::for('activecampaign')), and let your queue backoff handle the rest.contact/sync vs contact/add. Sync upserts by email and is the safe default; add will fail on duplicates. Deletions are contact/delete and are permanent at the API level — soft-delete locally and reconcile before you call it.while (count($batch) === 100) with an incrementing offset, plus a guard against the total shifting mid-iteration.php artisan config:cache will bake in whatever is in config/activecampaign.php at cache time. If you resolve custom field IDs at runtime and write them back to config, that write is lost on the next deploy — persist to the database instead.fieldValues date fields sent with a local-time string will look shifted. Use ->utc()->toIso8601String().activecampaign in a service provider to a subclass that adds retry, logging middleware, or metrics, rather than forking the package.How can I help you explore Laravel packages today?