Initial commit
This commit is contained in:
parent
759c893bfc
commit
b57ef58ea4
54
.github/workflows/ci.yml
vendored
Normal file
54
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main, master ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ main, master ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
name: PHP ${{ matrix.php }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
php: [ '8.1', '8.2', '8.3' ]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup PHP
|
||||||
|
uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: ${{ matrix.php }}
|
||||||
|
coverage: none
|
||||||
|
tools: composer:v2
|
||||||
|
|
||||||
|
- name: Get Composer Cache Directory
|
||||||
|
id: composer-cache
|
||||||
|
run: |
|
||||||
|
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Cache dependencies
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ${{ steps.composer-cache.outputs.dir }}
|
||||||
|
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
|
||||||
|
restore-keys: ${{ runner.os }}-composer-
|
||||||
|
|
||||||
|
- name: Validate composer.json
|
||||||
|
run: composer validate --strict
|
||||||
|
|
||||||
|
- name: Install dependencies (libraries use update)
|
||||||
|
run: composer update --no-interaction --no-progress --prefer-dist
|
||||||
|
|
||||||
|
- name: Static Analysis (PHPStan)
|
||||||
|
run: vendor/bin/phpstan analyse
|
||||||
|
|
||||||
|
- name: Coding Standards (PHP-CS-Fixer dry run)
|
||||||
|
run: vendor/bin/php-cs-fixer fix --dry-run --diff
|
||||||
|
|
||||||
|
- name: PHPUnit
|
||||||
|
run: vendor/bin/phpunit -c phpunit.xml.dist
|
||||||
93
.gitignore
vendored
93
.gitignore
vendored
|
|
@ -1,87 +1,8 @@
|
||||||
# ---> Composer
|
/.idea/
|
||||||
composer.phar
|
|
||||||
/vendor/
|
/vendor/
|
||||||
|
/.phpunit.cache/
|
||||||
# Commit your application's lock file https://getcomposer.org/doc/01-basic-usage.md#commit-your-composer-lock-file-to-version-control
|
/.phpunit.result.cache
|
||||||
# You may choose to ignore a library lock file http://getcomposer.org/doc/02-libraries.md#lock-file
|
/.phpunit.cache/
|
||||||
# composer.lock
|
composer.lock
|
||||||
|
/.php-cs-fixer.cache
|
||||||
# ---> JetBrains
|
/.phpstan/
|
||||||
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
|
|
||||||
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
|
|
||||||
|
|
||||||
# User-specific stuff
|
|
||||||
.idea/**/workspace.xml
|
|
||||||
.idea/**/tasks.xml
|
|
||||||
.idea/**/usage.statistics.xml
|
|
||||||
.idea/**/dictionaries
|
|
||||||
.idea/**/shelf
|
|
||||||
|
|
||||||
# AWS User-specific
|
|
||||||
.idea/**/aws.xml
|
|
||||||
|
|
||||||
# Generated files
|
|
||||||
.idea/**/contentModel.xml
|
|
||||||
|
|
||||||
# Sensitive or high-churn files
|
|
||||||
.idea/**/dataSources/
|
|
||||||
.idea/**/dataSources.ids
|
|
||||||
.idea/**/dataSources.local.xml
|
|
||||||
.idea/**/sqlDataSources.xml
|
|
||||||
.idea/**/dynamic.xml
|
|
||||||
.idea/**/uiDesigner.xml
|
|
||||||
.idea/**/dbnavigator.xml
|
|
||||||
|
|
||||||
# Gradle
|
|
||||||
.idea/**/gradle.xml
|
|
||||||
.idea/**/libraries
|
|
||||||
|
|
||||||
# Gradle and Maven with auto-import
|
|
||||||
# When using Gradle or Maven with auto-import, you should exclude module files,
|
|
||||||
# since they will be recreated, and may cause churn. Uncomment if using
|
|
||||||
# auto-import.
|
|
||||||
# .idea/artifacts
|
|
||||||
# .idea/compiler.xml
|
|
||||||
# .idea/jarRepositories.xml
|
|
||||||
# .idea/modules.xml
|
|
||||||
# .idea/*.iml
|
|
||||||
# .idea/modules
|
|
||||||
# *.iml
|
|
||||||
# *.ipr
|
|
||||||
|
|
||||||
# CMake
|
|
||||||
cmake-build-*/
|
|
||||||
|
|
||||||
# Mongo Explorer plugin
|
|
||||||
.idea/**/mongoSettings.xml
|
|
||||||
|
|
||||||
# File-based project format
|
|
||||||
*.iws
|
|
||||||
|
|
||||||
# IntelliJ
|
|
||||||
out/
|
|
||||||
|
|
||||||
# mpeltonen/sbt-idea plugin
|
|
||||||
.idea_modules/
|
|
||||||
|
|
||||||
# JIRA plugin
|
|
||||||
atlassian-ide-plugin.xml
|
|
||||||
|
|
||||||
# Cursive Clojure plugin
|
|
||||||
.idea/replstate.xml
|
|
||||||
|
|
||||||
# SonarLint plugin
|
|
||||||
.idea/sonarlint/
|
|
||||||
|
|
||||||
# Crashlytics plugin (for Android Studio and IntelliJ)
|
|
||||||
com_crashlytics_export_strings.xml
|
|
||||||
crashlytics.properties
|
|
||||||
crashlytics-build.properties
|
|
||||||
fabric.properties
|
|
||||||
|
|
||||||
# Editor-based Rest Client
|
|
||||||
.idea/httpRequests
|
|
||||||
|
|
||||||
# Android studio 3.1+ serialized cache file
|
|
||||||
.idea/caches/build_file_checksums.ser
|
|
||||||
|
|
||||||
|
|
|
||||||
26
.php-cs-fixer.dist.php
Normal file
26
.php-cs-fixer.dist.php
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use PhpCsFixer\Config;
|
||||||
|
use PhpCsFixer\Finder;
|
||||||
|
|
||||||
|
$finder = Finder::create()
|
||||||
|
->in(__DIR__ . '/src')
|
||||||
|
->name('*.php')
|
||||||
|
->ignoreDotFiles(true)
|
||||||
|
->ignoreVCS(true);
|
||||||
|
|
||||||
|
return (new Config())
|
||||||
|
->setRiskyAllowed(true)
|
||||||
|
->setFinder($finder)
|
||||||
|
->setRules([
|
||||||
|
'@PSR12' => true,
|
||||||
|
'array_syntax' => ['syntax' => 'short'],
|
||||||
|
'ordered_imports' => true,
|
||||||
|
'no_unused_imports' => true,
|
||||||
|
'declare_strict_types' => true,
|
||||||
|
'no_trailing_whitespace' => true,
|
||||||
|
'single_quote' => true,
|
||||||
|
'no_superfluous_phpdoc_tags' => true,
|
||||||
|
]);
|
||||||
12
CHANGELOG.md
Normal file
12
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
Unreleased
|
||||||
|
- CI: GitHub Actions for PHP 8.1–8.3 (composer validate, PHPStan, PHP-CS-Fixer dry-run, PHPUnit)
|
||||||
|
- Tooling: PHPStan level 8; PHP-CS-Fixer with PSR-12 ruleset
|
||||||
|
- Composer: metadata (authors, keywords, homepage, support), scripts (validate, analyse, lint, fix, test)
|
||||||
|
- Docs: Documented hashing normalization and boundary behavior in README
|
||||||
|
|
||||||
|
0.1.0 - YYYY-MM-DD
|
||||||
|
- Initial release with core components and tests
|
||||||
21
CODE_OF_CONDUCT.md
Normal file
21
CODE_OF_CONDUCT.md
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
Contributor Covenant Code of Conduct
|
||||||
|
|
||||||
|
Our Pledge
|
||||||
|
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone.
|
||||||
|
|
||||||
|
Our Standards
|
||||||
|
- Be respectful and inclusive.
|
||||||
|
- Accept constructive criticism.
|
||||||
|
- Focus on what is best for the community.
|
||||||
|
|
||||||
|
Enforcement Responsibilities
|
||||||
|
Project maintainers are responsible for clarifying and enforcing our standards of acceptable behavior.
|
||||||
|
|
||||||
|
Scope
|
||||||
|
This Code of Conduct applies within all community spaces and when an individual is officially representing the project.
|
||||||
|
|
||||||
|
Enforcement
|
||||||
|
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the maintainers. See SECURITY.md for private contact guidance.
|
||||||
|
|
||||||
|
Attribution
|
||||||
|
This Code of Conduct is adapted from the Contributor Covenant, version 2.1 (https://www.contributor-covenant.org/version/2/1/code_of_conduct.html).
|
||||||
38
CONTRIBUTING.md
Normal file
38
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
Contributing
|
||||||
|
|
||||||
|
Thanks for considering a contribution to FlagPole!
|
||||||
|
|
||||||
|
Development setup
|
||||||
|
1. Install PHP 8.1+ and Composer v2.
|
||||||
|
2. Install dependencies:
|
||||||
|
```
|
||||||
|
composer install
|
||||||
|
```
|
||||||
|
3. Run the test suite:
|
||||||
|
```
|
||||||
|
composer test
|
||||||
|
```
|
||||||
|
4. Static analysis and coding standards:
|
||||||
|
- Analyse (PHPStan level 8):
|
||||||
|
```
|
||||||
|
composer analyse
|
||||||
|
```
|
||||||
|
- Lint (PHP-CS-Fixer dry run):
|
||||||
|
```
|
||||||
|
composer lint
|
||||||
|
```
|
||||||
|
- Auto-fix style issues where possible:
|
||||||
|
```
|
||||||
|
composer fix
|
||||||
|
```
|
||||||
|
|
||||||
|
Guidelines
|
||||||
|
- Follow PSR-12 coding style (automated via PHP-CS-Fixer config).
|
||||||
|
- Include tests for new features or bug fixes.
|
||||||
|
- Keep public API changes minimal and document them in the README and CHANGELOG.
|
||||||
|
- For large changes, please open an issue for discussion before investing significant effort.
|
||||||
|
|
||||||
|
Releasing (maintainers)
|
||||||
|
- Ensure CI is green on main.
|
||||||
|
- Update CHANGELOG.md.
|
||||||
|
- Tag a release (e.g., `v0.1.0`) and push the tag.
|
||||||
81
README.md
81
README.md
|
|
@ -1,3 +1,82 @@
|
||||||
# FlagPole
|
# FlagPole
|
||||||
|
|
||||||
Feature Flag handling
|
Feature flag handling for PHP. Simple, framework-agnostic, and lightweight.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Install via Composer:
|
||||||
|
|
||||||
|
```
|
||||||
|
composer require phred/flagpole
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```php
|
||||||
|
use FlagPole\FeatureManager;
|
||||||
|
use FlagPole\Context;
|
||||||
|
use FlagPole\Repository\InMemoryFlagRepository;
|
||||||
|
|
||||||
|
require __DIR__ . '/vendor/autoload.php';
|
||||||
|
|
||||||
|
$repo = InMemoryFlagRepository::fromArray([
|
||||||
|
'new-dashboard' => [
|
||||||
|
'enabled' => null, // not a hard on/off
|
||||||
|
'rolloutPercentage' => 25, // 25% gradual rollout
|
||||||
|
'allowList' => ['user_1'], // always on for specific users
|
||||||
|
],
|
||||||
|
'hard-off' => [ 'enabled' => false ],
|
||||||
|
'hard-on' => [ 'enabled' => true ],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$flags = new FeatureManager($repo);
|
||||||
|
|
||||||
|
$context = Context::fromArray(['userId' => 'user_42']);
|
||||||
|
|
||||||
|
if ($flags->isEnabled('new-dashboard', $context, false)) {
|
||||||
|
// show the new dashboard
|
||||||
|
} else {
|
||||||
|
// keep the old dashboard
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Concepts
|
||||||
|
|
||||||
|
- Flag: has a `name` and optional strategies:
|
||||||
|
- `enabled`: explicit boolean on/off overrides everything.
|
||||||
|
- `rolloutPercentage`: 0-100 gradual rollout based on a stable hash of the flag name + user key.
|
||||||
|
- `allowList`: list of user keys that always get the flag enabled.
|
||||||
|
- Context: attributes about the subject (e.g. `userId`, `email`) used for evaluation.
|
||||||
|
- Repository: source of truth for flags. Provided: `InMemoryFlagRepository`. You can implement your own.
|
||||||
|
|
||||||
|
## Targeting key
|
||||||
|
|
||||||
|
Evaluator looks for a stable key in the context in this order: `key`, `userId`, `id`, `email`.
|
||||||
|
|
||||||
|
## Rollout hashing and boundary behavior
|
||||||
|
|
||||||
|
- Stable bucketing uses `crc32(flagName:key)` normalized to an unsigned 32-bit integer, then mapped to buckets 0..99.
|
||||||
|
- This guarantees consistent behavior across 32-bit and 64-bit platforms.
|
||||||
|
- Boundary rules:
|
||||||
|
- 0% rollout always evaluates to `false` when a targeting key is present.
|
||||||
|
- 100% rollout always evaluates to `true` when a targeting key is present.
|
||||||
|
- If no targeting key is present in the `Context`, percentage rollout falls back to the `default` you pass to `isEnabled()`.
|
||||||
|
|
||||||
|
## Precedence semantics
|
||||||
|
|
||||||
|
When evaluating a flag, the following precedence applies:
|
||||||
|
1. `allowList` — if the targeting key is in the allow-list, the flag is enabled.
|
||||||
|
2. `enabled` — explicit on/off overrides percentage rollout and defaults.
|
||||||
|
3. `rolloutPercentage` — uses stable bucketing over the targeting key.
|
||||||
|
4. Fallback — returns the provided default when none of the above apply.
|
||||||
|
|
||||||
|
## Framework integration
|
||||||
|
|
||||||
|
FlagPole is framework-agnostic. Wrap `FeatureManager` in your framework's container and bind a repository suitable for your environment.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
7
SECURITY.md
Normal file
7
SECURITY.md
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
Security Policy
|
||||||
|
|
||||||
|
If you discover a security vulnerability, please do not open a public issue.
|
||||||
|
|
||||||
|
Instead, email the maintainer (Phred) privately to report the issue. If you do not have a direct email, open a GitHub issue stating only that you wish to disclose a security problem and request a contact channel.
|
||||||
|
|
||||||
|
We will confirm receipt within 72 hours and provide a timeline for a fix when possible.
|
||||||
83
composer.json
Normal file
83
composer.json
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
{
|
||||||
|
"name": "phred/flagpole",
|
||||||
|
"description": "Feature flag handling library for PHP. Simple, fast, and framework-agnostic.",
|
||||||
|
"type": "library",
|
||||||
|
"license": "MIT",
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Phred",
|
||||||
|
"email": "phred@phred.com",
|
||||||
|
"homepage": "https://phred.com",
|
||||||
|
"role": "Owner"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"feature-flags",
|
||||||
|
"flags",
|
||||||
|
"toggle",
|
||||||
|
"rollout",
|
||||||
|
"ab-testing",
|
||||||
|
"php"
|
||||||
|
],
|
||||||
|
"homepage": "https://github.com/phred/flagpole",
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/phred/flagpole/issues",
|
||||||
|
"source": "https://github.com/phred/flagpole"
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.1"
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"FlagPole\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpunit/phpunit": "^10.5",
|
||||||
|
"phpstan/phpstan": "^1.11",
|
||||||
|
"friendsofphp/php-cs-fixer": "^3.64"
|
||||||
|
},
|
||||||
|
"autoload-dev": {
|
||||||
|
"psr-4": {
|
||||||
|
"FlagPole\\Tests\\": "tests/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"composer-validate": [
|
||||||
|
"@composer validate --strict"
|
||||||
|
],
|
||||||
|
"analyse": [
|
||||||
|
"@php -d memory_limit=-1 vendor/bin/phpstan analyse"
|
||||||
|
],
|
||||||
|
"lint": [
|
||||||
|
"@php vendor/bin/php-cs-fixer fix --dry-run --diff"
|
||||||
|
],
|
||||||
|
"fix": [
|
||||||
|
"@php vendor/bin/php-cs-fixer fix"
|
||||||
|
],
|
||||||
|
"test": [
|
||||||
|
"@php vendor/bin/phpunit -c phpunit.xml.dist"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"sort-packages": true,
|
||||||
|
"preferred-install": {
|
||||||
|
"*": "dist"
|
||||||
|
},
|
||||||
|
"optimize-autoloader": true
|
||||||
|
},
|
||||||
|
"archive": {
|
||||||
|
"exclude": [
|
||||||
|
".github/",
|
||||||
|
".idea/",
|
||||||
|
"tests/",
|
||||||
|
".phpunit.cache/",
|
||||||
|
"phpunit.xml.dist",
|
||||||
|
".php-cs-fixer.cache",
|
||||||
|
"phpstan.neon",
|
||||||
|
"phpstan.neon.dist"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"minimum-stability": "stable",
|
||||||
|
"prefer-stable": true
|
||||||
|
}
|
||||||
7
phpstan.neon.dist
Normal file
7
phpstan.neon.dist
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
parameters:
|
||||||
|
level: 8
|
||||||
|
paths:
|
||||||
|
- src
|
||||||
|
checkGenericClassInNonGenericObjectType: true
|
||||||
|
checkMissingIterableValueType: true
|
||||||
|
reportUnmatchedIgnoredErrors: true
|
||||||
11
phpunit.xml.dist
Normal file
11
phpunit.xml.dist
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd"
|
||||||
|
bootstrap="vendor/autoload.php"
|
||||||
|
colors="true">
|
||||||
|
<testsuites>
|
||||||
|
<testsuite name="FlagPole Test Suite">
|
||||||
|
<directory>tests</directory>
|
||||||
|
</testsuite>
|
||||||
|
</testsuites>
|
||||||
|
</phpunit>
|
||||||
44
src/Context.php
Normal file
44
src/Context.php
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace FlagPole;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluation context passed to the flag evaluator.
|
||||||
|
* Wrapper around an associative array of attributes (e.g., userId, email, team).
|
||||||
|
*/
|
||||||
|
final class Context
|
||||||
|
{
|
||||||
|
/** @var array<string, scalar|null> */
|
||||||
|
private array $attributes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, scalar|null> $attributes
|
||||||
|
*/
|
||||||
|
public function __construct(array $attributes = [])
|
||||||
|
{
|
||||||
|
$this->attributes = $attributes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, scalar|null> $attributes
|
||||||
|
*/
|
||||||
|
public static function fromArray(array $attributes): self
|
||||||
|
{
|
||||||
|
return new self($attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(string $key, mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
return $this->attributes[$key] ?? $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, scalar|null>
|
||||||
|
*/
|
||||||
|
public function all(): array
|
||||||
|
{
|
||||||
|
return $this->attributes;
|
||||||
|
}
|
||||||
|
}
|
||||||
60
src/Evaluator.php
Normal file
60
src/Evaluator.php
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace FlagPole;
|
||||||
|
|
||||||
|
final class Evaluator
|
||||||
|
{
|
||||||
|
public function evaluate(Flag $flag, ?Context $context = null, bool $default = false): bool
|
||||||
|
{
|
||||||
|
$context ??= new Context();
|
||||||
|
|
||||||
|
// 1) Allow list wins if present
|
||||||
|
if (!empty($flag->allowList)) {
|
||||||
|
$key = $this->resolveTargetingKey($context);
|
||||||
|
if ($key !== null && in_array((string)$key, $flag->allowList, true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Explicit on/off
|
||||||
|
if ($flag->enabled !== null) {
|
||||||
|
return $flag->enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Percentage rollout if available
|
||||||
|
if ($flag->rolloutPercentage !== null) {
|
||||||
|
$key = $this->resolveTargetingKey($context);
|
||||||
|
if ($key === null) {
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
$bucket = $this->computeBucket($flag->name, (string)$key);
|
||||||
|
return $bucket < $flag->rolloutPercentage;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) Fallback
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveTargetingKey(Context $context): ?string
|
||||||
|
{
|
||||||
|
$candidates = ['key', 'userId', 'id', 'email'];
|
||||||
|
foreach ($candidates as $attr) {
|
||||||
|
$v = $context->get($attr);
|
||||||
|
if ($v !== null && $v !== '') {
|
||||||
|
return (string)$v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function computeBucket(string $flagName, string $key): int
|
||||||
|
{
|
||||||
|
$hash = crc32($flagName . ':' . $key);
|
||||||
|
// Normalize to unsigned 32-bit to avoid negative values on some platforms
|
||||||
|
$unsigned = (int) sprintf('%u', $hash);
|
||||||
|
// Map to 0..99
|
||||||
|
return (int)($unsigned % 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/FeatureManager.php
Normal file
25
src/FeatureManager.php
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace FlagPole;
|
||||||
|
|
||||||
|
use FlagPole\Repository\FlagRepositoryInterface;
|
||||||
|
|
||||||
|
final class FeatureManager
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private FlagRepositoryInterface $repository,
|
||||||
|
private Evaluator $evaluator = new Evaluator()
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isEnabled(string $flagName, ?Context $context = null, bool $default = false): bool
|
||||||
|
{
|
||||||
|
$flag = $this->repository->get($flagName);
|
||||||
|
if ($flag === null) {
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
return $this->evaluator->evaluate($flag, $context, $default);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/Flag.php
Normal file
25
src/Flag.php
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace FlagPole;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable definition of a feature flag.
|
||||||
|
*/
|
||||||
|
final class Flag
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $name,
|
||||||
|
public readonly ?bool $enabled = null,
|
||||||
|
public readonly ?int $rolloutPercentage = null,
|
||||||
|
/** @var list<string> */
|
||||||
|
public readonly array $allowList = [],
|
||||||
|
) {
|
||||||
|
if ($this->rolloutPercentage !== null) {
|
||||||
|
if ($this->rolloutPercentage < 0 || $this->rolloutPercentage > 100) {
|
||||||
|
throw new \InvalidArgumentException('rolloutPercentage must be between 0 and 100');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/Repository/FlagRepositoryInterface.php
Normal file
17
src/Repository/FlagRepositoryInterface.php
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace FlagPole\Repository;
|
||||||
|
|
||||||
|
use FlagPole\Flag;
|
||||||
|
|
||||||
|
interface FlagRepositoryInterface
|
||||||
|
{
|
||||||
|
public function get(string $name): ?Flag;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return iterable<string, Flag>
|
||||||
|
*/
|
||||||
|
public function all(): iterable;
|
||||||
|
}
|
||||||
54
src/Repository/InMemoryFlagRepository.php
Normal file
54
src/Repository/InMemoryFlagRepository.php
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace FlagPole\Repository;
|
||||||
|
|
||||||
|
use FlagPole\Flag;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple in-memory repository. Useful for bootstrapping or tests.
|
||||||
|
*/
|
||||||
|
final class InMemoryFlagRepository implements FlagRepositoryInterface
|
||||||
|
{
|
||||||
|
/** @var array<string, Flag> */
|
||||||
|
private array $flags = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, array{enabled?:bool|null, rolloutPercentage?:int|null, allowList?:list<string>}> $config
|
||||||
|
*/
|
||||||
|
public static function fromArray(array $config): self
|
||||||
|
{
|
||||||
|
$items = [];
|
||||||
|
foreach ($config as $name => $def) {
|
||||||
|
$items[$name] = new Flag(
|
||||||
|
name: (string)$name,
|
||||||
|
enabled: $def['enabled'] ?? null,
|
||||||
|
rolloutPercentage: $def['rolloutPercentage'] ?? null,
|
||||||
|
allowList: $def['allowList'] ?? []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return new self($items);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, Flag> $flags
|
||||||
|
*/
|
||||||
|
public function __construct(array $flags = [])
|
||||||
|
{
|
||||||
|
$this->flags = $flags;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(string $name): ?Flag
|
||||||
|
{
|
||||||
|
return $this->flags[$name] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return iterable<string, Flag>
|
||||||
|
*/
|
||||||
|
public function all(): iterable
|
||||||
|
{
|
||||||
|
return $this->flags;
|
||||||
|
}
|
||||||
|
}
|
||||||
119
tests/FeatureManagerTest.php
Normal file
119
tests/FeatureManagerTest.php
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace FlagPole\Tests;
|
||||||
|
|
||||||
|
use FlagPole\Context;
|
||||||
|
use FlagPole\FeatureManager;
|
||||||
|
use FlagPole\Repository\InMemoryFlagRepository;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class FeatureManagerTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testExplicitEnabledOverrides(): void
|
||||||
|
{
|
||||||
|
$repo = InMemoryFlagRepository::fromArray([
|
||||||
|
'hard-on' => ['enabled' => true],
|
||||||
|
'hard-off' => ['enabled' => false],
|
||||||
|
]);
|
||||||
|
$fm = new FeatureManager($repo);
|
||||||
|
|
||||||
|
$this->assertTrue($fm->isEnabled('hard-on'));
|
||||||
|
$this->assertFalse($fm->isEnabled('hard-off', null, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAllowListWins(): void
|
||||||
|
{
|
||||||
|
$repo = InMemoryFlagRepository::fromArray([
|
||||||
|
'flag' => [
|
||||||
|
'enabled' => false,
|
||||||
|
'allowList' => ['user_1'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$fm = new FeatureManager($repo);
|
||||||
|
$ctx1 = Context::fromArray(['userId' => 'user_1']);
|
||||||
|
$ctx2 = Context::fromArray(['userId' => 'user_2']);
|
||||||
|
|
||||||
|
$this->assertTrue($fm->isEnabled('flag', $ctx1));
|
||||||
|
$this->assertFalse($fm->isEnabled('flag', $ctx2));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPercentageRolloutIsStable(): void
|
||||||
|
{
|
||||||
|
$repo = InMemoryFlagRepository::fromArray([
|
||||||
|
'gradual' => [
|
||||||
|
'rolloutPercentage' => 10,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$fm = new FeatureManager($repo);
|
||||||
|
$ctx = Context::fromArray(['userId' => 'user_42']);
|
||||||
|
|
||||||
|
$first = $fm->isEnabled('gradual', $ctx);
|
||||||
|
$second = $fm->isEnabled('gradual', $ctx);
|
||||||
|
|
||||||
|
$this->assertSame($first, $second, 'Rollout decision should be stable for same user');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRolloutBoundaryPercentages(): void
|
||||||
|
{
|
||||||
|
$repo = InMemoryFlagRepository::fromArray([
|
||||||
|
'zero' => [ 'rolloutPercentage' => 0 ],
|
||||||
|
'hundred' => [ 'rolloutPercentage' => 100 ],
|
||||||
|
]);
|
||||||
|
$fm = new FeatureManager($repo);
|
||||||
|
$ctx = Context::fromArray(['userId' => 'any_user']);
|
||||||
|
|
||||||
|
$this->assertFalse($fm->isEnabled('zero', $ctx), '0% rollout should always be false when key present');
|
||||||
|
$this->assertTrue($fm->isEnabled('hundred', $ctx), '100% rollout should always be true when key present');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRolloutEdgeCasesOneAndNinetyNinePercent(): void
|
||||||
|
{
|
||||||
|
$repo = InMemoryFlagRepository::fromArray([
|
||||||
|
'one' => [ 'rolloutPercentage' => 1 ],
|
||||||
|
'ninetyNine' => [ 'rolloutPercentage' => 99 ],
|
||||||
|
'hundred' => [ 'rolloutPercentage' => 100 ],
|
||||||
|
]);
|
||||||
|
$fm = new FeatureManager($repo);
|
||||||
|
|
||||||
|
// Find a user that gets bucket < 1 (i.e., enabled at 1%)
|
||||||
|
$foundOne = null;
|
||||||
|
for ($i = 0; $i < 5000; $i++) {
|
||||||
|
$ctx = Context::fromArray(['userId' => 'u'.$i]);
|
||||||
|
if ($fm->isEnabled('one', $ctx)) { // enabled under 1%
|
||||||
|
$foundOne = $ctx;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->assertNotNull($foundOne, 'Should find a user enabled at 1% within a reasonable search space');
|
||||||
|
|
||||||
|
// Find a user that is NOT enabled at 99% but is at 100%
|
||||||
|
$foundFalseAt99 = null;
|
||||||
|
for ($i = 0; $i < 5000; $i++) {
|
||||||
|
$ctx = Context::fromArray(['userId' => 'v'.$i]);
|
||||||
|
if (!$fm->isEnabled('ninetyNine', $ctx) && $fm->isEnabled('hundred', $ctx)) {
|
||||||
|
$foundFalseAt99 = $ctx;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->assertNotNull($foundFalseAt99, 'Should find a user disabled at 99% but enabled at 100%');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPercentageRolloutWithMissingTargetingKeyFallsBackToDefault(): void
|
||||||
|
{
|
||||||
|
$repo = InMemoryFlagRepository::fromArray([
|
||||||
|
'gradual' => [ 'rolloutPercentage' => 50 ],
|
||||||
|
]);
|
||||||
|
$fm = new FeatureManager($repo);
|
||||||
|
|
||||||
|
// No context provided
|
||||||
|
$this->assertFalse($fm->isEnabled('gradual', null, false));
|
||||||
|
$this->assertTrue($fm->isEnabled('gradual', null, true));
|
||||||
|
|
||||||
|
// Empty context (no targeting attributes)
|
||||||
|
$emptyCtx = Context::fromArray([]);
|
||||||
|
$this->assertFalse($fm->isEnabled('gradual', $emptyCtx, false));
|
||||||
|
$this->assertTrue($fm->isEnabled('gradual', $emptyCtx, true));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue