Dynamic Route loading in a non standard Symfony structure

2024-08-16

When you divert from Symfony’s standard structure there are some things that do not work out of the box anymore. One of it is routing.

Default Symfony

If you start a fresh Symfony project you will be presented with the following stricture:

app/src  
├─ Controller
├─ Entity
├─ Repository
└─ Kernel.php

The routing config looks like this:

# app/config/routes.yaml
controllers:
    resource:
        path: ../src/Controller/
        namespace: App\Controller
    type: attribute

So the Controller directory is the place all controllers go you might think first. But When the project gets bigger a different structure might make more sense.

The DDD/Clean Architecture approach

If we use a DDD/Clean Architecture approach the structure might look like this:

app/src
├─ Blog 
│ ├─ Application
│ │ └─ CreateArticleController.php
│ ├─ Domain
│ │ └─ Article.php
│ └─ Infrastructure
├─ Registration
│ ├─ Application
│ │ ├─ CreateAccountController.php
│ │ ├─ CreateAccountRequest.php
│ │ └─ CreateAccountResponse.php
│ ├─ Domain
│ │ └─ Account.php
│ └─ Infrastructure
└─ Kernel.php

But now every Controller needs to be added to the routing config since the loading expects all controllers in one place.

tip
Configs should be kept clean and small
# app/config/routes.yaml
app_registration_createaccount:
    # loads routes from the PHP attributes of the given class
    resource: App\Registration\CreateAccountController
    type:     attribute

app_blog_createarticle:
    # loads routes from the PHP attributes of the given class
    resource: App\Blog\CreateArticleController
    type:     attribute

This is not the comfortable way, since we were starting to embrace the Route attribute because it means we do not need to add each route to the routing config in a growing file and developers must not forget to add or update each route which might get annoying during refactoring.

Loading Routes dynamic

In Symfony there is a simple way we can solve this: We can create our own loader with a custom service.

The implementation

From the Symfony Documentation

When the main loader parses this, it tries all registered delegate loaders.

If you’re using autoconfigure, your class should implement the RouteLoaderInterface interface to be tagged automatically.

If your service is invokable, you don’t need to specify the method to use.

Your service doesn’t have to extend or implement any special class, but the called method must return a RouteCollection object.

All good points

  • We create an invokable service class
  • Implement the RouteLoaderInterface
  • Let it return a RouteCollection
app/src
├─ Blog
│ └─ Application
│   └─ CreateArticleController.php
├─ Registration
│ └─ Application
│   └─ CreateAccountController.php
├─ Shared
│ └─ Application
│   └─ Routing
│     └─ RouteLoader.php <=== here
└─ Kernel.php

The Loader class:

<?php  
  
declare(strict_types=1);  
  
namespace App\Shared\Application\Routing;  
  
use ReflectionClass;  
use Symfony\Bundle\FrameworkBundle\Routing\RouteLoaderInterface;  
use Symfony\Component\Finder\Finder;  
use Symfony\Component\Routing\Attribute\Route as RouteAttribute;  
use Symfony\Component\Routing\Route;  
use Symfony\Component\Routing\RouteCollection;  
  
class RouteLoader implements RouteLoaderInterface  
{  
    private bool $isLoaded = false;  
  
    public function __construct(private readonly string $routeLoaderBaseDirectory)  
    {  
    }  
 
    public function __invoke(mixed $resource, string $type = null): RouteCollection  
    {
        if ($this->isLoaded) {  
            throw new \RuntimeException('Do not add this loader twice.');
        }

        $routeCollection = new RouteCollection();  

        $finder = self::fromDirectories(
            $this->routeLoaderBaseDirectory,
            $this->routeLoaderBaseDirectory . '/*/**',
        );

        foreach ($finder as $file) {
            $className = $this->getClassNameFromFile($file->getRealPath());
            $namespace = $this->getClassNamespaceFromFile($file->getRealPath());
            if (!$className || !$namespace) {
                continue;
            }
            $fullQualifiedClassName = $namespace . '\\' . $className;

            // Use reflection to check for Symfony Route attributes
            $reflectionClass = new ReflectionClass($fullQualifiedClassName);
            $attributes = $this->getRouteAttributes($reflectionClass);

            // Handle class-level attributes for invokable controller classes
            if ($reflectionClass->hasMethod('__invoke')) {
                foreach ($attributes as $routeAttribute) {
                    $route = $this->createRouteFromAttribute($routeAttribute);
                    $routeName = $routeAttribute->getName() ?? $this->generateRouteName($fullQualifiedClassName, '__invoke'); 
                    $routeCollection->add($routeName, $route);
                }
                continue; // there should only be a class declaration when invokable
            }

            // Handle method-level attributes
            foreach ($reflectionClass->getMethods() as $method) {
                foreach ($attributes as $routeAttribute) {
                    $route = $this->createRouteFromAttribute($routeAttribute);
                    $routeName = $routeAttribute->getName() ?? $this->generateRouteName($fullQualifiedClassName, $method->getName());
                    $routeCollection->add($routeName, $route);
                }
            }
        }

        $this->isLoaded = true;

        return $routeCollection;
    }

    // ...
}

See full gist.github.com/dazz of RouteLoader.php

Add the class in the routing config:

# app/config/routing.yaml
controllers:  
    resource: App\Shared\Application\Routing\RouteLoader  
    type: service

One thing still left to do is make the path with the $routeLoaderBaseDirectory in the service configuration autowirable.

# app/config/services.yaml
parameters:  
  
services:  
    _defaults:  
        autowire: true
        autoconfigure: true
        bind:
            string $routeLoaderBaseDirectory: '%kernel.project_dir%/src'

Controller

namespace App\Registration\Application;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;

#[AsController] # to make it autowirable
#[Route(
    path: '/registration/createaccount',
    name: 'app_registration_createaccount',
    methods: [Request::METHOD_POST]
)]
final readonly class CreateAccountController
{
    public function __invoke(CreateAccountRequest $request): CreateAccountResponse
    {
        // create account logic
        return new CreateAccountResponse();
    }
}

bin/console debug:router

> bin/console debug:router
 -------------------------------- -------- -------- ------ ----------------------------- 
  Name                             Method   Scheme   Host   Path                         
 -------------------------------- -------- -------- ------ ----------------------------- 
  app_blog_createarticle           POST     ANY      ANY    /blog/article  
  app_registration_createaccount   POST     ANY      ANY    /registration/account
 -------------------------------- -------- -------- ------ -----------------------------

Happy loading routes everyone!

More sources

Enter your instance's address


More posts like this

Environment variables in a dockerized Symfony

2023-01-02 | #cd #ci #docker #docker-compose #dotenv #env_file #symfony

I have developed a Symfony Web-Application, and it runs locally in a dockerized environment with docker-compose. This app is going to be deployed to production as a docker container. In production the handling of environment variables and how they are passed to the container during development is different. 12 Factor App A few points from the 12factor methodology: III. Config: Store config in the environment since env vars are easy to change between deploys without changing any code X.

Continue reading 


about

0001-01-01 | #berlin #dazz #symfony

I love programming and most parts of it is trying to figure out how things can be used to make the life easier for everyone. Symfony is making my life easier since 2012, and I’m now the organizer of the Symfony Usergroup Berlin. I’m member of c-base the crashed space station in berlin and there I experiment with all the other carbon based beings how to interact in a future compatible style.

Continue reading 