TL;DR
- A package that lets you configure a model, then calls the default one statically everywhere, silently ignores your config.
- Fix: resolve the class-string from config at every query call site, with the vendor default as fallback.
- One static helper, real Pest coverage, zero breaking changes.
Spatie's media-library lets you swap in your own Media subclass via media-library.media_model — handy when you need a different DB connection, extra global scopes, or a few custom methods. My package cleaniquecoders/media-manager wraps that library with a Livewire browser/picker UI. Problem: every query in my package called Spatie's Media class statically. So if you configured a subclass, my code quietly bypassed it. Your scopes, your connection — ignored. No error, just wrong data.
The trap: a static call is a hard-coded dependency
The offending pattern was everywhere:
use Spatie\MediaLibrary\MediaCollections\Models\Media;
$query = Media::query()->latest(); // always Spatie's class, config be damned
Media::query() reads great and works fine — until someone points the config at App\Models\TenantMedia. The static reference is baked in at parse time; config never gets a look in. This is the subtle version of hard-coding: it's configurable in theory, broken in practice.
The fix: resolve the class-string, don't name it
One place decides which model to use, reading config with the vendor class as the fallback:
use Spatie\MediaLibrary\MediaCollections\Models\Media;
/** @return class-string<Media> */
public static function mediaModel(): string
{
return config('media-library.media_model', Media::class);
}
Then every query call site resolves through it instead of naming a class:
// before
$query = Media::query()->latest();
// after
$mediaModel = MediaManager::mediaModel();
$query = $mediaModel::query()->latest();
Variable static calls ($mediaModel::query()) are the boring hero here — PHP resolves the class at runtime, so config finally wins. I applied it across the service and the Livewire components: list, collections, mime types, find, findMany, deleteMultiple, reorder.
The type hints didn't change. Return types still say ?Media, because a configured model must extend Spatie's Media — the contract holds, the concrete class flexes. That's the whole trick: widen what runs, keep what's guaranteed.
Why config-with-fallback beats a bind
I could have bound an interface in the container. For a value that's literally already a config key in the parent library, that's ceremony. config(..., Default::class) gives the same swappability with the default sitting right there in the signature — nobody who didn't opt in notices a thing.
| Approach | Swappable | Breaks default users | Extra wiring |
|---|---|---|---|
Static Media::
|
No | — | None |
| Container bind + contract | Yes | No | Interface + binding |
config() with fallback |
Yes | No | None |
Prove it with a subclass
The test that matters isn't "does the helper return a string" — it's "does a real query come back as the configured type." A tiny fixture plus a Pest expectation:
class CustomMedia extends Media {}
it('queries media through the configured custom model', function () {
config()->set('media-library.media_model', CustomMedia::class);
$post = TestPost::create(['title' => 'Test Post']);
$this->service->upload($post, UploadedFile::fake()->image('t.jpg'), 'gallery');
$paginator = $this->service->getMedia();
expect($paginator->items()[0])->toBeInstanceOf(CustomMedia::class);
});
toBeInstanceOf(CustomMedia::class) is the assertion that would have caught the original bug — the default class would fail it.
Takeaway
If your package exposes a "configure your own model" knob, grep for the concrete class name. Every static call to it is a place the knob does nothing. Route them through one resolver, keep the type hint on the base class, and add a subclass fixture test so the guarantee can't rot. Cheap fix, and it turns a silent lie into a real extension point.
Top comments (0)