DEV Community

Aleksander Wons
Aleksander Wons

Posted on

Symfony 7 vs. .NET Core 8 - Templating

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

Templating in Symfony and .NET Core

Creating templates

Symfony

When we build web applications (not APIs), we must render the content of our pages. Symfony uses a third-party templating engine Twig for that purpose.

Twig is a real templating engine. This means it's a "language" in itself, not a mix of PHP and HTML. It is very simple yet easy to learn and, despite its simplicity, very powerful. A template will be rendered into PHP and cached for reuse, so no real performance penalty exists. Here is an example of how a template might look like:

<!DOCTYPE html>
<html>
    <head>
        <title>Welcome to Symfony!</title>
    </head>
    <body>
        <h1>{{ page_title }}</h1>

        {% if user.isLoggedIn %}
            Hello {{ user.name }}!
        {% endif %}

        {# ... #}
    </body>
</html>
Enter fullscreen mode Exit fullscreen mode

It has three primary constructs that build the engine:

  • {{ ... }}, used to display the content of a variable or the result of evaluating an expression;
  • {% ... %}, used to run some logic, such as a conditional or a loop;
  • {# ... #}, used to add comments to the template (unlike HTML comments, these comments are not included in the rendered page).

We cannot write and call PHP directly from a template. What we can do, though, is to use built-in functions and filters or crate custom functions that we can call from within a template (so-called Extensions).

It might not be obvious at first, but even though we can't call PHP directly, we can still call methods on objects (variables) passed into a template like this:

{% if user.isLoggedIn %}
    Hello {{ user.name }}!
{% endif %}
Enter fullscreen mode Exit fullscreen mode

Here, Twig will determine if we want to access a property, call a method on an object, or access a key in an array. It does some reverse engineering to figure out what this user.name actually means in PHP.

Everything we output in a template is escaped by default, so we should not have any security concerns. However, it is possible to turn auto-escaping off for parts of a template or all templates altogether. On top of that, we can render raw data using the raw filter:

<h1>{{ product.title|raw }}</h1>
Enter fullscreen mode Exit fullscreen mode

.NET Core

.NET Core renders content using Razor. Here, we will discuss a specific implementation of that engine, the ASP.NET Core Razor. Razor Engin has many implementations that vary in the number of features and environments it runs in. Still, because we discussed the .NET Core, we will only concentrate on that version.

A template is defined in .cshtml file. Here is an example of a simple template. It looks very similar to the Twig template:

@model App.ViewModels.BrandNew
@{
    Layout = null;
}
<!DOCTYPE html>
<html>
    <head>
        <title>Welcome to .NET Core!</title>
    </head>
    <body>
        <h1>@Model.Title</h1>

        @if (Model.User.IsLoggedIn)
        {
            <p>Hello @Model.User.Name</p>
        }
    </body>
</html>
Enter fullscreen mode Exit fullscreen mode

The first difference from Twig is that a Razor template can contain C# code. There is no need to define special extensions.

Another difference is the use of the "model." A model is a DTO that contains a representation of the data structures used in a template. While not strictly necessary, it is good practice to define what goes into your model and make sure the types are correct.

We also explicitly set the layout to null. This is necessary because, in an ASP.NET Core MVC application, the Razor templating engine assumes there is a layout and will, by convention, look for it and render it. We will later see how layouts are used in terms of inheritance and building more complex page structures.

Everything in a Razor template is either HTML or C#. The transition from HTML to C# is marked using the @ symbol. We can also go back into an HTML mode by opening an HTML tag like in the example above <p>Hello @Model.User.Name</p>. There are way more details related to the syntax, but @ is the core element of it.

There is no magic happening when accessing variables. This is like a plain C# code. In the Model.Title, Model is an object, and the Title is a string property. You might have noticed that the "Hello..." sentence is wrapped in a p tag. It is because otherwise, everything within parenthesis would be treated as C# code. We could write there something like this:

@if (true)
{
    string lastname = "Smith";
    <p>@lastname</p>
}
Enter fullscreen mode Exit fullscreen mode

The p tag escapes the context of C# and enters the context of HTML.

As in Twig, everything we render is automatically escaped but can be rendered raw if necessary.

<h1>@Html.Raw(Product.Title)</h1>
Enter fullscreen mode Exit fullscreen mode

The difference is that we cannot say we want to disable escaping (HTML encoding) in a Razor template. The only way to do it is to use the @Html.Raw() method.

Linking to pages

Symfony

We can link to routes using the path extension:

<a href="{{ path('blog_index') }}">Homepage</a>

{# ... #}

{% for post in blog_posts %}
    <h1>
        <a href="{{ path('blog_post', {slug: post.slug}) }}">{{ post.title }}</a>
    </h1>

    <p>{{ post.excerpt }}</p>
{% endfor %}
Enter fullscreen mode Exit fullscreen mode

When we need an absolute URL (with protocol/schema, domain name, and port), we can use the url() function, which takes the same arguments as path().

<a href="{{ url('blog_post', {slug: post.slug}) }}">{{ post.title }}</a>
Enter fullscreen mode Exit fullscreen mode

And also link to assets relative to root using the assets function or absolute URLs using absolute_url:

{# the image lives at "public/images/logo.png" #}
<img src="{{ asset('images/logo.png') }}" alt="Symfony!"/>

{# the CSS file lives at "public/css/blog.css" #}
<link href="{{ asset('css/blog.css') }}" rel="stylesheet"/>

{# the JS file lives at "public/bundles/acme/js/loader.js" #}
<script src="{{ asset('bundles/acme/js/loader.js') }}"></script>

<img src="{{ absolute_url(asset('images/logo.png')) }}" alt="Symfony!"/>

<link rel="shortcut icon" href="{{ absolute_url('favicon.png') }}">
Enter fullscreen mode Exit fullscreen mode

.NET Core

As in Twig, we can generate links to routes or controllers. We can generate a whole a tag using a helper function. Linking to routes looks like this:

@Html.RouteLink("Homepage", "blog_index")

@foreach (var Post in Model.BlogPosts)
{
    <h1>@Html.RouteLink(Post.Title, "blog_post", new { slug = Post.Slug }, new { rel = "alternate"})</h1>
    <p>@Post.Excerpt</p>
}
Enter fullscreen mode Exit fullscreen mode

Linking to controllers is very similar:

<p>@Html.ActionLink("Homepage", "Index", "Home")</p>
Enter fullscreen mode Exit fullscreen mode

We can also render only the href part using the Url helper instead of Html.

<a href="@Url.Action("Index", "Home")">Homepage</a>
<a href="@Url.RouteUrl("homepage"))">Homepage</a>
Enter fullscreen mode Exit fullscreen mode

Another way is to use tag helpers like this:

<a asp-controller="Home" asp-action="Index">Homepage</a>
<a asp-route="homepage">Homepage</a>
Enter fullscreen mode Exit fullscreen mode

All of the above links are relative to the root.

If we want to generate an absolute URL (with protocol/schema, domain, and port), we need to use the Url helper and provide the protocol (the Html helper only deals with relative URLs).

<a href="@Url.Action("Index","Home", new {}, Context.Request.Scheme)">Homepage</a>
<a href="@Url.RouteUrl("homepage", new {}, Context.Request.Scheme)">Homepage</a>
Enter fullscreen mode Exit fullscreen mode

Working with assets is a bit mire straightforward. By default assets will be placed under the wwwroot folder under the root of our project. Let's say we have the following file: /home/my_project/wwwroot/images/my_image.jpg. Here is how we will reference that file:

<img src="~/images/my_image.jpg" />
Enter fullscreen mode Exit fullscreen mode

The same works for any assets under the wwwroot folder.

Global variables

Symfony

There is one global variable (an AppValriable) accessible in every template, which can give us info about the current request, user, or environment:

<p>Username: {{ app.user.username ?? 'Anonymous user' }}</p>
{% if app.debug %}
    <p>Request method: {{ app.request.method }}</p>
    <p>Application Environment: {{ app.environment }}</p>
{% endif %}
Enter fullscreen mode Exit fullscreen mode

However, we can also define and access global variables within a template. This is achieved using Symfony's configuration files:

# config/packages/twig.yaml
twig:
    globals:
        my_variable: 'My Value'
Enter fullscreen mode Exit fullscreen mode

.NET Core

We have access to way more variables than in Twig. For example, we can access the whole request context object by using @Context. Or we can use a JSON helper to render JSON like this: @Json.Serialize(new {key = 1, keyTwo = "value"}). The current user can be accessed like this: @User. We can also get the current template path like this: @Path.Normalize(). It will output something like /Views/Home/Index.cshtml.

All in all, we have access to way more data than Twig does.

Defining custom variables is different. There is no such thing in Razor. But we need to remember that we can have a regular C# code there. This means we can define a static class with static properties and then access them from a template.

namespace App.Constants;

public static class MyConstants
{
    public const string Title = "This is a title";
}
Enter fullscreen mode Exit fullscreen mode
<p>@App.Constants.MyConstants.Title</p>
Enter fullscreen mode Exit fullscreen mode

From what I can tell, there is no one way to do this. Variables can be defined in an HTTP Application context, and we could inject a service that will return the values we need.

Yet another way is to use the appsettings.json file to define some values and then use them in a template.

{
  "MyKey": "MyValue"
}
Enter fullscreen mode Exit fullscreen mode
@using Microsoft.Extensions.Configuration
@inject IConfiguration Configuration
<h1>Value from config:@Configuration["MyKey"]</h1>
Enter fullscreen mode Exit fullscreen mode

Rendering templates

Basics

Symfony

We need to render the template. The simplest and most common way is to call a helper method render from a controller that extends from the AbstractController. We have a few options here:

  • We return a response object with the rendered content like this:
return $this->render('product/index.html.twig', [
    'category' => '...',
    'promotions' => ['...', '...'],
]);
Enter fullscreen mode Exit fullscreen mode
  • We render the content into a variable like this:
$contents = $this->renderView('product/index.html.twig', [
    'category' => '...',
    'promotions' => ['...', '...'],
]);
Enter fullscreen mode Exit fullscreen mode

The above methods render an entire template. However, Twig can also take a whole template and render only part of it. This is done using template blocks.

// index.html.twig
{% block head %}
    <link rel="stylesheet" href="style.css"/>
    <title>{% title %}{% endblock %} - My Webpage</title>
{% endblock %}

{% block footer %}
    &copy; Copyright 2011 by <a href="https://example.com/">you</a>.
{% endblock %}
Enter fullscreen mode Exit fullscreen mode

We can then render just one block out of this template.

return $this->renderBlock('index.html.twig', 'head', ['title' => 'My Title']);

// or like this
$content = $this->renderBlockView('index.html.twig', 'head', ['title' => 'My Title']);

Enter fullscreen mode Exit fullscreen mode

We can actually render from anywhere (including a controller that does not extend from the AbstractController) by injecting the Twig service:


use Twig\Environment;

class MyController
{
    public function __construct(private readonly Environment $twig){}

    public function index()
    {
        return $this->twig->render('product/index.html.twig');
    }

    public function blog()
    {
        $content = return $this->twig->renderView('product/index.html.twig');
    }
}
Enter fullscreen mode Exit fullscreen mode

If we are inside a controller, a nice addon is the [Template] attribute, which we can put on a method in a controller to indicate what template we want to render and then return an array of variables to go into the template:

#[Template('product/index.html.twig')]
public function index(): array
{
    return [
        'category' => '...',
        'promotions' => ['...', '...'],
    ];
}
Enter fullscreen mode Exit fullscreen mode

.NET Core

Twig provided us with two helper functions: one that returns a response object with the rendered template and another that returns the rendered template as a string. The engine can also be injected into any service and render templates into a string. This is a cool feature. We can use it to render and cache parts of the website or render emails.

This is the first place I see a big difference compared to .NET. While still possible, it is not as straightforward as in Symfony.

First, there is only one helper function: View(). It will return a response with a rendered template. While this is completely sufficient in a typical web application, it is insufficient for rendering email content or templates to cache them. This is because the ASP.NET Core implementation of the Razor engine is tightly coupled to something called ControllerContext. We can still get this working from within a controller.

static string RenderViewToString(ControllerContext context, 
  string viewPath, object model = null, bool partial = false) {
    // first find the ViewEngine for this view
    ViewEngineResult viewEngineResult = null;
    if (partial)
        viewEngineResult = ViewEngines.Engines.FindPartialView(context, viewPath);
    else
        viewEngineResult = ViewEngines.Engines.FindView(context, viewPath, null);

    if (viewEngineResult == null)
        throw new FileNotFoundException("View cannot be found.");

    // get the view and attach the model to view data
    var view = viewEngineResult.View;
    context.Controller.ViewData.Model = model;

    string result = null;

    using (var sw = new StringWriter())
    {
        var ctx = new ViewContext(context, view, context.Controller.ViewData, context.Controller.TempData, sw);
        view.Render(ctx, sw);
        result = sw.ToString();
    }

    return result;
}
Enter fullscreen mode Exit fullscreen mode

But it requires more gymnastics if we want a service that can render a template. If you want to read more, have a look at the article from Rick Strahl: Rendering ASP.NET MVC Razor Views to String or from Scott Sauber: Walkthrough: Creating an HTML Email Template with Razor and Razor Class Libraries.

Long story short: it's doable but not as straightforward as in Symfony.

Rendering templates from a route

Symfony has an interesting feature: We can rent a static template from within a route configuration. The following example from Symfony's documentation illustrates the usage:

# config/routes.yaml
acme_privacy:
    path:          /privacy
    controller:    Symfony\Bundle\FrameworkBundle\Controller\TemplateController
    defaults:
        # the path of the template to render
        template:  'static/privacy.html.twig'

        # the response status code (default: 200)
        statusCode: 200

        # special options defined by Symfony to set the page cache
        maxAge:    86400
        sharedAge: 86400

        # whether or not caching should apply for client caches only
        private: true

        # optionally you can define some arguments passed to the template
        context:
            site_name: 'ACME'
            theme: 'dark'
Enter fullscreen mode Exit fullscreen mode

That's another feature that I didn't find in .NET Core. It's certainly not that big of a deal, but sometimes all you need is just some configuration and a path to a template. Unfortunately, it won't work here (feel free to correct me if I'm wrong).

Checking templates for errors

Symfony

Because a Twig template will be compiled into PHP on the fly (and cached), we need a way to tell if it does not contain any syntax errors and can compile. Symfony has a console command for that.

php bin/console lint:twig
Enter fullscreen mode Exit fullscreen mode

The above command will check all templates within our project. However, we can also check templates within a folder or a single template:

php bin/console lint:twig templates/email/
php bin/console lint:twig templates/article/recent_list.html.twig
Enter fullscreen mode Exit fullscreen mode

.NET Core

There is no such thing in .NET, and it wouldn't even make sense. A Razor template is compiled at build time by default. This compilation "converts" it into a C# class. This means that if it compiles, it is syntactically correct. There is no need for an additional tool here.

Reusing templates

Symfony

The simplest way to reuse a template is to include it in another template using the {{ include() }} function. An included template will see the same variables as the parent template (although this can be disabled per include). Here is a simple example of such an include:

# blog/_user_profile.html.twig
<div class="user-profile">
    <img src="{{ user.profileImageUrl }}" alt="{{ user.fullName }}"/>
    <p>{{ user.fullName }} - {{ user.email }}</p>
</div>
Enter fullscreen mode Exit fullscreen mode
# blog/index.html.twig

{{ include('blog/_user_profile.html.twig') }}
Enter fullscreen mode Exit fullscreen mode

We can also pass variables explicitly into the included template, for example, to rename a variable:

{{ include('blog/_user_profile.html.twig', {user: blog_post.author}) }}
Enter fullscreen mode Exit fullscreen mode

This method of reuse has a drawback. The parent must already provide all the variables that the included template needs. So, the pattern always needs to know what will be included (also when we have a deep nested inclusion).

Another option is to render a controller. This allows us to encapsulate some logic (or database interactions) in one place and reuse it as a template fragment.

There are two ways to do it. The first one is to use reference a controller using a route:

{{ render(path('latest_articles', {max: 3})) }}
{{ render(url('latest_articles', {max: 3})) }}
Enter fullscreen mode Exit fullscreen mode

In the above example, the latest_articles is a route name, and {max: 3} is a list of arguments for that route.

Another option is to reference a controller directly:

{{ render(controller(
    'App\\Controller\\BlogController::recentArticles', {max: 3}
)) }}
Enter fullscreen mode Exit fullscreen mode

Frontend helpers

There is a way to render controllers asynchronously using dedicated JavaScript helper functions. Those helpers allow us to render a placeholder, and then Javascript will call the specified controller/route to load additional content asynchronously.

{{ render_hinclude(controller('...')) }}
{{ render_hinclude(url('...')) }}
Enter fullscreen mode Exit fullscreen mode

.NET Core

To include one template in another (in Twig {{ include() }}), we can use the @Html.Partial() method. The consequences of using it are the same as in Symfony with Twig - we already need all the required data for the partial template.

# index.cshtml

<h2>Title</h2>
@Html.Partial("_inner")
Enter fullscreen mode Exit fullscreen mode
# _inner.cshtml

<h3>Inner</h3>
Enter fullscreen mode Exit fullscreen mode

We can also pass arguments into such partial template:

# index.cshtml

<h2>Title</h2>
@Html.Partial("_inner", new { Key = "Value" })
Enter fullscreen mode Exit fullscreen mode
# _inner.cshtml

<h3>_inner: @Model.Key</h3>
Enter fullscreen mode Exit fullscreen mode

In contrast to Symfony, in .NET Core we do not render controllers within controllers. What we would do is to render a View Component. To do that we need a class that extends from ViewComponent, has a suffix ViewComponent, or an attribute [ViewComponent].

The component class would look like this (there is also a way to implement an async InvokeAsync method):

using Microsoft.AspNetCore.Mvc;

namespace App.ViewComponents;

public class MyComponentViewComponent : ViewComponent
{
    public IViewComponentResult Invoke(int maxValue)
    {
        return View(new { ComponentKey = "ComponentValue", maxValue });
    }
}
Enter fullscreen mode Exit fullscreen mode

A template for that view component:

# Views/Shared/Components/MyComponent/Default.cshtml

<h3>My component</h3>
<p>Max value: @Model.maxValue</p>
Enter fullscreen mode Exit fullscreen mode

And this is how we can call it:

@await Component.InvokeAsync("MyComponent", new {maxValue = 100})
Enter fullscreen mode Exit fullscreen mode

Frontend helpers

There are many frontend helpers in Razor, but the one I mentioned before is not there. At least, I couldn't find it in the official documentation or other online resources. Please let me know if I missed something :)

Inheritance and layout

Symfony

When building web pages, we often need to structure our templates. Our templates will have components like headers, footers, navigation, sidebars, and so on. On top of that, we can have content and other page elements that we want to keep dynamic based on the page we are on. A typical page would look as follows.

At the highest level, we will have a base template that defines some elements inherited by all pages.

{# templates/base.html.twig #}
<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <title>{% block title %}My Application{% endblock %}</title>
        {% block stylesheets %}
            <link rel="stylesheet" type="text/css" href="/css/base.css"/>
        {% endblock %}
    </head>
    <body>
        {% block body %}
            <div id="sidebar">
                {% block sidebar %}
                    <ul>
                        <li><a href="{{ path('homepage') }}">Home</a></li>
                        <li><a href="{{ path('blog_index') }}">Blog</a></li>
                    </ul>
                {% endblock %}
            </div>

            <div id="content">
                {% block content %}{% endblock %}
            </div>
        {% endblock %}
    </body>
</html>
Enter fullscreen mode Exit fullscreen mode

The block element is the element that the child template can overwrite.

Our page default layout could look like this:

{# templates/blog/layout.html.twig #}
{% extends 'base.html.twig' %}

{% block content %}
    <h1>Blog</h1>

    {% block page_contents %}{% endblock %}
{% endblock %}
Enter fullscreen mode Exit fullscreen mode

The extends element tells us we inherit the base template and will replace the content element. Everything else stays the same.

The last level is the page itself. There, we extend from the blog/layout.html.twig template and overwrite title block (which was defined in templates/base.html.twig) and page_contents block (which was defined in templates/blog/layout.html.twig).

{# templates/blog/index.html.twig #}
{% extends 'blog/layout.html.twig' %}

{% block title %}Blog Index{% endblock %}

{% block page_contents %}
    {% for article in articles %}
        <h2>{{ article.title }}</h2>
        <p>{{ article.body }}</p>
    {% endfor %}
{% endblock %}
Enter fullscreen mode Exit fullscreen mode

.NET

By convention, a layout is defined under /Views/Shared/_Layout.cshtml. The underscore at the beginning of the name ensures this file will not be treated as a regular template. By default, every view will use this default layout. A minimalistic layout could look like this:

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="utf-8" />
        <title>My page</title>
    </head>
    <body>
        @RenderBody()
    </body>
</html>
Enter fullscreen mode Exit fullscreen mode

The key element here is the call to the RenderBody() method. This makes this template a "layout" (though we will see later that this is not mandatory). The call to this method will render our view. Layouts can also be nested, as I will show later.

A view can use the default layout or explicitly set any other layout by using the following construct:

@{
    Layout = "_AnotherLayout"
}
Enter fullscreen mode Exit fullscreen mode

The above is the most simple example possible. But in a real-world scenario, we will have multiple page elements that we want to render dynamically. This can be achieved by using sections.

A section is similar to a block in Twig but has a few properties that make it stand out. First, a section can be mandatory, meaning a child template must define it. A section can also have a default content and, therefore won't need to be defined. The most significant difference is that sections cannot be nested as in Twig. This will force us sometimes to design things differently than we could in Twig.

The following is my try to mimic the example from Twig. At the top, we have our main page layout:

// Vies/Shared/_MainLayout.cshtml
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>@await RenderSectionAsync("title", required: true)</title>
    @if (IsSectionDefined("stylesheets"))
    {
        @await RenderSectionAsync("stylesheets")        
    }
    else
    {
        <link rel="stylesheet" type="text/css" href="/css/base.css"/>
    }
</head>
<body>
    <div id="sidebar">
        @if (IsSectionDefined("sidebar"))
        {
            @await RenderSectionAsync("sidebar")        
        }
        else
        {
            <ul>
                <li>
                    <a asp-controller="Home" asp-action="Index">Home</a>
                </li>
                <li>
                    <a asp-controller="Blog" asp-action="Index">Blog</a>
                </li>
            </ul>        
        }
    </div>
    <div id="content">
        @await RenderSectionAsync("content", required: true)
    </div>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

We may notice that a Razor template is much more verbose than a Twig template. If we want a section's default content but allow it to be overwritten, we need to wrap it with an if/else statement explicitly. You may have noticed no call to the RenderContent() method. We will operate solely with sections here.

The next level is a layout for our blog page. This layout will look like this:

Views/Blog/_BaseLayout.cshtml
@{
    Layout = "_MainLayout";
}
@section title {
    @await RenderSectionAsync("title")
}
@section content {
    <h1>Blog</h1>
    @await RenderSectionAsync("page_contents")
}
Enter fullscreen mode Exit fullscreen mode

First, we extend from the parent layout using Layout = "_MainLayout";. The next part (@section title) might not initially seem evident. The problem is that if a section is rendered in a parent template, we must define it in a direct child. Even if it will do nothing else but again require a section from yet another child. This is not the most convenient way of doing things (and not needed in Twig), but it is not something that is in any way problematic.

At the lowest level, we have our page template:

Views/Blog/Index.cshtml
@{
    Layout = "_BaseLayout";
}
@section title{Blog Index}

@section page_contents {
    <h2>Article title</h2>
    <p>Article body</p>
}
Enter fullscreen mode Exit fullscreen mode

What's next?

In the next post, I will talk about configuration and environment variables.

Thanks for your time!
I'm looking forward to your comments. You can also find me on LinkedIn, X, or Discord.

Top comments (0)