Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,12 @@ setono_meta_conversions_api:
cookies:
fbp: true
fbc: true
# 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
Expand Down
21 changes: 21 additions & 0 deletions UPGRADE.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,27 @@ anything to `framework.messenger`. `SendEvent` is dispatched on your application
`?ConsentContextInterface $consentContext` and `bool $consentEnabled` / `bool $clientSideEnabled` /
`bool $serverSideEnabled` arguments. Adapt subclasses, decorators and custom service definitions.

## Cookies

New `cookies` options:

```yaml
setono_meta_conversions_api:
cookies:
fbp: true
fbc: true
domain: null # e.g. example.com
lifetime: '+90 days'
```

The `_fbp` and `_fbc` cookies are now only written on a 2xx or 3xx response and only when at least one pixel is
available, because every `Set-Cookie` header makes a response uncacheable for shared caches.

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
Expand Down
5 changes: 4 additions & 1 deletion src/Context/Fbc/QueryBasedFbcContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -20,6 +21,7 @@ final class QueryBasedFbcContext implements FbcContextInterface
public function __construct(
private readonly FbcContextInterface $decorated,
private readonly RequestStack $requestStack,
private readonly CookieDomain $cookieDomain,
) {
}

Expand All @@ -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());
}
}
8 changes: 7 additions & 1 deletion src/Context/Fbp/GeneratedFbpContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}
48 changes: 48 additions & 0 deletions src/Cookie/CookieDomain.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

declare(strict_types=1);

namespace Setono\MetaConversionsApiBundle\Cookie;

use Symfony\Component\HttpFoundation\RequestStack;

/**
* Decides which domain the _fbp and _fbc cookies are written on, and the subdomain index that goes with it
*
* Meta encodes the level the cookie was set at in the second segment of the value: fb.1. means example.com,
* fb.2. means www.example.com and fb.0. means a single label host or an ip address. Writing a host-only cookie
* while claiming index 1 makes the two disagree, and apex and www then end up with different cookies
*
* See https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/fbp-and-fbc
*/
final class CookieDomain
{
public function __construct(
private readonly RequestStack $requestStack,
private readonly ?string $domain = null,
) {
}

/**
* The domain to write the cookies on, or null to let the browser scope them to the current host
*/
public function domain(): ?string
{
return $this->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, '.'), '.'));
}
}
10 changes: 10 additions & 0 deletions src/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Composer\InstalledVersions;
use Composer\Semver\VersionParser;
use Setono\Consent\DefaultConsents;
use Setono\MetaConversionsApiBundle\Cookie\Cookies;
use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface;

Expand Down Expand Up @@ -74,6 +75,15 @@ public function getConfigTreeBuilder(): TreeBuilder
->children()
->booleanNode('fbp')->defaultTrue()->end()
->booleanNode('fbc')->defaultTrue()->end()
->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')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,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<array-key, array{id: string, access_token: string}>, http_client: string, test_event_code: array{query_parameter: bool, value: string|null}, cookies: array{fbp: bool, fbc: bool}, filters: array{user_agent: list<string>}} $config
* @var array{consent: array{enabled: bool, category: string}, client_side: array{enabled: bool}, server_side: array{enabled: bool, message_bus: string}, pixels: array<array-key, array{id: string, access_token: string}>, http_client: string, test_event_code: array{query_parameter: bool, value: string|null}, cookies: array{fbp: bool, fbc: bool, domain: string|null, lifetime: string}, filters: array{user_agent: list<string>}} $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
Expand All @@ -39,6 +39,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']);
Expand Down
7 changes: 6 additions & 1 deletion src/EventSubscriber/StoreFbcSubscriber.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
) {
}

Expand Down Expand Up @@ -70,7 +73,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
}
}
7 changes: 6 additions & 1 deletion src/EventSubscriber/StoreFbpSubscriber.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
) {
}

Expand Down Expand Up @@ -68,7 +71,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
}

Expand Down
7 changes: 7 additions & 0 deletions src/Resources/config/services/context.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
<container xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://symfony.com/schema/dic/services"
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
<services>
<service id="Setono\MetaConversionsApiBundle\Cookie\CookieDomain">
<argument type="service" id="request_stack"/>
<argument>%setono_meta_conversions_api.cookies.domain%</argument>
</service>

