DEV Community

Aleksander Wons
Aleksander Wons

Posted on • Updated on

Symfony 7 vs. .NET Core 8 - Routing; part 2

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

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

#[Route('/blog/{slug}')]
public function show(string $slug): Response
Enter fullscreen mode Exit fullscreen mode

.NET Core

[Route("/blog/{slug}")]
public IActionResult Show(string slug)
Enter fullscreen mode Exit fullscreen mode

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.

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

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

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

Some predefined regular expressions exist in the Requirements 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.

[Route("/blog/{page:int}")]
public IActionResult Show(int page)
Enter fullscreen mode Exit fullscreen mode

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).

[Route("/blog/{page:int:min(1)}")]
public IActionResult Show(int page)
Enter fullscreen mode Exit fullscreen mode

Here is how we could use a regular expression.

[Route("/blog/{page:regex(^\d+$)}")]
public IActionResult Show(int page)
Enter fullscreen mode Exit fullscreen mode

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.

[Route("/blog/{page:noZeroes}")]
public IActionResult Show(int page)
Enter fullscreen mode Exit fullscreen mode

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).

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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

builder.Services.AddRouting(options =>
    options.ConstraintMap.Add("noZeroes", typeof(NoZeroesRouteConstraint)));
Enter fullscreen mode Exit fullscreen mode

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

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

.NET Core

[Route("/blog/{page:int}")]
public IActionResult Show(int page = 1)
Enter fullscreen mode Exit fullscreen mode

In both frameworks, parameters are nullable.

Symfony

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

.NET Core

[Route("/blog/{page:int?}")]
public IActionResult Show(int? page)
Enter fullscreen mode Exit fullscreen mode

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

Symfony

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

.NET Core

[Route("/blog/{page:int=1}")]
public IActionResult Show(int page)
Enter fullscreen mode Exit fullscreen mode

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:

#[Route('/blog/{slug}', name: 'blog_show')]
public function show(BlogPost $post): Response
Enter fullscreen mode Exit fullscreen mode

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:

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

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):

public function dashboard(
    #[MapQueryParameter] string $firstName,
    #[MapQueryParameter] string $lastName,
    #[MapQueryParameter] int $age,
): Response
Enter fullscreen mode Exit fullscreen mode

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

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

.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:

public void OnGet([FromHeader(Name = "Accept-Language")] string language)
Enter fullscreen mode Exit fullscreen mode

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):

public class Instructor
{
    public int Id { get; set; }

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

    // ...
}
Enter fullscreen mode Exit fullscreen mode

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:

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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

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

        return new ListIntModelBinder();
    }
}
Enter fullscreen mode Exit fullscreen mode

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

builder.Services.AddControllers(options =>
{
    options.ModelBinderProviders.Insert(0, new ListIntModelBinderProvider());
});
Enter fullscreen mode Exit fullscreen mode

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

[HttpGet("/numbers")]
public IActionResult Index([ModelBinder(BinderType = typeof(ListIntModelBinder))] List<int> ints)
Enter fullscreen mode Exit fullscreen mode

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.

public function dashboard(
    #[MapQueryString(
        validationGroups: ['strict', 'edit'],
        validationFailedStatusCode: Response::HTTP_UNPROCESSABLE_ENTITY
    )] UserDto $userDto
): Response
Enter fullscreen mode Exit fullscreen mode

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.

#[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
    {
        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

.NET Core

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

[Route("/group", Name = "my_group")]
public class GroupController
{
    [Route("path", Name = "my_path")]
    public string MyPath()
    {
        return "my_path controller";
    }
}
Enter fullscreen mode Exit fullscreen mode

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:

# 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
Enter fullscreen mode Exit fullscreen mode

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

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

.NET Core

[Route("/", Name = "mobile_homepage")]
[Host("mobile.example.com", "m.example.com")]
public string MobileHomepage()
Enter fullscreen mode Exit fullscreen mode

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:

#[Route('/', name: 'homepage', stateless: true)]
public function homepage(): Response
Enter fullscreen mode Exit fullscreen mode

.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.

[ResponseCache(Duration = 43200)]
public ActionResult GetPost()
Enter fullscreen mode Exit fullscreen mode

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, X, or Discord.

Top comments (0)