coyshdigital/beaconcrm-php

PHP client for the Beacon CRM API — schema discovery, entity CRUD and upsert, with the field shaping Beacon expects.

Maintainers

Package info

github.com/Coysh-Digital/beaconcrm-php

pkg:composer/coyshdigital/beaconcrm-php

Transparency log

Statistics

Installs: 5

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-07-22 15:42 UTC

This package is auto-updated.

Last update: 2026-07-22 15:47:58 UTC


README

A PHP client for the Beacon CRM API.

Beacon generates its API documentation from each account's own database configuration, so no two accounts look alike. This library reads your schema at runtime and shapes values against it, rather than hard-coding record types and fields that would only fit one account.

use CoyshDigital\Beacon\BeaconClient;

$beacon = BeaconClient::make(getenv('BEACON_ACCOUNT_ID'), getenv('BEACON_API_KEY'));

$people = $beacon->entitiesWithSchema('person');

$id = $people->create(
    $people->payload()
        ->set('name:first', 'Alex')
        ->set('name:last', 'Rivera')
        ->set('emails', 'alex@example.org')
        ->set('c_monthly_gift', 25.5)
)->entityId();

Features

  • Record types, fields, drop-down options and cardinality all come from your account. Custom types and c_* fields work with no extra code.
  • Beacon wants a different JSON shape for almost every field type. Values are converted for you. See Field shaping.
  • Beacon buries the real cause of a validation failure in a nested error.raw property behind a generic message. This library digs it out and puts it in a typed exception.
  • Rate limits and transient server errors are retried with exponential backoff and jitter, honouring Retry-After. Validation failures never are.
  • Any call can be described without being sent, so a framework can do its own HTTP and keep its own events, logging and test modes.

Requirements

  • PHP 8.2+
  • Guzzle 7.5+ (installed with the package)

Installation

composer require coyshdigital/beaconcrm-php

Authentication

In Beacon, go to Settings > API keys and create a key. Only an administrator can create one, and the key is shown once, when you create it. Copy it straight away. If you lose it, revoke it and make a new one.

Your account ID is the number in your Beacon API URL:

https://api.beaconcrm.org/v1/account/12345
                                     ^^^^^

Keep both out of your code:

BEACON_ACCOUNT_ID=12345
BEACON_API_KEY=your-secret-key
$beacon = BeaconClient::make(getenv('BEACON_ACCOUNT_ID'), getenv('BEACON_API_KEY'));

if (!$beacon->ping()) {
    // Wrong account ID, or the key was revoked or mistyped.
}

A Beacon key grants full access to the account. Put it in an environment variable or a secrets manager, never in code or version-controlled config.

Discovering the schema

foreach ($beacon->entityTypes()->all() as $type) {
    echo $type->key . ' - ' . $type->label . PHP_EOL;

    foreach ($type->mappableFields() as $field) {
        echo '  ' . $field->key . ' (' . $field->rawType . ')' . PHP_EOL;

        if ($field->options()) {
            echo '    options: ' . implode(', ', $field->options()) . PHP_EOL;
        }
    }
}

EntityType gives you:

Method What you get
fields() Every field, keyed by field key
field('emails') One field. A part handle such as name:first resolves to its parent
writableFields() Fields Beacon will accept a write to
mappableFields() Fields writable from a single plain value, which is the set worth showing in a mapping UI

Field gives you label, type (a FieldType), rawType, options(), allowsMultiple(), includesTime(), isWritable() and isMappable().

Creating records

Build the body with payload(). It shapes each value against the field it is going to:

$people = $beacon->entitiesWithSchema('person');

$response = $people->create(
    $people->payload()
        ->set('name:first', 'Alex')
        ->set('name:last', 'Rivera')
        ->set('emails', 'alex@example.org')
        ->set('phone_numbers', '+441234567890')
        ->set('c_tier', 'Gold')          // single-select, sent as ["Gold"]
        ->set('organisation', 4812)      // record link, sent as [4812]
        ->set('c_monthly_gift', 25.5)    // currency, sent as {"value": 25.5}
);

$response->entityId();   // 4100
$response->entity();     // the full record Beacon stored
$response->references(); // linked-record data, when populated

entitiesWithSchema() fetches the record type's schema once and caches it for the life of the client. If you already have the EntityType, pass it to entities() and nothing is fetched. entities('person') with no schema also works, and sends values exactly as given, which is what you want if you are shaping them yourself.

