# Symfony

Source: https://docs.mirafive.io/sdks/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](https://docs.mirafive.io/sdks/php) 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

```sh
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`:

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

## Set up

You need two keys, see [Keys](https://docs.mirafive.io/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:

```sh title=".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:

```php title="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`:

```twig title="templates/base.html.twig"
<head>
    {{ mirafive_script() }}
</head>
```

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

```html
<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](https://docs.mirafive.io/guides/consent#consentless-mode): no cookies, no storage, no consent banner. Server events are sent in [full mode](https://docs.mirafive.io/guides/consent#full-mode). See [Consent](#consent).

## Verify

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

   ```sh
   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](#troubleshooting).

## Track events

`MiraFive\Mira` takes the named arguments of the [PHP SDK](https://docs.mirafive.io/sdks/php#track-events). `userId` is your internal id as a string, never an email address:

```php
$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:

```php
$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](https://docs.mirafive.io/guides/track-events).

## Identify users

Identify the user after login with a `LoginSuccessEvent` listener:

```php title="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](https://docs.mirafive.io/guides/identify-users).

## Tracker tag

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

```twig
{{ mirafive_script({autocapture: true, site_search: ['q', 'term']}) }}
```

| Option | Attribute | Description |
| --- | --- | --- |
| `mode` | `data-mode` | `'consentless'` or `'full'`. Defaults to `script_mode`. |
| `hash` | `data-hash` | The site routes by `#`. |
| `manual` | `data-manual` | No automatic pageviews. |
| `autocapture` | `data-autocapture` | Clicks, submits and changes. |
| `site_search` | `data-site-search` | `true`, or the query parameters as a string or list. |
| `flags` | `data-flags` | Load flags at once. |
| `track_localhost` | `data-track-localhost` | Measure `localhost` too. |
| `host` | `data-host` | Defaults to the configured host when it is not the default. |
| `src` | `src` | A pinned (`mira.<hash>.js`) or self-hosted copy. |
| `integrity` | `integrity`, `crossorigin` | Subresource Integrity for a pinned copy. The rolling `mira.js` cannot carry it. |
| `nonce` | `nonce` | On both script tags, for a Content Security Policy. |

An unknown option throws. The attributes themselves are described on the [script tag page](https://docs.mirafive.io/sdks/script-tag).

## Consent

Server events and the tracker have separate collection modes:

| | Setting | Default |
| --- | --- | --- |
| Server events | `mode` | `full` |
| Tracker tag | `script_mode` | `consentless` |

- **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](https://docs.mirafive.io/sdks/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](https://docs.mirafive.io/guides/consent).

## Feature flags

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

```php
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](https://docs.mirafive.io/sdks/php#feature-flags). See [Feature flags](https://docs.mirafive.io/guides/feature-flags).

### Bootstrap in Twig

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

```twig
<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:

```php
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:

```yaml title="config/packages/mirafive.yaml"
mirafive:
    messenger: true # or a bus service id, e.g. messenger.bus.events
```

```yaml title="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:

```yaml title="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:

```php title="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:

```yaml title="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:

```yaml title="config/packages/mirafive.yaml"
when@dev:
    mirafive:
        enabled: false
```

### Services

| Service | Description |
| --- | --- |
| `MiraFive\Mira` | `track()`, `identify()`, `send()`, `flush()` and `flags()`, as in the [PHP SDK](https://docs.mirafive.io/sdks/php#api-reference). |
| `MiraFive\Flags\MiraFlags` | `for(userId:, anonymousId:, properties:, consent:, optedOut:)` returning `UserFlags` (`enabled`, `variant`, `config`, `evaluate`, `bootstrap`); `ready()`, `snapshot()`, `status()`. |
| `MiraFive\Symfony\Test\MiraFake` | Test mode only. |

### Twig

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

### `MiraFive\Symfony\Test\MiraFake`

| Method | Description |
| --- | --- |
| `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

| Symptom | Cause and fix |
| --- | --- |
| `mirafive:check` says disabled | `MIRAFIVE_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_bearer` | `MIRAFIVE_SECRET_KEY` holds a wrong key or the website key. Server code needs the secret key of a server source. |
| Nothing arrives, no error | Look 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 end | They are sent on `console.terminate`. Call `$mira->flush()` at checkpoints of a long import. |
| The Twig tag prints nothing | `website_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 cache | A 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:

```text
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.
```
