Last active
August 18, 2024 18:04
-
-
Save dazz/151ed59887dc0299c4f462c33b701c94 to your computer and use it in GitHub Desktop.
Dynamic route loading in a non-standard Symfony directory structure
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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 the "extra" loader twice'); | |
} | |
$routeCollection = new RouteCollection(); | |
// Search for all PHP files in the Registration/Application directory (or adjust as needed) | |
$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 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; | |
} | |
private static function fromDirectories(string $dir, string ...$moreDirs): Finder | |
{ | |
return (new Finder())->in($dir)->in($moreDirs)->files()->name('*Controller.php')->sortByName()->followLinks(); | |
} | |
/** | |
* @see https://stackoverflow.com/a/39887697 | |
*/ | |
private function getClassNameFromFile($filePathName): string | |
{ | |
$contents = file_get_contents($filePathName); | |
$classes = []; | |
$tokens = token_get_all($contents); | |
$count = count($tokens); | |
for ($i = 2; $i < $count; $i++) { | |
if ($tokens[$i - 2][0] == T_CLASS | |
&& $tokens[$i - 1][0] == T_WHITESPACE | |
&& $tokens[$i][0] == T_STRING | |
) { | |
$className = $tokens[$i][1]; | |
$classes[] = $className; | |
} | |
} | |
return array_pop($classes); | |
} | |
private function getClassNamespaceFromFile($filePathName): ?string | |
{ | |
$src = file_get_contents($filePathName); | |
$tokens = token_get_all($src); | |
$count = count($tokens); | |
$i = 0; | |
$namespace = ''; | |
$namespaceOk = false; | |
while ($i < $count) { | |
$token = $tokens[$i]; | |
if (is_array($token) && $token[0] === T_NAMESPACE) { | |
// Found namespace declaration | |
while (++$i < $count) { | |
if ($tokens[$i] === ';') { | |
$namespaceOk = true; | |
$namespace = trim($namespace); | |
break; | |
} | |
$namespace .= is_array($tokens[$i]) ? $tokens[$i][1] : $tokens[$i]; | |
} | |
break; | |
} | |
$i++; | |
} | |
return $namespaceOk ? $namespace : null; | |
} | |
private function createRouteFromAttribute(RouteAttribute $routeAttribute): Route | |
{ | |
return new Route( | |
path: $routeAttribute->getPath(), | |
defaults: $routeAttribute->getDefaults(), | |
requirements: $routeAttribute->getRequirements(), | |
options: $routeAttribute->getOptions(), | |
host: $routeAttribute->getHost(), | |
schemes: $routeAttribute->getSchemes(), | |
methods: $routeAttribute->getMethods(), | |
condition: $routeAttribute->getCondition() | |
); | |
} | |
private function generateRouteName(string $className, string $methodName): string | |
{ | |
$routeName = strtolower(str_replace( | |
['\\', 'Controller', 'Application_'], | |
['_', '', ''], | |
$className) | |
); | |
if ($methodName === '__invoke') { | |
return $routeName; | |
} | |
return $routeName . '_' . $methodName; | |
} | |
/** | |
* @return RouteAttribute[] | |
*/ | |
private function getRouteAttributes(ReflectionClass $reflectionClass): array | |
{ | |
return array_map(fn(\ReflectionAttribute $attribute) => $attribute->newInstance(), $reflectionClass->getAttributes(RouteAttribute::class)); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
controllers: | |
resource: App\Shared\Application\Routing\RouteLoader | |
type: service | |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# This file is the entry point to configure your own services. | |
# Files in the packages/ subdirectory configure your dependencies. | |
# Put parameters here that don't need to change on each machine where the app is deployed | |
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration | |
parameters: | |
services: | |
# default configuration for services in *this* file | |
_defaults: | |
autowire: true # Automatically injects dependencies in your services. | |
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. | |
bind: | |
string $routeLoaderBaseDirectory: '%kernel.project_dir%/src' |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
see https://blog.dazzlog.de/posts/2024-08-16_dynamic-route-loading-symfony/