Arrays work anywhere Beacon takes multiple values:

$people->payload()
    ->set('emails', ['work@example.org', 'home@example.org'])  // first is primary
    ->set('c_channels', ['Email', 'SMS']);

Reading records

$response = $people->read(1988);

// Skip linked-record data. Much faster for large exports.
$response = $people->read(1988, populate: false);

// Include archived records.
$response = $people->read(1988, archived: true);

Updating and upserting

An update leaves any field not in the payload untouched. Note the verb: Beacon takes a PATCH here and answers a PUT with a 404.

$people->update(1988, $people->payload()->set('c_tier', 'Gold'));

Upsert creates a record, or updates the one whose lookup field matches. It is the documented way to avoid duplicates from repeat submissions:

$people->upsert('emails', $people->payload()
    ->set('emails', 'alex@example.org')
    ->set('c_tier', 'Gold'));

The lookup field has to be genuinely unique in your account, and it has to carry a value in the payload. Otherwise nothing can match and every call creates another record. That case is caught before the request goes out:

use CoyshDigital\Beacon\Exception\InvalidPayloadException;

try {
    $people->upsert('emails', $people->payload()->set('c_tier', 'Gold'));
} catch (InvalidPayloadException $e) {
    // Upsert key "emails" has no value in the person payload...
}

An email address is the obvious key for people, but it is also mutable. For migrations and repeatable imports, a stable legacy or external ID makes a better one.

Listing records

Listing lives at the plural entities/{type}, unlike every single-record operation. The response reports the full match count alongside one page of results, and each result arrives in its own {entity, references} envelope. entities() unwraps them:

$response = $people->list(page: 1, perPage: 100, populate: false);

$response->total();     // 39713, the whole match count rather than the page
$response->entities();  // the records, unwrapped

Beacon's own default page size is 200. To walk everything without holding it in memory, each() pages for you:

foreach ($people->each(perPage: 200, populate: false) as $person) {
    echo $person['id'];
}

Pass archived: true to include archived records. On a real account that can be a large jump, since archived records can outnumber live ones.

Set populate: false for any bulk read. Linked-record data makes responses substantially bigger.

No search endpoint

Beacon's guide describes a filtering system, but there is no corresponding API endpoint. entity/{type}/search, /list and /filter all return a 404. Filter a list client-side, or use request() if your account exposes something this library does not model.

Exports

Trigger a CSV export from a saved export template, poll until it finishes, then download from the signed URL:

$exportId = $beacon->exports()->trigger($templateId)->entity()['id'] ?? null;

$status = $beacon->exports()->status($exportId)->results()[0] ?? [];

$status['status'];   // in_progress or finished
$status['progress']; // 0 to 100

Download URLs expire after an hour, though the data is kept for seven days. Poll again for a fresh one. Beacon describes these endpoints as early access and does not list them in its main developer documentation.

Field shaping

Beacon expects a different JSON shape for each field type. EntityPayload converts your values:

Beacon field type What gets sent
Short text, long text, URL The value as-is
Person name An object of name parts, with full derived if you set only the parts
Email [{"email": "...", "is_primary": true}]
Phone [{"number": "...", "is_primary": true}]
Drop-down An array of values, even for single-select fields
Record link An array of integer Beacon record IDs, even for single links
Checkbox A JSON boolean
Number, percent, rating A JSON number
Currency An object, {"value": 25.5}
Date An ISO date such as 2026-07-21. Read back, Beacon returns a full ISO 8601 timestamp

Person names are addressed one part at a time (name:full, name:first, name:last, name:middle, name:prefix) and reassembled into a single object when the payload is built. Set only first and last and full is derived, which matters because full is what Beacon shows throughout its UI.

Currency fields do not take a plain number. Beacon wants an object, and amounts are in major units, so 25.5 means £25.50 rather than 25.5 pence. Send a bare number and Beacon accepts the request with a success response and then stores nothing. The amount disappears with no error anywhere. This library always sends the object form, but keep it in mind if you write to Beacon from anywhere else.

The currency code is left off deliberately, so Beacon applies your account default. Its response echoes the value back with the currency filled in:

{ "currency": "GBP", "value": 25.5, "base_value": 25.5 }

Empty values

Empty values (null, '', []) are skipped rather than sent, so a blank optional field cannot overwrite data Beacon already holds. 0 and false are real values and are sent.

Fields you cannot write

