weeniebeenie's avatar

How to get first and latest records by date from ManyToMany relation/pivot for multiple rows/items

I'll try to explain this best I can. Building a site for a music band. They have songs, they play shows, they have releases (Albums, Singles, EPs etc). Each release has many songs attached to it, sometimes the same song on multiple. Many songs are played at many shows, so I have on top of those models two BelongsToMany relations/pivot tables - show_song and release_song.

These relations work fine in their primary intended use, like displaying all the songs played at each show or the inverse, listing/counting the shows each song was played at. Or getting all the songs on a release. Just simple uses of a M2M relationship.

On the individual song pages I can get all the shows it was played at like:

$paginated = $song->shows()->orderBy('date')->paginate(25);

From that I can also get the first show the song was played as well as the latest and totals:

$first = $paginated->first();
$latest = $song->shows()->latest('date')->first();
$total = $paginated->total();

I know latest uses another query but since it's only for one song and only retrieving one row it's fine. These stats are then easily displayed on each page, ie /song/{song}/{slug}/stats

Now the problem. I want to do something similar on the page for each release, ie /release/{release}.

I use the relation to get the songs for the release, $release->songs() which is going to return a collection with all the songs but then I again want to get the total number of times each song on the release was played live, plus the first and latest occasion. So the end user can see this data as an overview per each release rather than having to always go to each individual song.

To use a real world example, just to be crystal clear on my intentions, if I was doing this for Michael Jackson you'd be able to go to the page for 'Thriller' and see at a glance

Wanna Be Startin' Something 
First Played Jan 1st 1983
Last Played Jun 25th 2003 
Total Times Played - 965

Baby Be Mine
First Played Feb 11th 1984
Last Played August 5th 2005
Total Times Played - 465

And so on.

Getting the total is simple enough, I just use a withCount:

$songs = $this->songs()->withCount('shows')->get();

But any solution I can come up with for the first and latest shows either results in returning the whole shows relation and therefore bringing all shows for each song to memory (very bad!) or performing an extra query for each one with a foreach loop:

        foreach ($songs as $song) {
            $song->firstShow = $song->shows()->orderBy('date')->first();
            $song->lastShow = $song->shows()->orderBy('date', 'desc')->first();
        }

		return $songs;

So if a release had 20 songs, that'd produce an extra 40 queries. Also bad and clearly not the answer either.

Is there a clean and performant way to get just the first and latest show (by date) for each song from the relation? I checked the docs and nothing seems to fit.

I first thought to create a function like this but then I can't load that via ->with() because it returns a collection, not a relation:

    public function latestShow()
    {
        return $this->shows()->orderBy('date', 'desc')->first();
    }

and I can't use firstOfMany/latestOfMany:

    public function latestShow()
    {
	return $this->hasOne(Show::class)->latestOfMany();
	}

Because that apparantly doesn't work for ManyToMany relationships, only OneToMany/hasMany?

Am I missing something simple, or is this going to take more work, like maybe creating another table or view which just stores this data?

Thanks in advance for any help!

0 likes
3 replies
vincent15000's avatar

It's difficult to understand what you need.

How to get first and latest records by date from ManyToMany relation/pivot for multiple rows/items

You don't talk about any record in your post. But you talk about songs, shows, releases, ...

So impossible to give you any response.

Something else => I wonder why you have at the same time song and slug in this route : /song/{song}/{slug}/stats.

If I only have a look at your last code ...

public function latestShow()
{
	return $this->hasOne(Show::class)->latestOfMany();
}

latest() orders by DESC and oldest() orders by ASC.

https://laravel.com/docs/11.x/queries#latest-oldest

So the best way to retrieve the first "show" (record ?) is to use oldestOfMany() and for the last "show" latestOfMany().

https://laravel.com/docs/11.x/eloquent-relationships#has-one-of-many

weeniebeenie's avatar

@vincent15000

Sorry, I thought I was clear but perhaps not.

  • I have a songs table which contains all the info about each song the band have/have played.
  • Then i have a shows table which contains all the live shows they've played. Date, venue, country, etc.
  • Finally there is a releases table which contains their albums, EPs - title, release date, artwork etc

Then as I say, these are all related. songs belong to many shows (ie the setlists) and also many releases (tracklists of each album). So there are release_song and song_show pivot tables.

That means each release has many songs, and in turn each song has been played at many shows.

//Models/Song.php 

    public function shows()
    {
        return $this->belongsToMany(Show::class);
    }

    public function releases()
    {
        return $this->belongsToMany(Release::class)->withPivot(
            'is_main'
        );
    }
//Models/Release.php

    public function songs()
    {
        return $this->belongsToMany(Song::class)->withPivot('is_main', 'order');
    }
//Models/Show.php

    public function songs()
    {
        return $this->belongsToMany(Song::class);
    }

So on the page for each release /release/{release}/{slug} I need to get all of the songs on that release. That is the basic $release->songs() relationship. No problem. But then I want to get the first and latest shows each of those songs was played at.

As I say when I'm just on the song page, for a single song, this is simple.

$firstShow = $song->shows()->orderBy('date')->first();
$latestShow = $song->shows()->orderBy('date', desc)->first();

Just two queries. But a release might have upwards of 20 songs, and I need to get the first and last show for each one, on one page/view.

I get the main songs data by using $release->songs() of course, but then I need a with('something') where something is returning those two shows. Well, two somethings I guess. ('firstShow') and ('latestShow').

As I say, as far as I can understand hasOne..latestOfMany doesn't work on a M2M relationship. When I tried it, I got an error about missing columns because it's trying to look at the wrong table. However since I posted originally I had come up with functions that do work

//Songs.php Model 

    public function firstShow()
    {
        return $this->belongsToMany(Show::class)->oldest('date')->limit(1);
    }

    public function latestShow()
    {
        return $this->belongsToMany(Show::class)->latest('date')->limit(1);
    }

//Release.php Model

    public function getSongs()
    {
        $songs = $this->songs()->orderBy('order')->withCount('shows')->with(['firstShow', 'latestShow'])->get();
	}

//ReleaseController.php 

public function show(Release $release)
    {
        return view('releases.show', [
            'release' => $release,
            'songs' => $release->getSongs(),
        ]);
    }

Which works, not sure if it's the most efficent though? It only executes one query each for all of the songs but it's relatively complex.

select * from (select "shows".*, "show_song"."song_id" as "pivot_song_id", "show_song"."show_id" as "pivot_show_id", row_number() over (partition by "show_song"."song_id" order by "date" asc) as "laravel_row" from "shows" inner join "show_song" on "shows"."id" = "show_song"."show_id" where "show_song"."song_id" in (56, 90, 107, 139, 146, 147, 149, 174, 182, 222, 233, 259, 284, 309, 350, 429, 469, 477, 497) and "shows"."deleted_at" is null) as "laravel_table" where "laravel_row" <= 1 order by "laravel_row"

The second one the same but with order by "date" desc of course.

Something else => I wonder why you have at the same time song and slug in this route : /song/{song}/{slug}/stats.

Because the slug is just the song title which may not be unique (usually cover songs with the same title as an original song), so I still use the ID as the unique identifier and include the slug for a clearer/nicer URI.

I suppose I could modify the slug to include the artist name or something so they were all unique but I dunno, this seemed fine, for now at least. This is still my 'learn Laravel with my first proper website' project. :)

1 like
vincent15000's avatar

@weeniebeenie If you want to retrieve all songs from a release, you just have to do this.

public function songs(): BelongsToMany
{
	return $this->belongsToMany(Song::class, 'release_song');
}

You don't need the getSongs() function.

Please or to participate in this conversation.