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.

Working with data relationships - $many_many

In this lesson: DataObject, many_many, belongs_many_many, CheckboxSetField
SilverStripe version: 4.x (stable)

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

UncleCheese |

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 in the CMS that we'll 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. This way, if a content editor adds another ArticleHolder page, it will have the ability to provide its own distinct set of categories.

Managing the ArticleCategory objects

mysite/code/ArticleHolder.php

//...
use SilverStripe\Forms\GridField\GridField;
use SilverStripe\Forms\GridField\GridFieldConfig_RecordEditor;

class ArticleHolder extends Page {

    //...
    private static $has_many = [
        'Categories' => ArticleCategory::class,
    ];

    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

namespace SilverStripe\Lessons;

use SilverStripe\ORM\DataObject;
use SilverStripe\Forms\FieldList;
use SilverStripe\Forms\TextField;

class ArticleCategory extends DataObject {

    private static $db = [
        'Title' => 'Varchar',
    ];

    private static $has_one = [
        'ArticleHolder' => ArticleHolder::class,
    ];

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

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

Also take note that we won't use versioning for this DataObject. This is a deliberate decision based on the knowledge that there are no views where all of the categories will be listed. We know that the only way a category will appear on the front-end is when it is associated with an article. So based on that, we don't need to worry about the published state of categories.

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 = [
        'Categories' => ArticleCategory::class,
    ];
    //...
}

Run dev/build and see that we get a new table, SilverStripe_Lessons_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 a 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 = [
        'Articles' => ArticlePage::class,
    ];
    //...
}

We changed a static variable, so run a ?flush too.

$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 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

//...
use SilverStripe\Forms\CheckboxSetField;

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 constructor arguments we've passed to 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 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 type-ahead UI for associating records without needing to display all options 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 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 when iterating a loop are $First, $Even, $Odd, and a few others used for tracking positional information.

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'));
        }

        return null;
    }
}

We check the existence of categories with the exists() method. Simply checking the result of Categories() will not work because it will always return a DataList object, whether empty or not; It will never return false. We use exists() to check if the list contains records or not.

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>

Keep learning!

Introduction to frontend forms

In this tutorial we're going to look at adding forms to the frontend of our website. We'll add a new feature that allows users to post...

Data Extensions and SiteConfig

In this tutorial, we'll discuss one of the major building blocks of modular and reusable code in SilverStripe Framework: extensions. We won't be writing a whole...

Introduction to ModelAdmin

In this lesson, we'll create the Property object that will drive most of the content in our application, and add a management interface for it in...