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.
A surrounding test transaction usually hid the damage by rolling it back.
Tests without that transaction left the rows behind, while another test used
TRUNCATE for cleanup, committed the MySQL transaction, and made earlier writes
survive the rollback. The method returned an unpersisted object, but 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, not a promise that its factory
definition cannot run arbitrary code while producing the attributes.
The 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, which 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, and the same distinction matters outside tests:
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. That is not an excuse to smuggle it through a name which sounds local; it needs an owner which reveals the effect.
Laziness helps only when callers can decline the dependency
The 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, but it does not magically 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 worked as a practical migration seam, not as 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, making a misleading API look harmless right up until one test escapes the wrapper.
The 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 in the first place. 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 exactly the right level. The contract is the absence of database writes, so an isolated unit test of attribute arrays would prove the wrong thing.
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, and 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. ↩︎