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
35 changes: 34 additions & 1 deletion .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ jobs:
SYMFONY_REQUIRE: "${{ matrix.symfony }}"

- name: "Static analysis"
run: "vendor/bin/psalm --php-version=${{ matrix.php-version }}"
run: "vendor/bin/phpstan analyse --no-progress"

unit-tests:
name: "Unit tests"
Expand Down Expand Up @@ -179,6 +179,39 @@ jobs:
- name: "Run phpunit"
run: "composer phpunit"

mutation-testing:
name: "Mutation Testing"

runs-on: "ubuntu-latest"

strategy:
matrix:
php-version:
- "8.2"

dependencies:
- "highest"

steps:
- name: "Checkout"
uses: "actions/checkout@v4"

- name: "Setup PHP, with composer and extensions"
uses: "shivammathur/setup-php@v2"
with:
coverage: "pcov"
extensions: "${{ env.PHP_EXTENSIONS }}"
php-version: "${{ matrix.php-version }}"
tools: "flex"

- name: "Install composer dependencies"
uses: "ramsey/composer-install@v3"
with:
dependency-versions: "${{ matrix.dependencies }}"

- name: "Run Infection"
run: "vendor/bin/infection --ansi --no-interaction --no-progress --show-mutations --min-msi=100 --min-covered-msi=100"

code-coverage:
name: "Code Coverage"

Expand Down
72 changes: 72 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## What this is

A Symfony bundle (`setono/client-bundle`) that integrates the `setono/client` library into a Symfony app to **track users between visits**. It stores a cookie (`setono_client_id` by default) holding a client id plus first/last-seen timestamps, and optionally persists per-client key/value **metadata** to a database table. Metadata is lazy: nothing is queried or written unless the application actually reads or mutates it.

This is a library/bundle — there is no runnable application here. It is exercised through its test suite and consumed by host Symfony apps.

## Commands

PHP 8.1+ is required (CI runs 8.1 and 8.2). Use the `8.1`/`8.2` shell switchers if needed. The dev tooling is the individually-pinned set from `setono/code-quality-pack` (inlined into `require-dev` rather than depending on the meta-package); PHPUnit is held at `^10.5` so PHP 8.1 stays supported (PHPUnit 11 needs 8.2).

- Tests: `composer phpunit` (or `vendor/bin/phpunit`)
- Single test: `vendor/bin/phpunit --filter after_loading_the_correct_parameter_has_been_set` or by path `vendor/bin/phpunit tests/DependencyInjection/SetonoClientExtensionTest.php`
- Static analysis: `composer analyse` (PHPStan at `level: max`, config in `phpstan.neon.dist`; auto-loads the doctrine/symfony/phpunit/strict-rules extensions via `phpstan/extension-installer`)
- Check style: `composer check-style` (ECS, Sylius Labs ruleset)
- Fix style: `composer fix-style`
- Mutation testing: `composer infection` (config in `infection.json.dist`; needs a coverage driver — pcov/xdebug). CI gates on `--min-msi=100 --min-covered-msi=100`. One equivalent mutant (the defensive `instanceof ClassMetadata` in `ConvertToEntityListener`) is excluded in the config.
- Dependency analysis: `vendor/bin/composer-dependency-analyser` (no composer script; see `composer-dependency-analyser.php`)
- Rector (dry-run): `vendor/bin/rector process --dry-run` (config in `rector.php`, `withPhpSets(php81: true)`; not run in CI)

CI (`.github/workflows/build.yaml`) additionally runs `composer validate --strict` and `composer normalize --dry-run` across the PHP 8.1/8.2 × Symfony 6.4/7.0 matrix.

> Local note (PHP 8.4): the transitive dev dep `thecodingmachine/safe` emits a flood of implicit-nullable `E_DEPRECATED` notices on PHP 8.4 (they don't exist on CI's 8.1/8.2). This is harmless for PHPStan/PHPUnit but breaks Infection's initial test run — run it with `vendor/bin/infection --initial-tests-php-options="-d error_reporting=24575"` locally on 8.4.

## Architecture

