Looking to hire Laravel developers? Try LaraJobs

laravel-active-campaign maintained by datomatic

Description
Laravel wrapper for Active Campaign API v3
Last update
2026/04/20 09:53 (dev-main)
License
Downloads
601

Comments
comments powered by Disqus

Laravel wrapper for ActiveCampaign API v3

Latest Version on Packagist GitHub Tests Action Status GitHub Code Style Action Status Total Downloads

A small, explicit Laravel wrapper around the ActiveCampaign API v3. It builds on Laravel's HTTP client, so you keep timeouts, retries, Http::fake() and the rest of the framework's tooling, while the package takes care of authentication, the /api/3 base path, the request/response envelopes ActiveCampaign uses (contact, tag, field, fieldValue, …) and error handling.

use Datomatic\ActiveCampaign\Facades\ActiveCampaign;

$contact = ActiveCampaign::contacts()->sync([
    'email' => 'john@example.com',
    'firstName' => 'John',
    'lastName' => 'Doe',
]);

ActiveCampaign::contacts()->tag($contact['id'], 5);

Requirements

  • PHP 8.3+
  • Laravel 12 or 13

Installation

composer require datomatic/laravel-active-campaign

Publish the config file:

php artisan vendor:publish --tag="active-campaign-config"

Then add your credentials to .env:

ACTIVE_CAMPAIGN_BASE_URL=https://your-account.api-us1.com
ACTIVE_CAMPAIGN_API_KEY=your-api-key

The base URL is your account URL without the /api/3 suffix — the package appends it. You can find both values in your ActiveCampaign account under Settings → Developer.

Configuration

return [
    'base_url' => env('ACTIVE_CAMPAIGN_BASE_URL'),

    'api_key' => env('ACTIVE_CAMPAIGN_API_KEY'),

    // Request timeout, in seconds.
    'timeout' => 100,

    // How many times a request is attempted before giving up.
    // Only connection errors, 429 and 5xx responses are retried.
    'retry_times' => 3,

    // How long to wait between two attempts, in milliseconds.
    'retry_sleep' => 1000,

    // Map your ActiveCampaign custom field ids to the names you want to use in your code.
    'custom_fields' => [
        // 'is_email_verified' => 50,
    ],
];

Usage

Every resource is reachable from the ActiveCampaign facade (or by injecting Datomatic\ActiveCampaign\ActiveCampaign):

Method Resource ActiveCampaign endpoints
ActiveCampaign::contacts() contacts, contact tags, list subscriptions /contacts, /contact/sync, /contactTags, /contactLists
ActiveCampaign::import() bulk contact importer /import/bulk_import, /import/info
ActiveCampaign::lists() contact lists /lists
ActiveCampaign::automations() automations (read only) /automations
ActiveCampaign::contactAutomations() contact ↔ automation enrolments /contactAutomations
ActiveCampaign::deals() deals /deals
ActiveCampaign::dealStages() stages inside a pipeline /dealStages
ActiveCampaign::pipelines() pipelines /dealGroups
ActiveCampaign::accounts() CRM accounts /accounts
ActiveCampaign::accountContacts() account ↔ contact associations /accountContacts
ActiveCampaign::notes() notes on any record /notes
ActiveCampaign::tags() tags /tags
ActiveCampaign::fields() custom field definitions, their options and list relations /fields, /fieldOption/bulk, /fieldRels
ActiveCampaign::fieldOptions() selectable values of a field /fieldOptions, /fieldOption/bulk
ActiveCampaign::fieldRels() field ↔ list relations /fieldRels
ActiveCampaign::fieldValues() custom field values of a contact /fieldValues

All of them share the same CRUD surface:

ActiveCampaign::tags()->list();                    // Collection<int, array> — first page only
ActiveCampaign::tags()->list('filters[tagType]=contact');
ActiveCampaign::tags()->get(1);                    // array
ActiveCampaign::tags()->create([...]);             // array
ActiveCampaign::tags()->update(1, [...]);          // array
ActiveCampaign::tags()->delete(1);                 // void

Responses are returned as plain arrays, already unwrapped from the ActiveCampaign envelope and stripped of the links key.

Pagination

The API returns 20 records per page by default and 100 at most, so list() alone will quietly give you a slice of a large collection. Four methods cover the rest:

