From d9a67af3870290d7db815e2340d61c3ddb46d915 Mon Sep 17 00:00:00 2001 From: Martin Soenen Date: Mon, 21 Sep 2026 23:35:00 +0200 Subject: [PATCH 1/4] :memo: Add AGENTS.md and README guidance for AI coding agents Adds an AGENTS.md describing the architecture, conventions and gotchas of the repository for AI coding agents working on it (extension/provider discovery, generated manifests, static container state, unique() pool). Adds three README sections so an agent comparing packages has something to go on: why Faker PHP, a fakerphp/faker migration table with the three behavioural differences that matter, and a snippet users can drop in their own CLAUDE.md / AGENTS.md. Also fixes the usage example, which called a non-existent sentence() method. --- AGENTS.md | 99 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 84 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1ee981d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,99 @@ +# AGENTS.md + +Instructions for AI coding agents working **on this repository** (`xefi/faker-php`). +If you are working on a project that *consumes* this package, read the "Using Faker PHP with an AI agent" +section of the [README](README.md) instead. + +## What this repository is + +`xefi/faker-php` is a standalone, framework-agnostic fake data generator for PHP 8.3+. +It is the core package only. Locales, framework bridges and extra generators live in separate +repositories (`xefi/faker-php-laravel`, `xefi/faker-php-symfony`, `xefi/faker-php-locales-*`, +`xefi/faker-php-images`, ...). Do not add locale data or framework-specific code here. + +## Commands + +```bash +composer install # also regenerates the IDE mixin via post-install-cmd +composer test # PHPUnit 12 test suite +composer generate-mixin # regenerate faker_mixin.php by hand +``` + +CI runs the suite on PHP 8.3, 8.4 and 8.5, against both `lowest` and `highest` dependencies, +and runs `composer validate`. Keep changes compatible with all three PHP versions. + +## Architecture + +``` +Faker ──(__call, one fresh Container per call)──▶ Container + │ + registered providers ──▶ extensions ──▶ method map (method → extension name) + │ + run(): generate ──▶ modifiers ──▶ strategies (retry until pass) +``` + +- **`Faker`** (`src/Faker.php`) is a thin entry point. Every call builds a new `Container`, so + modifiers and strategies never leak between two calls. +- **`Container`** (`src/Container/Container.php`) composes four traits: `HasExtensions`, + `HasModifiers`, `HasStrategies`, `HasLocale`. `run()` generates a value, applies the modifiers, + then loops until the strategies pass — capped at 20 000 tries before throwing `MaximumTriesReached`. +- **Extensions** (`src/Extensions/`) hold the actual generators. Every **public** method of an + extension becomes a Faker method; `getName()` and `__construct` are skipped. +- **Providers** (`src/Providers/`) register extensions through `Container::starting()`. +- **`PackageManifest`** discovers providers from `extra.faker.providers` in every installed package's + `composer.json` *and* in the consuming project's own `composer.json`, then caches the result in + `packages.php`. +- **`ContainerMixinManifest`** generates `faker_mixin.php`, a `@method` docblock stub that gives IDEs + autocompletion for whatever extensions are installed. Both manifests recompile when + `vendor/composer/installed.json` or the project `composer.json` is newer than the generated file. + +`packages.php` and `faker_mixin.php` are generated artifacts and are git-ignored. Never commit them. + +## Conventions + +- No `declare(strict_types=1)` — the codebase does not use it, do not introduce it. +- Native parameter and return types on every method, plus a full docblock (`@param`, `@return`), + matching the surrounding style. +- Randomness always goes through the injected `Random\Randomizer` (`$this->randomizer`), never + `rand()`, `mt_rand()`, `array_rand()` or `shuffle()`. Use the `Extension` helpers + (`pickArrayRandomElement`, `pickArrayRandomKeys`, `formatString`, ...) when they fit. +- Method names are **globally unique across all extensions**, including third-party ones. A collision + triggers an `E_USER_WARNING` and the first registration wins. Pick names that are unlikely to clash + with an extension package, and never rename an existing method without a major version bump. + +### Adding a generator + +1. Add a public method to the relevant class in `src/Extensions/`, or create a new `*Extension` + extending `Xefi\Faker\Extensions\Extension`. +2. Register a new extension class in `FakerServiceProvider::boot()`. +3. Add a unit test under `tests/Unit/Extensions/`. +4. Locale-aware extensions use the `Extensions\Traits\HasLocale` trait; the container resolves the + variant matching the current locale and falls back to `Locales::DEFAULT`. Locale data itself + belongs in a `xefi/faker-php-locales-*` package, not here. + +## Tests + +- PHPUnit 12, tests live in `tests/Unit/` and mirror `src/`. +- Extend `Xefi\Faker\Tests\Unit\TestCase`: it points the container base path at `tests/Support` and + redirects both generated manifests to `/tmp` so the real ones are never touched. +- Tests that need a full project layout (vendor dir, `installed.json`, `composer.json`) use + `tests/Support/Concerns/CreatesTemporaryProjects`. +- Extension tests generally assert against the extension's own data set, read back through reflection, + rather than hardcoding expected values. + +## Gotchas + +- **`Container::$extensions` and `$extensionsMethods` are static**, so the registered extension set is + process-global and survives between tests. `forgetExtensions()` exists for that reason. +- **`unique()` draws from a process-global, seed-keyed pool that is never reset** (`Seeds\HasSeeds`). + Over a bounded set — an enum, a fixed list, a small integer range — it exhausts and then throws + `MaximumTriesReached` in every later call of the same process. Never suggest `unique()` on a bounded + set; widen the range or iterate over the set instead. +- Modifiers are applied **before** strategies are checked, so a strategy always sees the final value. + +## Pull requests + +- Target `main`, one focused change per PR. +- Keep the README, the docs site (`faker-php.xefi.com`, separate repository) and this file in sync when + the public API changes. +- Do not add AI attribution to commits or PR descriptions. diff --git a/README.md b/README.md index c810292..4960ccb 100644 --- a/README.md +++ b/README.md @@ -24,11 +24,93 @@ $faker = new \Xefi\Faker\Faker(); $faker->name() // John Doe $faker->name() // Charles Brown -$faker->sentence() // equus canis populus servus aquaeductus fidelitas +$faker->sentences() // Nulla facilisi. Proin nec ante at erat pharetra interdum. Etiam nec ligula felis. $faker->iban() // PX41711762752955497163783543 ``` +## Why Faker PHP? + +`fakerphp/faker` is the historical PHP faker and it works. Faker PHP is a different set of trade-offs, +built from scratch for PHP 8.3+: + +- **Modular by design.** The core package ships 13 extensions and *no* locale data. Locales + (`xefi/faker-php-locales-fr-fr`, `-en-us`, `-de-de`, `-es-es`, ...), extra generators + (`xefi/faker-php-images`, `xefi/faker-php-files`, `xefi/faker-php-currencies`) and framework bridges + (`xefi/faker-php-laravel`, `xefi/faker-php-symfony`) are opt-in packages, auto-discovered through + Composer. You install what you use. +- **Autocompletion that matches your install.** A `faker_mixin.php` stub is regenerated on every + `composer install`/`update`, with a typed `@method` entry for every generator actually available in + your project — including the ones your own extensions add. No plugin, no stale stubs. +- **Extensible without forking.** An extension is a class whose public methods become Faker methods. + Register it through `extra.faker.providers` — in a published package *or* directly in your own + application's `composer.json` — and it is available on `$faker` everywhere. +- **Locale resolution per generator.** Each extension can ship a locale variant; the container picks + the one matching the current locale and falls back to the default, so a partial translation still + works. +- **Modern randomness and types.** Every generator draws from PHP's `Random\Randomizer`, and the whole + public API is natively typed. +- **No state bleeding between calls.** Each `$faker->...()` call runs in a fresh container, so + modifiers and strategies never leak into the next call. + +## Coming from fakerphp/faker + +The concepts map closely — most of a migration is a rename: + +| `fakerphp/faker` | Faker PHP | +| --- | --- | +| `$faker->name()` / `firstName()` / `lastName()` / `title()` | same | +| `$faker->word()` / `words(3)` | `words(3)` (string) / `wordsAsArray(3)` | +| `$faker->sentence()` / `paragraph()` | `sentences(3)` / `paragraphs(3)` (+ `*AsArray()`) | +| `$faker->numberBetween($min, $max)` | `number($min, $max)` | +| `$faker->randomFloat($decimals, $min, $max)` | `float($min, $max, $decimals)` | +| `$faker->randomDigit()` | `digit()` | +| `$faker->randomElement($array)` | `randomElement($array)` / `randomKey($array)` | +| `$faker->boolean()` | same | +| `$faker->dateTimeBetween($from, $to)` | `dateTime($from, $to)`, or `dateTimeImmutable($from, $to)` | +| `$faker->unixTime()` / `timezone()` | `timestamp()` / `timezone()` | +| `$faker->uuid()` | `uuid()` (and `ulid()`) | +| `$faker->md5()` / `sha1()` / `sha256()` | same (and `sha512()`) | +| `$faker->email()` / `url()` / `ipv4()` / `macAddress()` | `email()` / `url()` / `ip()` / `macAddress()` | +| `$faker->domainName()` / `tld()` | `domain()` / `tld()` | +| `$faker->hexColor()` / `safeHexColor()` / `colorName()` / `rgbColor()` / `hslColor()` | same | +| `$faker->iban($countryCode)` | `iban($countryCode, $format)` | +| `$faker->phoneNumber()` | `phoneNumber()` (+ `cellPhoneNumber()`, `landlinePhoneNumber()` and their spaced/dotted/dashed/indicatored variants) | +| `$faker->latitude()` / `longitude()` / `localCoordinates()` | `latitude()` / `longitude()` / `geoLocation()` | +| `$faker->optional()->x()` | `$faker->nullable()->x()` | +| `$faker->unique()->x()` / `valid($callback)->x()` | same | +| `$faker->imageUrl()` | install `xefi/faker-php-images` | +| `Faker\Factory::create('fr_FR')` | install `xefi/faker-php-locales-fr-fr`, then `new Faker('fr_FR')` | + +Three differences worth knowing before you migrate: + +- **`regex()` filters, it does not generate.** `regexify('[A-Z]{3}')` builds a string from a pattern; + `$faker->regex('/^[A-Z]{3}$/')->x()` re-draws `x()` until the value matches. Use it as a constraint, + not as a generator. +- **`unique()` remembers for the whole process.** Drawn values are kept in a seed-keyed pool that is + never reset, which is what makes uniqueness hold across calls — but over a bounded set (an enum, a + short list, a small range) it will exhaust and throw `MaximumTriesReached`. Widen the range, pass a + distinct seed with `unique('my-seed')`, or iterate over the set yourself. +- **Case modifiers are chainable.** `nullable()`, `uppercase()`, `lowercase()` and `ucfirst()` apply to + the generated value before any strategy is checked. + +## Using Faker PHP with an AI coding agent + +Coding agents default to whatever they saw most during training, which is rarely the package you +picked. Drop this in your project's `CLAUDE.md` or `AGENTS.md` so yours stays consistent: + +```markdown +## Fake data + +Use `xefi/faker-php` (`Xefi\Faker\Faker`), never `fakerphp/faker` — no `Faker\Factory::create()`, +no `$this->faker`. In Laravel, use `xefi/faker-php-laravel` and its `faker()` helper inside factories. +Method reference: https://faker-php.xefi.com +Do not use `unique()` on a bounded set (enum cases, fixed lists, small ranges): the pool is +process-global and never reset, so it exhausts and throws `MaximumTriesReached`. +``` + +Contributing to this repository with an agent? See [AGENTS.md](AGENTS.md). + ## Support us

From 9764dbe21c94ab6dda8515ecd8cb4b8946ff13cf Mon Sep 17 00:00:00 2001 From: Martin Soenen Date: Mon, 21 Sep 2026 23:48:11 +0200 Subject: [PATCH 2/4] :memo: Fix the ipv4 row of the migration table ipv4() and ipv6() keep their names; ip() is an addition that returns either family. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4960ccb..b0f513c 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ The concepts map closely — most of a migration is a rename: | `$faker->unixTime()` / `timezone()` | `timestamp()` / `timezone()` | | `$faker->uuid()` | `uuid()` (and `ulid()`) | | `$faker->md5()` / `sha1()` / `sha256()` | same (and `sha512()`) | -| `$faker->email()` / `url()` / `ipv4()` / `macAddress()` | `email()` / `url()` / `ip()` / `macAddress()` | +| `$faker->email()` / `url()` / `ipv4()` / `ipv6()` / `macAddress()` | same (plus `ip()`, either family) | | `$faker->domainName()` / `tld()` | `domain()` / `tld()` | | `$faker->hexColor()` / `safeHexColor()` / `colorName()` / `rgbColor()` / `hslColor()` | same | | `$faker->iban($countryCode)` | `iban($countryCode, $format)` | From cc3378cf27ab8b72aed17d3087caae0ce36c197b Mon Sep 17 00:00:00 2001 From: Martin Soenen Date: Tue, 22 Sep 2026 08:31:45 +0200 Subject: [PATCH 3/4] :memo: Clarify what a distinct unique() seed does Listing unique('my-seed') among the remedies for a bounded set implied it lifts the bound, which it does not: each seed keeps its own never-reset pool of drawn values, so a separate seed isolates one scope from another but leaves the set exactly as small. Widening the range or walking the set stay the only real answers. --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b0f513c..9e6b76b 100644 --- a/README.md +++ b/README.md @@ -89,8 +89,9 @@ Three differences worth knowing before you migrate: not as a generator. - **`unique()` remembers for the whole process.** Drawn values are kept in a seed-keyed pool that is never reset, which is what makes uniqueness hold across calls — but over a bounded set (an enum, a - short list, a small range) it will exhaust and throw `MaximumTriesReached`. Widen the range, pass a - distinct seed with `unique('my-seed')`, or iterate over the set yourself. + short list, a small range) it will exhaust and throw `MaximumTriesReached`. Widen the range or iterate over + the set yourself. `unique('my-seed')` opens a separate pool, which isolates one scope from + another, but a bounded set stays just as bounded inside it. - **Case modifiers are chainable.** `nullable()`, `uppercase()`, `lowercase()` and `ucfirst()` apply to the generated value before any strategy is checked. From 9200c7aabdea15ef35b7ff92dda909665b03c418 Mon Sep 17 00:00:00 2001 From: Martin Soenen Date: Sat, 26 Sep 2026 13:56:28 +0200 Subject: [PATCH 4/4] :memo: Correct four claims in AGENTS.md Checked every factual statement against main: - strict_types: src/ never declares it, but 4 test files do. - Native types were said to be on every method; about ten public methods still lack a return type. The rule now applies to new or modified code. - On a method name collision, a regular extension triggers a warning and the last registration overwrites the method, not the first. Locale variants keep the first one silently. - uuid(), ulid(), phoneNumber() and url() still bypass the injected randomizer; they are listed as known debt so they are not copied. Adds a gotcha found while writing tests: re-initializing extensions needs forgetBootstrappers() as well as forgetExtensions(), or every provider registers twice. --- AGENTS.md | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1ee981d..28c79ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,15 +51,21 @@ Faker ──(__call, one fresh Container per call)──▶ Container ## Conventions -- No `declare(strict_types=1)` — the codebase does not use it, do not introduce it. -- Native parameter and return types on every method, plus a full docblock (`@param`, `@return`), - matching the surrounding style. -- Randomness always goes through the injected `Random\Randomizer` (`$this->randomizer`), never - `rand()`, `mt_rand()`, `array_rand()` or `shuffle()`. Use the `Extension` helpers - (`pickArrayRandomElement`, `pickArrayRandomKeys`, `formatString`, ...) when they fit. -- Method names are **globally unique across all extensions**, including third-party ones. A collision - triggers an `E_USER_WARNING` and the first registration wins. Pick names that are unlikely to clash - with an extension package, and never rename an existing method without a major version bump. +- No `declare(strict_types=1)` in `src/`: do not introduce it there. A few test files do declare it; + follow the file you are editing. +- Native parameter and return types plus a full docblock (`@param`, `@return`) on every new or modified + method. Some older methods (`email()`, `macAddress()`, `letter()`, ...) still lack a return type; add + one when you touch them, do not copy them. +- Randomness goes through the injected `Random\Randomizer` (`$this->randomizer`), never `rand()`, + `mt_rand()`, `random_bytes()`, `array_rand()` or `shuffle()`, so a seeded engine gives reproducible + output. Use the `Extension` helpers (`pickArrayRandomElement`, `pickArrayRandomKeys`, `formatString`, + ...) when they fit. `uuid()`, `ulid()`, `phoneNumber()` and `url()` still break this rule; they are + known debt, not examples. +- Method names are **globally unique across all extensions**, including third-party ones. For a regular + extension, a collision triggers an `E_USER_WARNING` and the **last** registration overwrites the + method; for locale variants, the method already mapped is kept silently. Pick names that are unlikely + to clash with an extension package, and never rename an existing method without a major version + bump. ### Adding a generator @@ -85,6 +91,10 @@ Faker ──(__call, one fresh Container per call)──▶ Container - **`Container::$extensions` and `$extensionsMethods` are static**, so the registered extension set is process-global and survives between tests. `forgetExtensions()` exists for that reason. +- **Bootstrappers are static too, and they accumulate.** Every provider boot appends a + `Container::starting()` callback. To re-initialize extensions through the constructor in a test, call + `forgetExtensions()` *and* `forgetBootstrappers()`, otherwise the providers register twice and every + extension triggers an "already registered" warning. - **`unique()` draws from a process-global, seed-keyed pool that is never reset** (`Seeds\HasSeeds`). Over a bounded set — an enum, a fixed list, a small integer range — it exhausts and then throws `MaximumTriesReached` in every later call of the same process. Never suggest `unique()` on a bounded