MediaLibrary::fake() swaps the manager for a recording fake and returns it:
use RoundlyConsulting\MediaLibrary\Facades\MediaLibrary;
$fake = MediaLibrary::fake();
$this->post('/profile/avatar', ['avatar' => UploadedFile::fake()->image('me.png')]);
$fake->assertAdded('avatar', to: $user);
$fake->assertNothingDeleted();- It writes nothing — no file on any disk, no row, no queued job, no event — so a test needs neither real disks nor Storage::fake(). Every disk it is asked to use must still be configured in filesystems.disks, as for the real manager — Storage::fake() alone doesn’t add one.
- It records every call, whether it came through the facade, an injected MediaLibraryManager, a for() handle, the InteractsWithMedia trait or a Media model method.
- Mutations still return something realistic: adds, attaches and copies return an unsaved Media with a fresh uuid, owner, bucket, disk, visibility and the same safe file name a real add stores; moves re-point the media in memory, and bindDraft() returns the draft on its bucket’s disk and visibility.
- It refuses exactly what the real manager refuses: bucket acceptance rules (mime allowlist, size cap, image dimensions) apply to adds, draft binds, attaches, moves, copies and replacements; an unconfigured disk throws DiskDoesNotExist, an owner that was never saved throws MediaOwnerNotSaved, withVisibility() accepts only public or private, and an unknown or expired draft token still throws. Reads run against the database as usual.
- remote() stays real and is not recorded: it stores nothing and its requests go through Laravel’s HTTP client, so pair it with Http::fake() — and with MediaLibrary::fakeDns() for its DNS lookups.
Assertions
Every argument is an optional filter; null matches anything:
$fake->assertAdded('avatar', to: $user);
$fake->assertAttached($logo, to: $user, bucket: 'avatar');
$fake->assertDraftBound($token, to: $user, bucket: 'avatar');
$fake->assertMoved($media, to: $otherUser, bucket: 'gallery', disk: 'cold');
$fake->assertVariantsMoved($media, disk: 'hot');
$fake->assertCopied($media, to: $otherUser, bucket: 'gallery');
$fake->assertReplaced($media);
$fake->assertDeleted($media); // clear() records one delete per media
$fake->assertRegenerated($media, only: ['thumb'], force: true);
$fake->assertDraftsPruned();
// The negative counterparts
$fake->assertNothingAdded();
$fake->assertNothingAttached();
$fake->assertNothingBound();
$fake->assertNothingMoved();
$fake->assertNoVariantsMoved();
$fake->assertNothingCopied();
$fake->assertNothingReplaced();
$fake->assertNothingDeleted();
$fake->assertNothingRegenerated();
$fake->assertDraftsNotPruned();End-to-end against a faked disk
When you want real files and generated variants, skip the fake and use Storage::fake() for the bucket's disks:
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use RoundlyConsulting\MediaLibrary\Facades\MediaLibrary;
it('stores an avatar and generates a thumbnail', function () {
Storage::fake('cold');
Storage::fake('hot');
$user = User::factory()->create();
MediaLibrary::for($user)
->add(UploadedFile::fake()->image('me.jpg', 600, 600))
->toBucket('avatar');
$avatar = MediaLibrary::for($user)->first('avatar');
expect($avatar->hasGeneratedVariant('thumb'))->toBeTrue();
Storage::disk('cold')->assertExists($avatar->getPath());
});Run your suite with the package's migration loaded (RefreshDatabase). Queued variants run synchronously under the sync queue in tests, so generated variants are assertable immediately.
Faking DNS for remote URLs
addFromUrl(), MediaLibrary::remote() and the SafeRemoteUrl rule resolve hosts for the private-network guard even under Http::fake(), and a real lookup in a test is slow and non-deterministic. MediaLibrary::fakeDns() answers the guard’s lookups from a table, so a test never reaches real DNS. Only DNS is faked — answer the requests with Http::fake():
use Illuminate\Support\Facades\Http;
use RoundlyConsulting\MediaLibrary\Facades\MediaLibrary;
$dns = MediaLibrary::fakeDns([
'docs.example' => ['93.184.216.34', '2606:2800:220:1:248:1893:25c8:1946'],
'intranet.example' => '10.0.0.5',
]);
Http::fake(['docs.example/*' => Http::response('hello')]);
MediaLibrary::remote()->fetch('https://docs.example/a')->body; // 'hello'
MediaLibrary::remote()->fetch('https://intranet.example/'); // RemoteFileRejected (blocked)
MediaLibrary::remote()->fetch('https://other.example/'); // RemoteFileUnavailable (unresolved)
$dns->resolve('other.example', '151.101.1.1')->unresolvable('docs.example');
$dns->assertLookedUp('docs.example', times: 1);- A name the table wasn’t given doesn’t resolve — RemoteFileUnavailable from remote(), RemoteFileRejected from addFromUrl(). MediaLibrary::fakeDns() with no records makes every name unresolvable, a good line for your base TestCase::setUp().
- Pinned addresses are judged like real answers: a private, loopback, link-local or metadata address is refused unless media.remote.allowed_private_hosts allows it, and a host with any one blocked address is refused.
- Names match the way the guard writes them — any case, one trailing dot, an internationalised name under its punycode form (with ext-intl). Address literals, localhost / *.localhost and allowlisted names are never looked up, so they never reach the table.
- The table lives in the application container: a second call in the same test returns the same DnsFake and adds to its pins, and the next test starts without one. It works with or without MediaLibrary::fake(), in either order.
- A pin the guard could never be asked for throws InvalidArgumentException — an address literal as the name, a name with a port, path, credentials or brackets, an empty address list (use unresolvable()), or something that is not an IP address.
| DnsFake method | Does |
|---|---|
resolve($host, $addresses) | Pin a host to one or more IPv4/IPv6 addresses (IPv6 without brackets); replaces an earlier pin for it. |
unresolvable($host) | Make a host not resolve — drops an earlier pin. |
lookups() | Every name the guard looked up, in order, one entry per lookup. |
assertLookedUp($host, times:) | Looked up at least once, or exactly times times. |
assertNotLookedUp($host) / assertNothingLookedUp() | Never looked up / no lookup at all. |
With media.remote.block_private_networks off, fetches judge nothing and so look nothing up; vet() and SafeRemoteUrl still ask the table. Without fakeDns(), a made-up host faked with Http::fake() — one that doesn’t resolve, or resolves to a private address like a local *.test domain — is refused; you can also add it to media.remote.allowed_private_hosts, or set media.remote.block_private_networks to false, in your test environment.
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.