How to use Twig in Laravel 10.x & Laravel 11.x

# laravel

Although the Blade templating engine that comes with Laravel is incredibly capable and simple to use, there are times when it isn't the best tool for the job. In my case, it's just personal preference. I'm working with a lot of Symfony based projects, so I work a lot with Twig. So I had to figure out how to integrate Twig into Laravel.

Differences between Blade and Twig

While Blade and Twig share many commonalities and serve similar purposes, they each have their distinct differences. Blade is heavily based on PHP syntax, so for PHP developers, the learning curve is usually less steep. Twig, however, introduces its own syntax which is more akin to other templating engines. It is also known as very flexible, fast and secure. Generally, Twig is also more widely used outside of the Laravel Ecosystem.

Installing Twig

You're going to need to install a package with composer called rcrowe/twigbridge which will allow use to seamlessly use Twig in Laravel.

composer require rcrowe/twigbridge

After the installation is complete, you'll have the option to publish the config file (config/twigbridge.php) with the following command.

php artisan vendor:publish --provider="TwigBridge\ServiceProvider"

Creating your first view

Create your first template

Create the file resources/views/welcome.twig

<!DOCTYPE html>  
<html lang="{{ app.getLocale }}">  
<head>  
<meta charset="utf-8">  
<meta name="viewport" content="width=device-width, initial-scale=1">  

<title>Laravel</title>  

