Intempt Docs
Developer DocsSDK

PHP SDK

Send events, identity, consent and commerce from your PHP server, and read recommendations on the request path.

PHP SDK

Server-side client for PHP. Data in, decisions out.

It holds no per-user state: every call takes its identifier explicitly.

Requirements

PHP8.1 or newer
Extensionsext-curl, ext-json
Dependenciesnone

Installation

Not on Packagist yet. composer require intempt/intempt-php does not resolve — there is no such package. The SDK is in preview and installs from the repository until it is published.

composer config repositories.intempt vcs https://github.com/intempt/intempt-php
composer require intempt/intempt-php:dev-feature/v1-mixpanel-core

The repository is public. feature/v1-mixpanel-core is its default branch while the SDK is pre-release; this page will move to a plain composer require intempt/intempt-php once the package is published.

Setup

valuewhere it comes fromrequired
orgyour organization name in the console URLyes
projectyour project name in the console URLyes
apiKeySettings → API keys, a public key in <prefix>.<secret> formyes
sourceIdSources → your source, a 19-digit idrecommended
export INTEMPT_API_KEY='your-prefix.your-secret'

Set zend.exception_ignore_args=1 in production. PHP copies call arguments into every stack trace, so your own new Intempt(['apiKey' => …]) frame carries the key into every later exception, including getTraceAsString(). No SDK can fix that from the inside. The SDK keeps the credential out of every object you can print; this ini setting closes the other half.

Initialization

use Intempt\Intempt;

$intempt = new Intempt([
    'org' => 'my-org',
    'project' => 'my-project',
    'apiKey' => getenv('INTEMPT_API_KEY'),
    'sourceId' => '684508596718616576',
]);

Keep sourceId a string. It is a 19-digit number, and a numeric round trip loses the last digits, which addresses a different source with no error.

Capture

track

$intempt->track('purchase', [
    'userId' => 'user@example.com',
    'properties' => ['total' => 99.99, 'currency' => 'USD'],
]);
optiontypenotes
userIdstringyour identifier for a person
accountIdstringyour identifier for a company
propertiesarrayevent properties
userAttributesarraytraits to set on the person
accountAttributesarraytraits to set on the account
timestampDateTimeInterface|intepoch milliseconds, defaults to now

At least one of userId or accountId is required. Every method throws on failure — nothing is swallowed.

trackBatch

$intempt->trackBatch([
    ['event' => 'page_view', 'userId' => 'u1', 'properties' => ['path' => '/pricing']],
    ['event' => 'signup', 'userId' => 'u2'],
]);

An error names the offending index: trackBatch[1]: event name is required.

Commerce

$intempt->ecommerce->productViewed(['userId' => 'u1', 'productId' => 'sku-1']);
$intempt->ecommerce->addedToCart(['userId' => 'u1', 'productId' => 'sku-1', 'quantity' => 2]);
$intempt->ecommerce->ordered([
    'userId' => 'u1',
    'products' => [
        ['productId' => 'sku-1', 'quantity' => 2],
        ['productId' => 'sku-2'],
    ],
]);

Identity and lifecycle

$intempt->identify(['userId' => 'u1', 'traits' => ['plan' => 'pro']]);
$intempt->group(['userId' => 'u1', 'accountId' => 'acme', 'attributes' => ['tier' => 'ent']]);
$intempt->alias(['userId' => 'new-id', 'previousUserId' => 'old-id']);

There is no reset(), and that is deliberate. Each call carries its own identifier, so there is nothing to reset. If you are looking for it, you are probably holding one client per user — hold one per request or per process.

profileId and masterId are not part of this SDK. profileId is device-minted by the browser SDK, so a server that invents one creates an orphan profile; masterId is assigned after identity resolution and cannot be read from a server.

Opt in / out

$intempt->optOut();      // suppresses track, batch, commerce and consent
$intempt->optIn();
$intempt->isOptedIn();   // bool
$intempt->consent->grant(['userId' => 'u1', 'category' => 'marketing']);
$intempt->consent->revoke(['userId' => 'u1', 'reason' => 'user requested deletion']);

Delivery

By default each call sends one request and returns when the server responds.

Leave batching off under PHP-FPM. A typical FPM process handles one request and dies, so an in-memory buffer has nowhere to live and flush() has nothing useful to do. Enabling it there loses events on every request.

Turn batching on only in a long-running process — a worker, a queue consumer, Swoole, RoadRunner:

use Intempt\BatchOptions;

$intempt = new Intempt([
    // …
    'batch' => new BatchOptions(size: 50, flushMs: 5_000, maxQueue: 10_000),
]);

$intempt->flush();   // send what is buffered now
$intempt->close();   // drain and release, at shutdown

close() drains for at most 30 seconds, then stops retrying and logs how many events it gave up on. flush() is not bounded.

Retry policy

responsebehaviour
413, batch > 1halve the batch size, retry
413, batch = 1drop the event, log it, return the width to full
429honour Retry-After, else exponential backoff
5xx, 408, timeoutexponential backoff, floored at 100ms, capped at 10 minutes
other 4xxdrop the batch, log the status and body
5 consecutive failuresstop batching and report how many events are stranded

Delivery is at-least-once, not exactly-once. Ingestion has no idempotency key, so a retry after a lost response duplicates rows.

Personalization

$feed = $intempt->recommend([
    'userId' => 'user@example.com',
    'feedId' => '5292',
    'fields' => ['id', 'title'],
    'limit' => 5,
]);

Pass exactly one of userId or accountId — the feeds API resolves a single entity, so passing both is an error.

Experiments and personalizations are deliberately absent: they resolve a web experience against a page, and a server has no page to modify.

Property types

PHP typesent as
string, int, float, boolas-is
DateTimeInterfaceepoch milliseconds
nulldropped, never reaches the wire
arrayas-is

Timestamps

timestamp is a backfill mechanism between 2010 and 2040.

your timestampwhat happens
before 2010-01-01request rejected, error names the threshold
2010 to 2040stored as given
after 2040-01-18silently replaced with the server's clock

Sending seconds where milliseconds were meant lands past 2040 and is quietly rewritten to now, with no error.

Configuration reference

Every option the Intempt constructor accepts. Only the first three are required.

optiontypedefaultwhat it does
orgstringyour organization name, from the console URL
projectstringyour project name, from the console URL
apiKeystringa public key, <prefix>.<secret>
sourceIdstringnullthe source events are attributed to. Keep it a string
hoststringapi.intempt.comaccepts host or host:port
schemestringhttpshttp is accepted for local testing only
pathstring''prefix for every request path, for a reverse proxy
timeoutfloat10.0seconds, applied to connect and to read
keepAlivebooltruereuse one curl handle. Fixed at construction
debugboolfalselog the method and path of every request. Never the body
maxRequestEventsint50hard ceiling on events per request, independent of batch.size
batchBatchOptions|arraynulloff by default; see Delivery
loggerLoggerNullLoggerimplement Intempt\Logger

BatchOptions:

optiontypedefaultwhat it does
sizeint50events per request, capped by maxRequestEvents
flushMsint5000milliseconds before an incomplete batch is sent
maxQueueint10000events held in memory. Beyond this, new events are dropped and logged
flushOnExitbooltruedrain on normal shutdown

setConfig() changes host, scheme, path, timeout, debug, maxRequestEvents and logger on a live client. org, project, apiKey, sourceId, batch and keepAlive are fixed at construction because the connection is built once — passing one to setConfig() throws rather than being ignored.

Errors and troubleshooting

use Intempt\IntemptApiException;
use Intempt\IntemptConfigException;

try {
    $intempt->track('purchase', ['userId' => 'u1']);
} catch (IntemptApiException $e) {
    error_log($e->status . ' ' . $e->body . ' retryable=' . var_export($e->isRetryable(), true));
} catch (IntemptConfigException $e) {
    error_log('bad arguments: ' . $e->getMessage());
}
exceptionwhen
IntemptConfigExceptionbad configuration or arguments — never retried
IntemptApiExceptionthe API answered, or the transport failed
IntemptExceptionbase class, catches both

IntemptApiException->status is null for a transport failure or timeout, which is why isRetryable() treats that case as retryable.

Verifying an event arrived

  1. Send one track call with a userId you can recognise.
  2. Open Sources → your source → Live events in the console.
  3. The event appears with the name you sent.
symptomcause
every call throws with 401wrong apiKey, or the key belongs to another project
404 on every callwrong org or project — check the console URL
events land in the wrong sourcesourceId lost precision; keep it a string
commerce accepted but no product showsingestion returns 201 for unknown ids
nothing arrives and nothing errorsyou called optOut(), or batching is on under FPM

Ingestion answers 201 for unknown accounts and products, so a made-up productId returns success and proves nothing. Test with an id that exists in your catalog.

Frequently asked

What happens to an event that is too large? The platform answers 413. With more than one event in the batch the SDK halves the batch and retries, so an oversized batch still gets through as smaller ones. A single event that is still refused is dropped and logged — it cannot be made smaller. After three consecutive drops the SDK says so once, rather than logging every one.

Is event order preserved? Within one client, yes. The queue is FIFO and one request is in flight at a time, so events leave in the order you recorded them. Order across separate clients or processes is not guaranteed, and the platform orders by your timestamp rather than by arrival.

Is there an EU or regional endpoint? No. There is one endpoint, api.intempt.com, and every project is served from it. The host option exists to point at a reverse proxy or a test server, not at a region. If data residency matters to your deployment, raise it before you integrate rather than after.

What happens if the process dies with events buffered? They are lost. The buffer is in memory and nothing is written to disk. close() drains it first, and flushOnExit covers a normal exit, but neither survives SIGKILL or a crash. Leave batching off where the process is short-lived — that is the default.

How do I send historical events? Pass timestamp explicitly. It is a genuine backfill mechanism between 2010 and 2040, and the event store keeps your value while recording arrival separately. See Timestamps for the two ways this goes wrong.

Can I use one client for everything? Under PHP-FPM a process handles one request and dies, so build one client per request and leave batching off — the client holds no per-user state, and every call carries its own identifier. That is why there is no reset().

Does optOut() delete data already sent? No. It stops this client sending anything further, and it is applied at send time, so anything already buffered is discarded rather than transmitted. Deleting stored data is a separate request — see the consent API and your DSAR process.

Why did my event not appear, with no error? Four causes, in the order they happen: you called optOut(); batching is on and you never flushed; the sourceId lost precision and the event went to a different source; or the timestamp was past 2040 and was rewritten to now, so the event is there but not where you were looking.

On this page