diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c6f06a8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index 49a80d2..91d5e18 100644 --- a/.gitignore +++ b/.gitignore @@ -1,87 +1,8 @@ -# ---> Composer -composer.phar +/.idea/ /vendor/ - -# Commit your application's lock file https://getcomposer.org/doc/01-basic-usage.md#commit-your-composer-lock-file-to-version-control -# You may choose to ignore a library lock file http://getcomposer.org/doc/02-libraries.md#lock-file -# composer.lock - -# ---> JetBrains -# 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 - +/.phpunit.cache/ +/.phpunit.result.cache +/.phpunit.cache/ +composer.lock +/.php-cs-fixer.cache +/.phpstan/ diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..e03632a --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,26 @@ +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, + ]); diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1369274 --- /dev/null +++ b/CHANGELOG.md @@ -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 \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..ab7614a --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -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). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..39b2380 --- /dev/null +++ b/CONTRIBUTING.md @@ -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. diff --git a/README.md b/README.md index f11e61b..4ce23a2 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,82 @@ # FlagPole -Feature Flag handling \ No newline at end of file +Feature flag handling for PHP. Simple, framework-agnostic, and lightweight. + +![CI](https://github.com/phred/flagpole/actions/workflows/ci.yml/badge.svg) +![Packagist](https://img.shields.io/packagist/v/phred/flagpole.svg) + +## 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 \ No newline at end of file diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..3180a0a --- /dev/null +++ b/SECURITY.md @@ -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. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..c6094bb --- /dev/null +++ b/composer.json @@ -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 +} diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..2ceae9a --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,7 @@ +parameters: + level: 8 + paths: + - src + checkGenericClassInNonGenericObjectType: true + checkMissingIterableValueType: true + reportUnmatchedIgnoredErrors: true diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..b37f780 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,11 @@ + + + + + tests + + + diff --git a/src/Context.php b/src/Context.php new file mode 100644 index 0000000..ebe2ada --- /dev/null +++ b/src/Context.php @@ -0,0 +1,44 @@ + */ + private array $attributes; + + /** + * @param array $attributes + */ + public function __construct(array $attributes = []) + { + $this->attributes = $attributes; + } + + /** + * @param array $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 + */ + public function all(): array + { + return $this->attributes; + } +} diff --git a/src/Evaluator.php b/src/Evaluator.php new file mode 100644 index 0000000..f810894 --- /dev/null +++ b/src/Evaluator.php @@ -0,0 +1,60 @@ +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); + } +} diff --git a/src/FeatureManager.php b/src/FeatureManager.php new file mode 100644 index 0000000..0780b46 --- /dev/null +++ b/src/FeatureManager.php @@ -0,0 +1,25 @@ +repository->get($flagName); + if ($flag === null) { + return $default; + } + return $this->evaluator->evaluate($flag, $context, $default); + } +} diff --git a/src/Flag.php b/src/Flag.php new file mode 100644 index 0000000..a983c76 --- /dev/null +++ b/src/Flag.php @@ -0,0 +1,25 @@ + */ + 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'); + } + } + } +} diff --git a/src/Repository/FlagRepositoryInterface.php b/src/Repository/FlagRepositoryInterface.php new file mode 100644 index 0000000..1857ab7 --- /dev/null +++ b/src/Repository/FlagRepositoryInterface.php @@ -0,0 +1,17 @@ + + */ + public function all(): iterable; +} diff --git a/src/Repository/InMemoryFlagRepository.php b/src/Repository/InMemoryFlagRepository.php new file mode 100644 index 0000000..5714d1a --- /dev/null +++ b/src/Repository/InMemoryFlagRepository.php @@ -0,0 +1,54 @@ + */ + private array $flags = []; + + /** + * @param array}> $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 $flags + */ + public function __construct(array $flags = []) + { + $this->flags = $flags; + } + + public function get(string $name): ?Flag + { + return $this->flags[$name] ?? null; + } + + /** + * @return iterable + */ + public function all(): iterable + { + return $this->flags; + } +} diff --git a/tests/FeatureManagerTest.php b/tests/FeatureManagerTest.php new file mode 100644 index 0000000..9952898 --- /dev/null +++ b/tests/FeatureManagerTest.php @@ -0,0 +1,119 @@ + ['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)); + } +}