How to migrate from Faker
This guide is written for a coding agent rewriting a codebase that uses Faker (PHP) or a Faker-alike so it emits Phony definitions instead. It is a mechanical, rule-based mapping: each Faker call has a Phony equivalent expressed in PGDL/PEL.
Intended mapping — the PHP port is not shipped yet
There is no drop-in Faker-to-Phony PHP library today. The Phony engine and CLI are real and everything below runs, but the PHP binding that would let you call Phony where you call Faker is not built yet. Treat this page as the intended mapping: the correct Phony construct for each Faker idiom, so a port lands on the right target. Don't claim a shipped PHP adapter.
The one rule that changes everything: determinism
Faker and Phony both take a seed, but they seed at different granularities, and this is the single most important thing to get right in a port.
- Faker seeds one global, mutable PRNG (
$faker->seed(1234)). Values come out in call order, so adding, removing, or reordering a field shifts every value after it. Reproducibility is fragile. - Phony derives each value's randomness from
(root seed, generator name, invocation index). A field's value depends on its own identity, not on how many fields came before it. Reorder fields, add a column, split a generator — the other values don't move. The root seed is a CLI flag (--seed 1234), not a mutable call.
Porting implication: you do not need to preserve call order. Map each Faker call to the Phony construct for that field and let the engine seed it independently.
The mapping table
Braces below are PEL inline generators; in a real definition they live inside a composition body/output or a field template. list sources may be inline values or a locale asset.
| Faker (PHP) | Phony equivalent | Notes |
|---|---|---|
numberBetween(1, 100) | {{ number:1-100 }} | or { "type": "logic", "algorithm": "int_between", "params": { "min": 1, "max": 100 } } |
randomNumber() | logic.int_between over your range | Faker's default is 0–~2³¹; always pick an explicit range |
randomFloat(2, 0, 100) | {{ number:0.0-100.0:2 }} | trailing :2 = decimal places; or logic.float_between |
randomDigit() | {{ number:0-9 }} | |
randomElement(['a','b','c']) | {{ pick(['a','b','c']) }} | or { "type": "list", "source": "inline", "values": [...] } |
randomElements($arr, 3) | {{ sample(list, 3) }} | n unique elements; sample(list, "1-3") for a range |
boolean(70) | {{ pick_weighted([{value: true, weight: 70}, {value: false, weight: 30}]) }} | 70% true |
firstName() | { "type": "model", "source": "person.first_names", "generation": { "mode": "word" } } | needs a locale package that ships the model asset — see locales |
lastName() | { "type": "model", "source": "person.last_names", "generation": { "mode": "word" } } | as above |
name() | composition: let a first + last, then {{ concat(first, ' ', last) }} | see coherent fields |
email() / safeEmail() | composition: {{ concat(lowercase(first), '.', lowercase(last), '@example.com') }} | derive from the same name let so it agrees |
city() / country() | { "type": "list", "source": "geo.cities" } | a locale asset; pick a coherent record for city+country (see below) |
dateTimeBetween('-1 year', 'now') | {{ datetime:-1year..now }} | relative to the frozen reference clock, not the wall clock |
date('Y-m-d') | {{ date:2020-01-01..2024-12-31 }} | |
time('H:i') | {{ time:09:00..18:00 }} | |
uuid() | {{ uuid }} | also {{ uuid:v7 }}, {{ ulid }}, {{ nanoid }} |
numerify('###-###') | {{ pattern:###-### }} | # = digit |
bothify('??-####') | {{ pattern:??-#### }} | ? = letter, # = digit, @ lower, ! upper, * alnum |
lexify('????') | {{ pattern:???? }} | |
slug() | {{ slugify(title) }} | over a value you already have |
unique()->numberBetween(...) | add the modifier "unique": true to the generator | distinct across the run |
optional(0.9)->... | add "nullable": 0.1 | Phony's rate is the null probability; Faker's optional($w) returns null with probability 1 − $w |
passthrough($x) / a literal | a literal in the template or output | |
seed(1234) | the CLI flag --seed 1234 | one root seed for the whole run |
Worked example — a Faker factory, ported
A typical Faker user factory:
$faker->seed(42);
[
'id' => $faker->unique()->numberBetween(1000, 9999),
'first' => $faker->firstName(),
'email' => strtolower($faker->firstName()).'@example.com', // BUG: unrelated name
'plan' => $faker->randomElement(['free','pro','enterprise']),
'created_at' => $faker->dateTimeBetween('-1 year', 'now'),
'ref' => $faker->bothify('??-####'),
];Note the classic Faker bug on line 4: the email's name is a fresh draw, so it never matches first. Phony makes coherence the default — let the name once and derive both fields from it. The ported composition (users/phony.json):
{
"name": "@app/users",
"version": "0.1.0",
"generators": [
{ "name": "@app/users:user",
"let": {
"id": { "type": "logic", "algorithm": "int_between", "params": { "min": 1000, "max": 9999 }, "unique": true },
"first": { "type": "list", "source": "inline", "values": ["Ada","Grace","Alan","Linus"] },
"plan": { "type": "list", "source": "inline", "values": [
{ "value": "free", "weight": 70 }, { "value": "pro", "weight": 25 }, { "value": "enterprise", "weight": 5 } ] }
},
"output": {
"id": "{{ id }}",
"first": "{{ first }}",
"email": "{{ concat(lowercase(first), '@example.com') }}",
"plan": "{{ plan }}",
"created_at": "{{ datetime:-1year..now }}",
"ref": "{{ pattern:??-#### }}"
} }
]
}phony generate --use @app/users:user --package ./users --seed 42 --reference-time 1717200000 -n 2[
{
"created_at": "2024-04-13T21:59:03Z",
"email": "grace@example.com",
"first": "Grace",
"id": 8214,
"plan": "free",
"ref": "kp-1846"
},
{
"created_at": "2023-11-30T12:14:36Z",
"email": "linus@example.com",
"first": "Linus",
"id": 5771,
"plan": "free",
"ref": "jr-4093"
}
]The email now always matches first, id is unique across the run, and created_at is reproducible because it resolves against the injected --reference-time, not the system clock.
What is not 1:1 yet
Be honest in a port — these have no clean equivalent today; flag them rather than inventing one:
regexify($pattern)— arbitrary-regex generation has no Phony equivalent. Re-express the intent as apattern(placeholder) string, or alistof realistic values.- Rich localized providers (
company(),catchPhrase(),realText(),address()as one blob) — Phony builds these from assets (lists/models in a locale package) plus a composition, not a single call. Port them to a composition that reads the right asset; where no asset exists yet, use an inlinelistof representative values. - Coherent multi-field providers (a matching city + state + postal) — draw one record from a
listof objects and read its properties, so the fields can't disagree. See coherent fields. - Stateful/sequential helpers — Faker's
unique()state maps to the"unique": truemodifier; there is no equivalent for reaching across rows or into another record (that's a cloud-product concern, not a generator's).
One-line instruction for your agent config
Drop this into CLAUDE.md / AGENTS.md so an agent working in the repo ports consistently:
When replacing Faker: map each `$faker->call()` to its Phony PGDL/PEL equivalent
(numberBetween→logic.int_between or {{number:a-b}}, randomElement→list/pick,
dateTimeBetween→{{datetime:..}}, unique()→"unique":true, optional($w)→"nullable":1-$w,
seed()→--seed). Never carry over call order — Phony seeds each field by its own
identity. Derive dependent fields (email from name, total from price) from a shared
`let` so they stay coherent, instead of drawing them independently.