Sonata Extra Bundle provides a blog module integrated into Sonata Page, with customizable routes and rendering adapted for multilingual sites.
Integration of main roads
Update of the main blog routes
Integrate the pages of your site with the blog controller by executing the update-core-route order:
php bin/console sonata:page:update-core-routes --site={id}
Executing this command synchronizes the Sonata page with the following hybrid routes:
- Blog category:
/blog-category/{category} - Blog label:
/blog-tag/{tag} - Blog article:
/blog-article/{article}
These routes correspond to dynamically generated pages that are linked to their respective controllers.
Customization of routes
Customize the default route templates to locate or modify the structure of your URL while preserving the link with the controller by changing the page details:
- An event listener on routing.loader is added automatically.
- A new route is created with your custom model from the custom_url field.

Refer to the following template to generate URL paths in Twig:
{{ path('sonata_extra_blog_category', {'category': category.getSlug()}) }}
{{ path('sonata_extra_blog_tag', {'tag': tag.getSlug()}) }}
{{ path('sonata_extra_blog_article', {'article': article.getSlug()}) }}
Templates and rendering
Connect your blog content production to your model's configuration using the Blog page type. This type of page service is explained in detail in the Documentation of SonataPageBundle.
Available Twig variables
The Blog page the service provides the following variables to your model for seamless integration:

content: The HTML content of your blog.page: TheSonataPagePageentity instance.site: TheSonataPageSiteentity instance.
Display the content of your blog in your Twig template as follows:
{{ content|raw }}
For the page article, the category, and the tags, the service will also provide SEO settings:
ogTitleogDescriptionogImage: The path of the image will already be calculated. Make sure you have a named image formatog_imageotherwisereferencewill be used. Since social networks have a maximum width/height, it is not recommended to use thereferenceformat.seoTitleseoKeywordseoDescription
In your model, you can use it like this
{% block sonata_seo_title %}
{% apply spaceless %}
<title>{{ seoTitle|default('Titre par défaut') }}</title>
{% endapply %}
{% endblock %}
{% block sonata_seo_metadatas %}
{% apply spaceless %}
{% if seoDescription is defined %}
<meta name="description" content="{{ seoDescription }}">
{% endif %}
{% if seoKeyword is defined %}
<meta name="keywords" content="{{ seoKeyword }}">
{% endif %}
{% endapply %}
{% endblock %}
{% block app_og_metadatas %}
{% apply spaceless %}
{% if ogTitle is defined %}
<meta property="og:title" content="{{ ogTitle }}">
{% endif %}
{% if ogDescription is defined %}
<meta property="og:description" content="{{ ogDescription }}">
{% endif %}
{% if ogImage is defined %}
<meta property="og:image" content="{{ absolute_url(ogImage) }}">
{% endif %}
<meta property="og:type" content="article">
{% endapply %}
{% endblock %}
and in your base model
{% block sonata_seo_title %}
{{ sonata_seo_title() }}
{% endblock %}
{% block sonata_seo_metadatas %}
{{ sonata_seo_metadatas() }}
{% endblock %}
{% block app_og_metadatas %}
{{ app_og_metadatas() }}
{% endblock %}
The Category and Tag entity should have reference fields
- App\Entity\SonataClassificationCategory.php :
<?php
namespace App\Entity;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Sonata\ClassificationBundle\Entity\BaseCategory;
use Partitech\SonataExtra\Traits\EntityTranslationTrait;
use Partitech\SonataExtra\Contract\MediaInterface;
#[ORM\Entity]
#[ORM\Table(name: 'classification__category')]
class SonataClassificationCategory extends BaseCategory
{
use EntityTranslationTrait;
#[ORM\Id]
#[ORM\Column(type: Types::INTEGER)]
#[ORM\GeneratedValue]
protected ?int $id = null;
public function getId(): ?int
{
return $this->id;
}
#[ORM\ManyToOne(targetEntity: MediaInterface::class, cascade: ["persist"])]
#[ORM\JoinColumn(name: "featured_image__media_id", referencedColumnName: "id", nullable: true, onDelete: "SET NULL")]
private $featured_image = null;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $seo_title = null;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $seo_keywords = null;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $seo_description = null;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $seo_og_title = null;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $seo_og_description = null;
#[ORM\ManyToOne(targetEntity: MediaInterface::class, cascade: ["persist"])]
#[ORM\JoinColumn(name: "seo_og_image__media_id", referencedColumnName: "id", nullable: true, onDelete: "SET NULL")]
private $seo_og_image = null;
public function getFeaturedImage()
{
return $this->featured_image;
}
public function setFeaturedImage($featured_image): self
{
$this->featured_image = $featured_image;
return $this;
}
public function getSeoTitle(): ?string {
return $this->seo_title;
}
public function setSeoTitle(?string $seo_title): self {
$this->seo_title = $seo_title;
return $this;
}
public function getSeoKeywords(): ?string {
return $this->seo_keywords;
}
public function setSeoKeywords(?string $seo_keywords): self {
$this->seo_keywords = $seo_keywords;
return $this;
}
public function getSeoDescription(): ?string {
return $this->seo_description;
}
public function setSeoDescription(?string $seo_description): self {
$this->seo_description = $seo_description;
return $this;
}
public function getSeoOgTitle(): ?string {
return $this->seo_og_title;
}
public function setSeoOgTitle(?string $seo_og_title): self {
$this->seo_og_title = $seo_og_title;
return $this;
}
public function getSeoOgDescription(): ?string {
return $this->seo_og_description;
}
public function setSeoOgDescription(?string $seo_og_description): self {
$this->seo_og_description = $seo_og_description;
return $this;
}
public function getSeoOgImage() {
return $this->seo_og_image;
}
public function setSeoOgImage($seo_og_image): self {
$this->seo_og_image = $seo_og_image;
return $this;
}
}
- App\Entyty\SonataClassificationTag :
<?php
declare(strict_types=1);
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Doctrine\DBAL\Types\Types;
use App\Repository\SonataClassificationTagRepository;
use Sonata\ClassificationBundle\Entity\BaseTag;
use Partitech\SonataExtra\Traits\EntityTranslationTrait;
use Partitech\SonataExtra\Contract\MediaInterface;
#[ORM\Entity(repositoryClass: SonataClassificationTagRepository::class)]
#[ORM\Table(name: 'classification__tag')]
#[ORM\HasLifecycleCallbacks]
class SonataClassificationTag extends BaseTag
{
use EntityTranslationTrait;
#[ORM\Id]
#[ORM\Column(type: Types::INTEGER)]
#[ORM\GeneratedValue]
protected ?int $id = null;
public function getId(): ?int
{
return $this->id;
}
#[ORM\ManyToOne(targetEntity: MediaInterface::class, cascade: ["persist"])]
#[ORM\JoinColumn(name: "featured_image__media_id", referencedColumnName: "id", nullable: true, onDelete: "SET NULL")]
private $featured_image = null;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $seo_title = null;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $seo_keywords = null;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $seo_description = null;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $seo_og_title = null;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $seo_og_description = null;
#[ORM\ManyToOne(targetEntity: MediaInterface::class, cascade: ["persist"])]
#[ORM\JoinColumn(name: "seo_og_image__media_id", referencedColumnName: "id", nullable: true, onDelete: "SET NULL")]
private $seo_og_image = null;
public function getFeaturedImage()
{
return $this->featured_image;
}
public function setFeaturedImage($featured_image): self
{
$this->featured_image = $featured_image;
return $this;
}
public function getSeoTitle(): ?string {
return $this->seo_title;
}
public function setSeoTitle(?string $seo_title): self {
$this->seo_title = $seo_title;
return $this;
}
public function getSeoKeywords(): ?string {
return $this->seo_keywords;
}
public function setSeoKeywords(?string $seo_keywords): self {
$this->seo_keywords = $seo_keywords;
return $this;
}
public function getSeoDescription(): ?string {
return $this->seo_description;
}
public function setSeoDescription(?string $seo_description): self {
$this->seo_description = $seo_description;
return $this;
}
public function getSeoOgTitle(): ?string {
return $this->seo_og_title;
}
public function setSeoOgTitle(?string $seo_og_title): self {
$this->seo_og_title = $seo_og_title;
return $this;
}
public function getSeoOgDescription(): ?string {
return $this->seo_og_description;
}
public function setSeoOgDescription(?string $seo_og_description): self {
$this->seo_og_description = $seo_og_description;
return $this;
}
public function getSeoOgImage() {
return $this->seo_og_image;
}
public function setSeoOgImage($seo_og_image): self {
$this->seo_og_image = $seo_og_image;
return $this;
}
}
Adapt the example to the installed version
The Symfony and Sonata examples remain tied to the versions indicated in the article. Symfony 4.x and Symfony 6.2 are no longer maintained: for a current project, use a maintained branch and the documentation that exactly matches your dependencies.
php bin/console about
composer show symfony/framework-bundle sonata-project/admin-bundle
Before resuming the code, check the signatures, services, routes, and templates concerned. Then add a functional test covering permissions, HTTP code, and the visible result.
Reference: maintained Symfony versions.