The core abstraction is `ClientContextInterface::getClient(): Client`. Controllers obtain the current `Setono\Client\Client` either by autowiring `ClientContextInterface`, or — more commonly — by type-hinting `Setono\Client\Client` directly on a controller argument, which `Controller/ClientValueResolver` resolves via the context.

### Decorator chains (the key structural idea)

Three concerns are each built as a **Symfony service-decoration chain** in `config/services.xml`. Higher `decoration-priority` is applied first and ends up *innermost*; the public alias resolves to the outermost decorator. Understanding the resolution order is essential before changing any service wiring:

- **Client context** — runtime order outermost→innermost: `CachedClientContext` (prio 32) → `CookieBasedClientContext` (prio 64) → `DefaultClientContext`.
- `Cached` memoizes the `Client` for the request.
- `CookieBased` reads the cookie; if present, builds `Client(cookieId, metadataProvider->getMetadata(...))`; otherwise delegates.
- `Default` returns an anonymous `new Client(null, new ChangeAwareMetadata())`.
- **Cookie provider** — `CachedCookieProvider` (prio 32, memoizes per `Request` via `spl_object_hash`) → `RequestBasedCookieProvider` (parses the cookie off the request).
- **Metadata provider** — `DoctrineOrmBasedMetadataProvider` (prio 64) → `EmptyMetadataProvider` (default). Doctrine loads from DB, falling back to the decorated provider when no row exists.

### Request lifecycle

- `EventSubscriber/StoreCookieSubscriber` (on `kernel.response`): dispatches `Event/PreStoreCookieEvent` (an app can set `$store = false` to veto), then writes/refreshes the cookie with an updated `lastSeenAt`. The cookie is set `HttpOnly(false)` on purpose so client-side JS can read it.
- `EventSubscriber/StoreMetadataSubscriber` (on `kernel.finish_request`): calls the metadata persister for the current client.

### Lazy metadata (why writes are cheap)

`Client/ChangeAwareMetadata` extends `setono/client`'s `Metadata` and flips a `dirty` flag on `set`/`remove`. `Client/LazyChangeAwareMetadata` adds Symfony VarExporter's `LazyGhostTrait`, so `DoctrineOrmBasedMetadataProvider` returns a lazy ghost whose DB query only fires on first access. `DoctrineOrmBasedMetadataPersister::persist()` then **short-circuits** in two cases: the metadata is a lazy ghost that was never initialized (never read), or it is not dirty (never mutated). This is what makes "lazy loaded metadata" in the README real — no SELECT and no flush unless the app touched metadata.

### Entity mapping

`Entity/Metadata` (table `setono_client__metadata`, string id `clientId`, nullable `json` `metadata`) is mapped in `config/doctrine-mapping/Metadata.orm.xml` as a **mapped-superclass**. `EventListener/Doctrine/ConvertToEntityListener` listens on Doctrine's `loadClassMetadata` and flips `isMappedSuperclass = false` so the bundle's own `Metadata` becomes a concrete entity when the app provides no replacement. Apps can swap in a custom class via the `metadata_class` config option; `SetonoClientExtension::load()` enforces that it implements `Entity/MetadataInterface` and they are responsible for mapping it.

### Configuration

Defined in `DependencyInjection/Configuration.php`, mapped to container parameters in `SetonoClientExtension`:

```yaml
setono_client:
cookie:
name: setono_client_id # changing this makes existing clients appear new
expiration: '+365 days' # any strtotime-parsable string
metadata_class: Setono\ClientBundle\Entity\Metadata
```

## Conventions

