Adding a custom data collector in the Symfony debug bar

Published on 2020-07-23 • Modified on 2020-07-23

In this post, we will see how to add a custom data collector in the Symfony debug bar. The debug bar, also called profiler, is one of the most useful components as it is of great help when developing. We will see a concrete case to help us improving the SEO of a website by displaying meta-information about the current page. Let's go! 😎

» Published in "A week of Symfony 708" (20-26 July 2020).

Prerequisite

I will assume you have a basic knowledge of Symfony and that you know how to create an application and access it with the debug environment enabled.

Configuration

Nothing particular here, but to have the profiler, you must have the symfony/profiler-pack development composer dependency installed.

  • PHP 8.3
  • Symfony 6.4.15
  • "symfony/profiler-pack": "*" (composer)

Introduction

SEO (Search Engine Optimization) is crucial nowadays as it determines the amount of visitor you get coming from search motors like Google. And being on top of the first page for given keywords is something that can make a business successful or not. I have recently been audited by the SEO experts Pragm, and they gave me a bunch of pieces of advice. One of them is to watch the content and length of the title (the <title> HTML tag) and description of the page (the <meta> HTML tag having the "description" value for the name attribute).

Goal

The goal will be to get the content of the title and description to check they meet some requirements:

  • The title must be between 30 and 65 characters.
  • The description must be between 120 and 155 characters.

Of course, both this information should be present. A page without title nor description is the worse case. So the new panel we are going to build will tell us the content of the title and description and their corresponding length.

The layout

First, let's see how the title and the description are rendered in the layout:

    <title>{% block title %}{{ 'head_meta_title'|trans({'%app_name%': app_name})|seo_title }}{% endblock title %}</title>
    <meta name="description" content="{% block description %}{{ 'head_meta_description'|trans({'%app_name%': app_name})|seo_description|raw }}{% endblock %}" />

As you can see, in both case, we define a block, then we get a translation and eventually; we apply a Twig filter. We'll see these filters later. So, for each page, we override the block and use a different translation context to have the correct texts for the current page. Using blocks is convenient as it allows to use them in other places for other meta information, like here for the Open Graph and Twitter meta tags:

    <meta property="og:title" content="{{ block('title') }}" />
    <meta property="og:description" content="{{ block('description') }}" />
    <meta name="twitter:title" content="{{ block('title') }}" />
    <meta name="twitter:description" content="{{ block('description') }}" />

The data collector

The first step is to create a custom data collector. If using auto-wiring, nothing to do, it will automatically be detected by Symfony. Some explanations after the code:

<?php

declare(strict_types=1);

// src/DataCollector/SeoCollector.php

namespace App\DataCollector;

use App\Twig\Extension\SeoExtension;
use Symfony\Component\DomCrawler\Crawler;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\DataCollector\DataCollector;

use function Symfony\Component\String\u;

final class SeoCollector extends DataCollector
{
    private const MAX_PANEL_WIDTH = 50;
    private const CLASS_ERROR = 'red';
    private const CLASS_WARNING = 'yellow';
    private const CLASS_OK = 'green';

    public function collect(Request $request, Response $response, ?\Throwable $exception = null): void
    {
        $crawler = new Crawler((string) $response->getContent());

        // Title ———————————————————————————————————————————————————————————————
        $titleTag = $crawler->filter('title');
        if ($titleTag->count() > 0) {
            $titleStr = u($titleTag->text());
            $titleSize = $titleStr->length();
            $titleInfo = [
                'value' => $titleStr->wordwrap(self::MAX_PANEL_WIDTH)->toString(),
                'size' => (string) $titleSize,
                'status' => $this->getTitleClass($titleSize),
            ];
            $this->data['title'] = $titleInfo;
        }

        // Description —————————————————————————————————————————————————————————
        $meta = $crawler->filterXPath('//meta[@name="description"]');
        if ($meta->count() > 0) {
            $descriptionStr = u((string) $meta->attr('content'));
            $descriptionLength = $descriptionStr->length();
            $descriptionInfo = [
                'value' => $descriptionStr->wordwrap(self::MAX_PANEL_WIDTH)->toString(),
                'size' => (string) $descriptionLength,
                'status' => $this->getDescriptionClass($descriptionLength),
            ];
            $this->data['description'] = $descriptionInfo;
        }
    }

    private function getTitleClass(int $size): string
    {
        if ($size === 0) {
            return self::CLASS_ERROR;
        }

        return $size >= SeoExtension::MIN_TITLE_LENGTH && $size <= SeoExtension::MAX_TITLE_LENGTH ? self::CLASS_OK : self::CLASS_WARNING;
    }

    private function getDescriptionClass(int $size): string
    {
        if ($size === 0) {
            return self::CLASS_ERROR;
        }

        return $size >= SeoExtension::MIN_DESCRITION_LENGTH && $size <= SeoExtension::MAX_DESCRITION_LENGTH ? self::CLASS_OK : self::CLASS_WARNING;
    }

    /**
     * @return array<string,string>
     */
    public function getTitle(): array
    {
        return $this->data['title'] ?? [];
    }

    /**
     * @return array<string,string>
     */
    public function getDescription(): array
    {
        return $this->data['description'] ?? [];
    }

    public function reset(): void
    {
        $this->data = [];
    }

    public function getName(): string
    {
        return self::class;
    }
}

The primary function of our new collector is the collect() one. It is responsible for populating new data. We do so by adding keys/values to the data attribute.

In our case, we populate two new keys: title and description. For each, we have three information:

  • value: The current value of the string.
  • size: Its length.
  • status: The length status, we have three cases; OK, warning or error.

For both information, we apply the same process. First, we get the string to analyse from the response content with the help of the Symfony crawler. If it exists, we compute the information we want to show in the debug panel; otherwise, the key stays empty. As you can see, I use the new string component for all string manipulations; it's more convenient than the standard PHP string functions.

OK, now that we have gathered the new data, let's display this information in the debug bar.

The debug panel

The debug panel is a Twig template that extends the WebProfiler/Profiler/layout.html.twig Symfony one. Here it is:

{% extends '@WebProfiler/Profiler/layout.html.twig' %}

