Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ jobs:
- "8.1"
- "8.2"
- "8.3"
- "8.4"

dependencies:
- "lowest"
Expand Down Expand Up @@ -92,6 +93,7 @@ jobs:
- "8.1"
- "8.2"
- "8.3"
- "8.4"

dependencies:
- "lowest"
Expand Down Expand Up @@ -127,6 +129,7 @@ jobs:
- "8.1"
- "8.2"
- "8.3"
- "8.4"

dependencies:
- "lowest"
Expand Down
59 changes: 59 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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).
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -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
Expand Down
217 changes: 208 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,39 +1,238 @@
# 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)
[![Build Status][ico-github-actions]][link-github-actions]
[![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 <head>: 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 `<script>` tag by default; pass `false` as the last argument to get the raw
JavaScript instead (e.g. to combine several calls into one tag).

## Custom events

Any event name that isn't one of the standard `Event::EVENT_*` constants is treated as a
[custom event](https://developers.facebook.com/docs/meta-pixel/implementation/conversion-tracking#custom-events).
`FbqGenerator` will emit `trackCustom` instead of `track` for these:

```php
$event = new Event('SubscribedToNewsletter');
$event->isCustom(); // true
```

## `fbc` / `fbp` cookies

The `_fbc` and `_fbp` cookies improve attribution. Assign them to the user data either as raw strings or as the typed
value objects, which validate the format:

```php
use Setono\MetaConversionsApi\ValueObject\Fbc;
use Setono\MetaConversionsApi\ValueObject\Fbp;

$event->userData->fbc = Fbc::fromString($_COOKIE['_fbc']);
$event->userData->fbp = Fbp::fromString($_COOKIE['_fbp']);
```

## Using your own HTTP client

By default the client auto-discovers a PSR-18 client and PSR-17 factories. To inject your own (e.g. a preconfigured
Guzzle client with timeouts and retries), use the setters:

```php
$client = new Client();
$client->setHttpClient($myPsr18Client);
$client->setRequestFactory($myPsr17RequestFactory);
$client->setStreamFactory($myPsr17StreamFactory);
```

## Logging

`Client` is `LoggerAware`. Pass any PSR-3 logger and the SDK will, for example, warn you when you try to send an event
that has no pixels associated:

```php
$client->setLogger($logger);
```

## Extending events

`Event` is intentionally **not** `final`, so you can build domain-specific events with sensible defaults:

```php
use Setono\MetaConversionsApi\Event\Event;

final class PurchaseEvent extends Event
{
public function __construct()
{
parent::__construct(self::EVENT_PURCHASE);
}
}
```

## Development

```bash
composer phpunit # run the test suite
composer analyse # static analysis (PHPStan)
composer check-style # coding standard check (ECS)
composer fix-style # fix coding standard violations
```

## License

This library is released under the [MIT License](LICENSE).

[ico-version]: https://poser.pugx.org/setono/meta-conversions-api-php-sdk/v/stable
[ico-license]: https://poser.pugx.org/setono/meta-conversions-api-php-sdk/license
[ico-github-actions]: https://github.com/Setono/meta-conversions-api-php-sdk/actions/workflows/build.yaml/badge.svg
Expand Down
25 changes: 17 additions & 8 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"require": {
"php": ">=8.1",
"ext-json": "*",
"facebook/php-business-sdk": "^22.0",
"facebook/php-business-sdk": "^25.0",
"php-http/discovery": "^1.20",
"psr/http-client": "^1.0",
"psr/http-client-implementation": "*",
Expand All @@ -22,12 +22,20 @@
"webmozart/assert": "^1.11"
},
"require-dev": {
"infection/infection": "^0.26.21",
"ergebnis/composer-normalize": "^2.50",
"infection/infection": "^0.29",
"jangregor/phpstan-prophecy": "^2.3",
"nyholm/psr7": "^1.8",
"phpunit/phpunit": "^9.6",
"psalm/plugin-phpunit": "^0.19",
"setono/code-quality-pack": "^2.9",
"shipmonk/composer-dependency-analyser": "^1.8.2",
"phpspec/prophecy-phpunit": "^2.5",
"phpstan/extension-installer": "^1.4",
"phpstan/phpstan": "^2.1",
"phpstan/phpstan-phpunit": "^2.0",
"phpstan/phpstan-strict-rules": "^2.0",
"phpstan/phpstan-webmozart-assert": "^2.0",
"phpunit/phpunit": "^10.5",
"rector/rector": "^2.4",
"shipmonk/composer-dependency-analyser": "^1.8",
"sylius-labs/coding-standard": "^4.5",
"symfony/http-client": "^6.4 || ^7.0"
},
"prefer-stable": true,
Expand All @@ -46,12 +54,13 @@
"dealerdirect/phpcodesniffer-composer-installer": false,
"ergebnis/composer-normalize": true,
"infection/extension-installer": true,
"php-http/discovery": false
"php-http/discovery": false,
"phpstan/extension-installer": true
},
"sort-packages": true
},
"scripts": {
"analyse": "psalm",
"analyse": "phpstan analyse",
"check-style": "ecs check",
"fix-style": "ecs check --fix",
"phpunit": "phpunit"
Expand Down
6 changes: 6 additions & 0 deletions phpstan.dist.neon
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
parameters:
level: max
treatPhpDocTypesAsCertain: false
paths:
- src
- tests
Loading
Loading