artisanplaybook .dev
ARTICLES / TESTING

assertDatabaseHas() passed and the feature is still broken

A green assertDatabaseHas tells you a matching row exists. It does not tell you it is the row you created, that the rest of it is right, or that it has not been deleted since.

ON THIS PAGE

A green suite and a broken checkout

The bug report says orders are stuck on pending after payment. The test for that exact code path is green and has been green for months:

it('marks the order as shipped', function () {
    $order = Order::factory()->create();

    $this->post("/orders/{$order->id}/ship");

    $this->assertDatabaseHas('orders', ['status' => 'shipped']);
});

Read the assertion again. It does not mention $order. It asks whether the orders table contains any row whose status is shipped — and the factory-created fixtures in that test, or any row the controller created as a side effect, can satisfy it while the order under test sits untouched on pending.

Run that arrangement for real and you get exactly what you would expect:

loose assert on status=shipped => PASSED while the subject row is still 'pending'

What the assertion actually does

There is no Eloquent in this code path at all. The constraint behind the assertion is four lines:

// Illuminate\Testing\Constraints\HasInDatabase
public function matches($table): bool
{
    return $this->database->table($table)
        ->where($this->data)
        ->exists();
}

A query builder where() against the raw table, and exists(). Three consequences follow from that, and all three are places tests go wrong:

  1. It is a partial match. Columns you do not name are not checked. A row with the right status and a mangled total, a null shipped_at and someone else's customer_id passes an assertion on status.
  2. It is an existence check, not an identity check. One row out of ten thousand is enough. Nothing ties it to the record your test created.
  3. It talks to the driver, not to your model. Casts, accessors, mutators and global scopes are not in play. The value you pass is compared against the bytes in the column.

None of that is a defect. assertDatabaseHas is a precise tool and its name says what it does: the table has a row like this. The mistake is reading it as "my feature worked".

Scope the assertion to the row you are testing

The cheapest upgrade available. Pass the model instead of the table name and the current release adds the primary key to your constraints for you:

$this->assertDatabaseHas($order, ['status' => 'shipped']);

Now the assertion is about that order. Against the same arrangement as before it fails, and the failure output tells you what it found instead:

Failed asserting that a row in the table [orders] matches the attributes {
    "id": 2,
    "status": "shipped"
}.

Found similar results: [
    {
        "id": 2,
        "status": "pending"
    }
]

That is a bug report in the test output. The unscoped version's failure would have been "no row anywhere has status shipped", which sends you looking in the wrong place.

The same call accepts a model class string, an iterable of models, and a list of attribute arrays to check row by row:

$this->assertDatabaseHas('order_lines', [
    ['order_id' => $order->id, 'sku' => 'AB-1', 'quantity' => 2],
    ['order_id' => $order->id, 'sku' => 'CD-9', 'quantity' => 1],
]);

That checks two rows exist. It does not check there are only two — pair it with assertDatabaseCount('order_lines', 2) when "and nothing else" is part of the behaviour, which for a sync or an import is usually the entire point.

Casts make the stored row a different shape

Because the assertion queries the raw table, the value you have in PHP is not always the value in the column. Four common casts, asserted against the row the model just wrote:

'ssn'      => '123-45-6789'      FAILED   (encrypted cast)
'password' => 'secret'           FAILED   (hashed cast)
'settings' => ['theme' => 'dark']  FAILED (array cast)
'active'   => true               PASSED
'active'   => 1                  PASSED

The booleans pass because the driver compares 1 to 1. The other three fail, and they fail for different reasons worth separating.

array and json casts store a JSON string, so a PHP array in the assertion is compared against text and never matches. The helper for this is castAsJson(), which compiles a driver-appropriate JSON value cast into the query:

$this->assertDatabaseHas('orders', [
    'id' => $order->id,
    'meta' => $this->castAsJson(['gift' => false]),
]);

That passes. But note what it asserts: the whole column, encoded the same way, key order included. For "this one key has this one value" inside a JSON column, a where on a JSON path is the honest expression of the intent:

expect(Order::whereJsonContains('meta->tags', 'fragile')->exists())->toBeTrue();

hashed and encrypted are a harder no. A hashed column holds a bcrypt digest with a random salt; an encrypted column holds ciphertext with a random IV, so encrypting the same string twice produces two different values — there is no literal you can put in the assertion that will match. These columns cannot be asserted with assertDatabaseHas at all. Assert on the model, which is what the cast is for:

expect($user->fresh()->ssn)->toBe('123-45-6789');
expect(Hash::check('secret', $user->fresh()->password))->toBeTrue();

The general rule: if a column has a cast, the database assertion is testing your cast configuration rather than your feature. datetime and decimal happen to round-trip cleanly through the comparison — a Carbon instance in the array matches a stored 2026-09-23 17:19:46, and 20 matches a decimal:2 column — which makes it easy to assume the others will too.

The row it found may be deleted

Soft deletes are the sharpest version of the raw-table problem, because the table and the model genuinely disagree about whether the record exists:

$user->delete();

$this->assertDatabaseHas('users', ['email' => 'sally@example.com']); // passes

deleted_at is set, User::count() returns 0, every query in the application skips it — and the assertion passes, because the row is still in the table. A test for "deleting a user removes them from the directory" written with assertDatabaseMissing fails on a soft-deleted model and sends you off to debug a controller that was correct.

Use the assertions built for it, and note that they also take the model instance:

$this->assertSoftDeleted($user);
$this->assertNotSoftDeleted($otherUser);

Assert on the behaviour, then on the row

The fix is not to abandon the assertion — it is to stop asking it to be the whole test. A database assertion is a good check that a write landed. It is a poor check that a feature works, because the thing your users experience is never "a row exists".

For the shipping example, the behaviour is three separate claims, and only one of them is about the table:

it('ships an order', function () {
    Mail::fake();

    $order = Order::factory()->pending()->create();

    $this->actingAs($order->user)
        ->post("/orders/{$order->id}/ship")
        ->assertRedirect(route('orders.show', $order));

    expect($order->fresh())
        ->status->toBe(OrderStatus::Shipped)
        ->shipped_at->not->toBeNull();

    Mail::assertSent(OrderShipped::class, fn (OrderShipped $mail) => $mail->hasTo($order->user));
});

Reading $order->fresh() puts the casts and accessors back in the picture, so an enum column is checked as an enum and a datetime as a date. The mail fake covers the side effect a row cannot tell you about. And the redirect covers the part the user actually sees.

Then, if a specific column is genuinely the contract — a ledger entry, a webhook receipt, an idempotency key — assert on it with the model-scoped form, and add the count so extra rows are a failure:

$this->assertDatabaseHas('ledger_entries', [
    'order_id' => $order->id,
    'amount_cents' => 4200,
    'direction' => 'credit',
]);

$this->assertDatabaseCount('ledger_entries', 1);

What to change

Grep your suite for assertDatabaseHas and assertDatabaseMissing. For each one, ask whether the constraint array contains a value that pins it to the record under test — a primary key, or a foreign key plus something unique. Every assertion that does not is currently able to pass on a row it was never about.

Convert those to the model-scoped form. Where the table is one your factories populate heavily, add the matching assertDatabaseCount. Then look for any column in those arrays that has a cast on the model, and move that assertion onto the model instance instead — for encrypted and hashed columns it cannot work any other way, and for the rest you get a check that exercises the code your application actually reads through.

Last, take one of your green tests, break the feature it covers on purpose, and run it. If it stays green, you learned something about it that no amount of reading it would have told you.

Related reading

ARCHIVE →