Skip to main content

This site requires you to update your browser. Your browsing experience maybe affected by not having the most up to date version.

UncleCheese
13th February 2015

In this tutorial, we'll add categories to our articles using another type of plural data relationship known as $many_many.

Working with data relationships - $many_many

Level: Beginner

Duration: 8:51

In this lesson:

What we'll cover

  • Creating a DataObject for our $many_many
  • Adding interface for $many_many
  • Pulling the data into the template

Creating a DataObject for $many_many

Let's turn our focus back to the ArticlePage now and see that each article is associated with many categories. We can imagine that in the CMS, we want a list of selectable categories, perhaps checkboxes, that are offered to each article. The first thing we'll need to do is set up a place to manage the categories. There are several different ways you can do this. It really depends on what kind of user experience you want to create, but for now, let's stick them on the ArticleHolder object, so that, conceivably, another ArticleHolder page could provide its own set of distinct categories.

Managing the ArticleCategory objects

mysite/code/ArticleHolder.php

class ArticleHolder extends Page {

    //...
    private static $has_many = array (
        'Categories' => 'ArticleCategory'
    );

    public function getCMSFields() {
        $fields = parent::getCMSFields();
        $fields->addFieldToTab('Root.Categories', GridField::create(
            'Categories',
            'Article categories',
            $this->Categories(),
            GridFieldConfig_RecordEditor::create()
        ));

        return $fields;
    }
}

Next, let's create that ArticleCategory object. It's going to be really simple.

mysite/code/ArticleCategory.php

class ArticleCategory extends DataObject {

    private static $db = array (
        'Title' => 'Varchar',
    );

    private static $has_one = array (
        'ArticleHolder' => 'ArticleHolder'
    );

    public function getCMSFields() {
        return FieldList::create(
            TextField::create('Title')
        );
    }
}

Notice once again that we have the reciprocal $has_one back to the ArticleHolder.

Run dev/build again and see that we get a new table. Edit the "Travel Guides" page in the CMS and add a few sample categories.

Relating Articles to Categories

Now that we have some categories to work with, let's relate them to the articles. Articles have many categories, as we can see on the template, so it's reasonable to assume we'll be using another $has_many, right?

In this case, a $has_many is not what we want. Remember that reciprocal $has_one we used with $has_many? That declares that each related object can only belong to one parent. Once that relation is created, it can't be used anywhere else. We don't want that behaviour with categories. Once a category is claimed by an article, it should still be available to other articles. Therefore, articles have many categories, and categories have many articles. This is a $many_many relationship.

mysite/code/ArticlePage.php

class ArticlePage extends Page {
    //...
    private static $many_many = array (
        'Categories' => 'ArticleCategory'
    );
    //...
}

Run dev/build and see that we get a new table, ArticlePage_Categories.

Reciprocating the $many_many

Optional, but strongly recommended is a reciprocation of this relationship on the ArticleCategory object, using $belongs_many_many. This variable does not create any database mutations, but will provide an magic method to the object for getting its parent records. In this case, we know that we'll need any ArticleCategory object to get its articles, because our design includes a filter by category in the sidebar, so this is quite important.

mysite/code/ArticleCategory.php

class ArticleCategory extends DataObject {
        //...
    private static $belongs_many_many = array (
        'Articles' => 'ArticlePage'
    );
        //...
}

We changed a static variable, so run ?flush.

$many_many vs $belongs_many_many

So if both sides of the relationship have many associated records, how do you know which one gets the $many_many and which one is $belongs_many_many? Typically, the object that contains the interface gets the $many_many. In this case, we'll add categories to the articles using checkboxes, so that's where our $many_many goes. Again, the $belongs_many_many just provides the convenience of an accessor method for getting the articles from within a category.

Adding interface for $many_many

Speaking of interface, we need to add some to the ArticlePage object. Let's introduce CheckboxSetField.

mysite/code/ArticlePage.php

class ArticlePage extends Page {
    //...
    public function getCMSFields() {
        $fields = parent::getCMSFields();
        //...
        $fields->addFieldToTab('Root.Categories', CheckboxSetField::create(
            'Categories',
            'Selected categories',
            $this->Parent()->Categories()->map('ID','Title')
        ));
        return $fields;
    }
}

Let's take a look at the argument signature of CheckboxSetField:

  • 'Categories': The name of the $many_many relation we're managing.
  • 'Selected categories': A label for the checkboxes
  • $this->Parent()->Categories(): The categories are stored on the parent ArticleHolder page, so we need to invoke Parent() first.
  • ->map('ID', 'Title'): Using the resulting list of categories, create an array that maps each category's ID to its Title. This tells the checkboxes to save the ID to the relation, but present the Title field as a label. Note that Title can be any public method executable on the object, which is useful if you want a computed value or concatenation of multiple fields. 99% of the time, you will want to use ID as the first argument here, as relational data is all held together by unique identifiers.

