maetl

Undocumenting PHP: URL Handling in SilverStripe

Many people have wondered about the lack of support for nested URLs in a basic install of the SilverStripe CMS. There is a forthcoming module that will enable nested URLs as plugin functionality, but hopefully these tips will open your eyes to a different way of approaching the construction of content sites.

The old way to develop content sites in SilverStripe revolved around creating custom extensions that inherited from Page or SiteTree objects. We can dub this the Site Tree approach, implemented by placing files in the mysite/code folder with a named PageType object alongside a matching PageType_Controller.

I'm not a particular fan of the underscore naming convention, nor of putting multiple classes into the same file, as it goes against the way that a lot of the other code in Sapphire has been constructed. I'm also more familiar and comfortable with the Rails MVC style of separating concerns in common folders - ie: models, templates, controllers. But that's just me - SilverStripe gives you complete freedom of choice here, because it hooks into __autoload, and scans your entire site path to build it's manifest file. You can structure your mysite however you like (I've noticed a lot of people also forget that you don't have to call it mysite either. Change the $project var in your _config.php to point to the name of your app folder instead).

In your app folder, you can (and should) set up MVC application folders, as mentioned above:

mysite/controllers
mysite/models
mysite/templates

You can call them anything you like - the above convention works for most people. Ignore the templates folder if you're using themes.

To bind a hierarchical URL structure to a controller, create a named controller file like mysite/controllers/ArticlesController.php and set the routing configuration in mysite/_config.php, for example:

Director::addRules(100, array(
    'articles/$ID' => 'ArticlesController'
));

All controllers are instances of the Sapphire RequestHandler. In mysite/controllers/ArticlesController.php, set up an additional mapping to intercept URLs passed to the /articles endpoint:

class ArticlesController extends Controller {
    
    public static $url_handlers = array(
        '' => 'index',
        '$Slug' => 'view',
        'tag/$Tag!' => 'list_by_tag'
    );

}

This means your controller will bind to incoming URLs like /articles, /articles/hello-world, and /articles/tag/design. The syntax of these rules is intuitive - literal strings are literal strings, and must be present for the handler to match. Variables are denoted by a leading $ symbol and are optional, unless the ! modifier is used.

This means that appending ! to the variable rule match is required - tag/$Tag would match both /tag and /tag/design, wheras tag/$Tag! would only match on URLs constructed like /tag/design. If the rule handling gets too confusing, you can use the ?debug_request=1 parameter to see the exact series of matches for a given request.

You can access the value of the specified URL variables using HTTPRequest::param inside handler methods. The request object is passed as the first parameter to these handlers by default.

To enable the controller, simply implement the handlers that the URL patterns point to:

class ArticlesController extends Controller {
    
    public static $url_handlers = array(
        '' => 'index',
        '$Slug' => 'view',
        'tag/$Tag' => 'listByTag'
    );
    
    public function index($request) {
        return $this->renderWith('Index');
    }

    public function view($request) {
        $Article = DataObject::get_by_slug('Article', $request->param('Slug'));
        return $Article->renderWith('Article');
    }

    private $Tag;

    public function listByTag($request) {
        $this->Tag = $request->param('Tag');
        return $this->renderWith('TagList');
    }

    public function ArticlesByTag() {
        return DataObject::get_by_tag('Article', $this->Tag);
    }

}

This is not production ready code and it does break away from the Site Tree model, that many developers are more familiar with, but it's useful to see how Sapphire can be used as a barebones MVC framework.

It's also possible to further nest the URL handlers, passing control down a component chain. This approach is slightly different to what many people are used to with Rails style frameworks. Instead of using a global routing table, Sapphire directs control down the chain of URL handlers until no more handlers are found, and a concrete response can be generated. You can see this in detail throughout the updated form handling and ModelAdmin code. One thing this chaining enables is the ability to target a single form field through a POST action by stepping down the nested controllers until the FormField object is reached. Way back in 2005, I described this in terms of only sending what you need , and it's cool to see SilverStripe jumping ahead with an implementation.