Skip to content

Add custom Symfony CMF Dynamic Router / Route Provider to new Route Bundle #7860

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Conversation

alexander-schranz
Copy link
Member

@alexander-schranz alexander-schranz commented Mar 26, 2025

Q A
Bug fix? no
New feature? yes
BC breaks? no
Deprecations? no
Fixed tickets fixes #
Related issues/PRs #
License MIT
Documentation PR sulu/sulu-docs#

What's in this PR?

Implement the cmf dynamic router into the new route bundle. The implementation will be kept internal. The new router uses our own Route Model / Entity: https://github.com/sulu/sulu/blob/3.0/packages/route/config/doctrine/Route/Route.orm.xml

Why?

The new route bundle should be the only bundle in core handling the CMS based routing.

@alexander-schranz alexander-schranz force-pushed the feature/move-cmf-routing-in-new-route-bundle branch 2 times, most recently from 53f4dda to c794039 Compare March 26, 2025 11:49
@alexander-schranz alexander-schranz added the Feature New functionality not yet included in Sulu label Mar 26, 2025
@alexander-schranz alexander-schranz force-pushed the feature/move-cmf-routing-in-new-route-bundle branch from c794039 to 32d082f Compare March 26, 2025 13:27
Comment on lines 21 to 32
/**
* @final
*
* @internal No BC promises are given for this class. It may be changed or removed at any time.
*/
class CmfRouteProvider implements RouteProviderInterface
{
/**
* @param RouteCollectionForRequestLoaderInterface[] $routeCollectionForRequestLoaders
*/
public function __construct(
private iterable $routeCollectionForRequestLoaders,
) {
}
Copy link
Member Author

@alexander-schranz alexander-schranz Mar 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The whole idea is that this is our only connection point with the Symfony\Cmf. Marked it as @internal as this shouldn't be changed or used by projects. Also we are even free the replace it if in the future dynamic router is maybe part of symfony core or a new major version of it is released with changes.

With our own RouteCollectionForRequestLoaderInterface which just has public function getRouteCollectionForRequest(Request $request): RouteCollection; we have all about routing then in our own control. This removes the complex service definitions which we have here:

  • <!-- route-defaults -->
    <service id="sulu_route.routing.defaults_provider" class="Sulu\Bundle\RouteBundle\Routing\Defaults\RouteDefaultsProvider">
    <argument type="collection">%sulu_route.routing.defaults_provider%</argument>
    </service>
    <!-- router -->
    <service id="sulu_route.routing.proxy_factory" class="ProxyManager\Factory\LazyLoadingValueHolderFactory">
    <argument type="service" id="sulu_core.proxy_manager.configuration"/>
    </service>
    <service id="sulu_route.routing.provider" class="Sulu\Bundle\RouteBundle\Routing\RouteProvider" lazy="true">
    <argument type="service" id="sulu.repository.route"/>
    <argument type="service" id="sulu_core.webspace.request_analyzer"/>
    <argument type="service" id="sulu_route.routing.defaults_provider"/>
    <argument type="service" id="request_stack"/>
    <argument type="service" id="sulu_route.routing.proxy_factory"/>
    <argument type="collection" />
    <tag name="sulu.context" context="website"/>
    </service>
    <service id="sulu_route.routing.generator" class="Symfony\Cmf\Component\Routing\ProviderBasedGenerator">
    <argument type="service" id="sulu_route.routing.provider"/>
    <tag name="sulu.context" context="website"/>
    </service>
    <service id="sulu_route.routing.final_matcher" class="Symfony\Cmf\Component\Routing\NestedMatcher\UrlMatcher">
    <argument type="service" id="cmf_routing.matcher.dummy_collection"/>
    <argument type="service" id="cmf_routing.matcher.dummy_context"/>
    <tag name="sulu.context" context="website"/>
    </service>
    <service id="sulu_route.routing.nested_matcher" class="Symfony\Cmf\Component\Routing\NestedMatcher\NestedMatcher">
    <argument type="service" id="sulu_route.routing.provider"/>
    <argument type="service" id="sulu_route.routing.final_matcher"/>
    <tag name="sulu.context" context="website"/>
    </service>
    <service id="sulu_route.routing.router" class="Symfony\Cmf\Bundle\RoutingBundle\Routing\DynamicRouter">
    <argument type="service" id="router.request_context"/>
    <argument type="service" id="sulu_route.routing.nested_matcher"/>
    <argument type="service" id="sulu_route.routing.generator"/>
    <argument>%sulu_route.routing.uri_filter_regexp%</argument>
    <argument type="service" id="event_dispatcher" on-invalid="ignore"/>
    <argument type="service" id="sulu_route.routing.provider"/>
    <tag name="sulu.context" context="website"/>
    <tag name="router" priority="20"/>
    </service>
    </services>
  • <service id="sulu_custom_urls.routing.provider" class="Sulu\Component\CustomUrl\Routing\CustomUrlRouteProvider">
    <argument type="service" id="sulu_core.webspace.request_analyzer"/>
    <argument type="service" id="sulu_document_manager.path_builder"/>
    <argument type="string">%kernel.environment%</argument>
    <argument type="collection" />
    <tag name="sulu.context" context="website"/>
    </service>
    <service id="sulu_custom_urls.routing.generator" class="Symfony\Cmf\Component\Routing\ProviderBasedGenerator">
    <argument type="service" id="sulu_custom_urls.routing.provider"/>
    <tag name="sulu.context" context="website"/>
    </service>
    <service id="cmf_sulu_custom_urls.final_matcher" class="Symfony\Cmf\Component\Routing\NestedMatcher\UrlMatcher">
    <argument type="service" id="cmf_routing.matcher.dummy_collection"/>
    <argument type="service" id="cmf_routing.matcher.dummy_context"/>
    <tag name="sulu.context" context="website"/>
    </service>
    <service id="sulu_custom_urls.routing.nested_matcher"
    class="Symfony\Cmf\Component\Routing\NestedMatcher\NestedMatcher">
    <argument type="service" id="sulu_custom_urls.routing.provider"/>
    <argument type="service" id="cmf_sulu_custom_urls.final_matcher"/>
    <tag name="sulu.context" context="website"/>
    </service>
    <service id="sulu_custom_urls.routing.route_enhancers.trailing_slash"
    class="Sulu\Component\CustomUrl\Routing\Enhancers\TrailingSlashEnhancer">
    <tag name="sulu.context" context="website"/>
    <tag name="sulu_custom_urls.route_enhancer" priority="100"/>
    </service>
    <service id="sulu_custom_urls.routing.route_enhancers.trailing_html"
    class="Sulu\Component\CustomUrl\Routing\Enhancers\TrailingHTMLEnhancer">
    <tag name="sulu.context" context="website"/>
    <tag name="sulu_custom_urls.route_enhancer" priority="90"/>
    </service>
    <service id="sulu_custom_urls.routing.route_enhancers.redirect"
    class="Sulu\Component\CustomUrl\Routing\Enhancers\RedirectEnhancer">
    <argument type="service" id="sulu_core.webspace.webspace_manager"/>
    <tag name="sulu.context" context="website"/>
    <tag name="sulu_custom_urls.route_enhancer" priority="80"/>
    </service>
    <service id="sulu_custom_urls.routing.route_enhancers.seo"
    class="Sulu\Component\CustomUrl\Routing\Enhancers\SeoEnhancer">
    <argument type="service" id="sulu_core.webspace.webspace_manager"/>
    <tag name="sulu.context" context="website"/>
    <tag name="sulu_custom_urls.route_enhancer" priority="70"/>
    </service>
    <service id="sulu_custom_urls.routing.route_enhancers.content"
    class="Sulu\Component\CustomUrl\Routing\Enhancers\ContentEnhancer">
    <argument type="service" id="sulu_document_manager.document_inspector"/>
    <argument type="service" id="sulu.content.structure_manager"/>
    <tag name="sulu.context" context="website"/>
    <tag name="sulu_custom_urls.route_enhancer" priority="60"/>
    </service>
    <service id="sulu_custom_urls.routing.route_enhancers.internal_link"
    class="Sulu\Component\CustomUrl\Routing\Enhancers\InternalLinkEnhancer">
    <tag name="sulu.context" context="website"/>
    <tag name="sulu_custom_urls.route_enhancer" priority="50"/>
    </service>
    <service id="sulu_custom_urls.routing.route_enhancers.structure"
    class="Sulu\Component\CustomUrl\Routing\Enhancers\StructureEnhancer">
    <tag name="sulu.context" context="website"/>
    <tag name="sulu_custom_urls.route_enhancer" priority="40"/>
    </service>
    <service id="sulu_custom_urls.routing.route_enhancers.external_link"
    class="Sulu\Component\CustomUrl\Routing\Enhancers\ExternalLinkEnhancer">
    <tag name="sulu.context" context="website"/>
    <tag name="sulu_custom_urls.route_enhancer" priority="30"/>
    </service>
    <service id="sulu_custom_urls.routing.router" class="Symfony\Cmf\Bundle\RoutingBundle\Routing\DynamicRouter">
    <argument type="service" id="router.request_context"/>
    <argument type="service" id="sulu_custom_urls.routing.nested_matcher"/>
    <argument type="service" id="sulu_custom_urls.routing.generator"/>
    <argument>%sulu_custom_urls.uri_filter_regexp%</argument>
    <argument type="service" id="event_dispatcher" on-invalid="ignore"/>
    <argument type="service" id="sulu_custom_urls.routing.provider"/>
    <tag name="sulu.context" context="website"/>
    <tag name="router" priority="30"/>
    </service>
    <service id="sulu_custom_urls.request_processor"
    class="Sulu\Bundle\CustomUrlBundle\Request\CustomUrlRequestProcessor"
    lazy="true">
    <argument type="service" id="sulu_custom_urls.manager"/>
    <argument type="service" id="sulu_custom_urls.domain_generator"/>
    <argument type="service" id="sulu_core.webspace.webspace_manager"/>
    <argument type="string">%kernel.environment%</argument>
    <tag name="sulu.context" context="website"/>
    <tag name="sulu.request_attributes" priority="32"/>
    </service>
  • https://github.com/sulu/SuluRedirectBundle/blob/6964ba5714cc1849e3243b6882a289fc845595f9/Resources/config/router.xml#L7-L51

So we are replacing the different DynamicRouter of 1.x and 2.x with a single DynamicRouter. Which config is a lot easier:

