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
| PHP | 8.1 or newer |
| Extensions | ext-curl, ext-json |
| Dependencies | none |
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-coreThe 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
| value | where it comes from | required |
|---|---|---|
org | your organization name in the console URL | yes |
project | your project name in the console URL | yes |
apiKey | Settings → API keys, a public key in <prefix>.<secret> form | yes |
sourceId | Sources → your source, a 19-digit id | recommended |
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'],
]);| option | type | notes |
|---|---|---|
userId | string | your identifier for a person |
accountId | string | your identifier for a company |
properties | array | event properties |
userAttributes | array | traits to set on the person |
accountAttributes | array | traits to set on the account |
timestamp | DateTimeInterface|int | epoch 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 shutdownclose() drains for at most 30 seconds, then stops retrying and logs how many
events it gave up on. flush() is not bounded.
Retry policy
| response | behaviour |
|---|---|
| 413, batch > 1 | halve the batch size, retry |
| 413, batch = 1 | drop the event, log it, return the width to full |
| 429 | honour Retry-After, else exponential backoff |
| 5xx, 408, timeout | exponential backoff, floored at 100ms, capped at 10 minutes |
| other 4xx | drop the batch, log the status and body |
| 5 consecutive failures | stop 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 type | sent as |
|---|---|
string, int, float, bool | as-is |
DateTimeInterface | epoch milliseconds |
null | dropped, never reaches the wire |
array | as-is |
Timestamps
timestamp is a backfill mechanism between 2010 and 2040.
| your timestamp | what happens |
|---|---|
| before 2010-01-01 | request rejected, error names the threshold |
| 2010 to 2040 | stored as given |
| after 2040-01-18 | silently 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.
| option | type | default | what it does |
|---|---|---|---|
org | string | — | your organization name, from the console URL |
project | string | — | your project name, from the console URL |
apiKey | string | — | a public key, <prefix>.<secret> |
sourceId | string | null | the source events are attributed to. Keep it a string |
host | string | api.intempt.com | accepts host or host:port |
scheme | string | https | http is accepted for local testing only |
path | string | '' | prefix for every request path, for a reverse proxy |
timeout | float | 10.0 | seconds, applied to connect and to read |
keepAlive | bool | true | reuse one curl handle. Fixed at construction |
debug | bool | false | log the method and path of every request. Never the body |
maxRequestEvents | int | 50 | hard ceiling on events per request, independent of batch.size |
batch | BatchOptions|array | null | off by default; see Delivery |
logger | Logger | NullLogger | implement Intempt\Logger |
BatchOptions:
| option | type | default | what it does |
|---|---|---|---|
size | int | 50 | events per request, capped by maxRequestEvents |
flushMs | int | 5000 | milliseconds before an incomplete batch is sent |
maxQueue | int | 10000 | events held in memory. Beyond this, new events are dropped and logged |
flushOnExit | bool | true | drain 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());
}| exception | when |
|---|---|
IntemptConfigException | bad configuration or arguments — never retried |
IntemptApiException | the API answered, or the transport failed |
IntemptException | base 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
- Send one
trackcall with auserIdyou can recognise. - Open Sources → your source → Live events in the console.
- The event appears with the name you sent.
| symptom | cause |
|---|---|
| every call throws with 401 | wrong apiKey, or the key belongs to another project |
| 404 on every call | wrong org or project — check the console URL |
| events land in the wrong source | sourceId lost precision; keep it a string |
| commerce accepted but no product shows | ingestion returns 201 for unknown ids |
| nothing arrives and nothing errors | you 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.
