MIRA FIVE

Symfony

Track MIRA FIVE events from Symfony, evaluate feature flags in-process and print the tracker tag and flag bootstrap in Twig.

mirafive/sdk-symfony is a bundle that wires the PHP SDK into your container. It sends events after the response has gone out, prints the tracker tag in Twig, hands flag answers to the page and records events in tests.

Install

composer require mirafive/sdk-symfony

Requires PHP 8.3 or newer and Symfony 6.4, 7 or 8. Twig (symfony/twig-bundle) and Messenger (symfony/messenger) are optional.

With Symfony Flex the bundle is registered for you. Without Flex, add it to config/bundles.php:

config/bundles.php
return [
    // …
    MiraFive\Symfony\MiraFiveBundle::class => ['all' => true],
];

Set up

You need two keys, see Keys for where to find them: the secret key of a server source for server events and flags, and, if you also measure the website, the website key of a website source. Put them in .env.local or your secret store, never in the repository:

.env.local
MIRAFIVE_SECRET_KEY=mf_…
MIRAFIVE_WEBSITE_KEY=mf_…

No configuration file is needed: the defaults read these variables. MiraFive\Mira is autowired. Track where things happen:

src/Controller/SignupController.php
<?php

namespace App\Controller;

use MiraFive\Mira;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final class SignupController extends AbstractController
{
    #[Route('/signup', methods: ['POST'])]
    public function __invoke(Request $request, Mira $mira): Response
    {
        $userId = 'u_42'; // the id of the account you created

        $mira->track('signup', userId: $userId, properties: ['plan' => $request->request->getString('plan', 'free')]);

        return $this->redirectToRoute('dashboard');
    }
}

track() only buffers. The bundle sends the buffer on kernel.terminate, after the response has reached the browser. You never call flush().

Add the tracker to <head> in templates/base.html.twig:

templates/base.html.twig
<head>
    {{ mirafive_script() }}
</head>

It prints the hosted tracker with the website key, and nothing without one:

<script>window.mirafive=window.mirafive||function(){(mirafive.q=mirafive.q||[]).push(arguments)}</script>
<script defer src="https://cdn.mirafive.io/mira.js" data-key="mf_…"></script>

The tracker starts in consentless mode: no cookies, no storage, no consent banner. Server events are sent in full mode. See Consent.

Verify

  1. Run the check command. It sends an install check, which is never stored or billed, and prints the receipt:

    bin/console mirafive:check

    It ends with The secret key and host work. Nothing was stored or billed. A wrong key prints the error code, such as unauthorized, and a hint.

  2. Load a page on its real domain and look for POST https://events.mirafive.io/v1/batch/mf_… answering 202 in the browser's network tab. The tracker skips localhost unless you pass track_localhost: true to mirafive_script().

  3. Trigger a server event, then open the source's live view in MIRA FIVE and find both.

Nothing arriving? See Troubleshooting.

Track events

MiraFive\Mira takes the named arguments of the PHP SDK. userId is your internal id as a string, never an email address:

$mira->track('order completed', userId: (string) $order->getCustomerId(), properties: [
    'revenue' => $order->getTotal(),
    'currency' => 'EUR',
]);

For events that must be recorded exactly once, such as a payment webhook, send() sends at once with an idempotency key and returns the MiraFive\Receipt. It never goes through Messenger:

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

Input MIRA FIVE would refuse throws an InvalidArgumentException at the call. Delivery never throws into your code: failures are logged as warnings on the mirafive Monolog channel (or the logger service). Event names, properties and revenue are covered in Track events.

Identify users

Identify the user after login with a LoginSuccessEvent listener:

src/EventListener/IdentifyOnLogin.php
<?php

namespace App\EventListener;

use App\Entity\User;
use MiraFive\Mira;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Security\Http\Event\LoginSuccessEvent;

#[AsEventListener]
final readonly class IdentifyOnLogin
{
    public function __construct(private Mira $mira) {}

    public function __invoke(LoginSuccessEvent $event): void
    {
        $user = $event->getUser();

        if (! $user instanceof User) {
            return;
        }

        // Your internal id. getUserIdentifier() is often the email address: never send that.
        $this->mira->identify((string) $user->getId(), ['plan' => $user->getPlan()]);
    }
}