            $builder->prependExtensionConfig(
                'cmf_routing',
                [
                    'chain' => [
                        'routers_by_id' => [
                            'router.default' => 100,
                            'cmf_routing.dynamic_router' => 20,
                        ],
                    ],
                    'dynamic' => [
                        'route_provider_service_id' => 'sulu_route.symfony_cmf_route_provider',
                        'enabled' => true,
                    ],
                ]
            );
        if ($builder->hasExtension('cmf_routing')) {
            $services->set('sulu_route.symfony_cmf_route_provider')
                ->class(CmfRouteProvider::class)
                ->args([
                    tagged_iterator('sulu_route.route_collection_for_request_loader'),
                ]);
        }

I also added the RouteDefaultsProviderInterface which is very similar to our existing RouteBundle entity of 2.6 version. But simplified it that every resourceKey requires an own defaults provider.

@alexander-schranz alexander-schranz force-pushed the feature/move-cmf-routing-in-new-route-bundle branch from eba11ee to d8f3ef2 Compare March 26, 2025 17:33
@alexander-schranz alexander-schranz force-pushed the feature/move-cmf-routing-in-new-route-bundle branch 9 times, most recently from 646c1eb to 98e0089 Compare March 27, 2025 12:41
Copy link

@Copilot Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR implements the CMF Dynamic Router by integrating a custom Symfony router into the new route bundle to handle CMS-based routing using an internal Route Model/Entity.

