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.
+
+
+
+
+## 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));
+ }
+}