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-symfonyRequires 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:
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:
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
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:
<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
-
Run the check command. It sends an install check, which is never stored or billed, and prints the receipt:
bin/console mirafive:checkIt ends with
The secret key and host work. Nothing was stored or billed.A wrong key prints the error code, such asunauthorized, and a hint. -
Load a page on its real domain and look for
POST https://events.mirafive.io/v1/batch/mf_…answering202in the browser's network tab. The tracker skipslocalhostunless you passtrack_localhost: truetomirafive_script(). -
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:
<?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']}) }}| 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.
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,anonymousIdandsessionIdyou 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: fullormirafive_script({mode: 'full'})) stores ids only once the visitor consents. Tell it withmirafive('consent', true)from your banner. See Script tag. mirafive_flags()treatsSec-GPC: 1orDNT: 1on 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:
mirafive:
messenger: true # or a bus service id, e.g. messenger.bus.eventsframework:
messenger:
routing:
MiraFive\Symfony\Messenger\DeliverBatch: async- Every buffered flush becomes a
MiraFive\Symfony\Messenger\DeliverBatchcarrying 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: falsedrops 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()andmirafive:checknever go through Messenger. Flag documents and segment lookups are always fetched directly.
Testing
Switch the bundle to test mode in the test environment:
when@test:
mirafive:
test: trueIn 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
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:
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 sendWith 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:
when@dev:
mirafive:
enabled: falseServices
| Service | Description |
|---|---|
MiraFive\Mira | track(), identify(), send(), flush() and flags(), as in the PHP SDK. |
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. |
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:
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.