MIRA FIVE

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-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). 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:

.env
MIRAFIVE_SECRET_KEY=mf_…

Create one client at bootstrap and reuse it:

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:

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

Server events are sent in full mode by default. See Consent.

Verify

  1. 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 MiraError instead (see Errors).

  2. 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']);
  • 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.

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.

  • 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:

    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:

ArgumentDescription
userIdYour id for the signed-in person. The unit of flags assigned by person.
anonymousIdThe browser SDK's anonymous id, when the browser sent it to you. The unit of flags assigned by browser.
propertiesFacts 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.
optedOuttrue 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:

PropertyDescription
errorCodeThe protocol code, e.g. validation_failed, unauthorized, rate_limited, plus network_error, timeout, invalid_event and unexpected.
statusThe HTTP status, or null when no answer came. getCode() returns it too (0 without one).
retryableWhether trying again later can succeed: 408, 429, 5xx, timeouts and network errors.
retryAfterMsFrom Retry-After, when sent.
errorsFor 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

ClassDescription
MiraFive\Http\CurlTransportThe default when ext-curl is loaded. Keeps its connection between requests to MIRA FIVE.
MiraFive\Http\StreamTransportPHP streams, no extension needed. One timeout for connecting and reading.
MiraFive\Http\Psr18TransportYour 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,
);
NameTypeDefaultDescription
keystring|false|nullnullThe source's secret key. null reads MIRAFIVE_SECRET_KEY; a getenv() result is accepted as is.
host?stringnullnull reads MIRAFIVE_HOST, else https://events.mirafive.io. A scheme is required.
modeModeMode::FullMode::Full or Mode::Consentless.
flushAtint100Send once this many events are buffered, 1–1000.
timeoutMsint5000Per attempt.
connectTimeoutMsint1000Per connection (cURL, and PSR-18 clients that support it).
flushDeadlineMsint3000What one flush may spend on attempts and waits together.
maxRetriesint2For 408, 429, 5xx, timeouts and network errors.
maxRetryAfterMsint3000A longer Retry-After ends the retries instead of stalling your request.
transport?TransportcURL, else streamsSee Transports.
onError?callable(MiraError): voidnullReceives every delivery failure.
logger?LoggerInterfacenullPSR-3, used when there is no onError.
cache?CacheInterfacenullPSR-16, shared between processes: the delivery pause and all flag state.
enabledbooltruefalse: nothing leaves the process and no key is needed. Input is still checked.
flushOnShutdownbooltruefalse: no shutdown function, because your framework flushes.
flagsRefreshSecondsint30Refresh interval of flags().
handOff?Closure(string $body, string $batchId): voidnullReceives buffered batches instead of sending them.

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

MemberDescription
track(string $name, ?string $userId = null, ?string $anonymousId = null, ?string $sessionId = null, array $properties = [], DateTimeInterface|int|null $time = null, ?array $page = null): voidBuffers one event.
identify(string $userId, array $traits = [], ?string $anonymousId = null, DateTimeInterface|int|null $time = null): voidBuffers $identify. Full mode only.
send(array $events, ?string $idempotencyKey = null): ReceiptSends 1–1000 events now as one batch. Throws MiraError. The key must not be empty.
flush(): voidSends the buffer, or hands it to handOff. Never throws.
deliverPrepared(string $body): ReceiptSends a batch handOff received, with the usual retries and refused-event recovery, under this client's key. Throws MiraError.
flags(): MiraFlagsThe 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,
);
MemberDescription
for(?string $userId = null, ?string $anonymousId = null, array $properties = [], array $consent = [], bool $optedOut = false): UserFlagsThe flags of one unit.
ready(): boolWhether a document is in hand, fetching one if due.
snapshot(): ?stringThe 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

MemberDescription
enabled(string $key, bool $fallback = false): booltrue for the variant on, false for off, the fallback otherwise.
variant(string $key, ?string $fallback = null): ?stringThe variant key.
config(string $key, mixed $fallback = null): mixedThe variant's remote-config value, JSON objects as arrays.
evaluate(string $key): EvaluationExplains the answer; never counts an exposure.
bootstrap(): stringThe <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

SymptomCause and fix
Nothing arrivesPass onError or a logger and look at errorCode; without either, failures go to error_log(). Then run the install check.
unauthorizedThe key is missing, wrong or revoked. MIRAFIVE_SECRET_KEY must hold the secret key of a server source.
website_key_as_bearerYou passed the website key. Server code needs the secret key.
InvalidArgumentException: A consentless client may not send userIdThe client is in Mode::Consentless. Drop the identifiers or use Mode::Full where you hold consent.
Events arrive late or never in a workerLong-running workers only reach the shutdown flush when they stop. Call $mira->flush() after each request or job.
Slow responses while MIRA FIVE is unreachableA 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.

On this page