ActiveCampaign::contacts()->list(limit: 100, offset: 200); // one page, explicitly
ActiveCampaign::contacts()->count();                       // total matching records
ActiveCampaign::contacts()->paginate(perPage: 50);         // LengthAwarePaginator, for views
ActiveCampaign::contacts()->lazy();                        // LazyCollection over every page
ActiveCampaign::contacts()->all();                         // Collection over every page

lazy() fetches one page at a time and only when you consume it, so it is the safe way to walk a large account:

ActiveCampaign::contacts()->lazy()
    ->filter(fn (array $contact) => $contact['email'])
    ->each(fn (array $contact) => ProcessContact::dispatch($contact));

paginate() returns Laravel's LengthAwarePaginator, so ->links() works in a Blade view:

$contacts = ActiveCampaign::contacts()->paginate(perPage: 50);

$contacts->total();       // from the API's meta.total
$contacts->lastPage();
$contacts->items();

A perPage above 100 is clamped to 100, and count() reads the meta.total the API sends back (it returns 0 for the few endpoints that do not report one).

All of them accept the same query string as list(), and it is applied to every page:

ActiveCampaign::contacts()->count('filters[created_after]=2024-01-01');
ActiveCampaign::contacts()->all('filters[created_after]=2024-01-01');

Note on the query string. It is parsed and re-encoded so that pagination params can be merged into it, so email=john@example.com goes out as email=john%40example.com. When the raw query and an explicit limit/offset argument set the same key, the argument wins.

Building queries

Anywhere a query string is accepted you can pass a Query instead, which spares you the API's filters[...] / orders[...] syntax:

use Datomatic\ActiveCampaign\Enums\FilterOperator;
use Datomatic\ActiveCampaign\Support\Query;

$contacts = ActiveCampaign::contacts()->all(
    Query::make()
        ->filter('created_after', new DateTimeImmutable('-30 days'), FilterOperator::GreaterThan)
        ->filter('status', 1)
        ->orderByDesc('cdate')
        ->include('contactTags')
);
Method Produces
filter('email', 'a@b.c') filters[email]=a@b.c
filter('cdate', $date, FilterOperator::GreaterThan) filters[cdate][gt]=...
filters(['a' => 1, 'b' => 2]) several equality filters at once
orderBy('cdate') / orderByDesc('cdate') orders[cdate]=ASC / DESC
include('contactTags', 'contactLists') include=contactTags,contactLists
limit(50) / offset(100) limit=50 / offset=100
where('search', 'john') a top-level param the API defines outside filters, such as contacts' email, search, listid or id_greater

Values are normalised for you: booleans become 1/0, backed enums become their value, arrays are joined with commas, and DateTimeInterface becomes an ISO-8601 string. FilterOperator covers the operators the API supports (eq, neq, lt, lte, gt, gte, contains, starts_with).

A Query is a Stringable, so (string) $query still gives you the raw query string.

Contacts are walked by id

ActiveCampaign recommends paginating contacts with id_greater rather than offset, because a deep offset on a large account is slow and can skip records while the list shifts underneath the walk. contacts()->lazy() and contacts()->all() do that for you, adding orders[id]=ASC&id_greater=<last id> to each page.

If your query already sets orders[...], id_greater or id_less, the generic offset walk is used instead so your ordering is preserved. paginate() is always offset-based, since it needs addressable page numbers.

Contacts

use Datomatic\ActiveCampaign\Facades\ActiveCampaign;

// List / search / filter — the query string is passed through to the API
$contacts = ActiveCampaign::contacts()->list('email=john@example.com');

$contact = ActiveCampaign::contacts()->get(1);

// Create — 'email' is required, unknown keys are dropped
$contact = ActiveCampaign::contacts()->create([
    'email' => 'john@example.com',
    'firstName' => 'John',
    'lastName' => 'Doe',
    'phone' => '+39 000 000 0000',
]);

// Create or update by email in a single call
$contact = ActiveCampaign::contacts()->sync([
    'email' => 'john@example.com',
    'firstName' => 'John',
]);

ActiveCampaign::contacts()->update(1, ['email' => 'john@example.com', 'lastName' => 'Doe']);

ActiveCampaign::contacts()->delete(1);

create(), update() and sync() accept only email, firstName, lastName, phone and the custom field names you declared in the config. Everything else is ignored, so you can hand them a model's toArray() without filtering it first. All three require email and throw an ActiveCampaignException without it.