See Identify users.

Tracker tag

mirafive_script() takes options that map to the tracker's attributes:

{{ mirafive_script({autocapture: true, site_search: ['q', 'term']}) }}
OptionAttributeDescription
modedata-mode'consentless' or 'full'. Defaults to script_mode.
hashdata-hashThe site routes by #.
manualdata-manualNo automatic pageviews.
autocapturedata-autocaptureClicks, submits and changes.
site_searchdata-site-searchtrue, or the query parameters as a string or list.
flagsdata-flagsLoad flags at once.
track_localhostdata-track-localhostMeasure localhost too.
hostdata-hostDefaults to the configured host when it is not the default.
srcsrcA pinned (mira.<hash>.js) or self-hosted copy.
integrityintegrity, crossoriginSubresource Integrity for a pinned copy. The rolling mira.js cannot carry it.
noncenonceOn both script tags, for a Content Security Policy.

An unknown option throws. The attributes themselves are described on the script tag page.

Server events and the tracker have separate collection modes:

SettingDefault
Server eventsmodefull
Tracker tagscript_modeconsentless
  • Full server events carry the userId, anonymousId and sessionId you pass. You hold the consent or other lawful basis for them.
  • Consentless carries no identifiers. On the server, passing one throws an InvalidArgumentException.
  • A tracker in full mode (script_mode: full or mirafive_script({mode: 'full'})) stores ids only once the visitor consents. Tell it with mirafive('consent', true) from your banner. See Script tag.
  • mirafive_flags() treats Sec-GPC: 1 or DNT: 1 on the request as an opt-out.
  • No personal data in event names or properties. The bundle never prints the secret key.

See Consent.

Feature flags

MiraFive\Flags\MiraFlags is autowired. It is $mira->flags(): one flag document per process.

use MiraFive\Flags\MiraFlags;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

public function checkout(MiraFlags $flags, Request $request): Response
{
    $user = $flags->for(
        userId: 'u_42',
        properties: ['plan' => 'pro'],                          // facts your rules test; never sent
        consent: ['experiments' => true, 'targeting' => false], // your banner's answer, when you have one
        optedOut: $request->headers->get('Sec-GPC') === '1' || $request->headers->get('DNT') === '1',
    );

    $template = $user->enabled('new-checkout') ? 'checkout/new.html.twig' : 'checkout/old.html.twig';

    return $this->render($template, [
        'limits' => $user->config('checkout-limits', ['maxItems' => 10]),
        'variant' => $user->variant('pricing-test', 'control'),
    ]);
}

Reads never throw; without a document or for an unknown key they answer your fallback. The document is fetched on first use and refreshed on read after flags.refresh_seconds. Under PHP-FPM every request starts empty, so the bundle shares the document through cache.app by default; set flags.cache to another pool, or to null. Experiments counted on the server send their $exposure through the same buffer. The unit, consent, segments and error codes work as in the PHP SDK. See Feature flags.

Bootstrap in Twig

Hand the server's answers to the browser so the first paint shows the right variant:

<head>
    {{ mirafive_flags({userId: app.user ? app.user.id : null, properties: {plan: 'pro'}}) }}
    {{ mirafive_script({flags: true}) }}
</head>

mirafive_flags() takes userId, anonymousId, properties, consent and optedOut, or a UserFlags you built in the controller. optedOut is read from Sec-GPC and DNT when you leave it out. It prints <script type="application/json" id="mirafive-flags">…</script> with every <, > and & escaped, and only flags your website reads. Disabled, it prints nothing.

A page with a bootstrap belongs to one visitor. The bundle sets Cache-Control: private, no-store on the main response of any request that rendered one. For a StreamedResponse, where Twig renders after the headers are sent, set them yourself:

use MiraFive\Flags\MiraFlags;

foreach (MiraFlags::BOOTSTRAP_HEADERS as $name => $value) {
    $response->headers->set($name, $value);
}

Messenger and worker runtimes

Flushing. The bundle sends the buffer once per request on kernel.terminate and once per command on console.terminate. The PHP SDK's own shutdown flush is off, so nothing goes out twice. Under PHP-FPM, kernel.terminate runs after the response has been sent.

