# PHP

Source: https://docs.mirafive.io/sdks/php

> Send MIRA FIVE events and evaluate feature flags from any PHP 8.3+ application with the mirafive/sdk-php package.

`mirafive/sdk-php` sends events from your PHP backend and evaluates feature flags in-process. Use it in plain PHP and in frameworks without a MIRA FIVE package. Using Laravel or Symfony? Take [`mirafive/sdk-laravel`](https://docs.mirafive.io/sdks/laravel) or [`mirafive/sdk-symfony`](https://docs.mirafive.io/sdks/symfony): they wire this SDK into the container and send after the response.

## Install

```sh
composer require mirafive/sdk-php
```

Requires PHP 8.3 or newer with `ext-json`. It has no required Composer dependencies. It sends with `ext-curl` when it is loaded, otherwise with PHP streams, or through your own PSR-18 client (see [Transports](#transports)). A PSR-3 logger and a PSR-16 cache are optional.

## Set up

You need the **secret key** of a server source (`mf_…`). See [Keys](https://docs.mirafive.io/keys) for where to find it. Put it in the environment:

```sh title=".env"
MIRAFIVE_SECRET_KEY=mf_…
```

Create one client at bootstrap and reuse it:

```php title="bootstrap.php"
<?php

declare(strict_types=1);

use MiraFive\Mira;

require __DIR__.'/vendor/autoload.php';

// Reads MIRAFIVE_SECRET_KEY, and MIRAFIVE_HOST when it is set.
$mira = new Mira;
```

`new Mira` reads the variables from `$_ENV`, `$_SERVER` or `getenv()`. Pass `key:` and `host:` to set them yourself.

`track()` buffers. The buffer is sent at the end of the request (once, in a shutdown function), when the client is destroyed, every 100 events, and when you call `flush()`. A flush never takes longer than 3 seconds, even when MIRA FIVE is unreachable.

Under PHP-FPM, also pass a PSR-16 cache your app already has. It lets every PHP process share the flag document and pause delivery together during an outage:

```php
$mira = new Mira(cache: $psr16Cache);
```

Server events are sent in [full mode](https://docs.mirafive.io/guides/consent#full-mode) by default. See [Consent](#consent).

## Verify

1. Send an install check once. It proves the key and host work and is never stored or billed:

   ```php
   $receipt = $mira->send([['name' => '$install_check']]);

   var_dump($receipt->reason); // string(13) "install_check"
   ```

   A wrong key throws a `MiraError` instead (see [Errors](#errors)).
2. Track a real event, then open the source's live view in MIRA FIVE and find it.

Nothing arriving? See [Troubleshooting](#troubleshooting).

## Track events

Call `track()` with named arguments where the thing happens:

```php
$mira->track('signup', userId: (string) $user->id, properties: ['plan' => 'pro']);
```

- `userId` is your own pseudonymous id, never an email address. Ids are strings: cast integer ids with `(string)`.
- `time` is a `DateTimeInterface` or epoch milliseconds, and defaults to now.
- `page` takes `url`, `title` and `referrer`. Values that are too long are shortened.
- A PHP `[]` is sent as a JSON list. Pass `new stdClass` where you mean `{}`.

Input MIRA FIVE would refuse throws an `InvalidArgumentException` at once, because one bad event would otherwise cost every event in its batch: names of 1–128 characters without surrounding whitespace, `$` names other than the reserved ones, blank ids or ids over 256 characters, properties that are a list, nest deeper than 5 levels, carry more than 64 values or encode to more than 32 KB.

Event names, properties and revenue are covered in [Track events](https://docs.mirafive.io/guides/track-events).

## Identify users

`identify()` records the person's traits. With the browser's anonymous id, it also links the visitor's earlier events to the user:

```php
$mira->identify((string) $user->id, ['plan' => 'pro'], anonymousId: $anonymousId);
```

Call it after signup and login. It needs full mode. See [Identify users](https://docs.mirafive.io/guides/identify-users).

## Idempotent sends

Webhooks can arrive twice. `send()` delivers 1–1000 events as one batch now and returns the server's `Receipt`. Give it an idempotency key and a repeat is stored and billed once:

```php
$receipt = $mira->send([
    ['name' => 'order completed', 'userId' => 'u_42', 'properties' => ['revenue' => 129, 'currency' => 'EUR']],
], idempotencyKey: 'order-981');
```

Each event is an array with `name` and optionally `userId`, `anonymousId`, `sessionId`, `properties`, `time`, `page` and `id` (a UUID). The batch id is derived from the key, so every MIRA FIVE SDK maps the same key to the same batch. `send()` throws a `MiraError` when MIRA FIVE refuses the batch or cannot be reached after the retries.

## Consent

- **Full mode** (`Mode::Full`, default) may carry `userId`, `anonymousId` and `sessionId`. You hold the consent or other lawful basis for them.
- **Consentless mode** (`Mode::Consentless`) carries no identifiers. Passing one throws an `InvalidArgumentException` on the first call:

  ```php
  use MiraFive\Mira;
  use MiraFive\Mode;

  $mira = new Mira(mode: Mode::Consentless);
  $mira->track('invoice paid', properties: ['revenue' => 99, 'currency' => 'EUR']);
  ```

- No personal data in event names or properties: no email addresses, names, phone numbers or free text a person typed.
- The secret key never goes into HTML, JavaScript or a mobile app. Browsers use the website key with the [script tag](https://docs.mirafive.io/sdks/script-tag) or the [browser SDK](https://docs.mirafive.io/sdks/browser).

See [Consent](https://docs.mirafive.io/guides/consent).

## Feature flags

`$mira->flags()` returns the `MiraFive\Flags\MiraFlags` of this source. It shares the client's key, host, transport and cache, and sends exposures through the client:

```php
$userFlags = $mira->flags()->for(
    userId: (string) $user->id,
    properties: ['plan' => 'pro'],
    consent: ['experiments' => true, 'targeting' => false],
    optedOut: ($_SERVER['HTTP_SEC_GPC'] ?? null) === '1' || ($_SERVER['HTTP_DNT'] ?? null) === '1',
);

if ($userFlags->enabled('new-checkout')) {
    // the new checkout
}

$variant = $userFlags->variant('pricing-test', 'control');
$limits = $userFlags->config('checkout-limits', ['maxItems' => 10]);
```

Reads are synchronous and never throw. Without a document, or for an unknown key, they answer your fallback.

`for()` takes:

| Argument | Description |
| --- | --- |
| `userId` | Your id for the signed-in person. The unit of flags assigned by person. |
| `anonymousId` | The browser SDK's anonymous id, when the browser sent it to you. The unit of flags assigned by browser. |
| `properties` | Facts your targeting rules test. Held in memory, never sent. |
| `consent` | `['experiments' => bool, 'targeting' => bool]`. Leave a scope out when your own lawful basis applies; set it to `false` when the person declined. Other keys throw. |
| `optedOut` | `true` when the request carries `Sec-GPC: 1` or `DNT: 1`. No unit, no segment lookup, no exposure, whatever `consent` says. Fixed values and property rules still apply. |

Without `experiments` consent, the anonymous id is not used and every experiment answers its default (`NOT_ALLOWED`). Without `targeting` consent, no segment is looked up and segment conditions are false. See [Feature flags](https://docs.mirafive.io/guides/feature-flags) and [Experiments](https://docs.mirafive.io/guides/experiments).

**The document.** `MiraFlags` fetches `GET /v1/flags` on first use (waiting up to 1.5 seconds) and again on a read once it is older than 30 seconds, with `If-None-Match`. PHP-FPM starts every request with an empty process, so pass a PSR-16 cache: it shares the document, failed fetches, segment memberships and exposure marks between requests. Without one, each request fetches the document once and an experiment may be counted once per request.

**Segments.** When a flag tests a segment, `for()` asks `POST /v1/flags/segments` about the unit, with a 500 ms timeout, and keeps the answer a minute. A failed lookup makes segment conditions false, `evaluate()` reports `MEMBERSHIP_UNAVAILABLE`, and lookups pause for 30 seconds (or as long as `Retry-After` asks).

**Experiments.** `enabled`, `variant` and `config` send one `$exposure` per unit, experiment and variant an hour for experiments counted on your server. `evaluate()` never counts anyone. Experiments counted in the browser answer their default on the server (`NOT_ALLOWED`).

**Bootstrap.** Hand the server's answers to the browser SDK so the first paint shows the right variant:

```php
use MiraFive\Flags\MiraFlags;

foreach (MiraFlags::BOOTSTRAP_HEADERS as $name => $value) {
    header("{$name}: {$value}");
}

echo $userFlags->bootstrap();
```

`bootstrap()` returns `<script type="application/json" id="mirafive-flags">…</script>`. It carries only flags your website reads, with every `<`, `>` and `&` escaped so no value can end the script. `BOOTSTRAP_HEADERS` is `Cache-Control: private, no-store`: the page belongs to one visitor, so never let a shared cache store it. Print the block in `<head>`.

**Snapshots.** `$mira->flags()->snapshot()` returns the document in use as the JSON string MIRA FIVE sent, or `null`. Store it at deploy time and pass it back as `document:` to `new MiraFlags(…)`: it is used while no document was fetched and while it is younger than 7 days.

## Queues and workers

**Deliver from a queue.** With `handOff`, every buffered flush passes the encoded batch to your closure instead of sending it, so the request does no delivery work. Put the body on your queue:

```php
$mira = new Mira(handOff: function (string $body, string $batchId) use ($queue): void {
    $queue->push('mirafive', $body);
});
```

The worker sends it with its own client, so the key never travels in the message:

```php
use MiraFive\MiraError;

try {
    $mira->deliverPrepared($body);
} catch (MiraError $error) {
    if ($error->retryable) {
        throw $error; // let the queue retry the job
    }
}
```

The body is final: retries resend it byte for byte under its batch id, so a job that runs twice is stored once. `send()` ignores `handOff` and always sends at once.

**Long-running workers.** Octane, RoadRunner, Swoole and queue workers serve many requests in one process, so the shutdown flush only runs when the worker stops. Call `$mira->flush()` after each request or job. Frameworks that flush on terminate pass `flushOnShutdown: false`.

**Tests and local environments.** `enabled: false` sends nothing and needs no key, but refuses the same input as production. `track()` keeps nothing, `send()` and `deliverPrepared()` return a local receipt with every event accepted, and flags answer their fallbacks:

```php
$mira = new Mira(enabled: getenv('APP_ENV') === 'production');
```

To assert on what was sent, pass a `transport:` that records requests (see [Transports](#transports)).

## Errors

`track()`, `identify()` and `flush()` never throw for transport reasons. Their failures go to `onError`, else to the PSR-3 `logger`, else to `error_log()`. `send()` and `deliverPrepared()` throw a `MiraError`. Invalid input and identifiers in consentless mode throw an `InvalidArgumentException`.

```php
use MiraFive\Mira;
use MiraFive\MiraError;

$mira = new Mira(onError: function (MiraError $error): void {
    error_log("[mirafive] {$error->errorCode}: {$error->getMessage()}");
});
```

`MiraFive\MiraError` extends `RuntimeException`:

| Property | Description |
| --- | --- |
| `errorCode` | The protocol code, e.g. `validation_failed`, `unauthorized`, `rate_limited`, plus `network_error`, `timeout`, `invalid_event` and `unexpected`. |
| `status` | The HTTP status, or `null` when no answer came. `getCode()` returns it too (`0` without one). |
| `retryable` | Whether trying again later can succeed: 408, 429, 5xx, timeouts and network errors. |
| `retryAfterMs` | From `Retry-After`, when sent. |
| `errors` | For `validation_failed`: up to 10 `['path' => …, 'message' => …]`. |

The codes MIRA FIVE sends are listed in [Ingest API errors](https://docs.mirafive.io/ingest-api/errors).

**Outages.** Each attempt's timeout shrinks to what is left of the flush deadline (`flushDeadlineMs`, 3 seconds), and a wait that would pass it ends the flush. After a flush fails for a retryable reason, flushes skip the network for 30 seconds and drop their events; the first skip is reported. With a PSR-16 cache, that pause holds for every PHP process, so an outage costs one request a timeout rather than every request. `send()` and `deliverPrepared()` always try.

**Refused events.** When MIRA FIVE refuses a buffered batch with `validation_failed`, the events its errors name are reported and dropped, and the rest is resent under a batch id derived from the old one. When an error names no event, the whole batch is reported and dropped.

**Delivery.** Batches go to `POST /v1/batch` with the secret key as a bearer token, at most 1000 events and 1 MiB each; larger buffers are split. Retries use full-jitter backoff (100 ms doubling, at most 1 s), honour `Retry-After` up to 3 seconds, and resend the byte-identical body under the same batch id. These defaults are lower than the [Node.js SDK's](https://docs.mirafive.io/sdks/node) because delivery usually runs inside a web request.

## Transports

| Class | Description |
| --- | --- |
| `MiraFive\Http\CurlTransport` | The default when `ext-curl` is loaded. Keeps its connection between requests to MIRA FIVE. |
| `MiraFive\Http\StreamTransport` | PHP streams, no extension needed. One timeout for connecting and reading. |
| `MiraFive\Http\Psr18Transport` | Your PSR-18 client, with PSR-17 request and stream factories. The client's own timeout applies: keep it short. |

```php
use MiraFive\Http\Psr18Transport;
use MiraFive\Mira;

$mira = new Mira(transport: new Psr18Transport($client, $requestFactory, $streamFactory));
```

Any class that implements `MiraFive\Http\Transport` works, for example one that records batches in a test:

```php
use MiraFive\Http\Response;
use MiraFive\Http\Transport;

final class RecordingTransport implements Transport
{
    /** @var list<string> */
    public array $bodies = [];

    public function request(string $method, string $url, array $headers, ?string $body, int $timeoutMs, int $connectTimeoutMs): Response
    {
        $this->bodies[] = $body ?? '';

        return new Response(202, [], '{"accepted":1,"dropped":0}');
    }
}
```

## API reference

### `MiraFive\Mira`

```php
new Mira(
    key: null,               // string|false|null. null reads MIRAFIVE_SECRET_KEY
    host: null,              // null reads MIRAFIVE_HOST, else Mira::DEFAULT_HOST
    mode: Mode::Full,
    flushAt: 100,
    timeoutMs: 5_000,
    connectTimeoutMs: 1_000,
    flushDeadlineMs: 3_000,
    maxRetries: 2,
    maxRetryAfterMs: 3_000,
    transport: null,
    onError: null,
    logger: null,
    cache: null,
    enabled: true,
    flushOnShutdown: true,
    flagsRefreshSeconds: 30,
    handOff: null,
);
```

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `key` | `string\|false\|null` | `null` | The source's secret key. `null` reads `MIRAFIVE_SECRET_KEY`; a `getenv()` result is accepted as is. |
| `host` | `?string` | `null` | `null` reads `MIRAFIVE_HOST`, else `https://events.mirafive.io`. A scheme is required. |
| `mode` | `Mode` | `Mode::Full` | `Mode::Full` or `Mode::Consentless`. |
| `flushAt` | `int` | `100` | Send once this many events are buffered, 1–1000. |
| `timeoutMs` | `int` | `5000` | Per attempt. |
| `connectTimeoutMs` | `int` | `1000` | Per connection (cURL, and PSR-18 clients that support it). |
| `flushDeadlineMs` | `int` | `3000` | What one flush may spend on attempts and waits together. |
| `maxRetries` | `int` | `2` | For 408, 429, 5xx, timeouts and network errors. |
| `maxRetryAfterMs` | `int` | `3000` | A longer `Retry-After` ends the retries instead of stalling your request. |
| `transport` | `?Transport` | cURL, else streams | See [Transports](#transports). |
| `onError` | `?callable(MiraError): void` | `null` | Receives every delivery failure. |
| `logger` | `?LoggerInterface` | `null` | PSR-3, used when there is no `onError`. |
| `cache` | `?CacheInterface` | `null` | PSR-16, shared between processes: the delivery pause and all flag state. |
| `enabled` | `bool` | `true` | `false`: nothing leaves the process and no key is needed. Input is still checked. |
| `flushOnShutdown` | `bool` | `true` | `false`: no shutdown function, because your framework flushes. |
| `flagsRefreshSeconds` | `int` | `30` | Refresh interval of `flags()`. |
| `handOff` | `?Closure(string $body, string $batchId): void` | `null` | Receives buffered batches instead of sending them. |

Options out of range and a host without a scheme throw an `InvalidArgumentException`.

| Member | Description |
| --- | --- |
| `track(string $name, ?string $userId = null, ?string $anonymousId = null, ?string $sessionId = null, array $properties = [], DateTimeInterface\|int\|null $time = null, ?array $page = null): void` | Buffers one event. |
| `identify(string $userId, array $traits = [], ?string $anonymousId = null, DateTimeInterface\|int\|null $time = null): void` | Buffers `$identify`. Full mode only. |
| `send(array $events, ?string $idempotencyKey = null): Receipt` | Sends 1–1000 events now as one batch. Throws `MiraError`. The key must not be empty. |
| `flush(): void` | Sends the buffer, or hands it to `handOff`. Never throws. |
| `deliverPrepared(string $body): Receipt` | Sends a batch `handOff` received, with the usual retries and refused-event recovery, under this client's key. Throws `MiraError`. |
| `flags(): MiraFlags` | The flags of this source. |

Public properties: `host`, `mode`, `enabled`. Constants: `Mira::DEFAULT_HOST`, `Mira::VERSION`, `Mira::SDK` (`mirafive-php/1.0.0`), `Mira::MAX_BATCH_SIZE` (1000).

### `MiraFive\Receipt`

`batch` (string), `accepted` (int), `dropped` (int), `reason` (`bot`, `install_check`, `ingestion_paused`, `allowance_exhausted` or `null`). `dropped > 0` with a reason means nothing was kept; the answer is still final.

### `MiraFive\Flags\MiraFlags`

```php
new MiraFlags(
    key: null,              // null reads MIRAFIVE_SECRET_KEY
    host: null,             // null reads MIRAFIVE_HOST
    refreshSeconds: 30,     // at least 10
    timeoutMs: 1_500,       // the document fetch
    lookupTimeoutMs: 500,   // the segment lookup
    connectTimeoutMs: 1_000,
    cache: null,            // Psr\SimpleCache\CacheInterface
    document: null,         // a snapshot() string or an array, used while younger than 7 days
    enabled: true,          // false: never fetches or looks up; reads answer their fallbacks
    mira: null,             // sends exposures; defaults to a client on the same key
    transport: null,
    onError: null,
    logger: null,
);
```

| Member | Description |
| --- | --- |
| `for(?string $userId = null, ?string $anonymousId = null, array $properties = [], array $consent = [], bool $optedOut = false): UserFlags` | The flags of one unit. |
| `ready(): bool` | Whether a document is in hand, fetching one if due. |
| `snapshot(): ?string` | The document in use, as JSON. |
| `status(): array` | `['ready' => bool, 'etag' => ?string, 'fetchedAt' => ?int]`. |
| `BOOTSTRAP_HEADERS` | `['Cache-Control' => 'private, no-store']`. |

A 401 or 403 stops fetching until the process restarts.

### `MiraFive\Flags\UserFlags`

| Member | Description |
| --- | --- |
| `enabled(string $key, bool $fallback = false): bool` | `true` for the variant `on`, `false` for `off`, the fallback otherwise. |
| `variant(string $key, ?string $fallback = null): ?string` | The variant key. |
| `config(string $key, mixed $fallback = null): mixed` | The variant's remote-config value, JSON objects as arrays. |
| `evaluate(string $key): Evaluation` | Explains the answer; never counts an exposure. |
| `bootstrap(): string` | The `<script id="mirafive-flags">` block for the browser SDK. |

`MiraFive\Flags\Evaluation` has `variant` (`?string`), `reason` (`Reason`), `rule` (`?int`), `errorCode` (`?ErrorCode`) and `value`. `Reason`: `STATIC`, `TARGETING_MATCH`, `SPLIT`, `DEFAULT`, `DISABLED`, `ERROR`. `ErrorCode`: `UNSUPPORTED` (the flag needs a newer SDK), `NOT_READY` (no document yet), `FLAG_NOT_FOUND`, `MEMBERSHIP_UNAVAILABLE` (segments could not be looked up), `NOT_ALLOWED` (no consent, or an experiment counted in the browser).

## Troubleshooting

| Symptom | Cause and fix |
| --- | --- |
| Nothing arrives | Pass `onError` or a logger and look at `errorCode`; without either, failures go to `error_log()`. Then run the install check. |
| `unauthorized` | The key is missing, wrong or revoked. `MIRAFIVE_SECRET_KEY` must hold the secret key of a server source. |
| `website_key_as_bearer` | You passed the website key. Server code needs the secret key. |
| `InvalidArgumentException: A consentless client may not send userId` | The client is in `Mode::Consentless`. Drop the identifiers or use `Mode::Full` where you hold consent. |
| Events arrive late or never in a worker | Long-running workers only reach the shutdown flush when they stop. Call `$mira->flush()` after each request or job. |
| Slow responses while MIRA FIVE is unreachable | A flush is capped at `flushDeadlineMs` and then pauses delivery for 30 seconds. Pass a PSR-16 `cache` so the pause covers every process, lower `flushDeadlineMs`, or use `handOff`. |
| A flag always answers its fallback | `$mira->flags()->status()` shows whether a document arrived. `$userFlags->evaluate($key)->errorCode` says why: `NOT_READY` (no document; see `onError`), `FLAG_NOT_FOUND` (not a flag of this source). |

## Set up with an AI agent

Paste this into your coding agent:

```text
Add MIRA FIVE server-side analytics to this PHP application with the Composer package mirafive/sdk-php.
Docs: https://docs.mirafive.io/sdks/php.md

1. If this is a Laravel or Symfony app, use mirafive/sdk-laravel or mirafive/sdk-symfony instead.
   Otherwise run `composer require mirafive/sdk-php` (PHP 8.3+, no other Composer dependencies).
2. Read the secret key from the environment variable MIRAFIVE_SECRET_KEY. Never hard-code it and never
   print it into HTML or JavaScript. Add MIRAFIVE_SECRET_KEY= to .env.example if the project has one.
3. Create one shared MiraFive\Mira at bootstrap (`$mira = new \MiraFive\Mira;` reads the variable) and reuse it.
   Under PHP-FPM, pass a PSR-16 cache the app already has: `new \MiraFive\Mira(cache: $psr16Cache)`.
4. Pass `onError: fn (\MiraFive\MiraError $e) => <the app's logger>` so delivery failures are visible.
5. Track the few business events that matter (signup, order completed) with
   `$mira->track('signup', userId: (string) $user->id, properties: ['plan' => $plan]);`
   and call `$mira->identify((string) $user->id, ['plan' => $plan]);` after signup and login.
   Use the internal user id, never an email address. No personal data in names or properties.
6. For webhooks that can arrive twice, use
   `$mira->send([['name' => 'order completed', 'userId' => (string) $userId, 'properties' => ['revenue' => $amount, 'currency' => 'EUR']]], idempotencyKey: (string) $orderId);`
7. The buffer is sent when the request ends. In long-running workers (queues, Octane, RoadRunner, Swoole)
   call `$mira->flush()` after each job or request.
8. Verify: `$mira->send([['name' => '$install_check']])` must return a MiraFive\Receipt whose reason is
   'install_check' (never stored or billed). Report what you changed.
Do not add other analytics libraries, cookies or consent banners.
```
