Skip to content

What's new in v2 ​

A guided tour of the v2.0 surface. For the per-version mechanical migration notes (renames, removed methods, Rector config), see the upgrading guide. This page is the why and the wow — what v2 lets you write that v1 didn't.

TL;DR ​

ThemeWhat it changed
Required parentsOne call composes every NOT NULL belongsTo parent chain
recycle()Share one parent across an entire batch
for() / has() alias overloadExplicit per-alias composition with target-type guard
Sequence context objectPer-row context ($s->index, isFirst, isLast, factory, generator) inside sequence() callables
Story scenariosFoundry-style entity pools with getRandom() / getRandomSet()
TableAssertionsTraitExpressive database-state assertions with sharp failure messages
Eager transaction strategy by defaultMixed direct-save + factory-save tests stay isolated again
strictDefinition detectorCatches the silent "FK in definition()" footgun before it ships
Bake qualityTimestampBehavior-managed columns are no longer baked, custom generator alias loads detected

withRequiredParents() ​

The single biggest ergonomic win of v2. Composes every belongsTo parent the root table requires — every NOT NULL FK — recursively, in one call:

php
// Tables: Authors belongsTo Address (NOT NULL); Address belongsTo City (NOT NULL);
//         City belongsTo Country (NOT NULL).
// One call satisfies the whole chain.
$author = AuthorFactory::new()->withRequiredParents()->save();

// Composes with recycle() for shared-parent batches:
$country = CountryFactory::new()->save();
$authors = AuthorFactory::new()
    ->count(50)
    ->withRequiredParents()
    ->recycle($country) // all 50 authors share this one Country
    ->saveMany();

The detector reads the schema; it only auto-composes the cases it can resolve unambiguously (single scalar NOT NULL FK). Composite-key, foreignKey => false, and nullable FKs aren't auto-resolved by design.

Three protected hooks let factory classes shape their required-parent set:

  • requiredParentAssociations() — opt in extras (typically a nullable single-scalar FK the factory wants composed regardless).
  • excludedRequiredParentAssociations() — opt out specific aliases at the class level. Wins over both auto-detection and the additive hook.
  • allowedForeignKeysInDefinition() — exempts specific FK columns from the strictDefinition deprecation (rare, transitional).

Per-call $except and $maxDepth arguments cover the one-off cases:

php
$author = AuthorFactory::new()
    ->withRequiredParents(except: ['Address'], maxDepth: 2)
    ->with('Address', $myAddress)
    ->save();

See the Required parents guide for the full contract, including the pinned-FK semantics, cycle detection, and the strict mode for catching a depth cap that leaves a NOT NULL FK unsatisfied.

recycle() ​

Reuse a saved entity wherever any belongsTo in the build graph targets the same table — substitutes at every depth:

php
$country = CountryFactory::new()->save();
AddressFactory::new()
    ->count(3)
    ->with('City', CityFactory::new()->forCountries())
    ->recycle($country) // every nested City reuses this Country
    ->saveMany();

Variadic and chainable; substitutes through hasMany / belongsToMany edges into sibling belongsTo branches; refuses ambiguous same-source duplicates in a single call. See the recycle section of the associations guide.

for() / has() alias overload ​

Cleaner composition when a table has multiple associations targeting the same model, with a target-type guard that catches misuse at compose time:

php
// Disambiguates: Addresses belongsTo City AND ShippingCity
$order = OrderFactory::new()
    ->for(CityFactory::new(), 'ShippingCity')   // explicit alias
    ->has(LineItemFactory::new()->count(3))     // alias auto-resolved
    ->save();

// has() now also takes pivot data for belongsToMany joins:
$author = AuthorFactory::new()
    ->has(ArticleFactory::new()->count(2), 'Articles', ['featured' => true])
    ->save();

has() rejects pivot data on non-BTM aliases (was silently dropped in v1). The alias overload's target-type guard catches misuse like OrderFactory::for(BarFactory::new(), 'ShippingCity') — that would have silently mis-wired a Bar entity into the City slot in v1.

Sequence context object ​

sequence() callables now receive a single Sequence value object instead of two positional args. Exposes the current row's full context:

php
ArticleFactory::new()
    ->count(5)
    ->sequence(fn (Sequence $s) => [
        'rank' => $s->index,            // 0-based
        'position' => $s->position,     // 1-based
        'total' => $s->total,
        'is_first' => $s->isFirst,
        'is_last' => $s->isLast,
        'slug' => $s->generator->slug(),
        'parent' => $s->factory->getTable()->getAlias(),
    ])
    ->saveMany();

