Symfony 7 vs. .NET Core 8 - Routing; part 2
Tags: symfony, dotnetcore, routing
Published: 2024-05-20 08:59:09

## Disclaimer

This is a tutorial or a training course. Please don't expect a walk-through tutorial showing how to use ASP.NET Core. It only compares similarities and differences between Symfony and ASP.NET Core. Symfony is taken as a reference point, so if there are features only available in .NET Core, they may never get to this post (unless relevant to the comparison).

This is the continuation of the first post: [Symfony 7 vs. .NET Core 8 - Routing; part 1](https://dev.to/awons/symfony-7-vs-net-core-8-routing-part-1-3djp)

## Route parameters

### Defining and passing parameters

No big differences should be expected here. It all works similarly in both frameworks.

Both frameworks define parameters using curly braces `/blog/{slug}`. They are then received as arguments to the controller method (also lambda or delegate) like in the following examples.

#### Symfony

```php
#[Route('/blog/{slug}')]
public function show(string $slug): Response
```

#### .NET Core

```c#
[Route("/blog/{slug}")]
public IActionResult Show(string slug)
```

### Parameter constraints

Both frameworks allow us to constrain the value of a parameter.

#### Symfony

In Symfony, constraints are always defined as regular expressions. The preferred way is to define them outside the path, like in the following example.

```php
#[Route('/blog/{page}', requirements: ['page' => '\d+'])]
public function list(int $page): Response
```

We could also do the same thing directly on the path, but this is discouraged.

```php
#[Route('/blog/{page<\d+>}')]
public function list(int $page): Response
```

Some predefined regular expressions exist in the [Requirements](https://github.com/symfony/symfony/blob/7.0/src/Symfony/Component/Routing/Requirement/Requirement.php) enum.

Additional constraints can be derived from configuration parameters or defined in enums.

#### .NET Core

In contrast to Symfony, .NET Core always defines constraints on the path.

It always has some pre-defined constraints and allows for regular expressions.

The following is an example of a pre-defined constraint.

```c#
[Route("/blog/{page:int}")]
public IActionResult Show(int page)
```

We can even put multiple constraints on one parameter (it is possible in Symfony as long as we can put the constraint as a regular expression).

```c#
[Route("/blog/{page:int:min(1)}")]
public IActionResult Show(int page)
```

Here is how we could use a regular expression.

```c#
[Route("/blog/{page:regex(^\d+$)}")]
public IActionResult Show(int page)
```

Symfony allows us to pass a constraint as a parameter from the configuration. This is not supported in .NET (at least, I haven't found anything similar).

However, we can define custom constraints, which require writing some additional code.

We define the constraint as always, this time with a custom name.

```c#
[Route("/blog/{page:noZeroes}")]
public IActionResult Show(int page)
```

Then, we need the actual constraint (remember that we also need to parse and give back the actual value after the match: `out var routeValue`).

```c#
public class NoZeroesRouteConstraint : IRouteConstraint
{
    private static readonly Regex _regex = new(
        @"^[1-9]*$",
        RegexOptions.CultureInvariant | RegexOptions.IgnoreCase,
        TimeSpan.FromMilliseconds(100));

    public bool Match(
        HttpContext? httpContext, IRouter? route, string routeKey,
        RouteValueDictionary values, RouteDirection routeDirection)
    {
        if (!values.TryGetValue(routeKey, out var routeValue))
        {
            return false;
        }

        var routeValueString = Convert.ToString(routeValue, CultureInfo.InvariantCulture);

        if (routeValueString is null)
        {
            return false;
        }

        return _regex.IsMatch(routeValueString);
    }
}
```

At the end, we have to make the constraint available for the routing system like this:

```c#
builder.Services.AddRouting(options =>
    options.ConstraintMap.Add("noZeroes", typeof(NoZeroesRouteConstraint)));
```

### Optional parameters

Both frameworks know the concept of optional parameters. In both defined parameters, they are mandatory. In both, we can provide defaults directly in method signatures.

#### Symfony

```php
#[Route('/blog/{page}', requirements: ['page' => '\d+'])]
public function list(int $page = 1): Response
```

#### .NET Core

```c#
[Route("/blog/{page:int}")]
public IActionResult Show(int page = 1)
```

In both frameworks, parameters are nullable.

#### Symfony

```php
#[Route('/blog/{page?}', requirements: ['page' => '\d+'])]
public function list(?int $page): Response
```

#### .NET Core

```c#
[Route("/blog/{page:int?}")]
public IActionResult Show(int? page)
```

In both frameworks, we can provide the defaults in parameter definition.

#### Symfony

```php
#[Route('/blog/{page<\d+>?1}')]
public function list(int $page): Response
```

#### .NET Core

```c#
[Route("/blog/{page:int=1}")]
public IActionResult Show(int page)
```

### Parameter conversion

#### Symfony

Symfony has a feature, where it can convert request input into something different than primitives like integers or strings. The most prominent example is the integration with Doctrine, where we can do something like this:

```php
#[Route('/blog/{slug}', name: 'blog_show')]
public function show(BlogPost $post): Response
```

The converter will find the appropriate entry in the database and pass it as an input to the `show()` method.

While this works in combination with Doctrine, Symfony itself has a few built-in value resolvers for enums or date-time objects.

But this is not just to convert input. We can use this to inject the current user or session into the controller's action.

We can always implement our own resolver by implementing the following class:

```php
interface ValueResolverInterface
{
    /**
     * Returns the possible value(s).
     */
    public function resolve(Request $request, ArgumentMetadata $argument): iterable;
}
```

If you have ever used the `Request` object as an argument in an action, this is exactly how it works —use a resolver.

But this is not the only way to map input to method arguments. Symfony has a set of attributes we can use to decorate arguments and get things injected automatically (and as you might have guessed, it uses the `ValueResolverInterface` behind the scenes):

```php
public function dashboard(
    #[MapQueryParameter] string $firstName,
    #[MapQueryParameter] string $lastName,
    #[MapQueryParameter] int $age,
): Response
```

We can even validate the input and map only the expected value.

```php
public function dashboard(
    #[MapQueryParameter(filter: \FILTER_VALIDATE_REGEXP, options: ['regexp' => '/^\w+$/'])] string $firstName,
    #[MapQueryParameter] string $lastName,
    #[MapQueryParameter(filter: \FILTER_VALIDATE_INT)] int $age,
): Response
```

#### .NET Core

.NET has a concept called "model binding," which is very similar to what Symfony offers. This is how it works in a nutshell:

> * Retrieves data from various sources such as route data, form fields, and query strings.
> * Provides the data to controllers and Razor pages in method parameters and public properties.
> * Converts string data to .NET types.
> * Updates properties of complex types.

The biggest difference here is that while the feature can validate the model, it still calls the controller, and from there, we can check if the binding is valid.

Here is an example of how to get a header mapped into an argument:

```c#
public void OnGet([FromHeader(Name = "Accept-Language")] string language)
```

or map parts of the request into specific fields of a class (something we cannot do with Symfony, though we can map an entire object):

```c#
public class Instructor
{
    public int Id { get; set; }

    [FromQuery(Name = "Note")]
    public string? NoteFromQueryString { get; set; }

    // ...
}
```

These are just a few examples, but the idea should be clear.

We can also create a custom binding. First, create the binder that will map input into a custom object:

```c#
public class ListIntModelBinder : IModelBinder
{
    public Task BindModelAsync(ModelBindingContext context)
    {
        var query = context.HttpContext.Request.Query;
        var ints = query["ints"].ToString();
        if (string.IsNullOrEmpty(ints))
        {
            return Task.CompletedTask;
        }
        var values = ints.Split(',').Select(int.Parse).ToList();
        context.Result = ModelBindingResult.Success(values);

        return Task.CompletedTask;
    }
}
```

Next, we need a provider that will return our binder if the arguments of a method match:

```c#
public class ListIntModelBinderProvider : IModelBinderProvider
{
    public IModelBinder? GetBinder(ModelBinderProviderContext context)
    {
        if (context.Metadata.ModelType != typeof(List<int>))
        {
            return null;
        }

        return new ListIntModelBinder();
    }
}
```

Then, we need to register the binder provider like this:

```c#
builder.Services.AddControllers(options =>
{
    options.ModelBinderProviders.Insert(0, new ListIntModelBinderProvider());
});
```

And last but not least, use an attribute to mark where we want to use the binder:

```c#
[HttpGet("/numbers")]
public IActionResult Index([ModelBinder(BinderType = typeof(ListIntModelBinder))] List<int> ints)
```

We can now pass a comma-separated list as a query string parameter `/numbers?ints=1,2,3,4,5,6`, and it will convert it into a `List<int>`.

The biggest difference is validation. In both frameworks, validation is part of the resolver/binder. But in .NET, we still get into the controller, where we must check for validation errors and handle them appropriately. In Symfony, we can automate this process and return specific error codes before reaching the controller.

```php
public function dashboard(
    #[MapQueryString(
        validationGroups: ['strict', 'edit'],
        validationFailedStatusCode: Response::HTTP_UNPROCESSABLE_ENTITY
    )] UserDto $userDto
): Response
```

### Route aliasing

This comparison is going to be very short. I might be missing something, but from what I can tell, .NET does not have a concept of route aliases. A route can have a name, but this is where it ends.

In Symfony, we can mark route aliases/versions as deprecated. While this is not possible in the same way in .NET Core, we can mark whole routes as deprecated with Swagger. This is because Swagger is natively integrated with .NET Core.

### Route groups and prefixes

This is where we will notice the first differences. Although both frameworks have a concept of route grouping, the details differ.

#### Symfony

In Symfony, we can define groups in attributes and configuration files. Additionally, we can define requirements on the group level and apply them to all routes in the group.
Lastly, the route name will be concatenated with the parent name. So the `show()` route will be named `blog_show`.

```php
#[Route('/blog', requirements: ['_locale' => 'en|es|fr'], name: 'blog_')]
class BlogController extends AbstractController
{
    #[Route('/{_locale}', name: 'index')]
    public function index(): Response
    {
        // ...
    }

    #[Route('/{_locale}/posts/{slug}', name: 'show')]
    public function show(string $slug): Response
    {
        // ...
    }
}
```

#### .NET Core

In .NET Core, we can also similarly group routs using attributes:

```c#
[Route("/group", Name = "my_group")]
public class GroupController
{
    [Route("path", Name = "my_path")]
    public string MyPath()
    {
        return "my_path controller";
    }
}
```

But there are a few differences:

* Route names are not concatenated; to get the `MyPath` route, we must reference it as `my_path`.
* We cannot define the requirements for the children in the group.

### Redirects

In Symfony, we can define redirects on a route level. This is only possible in a configuration file like this:

```yaml
# config/routes.yaml
doc_shortcut:
    path: /doc
    controller: Symfony\Bundle\FrameworkBundle\Controller\RedirectController
    defaults:
        route: 'doc_page'
        # optionally you can define some arguments passed to the route
        page: 'index'
        version: 'current'
        # redirections are temporary by default (code 302) but you can make them permanent (code 301)
        permanent: true
        # add this to keep the original query string parameters when redirecting
        keepQueryParams: true
        # add this to keep the HTTP method when redirecting. The redirect status changes
        # * for temporary redirects, it uses the 307 status code instead of 302
        # * for permanent redirects, it uses the 308 status code instead of 301
        keepRequestMethod: true
        # add this to remove all original route attributes when redirecting
        ignoreAttributes: true
        # or specify which attributes to ignore:
        # ignoreAttributes: ['offset', 'limit']

legacy_doc:
    path: /legacy/doc
    controller: Symfony\Bundle\FrameworkBundle\Controller\RedirectController
    defaults:
        # this value can be an absolute path or an absolute URL
        path: 'https://legacy.example.com/doc'
        permanent: true
```

Because .NET Core does not have such configuration files, this kind of redirection requires writing custom code. We could not use anything out of the box to get it working. But custom code can certainly do the trick.

### Sub-domain routing

Possible in both frameworks. We can do it in .NET Core in a similar way as in Symfony.

#### Symfony

```php
#[Route('/', name: 'mobile_homepage', host: 'm.example.com')]
public function mobileHomepage(): Response
```

```php
#[Route('/', name: 'mobile_homepage', host: '{subdomain}.example.com', requirements: ['subdomain' => 'm|mobile'])]
public function mobileHomepage(): Response
```

#### .NET Core

```c#
[Route("/", Name = "mobile_homepage")]
[Host("mobile.example.com", "m.example.com")]
public string MobileHomepage()
```

The biggest difference is that in Symfony, we can parameterize the arguments to the `Route` attribute. This is something we cannot do in .NET Core (at least not out of the box without writing some custom code).

### Stateless routes/caching

#### Symfony

In Symfony, all non-stateless requests (requests where a session was started) will always respond with headers forbidding caching. A special parameter can overwrite this:

```php
#[Route('/', name: 'homepage', stateless: true)]
public function homepage(): Response
```

#### .NET Core

.NET Core does not manipulate the response headers when the session is started. We are 100% responsible for controlling what and how it gets cached. The response cache can be controlled using attributes.

```c#
[ResponseCache(Duration = 43200)]
public ActionResult GetPost()
```

## What's next?

We will continue with generating URLs in the next post.

Thanks for your time!
I'm looking forward to your comments. You can also find me on [LinkedIn](https://www.linkedin.com/in/aleksanderwons/), [X](https://x.com/AleksanderWons), or [Discord](https://discordapp.com/users/601775386233405470).