mirafive / sdk-symfony
MIRA FIVE SDK for Symfony
Requires
- php: ^8.3
- mirafive/sdk-php: ^1.0
- psr/log: ^1.1|^2.0|^3.0
- psr/simple-cache: ^2.0|^3.0
- symfony/cache: ^6.4|^7.0|^8.0
- symfony/config: ^6.4|^7.0|^8.0
- symfony/console: ^6.4|^7.0|^8.0
- symfony/dependency-injection: ^6.4|^7.0|^8.0
- symfony/event-dispatcher: ^6.4|^7.0|^8.0
- symfony/framework-bundle: ^6.4|^7.0|^8.0
- symfony/http-foundation: ^6.4|^7.0|^8.0
- symfony/http-kernel: ^6.4|^7.0|^8.0
- symfony/service-contracts: ^3.0
Requires (Dev)
- laravel/pint: ^1.24
- phpstan/phpstan: ^2.1
- phpstan/phpstan-symfony: ^2.0
- phpunit/phpunit: ^11.5|^12.0
- symfony/messenger: ^6.4|^7.0|^8.0
- symfony/twig-bundle: ^6.4|^7.0|^8.0
- twig/twig: ^3.10
Suggests
- symfony/messenger: To hand batches to a Messenger transport instead of sending them in the request.
- symfony/twig-bundle: For the mirafive_script() and mirafive_flags() Twig functions.
Provides
None
Conflicts
- twig/twig: <3.10
Replaces
None
README
Privacy-first analytics and feature flags for Symfony applications, hosted in the EU. The bundle wires the PHP SDK into your container, sends events after the response has gone out, prints the tracker tag in Twig and hands flag answers to the page.
Install
composer require mirafive/sdk-symfony
PHP 8.3 or newer, Symfony 6.4, 7.x or 8.x. Twig and 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], ];
Create a server source in MIRA FIVE for the backend and, if you also measure the website, a website source. Put the keys in .env.local (or your secret store), never in the repository:
MIRAFIVE_SECRET_KEY=mf_ab12cd34_… # server source, server-side only MIRAFIVE_WEBSITE_KEY=mf_ef56gh78_… # website source, public
No configuration file is needed; the defaults read these variables. To change something, create config/packages/mirafive.yaml (see Configuration).
Check the setup:
bin/console mirafive:check
It sends an $install_check event, which is never stored or billed, and prints the receipt.
Quickstart
Track from a controller
MiraFive\Mira is autowired. track() only buffers; the bundle sends the buffer on kernel.terminate, after the response has reached the browser.
use MiraFive\Mira; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; final class SignupController extends AbstractController { #[Route('/signup', methods: ['POST'])] public function __invoke(Mira $mira): Response { $user = /* … create the account … */; $mira->track('signup', userId: (string) $user->getId(), properties: ['plan' => 'pro']); return $this->redirectToRoute('dashboard'); } }
Identify after login
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(); // Your internal id. getUserIdentifier() is often the e-mail address: never send that. $this->mira->identify((string) $user->getId(), ['plan' => $user->getPlan()]); } }
The tracker tag in base.html.twig
<head> {# … #} {{ mirafive_script() }} </head>
This prints the hosted tracker with the website key:
<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_ef56gh78_…"></script>
It needs only the website key and prints nothing without one or with enabled: false. Options map to the tracker's attributes:
{{ mirafive_script({mode: 'full', autocapture: true, site_search: ['q', 'term']}) }}
| Option | Attribute | |
|---|---|---|
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, changes |
site_search |
data-site-search |
true, or the query parameters as a string or list |
flags |
data-flags |
load the flags chunk up front |
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 of the tracker |
integrity |
integrity, crossorigin |
Subresource Integrity for a pinned copy, from the tracker's manifest.json; the rolling mira.js changes under one URL and cannot carry it |
nonce |
nonce |
set on both script tags, for a Content Security Policy |
Consent & privacy
The server client and the tracker tag each have a collection mode.
- Server events default to full (
mode: full), as with every MIRA FIVE server SDK: they may carryuserId,anonymousIdandsessionId. Use it for people who consented, or where you hold another lawful basis.mode: consentlesssends no identifiers at all; passing one throws anInvalidArgumentException. - The tracker tag has its own mode,
script_mode, default consentless, which needs no banner: no cookies, no storage, no identifiers. Withscript_mode: full(ormirafive_script({mode: 'full'})) the tracker stores ids only once the visitor consents; tell it withmirafive('consent', true)or{statistics, experiments, targeting}from your banner. - Flags take the banner's answer and the visitor's opt-out, see Flags.
Always:
- No personal data in event names or properties. No e-mail addresses, names or free text a person typed.
userIdis pseudonymous: your internal id, nevergetUserIdentifier()when that is an e-mail address.- The secret key stays on the server. Only
website_keyever reaches a page; the bundle never prints the secret key. - Browsers sending Do Not Track or Global Privacy Control are not measured by the tracker, and
mirafive_flags()treatsSec-GPC: 1orDNT: 1as an opt-out.
Configuration
Every key is optional. The full reference with 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 sharing the flag document; null for none test: false # record instead of send, for the test environment
- Disabled. With
enabled: false, or without a secret key,MiraandMiraFlagsare still autowired but record nothing: nothing leaves the process,send()returns a local receipt, flags answer your fallbacks andmirafive_flags()prints nothing.mirafive_script()needs only the website key, so it is removed byenabled: falsealone. Invalid input still throws, so a bug shows up before production. A typicalwhen@dev: { mirafive: { enabled: false } }. - Autowired services.
MiraFive\MiraandMiraFive\Flags\MiraFlags. The injectedMiraFlagsis$mira->flags(): one flag document per process. - Errors. Delivery never throws into your code. Failures go to the
mirafiveMonolog channel (or theloggerservice), as warnings. - Idempotent sends.
$mira->send([...], idempotencyKey: 'order-981')sends at once and returns the receipt; the same key is stored once. Use it for webhooks that may arrive twice.
Messenger & worker runtimes
Flushing. The bundle sends the buffer once per request on kernel.terminate, and once per command on console.terminate. It switches the PHP SDK's own shutdown flush off (flushOnShutdown: false), so nothing goes out twice. Under PHP-FPM, kernel.terminate runs after fastcgi_finish_request(), so delivery does not delay the response.
Worker runtimes (FrankenPHP worker mode, RoadRunner, Swoole, messenger:consume). Every service reset between two requests (kernel.reset) sends what is left in the buffer, including events tracked after the terminate flush. In messenger:consume, where no request and no kernel.* event ever happens, the buffer is also sent after each handled or failed message, so events tracked inside your handlers go out message by message. Request state (whether a page carried a flag bootstrap) lives on the request, not in a service, so nothing leaks into the next request. The flag document is kept on purpose: it is a process-wide cache.
Messenger. Sending happens in kernel.terminate, which is already after the response for PHP-FPM and FrankenPHP. If you would still rather send from a worker, 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 is handed to Messenger as a
MiraFive\Symfony\Messenger\DeliverBatchcarrying the body exactly as the PHP SDK encoded it (itshandOffseam). The message carries no key. - The worker delivers it with its own
Mira(deliverPrepared()), byte for byte, so every retry reuses the batch id and MIRA FIVE stores a retried batch once. The worker's client retries briefly first; a failure that is still retryable (timeouts,429,5xx) is then thrown for your retry strategy, and refusals (400,401,403,413) are unrecoverable and go to the failure transport. - A worker with
enabled: falsedrops queued batches on purpose. A worker that is enabled but has no secret key logs an error on themirafivechannel and marks the message unrecoverable, so the batch lands in the failure transport instead of vanishing. - Without a routing entry the message is handled synchronously, i.e. sent in
kernel.terminateas without Messenger. $mira->send()is never queued: it sends at once and returns the collector's receipt. So doesbin/console mirafive:check.- Flag documents and segment lookups are always fetched directly.
Flags
use MiraFive\Flags\MiraFlags; use Symfony\Component\HttpFoundation\Request; public function checkout(MiraFlags $flags, Request $request): Response { $id = $this->getUser()?->getId(); $user = $flags->for( userId: $id === null ? null : (string) $id, 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', ); if ($user->enabled('new-checkout')) { // … } $limits = $user->config('limits', ['max' => 3]); $variant = $user->variant('pricing-test'); }
Reads never throw; without a document or for an unknown key they answer your fallback. The document is fetched from MIRA FIVE on first use and refreshed on read after flags.refresh_seconds. With 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. Server-counted experiments send one $exposure per person, experiment and variant through the same buffer. Consent, opt-out, reasons and error codes are described in the PHP SDK README.
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 (read from Sec-GPC/DNT when left out), or a UserFlags you already built in the controller. It prints <script type="application/json" id="mirafive-flags">…</script> with every <, > and & escaped, and only flags your website reads.
A page with a bootstrap belongs to one person. The bundle therefore sets Cache-Control: private, no-store (MiraFlags::BOOTSTRAP_HEADERS) on the main response of any request that rendered one. For a StreamedResponse, where Twig renders after the headers are sent, set them yourself:
foreach (MiraFlags::BOOTSTRAP_HEADERS as $name => $value) { $response->headers->set($name, $value); }
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, a secret key is not needed, Messenger and the flag cache are bypassed. MiraFive\Symfony\Test\MiraFake asserts on what was tracked, including events still in the buffer:
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()->assertIdentified('42'); self::mira()->assertNotTracked('checkout'); } }
MiraFake |
|
|---|---|
assertTracked(name, ?where, ?times) |
where receives each wire event: name, time, userId, anonymousId, properties, page |
assertNotTracked(name, ?where) |
|
assertIdentified(userId, ?traits) |
a $identify for this user, with exactly these traits when given |
assertNothingTracked() |
|
events(?name), batches() |
what was recorded, decoded |
serveFlags(document) |
the flag document MiraFlags fetches (FLAGS.md §3); call it before the first flag read |
clear() |
forget what was recorded |
Without a WebTestCase, get it from the container: static::getContainer()->get(MiraFake::class).
Troubleshooting
bin/console mirafive:checksays disabled.MIRAFIVE_SECRET_KEYis empty in this environment, ormirafive.enabledis false.bin/console debug:container --env-varsshows what Symfony sees.unauthorized/website_key_as_bearer.MIRAFIVE_SECRET_KEYholds a wrong key or the public website key. Server code needs the secret key of a server source.- Nothing arrives, no error. Look at the
mirafivelog channel. Runmirafive:check. Withmessengerset, make sure a worker consumes the transportDeliverBatchis 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_keyis empty ormirafive.enabledis false in this environment. - Flags always answer the fallback.
$flags->status()shows whether a document arrived;$user->evaluate($key)->errorCodesays why:NOT_READY(no document yet, see the log),FLAG_NOT_FOUND(not served to this server source),NOT_ALLOWED(consent, or an experiment counted in the browser). - Pages with a bootstrap end up in a shared cache. A reverse proxy that ignores
Cache-Control: private, no-store, or aStreamedResponse(set the headers yourself).
For AI agents
(a) A prompt for an agent adding MIRA FIVE to a Symfony application:
Add MIRA FIVE analytics to this Symfony application with the Composer package mirafive/sdk-symfony.
1. Run `composer require mirafive/sdk-symfony` (PHP 8.3+, Symfony 6.4/7/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. 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 e-mail address or getUserIdentifier() if that is an e-mail.
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, and report what you changed.
Do not add other analytics libraries, cookies or consent banners.
(b) Facts for agents:
- Package
mirafive/sdk-symfony, bundleMiraFive\Symfony\MiraFiveBundle, config keymirafive. Core:mirafive/sdk-php, namespaceMiraFive. - Autowired:
MiraFive\Mira(track,identify,send,flush) andMiraFive\Flags\MiraFlags(for(userId:, anonymousId:, properties:, consent:, optedOut:)→enabled,variant,config,evaluate,bootstrap). - Env vars:
MIRAFIVE_SECRET_KEY(server source, server-side only),MIRAFIVE_WEBSITE_KEY(public),MIRAFIVE_HOST(optional, defaulthttps://events.mirafive.io). - Without a secret key, or with
enabled: false, server events and flags are a silent no-op; invalid input still throwsInvalidArgumentException.mirafive_script()needs only the website key. - Sent on
kernel.terminateandconsole.terminate, onkernel.resetin worker runtimes, and after each message inmessenger:consume. Never callflush()in a controller or message handler. - Config
mode(server events, defaultfull) andscript_mode(tracker tag, defaultconsentless) are separate. - Twig:
mirafive_script(options)(tracker tag, website key, mode fromscript_mode),mirafive_flags(unit)(flag bootstrap; setsCache-Control: private, no-store). - Delivery never throws; failures are logged on the
mirafivechannel.send()throwsMiraFive\MiraError. - Tests:
when@test: { mirafive: { test: true } }, thenMiraFive\Symfony\Test\MiraFake(assertTracked,assertNotTracked,assertIdentified,assertNothingTracked,serveFlags) via theInteractsWithMiratrait. - Verify:
bin/console mirafive:checkexits 0 and prints the reasoninstall_check. - Wire contract: mirafive/protocol.
License
MIT, see LICENSE. Copyright (c) 2026 Cloo GmbH.