<link href="https://fonts.bunny.net/css2?family=Nunito:wght@400;600;700&display=swap" rel="stylesheet">  
<style>  
/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */html{line-height:1.15;-webkit-text-size-adjust:100%}body{margin:0}a{background-color:transparent}[hidden]{display:none}html{font-family:system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;line-height:1.5}*,:after,:before{box-sizing:border-box;border:0 solid #e2e8f0}a{color:inherit;text-decoration:inherit}svg,video{display:block;vertical-align:middle}video{max-width:100%;height:auto}.bg-white{--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity))}.bg-gray-100{--tw-bg-opacity: 1;background-color:rgb(243 244 246 / var(--tw-bg-opacity))}.border-gray-200{--tw-border-opacity: 1;border-color:rgb(229 231 235 / var(--tw-border-opacity))}.border-t{border-top-width:1px}.flex{display:flex}.grid{display:grid}.hidden{display:none}.items-center{align-items:center}.justify-center{justify-content:center}.font-semibold{font-weight:600}.h-5{height:1.25rem}.h-8{height:2rem}.h-16{height:4rem}.text-sm{font-size:.875rem}.text-lg{font-size:1.125rem}.leading-7{line-height:1.75rem}.mx-auto{margin-left:auto;margin-right:auto}.ml-1{margin-left:.25rem}.mt-2{margin-top:.5rem}.mr-2{margin-right:.5rem}.ml-2{margin-left:.5rem}.mt-4{margin-top:1rem}.ml-4{margin-left:1rem}.mt-8{margin-top:2rem}.ml-12{margin-left:3rem}.-mt-px{margin-top:-1px}.max-w-6xl{max-width:72rem}.min-h-screen{min-height:100vh}.overflow-hidden{overflow:hidden}.p-6{padding:1.5rem}.py-4{padding-top:1rem;padding-bottom:1rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.pt-8{padding-top:2rem}.fixed{position:fixed}.relative{position:relative}.top-0{top:0}.right-0{right:0}.shadow{--tw-shadow: 0 1px 3px 0 rgb(0 0 0 / .1), 0 1px 2px -1px rgb(0 0 0 / .1);--tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.text-center{text-align:center}.text-gray-200{--tw-text-opacity: 1;color:rgb(229 231 235 / var(--tw-text-opacity))}.text-gray-300{--tw-text-opacity: 1;color:rgb(209 213 219 / var(--tw-text-opacity))}.text-gray-400{--tw-text-opacity: 1;color:rgb(156 163 175 / var(--tw-text-opacity))}.text-gray-500{--tw-text-opacity: 1;color:rgb(107 114 128 / var(--tw-text-opacity))}.text-gray-600{--tw-text-opacity: 1;color:rgb(75 85 99 / var(--tw-text-opacity))}.text-gray-700{--tw-text-opacity: 1;color:rgb(55 65 81 / var(--tw-text-opacity))}.text-gray-900{--tw-text-opacity: 1;color:rgb(17 24 39 / var(--tw-text-opacity))}.underline{text-decoration:underline}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.w-5{width:1.25rem}.w-8{width:2rem}.w-auto{width:auto}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}@media (min-width:640px){.sm\:rounded-lg{border-radius:.5rem}.sm\:block{display:block}.sm\:items-center{align-items:center}.sm\:justify-start{justify-content:flex-start}.sm\:justify-between{justify-content:space-between}.sm\:h-20{height:5rem}.sm\:ml-0{margin-left:0}.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}.sm\:pt-0{padding-top:0}.sm\:text-left{text-align:left}.sm\:text-right{text-align:right}}@media (min-width:768px){.md\:border-t-0{border-top-width:0}.md\:border-l{border-left-width:1px}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:1024px){.lg\:px-8{padding-left:2rem;padding-right:2rem}}@media (prefers-color-scheme:dark){.dark\:bg-gray-800{--tw-bg-opacity: 1;background-color:rgb(31 41 55 / var(--tw-bg-opacity))}.dark\:bg-gray-900{--tw-bg-opacity: 1;background-color:rgb(17 24 39 / var(--tw-bg-opacity))}.dark\:border-gray-700{--tw-border-opacity: 1;border-color:rgb(55 65 81 / var(--tw-border-opacity))}.dark\:text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity))}.dark\:text-gray-400{--tw-text-opacity: 1;color:rgb(156 163 175 / var(--tw-text-opacity))}.dark\:text-gray-500{--tw-text-opacity: 1;color:rgb(107 114 128 / var(--tw-text-opacity))}}  
</style>  
<style>  
body {  
font-family: 'Nunito', sans-serif;  
}  
</style>  
</head>  
<body class="antialiased">  
<div class="relative flex items-top justify-center min-h-screen bg-gray-100 dark:bg-gray-900 sm:items-center py-4 sm:pt-0">  
{% if Route.has('login') %}  
<div class="hidden fixed top-0 right-0 px-6 py-4 sm:block">  
{% if auth_check() %}  
<a href="{{ url('/home') }}" class="text-sm text-gray-700 underline">Home</a>  
{% else %}  
<a href="{{ route('login') }}" class="text-sm text-gray-700 underline">Login</a>  

{% if Route.has('register') %}  
<a href="{{ route('register') }}" class="ml-4 text-sm text-gray-700 underline">Register</a>  
{% endif %}  
{% endif %}  
</div>  
{% endif %}  

<div class="max-w-6xl mx-auto sm:px-6 lg:px-8">  
<div class="flex justify-center pt-8 sm:justify-start sm:pt-0">  
<svg viewBox="0 0 651 192" fill="none" xmlns="http://www.w3.org/2000/svg" class="h-16 w-auto text-gray-700 sm:h-20">  
<g clip-path="url(#clip0)" fill="#EF3B2D">  
<path d="M248.032 44.676h-16.466v100.23h47.394v-14.748h-30.928V44.676zM337.091 87.202c-2.101-3.341-5.083-5.965-8.949-7.875-3.865-1.909-7.756-2.864-11.669-2.864-5.062 0-9.69.931-13.89 2.792-4.201 1.861-7.804 4.417-10.811 7.661-3.007 3.246-5.347 6.993-7.016 11.239-1.672 4.249-2.506 8.713-2.506 13.389 0 4.774.834 9.26 2.506 13.459 1.669 4.202 4.009 7.925 7.016 11.169 3.007 3.246 6.609 5.799 10.811 7.66 4.199 1.861 8.828 2.792 13.89 2.792 3.913 0 7.804-.955 11.669-2.863 3.866-1.908 6.849-4.533 8.949-7.875v9.021h15.607V78.182h-15.607v9.02zm-1.431 32.503c-.955 2.578-2.291 4.821-4.009 6.73-1.719 1.91-3.795 3.437-6.229 4.582-2.435 1.146-5.133 1.718-8.091 1.718-2.96 0-5.633-.572-8.019-1.718-2.387-1.146-4.438-2.672-6.156-4.582-1.719-1.909-3.032-4.152-3.938-6.73-.909-2.577-1.36-5.298-1.36-8.161 0-2.864.451-5.585 1.36-8.162.905-2.577 2.219-4.819 3.938-6.729 1.718-1.908 3.77-3.437 6.156-4.582 2.386-1.146 5.059-1.718 8.019-1.718 2.958 0 5.656.572 8.091 1.718 2.434 1.146 4.51 2.674 6.229 4.582 1.718 1.91 3.054 4.152 4.009 6.729.953 2.577 1.432 5.298 1.432 8.162-.001 2.863-.479 5.584-1.432 8.161zM463.954 87.202c-2.101-3.341-5.083-5.965-8.949-7.875-3.865-1.909-7.756-2.864-11.669-2.864-5.062 0-9.69.931-13.89 2.792-4.201 1.861-7.804 4.417-10.811 7.661-3.007 3.246-5.347 6.993-7.016 11.239-1.672 4.249-2.506 8.713-2.506 13.389 0 4.774.834 9.26 2.506 13.459 1.669 4.202 4.009 7.925 7.016 11.169 3.007 3.246 6.609 5.799 10.811 7.66 4.199 1.861 8.828 2.792 13.89 2.792 3.913 0 7.804-.955 11.669-2.863 3.866-1.908 6.849-4.533 8.949-7.875v9.021h15.607V78.182h-15.607v9.02zm-1.432 32.503c-.955 2.578-2.291 4.821-4.009 6.73-1.719 1.91-3.795 3.437-6.229 4.582-2.435 1.146-5.133 1.718-8.091 1.718-2.96 0-5.633-.572-8.019-1.718-2.387-1.146-4.438-2.672-6.156-4.582-1.719-1.909-3.032-4.152-3.938-6.73-.909-2.577-1.36-5.298-1.36-8.161 0-2.864.451-5.585 1.36-8.162.905-2.577 2.219-4.819 3.938-6.729 1.718-1.908 3.77-3.437 6.156-4.582 2.386-1.146 5.059-1.718 8.019-1.718 2.958 0 5.656.572 8.091 1.718 2.434 1.146 4.51 2.674 6.229 4.582 1.718 1.91 3.054 4.152 4.009 6.729.953 2.577 1.432 5.298 1.432 8.162 0 2.863-.479 5.584-1.432 8.161zM650.772 44.676h-15.606v100.23h15.606V44.676zM365.013 144.906h15.607V93.538h26.776V78.182h-42.383v66.724zM542.133 78.182l-19.616 51.096-19.616-51.096h-15.808l25.617 66.724h19.614l25.617-66.724h-15.808zM591.98 76.466c-19.112 0-34.239 15.706-34.239 35.079 0 21.416 14.641 35.079 36.239 35.079 12.088 0 19.806-4.622 29.234-14.688l-10.544-8.158c-.006.008-7.958 10.449-19.832 10.449-13.802 0-19.612-11.127-19.612-16.884h51.777c2.72-22.043-11.772-40.877-33.023-40.877zm-18.713 29.28c.12-1.284 1.917-16.884 18.589-16.884 16.671 0 18.697 15.598 18.813 16.884h-37.402zM184.068 43.892c-.024-.088-.073-.165-.104-.25-.058-.157-.108-.316-.191-.46-.056-.097-.137-.176-.203-.265-.087-.117-.161-.242-.265-.345-.085-.086-.194-.148-.29-.223-.109-.085-.206-.182-.327-.252l-.002-.001-.002-.002-35.648-20.524a2.971 2.971 0 00-2.964 0l-35.647 20.522-.002.002-.002.001c-.121.07-.219.167-.327.252-.096.075-.205.138-.29.223-.103.103-.178.228-.265.345-.066.089-.147.169-.203.265-.083.144-.133.304-.191.46-.031.085-.08.162-.104.25-.067.249-.103.51-.103.776v38.979l-29.706 17.103V24.493a3 3 0 00-.103-.776c-.024-.088-.073-.165-.104-.25-.058-.157-.108-.316-.191-.46-.056-.097-.137-.176-.203-.265-.087-.117-.161-.242-.265-.345-.085-.086-.194-.148-.29-.223-.109-.085-.206-.182-.327-.252l-.002-.001-.002-.002L40.098 1.396a2.971 2.971 0 00-2.964 0L1.487 21.919l-.002.002-.002.001c-.121.07-.219.167-.327.252-.096.075-.205.138-.29.223-.103.103-.178.228-.265.345-.066.089-.147.169-.203.265-.083.144-.133.304-.191.46-.031.085-.08.162-.104.25-.067.249-.103.51-.103.776v122.09c0 1.063.568 2.044 1.489 2.575l71.293 41.045c.156.089.324.143.49.202.078.028.15.074.23.095a2.98 2.98 0 001.524 0c.069-.018.132-.059.2-.083.176-.061.354-.119.519-.214l71.293-41.045a2.971 2.971 0 001.489-2.575v-38.979l34.158-19.666a2.971 2.971 0 001.489-2.575V44.666a3.075 3.075 0 00-.106-.774zM74.255 143.167l-29.648-16.779 31.136-17.926.001-.001 34.164-19.669 29.674 17.084-21.772 12.428-43.555 24.863zm68.329-76.259v33.841l-12.475-7.182-17.231-9.92V49.806l12.475 7.182 17.231 9.92zm2.97-39.335l29.693 17.095-29.693 17.095-29.693-17.095 29.693-17.095zM54.06 114.089l-12.475 7.182V46.733l17.231-9.92 12.475-7.182v74.537l-17.231 9.921zM38.614 7.398l29.693 17.095-29.693 17.095L8.921 24.493 38.614 7.398zM5.938 29.632l12.475 7.182 17.231 9.92v79.676l.001.005-.001.006c0 .114.032.221.045.333.017.146.021.294.059.434l.002.007c.032.117.094.222.14.334.051.124.088.255.156.371a.036.036 0 00.004.009c.061.105.149.191.222.288.081.105.149.22.244.314l.008.01c.084.083.19.142.284.215.106.083.202.178.32.247l.013.005.011.008 34.139 19.321v34.175L5.939 144.867V29.632h-.001zm136.646 115.235l-65.352 37.625V148.31l48.399-27.628 16.953-9.677v33.862zm35.646-61.22l-29.706 17.102V66.908l17.231-9.92 12.475-7.182v33.841z"/>  
</g>  
</svg>  
</div>  

<div class="mt-8 bg-white dark:bg-gray-800 overflow-hidden shadow sm:rounded-lg" style="width: 100%">  
{{ dump(_context) }}  
</div>  

<div class="flex justify-center mt-4 sm:items-center sm:justify-end">  
<div class="ml-4 text-center text-sm text-gray-500 sm:text-right sm:ml-0" style="width: 100%">  
Laravel v{{ app.version }}  
</div>  
</div>  
</div>  
</div>  
</body>  
</html>

This file can be seen as a equivalent to the welcome.blade.php file.

If you're new to twig, you'll might wonder about the {{ dump(_context) }}. You can use that every time you want to see, what data you have available in your current view (_context). The dump() function on the other hand is only active in development mode and will display a debug output of the given vars.

(optional) Register new route

If you're using a fresh Laravel installation, the base route will already point to the welcome view, otherwise just create a route like this:

// routes/web.php

Route::get('/', function() {
    return View::make('welcome'); // uses template "resources/views/welcome.twig"
})

Digging deeper

Calling views

You call your Twig views like any other view:

view('my_twig_template', [...])

Or when you're templates are in a subdirectory:

view('my_folder.my_twig_template', [...])

Or even with views from other packages

view('pagination::simple', [...])

Extending views

The same way you would call a view, you can also inherit it:

{% extends "my_folder.my_twig_template" %}
{% extends "pagination::parent" %}

Including views

As you can extend views, you can obviously also include them:

{% include "my_folder.my_twig_template" %}
{% include "pagination::simple" %}

If that's not enough for you, you can also pass your own variables to an included view:

{% include "components.navigation" with {
    links: [
        {
            'url': '/',
            'label': 'Home'
        },
        {
            'url': url('/some-path'),
            'label': 'Some Label'
        },
    ]
} %}

Output variables

All variables you want to print, are escaped by default in Twig. You can use the raw filter to skip escaping.

{{ some_var }}
{{ html_var | raw }}

Most of the available filters and function from Blade are also available in Twig.

{# Example functions and filters available #}
{{ session_get('some_key') }}
{{ config_get('some_key') }}
{{ url('/') }}
{{ long_var | str_limit(50) }}
{{ some_var | camel_case }}

Configuration

Namespaces

You might want to use Twig's namespaces. All you need to do to use them is to add a snippet like the following to the boot() method in App\Providers\AppServiceProvider

$loader = new \Twig\Loader\FilesystemLoader();

// for each of your desired namespaces
$loader->addPath(base_path() . '/resources/views/components', 'components')

\Twig::getLoader()->addLoader($loader);

Now you can use your namespaces like this: @components/navbar.twig.

Extensions

By default, TwigBridge comes with the following extensions enabled:

  • Twig\Extension\DebugExtension
  • TwigBridge\Extension\Laravel\Auth
  • TwigBridge\Extension\Laravel\Config
  • TwigBridge\Extension\Laravel\Dump
  • TwigBridge\Extension\Laravel\Form
  • TwigBridge\Extension\Laravel\Gate
  • TwigBridge\Extension\Laravel\Html
  • TwigBridge\Extension\Laravel\Input
  • TwigBridge\Extension\Laravel\Session
  • TwigBridge\Extension\Laravel\String
  • TwigBridge\Extension\Laravel\Translator
  • TwigBridge\Extension\Laravel\Url
  • TwigBridge\Extension\Loader\Facades
  • TwigBridge\Extension\Loader\Filters
  • TwigBridge\Extension\Loader\Functions

Create your own extension

If you need to create some custom functionality that is not provided by a built-in function, you can simply add your own by creating a class and registering it.

Let's create our own function, we want to have a now() function that gets the current timestamp from Carbon (Carbon\Carbon::now()). First, create the file like app/Twig/Functions.php and add the following:

<?php

namespace App\Twig;

use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
use Carbon\Carbon;

class Functions extends AbstractExtension
{
    public function getFunctions(): array
    {
        return [
            new TwigFunction('now', [$this, 'now'])
        ];
    }

    public static function now(): string
    {
        return Carbon::now()->toString();
    }
}

Then, go to your config file (config/twigbridge.php) and add App\Twig\Functions to the enabled extensions.

return [
    'twig' => [

        // ...

        'extensions' => [
            'enabled' => [
                // ...
                'App\Twig\Functions',
            ],
        ],

        // ...
    ]
]

Now, you can use your function in your views like this:

<p>{{ now() }}</p>

Create your own filters

You can add filters to the same extension, but I personally like to keep them seperated. We're gonna create the filter count_words which counts the words in a string. Create the file app/Twig/Filters.php.

<?php

namespace App\Twig;

use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;

class Filters extends AbstractExtension
{
    public function getFilters(): array
    {
        return [
            new TwigFilter('count_words', [$this, 'count_words'])
        ];
    }

    public static function count_words(string $sentence): int
    {
        return count(explode(' ', $sentence))
    }
}

Add the new filter extension to your config (config/twigbridge.php):

return [
    'twig' => [

        // ...

        'extensions' => [
            'enabled' => [
                // ...
                'App\Twig\Functions',
                'App\Twig\Filters',
            ],
        ],

        // ...
    ]
]

And you'll be able to use your new filter.

<p>{{ 'Here we have a little sentence'|count_words }}</p>



This should pretty much cover most of the use cases you might need with Twig in Laravel to start out. I hope this post helps you save a few hours searching for answers.

Other great resource to dig even deeper: