diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..28c79ab --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,109 @@ +# 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)` 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 + +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. +- **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 + 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..9e6b76b 100644 --- a/README.md +++ b/README.md @@ -24,11 +24,94 @@ $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()` / `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)` | +| `$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 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. + +## 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