TabletopDOCS
Packages

Media library

tbtop/spatie-media-library: an image picker for tbtop/admin forms backed by one record's spatie/laravel-medialibrary collection, with upload and URL import.

This page documents tbtop/spatie-media-library v0.1.2. The package releases independently of tbtop/admin and works with tbtop/admin ^0.4 || ^0.5. Source: DiVotek/tbtop-spatie-media-library.

What it is

The package adds one form field, $s->imageGallery(), that lists the files in one record's spatie/laravel-medialibrary collection, uploads new files into it, and imports images by URL. The field submits the ids of the picked media.

It is a field, not a media manager. Files belong to the record they are attached to: there is no shared pool, no folders and no browsing across records.

You needUse
Images that belong to one model already on spatie media collectionsimageGallery from this package
Files that editors reuse across recordsThe built-in media field
A single file stored as a path on a columnThe built-in upload field

Requirements: PHP 8.4, Laravel 11–13, spatie/laravel-medialibrary ^11, and @tbtop/inertia-admin ^0.4 or ^0.5 with React 19 on the client.

Install

The package has a PHP half and a React half, published from the same tag. Install both:

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

The service provider is auto-discovered. It registers the imageGallery field kind and one upload route per admin page. The package ships no migrations of its own: the media table is spatie's. If the app does not use spatie/laravel-medialibrary yet, publish and run its migration as spatie's installation guide describes.

Register the client field in resources/js/admin.tsx, before createInertiaApp() runs, the same place as any custom client component:

resources/js/admin.tsx
import { registerMediaLibraryField } from "@tbtop/spatie-media-library";

registerMediaLibraryField();

Tailwind skips node_modules, so declare the package's build as a source in resources/css/admin.css, or the field renders unstyled. The path is relative to the stylesheet:

resources/css/admin.css
@source "../../node_modules/@tbtop/spatie-media-library/dist";

Using the field in a form

The model implements spatie's HasMedia and declares the collection:

app/Models/Product.php
use Illuminate\Database\Eloquent\Model;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;

final class Product extends Model implements HasMedia
{
    use InteractsWithMedia;

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

On the edit page, bind the field to the record and the collection with forCollection(), and seed the form with the collection's current ids:

use Tbtop\Admin\Actions\ActionCtx;
use Tbtop\Admin\Actions\Effects;
use Tbtop\Admin\Dsl\Actions\FormActions;
use Tbtop\SpatieMediaLibrary\Support\MediaGalleryOptions;

$s->form('product', [
    $s->text('name')->label('Name')->required(),
    $s->imageGallery('photos')->label('Photos')
        ->forCollection($product, 'photos')
        ->multiple()
        ->rules('nullable|array'),
    $s->actionsRow([FormActions::save($s)]),
])
    ->record([
        'name' => $product->name,
        'photos' => MediaGalleryOptions::ids($product, 'photos'),
    ])
    ->onSubmit(function (ActionCtx $ctx) use ($product): Effects {
        $product->update(['name' => $ctx->form['name']]);

        // Keep only ids that really belong to this collection.
        $picked = array_values(array_intersect(
            (array) ($ctx->form['photos'] ?? []),
            MediaGalleryOptions::ids($product, 'photos'),
        ));

        // Deleting what the editor deselected is your call; the field itself never deletes.
        $product->getMedia('photos')
            ->reject(fn ($media) => in_array((string) $media->getKey(), $picked, true))
            ->each->delete();

        return Effects::make()->notify('Saved');
    }),

forCollection() is the whole configuration. The client derives both endpoints from the page path, so the PHP side declares no URLs. imageGallery extends the core select, so multiple(), label(), rules() and the rest of the common field API work as usual.

The value is a media id as a string, or with multiple() a list of ids in the order the editor picked them; an empty selection submits null. Like every field, it saves nothing on submit: what the ids mean — a kept set, a cover, an order — is up to your handler.

In the form, the field shows the picked images as tiles and a "+" tile. Clicking a tile replaces that one image; the × on a tile removes it from the selection. The picker dialog searches the collection by media name. Non-image files in the collection show as a file icon labelled with their MIME subtype (for example pdf). In a table, the cell shows the count, such as "3 images".

The record must be saved. Spatie builds the storage path from the model key, so an upload to an unsaved model is refused with a 422. Use the field on edit pages, or drop it from a create page with ->when($product->exists).

Uploading and URL import

The picker dialog has an Upload button and a URL input. Both post to {page-path}/gallery-upload/{field}, a route the package registers for every page of every panel in tbtop-admin.panels. The page's can() gate applies, but a page-level middleware() override does not: the upload route always runs under the panel's default stack. If a page relies on extra middleware for access control, express that check as the page's can() gate too. The target record and collection are read from the field on the re-resolved page, never from the request, so a caller cannot redirect an upload into another model.

A stored file is added to the collection at once, before the form is saved, and is pre-selected in the dialog.

On the way in:

