Search by

kandysoft / media-kit

TheKandySoft

Storage-agnostic media library for Laravel: uploads, per-locale captions and on-demand image variants on top of the framework's filesystem disks.

Package info

github.com/TheKandySoft/media-kit

pkg:composer/kandysoft/media-kit

Statistics

Installs: 298

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.12.0 2026-11-06 11:29 UTC

This package is auto-updated.

Last update: 2026-11-06 11:32:08 UTC


README

A media library for Laravel that stores files on your disks. Uploads, per-locale captions and on-demand image variants — with no storage abstraction of its own to get in the way.

composer require kandysoft/media-kit
php artisan migrate

Storage

Anything in config/filesystems.php works: local, S3, MinIO, Google Cloud, whatever the host has configured. Point the package at one:

MEDIA_KIT_DISK=public
MEDIA_KIT_DIRECTORY=media

A disk counts as public when it declares 'visibility' => 'public'. Public files get a permanent URL from the disk; private files get a temporary one, or a signed route that streams through the application when the driver cannot mint temporary URLs.

Policy per disk

How long a link lives is a property of the disk that stores the file, not of the application. The top-level settings are the defaults; disks holds the exceptions:

'temporary_url_ttl' => 60,        // minutes
'variant_url_ttl'   => null,      // null — the address never expires
'cache_ttl'         => 31536000,  // seconds

'disks' => [
    'documents' => [
        'temporary_url_ttl' => 15,
        'variant_url_ttl'   => 15,
        'cache_ttl'         => 900,
    ],
],

A disk nobody lists behaves like every other one. Attaching the decision to where a file is stored is the point: a scanned document cannot acquire a permanent address by accident, because it is not kept where permanent addresses are handed out.

Variant links are signed but do not expire by default. The signature stops anyone requesting arbitrary sizes and filling the disk; it is not there to keep the picture secret. An expiry would change the address on every render, so browsers and CDNs would re-download bytes they already hold, and anything that reads an address once and fetches it later — an image crawler, a product feed, a link preview — would find it dead.

Warming, so the first visitor does not wait

Renditions are produced on demand: the first request for a size creates the file. That request pays for a redirect through the application, a signature check and a database lookup on the way to what is ultimately a static file — and on a catalogue the one paying is usually a search engine reading a page nobody has opened yet.

Warm them where the cost is free, and the address of a finished rendition points at the disk:

$writer->warmForModel($product, MediaFilter::images(), ImageSize::of(800, 600));

Reads decide per rendition: one that already exists is addressed on the disk, one that does not still gets the on-demand endpoint. Nothing has to be warmed for the package to work — warming only removes the application from the path.

Existence is answered from the recorded variants, not by asking the disk, so describing a listing of thirty pictures costs one query rather than ninety stat calls. A private disk keeps the endpoint either way: a direct address there is a temporary link with a lifetime of its own, which would step around the disk's policy.

What the package can and cannot cache

Served files carry Cache-Control, and an answer never claims to live longer than the signed link that produced it. For a public disk, though, the package answers with a redirect to the disk's own URL, so these headers apply to the redirect: the bytes themselves are served by nginx, S3 or the CDN in front of them, under their configuration. Set the cache headers there too, or most of the benefit stays on the table.

Writing

use KandySoft\MediaKit\Contracts\MediaWriter;
use KandySoft\MediaKit\Data\MediaCaption;
use KandySoft\MediaKit\Data\MediaUpload;

public function __construct(private readonly MediaWriter $media) {}

$this->media->sync($product, [
    MediaUpload::file($request->file('photo'), tag: 'gallery', captions: [
        new MediaCaption('en', alt: 'Front view'),
        new MediaCaption('uk', alt: 'Вигляд спереду'),
    ]),
    MediaUpload::keep($existingUuid, tag: 'gallery'),
]);

Anything not in the list is deleted, together with the variants generated from it. A file needs no owner at all — store() with $model = null keeps it standalone under its tag.

Replacing keeps the UUID, so links and stored references survive:

$this->media->replace($logo->uuid, $request->file('logo'));

Captions change without the file:

$this->media->caption($logo->uuid, [new MediaCaption('uk', alt: 'Логотип', title: 'Бренд')]);

So does the part of a picture its renditions show — see chosen area:

$this->media->crop($photo->uuid, MediaCrop::of(x: 0.55, y: 0.0, width: 0.45, height: 1.0));

Reading

use KandySoft\MediaKit\Contracts\MediaReader;
use KandySoft\MediaKit\Data\ImageSize;
use KandySoft\MediaKit\Data\MediaFilter;

$images = $reader->forModel(
    $product,
    MediaFilter::images(captionLocale: 'uk', withFallback: true),
    ImageSize::of(800, 600),
);

$images[0]->url;                       // the original
$images[0]->variant('medium')?->url;   // one rendition
$images[0]->srcset();                  // all of them, for the browser to choose from
$images[0]->placeholder;               // a tiny data: URI to show meanwhile
$images[0]->focus();                   // where the chosen area sits in the renditions, or null
$images[0]->caption?->alt;

srcset() matters more than it looks: without it a phone downloads the picture a desktop gets, to paint it a third of the size, over the connection least able to afford it. placeholder is a copy a few hundred bytes big, stored beside the image — the card shows it instead of an empty box while the real file is on its way.

Every read is handed out under the policy of the disk the file lives on. When one caller needs something shorter — an admin preview, an export leaving the building — pass a lifetime and it replaces the policy for that read, original and variants together:

use KandySoft\MediaKit\Data\LinkLifetime;

$preview = $reader->byUuid($uuid, size: ImageSize::of(400, 300), lifetime: LinkLifetime::minutes(15));

Reads return MediaResource objects, never models or loose arrays. The disk, the path and the owning model stay inside the package.

Facade or injection

The facades resolve the very same singletons the container injects, so both styles are equivalent:

MediaRead::forModel($product);            // facade
app(MediaReader::class)->forModel($product);  // container

Identity

Files are addressed by UUID, everywhere. There is no auto-increment key — not in the schema, not in URLs, not in the payloads clients receive.

Configuration

php artisan vendor:publish --tag=media-kit-config for the full file. The interesting parts:

Key Purpose
disk, directory where files land
image.driver gd or imagick
image.variants named multipliers of the requested size
image.quality encoder quality per format (webp, jpg, avif); bind Contracts\RenditionQuality to choose it elsewhere — see rendition quality
image.upscale false (default) stops a rendition at its original's size instead of enlarging it
image.crop_position, image.strip_metadata, image.progressive, image.sharpen how a rendition is cropped and encoded — see rendition quality
image.resample, image.srgb Lanczos scaling and conversion to sRGB (Imagick)
image.original limits, quality and metadata stripping for the uploaded original
image.version raise it to regenerate every rendition
temporary_url_ttl, variant_url_ttl link lifetimes, in minutes; empty means no expiry
cache_ttl how long a served answer may be reused, in seconds
disks per-disk overrides of the three above
routes.enabled, routes.prefix, routes.middleware serving endpoints, or none at all
routes.url the root links start at, when a proxy rewrites the address — see storage
types extensions accepted on top of the built-in ones, per type
encryption.enabled, encryption.keys, encryption.current, disks.<name>.encrypt encryption at rest — see storage
video.* web copies and posters of videos — see video

Video

A phone's video — HEVC in a .mov, 10-bit, 60 fps, 4K, tens of megabytes — plays in Safari and nowhere else. Uploaded, it is stored as it came, and the package makes from it what a page can show:

  • A web copy: H.264 in MP4 with the index at the front, scaled to video.max_dimension on the longer side, at most video.max_fps, BT.709 (HDR tone-mapped), without metadata — GPS included — and without sound if video.audio is off. A 5-second 1080 × 1920 HEVC clip of 11 MB comes out at about 1 MB.
  • A poster: a frame of it as a JPEG — an image with renditions, a srcset and a placeholder like any other, so a page has a picture before the video plays, and instead of it.
  • Its size and length, read by ffprobe while it is stored — upright, for a phone video recorded on its side.

The copy is made in the background by Jobs\ProcessVideo, dispatched after the record is committed (video.connection, video.queue). Until it exists the original is served; when it is done, Events\VideoProcessed tells the application, which rebuilds whatever cached the video's description. Without ffmpeg on the worker nothing is made and nothing fails.

What a reader gets for a video:

$video->url;             // the web copy, or the original until it is made
$video->mimeType;        // video/mp4
$video->width, $video->height, $video->duration;
$video->poster;          // MediaResource of the frame: url, variants, srcset, placeholder
$video->placeholder;     // the poster's
$video->focus();         // object-position keeping the chosen area in a frame of the requested size
$video->fit;             // MediaFit::Contain when it is to be shown whole; null is cover

Placing it in a frame. A gallery shows every file in one frame — a product photo's. By default a video fills it and the rest is cut, as a picture's rendition is cut (MediaFit::Cover). MediaWriter::crop() chooses the part to keep, as for a picture: the poster is cut from it, and focus() is the object-position that shows the same part of the video in a frame of the size the reader asked for. MediaWriter::fit($uuid, MediaFit::Contain) — or fit on a MediaUpload — asks for the whole video with bars instead. The copy itself is never cut.

Choosing the poster. By default the poster is the frame at video.poster_at. A person picks another one — or a picture of their own:

MediaWrite::poster($uuid, MediaPoster::frame(2.4));                 // the frame at 2.4 s
MediaWrite::poster($uuid, MediaPoster::image($request->file('p'))); // an uploaded picture
MediaWrite::poster($uuid, MediaPoster::frame(null));                // back to the default

poster on a MediaUpload does the same from a synced form (['at' => 2.4] or ['image' => $file]). A frame is made in the background, and only the frame: the copy stays. Until it is ready the poster made before is shown. An uploaded picture is a rendition of the video — cut from the area chosen for it, deleted with it.

Cover. cover on a MediaUpload marks the file a list shows for its owner — a product card showing a video while the gallery opens with a photo. MediaResource::$cover, and cover: true in its array.

Every video.* key that shapes the copy is part of its path, like an image rendition's: change one and run media:videos to make new copies; the old ones are deleted as the new ones arrive. video.threads and video.lookahead keep an encode small on a worker with a fraction of a core — with 1 and 20, a phone clip encodes in under 200 MB.

The worker needs a timeout above video.timeout and a connection whose retry_after is above that, or a slow encode starts a second time while the first runs.

Rendition quality

Every setting that shapes the pixels — quality, crop anchor, metadata, sharpening and image.version — is part of a rendition's path, and so is a file's chosen area. Changing one does not overwrite anything: the next request for a size makes a new file at a new path, and the old file is simply no longer asked for. Renditions are matched by that path, so a file of the right size made at yesterday's quality is never served again.

  • Quality from elsewhere. image.quality is only the default answer of Contracts\RenditionQuality. A host that lets someone choose it — a setting in an admin panel — binds its own implementation; it is asked at the moment of encoding, so a long-running worker never holds a stale value.
  • No enlarging by default. A 1200-pixel original asked for at 2400 gives a 1200-pixel rendition with the same proportions, not a blurred 2400. Two variants may then share a width; the srcset lists it once. Set image.upscale to true for exact sizes.
  • Metadata is stripped (image.strip_metadata): camera, time and GPS position do not leave with the picture.
  • Colour and detail survive on Imagick: a picture with a colour profile is converted to sRGB first (image.srgb), and scaling uses the Lanczos filter (image.resample).
  • The original itself is stored as it came unless image.original sets limits or asks for its metadata to go — then it is scaled down, converted and re-encoded once, on upload.
  • media:regenerate makes the renditions again right away instead of on first request.
  • After changing the settings, rebuild whatever links to renditions directly (a catalogue index that warmed them), then run media:prune to delete the files made with the old ones — or let the application do it: VariantPruner::stale() deletes renditions made with other settings (one file's, after its area changed, or all), and VariantPruner::retired() those of a size it stopped asking for, for one kind of file.

Commands

php artisan media:show <uuid>     # what a client would receive
php artisan media:adopt           # register files already on a disk
php artisan media:prune           # drop renditions nothing can reach any more
php artisan media:prune --model='App\Models\Product' --width=1200 --height=840
                                  # …and those of one owner no longer at this size
php artisan media:placeholders    # fill in placeholders for images stored earlier
php artisan media:regenerate      # make renditions again under the current settings (--prune)
php artisan media:videos          # make the missing web copies and posters of videos (--queue, --force)
php artisan media:key             # print a new encryption key
php artisan media:encrypt         # encrypt files stored before encryption was on
php artisan media:decrypt         # …and back
php artisan media:rekey           # rewrap every file key with the current key

Documentation

License

MIT — see LICENSE and NOTICE.