  • Implement the dynamic router for CMS routes.
  • Add service configuration for ExampleSiteRouteGenerator in the test application configuration.
Files not reviewed (18)
  • config/bundles.php: Language not supported
  • packages/route/src/Application/Routing/Generator/RouteGenerator.php: Language not supported
  • packages/route/src/Application/Routing/Generator/RouteGeneratorInterface.php: Language not supported
  • packages/route/src/Application/Routing/Generator/SiteRouteGeneratorInterface.php: Language not supported
  • packages/route/src/Application/Routing/Matcher/RouteCollectionForRequestLoaderInterface.php: Language not supported
  • packages/route/src/Application/Routing/Matcher/RouteCollectionForRequestRouteLoader.php: Language not supported
  • packages/route/src/Application/Routing/Matcher/RouteDefaultsProviderInterface.php: Language not supported
  • packages/route/src/Application/Routing/Matcher/RouteHistoryDefaultsProvider.php: Language not supported
  • packages/route/src/Domain/Exception/MissingRequestContextParameterException.php: Language not supported
  • packages/route/src/Domain/Model/Route.php: Language not supported
  • packages/route/src/Domain/Value/RequestAttributeEnum.php: Language not supported
  • packages/route/src/Infrastructure/Symfony/HttpKernel/SuluRouteBundle.php: Language not supported
  • packages/route/src/Infrastructure/SymfonyCmf/Routing/CmfRouteProvider.php: Language not supported
  • packages/route/tests/Application/ExampleSiteRouteGenerator.php: Language not supported
  • packages/route/tests/Application/Kernel.php: Language not supported
  • packages/route/tests/Functional/Infrastructure/Doctrine/Repository/RouteRepositoryTest.php: Language not supported
  • packages/route/tests/Functional/Infrastructure/Symfony/HttpKernel/SuluRouteBundleTest.php: Language not supported
  • packages/route/tests/Functional/Infrastructure/SymfonyCmf/Routing/CmfRouteProviderTest.php: Language not supported
Comments suppressed due to low confidence (1)

packages/route/tests/Application/config/config.yml:11

