Resources

Media Gallery

Upload, storage, thumbnails, auto-cropping, and the REST + Filament UI surfaces.

Media Gallery

Tagixo ships a complete media management system: upload, storage abstraction, thumbnail generation, and a gallery picker that integrates directly with the visual builder. Everything described on this page lives in the core package (ccast/tagixo) and is wired up through your own Laravel controllers and routes.

If you are using an admin panel SDK, refer to the Filament or Primix SDK documentation sections for the ready-made UI surfaces.

The Media Model

The Ccast\Tagixo\Models\Media Eloquent model maps to the tgx_media table. Originals and derived crops both live in this table; crops are differentiated by a non-null parent_id.

tgx_media table schema

Column Type Notes
id bigint Auto-increment PK
uuid varchar(36) Auto-generated on create; used in public URLs
filename varchar Stored filename (may be randomised)
original_filename varchar User-supplied filename at upload time
disk varchar Laravel filesystem disk name
path varchar Relative path within the disk
mime_type varchar Full MIME, e.g. image/jpeg
extension varchar Lowercase extension without dot
size bigint File size in bytes
width int|null Pixel width (images only)
height int|null Pixel height (images only)
alt_text text|null Editable accessibility label
title varchar|null Human-readable title
description text|null Optional long description
folder varchar|null Slash-delimited folder path, e.g. marketing/hero
metadata json|null Extension point for app-specific fields
uploaded_by bigint|null FK to your users table; set to null on user delete
parent_id bigint|null Null on originals; set to parent id on crops
thumbs json|null Map of preset name → stored path for generated crops
created_at timestamp
updated_at timestamp
deleted_at timestamp|null Soft-delete column

Relations

// All defined on Ccast\Tagixo\Models\Media

public function uploader(): BelongsTo  // → your User model
public function original(): BelongsTo  // → Media (parent record)
public function crops(): HasMany       // → Media records with matching parent_id

Scopes

Scope SQL effect
originals() WHERE parent_id IS NULL
images() WHERE mime_type LIKE 'image/%'
videos() WHERE mime_type LIKE 'video/%'
documents() Non-image, non-video MIME types
inFolder(?string $folder) WHERE folder = ?null matches root/no-folder items
search(string $term) Matches on filename, original_filename, alt_text, title

Boot hooks

A UUID is generated automatically on creating. On hard-delete (deleting), the model removes the physical file from the configured disk and any generated thumbnail files. The soft-delete column is available; file cleanup only fires on a force-delete.

Configuration

Publish the config once if it is not already present:

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

The relevant block in config/tagixo.php:

'media_gallery' => [
    'disk'            => env('TAGIXO_MEDIA_DISK', 'public'),
    'path'            => 'media',           // base folder within the disk
    'max_file_size'   => 20480,             // kilobytes — 20 MB default
    'accepted_types'  => [                  // MIME prefix allowlist
        'image/*',
        'video/*',
        'application/pdf',
        'application/zip',
    ],
    'generate_thumbs' => true,              // false to skip thumbnail generation entirely
    'thumb_quality'   => 85,               // JPEG quality for generated thumbnails
    'crop_presets'    => [
        'thumbnail' => ['width' => 150,  'height' => 150,  'fit' => 'crop'],
        '4x3'       => ['width' => 800,  'height' => 600,  'fit' => 'contain'],
        '16x9'      => ['width' => 1920, 'height' => 1080, 'fit' => 'contain'],
    ],
    'signed_url_ttl'  => 1440,             // minutes; only applies to private disks
],

Disk configuration

Point TAGIXO_MEDIA_DISK at any disk defined in config/filesystems.php. The gallery works with any Laravel disk driver:

# .env — local public disk (default)
TAGIXO_MEDIA_DISK=public

# .env — S3-compatible (AWS S3, MinIO, Cloudflare R2)
TAGIXO_MEDIA_DISK=s3
// config/filesystems.php — MinIO example
'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'),
    'endpoint'                => env('AWS_ENDPOINT'),
    'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
    'visibility'              => 'private',
],