Worker runtimes (FrankenPHP worker mode, RoadRunner, Swoole, messenger:consume). Every service reset between two requests (kernel.reset) sends what is left in the buffer. In messenger:consume the buffer is also sent after each handled or failed message, so events tracked in your handlers go out message by message. Per-request state lives on the request, so nothing leaks into the next one; the flag document is kept, as a process-wide cache.

Messenger delivery. To send from a worker instead of kernel.terminate, hand the batches to Messenger:

config/packages/mirafive.yaml
mirafive:
    messenger: true # or a bus service id, e.g. messenger.bus.events
config/packages/messenger.yaml
framework:
    messenger:
        routing:
            MiraFive\Symfony\Messenger\DeliverBatch: async
  • Every buffered flush becomes a MiraFive\Symfony\Messenger\DeliverBatch carrying the encoded body. The message carries no key.
  • The worker sends it with its own client, byte for byte, so a retried batch keeps its batch id and is stored once.
  • The worker's client retries briefly first. A failure that is still retryable (timeouts, 429, 5xx) is then thrown for your retry strategy. Refusals (400, 401, 403, 413) are unrecoverable and go to the failure transport.
  • A worker with enabled: false drops queued batches. A worker without a secret key logs an error and marks the message unrecoverable, so the batch lands in the failure transport.
  • Without a routing entry the message is handled synchronously, in kernel.terminate.
  • send() and mirafive:check never go through Messenger. Flag documents and segment lookups are always fetched directly.

Testing

Switch the bundle to test mode in the test environment:

config/packages/mirafive.yaml
when@test:
    mirafive:
        test: true

In test mode nothing touches the network: batches are recorded, no secret key is needed, and Messenger and the flag cache are bypassed. MiraFive\Symfony\Test\MiraFake asserts on what was tracked, including events still in the buffer:

tests/SignupTest.php
<?php

namespace App\Tests;

use MiraFive\Symfony\Test\InteractsWithMira;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

final class SignupTest extends WebTestCase
{
    use InteractsWithMira;

    public function test_signup_is_tracked(): void
    {
        $client = static::createClient();
        $client->request('POST', '/signup', ['plan' => 'pro']);

        self::mira()->assertTracked('signup', fn (array $event): bool => $event['properties']['plan'] === 'pro');
        self::mira()->assertNotTracked('checkout');
    }
}

Without a WebTestCase, get it from the container: static::getContainer()->get(MiraFake::class). To test flag code, call self::mira()->serveFlags($document) with a flag document before the first flag read.

API reference

Configuration

Every key is optional. The defaults:

config/packages/mirafive.yaml
mirafive:
    secret_key: '%env(default::MIRAFIVE_SECRET_KEY)%'   # server source; server-side only
    website_key: '%env(default::MIRAFIVE_WEBSITE_KEY)%' # website source; printed by mirafive_script()
    host: '%env(default::MIRAFIVE_HOST)%'               # empty: https://events.mirafive.io
    mode: full                                          # full | consentless, for server events
    script_mode: consentless                            # consentless | full, for the tracker tag
    enabled: true                                       # false: record nothing
    messenger: null                                     # true or a bus service id: deliver through Messenger
    flags:
        refresh_seconds: 30                             # at least 10
        cache: cache.app                                # PSR-16 or PSR-6 service id; null for none
    test: false                                         # record instead of send

With enabled: false, or without a secret key, Mira and MiraFlags are still autowired but record nothing: send() returns a local receipt, flags answer your fallbacks and mirafive_flags() prints nothing. Invalid input still throws. mirafive_script() needs only the website key, so only enabled: false removes it. A typical development setting:

config/packages/mirafive.yaml
when@dev:
    mirafive:
        enabled: false

Services

ServiceDescription
MiraFive\Miratrack(), identify(), send(), flush() and flags(), as in the PHP SDK.
MiraFive\Flags\MiraFlagsfor(userId:, anonymousId:, properties:, consent:, optedOut:) returning UserFlags (enabled, variant, config, evaluate, bootstrap); ready(), snapshot(), status().
MiraFive\Symfony\Test\MiraFakeTest mode only.

