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 or mirafive/sdk-symfony: they wire this SDK into the container and send after the response.
Install
composer require mirafive/sdk-phpRequires 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). 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 for where to find it. Put it in the environment:
MIRAFIVE_SECRET_KEY=mf_…Create one client at bootstrap and reuse it:
<?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:
$mira = new Mira(cache: $psr16Cache);Server events are sent in full mode by default. See Consent.
Verify
-
Send an install check once. It proves the key and host work and is never stored or billed:
$receipt = $mira->send([['name' => '$install_check']]); var_dump($receipt->reason); // string(13) "install_check"A wrong key throws a
MiraErrorinstead (see Errors). -
Track a real event, then open the source's live view in MIRA FIVE and find it.
Nothing arriving? See Troubleshooting.
Track events
Call track() with named arguments where the thing happens:
$mira->track('signup', userId: (string) $user->id, properties: ['plan' => 'pro']);userIdis your own pseudonymous id, never an email address. Ids are strings: cast integer ids with(string).timeis aDateTimeInterfaceor epoch milliseconds, and defaults to now.pagetakesurl,titleandreferrer. Values that are too long are shortened.- A PHP
[]is sent as a JSON list. Passnew stdClasswhere 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.
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:
$mira->identify((string) $user->id, ['plan' => 'pro'], anonymousId: $anonymousId);Call it after signup and login. It needs full mode. See 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:
$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 carryuserId,anonymousIdandsessionId. You hold the consent or other lawful basis for them. -
Consentless mode (
Mode::Consentless) carries no identifiers. Passing one throws anInvalidArgumentExceptionon the first call: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 or the browser SDK.
See 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:
$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 and 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:
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:
$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:
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:
$mira = new Mira(enabled: getenv('APP_ENV') === 'production');To assert on what was sent, pass a transport: that records requests (see 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.
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.
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 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. |
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:
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
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. |
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
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:
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.