Symfony 7 vs. .NET Core 8 - Controllers
Tags: symfony, dotnetcore, controllers
Published: 2024-07-02 17:37:06

## 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 features are only available in .NET Core, they may never get to this post (unless relevant to the comparison).

Most of the concepts mentioned in this post will be discussed in more detail later. This should be treated as a comparison of the "Quick Start Guides."

## Intro

Bot frameworks have a concept of controllers. A controller is a group of one or more actions put together, usually in the form of a class and methods. While in both frameworks, we can point routes to things different than controllers (like inlined endpoints in .NET or callbacks in Symfony), we will focus here on the controllers only.

## Controllers

### Symfony

We've already seen in [Symfony 7 vs. .NET Core 8 - Web application; the basics](https://dev.to/awons/symfony-7-vs-net-core-8-web-application-the-basics-2ip8), that we can define a controller by attaching an attribute to it, like in the following example:

```php
class LuckyController
{
    #[Route('/lucky/number/{max}', name: 'app_lucky_number')]
    public function number(int $max): Response
    {
        $number = random_int(0, $max);

        return new Response(
            '<html><body>Lucky number: '.$number.'</body></html>'
        );
    }
}
```

The basic idea is to map a route (URL) using a specific method. This method returns a response object that is sent to the browser.

This is the most trivial example, but extending our controller with an `AbstractController` class gives us access to a few handy helper methods, which we will discuss later.

```php
class LuckyController extends AbstractController
{
}
```

#### .NET Core

.NET Core is no different here. We can either have a method in a class that will serve as a target of a route or extend from a base class that will provide us with some additional helpers, similar to Symfony.

```c#
public class LuckyController
{
    [Route("/lucky/number/{max}", Name = "app_lucky_number")]
    public IResult Number(int max)
    {
        var random = new Random();
        var number = random.Next(max);

        var response = $"Lucky number: {numhttps://learn.microsoft.com/en-us/aspnet/core/razor-pages/?view=aspnetcore-8.0&tabs=visual-studiober}";

        return TypedResults.Ok(response);
    }
}
```

We inherit from a base controller to access those helper methods.

```c#
public class LuckyExtendedController : Controller
{
    [Route("/lucky/number/{max}", Name = "app_lucky_number")]
    public IActionResult Number(int max)
    {
        var random = new Random();
        var number = random.Next(max);

        var html = $"<html><body>Lucky number: {number}</body></html>";

        return new ContentResult
        {
            Content = html,
            ContentType = "text/html"
        };
    }
}
```

On the surface, it looks very similar. But an astute reader probably noticed that the example not extending the base class returns an object implementing the `Microsoft.AspNetCore.Http.IResult`, and the one extending the base class returns an object implementing the `Microsoft.AspNetCore.Mvc.IActionResult`.

.NET Core distinguishes between two different approaches to building web applications:the

* MVC-base extensively uses classes from the `Microsoft.AspNetCore.Mvc` namespace.
* Minimal APIs that use responses from the `Microsoft.AspNetCore.Http` namespace.

We can still mix and match, as in the former example, but we can also do things like this:

```c#
app.MapGet("/hello", () => TypedResults.Ok(new Message() { Text = "Hello World!" }));
```

The above example will return a `200` JSON response.

Building such minimal APIs is also possible in Symfony, but the difference is that Symfony does not distinguish between minimal APIs and MVC. Behind the scenes, the same `Symfony\Component\HttpFoundation\Respone` object is always used.

