---
title: "Model-swap proof — Testing for Laravel | Roundly"
description: "Drive the real flow and prove every model it produces is the host subclass — by concrete class and created events, not instanceof."
url: https://roundly-consulting.com/open-source/docs/testing-for-laravel/model-swap
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Testing for Laravel](https://roundly-consulting.com/open-source/docs/testing-for-laravel.md)

# Model-swap proof

Proves that a package honours a configured model swap by driving the real flow and checking the concrete class of every model it produces — not merely instanceof:

```php
expect($configKey)->toHonourModelSwap(string $subclass, Closure $exercise, bool $expectsCreation = true);
```

| Parameter | Type | Meaning |
| --- | --- | --- |
| `$configKey` | string | The config key that swaps the model, e.g. media.media\_model. |
| `$subclass` | class-string | The host subclass — set into config before boot. Must use CountsCreations. |
| `$exercise` | Closure(): Model\|iterable<Model> | Drives the real flow and returns the model(s) it produced. |
| `$expectsCreation` | bool (true) | Whether the flow creates a row. Pass false for read-only flows. |

The host subclass must use the shipped CountsCreations trait:

```php
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use RoundlyConsulting\MediaLibrary\Models\Media;
use RoundlyConsulting\Testing\Fixtures\Concerns\CountsCreations;

class CustomMedia extends Media
{
    use CountsCreations;
}

// config('media.media_model') swapped to CustomMedia::class before boot
it('honours the media model swap', function (): void {
    Storage::fake('public');
    $user = User::factory()->create();

    expect('media.media_model')->toHonourModelSwap(CustomMedia::class, function () use ($user) {
        // the real flow, not a resolver string check
        $media = $user->addMedia(UploadedFile::fake()->image('me.jpg'))->toMediaBucket('avatar');

        return [$media, $user->getFirstMedia('avatar')];
    });
});
```

## What it checks

- Fails fast if config($configKey) is not $subclass — the swap wasn’t applied before boot. Use configBeforeBoot() or swapModel().
- Every returned model’s concrete class ($model::class) must equal $subclass. instanceof is not enough: a static::query() helper inside the packaged model creates the row as the packaged class, so the host’s model events never fire.
- At least one created event must land on $subclass, counted by CountsCreations — the only proof the row was really created as the host class.
- An exercise that returns no models, or a non-model, fails loudly — a resolver test that only checks a returned string is exactly what this replaces.

## Flows that create nothing

CountsCreations is required, not detected — a missing trait fails with instructions, so a caller can never get a weaker proof under the same name. For a flow that only reads existing rows, say so explicitly:

```php
expect('media.media_model')->toHonourModelSwap(
    CustomMedia::class,
    fn () => $user->getFirstMedia('avatar'),   // reads an existing row, creates nothing
    expectsCreation: false,
);
```

## CountsCreations

The trait hooks the model’s own created event, so the count only moves for rows created as that exact class. resetCreationCount() and creationCount() are public statics; the assertion resets the counter before running the exercise, so the count reflects that flow alone.

## Why it exists

Broken swap seams were the single largest bug class across our packages: implicit hasMany keys derived from the parent class name, bare belongsToMany() deriving the pivot, static::query() in a findOrCreate helper, hard-coded call sites beside an honoured config, and final on the very class a host was invited to extend.

## Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

[More ways to support, including crypto](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/testing-for-laravel.md)
