Skip to content

Repository files navigation

Meta / Facebook Conversions API bundle

Latest Version Software License Build Status

Work with the Meta / Facebook Conversions API in your Symfony application. Under the hood this bundle integrates the Meta Conversions API PHP SDK library.

Requirements

Long running runtimes are supported. The services that cache per-request values are reset between requests, so FrankenPHP worker mode, RoadRunner and Swoole do not leak one visitor's fbp or fbc into the next visitor's events.

Installation

composer require setono/meta-conversions-api-bundle

The SDK sends events through a PSR-18 client and PSR-17 factories, which it discovers automatically. If your application does not already ship both, install them alongside the bundle:

composer require setono/meta-conversions-api-bundle symfony/http-client nyholm/psr7

The SDK depends on php-http/discovery, which contains a Composer plugin. Composer asks whether to allow it, and either answer works. To skip the prompt (e.g. in CI) declare it in your composer.json:

{
    "config": {
        "allow-plugins": {
            "php-http/discovery": false
        }
    }
}

Installing the bundle also installs the Bot Detection Bundle, which is used to filter bot requests.

If you want to handle consent (i.e. cookie/GDPR consent), install the consent bundle and enable the consent option (see Configuration):

composer require setono/consent-bundle

While consent is not granted, events are still built and enriched, but nothing leaves your server and nothing is stored on the visitor's device: no fbq() calls are rendered, no SendEvent is dispatched, and neither the _fbp nor the _fbc cookie is written. Each of those is logged at debug level, see Why did my event not show up?.

If the consent bundle is not installed, or the consent option is off, everything is treated as granted.

Upgrading from 0.1.x? See UPGRADE.md.

Configuration

All options with their defaults:

# config/packages/setono_meta_conversions_api.yaml
setono_meta_conversions_api:
    # Only track when the visitor has granted consent. Requires the consent bundle (see above)
    consent:
        enabled: false
        # The consent category that must be granted, see Setono\Consent\DefaultConsents
        category: marketing

    # Client side tracking, i.e. rendering the Meta pixel and fbq() calls in the browser.
    # Requires setono/tag-bag-bundle ^3.0 and defaults to enabled when that bundle is installed
    client_side:
        enabled: false

    # Server side tracking, i.e. sending the events to the Conversions API through Symfony Messenger
    server_side:
        enabled: true
        # The Messenger bus the SendEvent command is dispatched on. Defaults to your application's default bus
        message_bus: messenger.default_bus

    # The pixels to send events to (empty by default). Alternatively provide pixels from your own source by
    # aliasing Setono\MetaConversionsApiBundle\Provider\PixelProviderInterface to your own service.
    # The access token is only needed for server side tracking: client side tracking renders fbq() calls, which
    # only need the pixel id. A pixel without an access token is skipped server side, with a warning in the log
    pixels:
        - id: '%env(META_PIXEL_ID)%'
          access_token: '%env(META_ACCESS_TOKEN)%'

    # 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
    http_client: psr18.http_client

    # Send events as test events, so they show up under 'Test events' in Meta's event manager instead of counting
    # as real conversions
    test_event_code:
        # If enabled, ?_testEventCode=... sets the test event code for the rest of the visitor's session.
        # Defaults to the value of kernel.debug, i.e. enabled in dev and disabled in prod
        query_parameter: '%kernel.debug%'
        # A static test event code applied to every event, e.g. on a staging environment
        value: null

    filters:
        # Regular expression fragments without delimiters, matched case insensitively. Events with a matching user
        # agent are not tracked. Invalid fragments, and fragments containing an unescaped '#', fail at compile time
        user_agent: []

Route the command to a transport

Server side events are dispatched on your application's default Messenger bus. The bundle does not register a bus of its own, so your bus configuration is left untouched. Point the bundle at another bus with the server_side.message_bus option if you prefer.

Route the command to an async transport. Without it, Messenger handles the command synchronously, which means the http call to Meta happens inside the visitor's request: their page waits for Meta's round trip, and Meta's availability becomes your availability.

# config/packages/messenger.yaml
framework:
    messenger:
        routing:
            'Setono\MetaConversionsApiBundle\Message\Command\SendEvent': async