- Strict types everywhere (`declare(strict_types=1)`); concrete classes are `final`, except the metadata/entity classes (`ChangeAwareMetadata`, `LazyChangeAwareMetadata`, `Entity\Metadata`) which are intentionally extensible.
- New services go in `config/services.xml` using the `setono_client.<concern>.<variant>` id scheme, with a `setono_client.<concern>.default` alias and an interface alias for autowiring. To insert behavior into a chain, add a decorator with an appropriate `decoration-priority` rather than editing existing classes.
- Tests use `matthiasnoback/symfony-dependency-injection-test` (e.g. `AbstractExtensionTestCase`) and Prophecy (`ProphecyTrait`), with the `@test` annotation and snake_case method-name style. Keep the suite at 100% MSI — when adding logic, add a test that kills the corresponding mutant.
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) 2024 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
32 changes: 25 additions & 7 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,23 @@
"symfony/var-exporter": "^6.4 || ^7.0"
},
"require-dev": {
"ergebnis/composer-normalize": "^2.50",
"infection/infection": "^0.27 || ^0.28",
"jangregor/phpstan-prophecy": "^2.3",
"matthiasnoback/symfony-dependency-injection-test": "^4.3.1 || ^5.1",
"phpspec/prophecy-phpunit": "^2.2",
"phpspec/prophecy": "^1.20",
"phpspec/prophecy-phpunit": "^2.5",
"phpstan/extension-installer": "^1.4.3",
"phpstan/phpstan": "^2.1.46",
"phpstan/phpstan-doctrine": "^2.0",
"phpstan/phpstan-phpunit": "^2.0.16",
"phpstan/phpstan-strict-rules": "^2.0.10",
"phpstan/phpstan-symfony": "^2.0",
"phpstan/phpstan-webmozart-assert": "^2.0",
"phpunit/phpunit": "^10.5",
"psalm/plugin-phpunit": "^0.19",
"psalm/plugin-symfony": "^5.1",
"setono/code-quality-pack": "^2.7",
"shipmonk/composer-dependency-analyser": "^1.8.2"
"rector/rector": "^2.4.1",
"shipmonk/composer-dependency-analyser": "^1.8.4",
"sylius-labs/coding-standard": "^4.5"
},
"prefer-stable": true,
"autoload": {
Expand All @@ -49,14 +59,22 @@
"config": {
"allow-plugins": {
"dealerdirect/phpcodesniffer-composer-installer": false,
"ergebnis/composer-normalize": true
"ergebnis/composer-normalize": true,
"infection/extension-installer": true,
"phpstan/extension-installer": true
},
"policy": {
"advisories": {
"block": false
}
},
"sort-packages": true
},
"scripts": {
"analyse": "psalm",
"analyse": "phpstan analyse",
"check-style": "ecs check",
"fix-style": "ecs check --fix",
"infection": "infection",
"phpunit": "phpunit"
}
}
16 changes: 16 additions & 0 deletions infection.json.dist
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"$schema": "vendor/infection/infection/resources/schema.json",
"source": {
"directories": [
"src"
]
},
"mutators": {
"@default": true,
"InstanceOf_": {
"ignore": [
"Setono\\ClientBundle\\EventListener\\Doctrine\\ConvertToEntityListener::loadClassMetadata"
]
}
}
}
14 changes: 14 additions & 0 deletions phpstan.neon.dist
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
parameters:
level: max
phpVersion: 80100
paths:
- src
- tests
ignoreErrors:
# Symfony's LazyGhostTrait initializer must call __construct() on the ghost
# instance to populate it. This is the documented idiom for lazy ghosts, so
# phpstan-strict-rules' ban on calling __construct() does not apply here.
-
identifier: constructor.call
path: src/MetadataProvider/DoctrineOrmBasedMetadataProvider.php
count: 1
27 changes: 0 additions & 27 deletions psalm.xml

This file was deleted.

15 changes: 5 additions & 10 deletions rector.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,10 @@
declare(strict_types=1);

use Rector\Config\RectorConfig;
use Rector\Set\ValueObject\LevelSetList;

return static function (RectorConfig $rectorConfig): void {
$rectorConfig->paths([
return RectorConfig::configure()
->withPaths([
__DIR__ . '/src',
__DIR__ . '/tests/Unit',
]);

$rectorConfig->sets([
LevelSetList::UP_TO_PHP_74
]);
};
__DIR__ . '/tests',
])
->withPhpSets(php81: true);
2 changes: 1 addition & 1 deletion src/Client/ChangeAwareMetadata.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ public function isDirty(): bool
return $this->dirty;
}

