Components

Media Gallery Field

Embed the Tagixo media gallery picker inside Filament forms with the SDK field component.

Media Gallery

The Tagixo Filament SDK ships a first-class Media Gallery feature: a standalone Filament resource page for browsing and uploading files, a MediaGalleryPickerField for forms, and a MediaGalleryColumn for tables. All three are opt-in and disabled by default.


1. Enabling the Media Gallery

Register the feature on the plugin instance inside your panel provider:

use Ccast\TagixoFilament\TagixoFilamentPlugin;

$panel->plugin(
    TagixoFilamentPlugin::make()
        ->withMediaGallery()
);

This registers the MediaGalleryResource, its upload controller, and all related routes. If you need to publish the default config:

php artisan vendor:publish --tag=tagixo-config

Key entries under config/tagixo.php:

Key Default Description
media.disk 'public' Storage disk for uploads
media.directory 'tagixo-media' Directory within the disk
media.max_size 10240 Max upload size in KB
media.accepted_mime ['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'image/svg+xml', 'application/pdf'] Allowed MIME types
media.signed_urls false Generate signed URLs (S3)
media.signed_ttl 60 Signed URL TTL in minutes

2. What the Media Gallery Adds

Once enabled, the plugin registers:

  • MediaGalleryResource — a Filament resource page accessible from the admin sidebar, showing a responsive thumbnail grid of all uploaded files stored in tgx_media.
  • Per-item metadata — each item exposes filename, original name, MIME type, file size, disk, and path. Clicking a thumbnail opens a detail drawer.
  • Navigation customisation — the resource accepts fluent builder methods to control its position in the sidebar:
TagixoFilamentPlugin::make()
    ->withMediaGallery()
    ->mediaGalleryNavigationLabel('Assets')
    ->mediaGalleryNavigationGroup('Content'),

3. MediaGalleryPickerField

MediaGalleryPickerField is a Filament form component that opens the gallery picker in a modal and writes the selected file reference back to the form state.

use Ccast\TagixoFilament\Forms\MediaGalleryPickerField;

MediaGalleryPickerField::make('featured_image_id')
    ->label('Featured Image')
    ->storeAs('id')           // 'id' | 'url' | 'path'
    ->accept(['image/*'])     // MIME filter shown in the picker
    ->clearable()
    ->columnSpanFull(),

storeAs() options

Value What is saved Best for
'id' The tgx_media.id integer FK relations, resolving URLs server-side
'url' The public URL of the file Simple display, no FK needed
'path' The disk-relative path Manual Storage::url() resolution

Storing the ID is the safest option: if you rename a file or switch disks, the URL is regenerated from the stored ID at render time. Storing url or path creates a snapshot that may go stale.

accept()

Pass an array of MIME type strings. The picker filters the gallery grid and blocks non-matching uploads from within the modal. Server-side upload validation is governed by config/tagixo.php media.accepted_mimeaccept() only narrows the client-side view.

clearable()

When set, a "Remove" button appears below the thumbnail, allowing the field to be reset to null. Pass clearable(false) to suppress it once a value is set.


4. MediaGalleryColumn

MediaGalleryColumn renders a fixed-size thumbnail preview of a media item inside a Filament table.

use Ccast\TagixoFilament\Tables\MediaGalleryColumn;

MediaGalleryColumn::make('featured_image_id')
    ->label('Image')
    ->size(48)          // pixel size of the square thumbnail
    ->shape('rounded'), // 'square' | 'rounded' | 'circle'

When the stored value is an ID, the column resolves the MediaItem and uses its cached public URL. When it is a raw URL or path string, it is used directly.

Options

Method Values Description
size(int $px) any integer Width and height of the rendered thumbnail
shape(string) 'square', 'rounded', 'circle' CSS border-radius preset

Avoid N+1 queries. If you display MediaGalleryColumn for a list resource, eager-load the media relationship in your Eloquent query. The column does not auto-eager-load.


5. Upload Flow in the Builder

When withMediaGallery() is enabled, the visual builder's image slot component detects the feature flag and swaps the native file-input for the gallery modal. The six-step event sequence:

  1. Editor clicks an image placeholder or the "Change image" button on an existing image module.
  2. The builder emits tagixo:open-media-picker on window with a { callbackKey, accept } payload.
  3. The gallery modal opens as a Filament overlay. Any accept() restrictions declared by the PropType are passed to the modal.
  4. The editor browses or uploads a new file within the modal.
  5. On selection, the modal dispatches tagixo:media-selected with { callbackKey, id, url, path }.
  6. The builder canvas updates the prop value and re-renders the module preview.

No page navigation occurs. The builder state (unsaved canvas changes, selected element, scroll position, undo stack) is fully preserved across the picker open/close cycle.


6. Upload Restrictions

Upload validation runs server-side in Ccast\Tagixo\Http\Controllers\MediaUploadController before the file is written to disk. Client-side restrictions mirror the server config but are not enforced independently.

Config key Default Description
media.accepted_mime See config Allowlist of MIME types. Upload rejected with 422 if not in list.
media.max_size 10240 (10 MB) Max file size in kilobytes.
media.directory tagixo-media Storage directory prefix within the configured disk.

To extend the MIME allowlist at runtime without republishing the config file, merge into the config in a service provider:

// In a service provider boot():
config(['tagixo.media.accepted_mime' => array_merge(
    config('tagixo.media.accepted_mime', []),
    ['video/mp4', 'image/avif'],
)]);

7. Disk Configuration

Local public disk (default)

With TAGIXO_MEDIA_DISK=public, files land in storage/app/public/tagixo-media/ and are served via the standard storage symlink. Ensure php artisan storage:link has run and APP_URL is set correctly — the public URL is built with Storage::url().

S3 and other remote disks

Point TAGIXO_MEDIA_DISK at any Laravel filesystem disk name defined in config/filesystems.php:

TAGIXO_MEDIA_DISK=s3
TAGIXO_MEDIA_SIGNED_URLS=true
TAGIXO_MEDIA_SIGNED_TTL=60
// config/filesystems.php
's3' => [
    'driver' => 's3',
    'key'    => env('AWS_ACCESS_KEY_ID'),
    'secret' => env('AWS_SECRET_ACCESS_KEY'),
    'region' => env('AWS_DEFAULT_REGION'),
    'bucket' => env('AWS_BUCKET'),
    'url'    => env('AWS_URL'),
],

With signed_urls=true, every URL returned by MediaItem::url() goes through Storage::temporaryUrl(). TTL is in minutes.

Signed URLs expire. Do not cache rendered page HTML that contains signed S3 URLs for longer than TAGIXO_MEDIA_SIGNED_TTL. Use a short full-page cache TTL or bypass the page cache when signed_urls is true.

Custom URL resolution (resolveUrlUsing)

For CDN rewrites or image-transform services (Imgix, Cloudflare Images), bypass the default URL resolution with an escape hatch registered in a service provider:

// In a service provider boot():
\Ccast\Tagixo\Models\MediaItem::resolveUrlUsing(function (MediaItem $item): string {
    return 'https://cdn.example.com/' . ltrim($item->path, '/');
});

This callback replaces the default Storage::url() / Storage::temporaryUrl() logic entirely. The signed_urls config has no effect when a custom resolver is registered.


8. Spatie Media Library Compatibility

ccast/tagixo-filament's media gallery is entirely independent of spatie/laravel-medialibrary.

Concern Behaviour
Package conflict None — both packages can be required simultaneously.
Shared file store Not by default. Tagixo files go to tagixo-media/; Spatie files go to media/.
Shared picker UI Not provided. MediaGalleryPickerField only sources from tgx_media.
MediaGalleryColumn with Spatie Not compatible. Use Filament's built-in ImageColumn with a Spatie accessor instead.

If you want a unified gallery that shows both Spatie and Tagixo media, project Spatie records into tgx_media via a model observer:

class SpatieMediaObserver
{
    public function created(\Spatie\MediaLibrary\MediaCollections\Models\Media $media): void
    {
        \Ccast\Tagixo\Models\MediaItem::firstOrCreate(
            ['path' => $media->getPath()],
            [
                'disk'      => $media->disk,
                'mime_type' => $media->mime_type,
                'size'      => $media->size,
                'name'      => $media->file_name,
            ]
        );
    }

    public function deleted(\Spatie\MediaLibrary\MediaCollections\Models\Media $media): void
    {
        \Ccast\Tagixo\Models\MediaItem::where('path', $media->getPath())->delete();
    }
}

Register in AppServiceProvider::boot():

\Spatie\MediaLibrary\MediaCollections\Models\Media::observe(SpatieMediaObserver::class);

This is a one-way sync: Tagixo sees Spatie files but does not manage their lifecycle.


9. Permission Gating

The gallery enforces two distinct capability levels: browse/pick (read, select) and upload/delete (write, manage).

Default behaviour

By default, any authenticated panel user can browse, pick, upload, and delete media. No policies are applied out of the box.

Plugin-level callbacks

The plugin exposes two callback methods for simple role checks:

TagixoFilamentPlugin::make()
    ->withMediaGallery()
    ->mediaGalleryUploadPolicy(function (): bool {
        return auth()->user()?->can('upload_media') ?? false;
    })
    ->mediaGalleryDeletePolicy(function (): bool {
        return auth()->user()?->hasRole('admin') ?? false;
    }),

When mediaGalleryUploadPolicy returns false, the "Upload" button is hidden in both the resource page and the modal, and POST /tagixo/media/upload returns 403.

When mediaGalleryDeletePolicy returns false, the delete action is removed from the resource table and DELETE /tagixo/media/{id} returns 403.

Laravel Gate / Policy

Register a standard Laravel policy for Ccast\Tagixo\Models\MediaItem for finer-grained control:

Gate::policy(\Ccast\Tagixo\Models\MediaItem::class, \App\Policies\MediaItemPolicy::class);
namespace App\Policies;

use App\Models\User;
use Ccast\Tagixo\Models\MediaItem;

class MediaItemPolicy
{
    public function viewAny(User $user): bool
    {
        return true; // all panel users can browse
    }

    public function create(User $user): bool
    {
        return $user->can('upload_media');
    }

    public function delete(User $user, MediaItem $item): bool
    {
        return $user->hasRole('admin') || $item->uploaded_by === $user->id;
    }
}

MediaGalleryResource checks these gates via Filament's standard policy resolution. The ->mediaGalleryUploadPolicy() and ->mediaGalleryDeletePolicy() callbacks take precedence over Gate policies when both are defined.

Browse-only panels. If you want certain panel users to be able to pick from existing media but never upload or delete, set both callbacks to return false for those users. The picker modal will still open — it loads the gallery in read-only mode.


10. Complete Example

Migration

// In a migration:
$table->unsignedBigInteger('featured_image_id')->nullable();
$table->foreign('featured_image_id')
      ->references('id')->on('tgx_media')
      ->nullOnDelete();

Eloquent relation

// app/Models/Post.php
public function featuredImage(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
    return $this->belongsTo(\Ccast\Tagixo\Models\MediaItem::class, 'featured_image_id');
}

PostResource

namespace App\Filament\Resources;

use App\Models\Post;
use Ccast\TagixoFilament\Forms\MediaGalleryPickerField;
use Ccast\TagixoFilament\Tables\MediaGalleryColumn;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;

class PostResource extends Resource
{
    protected static ?string $model = Post::class;

    public static function form(Form $form): Form
    {
        return $form->schema([
            TextInput::make('title')
                ->required()
                ->columnSpanFull(),

            MediaGalleryPickerField::make('featured_image_id')
                ->label('Featured image')
                ->storeAs('id')
                ->accept(['image/jpeg', 'image/png', 'image/webp'])
                ->columnSpanFull(),
        ]);
    }

    public static function table(Table $table): Table
    {
        return $table->columns([
            MediaGalleryColumn::make('featured_image_id')
                ->label('')
                ->size(56)
                ->shape('rounded'),

            TextColumn::make('title')->searchable()->sortable(),
            TextColumn::make('created_at')->dateTime()->sortable(),
        ]);
    }
}

Blade usage

@if ($post->featuredImage)
    <img
        src="{{ $post->featuredImage->url() }}"
        alt="{{ $post->title }}"
        class="w-full rounded-lg"
    />
@endif

See also