Index

Design Before the Pattern Name

Construction Should Not Hide Durable Writes

A test called a model factory’s make() method. The model itself was never saved. Two related records were.

The factory computed the model’s default address and account fields by creating real database rows. A test which only needed an in-memory user therefore changed shared state before it reached its first assertion.

That state usually disappeared when a surrounding test transaction rolled back. Tests without the transaction trait left it behind. Another test used TRUNCATE for cleanup, which committed the MySQL transaction and made earlier writes survive the rollback.

The method returned an unpersisted object. Its name still gave the wrong account of what had happened.

The return value is only one effect

Laravel distinguishes the two common factory operations:

User::factory()->make();   // instantiate without saving the user
User::factory()->create(); // instantiate and save the user

The Laravel factory documentation describes make() as creating model instances without persisting them.1 That statement is about the model being built. A factory definition can still run arbitrary code while producing its attributes.

The retained factory effectively did this:

public function definition(): array
{
    $address = Address::factory()->create();
    $account = Account::factory()->create();

    return [
        'address_id' => $address->id,
        'account_id' => $account->id,
    ];
}

The observable contract was therefore:

make user
    insert address
    insert account
    return unsaved user

Looking only at exists === false on the returned model would miss both inserts. Construction needs an effect budget, not merely a return type.

Derived attributes are not free attributes

The address and account values were internally consistent. That made their creation look like ordinary fixture setup.

They were not ordinary values. Each one required:

  • a database connection;
  • an available schema;
  • write permission;
  • transaction ownership;
  • cleanup; and
  • a policy for choosing or creating a related row.

An attribute which requires those capabilities is a persisted dependency. It should not look interchangeable with a generated name or email address.

This distinction becomes useful in production code too:

plain construction
    parse values
    validate invariants
    derive in-memory fields

durable operation
    allocate an identifier
    insert a row
    reserve capacity
    publish a message
    contact another system

The second group may be necessary. It needs a name and an owner which reveal the effect.

Laziness helps only when callers can decline the dependency

The retained correction changed the default attributes from eagerly computed values to memoized closures. An explicit override can then prevent Laravel from evaluating the corresponding default:

'address_id' => fn () => $resolveAddress()->id,
'account_id' => fn () => $resolveAccount()->id,

If a caller supplies both fields, those closures need not run. If the caller uses all defaults, make() can still create the related rows. Laziness removes unnecessary effects; it does not make the default factory pure.

The correction therefore added an explicit factory state which supplies plain values for every attribute backed by persisted dependencies:

User::factory()
    ->withoutPersistedDependencies()
    ->make();

Dozens of unit-oriented call sites adopted that state. The default path retained its earlier behavior for compatibility with tests which expected a realistic relational fixture.

That is a practical migration seam. It is not the clearest possible endpoint. The name explains what is absent, and a caller can still accidentally use the default make(). A future factory can make the database-free path the default and give persistence the positive name:

UserFixture::detached();
UserFixture::persistedGraph();

The safer default should match the least capability implied by construction.

Transactions can conceal the violation

Database transactions make tests faster and isolate ordinary writes well. They can also hide an effectful factory because the unexpected rows vanish after each passing test.

The retained leaks became permanent through two separate gaps:

  • some tests performed writes without the transaction-owning trait; and
  • other tests ran TRUNCATE inside their wrapper.

MySQL documents that TRUNCATE TABLE is a data-definition statement which implicitly ends the active transaction as if a commit occurred before the statement.2 A later rollback cannot restore the boundary which the test already ended.

Replacing TRUNCATE with transactional deletion and applying the transaction trait repaired those isolation gaps. It did not answer whether make() should write. Both levels matter:

factory contract
    construction does not acquire durable capabilities unexpectedly

test harness
    every intentional write has isolation and cleanup

The harness is defence in depth. It should not be the only reason a misleading API appears harmless.

Prove absence through the boundary that matters

A useful regression does more than inspect the returned model:

$before = [
    'addresses' => Address::query()->count(),
    'accounts' => Account::query()->count(),
];

$user = User::factory()
    ->withoutPersistedDependencies()
    ->make();

expect($user->exists)->toBeFalse()
    ->and(Address::query()->count())->toBe($before['addresses'])
    ->and(Account::query()->count())->toBe($before['accounts']);

This is one case where a database-backed test is appropriate. The contract is the absence of database writes, so an isolated unit test of attribute arrays would prove the wrong boundary.

Also run the persisted path:

detached construction
    model rows added:       0
    dependency rows added:  0

persisted graph
    model rows added:       1
    required relations:     present

The second case prevents purity work from quietly breaking realistic fixtures.

Query logging can detect writes more directly when counts are ambiguous, but it must ignore transaction-control and read queries deliberately. A dedicated read-only database user is stronger still, although it can make a unit suite more expensive to operate.

Relation creation can remain explicit

Some tests need a complete graph. Creating every relation by hand can bury the scenario in setup noise.

Factories are good at that job when the call admits what it will do:

Order::factory()
    ->for($customer)
    ->has(Line::factory()->count(3))
    ->create();

The terminal create() owns persistence. Named states such as withDefaultCredential() may add more records after creation when the state documents that behavior and the test needs it.

An automatic persisted graph becomes suspect when:

  • make() triggers it;
  • an unrelated attribute override still triggers it;
  • the graph is much larger than most tests need;
  • creation invokes observers, queues, or remote calls; or
  • callers need special cleanup knowledge not expressed in the API.

At that point convenience has turned into hidden fixture scope.

The fixture separates three contracts

The public reproduction models an eager factory, a lazy compatible factory, and a database-free state. It proves with synthetic rows that:

  • eager defaults write even when the caller overrides their final values;
  • lazy defaults skip writes only when the caller supplies those values;
  • the compatibility default still writes;
  • the explicit detached state writes nothing; and
  • a simulated implicit commit makes an unexpected write survive rollback.

The fixture does not run Laravel or MySQL. The implicit-commit behavior comes from the cited MySQL contract, while the script only demonstrates why ending a test transaction defeats later rollback. It does not measure the private suite’s failure rate or claim production impact.

Construction should spend no hidden capabilities

An object factory may need rich data. It does not follow that generating those values should allocate durable state.

Start with plain values. Let a caller opt into a persisted graph through a name which says so. Keep transactions around intentional database tests, and keep DDL out of rollback-based cleanup.

Then make() can mean what its caller needs it to mean: produce an object for local use, without leaving evidence of that construction in a database the method never mentioned.