Media Vault for Laravel + Filament: upload once, reuse everywhere. One vault storage copy, many gallery memberships, morph attachments for your domain models.
Works standalone in any Filament app. Optional integrations with VoodBuilder (Asset Manager), Voodflow (e.g. Approval Page heroes), and any third-party package that registers its own vault root.
Built on Filament 5 and Spatie Media Library.
Central library: preview, galleries, usage counts, type, size, and upload time. Upload media or Import ZIP from the header.
Organize vault media with a hierarchy: Folder = container (year, product, plugin…); Album = holds photos/files. Mark a default gallery and toggle public albums.
Edit album metadata (parent, slug, public/default), manage images (browse library, upload, ZIP import, drag reorder), and assign album tags.
Modal for forms and builders: upload into the selected folder/album, filter by parent/gallery, search, grid/list view, multi-select → Use selection.
| Requirement | Notes |
|---|---|
| PHP | 8.4+ |
| Laravel | 12 or 13 |
| Filament | 5 |
| Spatie Media Library | 11 |
PHP gd extension |
Must be built with JPEG (and preferably WebP + FreeType). Spatie generates thumb conversions on upload; without JPEG support you get imagecreatefromstring(): No JPEG support in this PHP build. |
Verify in the app container:
php -r 'var_export(gd_info()["JPEG Support"] ?? false);'
# expect: trueDocker / php:*-fpm example:
RUN apt-get update && apt-get install -y \
libpng-dev libjpeg-dev libwebp-dev libfreetype6-dev \
&& docker-php-ext-configure gd --with-freetype --with-jpeg --with-webp \
&& docker-php-ext-install gdcomposer require voodflow/vmedia
php artisan vmedia:installRegister on the Filament panel:
->plugins([
\Voodflow\Vmedia\VmediaPlugin::make(),
])Attach media to a model:
use Voodflow\Vmedia\Concerns\HasAttachedMedia;
class Booth extends Model
{
use HasAttachedMedia;
}Upload in a Filament form:
use Voodflow\Vmedia\Filament\Forms\Components\VmediaFileUpload;
VmediaFileUpload::make('logo')
->collection('logo')
->image();Pick existing vault media:
use Voodflow\Vmedia\Filament\Forms\Components\VmediaPicker;
VmediaPicker::make('gallery')
->collection('gallery')
->images()
->multiple();VoodMedia does not hardcode product folders. Each package (or the host app) registers a top-level folder + default Library album, then uses it for uploads/pickers.
In your package ServiceProvider (or AppServiceProvider):
use Voodflow\Vmedia\Support\Integration\RegistersPluginVault;
public function boot(): void
{
if (! class_exists(RegistersPluginVault::class)) {
return;
}
// source = stable id (integration_source), slug = folder slug in the UI
RegistersPluginVault::register(
source: 'acme',
slug: 'acme',
name: 'Acme', // or fn (): string => __('acme::nav.media_root')
// integrationKey: 'root:acme', // optional; default root:{slug}
);
RegistersPluginVault::ensureOnBoot($this->app, 'acme');
}Equivalent low-level API:
use Voodflow\Vmedia\Vmedia;
Vmedia::registerPluginVault('acme', 'acme', 'Acme');
Vmedia::ensurePluginVault('acme'); // or RegistersPluginVault::ensureOnBoot(...)After boot you get a folder Acme with a child Library album (upload target when browsing that folder).
Sync all registered roots (after companions have booted):
php artisan vmedia:ensure-plugin-rootsuse Voodflow\Vmedia\Support\Integration\PluginVaultRootGroup;
use Voodflow\Vmedia\Support\Integration\PluginVaultLibraryGallery;
use Voodflow\Vmedia\Support\MediaLibrary;
use Voodflow\Vmedia\Filament\Forms\Components\VmediaPicker;
// Top-level folder (KIND_GROUP)
$root = PluginVaultRootGroup::for('acme');
// Default Library album under that folder (KIND_ALBUM) — prefer this for uploads
$library = PluginVaultLibraryGallery::album('acme');
MediaLibrary::store($uploadedFile, $library);
// Lock a Filament picker to your plugin library
VmediaPicker::make('hero')
->images()
->attachToRecord(false)
->vaultGallery(fn (): int => (int) PluginVaultLibraryGallery::album('acme')->getKey());Optional: nest under a configured parent (settings / multi-tenant layout):
PluginVaultRootGroup::for('acme', $optionalOuterRootId);| Concept | Rule |
|---|---|
source |
Stable string stored as integration_source (e.g. acme, voodflow) |
slug |
URL/path segment for the folder |
| Library album | Auto-created under the root; use it as default upload target |
| Isolation | Your package owns only its source; do not hardcode other products in VoodMedia |
Shared Logos folder is provided by VoodMedia itself (PluginVaultRootGroup::logos()).
- Vault — singleton Spatie owner of every file on disk (one storage copy)
- Galleries — many-to-many membership (a photo/video/file can sit in several galleries)
- Folders vs albums — folders nest structure; albums hold media
- Default gallery — fallback target for uploads when no gallery is selected; Choose can browse any gallery
- Attachments —
HasAttachedMediamorph pivot (domain models never own Spatie collections)
| Area | What you get |
|---|---|
| Admin | Library + galleries hierarchy, metadata (alt, caption, credits; video poster on videos), soft delete / trash |
| Picker | VmediaPicker + VmediaFileUpload for other plugins |
| Files | Photos, videos, and documents (PDF/Office/ZIP…) |
| Thumbs | Configurable Spatie conversion ladder (thumb + sm/md/lg/xl WebP by default); API exposes display / srcset / sizes |
| Usage | Attachment counts; delete protected when media is in use |
| Tags | Media tags + album tags; bulk assign |
| Duplicates | SHA-256 reuse (optional) |
| ZIP import | Bulk extract into the vault |
| Events | MediaStored, MediaAttached, MediaDetached, MediaDeleted, MediaRestored |
| Stats | Dashboard widget + php artisan vmedia:stats |
| Orphans | php artisan vmedia:prune-orphans --force |
| Public | GET /galleries/{slug} Blade gallery for is_public galleries |
| Method | Path | Role |
|---|---|---|
GET |
/vmedia/media/galleries |
Gallery list + counts |
GET |
/vmedia/media?page=&per_page=&gallery_id=&type=&q= |
Paginated assets (type: image|video|file) |
POST |
/vmedia/media/upload |
Upload; optional gallery_id (folder/album — folders resolve to library album) |
DELETE |
/vmedia/media/{media} |
Soft-delete (?force=1 force-deletes) |
All routes require authentication (and optional Gate ability VMEDIA_ABILITY).
VMEDIA_ENABLED=true
VMEDIA_DISK=public
VMEDIA_CONVERSIONS=true
VMEDIA_DETECT_DUPLICATES=true
VMEDIA_PROTECT_DELETE=true
VMEDIA_PUBLIC_GALLERIES=trueVMEDIA_DISK is any disk from Laravel’s config/filesystems.php. Spatie stores vault files on that disk (MediaVault → useDisk(...)).
Local (default):
VMEDIA_DISK=publicS3 (or compatible): define the disk as usual, then point VoodMedia at it:
FILESYSTEM_DISK=s3
AWS_ACCESS_KEY_ID=…
AWS_SECRET_ACCESS_KEY=…
AWS_DEFAULT_REGION=eu-west-1
AWS_BUCKET=your-bucket
AWS_URL=https://your-bucket.s3.eu-west-1.amazonaws.com
# optional: AWS_ENDPOINT=… AWS_USE_PATH_STYLE_ENDPOINT=true (MinIO, R2, …)
VMEDIA_DISK=s3config/filesystems.php (stock Laravel s3 disk is enough):
'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),
],Require the Flysystem S3 adapter if the host app does not already:
composer require league/flysystem-aws-s3-v3 "^3.0"No VoodMedia-specific S3 code path: Filament uploads and Spatie conversions use the configured disk. Keep Livewire temporary uploads on a local disk if you prefer (LIVEWIRE_TEMPORARY_FILE_UPLOAD_DISK=local); only the final vault objects need VMEDIA_DISK=s3.
Public URLs come from Storage::disk(...)->url(...) (local → /storage/..., S3 → bucket/CloudFront URL). Use a public bucket (or CDN AWS_URL) so thumbs and embeds resolve without signed URLs.
Public documentation: docs.voodflow.com
MIT — see LICENSE.




