Foreign keys in definition()
definition() is for the entity's own scalar columns — title, street, price, the entity's own uuid. Foreign-key columns belong somewhere else: in the ->with('Alias'), ->for(), or factory-helper layer that composes the related parent.
Returning a FK column from definition() is a smell. The plugin detects it at runtime and emits an E_USER_DEPRECATED pointing at the offending column. The check is on by default; opt out per the configuration reference while you migrate.
Why it matters
Consider this factory:
class ArticleFactory extends BaseFactory
{
protected function getRootTableRegistryName(): string
{
return 'Articles';
}
public function definition(GeneratorInterface $generator): array
{
return [
'title' => $generator->sentence(),
'author_id' => $generator->numberBetween(1, 100), // smell
];
}
}Two failure modes hide here:
1. Dangling FK when no parent is composed. ArticleFactory::new()->save() stores author_id = 47. Row 47 in authors may or may not exist. The test passes; the next test joining Articles → Authors finds no match, mid-flight, and the cause looks like anything but a bad default.
2. Silently overwritten when a parent IS composed. ArticleFactory::new()->with('Author', $author)->save() persists $author, gets its real id back, and writes that to author_id. The numberBetween() line is now dead code — but it lies about intent. A reader sees author_id in definition() and assumes it does something. Removing it would change no behavior, but you have to prove that line-by-line.
Both shapes hide the real source of the FK. The detector flags the line whether ->with() is present or not.
The fix
Drop the FK column from definition(). Compose the parent in configure() for the always-on case, or expose a helper for the sometimes-on case.
class ArticleFactory extends BaseFactory
{
protected function getRootTableRegistryName(): string
{
return 'Articles';
}
public function definition(GeneratorInterface $generator): array
{
return [
'title' => $generator->sentence(),
];
}
protected function configure(): static
{
return $this->forAuthor();
}
public function forAuthor(mixed $parameter = null): static
{
return $this->for(AuthorFactory::new($parameter));
}
}Now ArticleFactory::new()->save() builds and persists a real Author, and author_id points at that author's id. ArticleFactory::new()->forAuthor($existingAuthor) reuses one you already have. No dangling ids, no dead defaults.
When you genuinely need a literal id
Sometimes the test wants a specific orphan id — to assert how the code handles a parent that disappeared, for instance. Don't put that in definition(). Make it explicit at the call site, or behind a named helper:
public function withOrphanAuthorId(): static
{
return $this->state(['author_id' => 999999]);
}The unusual case is now named, traceable, and visible. Tests that don't call withOrphanAuthorId() get a real composed parent, not a silent default that points at nothing.
What the detector flags
The detector inspects the belongsTo associations declared on the factory's root table and builds a map of foreign_key_column → association_alias. Any column returned from definition() that matches a FK column triggers the deprecation — once per (factory class, column) per process, so a smelly factory called 10,000 times produces exactly one message.
Non-FK *_id columns (external system ids, enum-like status codes, anything not declared as a belongsTo FK) are not flagged. The detector consults the schema, not column-name patterns.
Migration cookbook
For each factory the detector flags:
- Identify the FK column it named and the matching association alias.
- Remove the column from
definition(). - Add a
forFoo()/withFoo()helper if one doesn't exist. - Decide whether the association should be auto-composed in
configure()(always-on parent) or stay opt-in at the call site (sometimes-on parent). - Update tests that previously asserted on the random FK value — they should now assert against the composed parent's real id.
Pinning the FK at the call site Just Works
The detector steers FK population out of definition() and into association composition. The natural follow-up question is: if the parent is now composed in configure(), how do I still test "this row belongs to that specific parent"? Historically you had to add a manual ->without('Alias') next to every Factory::new(['foo_id' => $x]), because the composed parent's id would otherwise overwrite the explicit FK.
That manual ->without() is no longer needed. With autoSkipComposeOnExplicitForeignKey (default true), explicitly providing the FK at the call site automatically suppresses the configure()-composed parent for that build, so the explicit value wins:
// Factory composes the parent in configure()->with('Author').
// Pinning author_id at the call site auto-skips that composition:
$article = ArticleFactory::new(['author_id' => $author->id])->save();
// $article->author_id === $author->id (no throw-away Author row)An explicit ->with('Author', ...) still always composes — that is an unambiguous request for a parent and wins over the auto-skip. Turn the flag off only if a suite relied on the old override behavior.
The ergonomic counterpart: withRequiredParents()
Steering FK population out of definition() is correct, but on its own it leaves a cliff: a factory whose root table has NOT NULL belongsTo FKs now needs explicit ->with('Alias') / ->for(...) boilerplate just to persist one row.
withRequiredParents() closes that cliff. One call composes every belongsTo whose single scalar FK column is NOT NULL, recursively down the chain:
$author = AuthorFactory::new()->withRequiredParents()->save();
// address_id → city_id → country_id all satisfied, no boilerplate.It is the pragmatic default for "I just need a persistable row". When a test asserts on a specific parent, keep attaching it explicitly with ->with('Alias', $entity) so the intent stays visible. Composite-key and foreignKey => false associations are never guessed — see the Required parents guide for the override hook and the shared-ancestor recycle default.
Opt-out while migrating
If a downstream suite has many offenders and can't migrate in one pass:
// tests/bootstrap.php
Configure::write('FixtureFactories.strictDefinition', false);The opt-out silences the detector entirely. It is transitional — the next major release removes the flag and promotes the deprecation to an exception. Plan the migration before then; the detector's report tells you exactly which factories and columns to touch.
Per-factory exception for non-managed join columns
strictDefinition exists to stop a dangling managed foreign-key id from masking a real composed parent. A foreignKey => false custom-condition belongsTo (for example a uuid-condition join, Child.parent_uuid = Parents.uuid) has a column CakePHP never manages — there is no managed pointer to dangle, so a generated value there is not that anti-pattern, yet the detector still recognises it as a join column.
For those columns only, a factory can declare the column intentional instead of switching the detector off globally:
protected function allowedForeignKeysInDefinition(): array
{
return ['parent_uuid'];
}The detector skips listed columns for that factory while staying fully active for every other factory and for every genuinely managed FK. Reserve it for non-managed condition-join columns — listing a managed FK column there defeats the check.