diff --git a/README.md b/README.md index 1b12896..6d9b7d5 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,16 @@ setono_meta_conversions_api: - id: '%env(META_PIXEL_ID)%' access_token: '%env(META_ACCESS_TOKEN)%' + # The bundle writes the _fbp and _fbc cookies server side, so a visitor is recognised even without the + # browser pixel. Both are only written when at least one pixel is available + cookies: + # The domain to write them on. Meta's own pixel uses the registrable domain, so set this to yours if your + # site is reachable on both the apex and www, or spans several subdomains. Null scopes them to the current + # host, which means apex and www get different cookies + domain: null + # Anything \DateTimeImmutable understands. Meta keeps these for 90 days + lifetime: '+90 days' + # The PSR-18 http client used to send events. Defaults to Symfony's default http client, which means requests # to Meta show up in the profiler and honour the options you configured. Point it at a scoped client to give # Meta its own timeout diff --git a/UPGRADE.md b/UPGRADE.md index 79208e1..5dee617 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -81,6 +81,66 @@ Enabling client side tracking without the tag bag bundle now throws `\LogicExcep `\InvalidArgumentException`, which is what Symfony uses for "this bundle needs that bundle". Adjust your test if you asserted on the old type. +## Cookies + +New `cookies` options: + +```yaml +setono_meta_conversions_api: + cookies: + domain: null # e.g. example.com + lifetime: '+90 days' +``` + +The `_fbp` and `_fbc` cookies are now only written when at least one pixel is available, because every `Set-Cookie` +header makes a response uncacheable for shared caches and there is nothing to send events to anyway. + +The subdomain index encoded in the value (the `1` in `fb.1.…`) is now derived from the domain the cookie is actually +written on, the same way Meta's parameter builder does it, instead of always being `1`. On a host-only cookie on +`www.example.com` the value is now `fb.2.…`. Set `cookies.domain` to your registrable domain to get `fb.1.…` and one +cookie shared between the apex and `www`. + +## The SendEvent command changed shape + +`SendEvent` no longer carries the `Setono\MetaConversionsApi\Event\Event` object. It carries the finished payload +instead: + +```php +new SendEvent( + string $eventName, + string $eventId, + array $payload, // already normalized and hashed by the SDK + array $pixelIds, // ids only, no access tokens + ?string $testEventCode = null, +); +``` + +Build one from an event with `SendEvent::fromEvent($event)`. + +**Why:** when the command is routed to a transport it is written to that transport's storage, and to the failure +transport when it fails. Previously that storage received the Conversions API access token and every raw email +address, phone number and name the application had attached, because hashing only happened later inside +`Client::sendEvent()`. Failure transports are often kept indefinitely, which made that a retention problem too. + +Access tokens are now resolved when the event is sent, through the new +`Setono\MetaConversionsApiBundle\AccessTokenResolver\AccessTokenResolverInterface`. The default implementation reads +them from the `pixels` configuration. If your pixels come from your own `PixelProviderInterface`, alias the resolver +as well: + +```yaml +services: + Setono\MetaConversionsApiBundle\AccessTokenResolver\AccessTokenResolverInterface: '@App\Provider\MyAccessTokenResolver' +``` + +Note that the resolver runs in the worker, so it must not depend on the current request. + +If you wrote your own handler or middleware for `SendEvent`, read `$message->payload` and `$message->pixelIds` +instead of `$message->event`. + +`SendEventHandler::__construct()` takes the resolver as its second argument, so its signature changed from +`(ClientInterface $client, ?LoggerInterface $logger)` to +`(ClientInterface $client, AccessTokenResolverInterface $accessTokenResolver, ?LoggerInterface $logger)`. Update the +service definition if you decorated or redefined it. ## Removed container parameters `setono_meta_conversions_api.client_side.enabled` and `setono_meta_conversions_api.server_side.enabled` are gone. No diff --git a/src/Context/Fbc/QueryBasedFbcContext.php b/src/Context/Fbc/QueryBasedFbcContext.php index 883ba2e..0f6a6bf 100644 --- a/src/Context/Fbc/QueryBasedFbcContext.php +++ b/src/Context/Fbc/QueryBasedFbcContext.php @@ -5,6 +5,7 @@ namespace Setono\MetaConversionsApiBundle\Context\Fbc; use Setono\MetaConversionsApi\ValueObject\Fbc; +use Setono\MetaConversionsApiBundle\Cookie\CookieDomain; use Symfony\Component\HttpFoundation\RequestStack; final class QueryBasedFbcContext implements FbcContextInterface @@ -20,6 +21,7 @@ final class QueryBasedFbcContext implements FbcContextInterface public function __construct( private readonly FbcContextInterface $decorated, private readonly RequestStack $requestStack, + private readonly CookieDomain $cookieDomain, ) { } @@ -35,6 +37,7 @@ public function getFbc(): ?Fbc return $this->decorated->getFbc(); } - return new Fbc($facebookClickId); + // The click id is about to be written as a cookie, so it has to say which level it was set at + return (new Fbc($facebookClickId))->withSubdomainIndex($this->cookieDomain->subdomainIndex()); } } diff --git a/src/Context/Fbp/GeneratedFbpContext.php b/src/Context/Fbp/GeneratedFbpContext.php index d028765..e593858 100644 --- a/src/Context/Fbp/GeneratedFbpContext.php +++ b/src/Context/Fbp/GeneratedFbpContext.php @@ -5,11 +5,17 @@ namespace Setono\MetaConversionsApiBundle\Context\Fbp; use Setono\MetaConversionsApi\ValueObject\Fbp; +use Setono\MetaConversionsApiBundle\Cookie\CookieDomain; final class GeneratedFbpContext implements FbpContextInterface { + public function __construct(private readonly CookieDomain $cookieDomain) + { + } + public function getFbp(): Fbp { - return new Fbp(); + // The generated value is about to be written as a cookie, so it has to say which level it was set at + return (new Fbp())->withSubdomainIndex($this->cookieDomain->subdomainIndex()); } } diff --git a/src/Cookie/CookieDomain.php b/src/Cookie/CookieDomain.php new file mode 100644 index 0000000..92d7266 --- /dev/null +++ b/src/Cookie/CookieDomain.php @@ -0,0 +1,48 @@ +domain; + } + + /** + * The number of dots in the domain the cookie ends up on, which is how Meta's own parameter builder + * computes it (`substr_count($etldPlus1, '.')`) + */ + public function subdomainIndex(): int + { + $domain = $this->domain ?? $this->requestStack->getMainRequest()?->getHost(); + if (null === $domain || '' === $domain) { + return 0; + } + + // A leading dot is the classic way of writing a domain cookie and says nothing about the level + return min(2, substr_count(ltrim($domain, '.'), '.')); + } +} diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index d0e4401..7822bc9 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -5,6 +5,7 @@ namespace Setono\MetaConversionsApiBundle\DependencyInjection; use Setono\Consent\DefaultConsents; +use Setono\MetaConversionsApiBundle\Cookie\Cookies; use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\ConfigurationInterface; @@ -66,6 +67,21 @@ public function getConfigTreeBuilder(): TreeBuilder ->end() ->end() ->end() + ->arrayNode('cookies') + ->info('How the _fbp and _fbc cookies are written') + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('domain') + ->info('The domain to write the cookies on, e.g. example.com, so the apex and www share one cookie the way Meta\'s own pixel does. Null scopes them to the current host') + ->defaultNull() + ->end() + ->scalarNode('lifetime') + ->info('Anything \DateTimeImmutable understands. Meta keeps these for 90 days') + ->defaultValue(Cookies::LIFETIME) + ->cannotBeEmpty() + ->end() + ->end() + ->end() ->scalarNode('http_client') ->info('The PSR-18 http client used to send events. Defaults to the application\'s psr18.http_client, i.e. the default Symfony http client. Point it at a scoped client to give Meta its own timeout') ->defaultValue('psr18.http_client') diff --git a/src/DependencyInjection/SetonoMetaConversionsApiExtension.php b/src/DependencyInjection/SetonoMetaConversionsApiExtension.php index 6f2bca1..fa59554 100644 --- a/src/DependencyInjection/SetonoMetaConversionsApiExtension.php +++ b/src/DependencyInjection/SetonoMetaConversionsApiExtension.php @@ -50,7 +50,7 @@ public function getConfiguration(array $config, ContainerBuilder $container): Co public function load(array $configs, ContainerBuilder $container): void { /** - * @var array{consent: array{enabled: bool, category: string}, client_side: array{enabled: bool}, server_side: array{enabled: bool, message_bus: string}, pixels: array, http_client: string, test_event_code: array{query_parameter: bool, value: string|null}, filters: array{user_agent: list}} $config + * @var array{consent: array{enabled: bool, category: string}, client_side: array{enabled: bool}, server_side: array{enabled: bool, message_bus: string}, pixels: array, http_client: string, test_event_code: array{query_parameter: bool, value: string|null}, cookies: array{domain: string|null, lifetime: string}, filters: array{user_agent: list}} $config */ $config = $this->processConfiguration($this->getConfiguration([], $container), $configs); // The XML format is deprecated since Symfony 7.4 and removed in 8.0. Migrate to PHP config before adding Symfony 8 support @@ -61,6 +61,10 @@ public function load(array $configs, ContainerBuilder $container): void $container->setParameter('setono_meta_conversions_api.pixels', $config['pixels']); $container->setParameter('setono_meta_conversions_api.filters.user_agent', $config['filters']['user_agent']); + $cookieDomain = $config['cookies']['domain']; + $container->setParameter('setono_meta_conversions_api.cookies.domain', '' === $cookieDomain ? null : $cookieDomain); + $container->setParameter('setono_meta_conversions_api.cookies.lifetime', $config['cookies']['lifetime']); + $testEventCode = $config['test_event_code']['value']; $container->setParameter('setono_meta_conversions_api.test_event_code.value', '' === $testEventCode ? null : $testEventCode); $container->setParameter('setono_meta_conversions_api.test_event_code.query_parameter', $config['test_event_code']['query_parameter']); diff --git a/src/EventSubscriber/StoreFbcSubscriber.php b/src/EventSubscriber/StoreFbcSubscriber.php index b0b408c..bbb9192 100644 --- a/src/EventSubscriber/StoreFbcSubscriber.php +++ b/src/EventSubscriber/StoreFbcSubscriber.php @@ -6,6 +6,7 @@ use Setono\MetaConversionsApiBundle\ConsentChecker\ConsentCheckerInterface; use Setono\MetaConversionsApiBundle\Context\Fbc\FbcContextInterface; +use Setono\MetaConversionsApiBundle\Cookie\CookieDomain; use Setono\MetaConversionsApiBundle\Cookie\Cookies; use Setono\MetaConversionsApiBundle\Provider\PixelProviderInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface; @@ -24,6 +25,8 @@ public function __construct( private readonly FbcContextInterface $fbcContext, private readonly ConsentCheckerInterface $consentChecker, private readonly PixelProviderInterface $pixelProvider, + private readonly CookieDomain $cookieDomain, + private readonly string $lifetime = Cookies::LIFETIME, ) { } @@ -64,7 +67,9 @@ public function store(ResponseEvent $event): void $response->headers->setCookie(Cookie::create( Cookies::FBC, $fbc->value(), - new \DateTimeImmutable(Cookies::LIFETIME), + new \DateTimeImmutable($this->lifetime), + '/', + $this->cookieDomain->domain(), )->withHttpOnly(false)); // we need this to allow the js library to also use the cookie value } } diff --git a/src/EventSubscriber/StoreFbpSubscriber.php b/src/EventSubscriber/StoreFbpSubscriber.php index 733805f..28a518a 100644 --- a/src/EventSubscriber/StoreFbpSubscriber.php +++ b/src/EventSubscriber/StoreFbpSubscriber.php @@ -7,6 +7,7 @@ use Setono\MetaConversionsApi\ValueObject\Fbp; use Setono\MetaConversionsApiBundle\ConsentChecker\ConsentCheckerInterface; use Setono\MetaConversionsApiBundle\Context\Fbp\FbpContextInterface; +use Setono\MetaConversionsApiBundle\Cookie\CookieDomain; use Setono\MetaConversionsApiBundle\Cookie\Cookies; use Setono\MetaConversionsApiBundle\Provider\PixelProviderInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface; @@ -26,6 +27,8 @@ public function __construct( private readonly FbpContextInterface $fbpContext, private readonly ConsentCheckerInterface $consentChecker, private readonly PixelProviderInterface $pixelProvider, + private readonly CookieDomain $cookieDomain, + private readonly string $lifetime = Cookies::LIFETIME, ) { } @@ -62,7 +65,9 @@ public function store(ResponseEvent $event): void $response->headers->setCookie(Cookie::create( Cookies::FBP, $fbp->value(), - new \DateTimeImmutable(Cookies::LIFETIME), + new \DateTimeImmutable($this->lifetime), + '/', + $this->cookieDomain->domain(), )->withHttpOnly(false)); // we need this to allow the js library to also use the cookie value } diff --git a/src/Resources/config/services/context.xml b/src/Resources/config/services/context.xml index f53346a..3c940e9 100644 --- a/src/Resources/config/services/context.xml +++ b/src/Resources/config/services/context.xml @@ -3,6 +3,11 @@ + + + %setono_meta_conversions_api.cookies.domain% + + @@ -25,6 +30,7 @@ decorates="Setono\MetaConversionsApiBundle\Context\Fbc\FbcContextInterface" decoration-priority="64"> + @@ -32,6 +38,7 @@ alias="Setono\MetaConversionsApiBundle\Context\Fbp\GeneratedFbpContext"/> + + + %setono_meta_conversions_api.cookies.lifetime% @@ -64,6 +66,8 @@ + + %setono_meta_conversions_api.cookies.lifetime% @@ -72,6 +76,8 @@ + + %setono_meta_conversions_api.cookies.lifetime% diff --git a/tests/Unit/Context/Fbc/QueryBasedFbcContextTest.php b/tests/Unit/Context/Fbc/QueryBasedFbcContextTest.php index 37c0109..7384ae1 100644 --- a/tests/Unit/Context/Fbc/QueryBasedFbcContextTest.php +++ b/tests/Unit/Context/Fbc/QueryBasedFbcContextTest.php @@ -11,6 +11,7 @@ use Setono\MetaConversionsApi\ValueObject\Fbc; use Setono\MetaConversionsApiBundle\Context\Fbc\FbcContextInterface; use Setono\MetaConversionsApiBundle\Context\Fbc\QueryBasedFbcContext; +use Setono\MetaConversionsApiBundle\Cookie\CookieDomain; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; @@ -22,7 +23,7 @@ public function it_falls_back_to_the_decorated_context_without_a_request(): void { $fbc = new Fbc('decorated'); - $context = new QueryBasedFbcContext(self::decorated($fbc), new RequestStack()); + $context = new QueryBasedFbcContext(self::decorated($fbc), new RequestStack(), new CookieDomain(new RequestStack())); self::assertSame($fbc, $context->getFbc()); } @@ -31,7 +32,7 @@ public function it_falls_back_to_the_decorated_context_without_a_request(): void #[DataProvider('validClickIds')] public function it_uses_a_valid_click_id_from_the_query(string $clickId): void { - $context = new QueryBasedFbcContext(self::decorated(null), self::requestStack($clickId)); + $context = new QueryBasedFbcContext(self::decorated(null), self::requestStack($clickId), new CookieDomain(new RequestStack())); $fbc = $context->getFbc(); @@ -56,7 +57,7 @@ public function it_falls_back_to_the_decorated_context_for_an_invalid_click_id(s { $decorated = new Fbc('decorated'); - $context = new QueryBasedFbcContext(self::decorated($decorated), self::requestStack($clickId)); + $context = new QueryBasedFbcContext(self::decorated($decorated), self::requestStack($clickId), new CookieDomain(new RequestStack())); self::assertSame($decorated, $context->getFbc()); } @@ -73,6 +74,21 @@ public static function invalidClickIds(): iterable yield 'dot' => ['fb.1.123.abc']; } + #[Test] + public function it_records_the_level_the_cookie_will_be_written_at(): void + { + $context = new QueryBasedFbcContext( + self::decorated(null), + self::requestStack('IwAR0rmfgHgx'), + new CookieDomain(new RequestStack(), 'www.example.com'), + ); + + $fbc = $context->getFbc(); + + self::assertInstanceOf(Fbc::class, $fbc); + self::assertSame(2, $fbc->getSubdomainIndex()); + } + private static function decorated(?Fbc $fbc): FbcContextInterface { return new class($fbc) implements FbcContextInterface { diff --git a/tests/Unit/Context/Fbp/GeneratedFbpContextTest.php b/tests/Unit/Context/Fbp/GeneratedFbpContextTest.php new file mode 100644 index 0000000..98fc33e --- /dev/null +++ b/tests/Unit/Context/Fbp/GeneratedFbpContextTest.php @@ -0,0 +1,38 @@ +getFbp()->value(), $context->getFbp()->value()); + } + + #[Test] + public function it_records_the_level_the_cookie_will_be_written_at(): void + { + $requestStack = new RequestStack(); + $requestStack->push(Request::create('https://www.example.com/')); + + // Host-only cookie on www.example.com, so the value has to say fb.2. + self::assertSame(2, (new GeneratedFbpContext(new CookieDomain($requestStack)))->getFbp()->getSubdomainIndex()); + + // ... but a cookie written on example.com is fb.1. + self::assertSame(1, (new GeneratedFbpContext(new CookieDomain($requestStack, 'example.com')))->getFbp()->getSubdomainIndex()); + } +} diff --git a/tests/Unit/Cookie/CookieDomainTest.php b/tests/Unit/Cookie/CookieDomainTest.php new file mode 100644 index 0000000..fd7a5c7 --- /dev/null +++ b/tests/Unit/Cookie/CookieDomainTest.php @@ -0,0 +1,77 @@ +domain()); + } + + #[Test] + public function it_returns_the_configured_domain(): void + { + self::assertSame('example.com', (new CookieDomain(new RequestStack(), 'example.com'))->domain()); + } + + /** + * Meta's own parameter builder computes this as the number of dots in the domain the cookie ends up on + */ + #[Test] + #[DataProvider('configuredDomains')] + public function it_derives_the_subdomain_index_from_the_configured_domain(string $domain, int $expected): void + { + self::assertSame($expected, (new CookieDomain(new RequestStack(), $domain))->subdomainIndex()); + } + + /** + * @return iterable + */ + public static function configuredDomains(): iterable + { + yield 'single label' => ['localhost', 0]; + yield 'registrable domain' => ['example.com', 1]; + yield 'leading dot' => ['.example.com', 1]; + yield 'subdomain' => ['www.example.com', 2]; + yield 'deep subdomain is capped' => ['shop.eu.example.com', 2]; + } + + #[Test] + #[DataProvider('hosts')] + public function it_falls_back_to_the_request_host(string $host, int $expected): void + { + $requestStack = new RequestStack(); + $requestStack->push(Request::create('https://' . $host . '/')); + + self::assertSame($expected, (new CookieDomain($requestStack))->subdomainIndex()); + } + + /** + * @return iterable + */ + public static function hosts(): iterable + { + yield 'single label' => ['localhost', 0]; + yield 'registrable domain' => ['example.com', 1]; + yield 'subdomain' => ['www.example.com', 2]; + } + + #[Test] + public function it_returns_zero_without_a_request_or_a_domain(): void + { + self::assertSame(0, (new CookieDomain(new RequestStack()))->subdomainIndex()); + } +} diff --git a/tests/Unit/EventSubscriber/StoreFbcSubscriberTest.php b/tests/Unit/EventSubscriber/StoreFbcSubscriberTest.php index fcf43ff..2a301c9 100644 --- a/tests/Unit/EventSubscriber/StoreFbcSubscriberTest.php +++ b/tests/Unit/EventSubscriber/StoreFbcSubscriberTest.php @@ -11,11 +11,13 @@ use Setono\MetaConversionsApi\ValueObject\Fbc; use Setono\MetaConversionsApiBundle\ConsentChecker\ConsentCheckerInterface; use Setono\MetaConversionsApiBundle\Context\Fbc\FbcContextInterface; +use Setono\MetaConversionsApiBundle\Cookie\CookieDomain; use Setono\MetaConversionsApiBundle\Cookie\Cookies; use Setono\MetaConversionsApiBundle\EventSubscriber\StoreFbcSubscriber; use Setono\MetaConversionsApiBundle\Provider\PixelProviderInterface; use Symfony\Component\HttpFoundation\Cookie; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Event\ResponseEvent; use Symfony\Component\HttpKernel\HttpKernelInterface; @@ -88,6 +90,18 @@ public function it_ignores_sub_requests(): void self::assertNull(self::cookie($event)); } + #[Test] + public function it_writes_the_cookie_on_the_configured_domain(): void + { + $event = self::event(new Request(['fbclid' => 'IwAR0rmfgHgx']), new Response()); + + self::subscriber(domain: 'example.com')->store($event); + + $cookie = self::cookie($event); + self::assertNotNull($cookie); + self::assertSame('example.com', $cookie->getDomain()); + } + private static function cookie(ResponseEvent $event): ?Cookie { foreach ($event->getResponse()->headers->getCookies() as $cookie) { @@ -106,6 +120,7 @@ private static function subscriber( ?Fbc $fbc = new Fbc('IwAR0rmfgHgx'), bool $consentGranted = true, ?array $pixels = null, + ?string $domain = null, ): StoreFbcSubscriber { return new StoreFbcSubscriber( new class($fbc) implements FbcContextInterface { @@ -141,6 +156,7 @@ public function getPixels(): array return $this->pixels; } }, + new CookieDomain(new RequestStack(), $domain), ); } diff --git a/tests/Unit/EventSubscriber/StoreFbpSubscriberTest.php b/tests/Unit/EventSubscriber/StoreFbpSubscriberTest.php index edad9bc..9a3cce0 100644 --- a/tests/Unit/EventSubscriber/StoreFbpSubscriberTest.php +++ b/tests/Unit/EventSubscriber/StoreFbpSubscriberTest.php @@ -11,11 +11,13 @@ use Setono\MetaConversionsApi\ValueObject\Fbp; use Setono\MetaConversionsApiBundle\ConsentChecker\ConsentCheckerInterface; use Setono\MetaConversionsApiBundle\Context\Fbp\FbpContextInterface; +use Setono\MetaConversionsApiBundle\Cookie\CookieDomain; use Setono\MetaConversionsApiBundle\Cookie\Cookies; use Setono\MetaConversionsApiBundle\EventSubscriber\StoreFbpSubscriber; use Setono\MetaConversionsApiBundle\Provider\PixelProviderInterface; use Symfony\Component\HttpFoundation\Cookie; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Event\ResponseEvent; use Symfony\Component\HttpKernel\HttpKernelInterface; @@ -102,6 +104,30 @@ public function it_renews_a_cookie_older_than_two_hours(): void self::assertNotNull(self::cookie($event)); } + #[Test] + public function it_writes_a_host_only_cookie_by_default(): void + { + $event = self::event(new Request(), new Response()); + + self::subscriber()->store($event); + + $cookie = self::cookie($event); + self::assertNotNull($cookie); + self::assertNull($cookie->getDomain()); + } + + #[Test] + public function it_writes_the_cookie_on_the_configured_domain(): void + { + $event = self::event(new Request(), new Response()); + + self::subscriber(domain: 'example.com')->store($event); + + $cookie = self::cookie($event); + self::assertNotNull($cookie); + self::assertSame('example.com', $cookie->getDomain()); + } + private static function cookie(ResponseEvent $event): ?Cookie { foreach ($event->getResponse()->headers->getCookies() as $cookie) { @@ -116,7 +142,7 @@ private static function cookie(ResponseEvent $event): ?Cookie /** * @param list|null $pixels */ - private static function subscriber(?Fbp $fbp = null, bool $consentGranted = true, ?array $pixels = null): StoreFbpSubscriber + private static function subscriber(?Fbp $fbp = null, bool $consentGranted = true, ?array $pixels = null, ?string $domain = null): StoreFbpSubscriber { return new StoreFbpSubscriber( new class($fbp ?? new Fbp()) implements FbpContextInterface { @@ -152,6 +178,7 @@ public function getPixels(): array return $this->pixels; } }, + new CookieDomain(new RequestStack(), $domain), ); }