{# templates/data_collector/seo_collector.html.twig #}

{% block toolbar %}
    {% set icon %}
        <span class="sf-toolbar-value"><i class="fad fa-vector-square"></i> SEO</span>
    {% endset %}

    {% set text %}
        <div class="sf-toolbar-info-piece">
            <b>Title length ({{ constant('App\\Twig\\Extension\\SeoExtension::MIN_TITLE_LENGTH') }} > {{ constant('App\\Twig\\Extension\\SeoExtension::MAX_TITLE_LENGTH') }})</b>
            {% set status = collector.title['status'] is defined ? collector.title['status'] : 'red' %}
            <span class="sf-toolbar-status sf-toolbar-status-{{ status }}">
                {% if collector.title is not empty %}
                    {{ collector.title['size'] }}
                {% else %}
                    <span>Title is empty.</span>
                {% endif %}
            </span>
        </div>

        {% if collector.title is not empty %}
            <div class="sf-toolbar-info-piece">
                <b>Title</b>
                <span>{{ collector.title['value']|nl2br|raw }}</span>
            </div>
        {% endif %}

        <div class="sf-toolbar-info-piece">
            <b>Description length ({{ constant('App\\Twig\\Extension\\SeoExtension::MIN_DESCRITION_LENGTH') }} > {{ constant('App\\Twig\\Extension\\SeoExtension::MAX_DESCRITION_LENGTH') }})</b>
            {% set status = collector.description['status'] is defined ? collector.description['status'] : 'red' %}
            <span class="sf-toolbar-status sf-toolbar-status-{{ status }}">
                {% if collector.description is not empty %}
                    {{ collector.description['size'] }}
                {% else %}
                    <span>Description is empty.</span>
                {% endif %}
            </span>
        </div>

        {% if collector.description is not empty %}
            <div class="sf-toolbar-info-piece">
                <b>Description</b>
                <span>{{ collector.description['value']|nl2br|raw }}</span>
            </div>
        {% endif %}
    {% endset %}

    {{ include('@WebProfiler/Profiler/toolbar_item.html.twig', { link: false }) }}
{% endblock %}

We have to associate this template with the data_collector tag. We add the following entry in the services.yml file. Note, that I use the FQCN of the collector class as the identifier (id:), it allows me to use self::class constant in the getName() function and prevents the config from having arbitrary string identifiers.

    App\DataCollector\SeoCollector:
        tags:
          -
            name:     data_collector
            template: 'data_collector/seo_collector.html.twig'
            id:       App\DataCollector\SeoCollector

Some explanations about the template; we override the toolbar block in which we set two variables: icon and text. The icon is what is shown in the debug bar and that you need to pass over with your mouse to see the panel content. The text is the content of the panel itself. These two variables are used in the WebProfiler/Profiler/toolbar_item.html.twig template which is included at the bottom. In the text section, we have four entries:

  • The title length with the recommended size
  • The title
  • The description length with the recommended size
  • The description

For the two length entries, you notice that we set a "status" (sf-toolbar-status-{{ status }}). It sets a background colour for the size (it was prepared in the data collector), red indicates a missing text, yellow tells us that the length is either too short or too big. Eventually green tells that the length respects the recommendations. Here is what the debug bar looks like with the new entry:


The Symfony debug bar with the new SEO entry.

And when expanded, we see the details:


The content of the SEO panel.

The background colour of the icon takes the "worst" status of all the panel entries. If you have at least one error entry, it is red. If you have at least one warning entry, it is yellow; otherwise, it is green. Symfony automatically handles this. Note that as we have passed the { link: false } parameter to the toolbar_item.html.twig template we don't have this new panel when accessing the full page profiler (https://127.0.0.1:8000/_profiler/b8fbf5).

Click here to see the content of the toolbar_item.html.twig template.
<div class="sf-toolbar-block sf-toolbar-block-{{ name }} sf-toolbar-status-{{ status|default('normal') }} {{ additional_classes|default('') }}" {{ block_attrs|default('')|raw }}>
    {% if link is not defined or link %}<a href="{{ url('_profiler', { token: token, panel: name }) }}">{% endif %}
        <div class="sf-toolbar-icon">{{ icon|default('') }}</div>
    {% if link|default(false) %}</a>{% endif %}
        <div class="sf-toolbar-info">{{ text|default('') }}</div>
</div>
Click here to see the content of the WebProfiler/Profiler/layout.html.twig template.
{% extends '@WebProfiler/Profiler/base.html.twig' %}

{% block body %}
    <div class="container">
        {{ include('@WebProfiler/Profiler/header.html.twig', {profile_type: profile_type}, with_context = false) }}

        <div id="summary">
        {% block summary %}
            {% if profile is defined %}
                {% set request_collector = profile.collectors.request|default(false) %}

                {{ include('@WebProfiler/Profiler/_%s_summary.html.twig'|format(profile_type), {
                    profile: profile,
                    command_collector: profile.collectors.command|default(false) ,
                    request_collector: request_collector,
                    request: request,
                    token: token
                }, with_context=false) }}
            {% endif %}
        {% endblock %}
    </div>

        <div id="content">
            <div id="main">
                <div id="sidebar">
                    {% block sidebar %}
                        <div id="sidebar-contents">
                            <div id="sidebar-shortcuts">
                                {% block sidebar_shortcuts_links %}
                                    <div class="shortcuts">
                                        <a class="btn btn-link" href="{{ path('_profiler_search', { limit: 10, type: profile_type }) }}">Last 10</a>
                                        <a class="btn btn-link" href="{{ path('_profiler', { token: 'latest', type: profile_type }|merge(request.query.all)) }}">Latest</a>

                                        <a class="sf-toggle btn btn-link" data-toggle-selector="#sidebar-search" {% if tokens is defined or about is defined %}data-toggle-initial="display"{% endif %}>
                                            {{ source('@WebProfiler/Icon/search.svg') }} <span class="hidden-small">Search</span>
                                        </a>
                                    </div>
                                {% endblock sidebar_shortcuts_links %}

                                {{ render(controller('web_profiler.controller.profiler::searchBarAction', query={type: profile_type }|merge(request.query.all))) }}
                            </div>

                            {% if templates is defined %}
                                <ul id="menu-profiler">
                                    {% if 'request' is same as(profile_type) %}
                                        {% set excludes = ['command'] %}
                                    {% elseif 'command' is same as(profile_type) %}
                                        {% set excludes = ['request', 'router'] %}
                                    {% endif %}

                                    {% for name, template in templates|filter((t, n) => n not in excludes) %}
                                        {% set menu -%}
                                            {%- if block('menu', template) is defined -%}
                                                {% with { collector: profile.getcollector(name), profiler_markup_version: profiler_markup_version } %}
                                                    {{- block('menu', template) -}}
                                                {% endwith %}
                                            {%- endif -%}
                                        {%- endset %}
                                        {% if menu is not empty %}
                                            <li class="{{ name }} {{ name == panel ? 'selected' }}">
                                                <a href="{{ path('_profiler', { token: token, panel: name, type: profile_type }) }}">{{ menu|raw }}</a>
                                            </li>
                                        {% endif %}
                                    {% endfor %}
                                </ul>
                            {% endif %}
                        </div>

                        {{ include('@WebProfiler/Profiler/settings.html.twig') }}
                    {% endblock sidebar %}
                </div>

                <div id="collector-wrapper">
                    <div id="collector-content">
                        {{ include('@WebProfiler/Profiler/base_js.html.twig') }}
                        {% block panel '' %}
                    </div>
                </div>
            </div>
        </div>
    </div>
{% endblock %}

The SEO Twig extension

When looking at the layout, we saw that we use two Twig filters, they enforce some rules. They cut the string if it is too big and for the title, we check if the branding string can be added without going over the max length (we add to the title " | Strangebuzz"). Here is the code:

<?php

declare(strict_types=1);

// src/Twig/Extension/SeoExtension.php

namespace App\Twig\Extension;

use Symfony\Component\String\AbstractString;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;

use function Symfony\Component\String\u;

/**
 * SEO related Twig helpers.
 */
final class SeoExtension extends AbstractExtension
{
    public const int MIN_TITLE_LENGTH = 30;
    public const int MAX_TITLE_LENGTH = 65;

    public const int MIN_DESCRITION_LENGTH = 120;
    public const int MAX_DESCRITION_LENGTH = 155;

    private const string BRANDING = ' | Strangebuzz';

    public function getFilters(): array
    {
        return [
            new TwigFilter('seo_title', $this->processTitle(...)),
            new TwigFilter('seo_description', $this->processDescription(...)),
        ];
    }

    private function prepareStr(string $str): AbstractString
    {
        return u(strip_tags($str))->trim();
    }

    public function processTitle(string $title): string
    {
        $str = $this->prepareStr($title);
        $brandingStr = u(self::BRANDING);
        $length = $str->length();

        // Nominal case
        if ($length >= self::MIN_TITLE_LENGTH && $length <= self::MAX_TITLE_LENGTH) {
            // Is there enough place for the branding?
            if (($length + $brandingStr->length()) <= self::MAX_TITLE_LENGTH) {
                $str = $str->ensureEnd($brandingStr->toString());
            }

            return $str->toString();
        }

        // Title too short, we add the branding
        if ($length < self::MIN_TITLE_LENGTH) {
            $str = $str->ensureEnd($brandingStr->toString());
        }

        // Title too long, we cup
        if ($length > self::MAX_TITLE_LENGTH) {
            $str = $str->truncate(self::MAX_TITLE_LENGTH);
        }

        return $str->toString();
    }

    public function processDescription(string $description): string
    {
        $str = $this->prepareStr($description);
        $length = $str->length();

        if ($length >= self::MIN_DESCRITION_LENGTH && $length <= self::MAX_DESCRITION_LENGTH) {
            return $str->toString();
        }

        // Description too long, we cut
        if ($length > self::MAX_DESCRITION_LENGTH) {
            $str = $str->truncate(self::MAX_DESCRITION_LENGTH);
        }

        return $str->toString();
    }
}

I also use the string component for almost all string manipulations except for the strip_tags function. I added this extension quite recently, but if I had to use it on a new project, I'd raise an exception when the title or description is empty or incorrect. That would enforce the developer to set them. Adding this information would be part of the contract when adding new public pages.

Conclusion

Adding a custom debug panel isn't very difficult as it was designed to be extended. We saw a concrete use case where it can be useful and make our developer experience better. I hope it will give ideas for your projects, don't hesitate to share them with me. 😉

That's it! I hope you like it. Check out the links below to have additional information related to the post. As always, feedback, likes and retweets are welcome. (see the box below) See you! COil. 😊

  Read the doc

  Work with me!


Call to action

Did you like this post? You can help me back in several ways: (use the Tweet on the right to comment or to contact me )

Thank you for reading! And see you soon on Strangebuzz! 😉

COil