Skip to content

[12.x] Add Eloquent Factory only() to give callers fine-grained control over which related factories are expanded in tests#59200

Open
forrestedw wants to merge 7 commits intolaravel:12.xfrom
forrestedw:12.x-eloquent-factory-only
Open

[12.x] Add Eloquent Factory only() to give callers fine-grained control over which related factories are expanded in tests#59200
forrestedw wants to merge 7 commits intolaravel:12.xfrom
forrestedw:12.x-eloquent-factory-only

Conversation

@forrestedw
Copy link

What this PR does

Adds an only() method to Illuminate\Database\Eloquent\Factories\Factory that gives callers fine-grained control over which related factories are expanded when building or creating models in tests. It also ships two opt-in global settings — Factory::onlyByDefault() and Factory::inferRequiredForeignKeys() — that make the behaviour automatic across a whole test suite.

Problem

Every factory definition() that includes a BelongsTo factory (e.g. 'user_id' => UserFactory::new()) will create that parent model unconditionally, even when the test being written doesn't care about it. On large schemas this silently creates many rows, pollutes assertions, slows down test suites, and produces hard-to-debug failures caused by unexpected related data. withoutParents() exists but is all-or-nothing; there was previously no way to say "create only this specific set of relations and ignore the rest".

Solution

->only(...$relationships) — whitelist which related factories in the definition should be expanded. All other factory-typed attributes are set to null; scalar attributes are left untouched so that states applied before or after only() are never clobbered.

// Only create the post's author — do not create any other related models.
$post = PostFactory::new()->only('author')->make();

// Dot-notation to restrict a nested factory too.
$post = PostFactory::new()->only('author.team')->make();

// No args = strip everything.
$post = PostFactory::new()->only()->make();

States always take precedence, regardless of chain order. The method is implemented as a first-class factory property (not another state closure), so definition() is called exactly once and PHP object identity (===) reliably distinguishes a definition-default factory from one explicitly set by a state:

// All three preserve the state's value.
PostFactory::new()->state(['user_id' => 99])->only()->make();
PostFactory::new()->state(['user_id' => UserFactory::new()])->only()->make();
PostFactory::new()->only()->state(['user_id' => UserFactory::new()])->make();

$required property — per-factory class override for relations that must always survive only(), regardless of what the caller passes:

class OrderFactory extends Factory
{
    protected $required = ['customer']; // always created, even with ->only()
}

Factory::onlyByDefault() — activates only() globally so factories strip unwhitelisted relations without any per-factory call. Equivalent to implicitly calling ->only() on every factory. The $required property is still respected. Configurable via config/database.php:

// AppServiceProvider::boot()
Factory::onlyByDefault();
// or config: DB_FACTORY_ONLY_BY_DEFAULT=true

Factory::inferRequiredForeignKeys() — when only() is active (explicitly or globally), inspects the database schema to find BelongsTo relationships whose FK column is NOT NULL and automatically adds them to the whitelist. This means truly required parents are never accidentally stripped. Schema results are cached per model class for the lifetime of the process — exactly one getColumns() call per model class per test suite run, regardless of how many factories or assertions use that model. Configurable:

Factory::inferRequiredForeignKeys();
// or config: DB_FACTORY_INFER_REQUIRED_FOREIGN_KEYS=true

Per-factory resolver callbacks — for cases where the global flags need different behaviour on a factory-by-factory basis:

Factory::resolveOnlyByDefaultUsing(
    fn ($factory) => $factory instanceof MyBaseFactory
);

Factory::resolveInferRequiredForeignKeysUsing(
    fn ($factory) => ! ($factory instanceof LegacyFactory)
);

Resolution order for each flag: resolver callback → static flag → config → false.

Why this does not break existing features

  • only() is purely additive; no existing call sites are changed.
  • onlyByDefault and inferRequiredForeignKeys default to false (both in the static property and in config/database.php), so existing applications are entirely unaffected unless they opt in.
  • flushState() resets all new static properties and resolvers, matching the existing pattern used by expandRelationshipsByDefault. The schema cache ($schemaRequiredForeignKeys) is intentionally not reset — the schema does not change between tests.
  • The applyOnlyFilter step runs after all states have been accumulated, so every existing chaining pattern (->state(), ->for(), ->has(), ->recycle(), ->count(), ->sequence(), ->withoutParents()) continues to behave exactly as before when only() is not involved.

Database support

Schema inspection uses SchemaBuilder::getColumns(), which normalises the nullable flag across all four Laravel-supported drivers: MySQL/MariaDB, PostgreSQL, SQLite, and SQL Server. If the connection is unavailable or the driver does not support column introspection, resolveRequiredForeignKeyRelations() fails gracefully and returns [], leaving the whitelist unchanged.

Defining required foreign keys directly on models and in tests is generally preferable. Factory::inferRequiredForeignKeys() is provided as a convenience for codebases adopting only() via Factory::onlyByDefault() — it means existing tests don't all need updating at once, giving a "fastest path to faster tests" on legacy codebases.

The more likely long-term approach is to configure requirements per model or test as needed. This doesn't take long even on a large codebase: $required only needs defining once per factory, failures are loud and obvious, and the per-test cases are easy to handle with a Foo::factory() → Foo::factory()->only('bar') find-and-replace.

forrestedw and others added 3 commits March 14, 2026 09:35
Introduces Factory::only(...$relationships) to selectively include factory-defined
relationships while nulling out all others. Supports dot-notation for nested
relationship control, a $required property for always-included relationships,
and respects existing withoutParents()/for() behaviour.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
…order

States — whether they set a concrete FK, a factory instance, or a scalar —
must always be respected, regardless of whether they are applied before or
after only().

The previous state-based approach (building only() as a state closure)
ran in the middle of the state queue and could not distinguish a factory
set by a prior state from the definition's own factory default, because
the initial carry in getRawAttributes() *is* definition() and both
values are factory objects.

Fix: make only() a first-class factory property (like expandRelationships)
and apply the filter in getRawAttributes() after all states have run.
Because definition() is now called exactly once, its factory instances
can be used as strict-identity sentinels:

  $attrs[$key] === $definition[$key]  →  definition default, no state touched it
  $attrs[$key] !== $definition[$key]  →  state (or for()) explicitly set it → preserve

The separate $forAttributes guard is removed: parentResolvers() always
runs first, so for() FK values are already concrete (≠ the definition
factory) by the time applyOnlyFilter() runs.

Adds factory-in-state tests for both orderings (before and after only()).

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
… factories

Two new opt-in settings on Factory, each resolvable via a custom callback,
a static flag, or config/database.php (precedence in that order, default false):

1. only_by_default / Factory::onlyByDefault() / Factory::resolveOnlyByDefaultUsing()
   Applies only() globally to every factory without needing to chain it.
   The $required property per factory class is still respected.

2. infer_required_foreign_keys / Factory::inferRequiredForeignKeys() / Factory::resolveInferRequiredForeignKeysUsing()
   When only() is active, automatically adds BelongsTo relationships whose FK
   column is NOT NULL in the schema to the effective whitelist. Uses
   SchemaBuilder::getColumns() — works across MySQL, MariaDB, PostgreSQL,
   SQLite, and SQL Server. Results are cached per model class for the process
   lifetime (not cleared by flushState()) so one DB query covers the whole
   test suite run. Fails gracefully when the schema cannot be inspected.

Resolution order for each flag:
  resolver callback > static property > config > false (hardcoded default)

The static properties are ?bool: null defers to config/resolver. flushState()
resets statics and resolvers to null; the schema cache is left intact.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
@github-actions
Copy link

Thanks for submitting a PR!

Note that draft PRs are not reviewed. If you would like a review, please mark your pull request as ready for review in the GitHub user interface.

Pull requests that are abandoned in draft may be closed due to inactivity.

forrestedw and others added 3 commits March 14, 2026 11:06
Wraps config() calls in a try/catch via resolveFactoryConfig() so factories
work in bare PHPUnit setups where no application container is bound.
Extracts flag resolution into isOnlyByDefault() and shouldInferRequiredForeignKeys().

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
…actory attribute

Add #[UseFactory(FactoryTestUserFactory::class)] to FactoryTestUser so that
FactoryTestUser::factory() resolves correctly regardless of test execution
order. Previously the tests implicitly depended on a guessFactoryNamesUsing
resolver registered by an earlier test persisting via static state; now that
Factory::flushState() is called in tearDown() this implicit dependency broke
in alphabetical test-run order.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
…tions

Limit reflection-based method iteration to methods declared on user-defined
model classes (the model itself or subclasses of Model), excluding built-in
methods from Model and its traits. On a fresh unpersisted model instance,
some Eloquent methods call Str::upper() with a null attribute value, producing
a deprecation in PHP 8.4.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
@forrestedw forrestedw marked this pull request as ready for review March 14, 2026 11:45
@forrestedw forrestedw changed the title 12.x eloquent factory only [12.x] Add Eloquent Factory only() to give callers fine-grained control over which related factories are expanded in tests Mar 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants