Search by

tbtop / spatie-media-library

divotek

Spatie media-library facade for tbtop/admin - collection-scoped image gallery field

Package info

github.com/DiVotek/tbtop-spatie-media-library

pkg:composer/tbtop/spatie-media-library

Statistics

Installs: 541

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.3.0 2026-10-09 13:31 UTC

README

An image picker for tbtop/admin forms, backed by spatie/laravel-medialibrary. The field lists one record's media collection, uploads into it, and imports images by URL.

It is a field, not a media library. Files belong to the record they are attached to — there is no shared pool, no folders, no cross-entity browsing. For files reused between entities, use the media library that ships with tbtop/admin.

The package is two halves in one repository: the PHP field (src/) and its React client (client/). Both are required.

Requirements

  • PHP 8.4, Laravel 11–13
  • tbtop/admin and @tbtop/inertia-admin (see peer ranges in composer.json / client/package.json)
  • spatie/laravel-medialibrary ^11

Install

Both halves publish from the same tag, so they cannot drift apart.

composer require tbtop/spatie-media-library
npm install @tbtop/spatie-media-library

Register the client field once, wherever your admin entrypoint registers its other fields:

import { registerMediaLibraryField } from "@tbtop/spatie-media-library";

registerMediaLibraryField();

Register the package for class detection, or its utilities are never generated and the field renders unstyled. Tailwind skips node_modules unless a source is declared explicitly:

@import "tailwindcss";
@source "../../node_modules/@tbtop/spatie-media-library/dist";

The path is relative to the stylesheet.

Usage

The model needs spatie's contract and a declared collection:

use Illuminate\Database\Eloquent\Model;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;

final class Post extends Model implements HasMedia
{
    use InteractsWithMedia;

    public function registerMediaCollections(): void
    {
        $this->addMediaCollection('banners');
    }
}

Then bind the field to that record and collection:

use Tbtop\SpatieMediaLibrary\Support\MediaGalleryOptions;

$s->form('post', [
    $s->imageGallery('banners')
        ->label('Post banners')
        ->forCollection($post, 'banners')
        ->multiple()
        ->rules('nullable|array'),
])->record(['banners' => MediaGalleryOptions::ids($post, 'banners')]);

forCollection() is the whole configuration. Endpoints are derived from the page path on the client — nothing to declare, nothing to keep in sync.

Without ->multiple() the field holds a single media id; with it, a list. MediaGalleryOptions::ids() shapes a collection for the form's record().

Order and removal on save

With ->multiple() the admin can drag tiles (or lift one with Space and move it with the arrow keys) to order the selection. The field only reorders its value; persist the order in the form's save handler so getMedia() follows it:

->onSubmit(function (ActionCtx $ctx) use ($post): void {
    if (! array_key_exists('banners', $ctx->form)) {
        return; // field not submitted: leave the collection alone
    }
    $ids = array_map('strval', (array) $ctx->form['banners']);

    MediaGalleryOptions::saveOrder($post, 'banners', $ids);
    // Optional, irreversible: delete media the admin removed from the field.
    MediaGalleryOptions::pruneUnselected($post, 'banners', $ids);
})

saveOrder() puts the given ids first and keeps the collection's other media after them; it deletes nothing. Removing a tile only edits the value — without pruneUnselected() the media stays in the collection, and with the record() above it comes back on reload. pruneUnselected() deletes rows and files, and an empty list empties the collection: a cleared field (null) becomes [] on purpose, while a missing key returns early so a request without the field deletes nothing.

The record must be persisted. Spatie derives the storage path from the model key, so attaching to an unsaved model is refused with a 422 rather than writing a row whose path cannot be built.

Uploading and URL import

The field posts to {page-path}/gallery-upload/{field}, registered per panel page. The target record and collection are read off the field on the re-resolved page and never from the request, so a caller cannot redirect an upload into another model. The upload runs under the same middleware as the page's other endpoints — the page's middleware() override when it has one, the panel's auth stack otherwise — and the page's own gate applies on top.

Uploads write immediately. The file is attached to the record when it is uploaded, not when the form is saved. For a collection with singleFile() or onlyKeepLatest(), spatie deletes the older media at that moment, so cancelling the dialog or the form does not bring it back.

When a collection declares acceptsMimeTypes() and the configured conversion would produce a type it refuses, the original file is stored unconverted. Files the collection refuses get a 422 with a short reason; spatie's own message is never returned, since it can carry server paths.

URL import reuses core's guards: private ranges and non-http schemes are blocked with DNS pinning against rebinding, redirects are refused, the transfer aborts when it exceeds the size ceiling, and the mime is verified from the downloaded bytes rather than the Content-Type header. Imports run synchronously and can take as long as the configured timeout.

Configuration

php artisan vendor:publish --tag=tbtop-spatie-media-library-config
Key Default Meaning
per_page 24 Rows returned per options request
conversion.format webp Re-encode format: webp, jpeg, png, or null to keep the original
conversion.quality 80 Encoder quality
accept ['image/*'] fnmatch patterns; text/html is refused regardless
url_import.enabled true Whether the URL import input is accepted

The import timeout, size ceiling and host allowlist are read from tbtop-admin.media — one setting for every ingestion path, rather than a second place to configure the same thing.

Development

composer test      # pest
composer analyse   # phpstan
composer format    # pint

cd client
bun install
bun run typecheck
bun run build      # writes client/dist

client/dist is a build artifact and is not committed: npm builds it from source through prepublishOnly when publishing, so the checkout never carries one.

client/package.json is the published npm manifest and the single source of truth for the version. The PHP half has no version of its own — Packagist reads it from the git tag.

Releasing

Bump the version in client/package.json and merge that to main, then run the Release workflow from main. It publishes the client to npm, pushes the vX.Y.Z tag that Packagist turns into the PHP release, and creates a GitHub Release with notes generated from the merged pull requests.

npm is published before the tag is pushed, so a failed publish leaves no dangling tag and a re-run stays clean.