optix/media
Optix Media is a Laravel package for managing uploads and media libraries, with image cropping, resizing, filters, and a clean API. It handles storage, validation, and conversions so you can add robust media management to any Laravel app quickly.
The package is installed via Composer and published with its service provider:
composer require optix/media
php artisan vendor:publish --provider="Optix\Media\MediaServiceProvider"
php artisan migrate
Before writing any feature code, check three things in this order:
composer.json constraints — v0.5.0 added Laravel 8.0 support and dropped PHP 7.2, so the floor is now PHP 7.3+ and illuminate/* ^8.0. Open vendor/optix/media/composer.json and confirm both match your app. On Laravel 8 you should resolve cleanly; on Laravel 9+ there is still no tagged support, so expect to pin illuminate/* via a Composer replace, fork the repo, or budget time to upgrade it.config/media.php (after publishing) — this is where the storage disk, upload path, and image conversion settings live. Most "it uploaded but the URL 404s" issues trace back here rather than to your controller.The canonical first use case: attach an uploaded image to an Eloquent model and render it.
// app/Models/Post.php
use Optix\Media\HasMedia;
class Post extends Model
{
use HasMedia;
}
// Controller
public function store(Request $request, Post $post)
{
$post = Post::create($request->validated());
$post->attachMedia(
$request->file('cover'),
'cover' // media group / collection name
);
return redirect()->route('posts.show', $post);
}
Render it with the accessor the trait exposes (typically getFirstMediaUrl('cover') or getMedia('cover')->first()->getUrl() — verify the exact method against the README, as naming shifted across versions).
Run php artisan storage:link once, or the URLs will silently point at a non-existent path.
Trait-per-model, group-per-slot. The idiomatic pattern is one HasMedia trait on each model, with a named group string for every logical slot (cover, gallery, avatar, attachment). This keeps queries simple (whereHas('media', fn ($q) => $q->where('group', 'cover'))) and avoids the temptation to hard-code a single file column on your table.
Controller-level upload, model-level storage. Keep UploadedFile handling in the controller (validation, max:, mime rules), then hand the file to the trait. That way web and API controllers share the same persistence path and your model stays free of request concerns.
$request->validate([
'cover' => ['required', 'image', 'max:4096'],
]);
$post->attachMedia($request->file('cover'), 'cover');
Replacing vs. appending. Upload slots are usually single-value, galleries are multi-value. Write a small helper on your model or a form request so a "replace cover" action clears the group first:
$post->clearMediaGroup('cover');
$post->attachMedia($file, 'cover');
Without this you accumulate orphaned files on every edit, which is the most common production surprise with media packages.
Conversions / thumbnails. If the package defines conversions in config (e.g. thumb, hero), pre-generate or queue them at upload time rather than on first request. First-request generation makes your TTFB depend on GD/Imagick being warm and is easy to miss in load tests.
Storage disk per environment. Point media.disk at public locally and s3 (or similar) in production via env, but do not switch disks on an existing database — the stored path is relative, and old rows will resolve against the new disk and 404. If you must migrate disks, plan a backfill/copy pass.
JSON/API responses. Wrap URL generation in an API Resource so the media shape is stable:
return [
'cover' => $post->getFirstMediaUrl('cover'),
'gallery' => $post->getMedia('gallery')->map->getUrl(),
];
Centralising this means a future swap to spatie/laravel-medialibrary touches one resource class instead of every controller.
Cleanup on delete. The package will not necessarily delete files when a model is deleted. Register an observer or model event:
static::deleting(fn ($model) => $model->clearMediaGroup());
and pair it with a scheduled command that sweeps media rows with no owning model — cheap insurance for polymorphic tables.
Form components. In Blade, a dedicated partial (<x-media-upload name="cover" :model="$post" />) that shows the current thumbnail plus a file input keeps create and edit views consistent and hides the replace-vs-append logic.
Laravel 8 is the ceiling, and that's the headline risk. v0.5.0 added Laravel 8.0 support and dropped PHP 7.2, which is the first meaningful compatibility movement in a long time — but it stops at Laravel 8, and Laravel 9/10/11 are still unsupported. Confirm your target before committing: Laravel 8 on PHP 7.3–8.0 is the safe envelope. Anything newer means a fork or an upgrade you own. Do a throwaway composer require in a scratch app first.
Dropping PHP 7.2 means your CI matrix must move too. If you run PHP 7.2 anywhere (local Docker images, older CI runners, a legacy service), the new constraint will fail resolution. Bump the matrix to at least 7.3 as part of the dependency update, not after.
PHP 8.1+ deprecations. The package's code predates the ReturnTypeWillChange sweep. Run your test suite with E_ALL and APP_DEBUG=true before shipping; deprecation noise in logs is usually this package, not your code. This matters specifically if you run Laravel 8 on PHP 8.1+, which is a common combination given the new constraint floor.
storage:link and disk visibility. Files land on the configured disk with the configured visibility. If public visibility isn't set on the disk, Storage::url() returns a path that 403s. Check both config/filesystems.php and config/media.php.
URLs are derived, not stored. The absolute URL depends on APP_URL and the disk's url setting. Changing APP_URL between environments (or forgetting it in a queue worker's env) produces dead links in emails and API payloads. Generate URLs at render time, never persist them.
Queue workers need the same config. Conversion jobs, if queued, read config/media.php at execution time. A worker started before a config change keeps the old disk. php artisan queue:restart after any deploy that touches media config.
Migration ordering. The published migration creates a table with a name that may collide if you already have a media table from another package. Rename the table in config and migration if you're integrating incrementally.
Orphan files are the default, not the exception. Neither model deletion nor detachMedia is guaranteed to unlink the underlying object. Treat "delete the DB row" and "delete the file" as two separate operations in your code and tests.
Method names drift between versions. Because the package is low-traffic, blog posts and Stack Overflow answers are thin and often target an older API. When in doubt, read vendor/optix/media/src/ — the trait and model are small enough to audit in ten minutes, and that audit will save you an hour of guessing.
Debugging uploads. When an upload "succeeds" but nothing appears: check storage/logs/laravel.log for the disk path actually used, confirm php artisan storage:link, then hit the file directly with Storage::disk(config('media.disk'))->exists($path) in tinker. That three-step sequence isolates config, symlink, and permission problems faster than reading the package source.
Extension point to prefer. If you need custom behaviour (signed URLs, on-the-fly resizing, CDN rewrites), override the media model in config and extend it rather than patching vendor code. That keeps composer update survivable — which matters more than usual for a package whose supported Laravel range trails current releases.
How can I help you explore Laravel packages today?