DEV Community

Luc Shelton
Luc Shelton

Posted on • Originally published at lucshelton.com on

SilverStripe: Generating URL Segments for DataObjects

As documented on the official SilverStripe website, you can render DataObjects as individual pages using controller actions. This is fine if you're comfortable in using the ID of the DataObject as part of the page URL, but if you're looking to create and use human-friendly URLs similar to that of SiteTree pages, then you will want to implement something that generates hyphenated and sanitized URL segments instead.

You can consider a "URL segment" to be a sanitized, human-friendly readable string generated from the Title field of the DataObject it is intended for. It doesn't contain any special URL encoding characters, and strictly only uses alpha-numeric characters. Anything that isn't alpha numeric is replaced by a hyphen.

By default, these are automatically generated for pages that derive from the SiteTree type. Unfortunately this same kind of behaviour does not exist for DataObjects, but this can be easily remedied by introducing a new data extension to your project and applying it to your DataObject type's list of extensions.

<?php

use SilverStripe\ORM\DataExtension;
use SilverStripe\View\Parsers\URLSegmentFilter;

class URLSegmentExtension extends DataExtension
{
    private static $db = [
        'URLSegment' => 'Varchar(255)'
    ];

    public function onBeforeWrite()
    {
        parent::onBeforeWrite();

        if ($this->owner->hasField('URLSegment')) {
            if (!$this->owner->URLSegment) {
                $this->owner->URLSegment = $this->generateURLSegment($this->owner->Title);
            }

            if (!$this->owner->isInDB() || $this->owner->isChanged('URLSegment')) {
                $this->owner->URLSegment = $this->generateURLSegment($this->owner->URLSegment);
                $this->makeURLSegmentUnique();
            }
        }
    }

    public function IsURLSegmentInUse($URLSegment)
    {
        $class = $this->owner;
        $items = $class::get()->filter('URLSegment', $URLSegment);

        if ($this->owner->ID > 0) {
            $items = $items->exclude('ID', $this->owner->ID);
        }

        return $items->exists();
    }

    public function makeURLSegmentUnique()
    {
        $count = 2;
        $currentURLSegment = $this->owner->URLSegment;

        while ($this->IsURLSegmentInUse($currentURLSegment)) {
            $currentURLSegment = preg_replace('/-[0-9]+$/', '', $currentURLSegment) . '-' . $count;
            ++$count;
        }

        $this->owner->URLSegment = $currentURLSegment;
    }

    public function generateURLSegment($title)
    {
        $filter = URLSegmentFilter::create();
        $filteredTitle = $filter->filter($title);

        $ownerClassName = $this->owner->ClassName;
        $ownerClassName = strtolower($ownerClassName);

        if (!$filteredTitle || $filteredTitle == '-' || $filteredTitle == '-1') {
            $filteredTitle = "$ownerClassName-$this->ID";
        }

        return $filteredTitle;
    }
}

Enter fullscreen mode Exit fullscreen mode

Breakdown

There's a bit to digest here, but the summary of what's going on in this extension is the following.

  1. This extension registers the field "URLSegment" to whichever DataObject that this extension type is appended to through the .yml configuration file.
  2. Each time the DataObject is published or written through from the Object-Relational Mapping system, it will automatically generate a new URL segment string if none has been generated yet.
  3. It achieves this by checking the URLSegment field, and if it has not been changed or updated, then it will attempt to use the Title field of the DataObject to generate a string suitable for the URLSegment database field.
  4. It checks recursively to see if there is an existing DataObject in the database that has the generated URLSegment already.
  5. If there is an existing DataObject in the database with the generated URLSegment, it will instead increment an integer, append it to the string, and recursively check again to see if the newly generated URLSegment has already been assigned.
  6. Once it has determined that the generated string is not in use, it will then apply it to the DataObject's field and continue with committing the data to the database.

Usage

Now that we have an applicable URLSegment string for our DataObject, we can put it into use in a similar manner as what has already been described on these pages. Except, in this instance we will be making use of the URLSegment field for filtering (based on the $Action value) instead of the ID of the DataObject.

The code for achieving this would look something like this.

if (is_numeric($params['ID'])) {
    $eventImages = EventImage::get()->filter([
        'EventID' => $this->dataRecord->ID,
        'ID' => intval($params['ID'])
    ]);
} else {
    $eventImages = EventImage::get()->filter([
        'EventID' => $this->dataRecord->ID,
        'URLSegment' => $params['ID']
    ]);
}
Enter fullscreen mode Exit fullscreen mode

Ensure that you have configured your project to use this data extension. In the example configuration file below, I am applying the extension that I've written above to the DataObject that I want the URL segments to be generated for.

Once the configuration file is saved, simply navigate to /dev/build?flush=all on your project, and this should now automatically generate the URL segments each time you publish modifications to your DataObjects.

---
Name: urlsegments
---
Portfolio\Models\TechnologyTag:
  extensions:
    - Portfolio\Extensions\URLSegmentExtension
Enter fullscreen mode Exit fullscreen mode

And that's all there is to it. You could make further modifications if you like, including ensuring that the URLSegment field for a DataObject uses the appropriate form field when modifying it from the CMS admin.

Top comments (0)