Another difference is that in .NET Core, we can integrate with OpenAPI out of the box (it is part of the framework), while in Symfony, an API-based application with OpenAPI features is only available using a third-party tool—the [API Platform](https://api-platform.com/).

We will review building API-based applications later in this series.

### Redirecting

#### Symfony

We have two ways to redirect in Symfony:

1. Redirect to a route

```php
public function index(): RedirectResponse
{
    return $this->redirectToRoute('homepage', [], Response::HTTP_MOVED_PERMANENTLY);
}
```

2. Redirect to a specific URL

```php
public function index(): RedirectResponse
{
    return $this->redirect('http://symfony.com/doc');
}
```

We return a `RedirectResponse` containing either a hardcoded URL or a URL generated from the existing routing configuration.

#### .NET Core

The biggest difference is that in .NET Core, we achieve the same things using more methods than in Symfony. For example, the permanent redirect is an argument in Symfony and a separate method in .NET Core. But apart from that, we can do the same things easily.

```c#
[Route("/redirect-to-route")]
public IActionResult MyRedirectToRoute()
{
    return RedirectToRoute("app_lucky_number", new { max = 123 });
}

[Route("/redirect-to-route-permanent")]
public IActionResult MyRedirectToRoutePermanent()
{
    return RedirectToRoutePermanent("app_lucky_number", new { max = 123 });
}
```

```c#
[Route("/redirect-to-url")]
public IActionResult MyRedirect()
{
    return Redirect("https://example.com");
}

[Route("/redirect-to-url-permanent")]
public IActionResult MyRedirect()
{
    return RedirectPermanent("https://example.com");
}
```

### Rendering templates

#### Symfony

Symfony uses Twig as a templating engine, and the `AbstractController` can help render a template:

 ```php
return $this->render('lucky/number.html.twig', ['number' => $number]);
```

We pass a path to a template and variables that will be used there.

PS. We will get into more details regarding templating in one of the following posts.

#### .NET Core

It is not that different in .NET Core. While Symfony uses a third-party templating engine, Twig, .NET has [Razor](https://learn.microsoft.com/en-us/aspnet/core/razor-pages/?view=aspnetcore-8.0&tabs=visual-studio). The rendering looks very similar, with one difference: due to naming conventions, we don't need to specify the template we want to render (unless we want to break out of convention).

```c#
ViewData["number"] = number;

return View();
```

### Injecting services directly into an action

#### Symfony

While I wouldn't consider this a good practice, we can definitely do something like this:

```php
#[Route('/lucky/number/{max}')]
public function number(int $max, LoggerInterface $logger): Response
{
    $logger->info('We are logging!');
    // ...
}
```

Symfony will automatically inject a configured service into our action.

#### .NET Core

This is also possible here. The only difference is that we have to explicitly state we want to inject an argument from DI.

```c#
[Route("/lucky/number/{max}")]
public IActionResult NumberV3(int max, [FromServices] ILogger<LuckyExtendedController> logger)
{
    var random = new Random();
    var number = random.Next(max);

    logger.LogInformation("My lucky number is {number}", number);

    // ...
}
```

### Generating controllers

#### Symfony

We can generate a controller with a corresponding template using the command line.

```bash
php bin/console make:controller BrandNewController

created: src/Controller/BrandNewController.php
created: templates/brandnew/index.html.twig
```

#### .NET Core

We can do the same in .NET Core, with a small caveat: we need to execute two commands, one to generate the controller and one to generate the view (so the template).

```bash
dotnet new mvccontroller -n BrandNewController -o Controllers --namespace App.Controllers
```

```bash
dotnet new view -n Index -o Views/BrandNew
```


### Handling errors

In the context of an MVC application, errors that reach the browser will be represented with status codes corresponding to a specific error type.

#### Symfony

If we haven't found something and want to give back a 404 status code, we can use a helper method:

```php
throw $this->createNotFoundException('The product does not exist');
```

or explicitly throw an exception:

```php
throw new Symfony\Component\HttpKernel\Exception\NotFoundHttpException('The product does not exist');
```

There are a few similar exception classes that will take care of returning the proper status code with the response.

Anything not inherited from the base `HttpException` will result in status code 500.

By default, Symfony will not display technical details if we are in production mode. In dev mode, we will get many details that can be useful for debugging.

#### .NET Core

.NET Core is different in this regard.

We can still use helper functions to return responses with specific status code like:

```c#
return BadRequest();
```

or

```c#
return Conflict()
```

But throwing an exception always leads to 500. We have to write custom code to convert exceptions into errors with specific status codes.

This means if we are not extending from the base `Controller` class, we need a different solution. We can use the `TypedResults` class to return a response we want:

```c#
public class TestErrorNoInheritanceController
{
    [Route("/test-error/no-inherit")]
    public IResult Index()
    {
        return TypedResults.NotFound();
    }
}
```

### Accessing the current request

#### Symfony

In Symfony, this is as simple as defining the method argument as in the following example:

```php
public function index(Request $request): Response
{
    $page = $request->query->get('page', 1);

    // ...
}
```

#### .NET Core

Depending on whether we inherit from the base class or not, the implementation will differ.

If we inherit from the base controller, we have access to the `HttpContext` object:

```c#
public IActionResult Index()
{
    var body = HttpContext.Request.Body;
    return Ok(body.ToString());
}
```

### Mapping of a request

We have already talked about it `here`, but we will now compare some more Symfony examples.

#### Symfony

We can map query string parameters:

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

Mapping can be combined with validation/filtering:

```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
{
    // ...
}
```

We can even map to DTOs like this:

```php
class UserDto
{
    public function __construct(
        #[Assert\NotBlank]
        public string $firstName,

        #[Assert\NotBlank]
        public string $lastName,

        #[Assert\GreaterThan(18)]
        public int $age,
    ) {
    }
}
```

```php
public function dashboard(
    #[MapQueryString] UserDto $userDto
): Response
{
    // ...
}
```

#### .NET Core

We can also map from query string parameters, which is very similar to how it's done in Symfony.

```c#
public IActionResult Index([FromQuery] string firstName, [FromQuery] string lastName, [FromQuery] int age)
{
    return new ContentResult
    {
        ContentType = "text/html",
        Content = $"firstName: {firstName}; lastName: {lastName}; age: {age}"
    };
}
```

Validation is also possible, but as I've already mentioned, we need check the state of the model ourselves and act accordingly.

```c#
public IActionResult IndexValidation(
    [FromQuery][StringLength(100, MinimumLength = 10, ErrorMessage = "First name must be between 10 and 100 characters")] string firstName,
    [FromQuery][RegularExpression(@"^[a-zA-Z''-'\s]{1,40}$", ErrorMessage = "Some characters are not allowed.")] string lastName,
    [FromQuery] int age)
{
    if (!ModelState.IsValid)
    {
        return ValidationProblem(ModelState);
    }

    return new ContentResult
    {
        ContentType = "text/html",
        Content = $"firstName: {firstName}; lastName: {lastName}; age: {age}"
    };
}
```

Last but not least, we can map an entire object; again, it is similar to how we would do it in Symfony.

```c#
public record UserDto(
    [StringLength(100, MinimumLength = 10)] string firstName,
    string lastName,
    int age
);
```

```c#
public IActionResult IndexModel([FromQuery] UserDto userDto)
{
    if (!ModelState.IsValid)
    {
        return ValidationProblem(ModelState);
    }

    return Json(userDto);
}
```

### Session

#### Symfony

The current session can be accessed from the current request.

```php
$request->getSession()
```

Symfony has an interesting concept of "flash messages." We can set a message in the current request, which will be available in the next one (and only the next one; it will be automatically deleted afterward).

```php
$this->addFlash(
    'notice',
    'Your changes were saved!'
);
// $this->addFlash() is equivalent to $request->getSession()->getFlashBag()->add()

return $this->redirectToRoute(/* ... */);
```

#### .NET Core

In .NET Core session needs to be first enabled:

```c#
// Program.cs

builder.Services.AddSession(options =>
{
    options.Cookie.Name = "MyCookieName";
    options.IdleTimeout = TimeSpan.FromSeconds(10);
    options.Cookie.IsEssential = true;
});

// ...

app.UseSession();
```

We also have a similar feature to flash messages in Symfony. It is called `TempData`. The end result is the same, but the usage is different.

We must define and tag a variable as a `[TempData]`. Afterward, this data will remain in session until it is read.

```c#
public class HomeController : Controller
{
    [TempData]
    public string? Message { get; set; }

    public IActionResult Index()
    {
        Message = "My flash message";
        return RedirectToRoute("my_controller");
    }
}
```

TempData can be passed in a cookie or stored in a session. It can be later retrieved either in a template or a controller. The easiest way to use it is to inherit from the base controller, where we get direct access to TempData.

```c#
public class MyController : Controller
{
    [Route("/my-controller", Name = "my_controller")]
    public IActionResult Index()
    {
        string message = TempData["Message"]?.ToString() ?? "";
        return new ContentResult
        {
            ContentType = "text/html",
            Content = message
        };
    }
}
```

We can also get the value directly in a template like this:

```c#
@page
@model IndexModel

@{
    if (TempData["Message"] != null)
    {
        <h3>Message: @TempData["Message"]</h3>
    }
}
```

The biggest differences in .NET are:


* A message is not always automatically removed. It must be read, and the return status code must be 200.
* We can use the `Peek` method to get the value, which will not mark the message for deletion.
* We can use the `Keep` method to keep the message anyway (after reading it).

### Accessing configuration values

#### Symfony

Symfony has a concept of `parameters`. Those parameters can come from different sources and be used to configure services. Parameters are normally defined in a YAML configuration file like this:

```yaml
# config/services.yaml
parameters:
    # the parameter name is an arbitrary string (the 'app.' prefix is recommended
    # to better differentiate your parameters from Symfony parameters).
    app.admin_email: 'something@example.com'
```

Those parameters can be accessed from within a controller like this:

```php
public function index(): Response
{
    $adminEmail = $this->getParameter('app.admin_email');
    // ...
}
```

#### .NET Core

In .NET Core, we can also configure our application using configuration values. These values can come from different sources (using providers) and even be hot-reloaded. Like in Symfony, we can access those values within our controller, but it is a bit more complicated. We need to inject the configuration service/object into the controller to access any values. This may seem strange, but normally, we configure our services based on the values (we will get to that later) and do not use config values directly.

```c#
public class MyController : Controller
{
    private readonly IConfiguration _configuration;

    public MyController(IConfiguration configuration)
    {
        _configuration = configuration;
    }

    [Route("/my-controller")]
    public IActionResult Index()
    {
        var allowedHosts = _configuration.GetValue<string>("AllowedHosts");
        // ...
    }
}
```

### Returning responses

#### Symfony

We can return different types of responses using helper functions.

This could be a JSON response (Symfony will try to serialize whatever we pass as an argument).

```php
public function index(): JsonResponse
{
    // returns '{"username":"jane.doe"}' and sets the proper Content-Type header
    return $this->json(['username' => 'jane.doe']);

    // the shortcut defines three optional arguments
    // return $this->json($data, $status = 200, $headers = [], $context = []);
}
```

We can also stream a response if, for example, we want to return a file.

```php
public function download(): BinaryFileResponse
{
    // send the file contents and force the browser to download it
    return $this->file('/path/to/some_file.pdf');
}
```

We can also return an early response (status code 103). However, this is only available when we use SAPI like FrankenPHP. It will not work with the regular PHP SAPI.

```php
public function index(): Response
{
    $response = $this->sendEarlyHints([
        new Link(rel: 'preconnect', href: 'https://fonts.google.com'),
        (new Link(href: '/style.css'))->withAttribute('as', 'stylesheet'),
        (new Link(href: '/script.js'))->withAttribute('as', 'script'),
    ]);

    // prepare the contents of the response...

    return $this->render('homepage/index.html.twig', response: $response);
}
```

#### .NET Core

We can do the same thing in .NET Core.

Returning a JSON response:

```c#
public class JsonController : Controller
{
    [Route("/json")]
    public IActionResult Index()
    {
        return Json(new { key = 1, key_2 = "value" });
    }
}
```

In both frameworks, we can control the configuration of the JSON serializer (with the `context` array parameter in Symfony and the second argument to the `Json` method: `JsonSerializerSettings` instance).

The biggest difference is that controlling the status code is not as straightforward as in Symfony.
We can definitely do it by coding something specific, but this is not available out of the box.

I personally don't see this as a big deal. At the end of the day, why would we want to return a status other than 200 when we return a JSON-formatted response?

Returning a file stream is similar to Symfony, though .NET operates directly on streams (as an alternative, we can use a byte array, but it won't be as efficient as using a stream).

```c#
public IActionResult Index()
{
    FileStream fileHandler = System.IO.File.OpenRead("appsettings.json");
    return File(fileHandler, "application/octet-stream");
}
```

Regarding early hints, I tried to figure out how to do it, but unfortunately, I gave up. It might be possible, but definitely not out of the box (such as by using a simple helper method as in Symfony).

## What's next?

In the next post, I will compare Twig to Razor (it will be high-level only; details would require a few posts, I guess).

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