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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [0.2.0] - 2026-04-24

### Changed

- Services implementing `OpenFeature\interfaces\hooks\Hook` are now autoconfigured with the `openfeature.hook` tag. No more manual tagging required in `services.yaml` (opt out with `autoconfigure: false` for per-call hooks such as `RegexpValidatorHook`).
- Services implementing `EvaluationContextProviderInterface` are now autoconfigured via `registerForAutoconfiguration()` in the bundle extension (the previous `#[AutoconfigureTag]` on the interface was not picked up by Symfony and had no effect).

### Upgrade notes

- **Remove redundant hook tags.** If your `services.yaml` has both `_defaults: autoconfigure: true` and `tags: [openfeature.hook]` on a `Hook` service, the hook will now be registered **twice** and fire twice per evaluation. Remove the explicit `tags: [openfeature.hook]` from such services.
- **Remove redundant evaluation context provider tags.** Same applies to `tags: [openfeature.evaluation_context_provider]` on services implementing `EvaluationContextProviderInterface` with autoconfigure on.

## [0.1.1] - 2026-04-18

### Fixed
Expand All @@ -14,3 +28,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.1.0] - 2026-04-11

Initial release.

[Unreleased]: https://github.com/aubes/openfeature-bundle/compare/v0.2.0...HEAD
[0.2.0]: https://github.com/aubes/openfeature-bundle/compare/v0.1.1...v0.2.0
[0.1.1]: https://github.com/aubes/openfeature-bundle/compare/v0.1.0...v0.1.1
[0.1.0]: https://github.com/aubes/openfeature-bundle/releases/tag/v0.1.0
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ class CheckoutController
- **`#[FeatureGate]`** blocks access when a flag is off
- **`#[FeatureFlag]`** injects resolved values, fully typed
- **Twig** helpers: `feature('flag')`, `feature_value('flag', default)`
- **Hooks** autoconfigured: implement `Hook` for logging, tracing, validation
- **Evaluation context** autoconfigured: implement `EvaluationContextProviderInterface` to feed targeting attributes
- **Symfony Profiler** panel with evaluated flags, provider info, and context
- **Any provider**: basic built-ins (InMemory, EnvVar, Redis) for quick starts, or plug any real OpenFeature provider (Flagd, ConfigCat, Unleash, LaunchDarkly...)
- **FrankenPHP** worker mode safe out of the box
Expand Down
4 changes: 2 additions & 2 deletions docs/features/evaluation-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,12 @@ class TenantContextProvider implements EvaluationContextProviderInterface
}
```

> **Note:** The interface is autoconfigured: if your service implements `EvaluationContextProviderInterface`, the `openfeature.evaluation_context_provider` tag is added automatically.
The service is picked up automatically, no tag or config needed.

## Multiple providers

Multiple context providers are supported. Their contexts are merged via the SDK's `EvaluationContext::merge()` method.

## FrankenPHP worker mode

The global `EvaluationContext` is automatically reset between requests via Symfony's `kernel.reset` mechanism. No configuration required.
The global `EvaluationContext` is automatically cleared between requests. No configuration required.
55 changes: 28 additions & 27 deletions docs/features/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,57 +4,58 @@ Hooks run around every flag evaluation. They are useful for logging, tracing, me

## Registering a hook

Implement the OpenFeature `Hook` interface and tag the service with `openfeature.hook`:
A service implementing the OpenFeature `Hook` interface is autoconfigured with the `openfeature.hook` tag.

```php
use OpenFeature\interfaces\hooks\Hook;
use OpenFeature\interfaces\hooks\HookContext;
use OpenFeature\interfaces\hooks\HookHints;
use OpenFeature\interfaces\provider\ResolutionDetails;
use Psr\Log\LoggerInterface;

