Symfony 7 vs. .NET Core 8 - Routing; part 1
Tags: symfony, dotnetcore, routing
Published: 2024-05-18 13:02:35

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

## Where do we define routes?

### Controllers

#### Symfony

In Symfony, routes are defined using attributes or in a configuration file. By default, the file needs to be a YAML file. But we can always change that and use an XML file or define it in a plain PHP file.
When working with attributes, the framework needs to be configured appropriately to use that feature:

```yaml
# config/routes/attributes.yaml
controllers:
    resource:
        path: ../../src/Controller/
        namespace: App\Controller
    type: attribute
```

The above configuration tells Symfony to search for attributes inside a specific path and namespace.

It is considered the best practice to define routing close to the controller. This will look something like this:

```php
// src/Controller/BlogController.php
namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

class BlogController extends AbstractController
{
    #[Route('/blog')]
    public function list(): Response
    {
        // ...
    }
}
```

#### .NET Core

On the surface, the .NET Core way of defining a route is similar. We need to put an attribute on a controller:

```c#
// Controllers/BlogController.cs
using Microsoft.AspNetCore.Mvc;

namespace App.Controllers;

public class BlogController
{
    [Route("/blog")]
    public string List()
    {
        // ...
    }
}
```

#### Differences

In Symfony, routing can be defined in several places, including configuration files like YAML. This is not an option in .NET, where everything is in code.

In Symfony, we need to tell the framework where to find controllers. Only then can we use an attribute.
In .NET Core, we can put the controller anywhere we want. The only important thing is that it has an attribute.

### Micro-framework approach

#### Symfony

In Symfony, we can define controllers using a so-called "micro-framework" approach. In that case, routing is defined directly in the kernel. To use that feature, we first need to tell the framework to get routes from the kernel:

```yaml
# config/routes/attributes.yaml
kernel:
    resource: App\Kernel
    type: attribute
```

Then, we can write something like this (a lot of code removed for brevity).

```php
// index.php
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
use Symfony\Component\Routing\Attribute\Route;

require __DIR__.'/vendor/autoload.php';

class Kernel extends BaseKernel
{
    use MicroKernelTrait;

    public function registerBundles(): array
    {
        return [
            new Symfony\Bundle\FrameworkBundle\FrameworkBundle(),
        ];
    }

    protected function configureContainer(ContainerConfigurator $container): void
    {
        // PHP equivalent of config/packages/framework.yaml
        $container->extension('framework', [
            'secret' => 'S0ME_SECRET'
        ]);
    }

    #[Route('/random/{limit}')]
    public function randomNumber(int $limit): JsonResponse
    {
        return new JsonResponse([
            'number' => random_int(0, $limit),
        ]);
    }
}

$kernel = new Kernel('dev', true);
$request = Request::createFromGlobals();
$response = $kernel->handle($request);
$response->send();
$kernel->terminate($request, $response);
```

The whole application is literally one file. There is more to how the microframework approach works, but the general idea of how routing is done should be clear from this example.

#### .NET Core

Is there anything similar in .NET Core?

Yes, but the internals are different from how this is implemented in Symfony.

In .NET, every request is passed through a chain of middleware. Two concepts need to be mentioned: Routing and Endpoints. Routing middleware maps a request into an Endpoint (which can be a delegate (closure)).

Those middleware are implicitly registered, so we don't have to do anything to use them. We can easily add a simple route on the application itself, just like this:

```c#
// Program.cs
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/random/{limit}", (int limit) =>
{
    int randomNumber = new Random().Next(limit);

    Dictionary<string, int> respose = new Dictionary<string, int>
    {
        { "number", randomNumber }
    };

    return Results.Json(respose);
});

app.Run();
```

As we can see, we don't use attributes to define the routing. We called a method, which defines the request method, and then passed a delegate (callback) that will get executed when the path matches.
Of course, this is oversimplified. The devil lies in the details, but at least on the surface, the end result is comparable. And in .NET, the hidden part is very big and complex but allows for much more than Symfony.

## Matching rules

### The basics

