PHPackages                             setono/client-bundle - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Utility &amp; Helpers](/categories/utility)
4. /
5. setono/client-bundle

ActiveSymfony-bundle[Utility &amp; Helpers](/categories/utility)

setono/client-bundle
====================

Integrate the client library into your Symfony application

v1.2.0(2w ago)117.3k↑31.9%1[1 issues](https://github.com/Setono/client-bundle/issues)4MITPHPPHP &gt;=8.1CI passing

Since Apr 9Pushed 2w ago1 watchersCompare

[ Source](https://github.com/Setono/client-bundle)[ Packagist](https://packagist.org/packages/setono/client-bundle)[ RSS](/packages/setono-client-bundle/feed)WikiDiscussions master Synced yesterday

READMEChangelog (10)Dependencies (52)Versions (12)Used By (4)

Client Bundle
=============

[](#client-bundle)

[![Latest Version](https://camo.githubusercontent.com/5d4ff0e82144381527ebd3c388b2a5bb4a0221dfee7ad2945e4ff11148a06eeb/68747470733a2f2f706f7365722e707567782e6f72672f7365746f6e6f2f636c69656e742d62756e646c652f762f737461626c65)](https://packagist.org/packages/setono/client-bundle)[![Software License](https://camo.githubusercontent.com/195dc4050694ccf36c4d758df89db937f676d80169a167af03d2c24aae42d6bc/68747470733a2f2f706f7365722e707567782e6f72672f7365746f6e6f2f636c69656e742d62756e646c652f6c6963656e7365)](LICENSE)[![Build Status](https://github.com/Setono/client-bundle/actions/workflows/build.yaml/badge.svg)](https://github.com/Setono/client-bundle/actions)[![Code Coverage](https://camo.githubusercontent.com/8b38ea5b3072a51cdda031ec9ac4d162edea0be6c98fb652c6696c6cda7ada81/68747470733a2f2f636f6465636f762e696f2f67682f5365746f6e6f2f636c69656e742d62756e646c652f6272616e63682f6d61737465722f67726170682f62616467652e737667)](https://codecov.io/gh/Setono/client-bundle)

Recognize returning visitors in your Symfony application and attach your own metadata to each of them.

The bundle assigns every visitor a stable **client id**, stored in a first‑party cookie (`setono_client_id` by default) that also remembers when the visitor was *first* and *last* seen. On top of that you can persist arbitrary **metadata** per client — for example the first Google click id (`gclid`) a visitor arrived with, an A/B test variant, or a referrer. Metadata is **lazy**: if you never read or write it, the bundle never touches the database.

Typical use cases: first‑party analytics, marketing attribution, returning‑visitor personalization.

Contents
--------

[](#contents)

- [Requirements](#requirements)
- [Installation](#installation)
- [Usage](#usage)
    - [Get the current client](#get-the-current-client)
    - [Read and write metadata](#read-and-write-metadata)
    - [Set metadata from a request](#set-metadata-from-a-request)
    - [Access the cookie directly](#access-the-cookie-directly)
    - [Skip storing the cookie (consent)](#skip-storing-the-cookie-consent)
- [Configuration](#configuration)
- [How it works](#how-it-works)
- [Extending the bundle](#extending-the-bundle)
- [Contributing](#contributing)
- [License](#license)

Requirements
------------

[](#requirements)

VersionPHP`>= 8.1`Symfony`^6.4` or `^7.0`Doctrine ORM`^2.0` or `^3.0`Installation
------------

[](#installation)

```
composer require setono/client-bundle
```

If you use [Symfony Flex](https://symfony.com/doc/current/setup/flex.html) the bundle is registered automatically. Otherwise, enable it manually in `config/bundles.php`:

```
return [
    // ...
    Setono\ClientBundle\SetonoClientBundle::class => ['all' => true],
];
```

The bundle maps a `Metadata` entity (table `setono_client__metadata`). If you intend to store metadata, create the table with [Doctrine Migrations](https://symfony.com/bundles/DoctrineMigrationsBundle/current/index.html):

```
php bin/console doctrine:migrations:diff
php bin/console doctrine:migrations:migrate
```

> If you only need the client id cookie and never store metadata, the table is never queried — but generating it now keeps things simple if you add metadata later.

Usage
-----

[](#usage)

### Get the current client

[](#get-the-current-client)

The simplest way to access the current visitor is to type‑hint `Setono\Client\Client` in a controller — the bundle resolves it for you:

```
use Setono\Client\Client;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;

final class HomeController extends AbstractController
{
    public function index(Client $client): Response
    {
        return $this->render('home.html.twig', [
            'clientId' => $client->id,
        ]);
    }
}
```

Anywhere else (services, subscribers) inject `ClientContextInterface`:

```
use Setono\ClientBundle\Context\ClientContextInterface;

final class SomeService
{
    public function __construct(private readonly ClientContextInterface $clientContext)
    {
    }

    public function __invoke(): void
    {
        $client = $this->clientContext->getClient();
        // $client->id, $client->metadata
    }
}
```

### Read and write metadata

[](#read-and-write-metadata)

`$client->metadata` is a key/value store. Reading a key for the first time lazily loads the metadata from the database; writing marks it dirty so it is persisted at the end of the request.

```
$metadata = $client->metadata;

$metadata->set('variant', 'B');           // store a value
$metadata->set('promo', 'X', ttl: 3600);  // optional TTL in seconds

$metadata->has('variant');                // true
$metadata->get('variant');                // 'B' — throws if the key is missing, so guard with has()
$metadata->remove('variant');

foreach ($metadata as $key => $value) {   // iterate everything
    // ...
}
```

### Set metadata from a request

[](#set-metadata-from-a-request)

A common pattern is to capture data from the incoming request — here, the Google click id:

```
use Setono\ClientBundle\Context\ClientContextInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;

final class GoogleClickIdSubscriber implements EventSubscriberInterface
{
    public function __construct(private readonly ClientContextInterface $clientContext)
    {
    }

    public static function getSubscribedEvents(): array
    {
        return [KernelEvents::REQUEST => 'capture'];
    }

    public function capture(RequestEvent $event): void
    {
        if (!$event->isMainRequest() || !$event->getRequest()->query->has('gclid')) {
            return;
        }

        $this->clientContext->getClient()->metadata->set(
            'google_click_id',
            $event->getRequest()->query->get('gclid'),
        );
    }
}
```

### Access the cookie directly

[](#access-the-cookie-directly)

The cookie stores the client id plus the first/last seen timestamps. Read it through `CookieProviderInterface`:

```
use Setono\ClientBundle\CookieProvider\CookieProviderInterface;

final class SomeService
{
    public function __construct(private readonly CookieProviderInterface $cookieProvider)
    {
    }

    public function __invoke(): void
    {
        $cookie = $this->cookieProvider->getCookie();
        if (null === $cookie) {
            return; // visitor has no cookie yet
        }

        $cookie->clientId;    // the client id
        $cookie->firstSeenAt; // unix timestamp of first visit
        $cookie->lastSeenAt;  // unix timestamp of the previous visit
    }
}
```

The cookie is intentionally **not** `HttpOnly`, so you can also read it from JavaScript (e.g. to send the client id to a third‑party tag).

### Skip storing the cookie (consent)

[](#skip-storing-the-cookie-consent)

Because the cookie identifies a visitor, you may need consent before storing it. Listen to `PreStoreCookieEvent`(dispatched on the response, before the cookie is written) and set `$event->store = false` to skip it for that request:

```
use Setono\ClientBundle\Event\PreStoreCookieEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

final class CookieConsentSubscriber implements EventSubscriberInterface
{
    public static function getSubscribedEvents(): array
    {
        return [PreStoreCookieEvent::class => 'onPreStore'];
    }

    public function onPreStore(PreStoreCookieEvent $event): void
    {
        if (!$this->hasConsent($event->request)) {
            $event->store = false;
        }
    }

    // ...
}
```

Configuration
-------------

[](#configuration)

All options are optional; the defaults are shown below:

```
setono_client:
    cookie:
        # Name of the cookie that holds the client id.
        # NOTE: changing this makes every visitor with the old cookie name look like a new client.
        name: setono_client_id

        # Cookie lifetime, expressed as any string strtotime() can parse.
        expiration: '+365 days'

    # Entity used to persist metadata. Override it with your own class to add mapped fields
    # or behaviour; it must implement Setono\ClientBundle\Entity\MetadataInterface.
    metadata_class: Setono\ClientBundle\Entity\Metadata
```

How it works
------------

[](#how-it-works)

- **Cookie** — on each main response, `StoreCookieSubscriber` writes/refreshes the cookie and bumps `lastSeenAt`. A new client id is generated only when no valid cookie is present.
- **Lazy metadata** — `$client->metadata` is a lazy ghost object. The database is queried only the first time you read a value, and a row is written only at the end of the request, and only if the metadata was actually changed.
- **Resolution chain** — `ClientContextInterface` is built from a chain of decorating services (`CachedClientContext` → `CookieBasedClientContext` → `DefaultClientContext`). The cached layer memoizes the client per request; the cookie‑based layer builds it from the cookie; the default layer creates a fresh, anonymous client.

Extending the bundle
--------------------

[](#extending-the-bundle)

Every moving part is a service behind an interface, so you can swap or decorate it:

ConcernInterfaceResolving the current client`Setono\ClientBundle\Context\ClientContextInterface`Reading the cookie`Setono\ClientBundle\CookieProvider\CookieProviderInterface`Loading metadata`Setono\ClientBundle\MetadataProvider\MetadataProviderInterface`Persisting metadata`Setono\ClientBundle\MetadataPersister\MetadataPersisterInterface`Metadata entity`Setono\ClientBundle\Entity\MetadataInterface`To add behaviour, [decorate](https://symfony.com/doc/current/service_container/service_decoration.html) the relevant service rather than replacing it outright — that is exactly how the bundle composes its own defaults.

Contributing
------------

[](#contributing)

```
git clone https://github.com/Setono/client-bundle.git
cd client-bundle
composer install
```

Quality tooling (also run in CI):

CommandPurpose`composer phpunit`Run the test suite`composer analyse`Static analysis (PHPStan, `level: max`)`composer check-style` / `composer fix-style`Coding standard (ECS)`composer infection`Mutation testing (requires a coverage driver)`vendor/bin/composer-dependency-analyser`Detect unused/undeclared dependenciesLicense
-------

[](#license)

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

###  Health Score

47

—

FairBetter than 93% of packages

Maintenance76

Regular maintenance activity

Popularity29

Limited adoption so far

Community15

Small or concentrated contributor base

Maturity57

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~79 days

Recently: every ~190 days

Total

11

Last Release

18d ago

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/2412177?v=4)[Joachim Løvgaard](/maintainers/loevgaard)[@loevgaard](https://github.com/loevgaard)

---

Top Contributors

[![loevgaard](https://avatars.githubusercontent.com/u/2412177?v=4)](https://github.com/loevgaard "loevgaard (61 commits)")

---

Tags

phpsymfonysymfony-bundletracking

###  Code Quality

TestsPHPUnit

Static AnalysisPHPStan, Rector

Type Coverage Yes

### Embed Badge

![Health badge](/badges/setono-client-bundle/health.svg)

```
[![Health](https://phpackages.com/badges/setono-client-bundle/health.svg)](https://phpackages.com/packages/setono-client-bundle)
```

###  Alternatives

[easycorp/easyadmin-bundle

Admin generator for Symfony applications

4.3k17.9M388](/packages/easycorp-easyadmin-bundle)[open-dxp/opendxp

Content &amp; Product Management Framework (CMS/PIM)

9421.6k61](/packages/open-dxp-opendxp)[pimcore/pimcore

Content &amp; Product Management Framework (CMS/PIM/E-Commerce)

3.8k3.8M508](/packages/pimcore-pimcore)[sulu/sulu

Core framework that implements the functionality of the Sulu content management system

1.3k1.4M203](/packages/sulu-sulu)[sylius/sylius

E-Commerce platform for PHP, based on Symfony framework.

8.5k5.9M736](/packages/sylius-sylius)[flow-php/flow

PHP ETL - Extract Transform Load - Data processing framework

85036.3k](/packages/flow-php-flow)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