Twig

FunctionDescription
mirafive_script(options = {})The tracker tag with the website key. See Tracker tag.
mirafive_flags(unit = {})The flag bootstrap block. Sets Cache-Control: private, no-store on the response.

MiraFive\Symfony\Test\MiraFake

MethodDescription
assertTracked(string $name, ?callable $where = null, ?int $times = null)$where receives each wire event: name, time, userId, anonymousId, properties, page.
assertNotTracked(string $name, ?callable $where = null)
assertIdentified(string $userId, ?array $traits = null)A $identify for this user, with exactly these traits when given.
assertNothingTracked()
events(?string $name = null), batches()What was recorded, decoded.
serveFlags(string|array $document)The flag document MiraFlags fetches. Call it before the first flag read.
clear()Forget what was recorded.

Every method flushes the client first, so events still in the buffer count. InteractsWithMira adds self::mira() to a KernelTestCase or WebTestCase.

Console

bin/console mirafive:check sends $install_check with the configured key and host and prints the batch, accepted, dropped and reason. It fails unless the reason is install_check.

Troubleshooting

SymptomCause and fix
mirafive:check says disabledMIRAFIVE_SECRET_KEY is empty in this environment, or mirafive.enabled is false. bin/console debug:container --env-vars shows what Symfony sees.
unauthorized or website_key_as_bearerMIRAFIVE_SECRET_KEY holds a wrong key or the website key. Server code needs the secret key of a server source.
Nothing arrives, no errorLook at the mirafive log channel and run mirafive:check. With messenger set, make sure a worker consumes the transport DeliverBatch is routed to.
Events from a long-running command arrive only at the endThey are sent on console.terminate. Call $mira->flush() at checkpoints of a long import.
The Twig tag prints nothingwebsite_key is empty or mirafive.enabled is false in this environment.
A flag always answers its fallback$flags->status() shows whether a document arrived. $user->evaluate($key)->errorCode says why: NOT_READY (no document yet; see the log), FLAG_NOT_FOUND (not a flag of this source), NOT_ALLOWED (consent, or an experiment counted in the browser).
Pages with a bootstrap end up in a shared cacheA reverse proxy ignores Cache-Control: private, no-store, or the page is a StreamedResponse (set the headers yourself).

Set up with an AI agent

Paste this into your coding agent:

Add MIRA FIVE analytics to this Symfony application with the Composer package mirafive/sdk-symfony.
Docs: https://docs.mirafive.io/sdks/symfony.md

1. Run `composer require mirafive/sdk-symfony` (PHP 8.3+, Symfony 6.4, 7 or 8). With Flex the bundle registers
   itself; otherwise add MiraFive\Symfony\MiraFiveBundle::class => ['all' => true] to config/bundles.php.
2. Add MIRAFIVE_SECRET_KEY= and MIRAFIVE_WEBSITE_KEY= (empty values) to .env and ask me for the real values
   for .env.local. Never commit real keys and never render MIRAFIVE_SECRET_KEY into a template or JavaScript.
   No config file is needed.
3. Add `when@test: { mirafive: { test: true } }` to config/packages/mirafive.yaml so tests never send.
4. Inject MiraFive\Mira where the few business events happen (signup, order completed) and call
     $mira->track('signup', userId: (string) $user->getId(), properties: ['plan' => $plan]);
   Use the internal user id, never an email address or getUserIdentifier() if that is an email.
   No personal data in event names or properties.
5. Add a LoginSuccessEvent listener calling $mira->identify((string) $user->getId(), [...traits]);
6. Put {{ mirafive_script() }} in the <head> of templates/base.html.twig. Keep the default consentless mode
   unless the project already has a consent banner; then set script_mode: full in mirafive.yaml and pass
   the banner's answer with mirafive('consent', …).
7. Do not call flush(): the bundle sends after the response and after console commands.
8. Add a test using MiraFive\Symfony\Test\InteractsWithMira and self::mira()->assertTracked('signup').
9. Verify with `bin/console mirafive:check` once a real key is set: it must end with
   "The secret key and host work". Report what you changed.
Do not add other analytics libraries, cookies or consent banners.

On this page