The routes we defined will always be evaluated in a specific order. Let's examine how both frameworks differ in that regard.

#### Symfony

The ordering is very simple. Routes are evaluated in the same order as they were defined. The first match stops the evaluation. But we can change the priority of a route.

* Routes with the higher priority are evaluated first
* Routes within one priority level are evaluated in the order they are defined
* The first matched rule stops the evaluation

To change the priority of a rule, all we have to do is to tweak the `Route` attribute:

```php
#[Route('/blog/list', priority: 2)]
public function list(): Response
{
    // ...
}
```

#### .NET Core

Ordering works differently in .NET Core. First, all possible matches are selected, and then, the order is decided based on:

* the route endpoint `Order` parameter
* the route template precedence

In the end, there can only be one possible match. If there is more than one, we will get an exception.

The order parameter is straightforward. The only difference is we can define an additional order on an HTTP verb level:

```c#
[Route("/blog", Order = 2)]
[HttpGet(Order = 1)]
public IActionResult List()
{
    // ...
}
```

Another difference is the "route template precedence." Let's take this path `/Product/List` and those two routes as an example:

* `/Product/{slug}`
* `/Product/List`

In Symfony, the final result is obvious. Because the first route is defined first, it automatically becomes a match.

In .NET Core, the second route would match. Why? A route with literal segments has precedence over routes with parameter segments. There are a few more rules ([which you can read about here](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/routing?view=aspnetcore-8.0#rtp)), but it should already be clear that the logic is more complicated.

### Matching HTTP methods

#### Symfony

We already know how to match a simple path. But what about HTTP methods (GET, POST)? This is how it works in Symfony:

```php
// src/Controller/BlogApiController.php
namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

class BlogApiController extends AbstractController
{
    #[Route('/api/posts/{id}', methods: ['GET', 'HEAD'])]
    public function show(int $id): Response
    {
        // ... return a JSON response with the post
    }

    #[Route('/api/posts/{id}', methods: ['PUT'])]
    public function edit(int $id): Response
    {
        // ... edit a post
    }
}
```

We pass an array of allowed methods in the attribute. Straightforward.

#### .NET Core

It looks and feels similar to what we know from Symfony.

```c#
using Microsoft.AspNetCore.Mvc;

namespace App.Controllers;

public class BlogApiController
{
    [Route("/api/posts/{id}")]
    [HttpGet, HttpHead]
    public IActionResult Show(int id)
    {
        // ...
    }

    [Route("/api/posts/{id}")]
    [HttpPost]
    public IActionResult Edit(int id)
    {
        // ...
    }
}
```

The biggest difference is that in .NET Core, the HTTP verb is configured using a separate attribute.

### Matching expressions

#### Symfony

To introduce much more complex matching rules, we can use the `condition` parameter of the `Route` attribute. The value we put there is an "expression language syntax." It is a simple language that will be compiled into PHP. The nice thing is we can reference a service that can execute any arbitrary logic:

```php
use Symfony\Bundle\FrameworkBundle\Routing\Attribute\AsRoutingConditionService;
use Symfony\Component\HttpFoundation\Request;

#[AsRoutingConditionService(alias: 'route_checker')]
class RouteChecker
{
    public function check(Request $request): bool
    {
        // ...
    }
}
```
```php
// Controller (using an alias):
#[Route(condition: "service('route_checker').check(request)")]
// Or without alias:
#[Route(condition: "service('App\\\Service\\\RouteChecker').check(request)")]
```

Conditions can access the current request, the request context (and therefore the route), and the request parameters:

```php
#[Route(
    '/contact',
    name: 'contact',
    condition: "context.getMethod() in ['GET', 'HEAD'] and request.headers.get('User-Agent') matches '/firefox/i'",
    // expressions can also include config parameters:
    // condition: "request.headers.get('User-Agent') matches '%app.allowed_browsers%'"
)]

#[Route(
    '/posts/{id}',
    name: 'post_show',
    // expressions can retrieve route parameter values using the "params" variable
    condition: "params['id'] < 1000"
)]
```

#### .NET Core

First and foremost, we need to distinguish between two different types of defining routes:

* Attribute-based routing
* Conventional routing

We've seen attribute-based routing when we defined rules using attributes. Conventional routing is when we define routing outside of the endpoint itself - like in this example:

```c#
// Program.cs
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapControllerRoute(
    name: "home",
    pattern: "/",
    defaults: new { controller = "Home", action = "Index" });

app.Run();
```

##### Attribute-based routing

This approach is closer to what Symfony does. So, can we achieve something similar to Symfony? Yes, but it works differently. No special parameter on an existing attribute would allow us to do it, but we can easily define our own attribute and attach it to our controller.

```c#
// Actions/Constraints/MyConstraintAttribute.cs
using Microsoft.AspNetCore.Mvc.ActionConstraints;

namespace Actions.Constraints;

public class MyConstraintAttribute : Attribute, IActionConstraint
{
    public int Order => 1;

    public bool Accept(ActionConstraintContext context)
    {
        return context.RouteContext.HttpContext.Request.Headers.UserAgent.FirstOrDefault<string>("").StartsWith("Mozilla/5.0");
    }
}
```

```c#
// Controllers/ConstrainedController.cs
using Microsoft.AspNetCore.Mvc;
using Actions.Constraints;

namespace Controllers;

public class ConstrainedController
{
    [Route("/my-constraint")]
    [MyConstraint]
    public IActionResult Index()
    {
        return new JsonResult(new object[] { });
    }
}
```

The [ActionConstraintContext](https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.mvc.actionconstraints.actionconstraintcontext?view=aspnetcore-8.0) object gives us access to the [HttpContext](https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.http.httpcontext?view=aspnetcore-8.0) that has a lot of information that allows us to do the same thing as in Symfony, though in C# code.

##### Conventional routing

The same thing would look differently when used with conventional routing:

```c#
// Program.cs
using Routing.Constraints;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllersWithViews();

var app = builder.Build();

app.UseRouting();

app.MapControllerRoute(
    name: "my-constrained-route",
    pattern: "/my-constraint",
    defaults: new { controller = "Constrained", action = "Index" },
    constraints: new { myConstraint = new MyConstraint() });

app.Run();

```

```c#
// Routing/Constraints/MyConstraint.cs
namespace Routing.Constraints;

public class MyConstraint : IRouteConstraint
{
    public bool Match(HttpContext? httpContext, IRouter? route, string routeKey, RouteValueDictionary values, RouteDirection routeDirection)
    {
        if (httpContext == null)
        {
            return true;
        }

        return httpContext.Request.Headers.UserAgent.FirstOrDefault<string>("").StartsWith("Mozilla/5.0");
    }
}
```

The end result is the same, but how we get there is totally different. You may wonder what's with the `httpContext` being `null` or the `RouteDirection`. We will get to it later.

#### Matching expressions - summary

As we can see, there is nothing we couldn't do in .NET Core that we can do in Symfony. Some details are different, but overall, it is easy to map from one framework to another.

What I noticed, though, is that it can get more complex when trying to put all the pieces together in .NET. Not because it is solved better in Symfony, but because the whole routing (and also matching) in .NET is way more powerful and allows us to solve our use cases in various ways.

## Debugging routes

This comparison is going to short and simple.

### Symfony

Symfony has a built-in console command that allows us to see all routes:

```bash
php bin/console debug:router
```

Or to debug one route:

```bash
php bin/console debug:router app_lucky_number
```

### .NET Core

There is no counterpart here.

It's not that we cannot write something similar to what is available in Symfony (or that it is difficult). It's just that nothing is available out of the box.

And it makes sense.

C# is a compiled language. When we build our application, it becomes a DLL or an executable. In PHP, it is easy to have multiple entry points to one application (one for HTTP requests and one for CLI commands). We can still access all the details when our application runs (like by defining a special endpoint/controller that will give us all the details), but a CLI command would be more difficult.

As you can already imagine, this is not just one example of a feature available in Symfony (over CLI) that is not available at all or works completely differently in .NET Core.

## What's next?

We will continue with routing in the next post. This is a complex and fundamental topic that requires some time to go through.

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