class LoggerHook implements Hook
{
public function before(HookContext $context, HookHints $hints): ?EvaluationContext
{
return null;
}
public function __construct(private readonly LoggerInterface $logger) {}

public function after(HookContext $context, ResolutionDetails $details, HookHints $hints): void
{
// log, trace, metrics...
$this->logger->info('Flag evaluated', [
'flag' => $context->getFlagKey(),
'value' => $details->getValue(),
]);
}

public function error(HookContext $context, \Throwable $error, HookHints $hints): void
{
// handle evaluation errors
}
// before(), error(), finally(), supportsFlagValueType() are also available
// see: https://openfeature.dev/specification/sections/hooks
}
```

public function finally(HookContext $context, HookHints $hints): void
{
// cleanup
}
### Hooks with constructor options

public function supportsFlagValueType(): bool
{
return true; // support all flag types
}
}
When a hook takes constructor arguments (for example the `RegexpValidatorHook` from [php-sdk-contrib](https://github.com/open-feature/php-sdk-contrib/blob/main/hooks/Validators/README.md)), declare the service with its arguments. Autoconfiguration still adds the tag:

```yaml
services:
App\Hook\HexadecimalValidatorHook:
class: OpenFeature\Hooks\Validators\RegexpValidatorHook
arguments: ['/^[0-9a-f]+$/']
```

### Per-call hooks (opt-out)

If you want a hook to be applied only at call-site via `EvaluationOptions` and not globally, disable autoconfiguration on that service:

```yaml
services:
App\OpenFeature\LoggerHook:
tags: [openfeature.hook]
App\Hook\HexadecimalValidatorHook:
class: OpenFeature\Hooks\Validators\RegexpValidatorHook
arguments: ['/^[0-9a-f]+$/']
autoconfigure: false
```

## Hook lifecycle

Hooks are called in the following order for each flag evaluation:

1. `before()` : before the provider resolves the flag
2. `after()` : after successful resolution
3. `error()` : if the provider throws an exception (instead of `after`)
4. `finally()` : always called, regardless of success or failure
See the [OpenFeature hooks specification](https://openfeature.dev/specification/sections/hooks) for the full lifecycle.

## Pre-built hooks

Expand Down
2 changes: 0 additions & 2 deletions src/EvaluationContext/EvaluationContextProviderInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
namespace Aubes\OpenFeatureBundle\EvaluationContext;

use OpenFeature\interfaces\flags\EvaluationContext;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
use Symfony\Component\HttpFoundation\Request;

/**
Expand All @@ -15,7 +14,6 @@
* Multiple providers are supported and their contexts are merged in priority order
* (highest priority first). Use the "priority" attribute on the tag to control ordering.
*/
#[AutoconfigureTag('openfeature.evaluation_context_provider')]
interface EvaluationContextProviderInterface
{
public function getContext(Request $request): ?EvaluationContext;
Expand Down
18 changes: 18 additions & 0 deletions src/Event/EvaluationContextContributedEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace Aubes\OpenFeatureBundle\Event;

use Aubes\OpenFeatureBundle\EvaluationContext\EvaluationContextProviderInterface;
use OpenFeature\interfaces\flags\EvaluationContext;
use Symfony\Contracts\EventDispatcher\Event;

final class EvaluationContextContributedEvent extends Event
{
public function __construct(
public readonly EvaluationContextProviderInterface $provider,
public readonly EvaluationContext $context,
) {
}
}
10 changes: 8 additions & 2 deletions src/EventListener/EvaluationContextListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@
namespace Aubes\OpenFeatureBundle\EventListener;

use Aubes\OpenFeatureBundle\EvaluationContext\EvaluationContextProviderInterface;
use Aubes\OpenFeatureBundle\Event\EvaluationContextContributedEvent;
use OpenFeature\implementation\flags\EvaluationContext;
use OpenFeature\implementation\flags\MutableEvaluationContext;
use OpenFeature\interfaces\flags\API;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Contracts\Service\ResetInterface;

Expand All @@ -17,6 +19,7 @@ class EvaluationContextListener implements ResetInterface
public function __construct(
private readonly API $api,
private readonly iterable $providers = [],
private readonly ?EventDispatcherInterface $dispatcher = null,
) {
}

Expand All @@ -29,9 +32,12 @@ public function onKernelRequest(RequestEvent $event): void
$contexts = [];
foreach ($this->providers as $provider) {
$context = $provider->getContext($event->getRequest());
if ($context !== null) {
$contexts[] = $context;
if ($context === null) {
continue;
}

$contexts[] = $context;
$this->dispatcher?->dispatch(new EvaluationContextContributedEvent($provider, $context));
}

if ($contexts === []) {
Expand Down
15 changes: 15 additions & 0 deletions src/OpenFeatureBundle.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,14 @@
use Aubes\OpenFeatureBundle\Command\DebugFeatureFlagsCommand;
use Aubes\OpenFeatureBundle\DependencyInjection\Compiler\RegisterHooksPass;
use Aubes\OpenFeatureBundle\DependencyInjection\Compiler\SetProviderPass;
use Aubes\OpenFeatureBundle\EvaluationContext\EvaluationContextProviderInterface;
use Aubes\OpenFeatureBundle\EvaluationContext\UserEvaluationContextProvider;
use Aubes\OpenFeatureBundle\Profiler\ContextProviderRecorder;
use Aubes\OpenFeatureBundle\Profiler\OpenFeatureDataCollector;
use Aubes\OpenFeatureBundle\Profiler\ProfilerHook;
use OpenFeature\interfaces\flags\API;
use OpenFeature\interfaces\flags\Client;
use OpenFeature\interfaces\hooks\Hook;
use Symfony\Component\Config\Definition\Configurator\DefinitionConfigurator;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
Expand Down Expand Up @@ -99,6 +102,12 @@ public function loadExtension(array $config, ContainerConfigurator $container, C
$loader = new PhpFileLoader($builder, new FileLocator(__DIR__ . '/Resources/config'));
$loader->load('services.php');

$builder->registerForAutoconfiguration(Hook::class)
->addTag('openfeature.hook');

$builder->registerForAutoconfiguration(EvaluationContextProviderInterface::class)
->addTag('openfeature.evaluation_context_provider');

$builder->setParameter('open_feature.provider', $config['provider']);
$builder->setParameter('open_feature.flags', $config['flags']);

Expand Down Expand Up @@ -156,9 +165,15 @@ public function loadExtension(array $config, ContainerConfigurator $container, C
->setAutoconfigured(true)
->addTag('openfeature.hook');

$builder->register(ContextProviderRecorder::class)
->setPublic(false)
->setAutoconfigured(true)
->addTag('kernel.reset', ['method' => 'reset']);

$builder->register(OpenFeatureDataCollector::class)
->addArgument(new Reference(ProfilerHook::class))
->addArgument(new Reference(API::class))
->addArgument(new Reference(ContextProviderRecorder::class))
->addTag('data_collector', [
'template' => '@OpenFeature/Collector/openfeature.html.twig',
'id' => 'open_feature',
Expand Down
36 changes: 36 additions & 0 deletions src/Profiler/ContextProviderRecorder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

namespace Aubes\OpenFeatureBundle\Profiler;

use Aubes\OpenFeatureBundle\Event\EvaluationContextContributedEvent;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Contracts\Service\ResetInterface;

#[AsEventListener(event: EvaluationContextContributedEvent::class)]
class ContextProviderRecorder implements ResetInterface
{
/** @var list<array{provider: class-string, targeting_key: ?string, attributes: array<array-key, mixed>}> */
private array $contributions = [];

public function __invoke(EvaluationContextContributedEvent $event): void
{
$this->contributions[] = [
'provider' => $event->provider::class,
'targeting_key' => $event->context->getTargetingKey(),
'attributes' => $event->context->getAttributes()->toArray(),
];
}

/** @return list<array{provider: class-string, targeting_key: ?string, attributes: array<array-key, mixed>}> */
public function getContributions(): array
{
return $this->contributions;
}

public function reset(): void
{
$this->contributions = [];
}
}
53 changes: 53 additions & 0 deletions src/Profiler/OpenFeatureDataCollector.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class OpenFeatureDataCollector extends DataCollector
public function __construct(
private readonly ProfilerHook $hook,
private readonly API $api,
private readonly ?ContextProviderRecorder $recorder = null,
) {
}

Expand All @@ -23,6 +24,8 @@ public function collect(Request $request, Response $response, ?\Throwable $excep
'evaluations' => $this->hook->getEvaluations(),
'provider' => $this->api->getProviderMetadata()->getName(),
'evaluation_context' => $this->serializeContext(),
'hooks' => $this->collectHooks(),
'context_providers' => $this->collectContextProviders(),
];
}

Expand Down Expand Up @@ -57,6 +60,24 @@ public function getEvaluationContext(): array
return $context;
}

/** @return list<class-string> */
public function getHooks(): array
{
/** @var list<class-string> $hooks */
$hooks = $this->data['hooks'] ?? [];

return $hooks;
}

/** @return list<array{provider: class-string, targeting_key: ?string, attributes: array<array-key, mixed>}> */
public function getContextProviders(): array
{
/** @var list<array{provider: class-string, targeting_key: ?string, attributes: array<array-key, mixed>}> $providers */
$providers = $this->data['context_providers'] ?? [];

return $providers;
}

public function getName(): string
{
return 'open_feature';
Expand Down Expand Up @@ -90,4 +111,36 @@ private function serializeContext(): array
'attributes' => $context->getAttributes()->toArray(),
];
}

/** @return list<class-string> */
private function collectHooks(): array
{
$hooks = [];
foreach ($this->api->getHooks() as $hook) {
if ($hook instanceof ProfilerHook) {
continue;
}

$hooks[] = $hook::class;
}

return $hooks;
}

/** @return list<array{provider: class-string, targeting_key: ?string, attributes: array<array-key, mixed>}> */
private function collectContextProviders(): array
{
if ($this->recorder === null) {
return [];
}

return \array_map(
fn (array $c): array => [
'provider' => $c['provider'],
'targeting_key' => $this->anonymizeTargetingKey($c['targeting_key']),
'attributes' => $c['attributes'],
],
$this->recorder->getContributions(),
);
}
}
1 change: 1 addition & 0 deletions src/Resources/config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
->args([
service(API::class),
tagged_iterator('openfeature.evaluation_context_provider'),
service('event_dispatcher')->nullOnInvalid(),
])
->tag('kernel.event_listener', ['event' => 'kernel.request', 'method' => 'onKernelRequest', 'priority' => 4])
->tag('kernel.reset', ['method' => 'reset']);
Expand Down
Loading
Loading