Custom fields

ActiveCampaign identifies custom fields by numeric id. Map them once in the config file:

'custom_fields' => [
    'is_email_verified' => 50,
    'city' => 51,
],

and then use their names on both sides of the call:

$contact = ActiveCampaign::contacts()->sync([
    'email' => 'john@example.com',
    'is_email_verified' => '1',
    'city' => 'Rome',
]);

$contact['city']; // 'Rome'

Empty values are skipped, so a field you do not pass is never overwritten with an empty string.

Tags on a contact

ActiveCampaign::contacts()->tags(1);        // the contactTag rows of contact 1
ActiveCampaign::contacts()->tag(1, 5);      // apply tag 5 to contact 1
ActiveCampaign::contacts()->untag(1, 5);    // throws if the contact is not tagged
ActiveCampaign::contacts()->tryUntag(1, 5); // no-op if the contact is not tagged

ActiveCampaign::contacts()->getContactTagId(1, 5); // ?int

Removing a tag needs the id of the association, not of the tag, so untag() and tryUntag() resolve it for you with an extra GET before the DELETE.

List subscriptions

use Datomatic\ActiveCampaign\Enums\ListStatus;

ActiveCampaign::contacts()->lists(1);   // current subscriptions, with their status

ActiveCampaign::contacts()->updateListStatus(1, [
    2 => ListStatus::Subscribed,
    3 => ListStatus::Unsubscribed,
]);

The array is keyed by list id. Plain integers (1 / 2) are accepted as well. One request per list is sent, because the API only accepts a single contactList object per call.

Automations

ActiveCampaign::contacts()->automations(1);              // automations the contact is in
ActiveCampaign::contacts()->addToAutomation(1, 42);
ActiveCampaign::contacts()->removeFromAutomation(1, 42); // throws if the contact is not in it
ActiveCampaign::contacts()->tryRemoveFromAutomation(1, 42);

ActiveCampaign::contacts()->getContactAutomationId(1, 42); // ?int

As with tags, removing needs the id of the enrolment rather than of the automation, so the remove methods resolve it with an extra GET before the DELETE.

Bulk import

Writing contacts one at a time means one request each, against a limit of 5 requests per second. The importer queues up to 250 contacts per request instead:

use Datomatic\ActiveCampaign\Enums\BulkImportStatus;

$result = ActiveCampaign::import()->bulk([
    ['email' => 'a@example.com', 'firstName' => 'Jane', 'city' => 'Rome'],
    ['email' => 'b@example.com', 'tags' => ['customer'], 'subscribe' => [1, 2]],
]);

$result['batchId']; // "0641fbdd-..."

Contacts are accepted in the same shape as contacts()->sync()firstName, lastName and the custom field names from your config — and translated to the different one this endpoint expects (first_name, fields: [{id, value}]). Its own keys are passed through untouched if you prefer writing them directly, and subscribe/unsubscribe accept plain list ids as well as [['listid' => 1]].

bulkAll() splits anything larger into batches the API accepts, and takes a LazyCollection so a large import never has to sit in memory:

$batches = ActiveCampaign::import()->bulkAll(
    User::lazy()->map(fn (User $user) => ['email' => $user->email, 'firstName' => $user->name]),
);

The import is asynchronous, so poll for the outcome:

$status = ActiveCampaign::import()->statusOf($result['batchId']); // ?BulkImportStatus

if ($status?->isFinished()) {
    $info = ActiveCampaign::import()->status($result['batchId']);

    $info['success']; // ids of the contacts created
    $info['failure']; // emails the API rejected
}

ActiveCampaign::import()->info(); // outstanding and recently completed batches, account wide

statusOf() returns null while the API has not set a status yet, which is the case for the first moment after queueing — leave a short delay before polling.

Lists

ActiveCampaign::lists()->list();
ActiveCampaign::lists()->list('filters[name]=Newsletter');
ActiveCampaign::lists()->get(1);

ActiveCampaign::lists()->createList(
    name: 'Newsletter',
    stringId: 'newsletter',
    senderUrl: 'https://example.com',
    senderReminder: 'You subscribed on our website.',
);

ActiveCampaign::lists()->delete(1);

The four arguments of createList() are the ones the API requires; a fifth array is merged into the list object for anything else (channel, user, send_last_broadcast, …).

