PHPackages                             coyshdigital/beaconcrm-php - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [API Development](/categories/api)
4. /
5. coyshdigital/beaconcrm-php

ActiveLibrary[API Development](/categories/api)

coyshdigital/beaconcrm-php
==========================

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

v1.0.0(yesterday)04↑2900%MITPHP &gt;=8.2

Since Jul 22Compare

[ Source](https://github.com/Coysh-Digital/beaconcrm-php)[ Packagist](https://packagist.org/packages/coyshdigital/beaconcrm-php)[ RSS](/packages/coyshdigital-beaconcrm-php/feed)WikiDiscussions Synced today

READMEChangelogDependencies (4)Versions (2)Used By (0)

Beacon CRM for PHP
==================

[](#beacon-crm-for-php)

A PHP client for the [Beacon CRM](https://beaconcrm.org) 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
--------

[](#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](#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
------------

[](#requirements)

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

Installation
------------

[](#installation)

```
composer require coyshdigital/beaconcrm-php
```

Authentication
--------------

[](#authentication)

In Beacon, go to **Settings &gt; 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
----------------------

[](#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:

MethodWhat 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
----------------

[](#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
---------------

[](#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
----------------------

[](#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-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

[](#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()`](#anything-else) if your account exposes something this library does not model.

Exports
-------

[](#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
-------------

[](#field-shaping)

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

Beacon field typeWhat gets sentShort text, long text, URLThe value as-isPerson nameAn object of name parts, with `full` derived if you set only the partsEmail`[{"email": "...", "is_primary": true}]`Phone`[{"number": "...", "is_primary": true}]`Drop-downAn array of values, **even for single-select fields**Record linkAn array of integer Beacon record IDs, even for single linksCheckboxA JSON booleanNumber, percent, ratingA JSON numberCurrencyAn object, `{"value": 25.5}`DateAn ISO date such as `2026-07-21`. Read back, Beacon returns a full ISO 8601 timestampPerson 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)

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

[](#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
--------------

[](#error-handling)

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

ExceptionWhenRetried`AuthenticationException`401 or 403. Key revoked, mistyped, or wrong accountNo`ValidationException`400, or a 5xx carrying Beacon's `error.raw` detailNo`NotFoundException`404. No such account, record type or recordNo`RateLimitException`429. Over the rate limitYes`ServerException`A bare 5xxYes`TransportException`No HTTP response at all: DNS, timeout, TLSYes`InvalidPayloadException`Caught before sending. Nothing was writtenn/a`ConfigurationException`Missing account ID or API keyn/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
-----------------------

[](#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
---------------------------------------

[](#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](https://github.com/Coysh-Digital/craft-formie-beacon)uses it to shape payloads here while letting Formie do the sending, which keeps its integration logging and payload events working.

Anything else
-------------

[](#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
-------------------

[](#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:

EndpointPurpose`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
-------

[](#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
------------

[](#contributing)

Issues and pull requests are welcome at [Coysh-Digital/beaconcrm-php](https://github.com/Coysh-Digital/beaconcrm-php).

License
-------

[](#license)

MIT. See [LICENSE.md](LICENSE.md).

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

###  Health Score

40

—

FairBetter than 86% of packages

Maintenance100

Actively maintained with recent releases

Popularity5

Limited adoption so far

Community2

Small or concentrated contributor base

Maturity45

Maturing project, gaining track record

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Unknown

Total

1

Last Release

1d ago

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/5764356?v=4)[Tim Coysh](/maintainers/Coysh)[@Coysh](https://github.com/Coysh)

---

Tags

apiclientsdkcrmbeaconbeacon crmbeaconcrm

###  Code Quality

TestsPHPUnit

Static AnalysisPHPStan

Type Coverage Yes

### Embed Badge

![Health badge](/badges/coyshdigital-beaconcrm-php/health.svg)

```
[![Health](https://phpackages.com/badges/coyshdigital-beaconcrm-php/health.svg)](https://phpackages.com/packages/coyshdigital-beaconcrm-php)
```

###  Alternatives

[aws/aws-sdk-php

AWS SDK for PHP - Use Amazon Web Services in your PHP project

6.3k543.5M2.7k](/packages/aws-aws-sdk-php)[neuron-core/neuron-ai

The PHP Agentic Framework.

2.0k656.1k46](/packages/neuron-core-neuron-ai)[openai-php/client

OpenAI PHP is a supercharged PHP API client that allows you to interact with the Open AI API

5.8k28.0M324](/packages/openai-php-client)[openai-php/laravel

OpenAI PHP for Laravel is a supercharged PHP API client that allows you to interact with the Open AI API

3.7k9.5M90](/packages/openai-php-laravel)[eslazarev/wildberries-sdk

Wildberries OpenAPI clients (generated).

293.1k](/packages/eslazarev-wildberries-sdk)[tempest/framework

The PHP framework that gets out of your way.

2.2k34.4k16](/packages/tempest-framework)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
