Articles Snippets Projects

The hasOne() That Returned Another Customer's Data

How One with() Call Broke Our Billing

March 9th ʼ26 3 days ago 8 min 1557 words

Your relationship works fine with lazy loading. Then someone adds with(), and Customer B's credit note shows up on Customer A's invoice.


The Setup

You have a subscription billing system. Each customer gets a monthly Invoice, and sometimes you issue a credit note (a negative invoice) for the same billing month. Both live in the same invoices table - the credit note is just an invoice with type = CREDIT_NOTE.

You write a self-referential hasOne to link them:

// Invoice.php
public function creditNote(): HasOne
{
    return $this->hasOne(self::class, 'billing_month', 'billing_month')
        ->where('customer_id', $this->customer_id)
        ->where('type', InvoiceType::CREDIT_NOTE);
}

Find another invoice in the same table, with the same billing_month, the same customer_id, and type = CREDIT_NOTE. Straightforward.

You write a test. It passes. You ship it.

Three weeks later, support opens a ticket. "Customer #312 is showing a credit note that doesn't belong to them."


The Problem

When you lazy load (access the relationship on a single model instance):

$invoice = Invoice::find(42);
$credit  = $invoice->creditNote;

Laravel builds this query:

SELECT * FROM invoices
WHERE billing_month = '2026-01'
  AND customer_id = 7            -- $this->customer_id = 7 ✓
  AND type = 'CREDIT_NOTE'

Works fine. $this->customer_id is 7 because the model is fully hydrated at this point.

Eager loading breaks this:

$invoices = Invoice::with('creditNote')->get();

Laravel needs to build the relationship query before it has the parent models. It creates a blank Invoice instance to read the relationship definition. $this->customer_id is null.

SELECT * FROM invoices
WHERE billing_month IN ('2026-01', '2026-02', '2026-03', ...)
  AND customer_id IS NULL        -- $this->customer_id = null ✗
  AND type = 'CREDIT_NOTE'

With ->where('customer_id', null), the query returns nothing. Or worse - depending on your constraint style, it returns credit notes from any customer that shares the same billing_month.

That's how Customer A ends up with Customer B's credit note on their screen. The customer_id filter silently disappears, and the only thing left to match on is billing_month - which every customer shares.


Why This Is Hard to Catch

Tests usually lazy load. You write $invoice->creditNote in a test, it works. To catch this, you'd need a test that eager loads across multiple customers. Most people don't write that test.

It's intermittent. The bug only shows up when two customers have invoices for the same billing_month and one of them has a credit note. Your dev database probably has one customer per month. Production has hundreds.

The code reads correctly. Look at ->where('customer_id', $this->customer_id) and tell me that looks wrong. It doesn't. There's no visual clue that this breaks under eager loading.


The Fix

No single-line fix exists here. This is a self-referential relationship with a composite match (billing_month + customer_id), and Laravel's hasOne only supports one foreign key.

Your first instinct might be whereColumn():

->whereColumn('customer_id', 'invoices.customer_id')

In a self-join, both sides are the same table. This generates invoices.customer_id = invoices.customer_id - always true. Useless.

The fix needs two layers:

Layer 1: Lazy loading (model level)

Guard the customer_id constraint with when():

// Invoice.php
public function creditNote(): HasOne
{
    return $this->hasOne(self::class, 'billing_month', 'billing_month')
        ->where('type', InvoiceType::CREDIT_NOTE)
        ->when($this->customer_id, fn ($q) => $q->where('customer_id', $this->customer_id));
}

Lazy loading? $this->customer_id has a value, constraint applies. Eager loading? It's null, when() skips it. Layer 2 picks up the slack.

Layer 2: Eager loading (call site)

Wherever you eager load, pass the customer_id constraint explicitly:

// BillingService.php
$invoices = $customer->invoices()
    ->with([
        'creditNote' => function (HasOne $query) use ($customer) {
            $query->where('customer_id', $customer->id);
        },
    ])
    ->get();

At the call site you know which customer you're working with, so you can hand it to the eager load callback directly.


Testing Both Paths

You need tests for both loading strategies. Create two customers with invoices for the same billing month, where only one has a credit note.

Lazy loading test

#[Test]
public function it_does_not_return_credit_note_from_another_customer_when_lazy_loaded(): void
{
    // Arrange - another customer has a credit note for January
    $otherCustomer = Customer::factory()->create();
    Invoice::factory()->create([
        'customer_id'   => $otherCustomer->id,
        'type'          => InvoiceType::CREDIT_NOTE,
        'billing_month' => '2026-01',
    ]);

    // Our customer has a charge for January, but no credit note
    $customer = Customer::factory()->create();
    $charge = Invoice::factory()->create([
        'customer_id'   => $customer->id,
        'type'          => InvoiceType::CHARGE,
        'billing_month' => '2026-01',
    ]);

    // Act
    $result = $charge->creditNote;

    // Assert - should NOT return the other customer's credit note
    $this->assertNull($result);
}

Eager loading test

#[Test]
public function it_does_not_return_credit_note_from_another_customer_when_eager_loaded(): void
{
    // Same setup
    $otherCustomer = Customer::factory()->create();
    Invoice::factory()->create([
        'customer_id'   => $otherCustomer->id,
        'type'          => InvoiceType::CREDIT_NOTE,
        'billing_month' => '2026-01',
    ]);

    $customer = Customer::factory()->create();
    $charge = Invoice::factory()->create([
        'customer_id'   => $customer->id,
        'type'          => InvoiceType::CHARGE,
        'billing_month' => '2026-01',
    ]);

    // Act - eager load with explicit customer_id constraint
    $result = Invoice::with([
        'creditNote' => fn ($q) => $q->where('customer_id', $customer->id),
    ])->find($charge->id)->creditNote;

    // Assert
    $this->assertNull($result);
}

The General Rule

Any time you write $this->something inside a relationship definition, ask yourself: "Will this still work when eager loaded?"

Almost certainly not.

Here's the compatibility matrix:

Inside Relationship

Lazy Loading

Eager Loading

->where('col', $this->attr)

✗ (null)

->whereColumn('a.col', 'b.col')

✓ (not self-join)

->where('type', 'CONSTANT')

Callback in with()

n/a


Further Reading

This behavior has come up in several Laravel framework issues:

The Laravel team's answer: it's by design. Eager loading queries are built from a blank model instance. If you need $this attributes in a relationship, guard them with when() and pass the real values through eager load callbacks.