mappableFields() leaves out fields that cannot be written from a plain value:

  • File uploads. Beacon requires a separate signed-upload handshake that cannot happen inside a record payload. File fields can be read, and you get a signed download URL valid for 60 minutes.
  • Locations and addresses. Beacon expects a structured address object.
  • Beacon users. These refer to user accounts rather than data.
  • Anything Beacon calculates. Smart fields, rollups and auto-increments are computed by Beacon and rejected on write. That covers a lot of useful-looking fields, such as totals, counts and percentages derived from other records.

Error handling

Every failure is a BeaconException. The subclasses tell you what to do about it:

Exception When Retried
AuthenticationException 401 or 403. Key revoked, mistyped, or wrong account No
ValidationException 400, or a 5xx carrying Beacon's error.raw detail No
NotFoundException 404. No such account, record type or record No
RateLimitException 429. Over the rate limit Yes
ServerException A bare 5xx Yes
TransportException No HTTP response at all: DNS, timeout, TLS Yes
InvalidPayloadException Caught before sending. Nothing was written n/a
ConfigurationException Missing account ID or API key n/a
use CoyshDigital\Beacon\Exception\ApiException;

try {
    $people->create($payload);
} catch (ApiException $e) {
    $e->getStatus();     // 500
    $e->getErrorCode();  // server_error
    $e->getMessage();    // Oh shoot! An unknown error occurred.
    $e->getRaw();        // Validation error: "gender": 0 Invalid option.
                         // Allowed options: Male, Female, Non-binary, ...
    $e->getPayload();    // what was sent, credentials redacted
    $e->getSummary();    // all of the above on one line, for a log
}

Why a 500 can be a validation failure. Beacon reports many validation problems with a 500 status and the real cause in error.raw, while message stays generic. Those requests will never succeed as sent, and retrying a create would duplicate the record. So a 5xx carrying error.raw becomes a ValidationException and is never retried. A bare 5xx with no such detail is treated as transient.

Rate limits and retries

Beacon allows 300 requests a minute, or 60 for bulk operations, and answers a breach with a 429. The default policy makes 3 attempts with exponential backoff and jitter, honouring Retry-After when Beacon sends it:

use CoyshDigital\Beacon\Http\RetryPolicy;

$beacon = BeaconClient::make($accountId, $apiKey, new RetryPolicy(
    maxAttempts: 5,
    baseDelayMs: 1000,
));

// Or turn retries off and handle them yourself.
$beacon = BeaconClient::make($accountId, $apiKey, RetryPolicy::none());

Describing a request without sending it

Every method that sends has a ...Request() twin that returns an unsent Request:

$request = $people->createRequest($payload);

$request->method; // POST
$request->path;   // entity/person
$request->uri();  // entity/person?populate=false
$request->body;   // the shaped entity

This is for host frameworks that wrap HTTP in their own events, logging, proxy settings or test modes. The Formie Beacon CRM plugin for Craft CMS uses it to shape payloads here while letting Formie do the sending, which keeps its integration logging and payload events working.

Anything else

Beacon generates its API per account, so your account may expose endpoints this library does not model. Paths are relative to .../v1/account/{accountId}/:

$response = $beacon->request('GET', 'some_other_endpoint', ['page' => 2]);
$response->toArray();

Endpoint confidence

Beacon's full API documentation is generated per account and sits behind a login. These endpoints have been exercised against a live account:

Endpoint Purpose
GET entity_types Read the account schema
POST entity/{type} Create a record
GET entity/{type}/{id} Read a record
PATCH entity/{type}/{id} Update a record
PUT entity/{type}/upsert Create or update on a lookup field
GET entities/{type} List records, with page and per_page

The export endpoints (POST entity_export/trigger and GET entity_exports) are documented by Beacon as early access and have not been exercised here.

Some paths that look obvious do not exist, in case you were about to try them:

  • PUT entity/{type}/{id} returns a 404. Updates are PATCH.
  • GET entity/{type} returns a permissions error rather than a list. Listing is the plural entities/{type}.
  • POST entity/{type}/search, /list and /filter all return a 404.

Corrections from other accounts are welcome, especially for the export endpoints.

Testing

composer install
vendor/bin/phpunit
vendor/bin/phpstan analyse

The test suite runs against mocked HTTP using an invented schema. No real account's field keys or option values appear in this repository.

Contributing

Issues and pull requests are welcome at Coysh-Digital/beaconcrm-php.

License

MIT. See LICENSE.md.

This is an independent library. It is not affiliated with or endorsed by Beacon.