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:
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:
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:
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 cryptoBy donating, you agree to our donation terms.
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.