<!-- fbc contexts -->
<service id="Setono\MetaConversionsApiBundle\Context\Fbc\FbcContextInterface"
alias="Setono\MetaConversionsApiBundle\Context\Fbc\CookieBasedFbcContext"/>
Expand All @@ -22,13 +27,15 @@
decorates="Setono\MetaConversionsApiBundle\Context\Fbc\FbcContextInterface" decoration-priority="64">
<argument type="service" id="Setono\MetaConversionsApiBundle\Context\Fbc\QueryBasedFbcContext.inner"/>
<argument type="service" id="request_stack"/>
<argument type="service" id="Setono\MetaConversionsApiBundle\Cookie\CookieDomain"/>
</service>

<!-- fbp contexts -->
<service id="Setono\MetaConversionsApiBundle\Context\Fbp\FbpContextInterface"
alias="Setono\MetaConversionsApiBundle\Context\Fbp\GeneratedFbpContext"/>

<service id="Setono\MetaConversionsApiBundle\Context\Fbp\GeneratedFbpContext">
<argument type="service" id="Setono\MetaConversionsApiBundle\Cookie\CookieDomain"/>
</service>

<service id="Setono\MetaConversionsApiBundle\Context\Fbp\CachedFbpContext"
Expand Down
6 changes: 6 additions & 0 deletions src/Resources/config/services/event_subscriber.xml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@

<service id="Setono\MetaConversionsApiBundle\EventSubscriber\PopulatePixelsSubscriber">
<argument type="service" id="Setono\MetaConversionsApiBundle\Provider\PixelProviderInterface"/>
<argument type="service" id="Setono\MetaConversionsApiBundle\Cookie\CookieDomain"/>
<argument>%setono_meta_conversions_api.cookies.lifetime%</argument>

<tag name="kernel.event_subscriber"/>
</service>
Expand Down Expand Up @@ -64,6 +66,8 @@
<argument type="service" id="Setono\MetaConversionsApiBundle\Context\Fbc\FbcContextInterface"/>
<argument type="service" id="Setono\MetaConversionsApiBundle\ConsentChecker\ConsentCheckerInterface"/>
<argument type="service" id="Setono\MetaConversionsApiBundle\Provider\PixelProviderInterface"/>
<argument type="service" id="Setono\MetaConversionsApiBundle\Cookie\CookieDomain"/>
<argument>%setono_meta_conversions_api.cookies.lifetime%</argument>

<tag name="kernel.event_subscriber"/>
</service>
Expand All @@ -72,6 +76,8 @@
<argument type="service" id="Setono\MetaConversionsApiBundle\Context\Fbp\FbpContextInterface"/>
<argument type="service" id="Setono\MetaConversionsApiBundle\ConsentChecker\ConsentCheckerInterface"/>
<argument type="service" id="Setono\MetaConversionsApiBundle\Provider\PixelProviderInterface"/>
<argument type="service" id="Setono\MetaConversionsApiBundle\Cookie\CookieDomain"/>
<argument>%setono_meta_conversions_api.cookies.lifetime%</argument>

<tag name="kernel.event_subscriber"/>
</service>
Expand Down
22 changes: 19 additions & 3 deletions tests/Unit/Context/Fbc/QueryBasedFbcContextTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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());
}
Expand All @@ -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();

Expand All @@ -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());
}
Expand All @@ -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 {
Expand Down
38 changes: 38 additions & 0 deletions tests/Unit/Context/Fbp/GeneratedFbpContextTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

namespace Setono\MetaConversionsApiBundle\Tests\Unit\Context\Fbp;

use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Setono\MetaConversionsApiBundle\Context\Fbp\GeneratedFbpContext;
use Setono\MetaConversionsApiBundle\Cookie\CookieDomain;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;

#[CoversClass(GeneratedFbpContext::class)]
final class GeneratedFbpContextTest extends TestCase
{
#[Test]
public function it_generates_a_new_fbp_every_time(): void
{
$context = new GeneratedFbpContext(new CookieDomain(new RequestStack()));

self::assertNotSame($context->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());
}
}
Loading
Loading