  • [nitpick] Consider verifying if the service name 'ExampleSiteRouteGenerator' clearly reflects its intended use. If it's solely for testing purposes, a more descriptive name might help distinguish its role.
+

@alexander-schranz alexander-schranz force-pushed the feature/move-cmf-routing-in-new-route-bundle branch from 98e0089 to 5d5c1b6 Compare March 27, 2025 12:42
…for request loader and history route defaults provider
@alexander-schranz alexander-schranz force-pushed the feature/move-cmf-routing-in-new-route-bundle branch from 5d5c1b6 to 46ce49c Compare March 27, 2025 13:21
Comment on lines +49 to +57
$hasDynamicRouting = false;
foreach ($bundles as $key => $bundle) {
// remove old route bundle to avoid conflicts
if (DeprecatedSuluRouteBundle::class === $bundle::class
|| SuluPreviewBundle::class === $bundle::class
|| \str_contains($bundle::class, 'Sulu')
|| \str_contains($bundle::class, 'Massive')
|| \str_contains($bundle::class, 'PHPCR')
|| \str_contains($bundle::class, 'SecurityBundle')
Copy link
Member Author

@alexander-schranz alexander-schranz Mar 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

currently having lot of conflicts with Sulu and other bundles as that combinations not work properly so I removed all bundles which are not required to run the RouteBundle tests.

@alexander-schranz alexander-schranz marked this pull request as ready for review March 27, 2025 13:48
@alexander-schranz alexander-schranz merged commit f4ca097 into sulu:3.0 Mar 28, 2025
8 of 9 checks passed
@alexander-schranz alexander-schranz deleted the feature/move-cmf-routing-in-new-route-bundle branch March 28, 2025 09:54
public function getRouteCollectionForRequest(Request $request): RouteCollection
{
foreach ($this->routeCollectionForRequestLoaders as $routeCollectionLoader) {
$routeCollection = $routeCollectionLoader->getRouteCollectionForRequest($request);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm little bit unsure about giving here the Request $request here. I see that the Symfony router is also using the Request or string pathInfo for the UrlMatcher: https://github.com/symfony/routing/blob/ee9a67edc6baa33e5fae662f94f91fd262930996/Matcher/UrlMatcher.php#L85

But from my point of view the RequestContext would maybe be a better option 🤔 to be more consistent between Matcher and Generator, maybe I miss understand the concept of the RequestContext, and we should stay with Request for matching and context for generating.

/cc @Prokyonn @dbu

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as i understand, the matcher uses the Request as that holds a rich model of the whole request including headers. the header bag gives you precise access to headers, even parsing cache headers, or more relevant to routing the accept header etc.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i would look if you can make this method a custom RouterInterface implementation that either throws the route not found exception or returns the one route that matches the slug.

the dynamic router is built around the idea of finding several candidates and not reimplement the logic to decide which route candidate should win, with accept or parameters and regex etc.

Copy link
Member Author

@alexander-schranz alexander-schranz Apr 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, the Symfony RouterInterface just has the getRouteCollection without any parameters and so Request so to achieve same logic I would create own router then and inject there the requestStack to get currentRequest for the matching. It doesn't make any problems implementing that interface and return a empty collection in none match / cli context? Thought maybe it would cache the empty collection and will then not longer be called again.

https://github.com/symfony/routing/blob/ee9a67edc6baa33e5fae662f94f91fd262930996/RouterInterface.php#L32

it has a strange comment on it:

 * WARNING: This method should never be used at runtime as it is SLOW.
 *          You might use it in a cache warmer though.

return new RouteCollection();
}

$route = $this->routeRepository->findOneBy([
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the general phylosophy of the dynamic router is that there could be several database entries potentially matching the request (the default entity of the cmf bundle allows for a parameter matching part in the url, and the usual constraints of symfony routes, so that the db could hold /data/{id} and /data/special and with the right rules the symfony matcher then picks the correct one.

or multiple routes with different requirements on the accept header.

if you only ever have one route per site & slug, fetching only one is fine, but if you are sure about that, i am not sure about doing a collection loader - you could also implement the RouterInterface directly and have it match the one route you found. (or do you expect that sometimes what you find with the slug is going to be not matched, because it has furhter constraints?)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really thank you @dbu for taking a look at this,

I think we really have never the case that multiple routes are returned. If a "static route" is matched it should not call our Route Entity. If a Slug exists it will return that route entity, and go only to the next loader / provider if it didn't match.

That two or more routes are returned I really think never happens in our case as we only handling "direct" matches. The only thing is that multiple URLs can match the same route e.g. /blog and /blog.rss where we skipt the .rss in loading.

(or do you expect that sometimes what you find with the slug is going to be not matched, because it has furhter constraints?)

Even it has further constraint it is route independent — so the controller may throw a 404 if now /blog.rss is requested but no blog.rss.twig exists. But that is a process totally outside of routing and don't need to be handled there.

To be sure the ChainRouter will only call the others if it could not find any match?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, the first router to match wins.

the routers can have a priority number to control the order (so you can have slugs overwrite symfony system routes, or protect system routes from being overwritten by slugs).

i would tend to put the symfony system router first, to avoid database queries when a system route is matched, and to prevent somebody creating a page at /login or whatever, which could at least mess up things, or in the worst case post security risks.

Copy link
Member Author

@alexander-schranz alexander-schranz Apr 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see, thank you. Static Routes are prioritzed also in your case. We still have on our list to not allow creating dynamic patches which may match a static route for better UX, but from the priority view its not a problem.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for that you could have a service that is triggered during save that does not have the chain router but only the symfony router and tries to match the slug and complains if it matches...

$defaults['_sulu_route'] = $route;

$routeCollection = new RouteCollection();
$symfonyRoute = new SymfonyRoute(
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

again, just a note, not something i think you have to change: in the cmf bundle, we had our entity extend the symfony route model class. it needed a few hacks however, but as we potentially match many more than 1 single route, it seemed worthwile to not have to convert all the objects.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah we avoid extend from third party when it is not required. We burned in the past often our hands when third party classes added new methods or return types. So we go over composition over inheritance where possible :) But still thx for the suggestion.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep, makes a lot of sense to do it this way. the constructor certainly is covered by the symfony BC promise. random internal methods and properties can be tricky.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Feature New functionality not yet included in Sulu
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants