Tests get hard to read when every arrangement is Invoice::factory()->create(['status' => 'void', 'voided_at' => now()]). The array says what changed, not why the test needs that invoice.

A named state says why. The test reads Invoice::factory()->voided()->create(), and the column values live in one method that every test shares. The method is voided(). void is a return type, so it stays off the method name.

Keep the default factory valid and boring: an open invoice, on a team, with every required column filled. Put the edge cases in states.

01 What it does

States return a new factory, so they compose. A void invoice with two lines is still one line, and the test name can match it.

InvoicePolicyTest.phpPHP
// tests/Feature/InvoicePolicyTest.php
use App\Models\Invoice;
use App\Models\InvoiceLine;

$invoice = Invoice::factory()
    ->voided()
    ->has(InvoiceLine::factory()->count(2))
    ->create();

$this->actingAs($invoice->team->owner)
    ->put(route('invoices.update', $invoice), ['number' => 'INV-2'])
    ->assertForbidden();

has() builds the related rows. If the interesting part is the parent, use for() on the child: InvoiceLine::factory()->for($invoice).

A state can take a parameter. Default it so the common call stays short.

InvoiceFactory.phpPHP
// database/factories/InvoiceFactory.php
use App\Models\InvoiceLine;

class InvoiceFactory
{
    public function withLines(int $count = 1): static
    {
        return $this->has(InvoiceLine::factory()->count($count));
    }
}

02 Gotchas

  • A state should only arrange data. Reaching a payment gateway from the factory makes every test that uses the state slow and brittle.
  • Chain a second state when a void invoice also needs a credit note. Do not hide the second fact inside voided().
  • afterCreating() is the right hook when a row has to exist before a follow-up write, such as a pivot. Keep expectations out of it. The test asserts.
  • The default factory must satisfy the database. A state that unsets a required column fails in every test that uses it, with an error that points at the insert rather than the state.

Factory states is the lesson. Authorisation is where a void invoice is the case under test. Policies vs gates is the rule that refuses the update.