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

app/src/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.

app/src/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 frontend 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.

app/src/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 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.

app/src/ArticleCategory.php

//...
class ArticleCategory extends DataObject {
    //...
    private static $belongs_many_many = [
        'Articles' => ArticlePage::class,
    ];
    //...
}

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.

app/src/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 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.

app/templates/SilverStripe/Lessons/Layout/ArticlePage.ss, line 23

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

app/src/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 at worst return an empty DataList object. It will never return false. We use exists() to check truthiness.

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.

app/templates/Silverstripe/Lessons/Layout/ArticlePage.ss, line 23

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

app/templates/Silverstripe/Lessons/Layout/ArticleHolder.ss, line 23

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

app/templates/SilverStripe/Lessons/Layout/HomePage.ss, line 284

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