| Install | |
|---|---|
composer require okamal/laravel-media-zone |
|
| Latest Version: | v1.0.0 |
| PHP: | ^8.1|^8.2|^8.3|^8.4 |
Elegant polymorphic media management for Laravel + Inertia.js
Organize uploads by zones. One trait. Beautiful Vue 3 component. Zero hassle.
avatar, gallery, documents, etc.)| Package | Version |
|---|---|
| PHP | ^8.1, ^8.2, ^8.3, ^8.4 |
| Laravel | ^10.0, ^11.0, ^12.0 |
| Inertia.js | ^1.0 or ^2.0 |
| Vue | ^3.3, ^3.4, or ^3.5 |
| Bootstrap | ^5.3 |
Note: This package includes a Vue 3 component styled with Bootstrap 5.3. Make sure your project uses Bootstrap 5.3+.
composer require okamal/laravel-media-zone
npm install vue3-uuid
Your project should already have
vue,@inertiajs/vue3, andaxiosinstalled.
Publish configuration, migrations, and the Vue component:
php artisan vendor:publish --provider="OKamal\LaravelMediaZone\LaravelMediaZoneServiceProvider"
Or publish individually:
# Publish config file
php artisan vendor:publish --tag=media-zone-config
# Publish migrations
php artisan vendor:publish --tag=media-zone-migrations
# Publish Vue component
php artisan vendor:publish --tag=media-zone-components
php artisan migrate
If you haven't already:
php artisan storage:link
Review and customize config/media-zone.php if needed.
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use OKamal\LaravelMediaZone\Traits\HasMediaZones;
class Post extends Model
{
use HasMediaZones;
protected $fillable = ['title', 'content'];
}
The component is published to resources/js/Components/MediaZone/MediaZoneUpload.vue.
<template>
<form @submit.prevent="submit">
<!-- Title Input -->
<div class="mb-3">
<label class="form-label">Title</label>
<input v-model="form.title" type="text" class="form-control" />
</div>
<!-- Single File Upload -->
<div class="mb-3">
<MediaZoneUpload
label="Featured Image"
model="App\Models\Post"
zone="featured_image"
accept="image/*"
helper-text="Upload a featured image for your post (max 5MB)"
:input-error="form.errors.featured_image"
v-model="form.featured_image"
/>
</div>
<!-- Multiple Files Upload -->
<div class="mb-3">
<MediaZoneUpload
label="Gallery"
model="App\Models\Post"
zone="gallery"
:multiple="true"
:max-files="10"
accept="image/*"
helper-text="Upload up to 10 images"
v-model="form.gallery"
/>
</div>
<button type="submit" class="btn btn-primary">
Create Post
</button>
</form>
</template>
<script setup>
import { useForm } from '@inertiajs/vue3';
import MediaZoneUpload from '@/Components/MediaZone/MediaZoneUpload.vue';
const form = useForm({
title: '',
featured_image: null,
gallery: []
});
const submit = () => {
form.transform(data => ({
...data,
featured_image: data.featured_image?.id,
gallery: data.gallery?.map(img => img.id) || []
})).post(route('posts.store'));
};
</script>
<?php
namespace App\Http\Controllers;
use App\Models\Post;
use Illuminate\Http\Request;
class PostController extends Controller
{
public function store(Request $request)
{
$request->validate([
'title' => 'required|string|max:255',
'featured_image' => 'required|integer|exists:media,id',
'gallery' => 'nullable|array',
'gallery.*' => 'integer|exists:media,id',
]);
$post = Post::create($request->only('title'));
// Sync media to model
$post->syncMedia([
'featured_image' => [$request->featured_image],
'gallery' => $request->gallery ?? []
]);
return redirect()->route('posts.index')
->with('success', 'Post created successfully!');
}
}
Add accessors to your model for easy access:
use Illuminate\Database\Eloquent\Casts\Attribute;
protected function featuredImage(): Attribute
{
return Attribute::make(
get: fn() => $this->getFirstMediaByZone('featured_image'),
);
}
protected function gallery(): Attribute
{
return Attribute::make(
get: fn() => $this->getMediaByZone('gallery'),
);
}
Display in your views:
<!-- Featured Image -->
@if($post->featured_image)
<img src="{{ $post->featured_image->url }}" alt="{{ $post->title }}">
@endif
<!-- Gallery -->
@foreach($post->gallery as $image)
<img src="{{ $image->url }}" alt="Gallery image">
@endforeach
That's it! 🎉
The configuration file is located at config/media-zone.php:
return [
// Storage disk (must be configured in config/filesystems.php)
'disk' => env('MEDIA_ZONE_DISK', 'public'),
// Base path for media files
'base_path' => env('MEDIA_ZONE_BASE_PATH', 'media'),
// Temporary upload path
'temp_path' => env('MEDIA_ZONE_TEMP_PATH', 'media/temp'),
// Global validation defaults
'validation' => [
'max_file_size' => 10240, // KB (10MB)
'allowed_mime_types' => [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/svg+xml',
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'video/mp4',
'video/mpeg',
],
],
// Auto-delete physical files when media records are deleted
'auto_delete_files' => true,
// Cleanup old temporary files
'cleanup_temp_files' => [
'enabled' => true,
'older_than_hours' => 24,
],
// Routes configuration
'routes' => [
'enabled' => true,
'prefix' => 'api/media-zone',
'middleware' => ['web', 'auth'],
'name' => 'media-zone.',
],
// Per-model validation configurations
'models' => [
// Example:
// App\Models\Post::class => App\MediaZone\PostMediaConfig::class,
],
];
| Prop | Type | Default | Required | Description |
|---|---|---|---|---|
label |
String | '' |
No | Label text above the file input |
model |
String | - | Yes | Full model class name (e.g., App\Models\Post) |
zone |
String | - | Yes | Zone identifier (e.g., avatar, gallery) |
multiple |
Boolean | false |
No | Allow multiple file uploads |
maxFiles |
Number | null |
No | Maximum number of files (only for multiple uploads) |
accept |
String | '*' |
No | File type filter (e.g., image/*, .pdf) |
inputError |
String | '' |
No | Validation error message |
helperText |
String | '' |
No | Helper text below the input |
uploadRoute |
String | null |
No | Custom upload endpoint |
deleteRoute |
String | null |
No | Custom delete endpoint (use :id placeholder) |
Example:
<MediaZoneUpload
label="Profile Picture"
model="App\Models\User"
zone="avatar"
accept="image/jpeg,image/png"
helper-text="Square image recommended. Max 2MB."
:input-error="form.errors.avatar"
v-model="form.avatar"
/>
// Get all media for this model
$model->media; // Collection
// Get media by zone
$model->getMediaByZone('gallery'); // Collection
// Get first media in zone
$model->getFirstMediaByZone('avatar'); // Media|null
// Get media URL (helper)
$model->getMediaUrl('avatar'); // string|null
// Sync media (recommended for form submissions)
$post->syncMedia([
'featured_image' => [$imageId],
'gallery' => [$id1, $id2, $id3],
'documents' => [$docId]
]);
// Add single media to zone
$post->addMediaToZone($mediaId, 'avatar');
// Replace all media in a zone
$post->replaceMediaInZone([$newId1, $newId2], 'gallery');
// Check if model has media in a zone
if ($post->hasMediaInZone('featured_image')) {
// Has featured image
}
// Clear specific zone
$post->clearMediaZone('gallery');
// Delete all media for this model
$post->deleteMedia();
// Get storage directory for a zone
$path = $post->mediaStorageDirectory('gallery');
// Example output: "media/posts/galleries"
$media = $post->featured_image;
$media->id; // int
$media->name; // string - Filename
$media->url; // string - Public URL
$media->mime_type; // string - MIME type
$media->size; // int - Size in bytes
$media->human_size; // string - Formatted size (e.g., "2.5 MB")
$media->zone; // string - Zone name
$media->storage_path; // string - Disk path
// Type checks
$media->isImage(); // bool
$media->isVideo(); // bool
$media->isDocument(); // bool
Create custom validation rules for specific models and zones.
<?php
namespace App\MediaZone;
use OKamal\LaravelMediaZone\Contracts\MediaZoneConfig;
class PostMediaConfig implements MediaZoneConfig
{
public function rules(string $zone): array
{
return match ($zone) {
'featured_image' => [
'featured_image' => [
'required',
'image',
'max:5120', // 5MB
'mimes:jpeg,png,webp',
'dimensions:min_width=800,min_height=600',
],
],
'gallery' => [
'gallery' => [
'required',
'image',
'max:2048', // 2MB
'mimes:jpeg,png',
],
],
'attachments' => [
'attachments' => [
'required',
'file',
'max:10240', // 10MB
'mimes:pdf,doc,docx',
],
],
default => [],
};
}
public function messages(string $zone): array
{
return match ($zone) {
'featured_image' => [
'featured_image.required' => 'Please upload a featured image.',
'featured_image.dimensions' => 'Featured image must be at least 800x600 pixels.',
'featured_image.max' => 'Featured image must not exceed 5MB.',
],
'gallery' => [
'gallery.max' => 'Each gallery image must not exceed 2MB.',
],
default => [],
};
}
public function zones(): array
{
return ['featured_image', 'gallery', 'attachments'];
}
public function isMultiple(string $zone): bool
{
return in_array($zone, ['gallery', 'attachments']);
}
}
// config/media-zone.php
'models' => [
App\Models\Post::class => App\MediaZone\PostMediaConfig::class,
],
Now all uploads for Post model will use these custom rules! 🎉
Files uploaded but not attached to any model are automatically cleaned up:
php artisan media-zone:cleanup
Schedule Automatic Cleanup:
Add to app/Console/Kernel.php:
protected function schedule(Schedule $schedule)
{
$schedule->command('media-zone:cleanup')->daily();
}
// Model
class User extends Authenticatable
{
use HasMediaZones;
}
<!-- Form -->
<MediaZoneUpload
label="Profile Picture"
model="App\Models\User"
zone="avatar"
accept="image/*"
v-model="form.avatar"
/>
<MediaZoneUpload
label="Cover Photo"
model="App\Models\User"
zone="cover"
accept="image/*"
v-model="form.cover"
/>
// Controller
public function updateProfile(Request $request)
{
$user = auth()->user();
$user->syncMedia([
'avatar' => [$request->avatar],
'cover' => [$request->cover],
]);
return back()->with('success', 'Profile updated!');
}
// Model
class Product extends Model
{
use HasMediaZones;
}
<!-- Form -->
<MediaZoneUpload
label="Product Images"
model="App\Models\Product"
zone="images"
:multiple="true"
:max-files="10"
accept="image/*"
v-model="form.images"
/>
// Accessor
protected function images(): Attribute
{
return Attribute::make(
get: fn() => $this->getMediaByZone('images'),
);
}
// Display
foreach($product->images as $image) {
echo "<img src='{$image->url}'>";
}
class Contract extends Model
{
use HasMediaZones;
}
<MediaZoneUpload
label="Contract PDF"
model="App\Models\Contract"
zone="contract_pdf"
accept=".pdf"
v-model="form.contract_pdf"
/>
<MediaZoneUpload
label="Supporting Documents"
model="App\Models\Contract"
zone="supporting_docs"
:multiple="true"
accept=".pdf,.doc,.docx"
v-model="form.supporting_docs"
/>
The Vue component is styled with Bootstrap 5.3 classes. Make sure your project includes Bootstrap 5.3+.
If you're using Bootstrap via npm:
npm install bootstrap@5.3
// In your app.js
import 'bootstrap/dist/css/bootstrap.min.css';
The component uses slots, so you can customize the markup:
<MediaZoneUpload
model="App\Models\Post"
zone="image"
v-model="form.image"
>
<template #label>
<h3 class="my-custom-label">Upload Your Image</h3>
</template>
<template #preview>
<!-- Your custom preview markup -->
</template>
</MediaZoneUpload>
Disable package routes and define your own:
// config/media-zone.php
'routes' => [
'enabled' => false,
],
// routes/web.php
use OKamal\LaravelMediaZone\Http\Controllers\MediaZoneController;
Route::post('/custom-upload', [MediaZoneController::class, 'store'])
->name('custom.upload');
<!-- Component -->
<MediaZoneUpload
upload-route="/custom-upload"
delete-route="/custom-delete/:id"
...
/>
Prevent N+1 queries:
$posts = Post::with('media')->get();
foreach ($posts as $post) {
$post->featured_image; // No additional query
}
Override the storage path method in your model:
public function mediaStorageDirectory(?string $zone = null): string
{
// Organize by user
return "media/users/{$this->user_id}/posts/" . str($zone)->plural();
}
Q: Do I need Bootstrap?
A: Yes, the Vue component uses Bootstrap 5.3 classes. A Tailwind version may be added in the future.
Q: Can I use this with React or Svelte?
A: Currently, only Vue 3 is supported. React/Svelte components may be added in future versions.
Q: Does this work with Laravel Livewire?
A: No, this package is specifically designed for Inertia.js.
Q: Can media be shared across multiple models?
A: Yes! The package uses a many-to-many polymorphic relationship, so the same media can be attached to multiple models.
See CHANGELOG.md for recent changes.
Contributions are welcome! Please feel free to submit a Pull Request.
git checkout -b feature/amazing-feature)git commit -m 'Add some amazing feature')git push origin feature/amazing-feature)If you discover a security vulnerability, please email e.omarkamal@gmail.com. All security vulnerabilities will be promptly addressed.
The MIT License (MIT). See LICENSE for details.
If this package saves you time:
Inspired by the needs of the Laravel + Inertia.js community and the excellent work of packages like Spatie's Media Library.
Need custom Laravel development or want to hire me for your project? Get in touch!
Made with ❤️ by Omar Kamal
If this package helps your project, please give it a ⭐ star!