ActiveCampaign does not document an update endpoint for lists, so lists()->update() is inherited but unverified. See API-COVERAGE.md.

Automations

Automations themselves are read only in the API — you build them in the ActiveCampaign UI and enrol contacts through the API:

ActiveCampaign::automations()->list();
ActiveCampaign::automations()->get(42);

ActiveCampaign::contactAutomations()->add(1, 42);   // same as contacts()->addToAutomation()
ActiveCampaign::contactAutomations()->list('filters[automation]=42');

Deals, pipelines and accounts

Pipelines are called dealGroups in the API; this package calls them pipelines.

$pipeline = ActiveCampaign::pipelines()->createPipeline('Qualifications', [
    'currency' => 'eur',
    'autoassign' => 1,
]);

$stage = ActiveCampaign::dealStages()->createStage('Initial contact', $pipeline['id'], [
    'order' => 1,
    'color' => '32B0FC',
]);

$deal = ActiveCampaign::deals()->createDeal(
    title: 'New business',
    value: 150000,          // in cents
    currency: 'eur',        // 3-letter ISO code
    groupId: $pipeline['id'],
    stageId: $stage['id'],
    ownerId: 1,
    contactId: 7,
);

value is in cents and currency is lower-cased for you. A deal needs a primary contact or an account — passing neither throws rather than letting the API reject it.

$account = ActiveCampaign::accounts()->createAccount('Example Ltd', [
    'accountUrl' => 'https://example.com',
    'owner' => 1,
]);

ActiveCampaign::accounts()->addContact($account['id'], 7, 'Product Manager');
ActiveCampaign::accountContacts()->list('filters[account]='.$account['id']);

Notes attach to any of the record types the API supports:

use Datomatic\ActiveCampaign\Enums\NoteRelType;

ActiveCampaign::notes()->createNote('Called them back', 7);                        // a contact
ActiveCampaign::notes()->createNote('Budget approved', $deal['id'], NoteRelType::Deal);
ActiveCampaign::notes()->createNote('Renewal in March', $account['id'], NoteRelType::Account);

Tags

use Datomatic\ActiveCampaign\Enums\TagType;

ActiveCampaign::tags()->list();
ActiveCampaign::tags()->createTag('customer', 'a paying user');
ActiveCampaign::tags()->createTag('header', '', TagType::Template);
ActiveCampaign::tags()->updateTag(1, 'customer', 'updated description');
ActiveCampaign::tags()->delete(1);

tagType defaults to contact when you do not pass one.

Fields

use Datomatic\ActiveCampaign\Enums\FieldType;

ActiveCampaign::fields()->list();

ActiveCampaign::fields()->createField('City');
ActiveCampaign::fields()->createField('Birthday', FieldType::Date, [
    'perstag' => 'BIRTHDAY',
    'visible' => 1,
]);

ActiveCampaign::fields()->updateField(1, 'Town', FieldType::Text);
ActiveCampaign::fields()->delete(1);

The third argument is merged into the field object, so any attribute the API supports (descript, perstag, defval, visible, ordernum, isrequired, …) can be passed through.

A field is not usable on its own

Two things are easy to miss, and the API will not warn you about either:

  1. A custom field stays invisible until it is related to a list. A contact only sees a field if one of the lists it belongs to has a relation to that field.
  2. Dropdown, listbox, radio, checkbox and multiselect fields need their options created separately. FieldType::requiresOptions() tells you which types those are.

createField() can do all three steps in one call:

$field = ActiveCampaign::fields()->createField(
    'Department',
    FieldType::DropDown,
    options: ['Sales', 'Engineering', 'Support'],
    lists: [1, 2],
);

That creates the field, bulk-creates its options in the order given, and relates it to lists 1 and 2. Omit options/lists and nothing extra is sent.

Each step is also available on its own:

ActiveCampaign::fields()->createOptions(34, ['Sales', 'Engineering']);
ActiveCampaign::fields()->options(34);      // Collection of the field's options

ActiveCampaign::fields()->relate(34, 1);    // relate field 34 to list 1
ActiveCampaign::fields()->relate(34);       // relate it to every list
ActiveCampaign::fields()->relations(34);    // Collection of the field's list relations

Options accept plain strings, or full arrays when you need more control. A string becomes an option whose label and value match, and orderid follows the array order unless you set it yourself:

ActiveCampaign::fields()->createOptions(34, [
    ['value' => 'sales', 'label' => 'Sales', 'isdefault' => true],
    ['value' => 'eng', 'label' => 'Engineering'],
]);

Options and relations have their own resources too, if you want to work with them directly:

ActiveCampaign::fieldOptions()->createMany([
    ['field' => 34, 'value' => 'Sales', 'label' => 'Sales', 'orderid' => 1],
]);

ActiveCampaign::fieldRels()->relate(34, 1);

ActiveCampaign creates options only through its bulk endpoint, so fieldOptions()->create() sends a one-element bulk request and hands you back the created option.

Field values

// Set custom field 3 of contact 7
ActiveCampaign::fieldValues()->createFieldValue(7, 3, 'Rome');

ActiveCampaign::fieldValues()->updateFieldValue(1, 3, 'Milan');

ActiveCampaign::fieldValues()->list('filters[fieldid]=3');
ActiveCampaign::fieldValues()->delete(1);

For contacts you own, contacts()->sync() with the custom_fields mapping is usually the shorter path — this resource is there for the cases where you need to address a field value directly.

Error handling

Every failing response raises a Datomatic\ActiveCampaign\Exceptions\ActiveCampaignException carrying the endpoint and the error body returned by ActiveCampaign:

use Datomatic\ActiveCampaign\Exceptions\ActiveCampaignException;

try {
    ActiveCampaign::contacts()->get(999999);
} catch (ActiveCampaignException $e) {
    // The request to "contacts/999999" generated this error: [{"title":"No Result found ..."}]
    report($e);
}

A misconfigured package raises Datomatic\ActiveCampaign\Exceptions\InvalidConfig instead, on the first call that needs the missing value.

Connection errors, 429 and 5xx responses are retried according to retry_times / retry_sleep before the exception is raised. 4xx responses are not retried.

Escape hatches

request() is public on every resource, so an endpoint the package does not wrap yet is still one line away:

use Datomatic\ActiveCampaign\Enums\Method;

$lists = ActiveCampaign::contacts()->request(
    method: Method::GET,
    path: 'lists',
    responseKey: 'lists',
);

You can also resolve the client directly and skip the resource layer entirely:

use Datomatic\ActiveCampaign\Contracts\ActiveCampaignClientContract;

$response = resolve(ActiveCampaignClientContract::class)
    ->send(Method::GET, 'campaigns'); // Illuminate\Http\Client\Response

Testing your own code

The package uses Laravel's HTTP client, so Http::fake() works as usual. ActiveCampaignFake saves you from writing base urls and response envelopes by hand:

use Datomatic\ActiveCampaign\Enums\Method;
use Datomatic\ActiveCampaign\Testing\ActiveCampaignFake;

ActiveCampaignFake::fake([
    'contacts' => ActiveCampaignFake::list('contacts', [
        ['id' => '1', 'email' => 'john@example.com'],
    ], total: 42),

    'contacts/1' => ActiveCampaignFake::single('contact', ['id' => '1']),

    'contacts/2' => ActiveCampaignFake::error(['No Result found'], 404),
]);

// ... exercise your code ...

ActiveCampaignFake::assertSent(Method::POST, 'contactTags');
ActiveCampaignFake::assertSentJson(Method::POST, 'contactTags', [
    'contactTag' => ['contact' => 1, 'tag' => 5],
]);
ActiveCampaignFake::assertNotSent(Method::DELETE, 'contacts/*');
ActiveCampaignFake::assertSentCount(2);

Paths are relative to /api/3 and may contain a * wildcard. A path you do not list answers with an empty 200, so a test only describes the calls it cares about, and query strings are ignored when matching so a paginated call still matches its bare path.

Testing

composer test
composer analyse
composer format

Roadmap

API-COVERAGE.md lists exactly which ActiveCampaign endpoints this package wraps and which it doesn't. ROADMAP.md is what is still to be built, in the order it is worth doing.

An endpoint the package does not wrap is still one line away — see Escape hatches.

Changelog

Please see CHANGELOG for more information on what has changed recently.

Contributing

Please see CONTRIBUTING for details.

Security Vulnerabilities

Please review our security policy on how to report security vulnerabilities.

Credits

License

The MIT License (MIT). Please see License File for more information.