  • Size. Capped at tbtop-admin.media.max_size (kilobytes, 10 MiB by default), for uploads and imports alike.
  • Type. The detected MIME type must match the package's accept list. text/html is refused whatever the list says.
  • Conversion. Raster images are re-encoded to conversion.format with the original name and a new extension. GD keeps the first frame of an animated GIF and drops EXIF metadata, including orientation. Set conversion.format to null if you need animation or camera orientation preserved. A format GD cannot encode, or a file GD cannot decode, is stored unchanged.
  • SVG. A stored SVG is sanitised, detected from its content rather than its claimed type. An SVG that cannot be sanitised is refused with a 422 (svg_invalid), but v0.1.2 has already created its media row, which stays in the collection without a file. Delete such rows in your submit handler; the example above removes every unpicked row.

Spatie's own checks still run after these: raising tbtop-admin.media.max_size above spatie's media-library.max_file_size, or a collection with acceptsMimeTypes()/acceptsFile(), makes a rejected upload fail with a 500 rather than a 422. Keep the two size limits aligned.

URL import uses core's fetcher: private address ranges and non-HTTP schemes are blocked with DNS pinning, redirects are refused, the download aborts past the size cap, and the MIME type is checked from the downloaded bytes. The timeout (tbtop-admin.media.url_import.timeout, 30 seconds) and the host allowlist (tbtop-admin.media.url_import.allowed_hosts) come from core's config. The import runs synchronously inside the request.

Files are stored on the disk spatie is configured to use, not on tbtop-admin.media.disk.

Configuration

Publish the config file to change the defaults:

php artisan vendor:publish --tag=tbtop-spatie-media-library-config
KeyDefaultMeaning
per_page24Most rows the picker lists per request
conversion.format'webp'Re-encode format: webp, jpeg or png; null keeps the original
conversion.quality80Encoder quality
accept['image/*']Allowed MIME types, as fnmatch patterns
url_import.enabledtrueWhether URL import is accepted. When false the input still shows in the dialog, and an import returns a 422 'URL import is disabled.'

These settings are global. The field has no per-field override for format, accepted types or limits.

Limitations

What v0.1.2 does not do:

  • No deletion from storage. Removing a tile only drops the id from the value. Files uploaded and then deselected, or uploaded in a form that is never saved, stay in the collection. Delete them in your submit handler, as in the example above.
  • No check of submitted ids. The field does not verify that the ids it receives belong to the collection. Intersect them with MediaGalleryOptions::ids() before use.
  • No shared library. Only the bound record's collection is listed. For files reused across records, use the core media field.
  • No cropping, alt text or custom properties. Edit those through spatie's API in your own code.
  • No drag sorting. The order is the order of picking. Spatie's Media::setNewOrder() can persist an order you compute.
  • Listing is capped at per_page, without pagination. Search narrows by name. The tiles of picked images are resolved from the same capped list. A picked file beyond the first per_page of the collection does not show, and while one is hidden, × and replace act on the wrong position: they can drop or overwrite a different image than the one clicked. A file uploaded into a collection that already holds per_page items does not appear in the dialog. Keep per_page above the largest collection the field will ever see.
  • Collection rules apply at upload time. On a singleFile() or onlyKeepLatest() collection, an upload deletes the older files at once, before the form is saved.
  • Page middleware does not guard uploads. The upload route runs under the panel's default stack, not a page's middleware() override. Put access checks the upload must respect in the page's can() gate.
  • A refused SVG leaves a media row behind. The row points at a deleted file and lists as a broken tile until your submit handler removes it.
  • The browser file dialog offers images only. Widening accept affects URL import and server checks; the Upload button's dialog still filters to image/*.
  • English-only interface. The dialog's labels and messages are not translated.

On this page