DEV Community

Aleksander Wons
Aleksander Wons

Posted on

Symfony 7 vs. .NET Core 8 - Controllers

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, that we can define a controller by attaching an attribute to it, like in the following example:

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

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.

class LuckyController extends AbstractController
{
}
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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:

app.MapGet("/hello", () => TypedResults.Ok(new Message() { Text = "Hello World!" }));
Enter fullscreen mode Exit fullscreen mode

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.

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
public function index(): RedirectResponse
{
    return $this->redirectToRoute('homepage', [], Response::HTTP_MOVED_PERMANENTLY);
}
Enter fullscreen mode Exit fullscreen mode
  1. Redirect to a specific URL
public function index(): RedirectResponse
{
    return $this->redirect('http://symfony.com/doc');
}
Enter fullscreen mode Exit fullscreen mode

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.

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

Rendering templates

Symfony

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

return $this->render('lucky/number.html.twig', ['number' => $number]);
Enter fullscreen mode Exit fullscreen mode

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

ViewData["number"] = number;

return View();
Enter fullscreen mode Exit fullscreen mode

Injecting services directly into an action

Symfony

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

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

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.

[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);

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

Generating controllers

Symfony

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

php bin/console make:controller BrandNewController

created: src/Controller/BrandNewController.php
created: templates/brandnew/index.html.twig
Enter fullscreen mode Exit fullscreen mode

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

dotnet new mvccontroller -n BrandNewController -o Controllers --namespace App.Controllers
Enter fullscreen mode Exit fullscreen mode
dotnet new view -n Index -o Views/BrandNew
Enter fullscreen mode Exit fullscreen mode

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:

throw $this->createNotFoundException('The product does not exist');
Enter fullscreen mode Exit fullscreen mode

or explicitly throw an exception:

throw new Symfony\Component\HttpKernel\Exception\NotFoundHttpException('The product does not exist');
Enter fullscreen mode Exit fullscreen mode

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:

return BadRequest();
Enter fullscreen mode Exit fullscreen mode

or

return Conflict()
Enter fullscreen mode Exit fullscreen mode

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:

public class TestErrorNoInheritanceController
{
    [Route("/test-error/no-inherit")]
    public IResult Index()
    {
        return TypedResults.NotFound();
    }
}
Enter fullscreen mode Exit fullscreen mode

Accessing the current request

Symfony

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

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

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

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

public IActionResult Index()
{
    var body = HttpContext.Request.Body;
    return Ok(body.ToString());
}
Enter fullscreen mode Exit fullscreen mode

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:

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

Mapping can be combined with validation/filtering:

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

We can even map to DTOs like this:

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

        #[Assert\NotBlank]
        public string $lastName,

        #[Assert\GreaterThan(18)]
        public int $age,
    ) {
    }
}
Enter fullscreen mode Exit fullscreen mode
public function dashboard(
    #[MapQueryString] UserDto $userDto
): Response
{
    // ...
}
Enter fullscreen mode Exit fullscreen mode

.NET Core

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

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

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

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

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

public record UserDto(
    [StringLength(100, MinimumLength = 10)] string firstName,
    string lastName,
    int age
);
Enter fullscreen mode Exit fullscreen mode
public IActionResult IndexModel([FromQuery] UserDto userDto)
{
    if (!ModelState.IsValid)
    {
        return ValidationProblem(ModelState);
    }

    return Json(userDto);
}
Enter fullscreen mode Exit fullscreen mode

Session

Symfony

The current session can be accessed from the current request.

$request->getSession()
Enter fullscreen mode Exit fullscreen mode

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

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

return $this->redirectToRoute(/* ... */);
Enter fullscreen mode Exit fullscreen mode

.NET Core

In .NET Core session needs to be first enabled:

// Program.cs

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

// ...

app.UseSession();
Enter fullscreen mode Exit fullscreen mode

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.

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

    public IActionResult Index()
    {
        Message = "My flash message";
        return RedirectToRoute("my_controller");
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

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

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

@page
@model IndexModel

@{
    if (TempData["Message"] != null)
    {
        <h3>Message: @TempData["Message"]</h3>
    }
}
Enter fullscreen mode Exit fullscreen mode

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:

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

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

public function index(): Response
{
    $adminEmail = $this->getParameter('app.admin_email');
    // ...
}
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

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.

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

.NET Core

We can do the same thing in .NET Core.

Returning a JSON response:

public class JsonController : Controller
{
    [Route("/json")]
    public IActionResult Index()
    {
        return Json(new { key = 1, key_2 = "value" });
    }
}
Enter fullscreen mode Exit fullscreen mode

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

public IActionResult Index()
{
    FileStream fileHandler = System.IO.File.OpenRead("appsettings.json");
    return File(fileHandler, "application/octet-stream");
}
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)