Initial commit
Some checks are pending
CI / PHP ${{ matrix.php }} (8.1) (push) Waiting to run
CI / PHP ${{ matrix.php }} (8.2) (push) Waiting to run
CI / PHP ${{ matrix.php }} (8.3) (push) Waiting to run

This commit is contained in:
Funky Waddle 2025-12-09 16:48:07 -06:00
parent 759c893bfc
commit b57ef58ea4
18 changed files with 690 additions and 87 deletions

54
.github/workflows/ci.yml vendored Normal file
View 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
View file

@ -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/

26
.php-cs-fixer.dist.php Normal file
View 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
View 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.18.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
View 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
View 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.

View file

@ -1,3 +1,82 @@
# FlagPole
Feature Flag handling
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

7
SECURITY.md Normal file
View 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
View 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
View file

@ -0,0 +1,7 @@
parameters:
level: 8
paths:
- src
checkGenericClassInNonGenericObjectType: true
checkMissingIterableValueType: true
reportUnmatchedIgnoredErrors: true

11
phpunit.xml.dist Normal file
View 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
View 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
View 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
View 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
View 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');
}
}
}
}

View 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;
}

View 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;
}
}

View 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));
}
}