The Sequence object is constructed by the data compiler and self-validates; callables never need to instantiate it. The new sequenceField() helper covers the per-field-cycle case without writing a callable:

php
ProductFactory::new()
    ->count(6)
    ->sequenceField('status', 'draft', 'review', 'published')
    ->saveMany();

See Usage examples for more factory patterns.

Story scenarios ​

A Foundry-style abstract that adds named entity pools on top of the existing FixtureScenarioInterface. Seed data once, store named handles, sample from them in the test body — no rebuilding, no explicit threading:

php
class BlogStory extends Story
{
    protected function build(): void
    {
        $this->addToPool('authors', UserFactory::new()->count(10)->saveMany());
        $this->addToPool('categories', CategoryFactory::new()->count(3)->saveMany());

        ArticleFactory::new()->count(50)->saveMany();
    }
}

// In the test:
$story = $this->loadFixtureScenario(BlogStory::class);
$author = $story->getRandom('authors');
$twoCategories = $story->getRandomSet('categories', 2);

Backwards-compatible: existing scenarios that implement FixtureScenarioInterface directly keep working unchanged. See the Scenarios guide.

TableAssertionsTrait ​

Expressive database-state assertions composed over Factory::query(). The trait is opt-in (use TableAssertionsTrait;) — no static read surface is added to BaseFactory.

php
public function testCreate(): void
{
    $this->post('/articles', ['title' => 'Hello']);

    $this->assertTableHas(ArticleFactory::class, ['title' => 'Hello']);
    $this->assertTableCount(ArticleFactory::class, 1);
    $this->assertTableMissing(ArticleFactory::class, ['status' => 'spam']);
    $this->assertEntityExists($article);
    $this->assertEntityMissing($deletedArticle); // refuses never-persisted entities
}

Sharp failure messages spell out what was expected and what was found, including the actual value of relevant columns. See the Queries guide.

Eager transaction strategy restored ​

FactoryTransactionStrategy is eager by default again. setupTest() opens a transaction on the primary test connection up-front so that direct $table->save($entity) / raw inserts inside a test are also rolled back at teardown — not just operations that go through a Factory's save().

v1.3 had flipped this to lazy, which silently broke tests that mixed Factory build with direct $table->save() (the testFind / testValidationDefault pattern) — the direct save ran outside any transaction and leaked across methods. v2 restores the eager default while keeping additional connections lazy.

If your suite is entirely Factory-based and you want the lazy contract back, opt in via LazyFactoryTransactionStrategy (whole suite) or LazyTransactionTrait (per test class).

strictDefinition FK-in-definition() detector ​

Catches the silent footgun where a factory's definition() returns a belongsTo FK column directly (e.g. 'author_id' => $generator->randomDigit()). That pattern silently overrides whatever parent a caller later composes via ->with('Author', ...) — the dangling-id bug that's hard to debug because the test reads green.

Enabled by default in v2; the detector emits a deprecation that names the column, the association, and the migration path:

ArticleFactory::definition() returns "author_id", which is the foreign-key
column for the "Author" belongsTo association. Move association composition
out of definition() — use ->with('Author') in configure(), a forAuthor() /
withAuthor() helper, or pass the association at the call site.

Opt out transitionally: Configure::write('FixtureFactories.strictDefinition', false); — removed in the next major.

See the FK-in-definition guide for the migration playbook.

Bake quality ​

  • TimestampBehavior-managed columns are no longer baked. Without this, a factory baked for a 'created'/'modified' table emitted $generator->datetime() for both, then TimestampBehavior deferred to the already-set values and the fixture row shipped with random timestamps instead of the test-run's now. Detection is by class (so aliased 'className' => 'Timestamp' loads are caught too) and only Model.beforeSave writers with new or always qualify.
  • The default-data guesser was extracted into a dedicated DefaultDataGuesser class that subclasses / projects can override or extend.

See Bake reference.

Notable bug fixes worth knowing ​

  • recycle() now substitutes mid-chain required parents (the withRequiredParents() interaction).
  • foreignKey => false belongsTo composition works at persist time (was crashing with "Cannot set an empty field" in v1).
  • FactoryTransactionStrategy rollback cascade and cross-connection finalize are no longer susceptible to per-connection failures.
  • The pinned-FK contract is now uniform across all four entry-paths: array-instantiation, entity-instantiation, the additive requiredParentAssociations() hook, and configure()-time. Sequence overriding an instantiation pin is correctly detected.

Removed in v2 ​

For the mechanical surface — old method names, deprecated wrappers, removed helpers — see upgrading. The bundled Rector config covers the safe renames automatically.

Where to go next ​

Released under the MIT License.