Routes

Improvements?

Let us know by posting here.

What is a Route?

In Concrete CMS, a route is a segment of a URL past the domain name, like /about/company or /login. These routes are often created by pages within the CMS, such as a "Company" page under "About," or through single pages like /login and /dashboard. Pages in Concrete are versatile, featuring themes, blocks, URLs, permissions, sitemap visibility, and search indexing.

However, for specific needs like a custom API, using pages can be limiting. For example, if you need an API endpoint /api/current_user to return JSON data about the logged-in user:

{
    "username": "aembler",
    "user_id": 1
}

Using a single page for this could clutter the sitemap and search results. Here's where Concrete's routing system comes in.

Routes in Concrete

The routing system in Concrete allows adding custom endpoints without affecting the sitemap or search index. It's useful for user interface elements, system actions, and when you need to create numerous endpoints, like in an API.

Routing Basics

To define a route in Concrete CMS, first access the router instance from application/bootstrap/app.php using the Concrete\Core\Application\Application object, $app:

$router = $app->make('router');

Define a route like /api/current_user to return a JSON representation of the logged-in user:

$router->get('/api/current_user', function() {
    return 'Return a simple string.';
});

Visiting http://www.example.com/api/current_user displays "Return a simple string.".

HTTP Verb Support

Routes in Concrete respond to specific HTTP verbs:

  • $router->get($endpoint, $callback)
  • $router->post($endpoint, $callback)
  • ...
  • $router->options($endpoint, $callback)

For all verbs, use $router->all($endpoint, $callback).

Route Parameters

Capture route parameters like this:

$router->get('/api/customer/{customerId}', function($customerId) {
    return 'The customer ID is: ' . $customerId;
});

Use multiple parameters in the same way.

Parameter Formats

Enforce parameter formats with regular expressions:

$router->get('/rss/{identifier}', function() {
    // Callback here.
})
->setRequirements(['identifier' => '[A-Za-z0-9_/.]+']);

Named Routes

Name a route for easy retrieval:

$router->get('/rss/{identifier}', function() {
    // Callback here.
})
->setName('rss')
->setRequirements(['identifier' => '[A-Za-z0-9_/.]+']);

Chainable Router Object

The Router object supports chaining for simpler syntax.

Accessing the Request in a Callback

Access the Request object using createFromGlobals:

$router->put('/api/customer/update/{customerId}', function($customerId) {
    $request = \Concrete\Core\Http\Request::createFromGlobals();
    $email = $request->request->get('email');
    // Use $customerId and $email.
});

Responses

Use Concrete\Core\Http\Response for more control over responses:

Simple Response

use Symfony\Component\HttpFoundation\Response;

$router->get('/api/current_user', function() {
    return new Response('Return a simple string.');
});

JSON Response

Handle JSON data with JsonResponse:

use Symfony\Component\HttpFoundation\JsonResponse;

$router->get('/api/current_user', function() {
    $u = new \User();
    if ($u->isRegistered()) {
        $data = ['user_id' => $u->getUserID(), 'username' => $u->getUserName()];
        return new JsonResponse($data);
    } else {
        return new JsonResponse([], 400);
    }
});

Redirect Responses

Redirect using RedirectResponse:

use Symfony\Component\HttpFoundation\RedirectResponse;

$router->get('/api/current_user', function() {
    return new RedirectResponse('/api/new_location', 301);
});

Error Handling

Use ErrorList for error handling:

use Symfony\Component\HttpFoundation\JsonResponse;
use Concrete\Core\Error\ErrorList\ErrorList;

$router->get('/api/current_user', function() {
    $u = new \User();
    $errors = new ErrorList();
    if (!$u->isRegistered()) {
        $errors->add('You must be logged in to get the current user.');
        return $errors->createResponse(400);
    }
    // Handle registered users.
});

Middleware

Add middleware for additional request filtering:

$router->get('/api/current_user', function() {
    // Logic here.
})->addMiddleware(OAuthAuthenticationMiddleware::class);

Beyond Closures

For more complex routing, use controllers instead of closures. Add routes in application/bootstrap/app.php.

Controllers

While callbacks offer a simple approach to wiring logic to a route, they aren’t suited for applications with a high volume of routes. Instead, wire your routes to a controller. In Concrete CMS, a controller is any PHP class.

Autoloading

(Note: in these examples, it’s assumed that you have autoloading set up to load these classes, and they are accessible through PHP. To learn how to enable autoloading of PHP classes in your Concrete packages or application directory, check out the autoloading documentation.

Route Example

Let’s take our current_user route example. How about we move it out of a route, and into a dedicated PHP controller. This is how that’s done:

$router->get('/api/current_user', 'Application\Api\Controller\UserController::getCurrentUser');

We now no longer include the logic of retrieving the current user inline with the route definition. Instead, it is housed in a separate class, found at Application\Api\Controller\User. Furthermore, we have a specific method within this controller class that we run when the route is visited, getCurrentUser.

What does this controller look like? There isn’t much to it. At application/src/Api/Controller/User.php, we have:

<?php
namespace Application\Api\Controller;
use Concrete\Core\User\User;
use Symfony\Component\HttpFoundation\JsonResponse;

class UserController
{

    public function getCurrentUser()
    {
        $u = new User();
        if ($u->isRegistered()) {
            $data = [];
            $data['user_id'] = $u->getUserID();
            $data['username'] = $u->getUserName();
            return new JsonResponse($data);
        } else {
            return new JsonResponse([], 500);
        }
    }
}

That’s it! We have moved our logic into a controller. You don’t need to extend any particular base classes. Just return a valid response object.

Note: Use Dependency Injection

Before we move on, let’s consider the code above, and how it can be improved by using dependency injection. Dependency injection moves any classes that are code is dependent on into the constructor. That means that we can test our code, and provide mock objects to the constructor with a minimum of fuss. Concrete automatically provides classes when you make them available to the class, so you don’t even have to worry about the dependent classes being instantiated and passed. Let’s change the above to use this functionality.

<?php
namespace Application\Api\Controller;
use Concrete\Core\User\User;
class UserController
{
    protected $currentUser;
    public function __construct(User $currentUser)
        $this->currentUser = $currentUser;
    }
    public function getCurrentUser()
    {
        if ($this->currentUser->isRegistered() {
            // etc…
        }
    }
}

Yes, this version contains more code, but if we want to test our code (and we should) this will be easier, because we can do things like pass PHP mock objects to the constructor when testing them, in order to determine whether their behavior is proper.

Views

Use view objects in route controllers for HTML rendering or complex responses.

View Object

A view in Concrete CMS is a PHP template path with an optional package handle. View objects, mainly Concrete\Core\View\View, hold template paths, package handles, themes, and rendering capabilities, but don't appear in sitemaps or have page attributes.

Rendering a View Object

Example of a GET route /account/activate rendering a view:

$router->get('/account/activate', function() use ($app) {
    $factory = $app->make(\Concrete\Core\Http\ResponseFactoryInterface::class);
    $view = new \Concrete\Core\View\View('/account/activate/form');
    $view->setViewTheme('green_salad');
    return $factory->createResponse($view->render());
});

Theme Considerations

Ensure themes can operate without a valid page object to avoid errors when rendering view templates.

Refactoring to Controller

Instead of a closure, use a controller for better encapsulation and testability:

$router->get('/account/activate', ‘Application\Controller\AccountController::activate’);

AccountController

<?php
namespace Application\Controller;
use Concrete\Core\Http\ResponseFactoryInterface;
use Concrete\Core\View\View;

class AccountController
{
    protected $responseFactory;

    public function __construct(ResponseFactoryInterface $responseFactory)
    {
        $this->responseFactory = $responseFactory;
    }

    public function activate()
    {
        $view = new View('/account/activate/form');
        $view->setThemeHandle('green_salad');
        return $this->responseFactory->view($view);
    }
}

Passing Data to View

Extend Concrete\Core\Controller\Controller for data passing:

<?php
namespace Application\Controller;
use Concrete\Core\Http\ResponseFactoryInterface;
use Concrete\Core\View\View;
use Concrete\Core\User\User;
use Concrete\Core\Controller\Controller as BaseController;

class AccountController extends BaseController
{
    protected $responseFactory;
    protected $user;

    public function __construct(User $user, ResponseFactoryInterface $responseFactory)
    {
        $this->user = $user;
        $this->responseFactory = $responseFactory;
    }

    public function activate()
    {
        $view = new View('/account/activate/form');
        $view->setThemeHandle(‘green_salad’);
        $this->set('username', $this->user->getUserName());
        $view->setController($this);
        return $this->responseFactory->view($view);
    }
}

Custom Dialog Views

For custom dialogs in Concrete, replace regular views with DialogView:

$view = new \Concrete\Core\View\DialogView('/account/activate/form');

Grouping Routes

RouteList

Replace in application/bootstrap/app.php:

$router->get(‘/api/users’, ‘Application\Api\Controller\UserController::getList’);
$router->get(‘/api/user/{userId}’, ‘Application\Api\Controller\UserController::read’);
$router->post(‘/api/user’, ‘Application\Api\Controller\UserController::add’);
$router->put(‘/api/user/{userId}’, ‘Application\Api\Controller\UserController::update’);
$router->delete(‘/api/user/{userId}’, ‘Application\Api\Controller\UserController::delete’);

With Application\RouteList in application/src/RouteList.php:

<?php
namespace Application;
use Concrete\Core\Routing\RouteListInterface;
use Concrete\Core\Routing\Router;
class RouteList implements RouteListInterface
{
    public function loadRoutes($router)
    {
        // Routes here
    }
}

Call in application/bootstrap/app.php:

$router = $app->make(Router::class);
$list = new Application\RouteList();
$list->loadRoutes($router);

Route Groups

Use buildGroup() to group routes with common configurations:

$router->buildGroup()
->setPrefix(‘/api’)
->setNamespace(‘Application\Api\Controller’)
->routes(function($groupRouter) {
    // Grouped routes here
});

For API authentication, add middleware:

$router->buildGroup()
->addMiddleware(OAuthAuthenticationMiddleware::class)
->setNamespace(‘Application\Api\Controller’);
// Group configurations...

Store Route Groups in Files

Separate group definition and routes:

$router->buildGroup()
->setPrefix(‘/api’)
->setNamespace(‘Application\Api\Controller’)
->routes(‘users.php’);

Move route registrations to application/routes/users.php.

Combine Route Lists and Route Groups

RouteList can register route groups in loadRoutes():

class ApiRouteList implements RouteListInterface
{
    public function loadRoutes(Router $router)
    {
        // Route group registrations with common middlewares and scopes
    }
}

Hierarchical route groups share common prefixes and middlewares.

Including Routes in Packages

Set Up Autoloading

In packages/classifieds/controller.php, enable autoloading for “Aembler\Classifieds”:

protected $pkgAutoloaderRegistries = [
    'src/' => ‘Aembler\\Classifieds’
];

Create Your RouteList File

Create RouteList.php in packages/classifieds/src with:

<?php
namespace Aembler\Classifieds;
use Concrete\Core\Routing\RouteListInterface;
use Concrete\Core\Routing\Router;

class RouteList implements RouteListInterface
{
    public function loadRoutes($router)
    {
        // Routes and route groups
    }
}

Specify Package Handle in Route Groups

For route groups from files, specify package handle:

$router->buildGroup()
->routes(‘routes.php’, ‘classifieds’);

Loads routes from packages/classifieds/routes/routes.php.

Register in on_start()

In packages/classifieds/controller.php, add:

use Aembler\Classifieds\RouteList;

public function on_start()
{
    $router = $this->app->make(‘router’);
    $list = new RouteList();
    $list->loadRoutes($router);
}

Routes in the package’s route list are now registered.

Supporting the Fractal Middleware

Fractal is a PHP library for managing API output. From their site:

Fractal provides a presentation and transformation layer for complex data output, like in RESTful APIs, and works well with JSON. It's like a view layer for your JSON/YAML/etc. In typical APIs, data is fetched from the database and passed to json_encode(). This works for simple APIs, but for public or mobile app APIs, this can lead to inconsistent output.

Fractal acts as a bridge between data objects and JSON conversion. It's useful for maintaining consistent output, especially when objects relate to each other.

Example

Concrete CMS uses Fractal in the API, like in the /ccm/api/v1/system/info route, which returns JSON data about the site (available in Dashboard > System and Settings > Environment > Environment Information). The route and logic are set up as follows:

Route definition in ApiRouteList:

$api->buildGroup()
    ->scope('system')
    ->routes('api/system.php');

Logic in concrete/routes/api/system.php (simplified version):

use Concrete\Core\System\Info;
use Symfony\HttpFoundation\JsonResponse;
$router->get('/system/info', function() {
    $info = new Info();
    // Data array with various properties
    return new JsonResponse($data);
});

Using Fractal, the route definition becomes more flexible:

use Concrete\Core\System\Info;
$router->get('/system/info', function() {
    return new \League\Fractal\Resource\Item(new Info(), new \Concrete\Core\System\InfoTransformer());
});

The InfoTransformer class:

<?php
namespace Concrete\Core\System;

use League\Fractal\TransformerAbstract;

class InfoTransformer extends TransformerAbstract
{
    public function transform(Info $info)
    {
        // Data array with various properties
    }
}

Middleware Handling:

Concrete\Core\Http\Middleware\FractalNegotiatorMiddleware checks if responses are Fractal objects and converts them to JSON.

public function process(Request $request, DelegateInterface $frame)
{
    $response = $frame->next($request);

    // Fractal conversion logic

    return $response;
}

Use Fractal in Your APIs

To utilize Fractal in custom API code, apply the Concrete\Core\Http\Middleware\FractalNegotiatorMiddleware to your route or group. This ensures well-managed and consistent API responses.