Custom Sulu Template Development
A template in Sulu consists of two parts: XML description (what the manager edits) and Twig template (how it is displayed). XML registers the page type in the system, Twig renders the content. You don't need to write a controller — Sulu uses DefaultController, or you can write a custom one to add data.
Complete XML Template
<!-- config/templates/service.xml -->
<?xml version="1.0" encoding="utf-8"?>
<template xmlns="http://schemas.sulu.io/template/template"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://schemas.sulu.io/template/template
http://schemas.sulu.io/template/template-1.0.xsd">
<key>service</key>
<view>pages/service</view>
<controller>App\Controller\Website\ServiceController::indexAction</controller>
<cacheLifetime type="seconds">1800</cacheLifetime>
<properties>
<property name="title" type="text_line" mandatory="true">
<meta><title lang="en">Title</title></meta>
<tag name="sulu.rlp.part"/>
</property>
<property name="intro" type="text_area" colspan="12">
<meta><title lang="en">Introductory text</title></meta>
<params>
<param name="rows" value="3"/>
</params>
</property>
<block name="content_blocks" default-type="text" colspan="12">
<meta><title lang="en">Content blocks</title></meta>
<types>
<type name="text">
<meta><title lang="en">Text</title></meta>
<properties>
<property name="text" type="text_editor">
<meta><title lang="en">Text</title></meta>
</property>
</properties>
</type>
<type name="image_text">
<meta><title lang="en">Image + text</title></meta>
<properties>
<property name="image" type="single_media_selection">
<meta><title lang="en">Image</title></meta>
</property>
<property name="text" type="text_editor">
<meta><title lang="en">Text</title></meta>
</property>
<property name="image_position" type="select">
<meta><title lang="en">Image position</title></meta>
<params>
<param name="values" type="collection">
<param name="left" title="Left"/>
<param name="right" title="Right"/>
</param>
</params>
</property>
</properties>
</type>
<type name="cta">
<meta><title lang="en">Call to action</title></meta>
<properties>
<property name="heading" type="text_line">
<meta><title lang="en">Heading</title></meta>
</property>
<property name="button_text" type="text_line">
<meta><title lang="en">Button text</title></meta>
</property>
<property name="button_link" type="text_line">
<meta><title lang="en">Link</title></meta>
</property>
</properties>
</type>
</types>
</block>
<section name="sidebar">
<meta><title lang="en">Sidebar</title></meta>
<properties>
<property name="show_form" type="checkbox">
<meta><title lang="en">Show contact form</title></meta>
<params>
<param name="defaultValue" value="true"/>
</params>
</property>
<property name="related_services" type="smart_content">
<meta><title lang="en">Related services</title></meta>
<params>
<param name="provider" value="pages"/>
<param name="types" value="service"/>
<param name="max_per_page" value="4"/>
</params>
</property>
</properties>
</section>
</properties>
</template>
Twig Template
{# templates/pages/service.html.twig #}
{% extends 'base.html.twig' %}
{% block title %}{{ content.title }} | {{ app.request.host }}{% endblock %}
{% block body %}
<div class="page-service">
<div class="container">
<header class="page-header">
<h1>{{ content.title }}</h1>
{% if content.intro %}
<p class="lead">{{ content.intro }}</p>
{% endif %}
</header>
<div class="service-layout {% if content.show_form %}service-layout--with-sidebar{% endif %}">
<main class="service-content">
{% for block in content.content_blocks %}
{% if block.type == 'text' %}
<div class="block block--text">
{{ block.text|raw }}
</div>
{% elseif block.type == 'image_text' %}
{% set img = sulu_resolve_media(block.image, locale) %}
<div class="block block--image-text block--image-{{ block.image_position }}">
{% if img %}
<figure>
<img
src="{{ img.thumbnails['service-block'] }}"
alt="{{ img.title }}"
loading="lazy"
>
</figure>
{% endif %}
<div class="block__text">{{ block.text|raw }}</div>
</div>
{% elseif block.type == 'cta' %}
<div class="block block--cta">
{% if block.heading %}
<h2>{{ block.heading }}</h2>
{% endif %}
<a href="{{ block.button_link }}" class="btn btn--primary">
{{ block.button_text }}
</a>
</div>
{% endif %}
{% endfor %}
</main>
{% if content.show_form %}
<aside class="service-sidebar">
{% include 'snippets/contact-form.html.twig' %}
{% if content.related_services %}
<div class="related-services">
<h3>Related services</h3>
{% for service in content.related_services %}
<a href="{{ service.url }}" class="related-link">
{{ service.title }}
</a>
{% endfor %}
</div>
{% endif %}
</aside>
{% endif %}
</div>
</div>
</div>
{% endblock %}
Custom Controller
// src/Controller/Website/ServiceController.php
namespace App\Controller\Website;
use Sulu\Bundle\WebsiteBundle\Resolver\TemplateAttributeResolverInterface;
use Sulu\Component\Content\Compat\StructureInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
class ServiceController extends AbstractController
{
public function __construct(
private readonly TemplateAttributeResolverInterface $resolver,
private readonly TestimonialRepository $testimonials
) {}
public function indexAction(
StructureInterface $structure,
bool $preview = false,
bool $partial = false
): Response {
$attributes = $this->resolver->resolve(
[
'content' => $structure->getContent(),
'testimonials' => $this->testimonials->findByService(
$structure->getUuid(),
limit: 3
),
],
$structure,
!$partial,
$preview
);
$view = $partial ? 'pages/service_partial.html.twig' : 'pages/service.html.twig';
return $this->render($view, $attributes);
}
}
Base Template and Twig Blocks
{# templates/base.html.twig #}
<!DOCTYPE html>
<html lang="{{ app.request.locale }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{{ sulu_content_path('/', webspace, locale) }}{% endblock %}</title>
{% block stylesheets %}
<link rel="stylesheet" href="{{ asset('build/app.css') }}">
{% endblock %}
</head>
<body>
{% block header %}
{% include 'snippets/header.html.twig' %}
{% endblock %}
{% block body %}{% endblock %}
{% block footer %}
{% include 'snippets/footer.html.twig' %}
{% endblock %}
{% block javascripts %}
<script src="{{ asset('build/app.js') }}" defer></script>
{% endblock %}
</body>
</html>
Sulu Twig Extension
{# navigation #}
{% set navigation = sulu_navigation_root_flat('main', 3) %}
{% for item in navigation %}
<a href="{{ item.url }}"
class="{{ item.uuid == content.uuid ? 'active' : '' }}">
{{ item.title }}
</a>
{% endfor %}
{# breadcrumbs #}
{% set breadcrumb = sulu_breadcrumb() %}
{% for crumb in breadcrumb %}
<a href="{{ crumb.url }}">{{ crumb.title }}</a>
{% endfor %}
{# snippet from snippets system #}
{{ sulu_snippet('footer_info', 'default', locale)|raw }}
{# page URL by UUID #}
<a href="{{ sulu_content_path(content.link, webspace, locale) }}">Link</a>
Template Registration in Webspace
<!-- config/packages/webspaces/example.xml -->
<templates>
<template type="page">service</template>
</templates>
After adding the template, clear the cache and reindex:
php bin/console cache:clear
php bin/console sulu:document:initialize
Timeline
One template with block editor, custom controller, and Twig: 2–3 days. Complete template set (5–8 types) for a corporate website: 1.5–2 weeks.







