Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Media Laravel Package

waad/media

waad/media is a Laravel package for managing media files and images. It provides Eloquent-based media records, configurable storage disks, and a simple API for uploading, attaching, and organizing files on your models.

View on GitHub
Deep Wiki
Context7

Getting Started

Get the package installed and wired into a single model before touching anything else.

1. Install and publish

composer require waadmawlood/media

Then publish whatever the package ships. List the available tags rather than guessing — this package is small and its tags are not guaranteed to follow Laravel's -config / -migrations convention:

php artisan vendor:publish --provider="Waad\Media\MediaServiceProvider"

You should end up with a config/media.php (disk selection, path/prefix, allowed mime types, size limits) and a media migration.

2. Point it at a disk

FILESYSTEM_DISK=public   # local dev
# FILESYSTEM_DISK=s3     # production

The whole selling point of the package is that this one env var switches storage backends without code changes. Verify that by running the same upload path twice with different disks.

3. Local disk needs the symlink

php artisan storage:link

4. Run the migration and attach the trait

php artisan migrate
use Waad\Media\Traits\HasMedia; // confirm namespace in src/Traits

class Post extends Model
{
    use HasMedia;
}

5. First use case: a single avatar upload

Read src/ in this order — it takes ten minutes and saves you hours:

  1. src/Traits/* — the public API you'll actually call on your models
  2. src/Models/Media.php — the columns you can query, and whether there's a mediable polymorphic relation
  3. config/media.php — every behaviour you can override without forking
  4. src/*ServiceProvider.php — what's registered, whether routes/commands exist

Then write one happy-path test before integrating into a real form:

Storage::fake('public');

$post = Post::create(['title' => 'Hello']);
$post->attachFile($request->file('cover')); // check the real method name in the trait

$this->assertDatabaseHas('media', ['mediable_id' => $post->id]);
Storage::disk('public')->assertExists($post->media->first()->path);

That test is your contract. If the method names in the trait differ from the README, this is where you find out — not in production.

Implementation Patterns

Trait-per-model, media as a separate table

The package is built around "a model has media", not "a model has a file column". Lean into that: Post, User, Product all use HasMedia, and all files live in one media table with a polymorphic owner. This gives you one place to add virus scanning, one place to add a cleanup command, one place to audit S3 spend.

Keep file lifecycle inside the package

The cardinal rule: never call Storage::delete() on a path that a Media row owns. Always go through the package's delete method so the row and the object stay in sync.

// yes
$post->deleteMedia($media);

// no — leaves an orphan row, or worse, an orphan object
Storage::disk('s3')->delete($media->path);
$media->delete();

Wrap it in a small domain action so controllers never touch the package directly:

class ReplacePostCover
{
    public function __construct(private Post $post) {}

    public function handle(?UploadedFile $file): void
    {
        if (! $file) {
            return;
        }

        $this->post->clearMediaCollection('cover'); // or deleteMedia loop
        $this->post->attachFile($file, 'cover');
    }
}

That one indirection is what lets you swap the package later (see Gotchas).

Validate before you upload, and validate against the config

Don't hardcode max:2048 in a FormRequest and max:5120 in config/media.php. Read one source:

class StorePostRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'cover' => [
                'required', 'file',
                'mimes:'.implode(',', config('media.allowed_mimes', ['jpg','png','webp'])),
                'max:'.config('media.max_size_kb', 5120),
            ],
        ];
    }
}

The package will likely throw its own exception on rejection — catch it in the controller and convert to a validation error, otherwise users get a 500 for an oversized file.

Local in dev, S3 in prod — but test the S3 path once

Local public disk has no ACLs, no eventual consistency, no presigned URLs. Those are exactly the things that break. Before shipping, run one staging deploy with FILESYSTEM_DISK=s3 and exercise upload → display → replace → delete. If the package returns a plain Storage::url(), that only works for public buckets; for private buckets you need temporaryUrl() and probably a controller route that proxies it. Check config/media.php for a visibility setting and a "generate temporary URLs" flag before you discover this in production.

Eager load media on index pages

$posts = Post::with('media')->paginate(20);

If the relation is polymorphic and not eager-loaded, a 20-row index page with one thumbnail each is 21 queries. Profile the index page specifically — it's the classic regression.

Represent media in API resources, not raw models

Never let Media leak into a JSON response — it exposes internal paths and column names.

class PostResource extends JsonResource
{
    public function toArray($request): array
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'cover' => $this->getFirstMediaUrl('cover'), // confirm method name
        ];
    }
}

This also lets you switch to signed URLs for authenticated endpoints without touching the frontend contract.

Schedule a cleanup command

Even with discipline, orphans happen (failed transactions, deleted users, manual DB edits). Write an artisan command that finds media rows whose owner no longer exists, and a second pass that finds storage objects with no row. Run monthly. This is 30 lines of code and it's the difference between a tidy bucket and a mystery bill.

Test with Storage::fake() everywhere

protected function setUp(): void
{
    parent::setUp();
    Storage::fake('public');
    Storage::fake('s3');
}

This makes the local/S3 switch testable in CI with zero credentials, and it fails fast if the package hardcodes disk('public') somewhere instead of reading config.

Gotchas and Tips

Config changes vanish after caching. php artisan config:cache will freeze whatever was in config/media.php at build time. If you change the disk via .env in a deployed environment, run php artisan config:clear && php artisan config:cache. This eats an afternoon for everyone once.

storage:link is per-environment. Docker images, Forge deployments, and fresh CI containers all need it. Put it in your deploy script. S3 users don't need it — which means local devs and prod diverge, so test both.

S3 IAM permissions are the #1 silent failure. A bucket policy that allows PutObject but not DeleteObject produces a package that uploads fine and silently fails (or throws on) delete. Verify with the exact actions the package uses — typically s3:PutObject, s3:GetObject, s3:DeleteObject, and s3:PutObjectAcl if it sets ACLs. Also check the region in config/filesystems.php; wrong region gives confusing PermanentRedirect errors.

Public vs private bucket. Storage::url() on a private S3 bucket returns a URL that 403s. If your files should not be world-readable, set visibility to private in config and route everything through temporaryUrl(). Decide this on day one — migrating a populated bucket from public to private later means rewriting every stored URL.

Database transactions don't roll back files. If you upload inside a transaction and the transaction rolls back, the S3 object is already there. Either upload after commit (DB::afterCommit(fn () => ...)) or accept it and rely on the cleanup command. Don't learn this from a disk-full alert.

Hard deletes orphan files. $post->forceDelete() bypasses model events in some configurations. Register a deleting/forceDeleting listener, or better, override the package's deletion path for models that own media. Soft deletes are fine — the file stays, which is usually what you want for restore.

Filename collisions and non-ASCII names. Most packages hash or UUID the filename. Confirm that's happening; if the package preserves original names, two users uploading photo.jpg can collide depending on directory structure. Also check how it handles names with spaces, #, and Arabic/Chinese characters — this package's author works in a bilingual context, so it's probably handled, but verify before trusting user-supplied names in URLs.

N+1 on media relations — covered above, but the specific trap here is that polymorphic relations are easy to forget in with(). Add Model::preventLazyLoading() in AppServiceProvider for non-production to catch it automatically.

Treat the dependency as a risk, and design accordingly. 19 stars and zero dependents is a bus-factor-1 package. There is no community to catch regressions. Concretely:

  • Pin the exact version in composer.json ("waadmawlood/media": "1.2.3", not ^1.0). A patch release from a solo maintainer is not a safe assumption.
  • Commit composer.lock and treat upgrades as a reviewed change, not routine.
  • Keep the package behind your own interface (the ReplacePostCover action above, plus a MediaService contract). If you need to fork or replace it, you change one class instead of grepping the codebase.
  • Read the source before relying on any method you haven't tested. The README's last release date and the actual tag list may not match; check composer show waadmawlood/media for what you actually got.

Extension points are config-first. Before writing custom code, exhaust config/media.php: disk, base path/prefix, allowed mimes, max size, whether the package auto-deletes the previous file on replace, whether it queues uploads. Forking is a last resort — a fork of an unmaintained package is a permanent maintenance cost.

Debugging checklist when an upload "works" but the file isn't there:

Log::info(config('media.disk'), [
    'exists' => Storage::disk(config('media.disk'))->exists($media->path),
    'path'   => $media->path,
    'url'    => $media->url,
]);

Then php artisan about to confirm the active filesystem, and php artisan config:show media to confirm the package isn't reading a stale cached config. Nine times out of ten it's the disk name, the missing symlink, or S3 permissions — in that order.

Queues. Large files plus image resizing belong in a job. If the package has no queue support, wrap the upload in your own job and store the file path immediately (so the UI can show a placeholder), then move/convert asynchronously. Don't block a request for 8 seconds uploading a 3 MB image to S3.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
uni-method/symfony-lib
tacticmedia/scim-server-bundle
neika-ai/symfony-bundle
mattleads/agent-publishing-bundle
glavweb/framework-bundle
glavweb/extra-bundle
glavweb/api-bundle
glavweb/admin-bundle
adachsoft/api-version-bundle
elsayed85/laravel-api-exceptions
ouhssini/laravel-demo-mode
daworks/ncloud-outbound-mailer-driver
gp247/cms
ignibyte/aic-gates
nexa-merchant/blog
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
shitoudev/permissionmanager