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 userLooking 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 systemThe 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
TRUNCATEinside 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 cleanupThe 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: presentThe 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.
Laravel 13.x, “Eloquent: Factories — Instantiating Models” , accessed 2026-07-25. ↩︎
MySQL 8.4 Reference Manual, “Statements That Cause an Implicit Commit” , accessed 2026-07-25. ↩︎