Skip to content

Instantly share code, notes, and snippets.

@dazz
Last active August 18, 2024 18:04
Show Gist options
  • Save dazz/151ed59887dc0299c4f462c33b701c94 to your computer and use it in GitHub Desktop.
Save dazz/151ed59887dc0299c4f462c33b701c94 to your computer and use it in GitHub Desktop.
Dynamic route loading in a non-standard Symfony directory structure
<?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));
}
}
controllers:
resource: App\Shared\Application\Routing\RouteLoader
type: service
# 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'
@dazz
Copy link
Author

dazz commented Aug 18, 2024

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment