diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 31eb1a1..14e89f4 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -56,6 +56,7 @@ jobs: - "8.1" - "8.2" - "8.3" + - "8.4" dependencies: - "lowest" @@ -92,6 +93,7 @@ jobs: - "8.1" - "8.2" - "8.3" + - "8.4" dependencies: - "lowest" @@ -127,6 +129,7 @@ jobs: - "8.1" - "8.2" - "8.3" + - "8.4" dependencies: - "lowest" diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..61d2c92 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,59 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +A PHP SDK providing value objects and a client for Meta's (Facebook) [Conversions API](https://developers.facebook.com/docs/marketing-api/conversions-api). It is a library (`setono/meta-conversions-api-php-sdk`), not an application — there is no runnable entry point. Requires PHP >= 8.1. + +## Commands + +Composer scripts (defined in `composer.json`): + +- `composer phpunit` — run the test suite (PHPUnit 10) +- `composer analyse` — PHPStan static analysis (`phpstan.dist.neon`: `level: max`, analysed against PHP 8.1) +- `composer check-style` / `composer fix-style` — ECS coding-standard check / autofix +- `vendor/bin/infection` — mutation testing (thresholds: minMsi 61.74, minCoveredMsi 76.77). Needs a coverage driver (pcov or Xdebug); CI uses pcov. With neither installed locally you'll get "No code coverage driver available". +- `vendor/bin/composer-dependency-analyser` — verify declared composer deps match actual usage + +The dev tooling (PHPStan + extensions, ECS via `sylius-labs/coding-standard`, PHPUnit, Infection, Rector, composer-normalize, composer-dependency-analyser) is listed directly in `require-dev` rather than pulled in through the `setono/code-quality-pack` meta-package. The pack's current major requires PHP >= 8.2; inlining the tools keeps the whole toolchain runnable on PHP 8.1. When bumping a tool, pick the latest version that still supports PHP 8.1 (e.g. PHPUnit stays on `^10.5`, Infection on `^0.29`). + +Run a single test by file or filter: + +```bash +vendor/bin/phpunit tests/Event/UserTest.php +vendor/bin/phpunit --filter it_sends_event +``` + +Tests use the `@test` annotation with `snake_case` method names (no `test` prefix). + +CI (`.github/workflows/build.yaml`) runs coding standards, dependency analysis, PHPStan, and PHPUnit against PHP 8.1–8.4 on both `lowest` and `highest` dependency versions, so check lowest-version compatibility when touching dependencies. A separate workflow runs Roave's backwards-compatibility check on PRs — this is a public library, so avoid BC breaks to the public API. + +### LiveClientTest + +`tests/Client/LiveClientTest.php` hits the real Meta API. It self-skips unless the env vars in `phpunit.xml.dist` are set (`PIXEL_ID`, `ACCESS_TOKEN`, `TEST_EVENT_CODE`, `URL`, `EMAIL`). Copy `phpunit.xml.dist` to `phpunit.xml` and fill them in to run it. + +## Architecture + +The core abstraction is the serialization pipeline in `src/Event/Parameters.php`. Everything sent to Meta flows through it. + +**`Parameters` (abstract base)** — each subclass implements `getMapping(string $context): array`, returning Meta's snake_case field names mapped to the object's (camelCase) PHP property values. `getPayload()` runs that mapping through `normalize()`, which recursively: +1. formats `DateTimeInterface` as `Ymd` and casts `Stringable` to string, +2. normalizes fields listed in `getNormalizedFields()` via `FacebookAds\Object\ServerSide\Normalizer`, +3. hashes fields listed in `getHashedFields()` via `FacebookAds\Object\ServerSide\Util::hash` (SHA-256 — this is how PII like email/phone is protected), +4. recurses into nested `Parameters` objects (calling their `getPayload()`), +5. strips empty values (`null`, `''`, `[]`) so they aren't sent. + +So to add a field: add the public property, map it in `getMapping()`, and register it in `getNormalizedFields()`/`getHashedFields()` if Meta requires it. The lists of which fields normalize/hash mirror the corresponding `FacebookAds\Object\ServerSide\*` classes (see the `@see` annotations) — keep them in sync with that SDK. + +**Two payload contexts** (`PAYLOAD_CONTEXT_SERVER` vs `PAYLOAD_CONTEXT_BROWSER`). The same objects serialize differently depending on whether they're sent server-side via the Conversions API or rendered into a client-side `fbq()` call. `User::getMapping()` strips server-only fields (IP, user agent, fbc, fbp) in browser context. + +**`Parameters` subclasses:** `Event` (the aggregate root — holds `User $userData`, `Custom $customData`, a list of `Pixel`, plus `metadata` for app-internal use that is never sent), `User` (customer matching data), `Custom` (event-specific data like value/currency/contents). `Event` auto-generates `eventId` (random, for [deduplication](https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/server-event#event-id)) and `eventTime` in its constructor. `Event` is intentionally **not** `final` so consumers can subclass it into domain-specific events; the other data objects are `final`. + +**`Client` (`src/Client/Client.php`)** — `sendEvent()` serializes the event once, then POSTs it (form-encoded) to `graph.facebook.com/v{ApiConfig::APIVersion}/{pixelId}/events` once per associated pixel (each pixel carries its own access token). Non-200 responses throw `ClientException` built from `ErrorResponse`. HTTP is fully PSR-based: PSR-18 client and PSR-17 factories are auto-discovered via `php-http/discovery` but can be injected with `setHttpClient()` / `setRequestFactory()` / etc. The client is `LoggerAware` and defaults to `NullLogger`. + +**`FbqGenerator` (`src/Generator/FbqGenerator.php`)** — the client-side counterpart. Generates the `fbq('init', ...)` / `fbq('track', ...)` JavaScript snippets, using the browser-context payload and reusing the same `eventId` so server and browser events deduplicate. `Event::isCustom()` decides between `track` and `trackCustom`. + +**Value objects (`src/ValueObject/`)** — `Fbc`/`Fbp` (extending `Fb`) model the `_fbc`/`_fbp` cookie values with `fromString()` validation and `value()` serialization; assignable to `User::$fbc`/`$fbp` as either the typed object or a raw string. + +The `facebook/php-business-sdk` dependency is used only for `Normalizer`, `Util::hash`, and `ApiConfig::APIVersion` (the API version is pinned to whatever that package ships). diff --git a/LICENSE b/LICENSE index e4c0848..84b974a 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 Setono +Copyright (c) 2026 Setono Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index ca6023e..88bb458 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# PHP library with basic objects and more for working with Facebook/Metas Conversions API +# Meta (Facebook) Conversions API PHP SDK [![Latest Version][ico-version]][link-packagist] [![Software License][ico-license]](LICENSE) @@ -6,34 +6,233 @@ [![Code Coverage][ico-code-coverage]][link-code-coverage] [![Mutation testing][ico-infection]][link-infection] +A small, typed PHP library for sending server-side events to Meta's (Facebook's) +[Conversions API](https://developers.facebook.com/docs/marketing-api/conversions-api), and for generating the +matching browser-side `fbq()` snippets. + +It gives you plain, well-typed objects (`Event`, `User`, `Custom`, …) and takes care of the fiddly parts for you: + +- **Automatic normalization & hashing** of customer information — you pass raw emails, phone numbers, names, etc. and + the SDK normalizes and SHA-256 hashes them the way Meta requires. Never hash this data yourself. +- **Server + browser deduplication** — every event gets an `eventId` you can reuse on both sides so Meta counts it once. +- **Bring your own HTTP client** — built on PSR-18/PSR-17 with auto-discovery, so it works with any compliant client. + +## Requirements + +- PHP 8.1+ +- A [PSR-18](https://www.php-fig.org/psr/psr-18/) HTTP client and [PSR-17](https://www.php-fig.org/psr/psr-17/) factories + (see [Installation](#installation)) + ## Installation -The easiest way to install this library is by installing the library along with its HTTP client dependencies: +The SDK talks to the API through a PSR-18 client and PSR-17 factories, which it discovers automatically. The simplest +way is to install it together with an implementation: ```bash composer require setono/meta-conversions-api-php-sdk kriswallsmith/buzz nyholm/psr7 ``` -If you want to use your own HTTP client, just do `composer require setono/meta-conversions-api-php-sdk` and then -remember to set the HTTP client and factories when instantiating the `Setono\MetaConversionsApi\Client\Client` +`symfony/http-client` works just as well if you prefer it: -## Usage +```bash +composer require setono/meta-conversions-api-php-sdk symfony/http-client nyholm/psr7 +``` + +If your project already ships a PSR-18 client and PSR-17 factories you only need the SDK itself +(`composer require setono/meta-conversions-api-php-sdk`); see [Using your own HTTP client](#using-your-own-http-client). + +## Quick start ```php +use Setono\MetaConversionsApi\Client\Client; use Setono\MetaConversionsApi\Event\Event; use Setono\MetaConversionsApi\Pixel\Pixel; $event = new Event(Event::EVENT_VIEW_CONTENT); $event->eventSourceUrl = 'https://example.com/products/blue-jeans'; -$event->userData->clientUserAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36'; -$event->userData->email[] = 'johndoe@example.com'; -$event->pixels[] = new Pixel('INSERT YOUR PIXEL ID', 'INSERT YOUR ACCESS TOKEN'); -// $event->testEventCode = 'test event code'; // uncomment this if you want to send a test event +$event->userData->clientUserAgent = $_SERVER['HTTP_USER_AGENT']; +$event->userData->clientIpAddress = $_SERVER['REMOTE_ADDR']; +$event->userData->email[] = 'johndoe@example.com'; // hashed for you before sending + +// A pixel carries the id and the access token used to authenticate the request +$event->pixels[] = new Pixel('YOUR_PIXEL_ID', 'YOUR_ACCESS_TOKEN'); $client = new Client(); $client->sendEvent($event); ``` +An `Event` is created with a random `eventId` and the current `eventTime` already set, and defaults to the `website` +action source. Pass a different source as the second constructor argument if needed (e.g. +`new Event(Event::EVENT_PURCHASE, Event::ACTION_SOURCE_PHYSICAL_STORE)`). + +## Sending a richer event + +`Event::$customData` holds the event-specific data (value, currency, contents, …) and `Event::$userData` holds the +customer-matching data: + +```php +use Setono\MetaConversionsApi\Client\Client; +use Setono\MetaConversionsApi\Event\Content; +use Setono\MetaConversionsApi\Event\Event; +use Setono\MetaConversionsApi\Pixel\Pixel; + +$event = new Event(Event::EVENT_PURCHASE); +$event->eventSourceUrl = 'https://example.com/checkout/complete'; + +// Customer information — pass raw values, the SDK normalizes and hashes them +$event->userData->email[] = 'johndoe@example.com'; +$event->userData->phoneNumber[] = '+1 (555) 123-4567'; +$event->userData->firstName[] = 'John'; +$event->userData->lastName[] = 'Doe'; +$event->userData->clientUserAgent = $_SERVER['HTTP_USER_AGENT']; +$event->userData->clientIpAddress = $_SERVER['REMOTE_ADDR']; + +// Event data +$event->customData->currency = 'USD'; +$event->customData->value = 142.52; +$event->customData->contents[] = new Content('SKU-1', 1, 99.99); +$event->customData->contents[] = new Content('SKU-2', 1, 42.53); + +// Anything not covered by a typed property can go into customProperties +$event->customData->customProperties['membership_level'] = 'gold'; + +$event->pixels[] = new Pixel('YOUR_PIXEL_ID', 'YOUR_ACCESS_TOKEN'); + +(new Client())->sendEvent($event); +``` + +### Multiple pixels + +Add more than one `Pixel` and the event is sent to each of them (every pixel carries its own access token): + +```php +$event->pixels[] = new Pixel('PIXEL_ID_1', 'ACCESS_TOKEN_1'); +$event->pixels[] = new Pixel('PIXEL_ID_2', 'ACCESS_TOKEN_2'); +``` + +### Test events + +While integrating, set a test event code so the event shows up in the *Test events* tool in Events Manager instead of +counting as real traffic: + +```php +$event->testEventCode = 'TEST12345'; +``` + +### Error handling + +`sendEvent()` throws a `ClientException` if Meta returns a non-2xx response. The message contains Meta's error message, +code, trace id and the raw response (including the user-facing explanation when Meta provides one): + +```php +use Setono\MetaConversionsApi\Exception\ClientException; + +try { + $client->sendEvent($event); +} catch (ClientException $e) { + $logger->error('Could not send event to Meta', ['exception' => $e]); +} +``` + +## 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 +the same `eventId` so they are deduplicated. `FbqGenerator` produces the matching JavaScript: + +```php +use Setono\MetaConversionsApi\Event\Parameters; +use Setono\MetaConversionsApi\Generator\FbqGenerator; + +$generator = new FbqGenerator(); + +// In your : initialise the pixel(s) and send a PageView +echo $generator->generateInit( + $event->pixels, + $event->userData->getPayload(Parameters::PAYLOAD_CONTEXT_BROWSER), +); + +// Where the conversion happens: fire the same event in the browser. +// Because it reuses $event->eventId, Meta counts the server + browser event once. +echo $generator->generateTrack($event); +``` + +Both methods wrap the output in a `