Visibility matters. If the disk is private (or your bucket policy blocks anonymous reads), the model issues signed temporary URLs automatically — see Signed URLs below. You do not need to branch on disk type in application code.

The Upload Endpoint

The builder's image-picker modal posts files to an upload endpoint you register in your application. You configure that URL when bootstrapping the builder (see the Standalone Integration page). A typical setup:

// routes/web.php
Route::middleware('auth')->group(function () {
    Route::post('/builder/media/upload', [MediaController::class, 'upload']);
});
// app/Http/Controllers/MediaController.php
namespace App\Http\Controllers;

use Ccast\Tagixo\Media\MediaUploader;
use Illuminate\Http\Request;

class MediaController extends Controller
{
    public function upload(Request $request, MediaUploader $uploader)
    {
        $request->validate([
            'file'   => ['required', 'file', 'max:' . config('tagixo.media_gallery.max_file_size')],
            'folder' => ['nullable', 'string', 'max:255'],
        ]);

        $media = $uploader->upload(
            file: $request->file('file'),
            disk: config('tagixo.media_gallery.disk'),
            folder: $request->input('folder'),
            uploadedBy: $request->user()?->id,
        );

        return response()->json([
            'id'       => $media->id,
            'uuid'     => $media->uuid,
            'url'      => $media->url(),
            'filename' => $media->original_filename,
            'width'    => $media->width,
            'height'   => $media->height,
            'thumbs'   => $media->thumbs,
        ]);
    }
}

MediaUploader::upload() stores the file, creates the tgx_media record, and — if generate_thumbs is enabled — dispatches thumbnail generation. The JSON response shape shown above is what the builder's picker modal expects to receive after a successful upload.

Accepted types enforcement

The accepted_types config key is also enforced server-side inside MediaUploader. An upload whose MIME type does not match any entry in the allowlist throws a Ccast\Tagixo\Exceptions\MediaTypeNotAllowedException, which you can catch and convert to a validation error response:

use Ccast\Tagixo\Exceptions\MediaTypeNotAllowedException;

try {
    $media = $uploader->upload(...);
} catch (MediaTypeNotAllowedException $e) {
    return response()->json(['message' => $e->getMessage()], 422);
}

Thumbnail Generation

When generate_thumbs is true, MediaUploader dispatches a queued job (Ccast\Tagixo\Jobs\GenerateMediaThumbs) immediately after the record is created. The job iterates crop_presets, resizes/crops using the Intervention Image library, stores each result on the same disk, and writes the path map to tgx_media.thumbs.

Make sure your queue worker is running:

php artisan queue:work

To generate thumbnails for previously uploaded media (e.g. after adding a new preset):

use Ccast\Tagixo\Jobs\GenerateMediaThumbs;
use Ccast\Tagixo\Models\Media;

Media::originals()->images()->each(
    fn ($m) => GenerateMediaThumbs::dispatch($m)
);

Access a specific thumb URL at runtime:

$thumbPath = $media->thumbs['thumbnail'] ?? null;

if ($thumbPath) {
    $url = Storage::disk($media->disk)->url($thumbPath);
}

Intervention Image is a required peer dependency. Run composer require intervention/image if it is not already in your composer.json. Tagixo supports both Intervention Image v2 (GD/Imagick) and v3.

The Gallery Picker in the Builder

When a module exposes an image prop (type ImagePropType or any media_id slot), the builder renders a "Choose from gallery" button inside the prop drawer. Clicking it opens a gallery modal iframed from a URL you define in your bootstrap payload. The builder communicates with the modal via postMessage.

The gallery endpoint must:

  1. Return an HTML page that lists existing media items.
  2. Post a tagixo:media-selected message to window.parent when the user picks an item.

Registering the gallery URL in the bootstrap endpoint

