Creating a Reusable Symfony Route Loader

Symfony routing is usually done with annotations, config files, or plain PHP. Annotations have the example of keeping your routing information close to the controller itself, but what if there were a way to do that without annotations?

With custom route loaders we can! We'll write a static method loader that can call a method on our controllers (or anything else) and keep our routing config close to the controller itself without using annotations.

There's a section in the Symfony cookbook on custom route loaders that's worth a read. We'll concentrate on demostrating a custom loader outside of the full stack framework here.

Getting Started

Symfony's route loaders are built on its own config component and all of them extends Symfony\Component\Config\Loader\Loader (or implement Symfony\Component\Config\Loader\LoaderInterface).

<?php

namespace Chrisguitarguy\StaticMethodLoader;

use Symfony\Component\Config\Loader\Loader;

final class StaticMethodLoader extends Loader
{
    const TYPE = 'staticmethod';

    /** {@inheritdoc} */
    public function supports($resource, $type=null)
    {
        return $type === self::TYPE;
    }

    /** {@inheritdoc} */
    public function load($resource, $type=null)
    {
        if (!$this->supports($resource, $type)) {
            throw new \InvalidArgumentException(sprintf(
                '%s resources are not supported',
                null === $type ? 'NULL' : $type
            ));
        }

        // ...
    }
}

Pretty simple start. We'll define a resource type we'll support and check for the type in the load method. In the full stack Symfony framework, an instance of Symfony\Bundle\FrameworkBundle\Routing\DelegatingLoader is used and the supports check in load is unnecessary. Since we're not relying on the full stack framework, we should check to be safe.

About Resources

The $resource argument should contain something that we can use to load routes. In YamlFileLoader, for example, $resource is a file name. In our custom loader we want resource to be a ClassName::methodName pair.

<?php

namespace Chrisguitarguy\StaticMethodLoader;

use Symfony\Component\Config\Loader\Loader;

final class StaticMethodLoader extends Loader
{
    // ...

    public function load($resource, $type=null)
    {
        // ...

        list($class, $method) = self::assureValidResource($resource);

        // ...
    }

    private static function assureValidResource($resource)
    {
        $parts = explode('::', $resource, 2);
        if (count($parts) !== 2) {
            throw new Exception\InvalidArgumentException(sprintf(
                '%s expects resources ...',
                __CLASS__,
                $resource
            ));
        }

        if (!class_exists($parts[0])) {
            throw new Exception\LogicException(sprintf(
                'class %s does not exist',
                $parts[0]
            ));
        }

        if (!is_callable($parts)) {
            throw new Exception\LogicException(sprintf(
                '%s is not callable',
                $resource
            ));
        }

        return $parts;
    }
}

In addition to validating the resource format we validate that both the class and method exist. Not that we favor is_callable here rather than method_exists: the latter will return true for private methods.

Actually Loading the Routes

Now that we have our resource validated we just need to call it. We'll pass in a route collection that the method can use.

<?php

namespace Chrisguitarguy\StaticMethodLoader;

use Symfony\Component\Config\Loader\Loader;

final class StaticMethodLoader extends Loader
{
    // ...

    public function load($resource, $type=null)
    {
        // ...

        list($class, $method) = self::assureValidResource($resource);

        call_user_func([$class, $method], $routes = new RouteCollection());

        return $routes;
    }
}

Configuration Resources

With this done our loader officially works! But there's one thing left: configuration resources.

Symfony's config component uses instances of ResourceInterface to see if the configuration is fresh and whether or not the config cache needs to be regenerated. In the full stack framework this will write a big matcher file with all the "compiled" routes. In other words, Symfony doesn't need to read all your routing config every time and build the router from scratch in production.

To make our loader play nice with the config cache, w'ell look up the file in which the class resides (along with files of parent classes) and add those files are resources. If any of the files have been modified since the cache was dumped, the router will build the cache again.

<?php

namespace Chrisguitarguy\StaticMethodLoader;

use Symfony\Component\Config\Loader\Loader;

final class StaticMethodLoader extends Loader
{
    // ...

    public function load($resource, $type=null)
    {
        // ...

        list($class, $method) = self::assureValidResource($resource);

        call_user_func([$class, $method], $routes = new RouteCollection());

        self::addResources(new \ReflectionClass($class), $routes);

        return $routes;
    }

    private static function addResources(
        \ReflectionClass $ref,
        RouteCollection $routes
    ) {
        do {
            $routes->addResource(new FileResource($ref->getFileName()));
        } while ($ref = $ref->getParentClass());
    }
}

Using the Loader

Simply pass the loader into the constructor of Router by itself or as part of a DelegatingLoader.

<?php
use Symfony\Component\Config\Loader\DelegatingLoader;
use Symfony\Component\Config\Loader\LoaderResolver;
use Symfony\Component\Routing\Router;
use Symfony\Component\Routing\Loader\YamlFileLoader;
use Chrisguitarguy\StaticMethodLoader\StaticMethodLoader;

// using only the static method loader
$router = new Router(
    new StaticMethodLoader(),
    'Vendor\Package\ClassName::load',
    ['resource_type' => 'staticmethod']
);

// or with a DelegatingLoader
$router = new Router(new DelegatingLoader(new LoaderResolver([
    new YamlFileLoader(),
    new StaticMethodLoader(),
])), 'path/to/routing.yml');

If part of the delegating loader, you can import staticmethod routes.

_imported:
    resource: Vendor\Package\ClassName::methodName
    type: staticmethod

Advantages & Disadvantages

With any non-standard loader there will be a learning curve for other developers. Will they grasp where routes are coming from? Or will they be looking for configuration files that don't exist?

On the flip side, having a loader tied to a static method would mean you could keep routing information closer to the controller itself. Building routes in PHP means we can do use some logic to build our routes more dynamically. Maybe that's something simple like sticking a requirement pattern in a variable rather than repeating it several times, but it could be something complex like building API routes dynamically based on method names in the controller class.

Symfony 2.8 and 3.0

Symfony 2.8 and 3.0 include an ObjectRouteLoader and a ServiceRouteLoader that integrates with the DI component. Both are very similar to what the loader in this example does.

All code for this tutorial is available on github.

#