knplabs/media-exposer
KnpLabs MediaExposer is a lightweight, framework-agnostic PHP library for exposing media files through a consistent interface. Pluggable exposers map media objects to public URLs, letting you serve images, video, and other assets from local storage or custom backends without tying app code to file p...
knplabs/media-exposer is a small, framework-agnostic PHP library that answers one question: given a media identifier and a format name, what path or URL should I use? It is not a file manager, not an upload handler, and it has no Laravel integration.
Install it and pin the version, because the repository is archived and will not receive further tags:
composer require knplabs/media-exposer
Because it predates PSR-4 adoption in most Knp packages, check composer.json for the autoload block first — you may need to add a classmap or psr-0 entry under autoload in your root composer.json and run composer dump-autoload before anything resolves. There is no service provider, no facade, and no config file to publish.
Where to look first, in order:
README.md — the whole public surface fits in about forty lines.src/Knp/MediaExposer/Exposer.php — the single entry point. Read expose() and note that it returns a plain string.src/Knp/MediaExposer/Resolver/ — the bundled resolvers (path, web path, URL, and a chain/aggregate). These are your templates for anything custom.Media.php and File.php — the two value objects the whole design hangs on.The mental model is a short pipeline: a Media (id + source name) is handed to a Provider registered under that source; the provider returns a File (a filesystem path and/or a URL); a Resolver that claims supports() for that media/format turns the File into the final string.
A realistic first use case is exactly the one this library was built for: user avatars stored on disk, exposed at several named sizes.
$exposer = new \Knp\MediaExposer\Exposer($resolver);
$exposer->registerProvider('avatar', fn ($id) => new Media($id, 'avatar', new File("/uploads/avatars/{$id}.jpg")));
echo $exposer->expose(new Media($userId, 'avatar'), 'small');
If your only need is Storage::url($path), stop here — Laravel already does this and does it better.
Bind the Exposer as a singleton in a service provider. Construct it once, register all providers, and inject Exposer where needed. Rebuilding the resolver graph per request is wasted work, and providers frequently hold repository dependencies.
$this->app->singleton(Exposer::class, function ($app) {
$resolver = new ChainResolver([
new WebPathResolver('/uploads', '/uploads'),
$app->make(CdnResolver::class),
]);
$exposer = new Exposer($resolver);
$exposer->registerProvider('avatar', fn ($id) => $app->make(AvatarProvider::class)($id));
return $exposer;
});
One provider per source, keyed by the exact source string. The source name is the join key between the Media object and the provider registry, and a typo produces a "no provider" failure rather than a silent fallback. Keep the names in a small constants class or enum rather than free-floating strings.
Use the chain resolver as your format-dispatch mechanism. Rather than one resolver with a switch ($format), register several narrow resolvers in priority order: a filesystem resolver for originals, a CDN resolver for derived sizes, and a last-resort resolver that throws a domain-specific exception. This keeps each resolver trivially testable.
Wrap it behind an application-level service. Do not let Exposer::expose() calls spread through controllers and Blade. Introduce something like MediaUrlResolver that takes your model, translates it into a Media, calls the library, and caches the result on the model or in the cache store keyed by "{$model->id}:{$format}". This gives you one place to swap the implementation later — which you almost certainly will, given the package's status.
For a custom backend (S3, a signed CDN), implement a resolver, not a fork. A resolver needs only supports(Media $media, $format) and resolve(Media $media, $format). Keep supports() cheap — a dictionary lookup on source and format, never an I/O call — because it runs for every resolver in the chain on every expose.
Testing. Resolvers are pure enough to unit test directly. For feature tests, bind a FakeResolver that returns "https://cdn.test/{$media->getId()}.jpg" for everything; this removes filesystem and network dependencies from anything that renders a view.
Migration path. Because the library returns a bare string, an adapter is easy: implement your MediaUrlResolver interface today over MediaExposer, and later over Storage::disk('s3')->url() plus an image-variant table. Nothing else in the codebase needs to change.
Symfony\Component\HttpKernel\Exception. Wrap library exceptions (RuntimeException when nothing supports a media/format pair) in your own so you control the HTTP status.expose() returns a string, not an object. There is no metadata, no MIME type, no dimensions, no expiry. If you need those — and for responsive images you do — you are maintaining a parallel data structure.null format must be explicitly supported by one of your resolvers, or you will get an exception on the simplest call. Make the "default" resolver the last in the chain and have it handle null.ChainResolver takes the first that supports; inserting a new resolver at the front silently changes output for previously working formats. Cover ordering with a test.File is a value object holding both a path and a URL, and the two can disagree. Be explicit about which one each resolver reads. Mixing getPath() and getUrl() in the same resolver is the most common source of // and ./ artifacts in output.Media and registerProvider(), a resolver that returns false/null from supports() for an unexpected reason, or a provider that threw and was swallowed. Log inside supports() temporarily; there is no debug mode to enable.ResolverInterface and a custom provider callable. Everything else requires forking. That is a small enough surface to keep patched, which is the only thing that makes this package viable at all.spatie/laravel-medialibrary or plain Flysystem covers this ground with maintained code. Adopt MediaExposer only to work inside an existing codebase that already depends on it, and isolate it behind a service boundary from day one.How can I help you explore Laravel packages today?