Every command the bundle dispatches implements Setono\MetaConversionsApiBundle\Message\Command\CommandInterface, so you can route them as a group instead.

With a transport, Messenger also retries a failed send and moves it to the failure transport when it keeps failing.

Either way, a send that fails is logged as an error and never propagates into the response, so an expired access token or an outage at Meta cannot break the page.

Usage

<?php

declare(strict_types=1);

use Psr\EventDispatcher\EventDispatcherInterface;
use Setono\MetaConversionsApi\Event\Event;
use Setono\MetaConversionsApiBundle\Event\ConversionsApiEventRaised;

final class YourService
{
    public function __construct(private readonly EventDispatcherInterface $eventDispatcher)
    {
    }

    public function track(): void
    {
        $event = new Event(Event::EVENT_VIEW_CONTENT);
        $event->customData->contentType = 'product';
        $event->customData->contentName = 'Blue Jeans';
        $event->customData->contentIds[] = 'PRODUCT_SKU';

        $this->eventDispatcher->dispatch(new ConversionsApiEventRaised($event));
    }
}

How it works

Dispatching a ConversionsApiEventRaised runs the event through a pipeline of listeners. The bundle populates the event first, then leaves a gap for your own listeners, then filters and sends:

Priority Listener What it does
PRIORITY_POPULATE (1000) PopulateRequestPropertiesSubscriber Source url, client ip and user agent from the request
900 PopulateFbpAndFbcPropertiesSubscriber fbp and fbc
800 PopulateTestEventCodePropertySubscriber Test event code
650 FilterEmptyUserAgentSubscriber Stops events without a user agent
625 FilterConfiguredUserAgentsSubscriber Stops events matching filters.user_agent
PRIORITY_FILTER (600) FilterBotsSubscriber Stops events from bots
500 PopulatePixelsSubscriber Pixels from the pixel provider
PRIORITY_ENRICH (0) your listeners Email, phone, external id, custom data
-950 StopPropagationIfNoPixelsHasBeenAddedSubscriber Stops events without pixels
PRIORITY_SEND (-1000) AddEventToTagBagSubscriber Renders the fbq() calls (client side)
PRIORITY_SEND (-1000) DispatchOnCommandBusSubscriber Dispatches SendEvent (server side)

Two things follow from this:

  • Enrich at PRIORITY_ENRICH, which is the default priority of any listener. Everything the bundle knows about the request is populated by then, and traffic the bundle does not want to track has already been discarded, so your listeners never do work for a bot.
  • A listener below PRIORITY_ENRICH may never run, because propagation can already have been stopped.

The constants live on ConversionsApiEventRaised, so you can position your listener without hard coding a number.

Enriching an event

Everything the Conversions API can do beyond the browser pixel comes from the user data you attach server side. Meta normalises and hashes it for you, so set the raw values:

<?php

declare(strict_types=1);

namespace App\EventListener;

use Setono\MetaConversionsApiBundle\Event\ConversionsApiEventRaised;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;

#[AsEventListener(priority: ConversionsApiEventRaised::PRIORITY_ENRICH)]
final class AddCustomerToConversionsApiEvent
{
    public function __construct(private readonly Security $security)
    {
    }

    public function __invoke(ConversionsApiEventRaised $event): void
    {
        $user = $this->security->getUser();
        if (!$user instanceof User) {
            return;
        }

        $userData = $event->event->userData;
        $userData->email[] = $user->getEmail();
        $userData->firstName[] = $user->getFirstName();
        $userData->lastName[] = $user->getLastName();
        $userData->externalId[] = (string) $user->getId();
    }
}

You can also replace a step instead of adding to it: alias PixelProviderInterface, FbpContextInterface or FbcContextInterface to your own service, or register a listener above the corresponding populate priority.

Passing context to your own listeners

The second constructor argument of ConversionsApiEventRaised carries anything your listeners need but that must never be sent to Meta, such as the order the event was raised for. The bundle never reads it.

$this->eventDispatcher->dispatch(new ConversionsApiEventRaised($event, ['order' => $order]));

// in a listener
if ($event->hasContext('order')) {
    $order = $event->getContext('order');
}

Deduplication between the browser and the server

With both sides enabled the same event is deliberately sent twice: once by the browser pixel and once through the Conversions API. Meta collapses the pair because both carry the same id. Event::$eventId is generated in the constructor and rendered as eventID in the fbq() call and sent as event_id in the api payload, so a single dispatch is deduplicated for you.

It only breaks if you assign ids yourself. If you do, use the same id on both sides, and make sure a page reload does not reuse an id from a previous page view.

See Meta's deduplication documentation.

What is sent to Meta

Besides whatever you attach yourself, the bundle fills in:

Field Where it comes from
event_source_url The full url of the current request, including its query string
client_ip_address Request::getClientIp()
client_user_agent The User-Agent header
fbp, fbc The _fbp and _fbc cookies, or the fbclid query parameter

Two things are worth checking in your application:

  • Configure framework.trusted_proxies. Without it getClientIp() returns your load balancer's address, and every visitor looks like they came from the same place.
  • The query string is sent as is. If your urls carry anything you would rather not hand to Meta, strip it in a listener at ConversionsApiEventRaised::PRIORITY_ENRICH:
    $event->event->eventSourceUrl = strtok((string) $event->event->eventSourceUrl, '?');

Personal data is normalised and hashed with SHA-256 by the SDK before it leaves your server, so raw email addresses and phone numbers are never sent, and they are not written to the Messenger transport either.

Why did my event not show up?

Every listener that drops an event says so at debug level on the setono_meta_conversions_api Monolog channel: the bot filter, the user agent filters, the no-pixels check, and each of the three consent gates. The send handler logs a warning when a pixel has no access token.

# config/packages/monolog.yaml
monolog:
    handlers:
        meta:
            type: stream
            path: '%kernel.logs_dir%/meta.log'
            level: debug
            channels: ['setono_meta_conversions_api']

Events that are not raised in a browser request

The pipeline assumes the event belongs to the request being handled. PopulateRequestPropertiesSubscriber therefore fills in the source url, client ip and user agent of the current request, and the bot and user agent filters only apply to events whose actionSource is website (the default).

For an event raised from a console command, a message handler or an incoming webhook, set another action source so the filters leave it alone:

$event = new Event(Event::EVENT_PURCHASE, Event::ACTION_SOURCE_SYSTEM_GENERATED);

If such an event is raised while handling an HTTP request, for instance a webhook from your payment provider, the request properties still describe that request, not the customer. Overwrite them in a listener above PRIORITY_POPULATE when they matter.

Giving Meta its own timeout

Because the client is a normal service, a scoped client works out of the box:

framework:
    http_client:
        scoped_clients:
            meta.client:
                base_uri: 'https://graph.facebook.com'
                timeout: 2
                max_duration: 5

setono_meta_conversions_api:
    http_client: meta.client

Note that a scoped client is a Symfony HttpClientInterface, so wrap it for PSR-18:

services:
    meta.psr18_client:
        class: Symfony\Component\HttpClient\Psr18Client
        arguments: ['@meta.client']

setono_meta_conversions_api:
    http_client: meta.psr18_client

Graph API version

Events are posted to the Graph API version of the installed facebook/php-business-sdk package (the SDK reads ApiConfig::APIVersion): v26.0 with the 26.x package, v25.0 with 25.x. To move to a newer Graph API version simply run composer update facebook/php-business-sdk.

Test the integration

Take the test event code from Meta / Facebook's event manager and append it to any url on your website: https://example.com/?_testEventCode=[YOUR TEST EVENT CODE] (or ?_test_event_code=[YOUR TEST EVENT CODE]). The code is saved in the session, so all your subsequent requests are sent with it. Clear it again with an empty value: https://example.com/?_testEventCode=.

Because anyone who can add a query parameter would otherwise be able to divert their own conversions into the test bucket, the query parameter is only honoured when test_event_code.query_parameter is enabled. It follows kernel.debug by default, so it works in dev and is off in prod. To send every event as a test event, for instance from a staging environment, set test_event_code.value instead.

About

Work with the Meta / Facebook Conversions API in your Symfony application

Resources

Stars

1 star

Watchers

1 watching

Forks

Releases

Sponsor this project

Used by

Contributors

Languages