Go into the CMS and edit an article under "Travel Guides." Check off some categories and make sure they save.

CheckboxSetField is a good go-to UI for most $many_many relations, but it doesn't scale very well. If we had 100 categories, this wouldn't be a pleasant experience for the user. For larger data sets there is also ListboxField, which provides a typeahead UI for associating records without displaying them all at once.

Pulling the data into the template

Now let's look at adding the comma-separated category list to the articles.

themes/one-ring/templates/Layout/ArticlePage.ss, line 22

<li><i class="fa fa-tags"></i> 
     <% loop $Categories %>$Title<% if not $Last %>, <% end_if %><% end_loop %>
</li>

We can use the global template variable $Last to tell us whether we're in the last iteration of the loop, which will determine whether or not we show the comma. Also available are $First, $Even, $Odd, and many others.

Using a custom getter

If we reload the page, this all looks great, but we're not done yet. The categories are also displayed on ArticleHolder.ss and HomePage.ss. This is a lot of template syntax to keep replicating. We could put this into an include, but it would be better if the ArticlePage objects could render a comma-separated list of categories themselves. Let's create a new method that does this.

mysite/code/ArticlePage.php

class ArticlePage extends Page {
        //...
    public function CategoriesList() {
        if($this->Categories()->exists()) {
            return implode(', ', $this->Categories()->column('Title'));
        }
    }
}

We check the existence of categories with the exists() method. Simply checking the result of Categories() will not work, because it will at worst return an empty DataList object. It will never return false. We use exists() to check trueness.

Invoking column() on the list of ArticleCategory objects will get an array of all the values for the given column, which saves us the trouble of looping through the list just to get one field.

Now update HomePage.ss, ArticleHolder.ss, and ArticlePage.ss to use the $CategoriesList method.

themes/one-ring/templates/Layout/ArticlePage.ss, line 22

<li><i class="fa fa-tags"></i> $CategoriesList</li>

themes/one-ring/templates/Layout/ArticleHolder.ss, line 25

<li><i class="fa fa-tags"></i> $CategoriesList</li>

themes/one-ring/templates/Layout/HomePage.ss, line 289

<li><i class="fa fa-tags"></i> $CategoriesList</li>

Questions and Feedback

I really have to say that these tutorials are really good! It gives a good introduction to Silverstripe and the concept. :D

Just a question: I'm trying filter out articles based on the category using an action that grabs the ID from the URL, but I'm having a total brain freeze and can't get further.

I thought you was going to cover this in the video, but I may have missed something somewhere.

Would be very thankful if someone could share their knowledge about this. In vanilla PHP, filtering using primary and foreign keys are really simple but I'm really having a brain freeze over here. I'm new to SilverStripe as well. :)

//Tobbe

by Tobbe at 07:41am, 28 April 2015

Author

Hi, Tobbe,

We'll be covering this soon, but for now, see the tutorial on Controller Actions / DataObjects as pages. You'll want to retrieve the category by ID, then run the belongs_many_many relation you have assigned it back to the Articles (e.g. $category->Articles()) and pass that back to the template.

by UncleCheese at 04:34pm, 29 April 2015

Thanks for the tips! :) I'll have a look at that one!

by Tobbe at 03:49am, 2 May 2015

First off, I agree with Tobbe, great tutorials. There not coming fast enough ;-)

Second, I'm stuck on something with many_many relationships. Suppose I want to add an extra field to the many_many table? How do I go about this? I'm using SilverStripe to make a webpage with my movies, music and books collection. So, I have a table with (music) albums that contain tracks. So, I first add the tracks to a table "Tracks". Then I add the album to the table "Albums". Then I add the tracks to the album using the many_many table "Albums_Tracks" with AlbumID and TrackID. I also want to add the column "Number" and maybe the column "Featured" or "Favorite" to "Albums_Tracks".

Third, is there a way to add the tracks when you're adding the album? I'm imagining a field "Track" where I type the title of the track. If it can not be found in the table "Tracks", I can just add it to that table.

For the second and third question I'm looking for somekind of FORM/SUBFORM way.

by Skadoosh.nu at 11:27pm, 10 May 2015

Author

Great question! This case doesn't come up very often, but fortunately, there is a provision for it in the ORM. It's called $many_many_extraFields. Here's how you would do it:

class Album extends DataObject {
  private static $many_many = array (
    'Tracks' => 'Track'
  );

  private static $many_many_extraFields = array (
    'Tracks' => array(
      'Featured' => 'Boolean',
      'Number' => 'Int'
    )
  );
}

To set the value, just use the convention ManyMany[YourFieldName] for the form field, e.g:

NumericField::create('ManyMany[Number]','Track number for this album');

To set it programatically, use the second argument of add().

$album->Tracks()->add($myTrack, array('Number' => 3));