public function set(string $key, mixed $value, int $ttl = null): void
public function set(string $key, mixed $value, ?int $ttl = null): void
{
parent::set($key, $value, $ttl);

Expand Down
3 changes: 0 additions & 3 deletions src/Client/LazyChangeAwareMetadata.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,6 @@

use Symfony\Component\VarExporter\LazyGhostTrait;

/**
* @psalm-suppress PropertyNotSetInConstructor
*/
class LazyChangeAwareMetadata extends ChangeAwareMetadata
{
use LazyGhostTrait;
Expand Down
4 changes: 2 additions & 2 deletions src/CookieProvider/CachedCookieProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@ public function __construct(
) {
}

public function getCookie(Request $request = null): ?Cookie
public function getCookie(?Request $request = null): ?Cookie
{
$request = $request ?? $this->requestStack->getMainRequest();
$request ??= $this->requestStack->getMainRequest();
if (null === $request) {
return null;
}
Expand Down
2 changes: 1 addition & 1 deletion src/CookieProvider/CookieProviderInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,5 @@ interface CookieProviderInterface
/**
* @param Request|null $request if null, the main request will be used
*/
public function getCookie(Request $request = null): ?Cookie;
public function getCookie(?Request $request = null): ?Cookie;
}
2 changes: 1 addition & 1 deletion src/CookieProvider/RequestBasedCookieProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public function __construct(
) {
}

public function getCookie(Request $request = null): ?Cookie
public function getCookie(?Request $request = null): ?Cookie
{
$cookieValue = ($request ?? $this->requestStack->getMainRequest())?->cookies->get($this->cookieName);
if (!is_string($cookieValue) || '' === $cookieValue) {
Expand Down
1 change: 0 additions & 1 deletion src/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ public function getConfigTreeBuilder(): TreeBuilder
/** @var ArrayNodeDefinition $rootNode */
$rootNode = $treeBuilder->getRootNode();

/** @psalm-suppress MixedMethodCall, UndefinedInterfaceMethod, PossiblyNullReference */
$rootNode
->addDefaultsIfNotSet()
->children()
Expand Down
2 changes: 0 additions & 2 deletions src/DependencyInjection/SetonoClientExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,6 @@ final class SetonoClientExtension extends Extension
public function load(array $configs, ContainerBuilder $container): void
{
/**
* @psalm-suppress PossiblyNullArgument
*
* @var array{cookie: array{name: string, expiration: string}, metadata_class: class-string} $config
*/
$config = $this->processConfiguration($this->getConfiguration([], $container), $configs);
Expand Down
5 changes: 4 additions & 1 deletion src/Entity/Metadata.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ class Metadata implements MetadataInterface
{
protected ?string $clientId = null;

/** @var null|array{__expires?: array<string, int>, ...<string, mixed>} */
/** @var array<string, mixed>|null */
protected ?array $metadata = [];

public function getClientId(): ?string
Expand All @@ -26,6 +26,9 @@ public function getMetadata(): array
return $this->metadata ?? [];
}

/**
* @param array<string, mixed> $metadata
*/
public function setMetadata(array $metadata): void
{
$this->metadata = [] === $metadata ? null : $metadata;
Expand Down
2 changes: 1 addition & 1 deletion src/Entity/MetadataInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ public function getClientId(): ?string;
public function setClientId(string $clientId): void;

/**
* @return array{__expires?: array<string, int>, ...<string, mixed>}
* @return array<string, mixed>
*/
public function getMetadata(): array;

Expand Down
3 changes: 3 additions & 0 deletions src/EventListener/Doctrine/ConvertToEntityListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
*/
final class ConvertToEntityListener
{
/**
* @param LoadClassMetadataEventArgs<\Doctrine\Persistence\Mapping\ClassMetadata<object>, \Doctrine\Persistence\ObjectManager> $eventArgs
*/
public function loadClassMetadata(LoadClassMetadataEventArgs $eventArgs): void
{
if (!is_a($eventArgs->getClassMetadata()->getName(), Metadata::class, true)) {
Expand Down
5 changes: 0 additions & 5 deletions src/MetadataProvider/DoctrineOrmBasedMetadataProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,6 @@ public function getMetadata(string $clientId): Metadata
$metadata = $entity->getMetadata();
}

/**
* todo remove the MixedArgumentTypeCoercion suppression when this issue is fixed: https://github.com/vimeo/psalm/issues/10964
*
* @psalm-suppress DirectConstructorCall,MixedArgumentTypeCoercion
*/
$instance->__construct($metadata);
});
}
Expand Down
Loading
Loading