kryption / laravel-mediahub
Multi-tenant media library for Laravel: folder tree, trash, quotas, derivatives, streamed archives and signed URLs.
Requires
- php: ^8.2
- illuminate/bus: ^12.0 || ^13.0
- illuminate/database: ^12.0 || ^13.0
- illuminate/filesystem: ^12.0 || ^13.0
- illuminate/http: ^12.0 || ^13.0
- illuminate/queue: ^12.0 || ^13.0
- illuminate/support: ^12.0 || ^13.0
- illuminate/translation: ^12.0 || ^13.0
- maennchen/zipstream-php: ^3.1
- symfony/process: ^7.0 || ^8.0
Requires (Dev)
- orchestra/testbench: ^10.0 || ^11.0
- phpunit/phpunit: ^11.0 || ^12.0
Suggests
- ext-gd: To build thumbnails for the common image formats (default driver).
- ext-imagick: For the formats GD cannot read: TIFF, HEIC, PDF.
This package is auto-updated.
Last update: 2026-08-26 15:31:37 UTC
README
A multi-tenant media library for Laravel. Folder tree, trash, quotas, derivatives, streamed archives, signed and expiring URLs — and no opinion about where your files belong.
composer require kryption/laravel-mediahub
That is the whole installation. Every point of contact with your application has a default that
works as it is: no scoping, no quota, the public disk. You replace only what concerns you.
⚠️ A ready-made bundle for an application with no build ships on tagged versions only.
composer require kryption/laravel-mediahub:^0.2 carries one; dev-main does not, and the
failure is quiet — the API answers and the page renders nothing. See delivery.
Status:
0.x, and the number is the promise. Everything documented here exists and is covered by the test suite; what is missing is listed in Roadmap. What0.xsays is that the public surface is still moving — Composer treats every minor as possibly breaking, which is the truth of this package today rather than a formality. Pin a minor if that matters to you.
Requirements
| PHP | 8.2 or later |
| Laravel | 12 or 13 |
| Extensions | fileinfo, json, mbstring |
GD and ImageMagick are optional. Without either, the package installs and works: files are uploaded, filed and served — there are simply no thumbnails.
Laravel 10 and 11 are not supported: Composer refuses to install them, every version carrying open security advisories.
What it does differently
You decide where files land. The package sanitises the folder you give it and stops there. How media are organised belongs to your trade — an agency files by client and campaign, an intranet by department, a messaging product by thread. What it keeps for itself is what nobody wants to write twice: path traversal closed off, and derivatives filed next to their original.
Scoping is a global scope, not a where clause you have to remember. Declare a
MediaScope and every read and every write is bounded by it — including the screen somebody
writes a year from now.
A path is never a URL. The database keeps a disk and a relative path; the URL is computed when serving. You can change storage, move behind a cache or turn signing on without migrating a single row.
No disk name, no table, no route is hardcoded. The package writes nothing into tables it does not own, reserves no global alias or function, and never changes your application's configuration at runtime.
Media attach to your models through one relation. HasMedia replaces the *_media_id
column that every model grows on its own — and addExistingMedia() attaches a file the user
already has, which an upload field cannot do at all.
It can adopt an existing schema. In table mode it maps onto tables you already have —
renamed columns, missing columns, 0 instead of NULL for "no parent" — without the rest of
your code knowing.
Everything streams. Uploads, downloads, Range requests for video, and multi-file archives
go through readStream/writeStream. Nothing loads a whole file into memory, and no temporary
archive is ever written to disk.
Quick start
Uploading
use Kryption\MediaHub\Actions\UploadMedia; use Kryption\MediaHub\ValueObjects\UploadedPayload; $media = app(UploadMedia::class)( UploadedPayload::fromUploadedFile($request->file('photo')), ['directory' => 'clients/acme/winter-campaign'], );
The payload has four entrances — an uploaded file, a local path, a Laravel disk, a stream — and all four go through the same validation, quota check, naming and write.
Serving
use Kryption\MediaHub\Contracts\UrlGenerator; $urls = app(UrlGenerator::class); $urls->url($media); // signed, expiring, direct from the storage when it can sign $urls->downloadUrl($media); // attachment, under the displayed name
Browsing
use Kryption\MediaHub\Actions\BrowseMedia; use Kryption\MediaHub\ValueObjects\BrowseQuery; $page = app(BrowseMedia::class)(BrowseQuery::fromInput($request->all(), $folder));
Sorting is an allow-list, the page size is capped, and every listing carries a tie-breaking second sort so no item shows up on two pages.
HTTP
The package ships its own routes — browse, upload, folders, trash, archive, serve, download —
inside the middleware group you choose. One route per operation, never a single entry point with
an action field.
'routes' => [ 'prefix' => 'media', 'middleware' => ['web', 'auth'], ],
An api group can be enabled alongside it, from the same route file, under its own prefix and
middleware.
Attaching media to your models
One polymorphic relation instead of a *_media_id column per case:
use Kryption\MediaHub\Concerns\HasMedia; use Kryption\MediaHub\Support\MediaCollections; class Post extends Model { use HasMedia; public function registerMediaCollections(MediaCollections $collections): void { $collections->add('cover')->single()->accepts('image/*')->maxSize(4096); $collections->add('attachments'); } }
$post->addMedia(UploadedPayload::fromUploadedFile($request->file('cover')), 'cover'); // ...or attach something the user already has, which is the point of a library $post->addExistingMedia($media, 'attachments'); $post->getMedia('attachments'); // in the order they were arranged $post->getFirstMediaUrl('cover'); // the declared fallback when there is none $post->syncMedia([$a, $b], 'attachments'); $post->removeMedia($a, 'attachments'); // detaches; the file stays in the library
addExistingMedia() is what an upload field cannot do. Attaching a file the user already
owns costs one row and no bytes — no re-upload, no second copy, nothing extra to delete later.
A collection is a rule, not a folder: single(), accepts(), maxSize(), onDisk(),
fallback(), conversions(). The rules are checked on the real type and before the bytes are
written, and a collection nobody declared is unconstrained rather than refused.
Derivatives, per collection
A cover and an attachment do not need the same thing. One is shown large and wants a wide version; the other is downloaded and wants nothing at all — and a single global list means every PDF in a folder of invoices costs queue time and storage for a thumbnail no screen ever shows.
$collections->add('cover')->conversions(['hero' => ['width' => 1200, 'fit' => 'contain']]); $collections->add('documents')->withoutConversions(); $collections->add('attachments'); // whatever the configuration says
⚠️ conversions() replaces the configured set rather than adding to it. Merging would make
the global thumb impossible to remove: a collection that wants one large image would get two,
with no way to say otherwise.
⚠️ And saying nothing keeps exactly what happened before. A collection without a mention gets the configured definitions, so adding this changes nothing for an installation that never asked for it.
A file chosen from the library gets them too, not only an upload — otherwise the one case a media library exists for would be the only one that never receives what the collection asked for. Derivatives are extra files keyed by name, so building one more takes nothing away from the other models pointing at the same media, and the original is never touched.
Fitting it to your application
Every point of contact is a contract with a working default. Bind only what you need — your binding always wins, whatever the order service providers boot in.
⚠️ The code you write to do that lives in your application, never in this package — it is called the glue, and it is usually fifteen lines. How to write it, with a complete worked example and the one default that is dangerous to leave alone.
| Contract | What it decides | Default |
|---|---|---|
MediaScope |
what partitions the library | no scoping |
PathGenerator |
where a file lands | the folder you give, sanitised |
FileNamer |
the name on disk | normalised, unique on the storage |
DiskResolver |
which disk | the one from the configuration |
QuotaPolicy |
how much room, how much is used | unlimited |
UploadValidator |
what is refused, and in which order | deep validation, content first |
MediaTypeResolver |
image, video, audio, document | deduced from the MIME type |
DuplicateResolver |
what to do with identical content | reuse the existing object |
ConversionDriver |
who builds the derivatives | GD, Imagick, or none |
UrlGenerator |
the URL of a media | signed and expiring |
AccessPolicy |
who may do what | the scope is the boundary |
Scoping, in full
use Illuminate\Database\Eloquent\Builder; use Kryption\MediaHub\Contracts\MediaScope; final class OrganisationScope implements MediaScope { public function currentKey(): ?string { return ($id = currentOrganisationId()) ? 'orgs/'.$id : null; } public function constrain(Builder $query): Builder { return $query->where('scope_key', $this->currentKey()); } }
$this->app->singleton(MediaScope::class, OrganisationScope::class);
The key is opaque: the package files it and bounds queries with it, and never interprets it.
null is a valid answer — a single-tenant product has no scope, and even elsewhere some files
belong to nobody in particular.
Security posture
- Uploads are validated on their content, never on what the client declares. Size, extension allow-list, real MIME type read from the first bytes, agreement between the two, image dimensions capped before decoding, and SVG refused as the executable document it is.
- Identifiers exposed are route keys, not database keys, so listings cannot be enumerated.
- "Not found" and "not yours" are the same answer. Telling them apart is an enumeration oracle.
- A batch is authorised whole, before the first write. Authorising as you go leaves half-run operations behind.
- Signed URLs actually expire. When signing is requested and no path can deliver it, the package raises rather than quietly handing back a permanent public link.
- The pipeline uses no secret at all, which is what keeps it verifiable for contributions coming from a fork.
Reporting a vulnerability: see SECURITY.md. Please do not open a public issue.
Adopting an existing schema
Point the package at tables you already have, and the rest of your code will not notice:
'backend' => [ 'driver' => 'table', 'preset' => 'legacy', 'map' => [ 'files' => ['path' => 'url'], // override the preset, column by column ], ],
A logical column may carry the same name, another name, or not exist at all — the third case is the one that shapes the design. What cannot be stored is derived on read where that makes sense, and refused for filtering where it does not: an accessor is not an index.
Testing
composer install vendor/bin/phpunit
Nothing else to install and nothing to start. A test that needs GD or Imagick skips itself when it is absent and says why — the same way the package does.
The package promises to work with or without an image library, and one machine cannot verify that. The pipeline therefore runs the same suite across PHP 8.2 / 8.3 / 8.4 and Laravel 12 / 13 with no image extension at all, then again with GD alone, with Imagick alone, and with both. That is how the promise is held rather than asserted.
Format detection is proven rather than observed. GdConversionDriver takes its capability
source as an argument, so a GD stripped of JPEG and WebP is described in one line and the
driver's answers are confronted with it — on any machine, including one with no GD at all.
Coverage floor: 85%, enforced by the pipeline.
Two languages ship complete, en and fr, and a test refuses a language that covers less than the other. With the markup frozen, translating is the only way to change a label — so a missing key would be a word nobody could change.
The browser side is held to the same floor, with npm run types and npm run test:coverage.
Its types are not merely declared: the fixtures they are checked against are written by the PHP
suite from real responses and committed, so the server cannot change the shape of a payload
without turning a test red on both sides. See the browser side.
Roadmap
| ✅ | contracts, defaults, path sanitising, naming |
| ✅ | models, migrations, uploading, derivatives, trash |
| ✅ | HTTP API, streamed serving and archives, signed URLs |
| ✅ | attaching media to host models — HasMedia, collections, addExistingMedia() |
| ✅ | the browser core — typed client, upload queue, no framework |
| ✅ | Vue 3 composables — browsing, selection, uploading, actions, picking |
| ✅ | the theme mechanism, the provider, and the primitives |
| ✅ | the standalone bundle, and the tag check that keeps it honest |
| ✅ | the picker, and media in a form — MhMediaInput, MhMediaGallery |
| ✅ | actions, uploading, quota and details |
| ✅ | the full library screen — MhMediaLibrary |
| ✅ | per-collection derivative definitions |
| ✅ | addMediaFromUrl(), guarded against request forgery |
⚠️ addMediaFromUrl() is guarded, and off until you turn it on.
// config/mediahub.php 'remote' => ['enabled' => true],
$post->addMediaFromUrl('https://example.com/photo.png', 'cover');
Fetching an address somebody else chose is a request-forgery primitive: the server sits inside your network and can reach the database, the queue, an admin panel bound to localhost and — on every major cloud — a metadata endpoint that hands out credentials to anything that asks. An installation that never uses this should not carry its risk, which is why it is off by default.
When it is on, the guard does the following, and a test is written for each as an attack rather than as a feature:
file://, gopher://, dict:// |
only http and https |
| loopback, RFC 1918, link-local, carrier-grade NAT, multicast | refused, IPv4 and IPv6 |
169.254.169.254 — cloud metadata |
refused |
::ffff:127.0.0.1, 2002::, 64:ff9b:: — IPv4 hiding in IPv6 |
unwrapped, then refused |
| DNS rebinding | resolved once, and the connection is pinned to the address that was checked |
| a redirect towards something internal | every hop re-checked, including the last |
| ports other than 80 and 443 | refused |
| a response of fifty gigabytes | capped in bytes while it arrives, and in time |
https://user:pass@host/ |
refused rather than stripped |
An allow-list of hosts is stronger than all of it, where you know the handful of places you fetch
from — and the match is exact, because example.com.attacker.test ends with example.com.
⚠️ What comes back is treated as an upload, not as something trusted. The same validation runs on the real bytes, the same quota is counted, the same naming and deduplication apply. A remote server does not get to decide what its file is by saying so in a header.
⚠️ The pinning goes through cURL. A host binding their own RemoteFetcher —
which is supported, and the right answer behind an egress proxy — inherits the whole obligation,
including that one. The contract says so.
Documentation
- Installation
- Usage
- Fitting it to your application
- Limits, and the machine underneath them
- The browser side
- Getting the browser side into your application
- Contributing
- Security policy
Contributing
Contributions are welcome. Read CONTRIBUTING.md first — it covers the branch naming the pipeline enforces, the environments the pipeline covers, and what a pull request is expected to carry. On your first one, an automation will ask you to accept the contributor agreement; you keep the copyright on what you write.
Licence
Apache-2.0. See NOTICE.