More info here: http://doc.silverstripe.org/en/developer_guides/forms/field_types/gridfield#many-many-extrafields

by UncleCheese at 10:38am, 12 May 2015

Great tutorial. It got everything working and it works great when I edit the articles. HOWEVER, when I go to create a new article, it gives me this error:

Uncaught LogicException: map can't be called on an UnsavedRelationList.

I'm guessing because a new project doesn't have a Parent() saved yet? I have your exact code and can't get passed this. When I do a die(var_dump($this->Parent()->Categories())) it gives me an Unsaved Relation List object back. $this->Parent() however gives me the ArticleHolder object.

Thank you for any help.

by Haley Schillig at 02:21am, 10 July 2015

I had to do ArticleCategory::get()->map('ID', 'Title')

by Haley at 02:42am, 10 July 2015

Aaron,

I noticed that the method LatestArticles is put in the controller HomePage_Controller while the method CategoryList is put in the model ArticlePage, and, both methods are used in templates. I'm just wondering what the MVC design decision for this was. Similarly, what is the design decision behind placing getCMSFields in the model ?

Cheers, David.

by SpiritLevel at 08:23am, 29 February 2016

Hi, David,

This is a great question, and it's one of those nuances that takes a bit of time to crack. Very often, methods can go in either place and result in exactly the same outcome, so the difference is mostly ideological, but there's also a practical component to it.

The controller is part of the request cycle. So methods defined here are those that are specific to a template or parameters in the request. $LatestArticles gets a set of data that is very specific to this template. It's unlikely that we would ever want to show the latest articles elsewhere. But if we did, we might consider putting the method in the Page_Controller parent class. (That's a bit bad practice in the long run, but that's another story). The point being that this method is specifically to be used on a template.

As for CategoriesList, that's a clear function of the model. You're asking for a comma-separate list of any given Article's categories. Any article, anywhere, on any template, in any context, should be able to tell you its categories. That may include views in the CMS, custom reports, migration tasks, etc. It goes way beyond the scope of a single page template.

getCMSFields() is in the model mostly because controllers are not used in the CMS, so it really has nowhere else to hang. There is much debate about the separation of concerns with this method -- it's very CMS specific, in a class that should really just be about database abstraction. You may someday see that migrated to an interface or extension of some sort, i.e. class MyDataObject extends DataObject implements CMSFieldProvider... but that's another topic. ;-)

by UncleCheese at 03:57pm, 9 March 2016

I am trying to create a dataobject that can be edited in the CMS and then:

  1. One page lists the items in the table in a certain way
  2. Another page lists the items in the table another way
  3. From one of the pages I can click an item and then view all of the details on another page

I can not wrap my head around how to structure this in Silverstripe.

I have successfully created the dataobject and can render the list in one page which is the same page I edit the data in the CMS but that is far as I have been able to get.

by silversunhunter at 11:21am, 23 March 2016

Hi, Daniel,

You can manipulate the list you get back from the many_many relation just like any other DataList.

$manyManyList1 = $myPage->SomeManyMany()->filter('Foo', 'bar')->sort('Date DESC');
$manyManyList2 = $myPage->SomeManyMany()->limit(50);

As for editing the records, assuming we're talking about the frontend, you'll want to look at the "Controller Actions / DataObjects as pages" tutorial to get you started.

by UncleCheese at 10:00am, 26 March 2016

Hi Unclecheese,

Im really stuck on something really easy but I couldn't get my head around it.

I wanted to display the category titled 'Featured' on the homepage.

class HomePage extends Page {

public function LatestEvents($num=4) { $holder = EventHolder::get()->$Categories("Featured"); return ($holder) ? EventPage::get()->filter('ParentID', $holder->ID)->sort('Date DESC')->limit($num) : false; } I hope you can help me. Thanks!

by Jem at 08:43pm, 23 March 2016

Hi, Jem,

Try something like this:

public function LatestEvents($num=4) {
   $holder = EventHolder::get()->filter('Title','Featured')->first();
    return ($holder) ? $holder->Children()->sort('Date DESC')->limit($num) : false;
}

by UncleCheese at 09:57am, 26 March 2016

I want the categories i created in an ArticleHolder to be available to all Articleholders. Where do i make the changes?

by charles at 11:44pm, 13 April 2016

Hi,

I am working with the categories. Is there a way to loop through posts/articles that have a specific category?

Thanks

by Conor at 03:11am, 5 August 2017

Stuck on something? Have something to share? Don't be shy!

Keep learning!

Working with data relationships - $has_many

In this lesson, we're going to dig a bit deeper into relational data by introducing some plural relationships with $has_many.

Introduction to the ORM

In this lesson, we’ll use the backbone of SilverStripe Framework -- the Object Relational Model (ORM) to syndicate content to the home page of our...

Working with Files and Images

In the previous lesson, we started adding some basic custom fields to our Article pages. We’ll now continue working on getting those pages more integrated with...