Laravel Media Library is a popular (>3M downloads) package by Spatie to associate files with Eloquent models. I use it widely in almost all of my projects, as it's my go-to package to handle any interaction between my models and media.

The latest example, which inspired this post, is in an e-commerce project I've been working on lately, in which a product can have multiple pictures. Inside the product's page, there's a carousel that displays these pictures.

The second way a product is displayed is in a card that features the product's name, price, and only one picture (the first one from the gallery). Those cards are used on the homepage, in the store's front page, in the sidebar of other pages, and even inside the product's page to display similar products and recently viewed products.

The first thing I'd done was to define a gallery collection in my Product model, which I'll use to store the product's pictures:

// src/Domains/Store/Models/Product.php

public function registerMediaCollections(): void
{
    $this->addMediaCollection('gallery')
        ->useFallbackUrl(asset('assets/img/store-product-placeholder.jpg'))
        ->useFallbackPath('/assets/img/store-product-placeholder.jpg');
}

I went on and created four products and associated three pictures to each one of them. Then, to display the product cards in the store's homepage, I created a product-card blade component to display the product's basic information alongside its first picture from the gallery media collection:

<!-- resources/views/components/store/product-card.blade.php -->

<a href="{{ route('store.product', $product->slug) }}" {{ $attributes->merge(['class' => 'relative block rounded-lg shadow-lg overflow-hidden']) }}>
    <img src="{{ $product->getFirstMediaUrl('gallery') }}" alt="{{ $product->name }}" class="w-full object-cover"/>
    <div class="absolute top-0 right-0">
        <div class="mt-4 px-4 py-2 rounded-l-lg bg-yellow-300 text-cool-gray-900 text-lg">{{ $product->name }}</div>
    </div>
    <div class="absolute bottom-0 left-0">
        <div class="mb-4 px-4 py-2 rounded-r-lg bg-yellow-300 text-cool-gray-900 text-lg">{{ $product->formatted_price }}</div>
    </div>
</a>

The controller is quite simple as well:

// src/App/Http/Controllers/Store/StoreController.php

<?php

namespace App\Http\Controllers\Store;

use Domains\Store\Models\Product;
use Illuminate\Database\Eloquent\Collection;
use Support\Http\Controller;

class StoreController extends Controller
{
    public function __invoke()
    {
        return view('pages.store.home')->with([
            'products' => $this->products(),
        ]);
    }

    private function products(): Collection
    {
        return Product::query()
            ->ordered()
            ->active()
            ->get();
    }
}

The Problem

I refreshed the page and noticed something concerning in my debugbar. Even though I loaded four products, five queries have run, and 16 models were hydrated:

Queries

Models

So I started digging. Under the hoods, in order to associate media to models, the Media Library package provides a Media model with a one-to-many polymorphic relationship, which means that every media item is saved in the database with model_type and model_id columns to specify to which model each media belongs. Additionally, in order to add the functionality to the model, the InteractsWithMedia trait contains the inverse media relation.

Naturally, the first thing that I've done was to eager load the media relation in my controller:

// src/App/Http/Controllers/Store/StoreController.php

private function products(): Collection
{
    return Product::query()
        ->with('media')
        ->ordered()
        ->active()
        ->get();
}

It solved the n+1 issue, as only two queries were run this time, but I still had 16 dehydrated models:

Queries

Why is this even a problem? Right now, when I have only four products, it probably isn't. The page uses 5MB of memory and loads pretty fast. But what happens when I have dozens of products, and each Product model that hydrates brings another three Media models with him, while I only need the first?

If you haven't watched it already, Jonathan Reinink has a GREAT course called Eloquent Performance Patterns, which covers, among many other interesting topics, the way this issue can affect your app. I can't recommend it enough.

The Solution

The solution here consists of two parts. First, I had to make sure that the value in the order_column column in the media table considers the associated model. For example, suppose I have two products. In that case, the order_column of their associated media entities in each media collection should start from 1 (the default behavior is to sort by the general scope and not by the related model and collection scope). This one was pretty easy. I extended the default Media model override the getHighestOrderNumber method:

// src/Support/MediaLibrary/Media.php

<?php

namespace Support\MediaLibrary;

use Spatie\MediaLibrary\MediaCollections\Models\Media as BaseMedia;

class Media extends BaseMedia
{
    public function getHighestOrderNumber(): int
    {
        return (int)static::where('model_type', $this->model_type)
            ->where('model_id', $this->model_id)
            ->where('collection_name', $this->collection_name)
            ->max($this->determineOrderColumnName());
    }
}

Then I changed the media_model key in the package's config file:

// config/media-library.php

/*
 * The fully qualified class name of the media model.
 */
'media_model' => Support\MediaLibrary\Media::class,

Now, every time something changes with a model's associated media, it will be reordered while taking the associated model into consideration.

Then, to load only the first media from the gallery collection as the "main picture", I created a firstGalleryMedia one-to-one polymorphic relationship in my Product model and an accessor to use it easily:

// src/Domains/Store/Models/Product.php

public function firstGalleryMedia(): MorphOne
{
    return $this->morphOne(config('media-library.media_model'), 'model')
        ->where('collection_name', 'gallery')
        ->where('order_column', 1);
}

public function getMainImageUrlAttribute(): string
{
    return $this->firstGalleryMedia->getUrl();
}

Then I eager loaded it in my controller:

// src/App/Http/Controllers/Store/StoreController.php

private function products(): Collection
{
    return Product::query()
        ->with('firstGalleryMedia')
        ->ordered()
        ->active()
        ->get();
}

And used it in my view:

<!-- resources/views/components/store/product-card.blade.php -->

<a href="{{ route('store.product', $product->slug) }}" {{ $attributes->merge(['class' => 'relative block rounded-lg shadow-lg overflow-hidden']) }}>
    <img src="{{ $product->main_image_url }}" alt="{{ $product->name }}" class="w-full object-cover"/>
    <div class="absolute top-0 right-0">
        <div class="mt-4 px-4 py-2 rounded-l-lg bg-yellow-300 text-cool-gray-900 text-lg">{{ $product->name }}</div>
    </div>
    <div class="absolute bottom-0 left-0">
        <div class="mb-4 px-4 py-2 rounded-r-lg bg-yellow-300 text-cool-gray-900 text-lg">{{ $product->formatted_price }}</div>
    </div>
</a>

Now only two queries were run, and only eight models were hydrated (one for each product and one for each main media):

Queries

Models

You can also create a composite index for the collection_name, order_column, model_id and model_type columns in the media table to optimize it even more.