// In your builder bootstrap controller (GET /builder/bootstrap):
return response()->json([
    // ... other builder config
    'media' => [
        'gallery_url' => route('builder.media.gallery'),
        'upload_url'  => route('builder.media.upload'),
    ],
]);

Implementing the gallery route

// routes/web.php
Route::middleware('auth')->group(function () {
    Route::get('/builder/media/gallery', [MediaController::class, 'gallery']);
});
// app/Http/Controllers/MediaController.php

public function gallery(Request $request)
{
    $query = Media::originals()->images()->latest();

    if ($search = $request->input('search')) {
        $query->search($search);
    }

    if ($folder = $request->input('folder')) {
        $query->inFolder($folder);
    }

    $items = $query->paginate(40)->through(fn ($m) => [
        'id'       => $m->id,
        'uuid'     => $m->uuid,
        'url'      => $m->url(),
        'thumb'    => $m->thumbs['thumbnail'] ?? $m->url(),
        'filename' => $m->original_filename,
        'alt_text' => $m->alt_text,
        'width'    => $m->width,
        'height'   => $m->height,
    ]);

    return view('builder.media-gallery', compact('items'));
}

The gallery view must call window.parent.postMessage with a specific shape when the user selects an item:

{{-- resources/views/builder/media-gallery.blade.php --}}
<script>
function selectMedia(item) {
    window.parent.postMessage({
        type:  'tagixo:media-selected',
        media: item   // the JSON object from your items collection
    }, '*');
}
</script>

Each item card in the gallery view calls selectMedia({{ json_encode($item) }}) on click. The builder closes the modal and populates the image prop with the returned id, uuid, and url automatically.

Authentication. The gallery page is loaded in an iframe inside the builder canvas, using the same session cookie as the parent window. Standard auth middleware works without any extra token handling, provided your session cookie is not scoped to a sub-path that would block the iframe request.

Using Media Items in Rendering

Builder JSON stores a media_id (the integer PK). In your module renderer, resolve the record and call url():

use Ccast\Tagixo\Models\Media;

$media = Media::find($props['image']['media_id'] ?? null);

$src   = $media?->url() ?? $props['image']['url'] ?? '';
$alt   = $media?->alt_text ?? $props['image']['alt'] ?? '';

The fallback to $props['image']['url'] handles cases where the builder stored an external URL directly rather than an uploaded media item.

For bulk rendering (listing pages, galleries), eager-load to avoid N+1:

$mediaIds = collect($pageModules)
    ->pluck('props.image.media_id')
    ->filter()
    ->unique();

$mediaMap = Media::whereIn('id', $mediaIds)->get()->keyBy('id');

// Later in a loop:
$media = $mediaMap->get($props['image']['media_id']);

Signed URLs for Private Disks

When the configured disk has 'visibility' => 'private' (or the storage backend requires authentication to read objects), $media->url() returns a signed temporary URL with the TTL set by signed_url_ttl (default 1440 minutes / 24 hours).

// The model handles this transparently:
$url = $media->url();  // signed if disk is private, plain if public

Do not cache signed URLs beyond their TTL. If you render a page server-side and cache the output (Octane page cache, Varnish, Redis), strip media URLs from the cache key or set the cache TTL below the signing TTL. Signed URLs contain an expiry timestamp; serving an expired URL produces a 403 from the storage backend.

To customise the signing TTL per-call:

use Illuminate\Support\Facades\Storage;

$url = Storage::disk($media->disk)->temporaryUrl(
    $media->path,
    now()->addMinutes(30)
);

Deleting Media

Soft-delete keeps the tgx_media row and leaves the physical file intact — useful for audit trails or recovering a media reference from builder JSON:

$media->delete(); // soft-delete only

Hard-delete removes the row and the physical file from the disk:

$media->forceDelete();

Deleting a parent record with crops does not cascade automatically. Force-delete crops explicitly before or after:

$media->crops->each->forceDelete();
$media->forceDelete();

See also