From 7c33bf6bd577ad22721d6cc0a95b49a53741e324 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joachim=20L=C3=B8vgaard?= Date: Mon, 14 Sep 2026 14:43:48 +0200 Subject: [PATCH] Let the client send a prepared Payload instead of only an Event A consumer that hashes the personal data at capture time and sends later, e.g. through a queue, could neither queue the Event (User holds the raw PII until getPayload() runs) nor hand the finished payload back to the client, which only accepted an Event. Event::toPayload() now returns a Payload: the wire-ready form of the event (name, id, the normalized and hashed data, the pixels and the test event code), made of scalars, arrays and Pixel objects only so it serializes without any tricks. Client::sendPayload() sends it, and sendEvent() is a one-line delegation to it. sendPayload() lives on a new PayloadClientInterface that Client also implements, rather than on ClientInterface, so existing implementors of ClientInterface keep working and the backwards compatibility check stays green. Closes #15 --- README.md | 27 +++++++++ src/Client/Client.php | 18 ++++-- src/Client/PayloadClientInterface.php | 21 +++++++ src/Event/Event.php | 9 +++ src/Event/Payload.php | 29 ++++++++++ tests/Client/ClientTest.php | 79 +++++++++++++++++++++++++++ tests/Event/EventTest.php | 30 ++++++++++ tests/Event/PayloadTest.php | 51 +++++++++++++++++ 8 files changed, 258 insertions(+), 6 deletions(-) create mode 100644 src/Client/PayloadClientInterface.php create mode 100644 src/Event/Payload.php create mode 100644 tests/Event/PayloadTest.php diff --git a/README.md b/README.md index 88bb458..47b9856 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,33 @@ try { } ``` +## Sending events later, e.g. through a queue + +`User` holds the raw email addresses, phone numbers and names until the payload is built, so an `Event` should not be +queued as is. `Event::toPayload()` returns the wire-ready form instead: normalized, hashed, and made of scalars, arrays +and `Pixel` objects only, so it serializes with the PHP serializer or the Symfony serializer without any tricks. Hash at +capture time, send later: + +```php +use Setono\MetaConversionsApi\Client\PayloadClientInterface; + +// at capture time +$payload = $event->toPayload(); +$queue->push($payload); + +// at send time (the client implements PayloadClientInterface) +$client->sendPayload($payload); +``` + +`Pixel::$accessToken` is nullable, so you can queue a payload with token-less pixels and fill the tokens in at send +time: + +```php +foreach ($payload->pixels as $pixel) { + $pixel->accessToken = $accessTokens[$pixel->id]; +} +``` + ## Browser-side tracking with deduplication To get the best match quality Meta recommends sending events both server-side (this SDK) *and* from the browser, using diff --git a/src/Client/Client.php b/src/Client/Client.php index f563e08..ffd3487 100644 --- a/src/Client/Client.php +++ b/src/Client/Client.php @@ -14,9 +14,10 @@ use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use Setono\MetaConversionsApi\Event\Event; +use Setono\MetaConversionsApi\Event\Payload; use Setono\MetaConversionsApi\Exception\ClientException; -final class Client implements ClientInterface, LoggerAwareInterface +final class Client implements ClientInterface, PayloadClientInterface, LoggerAwareInterface { private ?HttpClientInterface $httpClient = null; @@ -33,7 +34,12 @@ public function __construct() public function sendEvent(Event $event): void { - if (!$event->hasPixels()) { + $this->sendPayload($event->toPayload()); + } + + public function sendPayload(Payload $payload): void + { + if ([] === $payload->pixels) { $this->logger->error('You are trying to send events to Meta/Facebook, but you haven\'n associated any pixels with your event. This is most likely an error.'); return; @@ -42,16 +48,16 @@ public function sendEvent(Event $event): void $httpClient = $this->getHttpClient(); $requestFactory = $this->getRequestFactory(); - $data = json_encode([$event->getPayload()], \JSON_THROW_ON_ERROR); + $data = json_encode([$payload->data], \JSON_THROW_ON_ERROR); - foreach ($event->pixels as $pixel) { + foreach ($payload->pixels as $pixel) { $body = [ 'access_token' => $pixel->accessToken, 'data' => $data, ]; - if (null !== $event->testEventCode) { - $body['test_event_code'] = $event->testEventCode; + if (null !== $payload->testEventCode) { + $body['test_event_code'] = $payload->testEventCode; } $request = $requestFactory->createRequest( diff --git a/src/Client/PayloadClientInterface.php b/src/Client/PayloadClientInterface.php new file mode 100644 index 0000000..90fe88e --- /dev/null +++ b/src/Client/PayloadClientInterface.php @@ -0,0 +1,21 @@ +pixels; } + /** + * Returns the wire-ready form of this event, i.e. normalized and hashed, which is safe to store or queue + * and can be sent later with PayloadClientInterface::sendPayload() + */ + public function toPayload(): Payload + { + return new Payload($this->eventName, $this->eventId, $this->getPayload(), $this->pixels, $this->testEventCode); + } + /** * @return list */ diff --git a/src/Event/Payload.php b/src/Event/Payload.php new file mode 100644 index 0000000..3cf51aa --- /dev/null +++ b/src/Event/Payload.php @@ -0,0 +1,29 @@ + $data the result of Event::getPayload() + * @param list $pixels the pixels the payload should be sent to + */ + public function __construct( + public readonly string $eventName, + public readonly string $eventId, + public readonly array $data, + public readonly array $pixels, + public readonly ?string $testEventCode = null, + ) { + } +} diff --git a/tests/Client/ClientTest.php b/tests/Client/ClientTest.php index d69d353..d2fb623 100644 --- a/tests/Client/ClientTest.php +++ b/tests/Client/ClientTest.php @@ -18,6 +18,7 @@ use Psr\Http\Message\StreamInterface; use Psr\Http\Message\UriInterface; use Setono\MetaConversionsApi\Event\Event; +use Setono\MetaConversionsApi\Event\Payload; use Setono\MetaConversionsApi\Exception\ClientException; use Setono\MetaConversionsApi\Pixel\Pixel; use Setono\MetaConversionsApi\TestLogger; @@ -127,6 +128,84 @@ public function it_discovers_an_http_client_when_none_is_injected(): void self::assertCount(1, $httpClient->requests); } + /** + * @test + */ + public function it_sends_payload(): void + { + $httpClient = new TestHttpClient(); + + $client = new Client(); + $client->setHttpClient($httpClient); + + $payload = new Payload( + Event::EVENT_PURCHASE, + 'event_id', + ['event_name' => 'Purchase', 'event_time' => 1658743659123, 'event_id' => 'event_id', 'action_source' => 'website'], + [new Pixel('pixel_1', 'token_1'), new Pixel('pixel_2', 'token_2')], + 'TEST123', + ); + $client->sendPayload($payload); + + self::assertCount(2, $httpClient->requests); + + [$first, $second] = $httpClient->requests; + self::assertSame('POST', $first->getMethod()); + self::assertSame(sprintf('https://graph.facebook.com/v%s/pixel_1/events', ApiConfig::APIVersion), (string) $first->getUri()); + self::assertSame( + 'access_token=token_1&data=%5B%7B%22event_name%22%3A%22Purchase%22%2C%22event_time%22%3A1658743659123%2C%22event_id%22%3A%22event_id%22%2C%22action_source%22%3A%22website%22%7D%5D&test_event_code=TEST123', + (string) $first->getBody(), + ); + self::assertSame(sprintf('https://graph.facebook.com/v%s/pixel_2/events', ApiConfig::APIVersion), (string) $second->getUri()); + self::assertStringContainsString('access_token=token_2', (string) $second->getBody()); + } + + /** + * @test + */ + public function it_sends_the_same_request_for_an_event_and_its_payload(): void + { + $event = new Event(Event::EVENT_PURCHASE); + $event->eventId = 'event_id'; + $event->eventTime = 1658743659123; + $event->testEventCode = 'TEST123'; + $event->pixels[] = new Pixel('pixel_id', 'access_token'); + $event->userData->email[] = 'johndoe@example.com'; + + $eventHttpClient = new TestHttpClient(); + $eventClient = new Client(); + $eventClient->setHttpClient($eventHttpClient); + $eventClient->sendEvent($event); + + $payloadHttpClient = new TestHttpClient(); + $payloadClient = new Client(); + $payloadClient->setHttpClient($payloadHttpClient); + $payloadClient->sendPayload($event->toPayload()); + + self::assertCount(1, $eventHttpClient->requests); + self::assertCount(1, $payloadHttpClient->requests); + self::assertSame((string) $eventHttpClient->requests[0]->getUri(), (string) $payloadHttpClient->requests[0]->getUri()); + self::assertSame((string) $eventHttpClient->requests[0]->getBody(), (string) $payloadHttpClient->requests[0]->getBody()); + } + + /** + * @test + */ + public function it_does_not_send_payload_when_it_has_no_pixels(): void + { + $httpClient = new TestHttpClient(); + $logger = new TestLogger(); + + $client = new Client(); + $client->setHttpClient($httpClient); + $client->setLogger($logger); + + $client->sendPayload(new Payload(Event::EVENT_PURCHASE, 'event_id', [], [])); + + self::assertCount(0, $httpClient->requests); + self::assertTrue($logger->hasMessageMatching('#you haven\'n associated any pixels#')); + } + /** * @test */ diff --git a/tests/Event/EventTest.php b/tests/Event/EventTest.php index 27eeb16..b47e141 100644 --- a/tests/Event/EventTest.php +++ b/tests/Event/EventTest.php @@ -223,4 +223,34 @@ public function it_rejects_an_invalid_action_source(): void $event->getPayload(); } + + /** + * @test + */ + public function it_converts_to_a_payload(): void + { + $event = new Event(Event::EVENT_PURCHASE); + $event->eventId = 'event_id'; + $event->eventTime = 123; + $event->testEventCode = 'TEST123'; + $event->pixels[] = new Pixel('pixel_1', 'token_1'); + $event->pixels[] = new Pixel('pixel_2'); + $event->userData->email[] = 'johndoe@example.com'; + + $payload = $event->toPayload(); + + self::assertSame(Event::EVENT_PURCHASE, $payload->eventName); + self::assertSame('event_id', $payload->eventId); + self::assertSame('TEST123', $payload->testEventCode); + self::assertSame($event->pixels, $payload->pixels); + self::assertSame($event->getPayload(), $payload->data); + + // the name and id are duplicated from the data on purpose + self::assertSame($payload->eventName, $payload->data['event_name']); + self::assertSame($payload->eventId, $payload->data['event_id']); + + // the personal data is hashed, i.e. the payload is safe to store + self::assertSame(['em' => ['55e79200c1635b37ad31a378c39feb12f120f116625093a19bc32fff15041149']], $payload->data['user_data']); + self::assertStringNotContainsString('johndoe@example.com', serialize($payload)); + } } diff --git a/tests/Event/PayloadTest.php b/tests/Event/PayloadTest.php new file mode 100644 index 0000000..1ddeb59 --- /dev/null +++ b/tests/Event/PayloadTest.php @@ -0,0 +1,51 @@ + 'Purchase', 'event_id' => 'event_id', 'user_data' => ['em' => ['hashed']]], + [new Pixel('pixel_1', 'token_1'), new Pixel('pixel_2')], + 'TEST123', + ); + + $unserialized = unserialize(serialize($payload)); + + self::assertInstanceOf(Payload::class, $unserialized); + self::assertNotSame($payload, $unserialized); + self::assertEquals($payload, $unserialized); + self::assertSame('Purchase', $unserialized->eventName); + self::assertSame('event_id', $unserialized->eventId); + self::assertSame(['event_name' => 'Purchase', 'event_id' => 'event_id', 'user_data' => ['em' => ['hashed']]], $unserialized->data); + self::assertSame('TEST123', $unserialized->testEventCode); + self::assertCount(2, $unserialized->pixels); + self::assertSame('pixel_1', $unserialized->pixels[0]->id); + self::assertSame('token_1', $unserialized->pixels[0]->accessToken); + self::assertSame('pixel_2', $unserialized->pixels[1]->id); + self::assertNull($unserialized->pixels[1]->accessToken); + } + + /** + * @test + */ + public function it_has_no_test_event_code_by_default(): void + { + $payload = new Payload(Event::EVENT_PURCHASE, 'event_id', [], []); + + self::assertNull($payload->testEventCode); + self::assertSame([], $payload->pixels); + } +}