Search by

mulertech / seo-bundle

mulertech

Symfony bundle for SEO management: meta tags (OpenGraph, Twitter Cards), Schema.org JSON-LD structured data, sitemap XML generation, and robots.txt

Package info

github.com/mulertech/seo-bundle

Type:symfony-bundle

pkg:composer/mulertech/seo-bundle

Statistics

Installs: 428

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

v1.6.0 2026-09-30 17:25 UTC

This package is auto-updated.

Last update: 2026-09-30 17:28:34 UTC


README

Latest Version on Packagist GitHub Tests Action Status GitHub PHPStan Action Status Total Downloads Test Coverage

Symfony bundle for SEO management: meta tags (OpenGraph, Twitter Cards), Schema.org JSON-LD structured data, sitemap XML generation, robots.txt, and llms.txt.

Requirements

  • PHP 8.4+
  • Symfony 6.4+, 7.0+ or 8.0+

Installation

composer require mulertech/seo-bundle

Configuration

# config/packages/mulertech_seo.yaml
mulertech_seo:
    # Fallback for og:image / twitter:image. A path is resolved against the host of the
    # current request, so one value serves every environment. Use JPEG or PNG at
    # 1200x630: LinkedIn refuses WebP outright, and Facebook downgrades to a square
    # thumbnail below 600x315.
    default_image: '/images/og-default.jpg'
    default_image_width: 1200
    default_image_height: 630
    default_image_alt: 'MulerTech'
    default_locale: 'fr_FR'  # Default og:locale

    # Query parameters stripped from canonical and og:url. Omit the key to keep the
    # defaults (utm_*, gclid, fbclid, msclkid, ttclid, twclid, igshid, mc_cid, mc_eid,
    # yclid, _ga, ref_src, gbraid, wbraid).
    #
    # Declaring it REPLACES that list rather than adding to it, so repeat the defaults
    # you still want. Never list a parameter that changes what the page shows:
    # canonicalising /blog?page=2 to /blog declares page 2 a duplicate of page 1, and
    # search engines drop it from the index.
    canonical_ignored_parameters: ['utm_source', 'utm_medium', 'utm_campaign', 'fbclid', 's']
    schema_org:
        organization_type: 'LocalBusiness'
        organization_description: 'Your company description'
        price_range: '€€'
        address_region: 'Normandie'
        # Declared as schema.org logo, which Google reads for the knowledge panel. A path is
        # resolved against the host of the current request; a relative URL is ignored, so
        # outside an HTTP context (a console command rendering a template) configure an
        # absolute one.
        logo: '/images/logo.png'
        founder_name: 'Jane Doe'
        search_action_path_template: '/blog?q={search_term_string}'
        areas_served:
            - { type: 'City', name: 'Caen' }
            - { type: 'AdministrativeArea', name: 'Normandie' }
            - { type: 'Country', name: 'France' }
        offer_names:
            - 'Web Development'
            - 'Hosting'
            - 'Maintenance'
    robots:
        # Disallow lines of the '*' group, obeyed by every crawler without a group of its own.
        disallow_paths:
            - '/admin'
            - '/login'
        # Extra Allow lines for the '*' group. The longest matching rule wins, so this one
        # reopens a single path inside a disallowed section.
        allow_paths:
            - '/admin/help'
        # A crawler obeys the single group whose User-agent matches it best and ignores every
        # other, '*' included. A rule aimed at one robot therefore needs its own group, and
        # that group repeats whatever of disallow_paths should keep binding that robot.
        groups:
            - user_agents: ['HTTrack', 'WebCopier', 'WebZIP']
              disallow: ['/']
    llms:
        enabled: true                             # Serve the /llms.txt route
        title: 'My Company'                       # H1 (defaults to company name)
        summary: 'We build amazing web apps.'     # Blockquote summary
        notes: 'Optional intro prose.'            # Prose below the summary
        sections:                                 # Curated links, keyed by H2 heading
            Documentation:
                - { url: '/docs', title: 'Docs', description: 'Guides and references' }
            Services:
                - { url: '/services/web', title: 'Web Development' }

Usage

1. Implement SeoCompanyInfoProviderInterface

The bundle needs company information for meta tags and Schema.org data:

use MulerTech\SeoBundle\Model\SeoCompanyInfoProviderInterface;

class CompanyInfoProvider implements SeoCompanyInfoProviderInterface
{
    public function getName(): string { return 'My Company'; }
    public function getWebsite(): string { return 'https://mycompany.com'; }
    public function getEmail(): string { return 'contact@mycompany.com'; }
    public function getPhone(): string { return '+33 1 23 45 67 89'; }
    public function getPostalCode(): string { return '14000'; }
    public function getCity(): string { return 'Caen'; }
    public function getCountry(): string { return 'France'; }
    public function getSocialUrls(): array {
        return [
            'linkedin' => 'https://linkedin.com/company/mycompany',
            'github' => 'https://github.com/mycompany',
        ];
    }
}

Register it as a service aliased to the interface:

# config/services.yaml
MulerTech\SeoBundle\Model\SeoCompanyInfoProviderInterface:
    class: App\Seo\CompanyInfoProvider

2. Generate meta tags in controllers

use MulerTech\SeoBundle\Service\MetaTagService;

class HomeController extends AbstractController
{
    public function index(MetaTagService $metaTagService): Response
    {
        $seo = $metaTagService->generateMetaTags([
            'title' => 'Welcome to My Company',
            'description' => 'We build amazing web applications.',
        ]);

        return $this->render('home/index.html.twig', ['seo' => $seo]);
    }
}
Option Effect
title, description Truncated to 60 and 160 characters
url Overrides the canonical, which otherwise comes from the request
type website by default; article unlocks the three article:* options below
image Overrides default_image. A path is resolved against the current host
imageWidth, imageHeight Declared as og:image:width / og:image:height. Worth passing: without them Facebook must download the image before deciding whether it fills a large card, and the first share of a freshly published page is the one that matters
A page that supplies its own image never inherits default_image_width / default_image_height / default_image_alt: they describe the fallback image only
imageAlt Declared as og:image:alt and twitter:image:alt
imageType Overrides the MIME type, which is otherwise read from the extension
publishedTime, modifiedTime, author article type only

Include the meta tags template in your <head>:

{% block seo_meta %}
    {% include '@MulerTechSeo/seo_meta.html.twig' with { seo: seo } %}
{% endblock %}

3. Schema.org JSON-LD in Twig (requires twig/twig)

{# Organization + WebSite (global, in base.html.twig) #}
{{ schema_org_json_ld('organization') }}
{{ schema_org_json_ld('webSite') }}

{# Blog posting (in blog/show.html.twig) #}
{{ schema_org_json_ld('blogPosting', post) }}

{# Service (in service/show.html.twig) #}
{{ schema_org_json_ld('service', { title: 'Web Dev', description: 'Custom apps' }) }}

{# Breadcrumbs. `url()`, not `path()`: schema.org reads `item` as an address in its own
   right, and a relative one leaves the trail pointing nowhere. A null url falls back to
   the current request, which suits the last entry. #}
{{ schema_org_json_ld('breadcrumbList', [
    { label: 'Home', url: url('app_home') },
    { label: 'Blog', url: null }
]) }}

{# FAQ (on the page that displays the questions) #}
{{ schema_org_json_ld('faqPage', [
    { question: 'How long does a project take?', answer: '<p>Two to six weeks.</p>' },
    { question: 'Do you offer maintenance?', answer: '<p>Yes, monthly.</p>' }
]) }}

An unknown type, or data that does not match its type, throws an InvalidArgumentException naming what was expected: a structured data block that silently disappears is only noticed at the next SEO audit. service takes {title: string, description?: string}, breadcrumbList a list of {label: string, url: ?string}, faqPage a list of {question: string, answer: string}. An empty breadcrumbList or faqPage list renders nothing, so a page with no entries yet needs no condition around the call.

For faqPage, answer passes through as is and may hold the restricted HTML Google reads in Answer.text (p, br, ul, ol, li, a, strong, em, headings). The bundle does not convert markdown: render and sanitise the answers in the application, which knows the format of its content. The questions and answers must be visible on the page itself, or search engines treat the markup as misleading. Google shows FAQ rich results only for well-known government and health sites; Bing and LLM-based search engines still read the markup.

A JSON-LD block is data rather than code, so a browser never executes it and usually raises no Content Security Policy violation. Enforcement is not uniform, though, and a policy naming a nonce for script-src is written for <script> elements whatever their type. Passing the nonce settles the question and keeps the violation reports clean:

{{ schema_org_json_ld('organization', nonce=csp_nonce('main')) }}

For blogPosting, your entity must implement BlogPostingSeoInterface:

use MulerTech\SeoBundle\Model\BlogPostingSeoInterface;

class BlogPost implements BlogPostingSeoInterface
{
    public function getSeoTitle(): string { return $this->title; }
    public function getSeoExcerpt(): ?string { return $this->excerpt; }
    public function getSeoAuthorName(): string { return $this->author->getFullName(); }
    public function getSeoPublishedAt(): ?string { return $this->publishedAt?->toIso8601String(); }
    public function getSeoUpdatedAt(): ?string { return $this->updatedAt?->toIso8601String(); }
}

4. Sitemap (provider pattern)

Implement SitemapUrlProviderInterface for each content type:

use MulerTech\SeoBundle\Model\SitemapUrl;
use MulerTech\SeoBundle\Model\SitemapUrlProviderInterface;

class BlogSitemapProvider implements SitemapUrlProviderInterface
{
    public function __construct(
        private readonly BlogPostRepository $repository,
        private readonly UrlGeneratorInterface $urlGenerator,
    ) {}

    public function getUrls(): iterable
    {
        foreach ($this->repository->findPublished() as $post) {
            yield new SitemapUrl(
                loc: $this->urlGenerator->generate('app_blog_show', ['slug' => $post->getSlug()], UrlGeneratorInterface::ABSOLUTE_URL),
                priority: '0.6',
                changefreq: 'monthly',
                lastmod: $post->getUpdatedAt()?->toIso8601String(),
            );
        }
    }
}

Providers implementing SitemapUrlProviderInterface are auto-tagged and collected by the sitemap service.

5. llms.txt (provider pattern)

The /llms.txt route serves a Markdown index for LLMs (llmstxt.org). Static sections come from the llms.sections config; dynamic sections are contributed by implementing LlmsSectionProviderInterface:

use MulerTech\SeoBundle\Model\LlmsLink;
use MulerTech\SeoBundle\Model\LlmsSection;
use MulerTech\SeoBundle\Model\LlmsSectionProviderInterface;

class BlogLlmsProvider implements LlmsSectionProviderInterface
{
    public function __construct(
        private readonly BlogPostRepository $repository,
        private readonly UrlGeneratorInterface $urlGenerator,
    ) {}

    public function getSections(): iterable
    {
        $links = [];
        foreach ($this->repository->findPublished() as $post) {
            $links[] = new LlmsLink(
                url: $this->urlGenerator->generate('app_blog_show', ['slug' => $post->getSlug()], UrlGeneratorInterface::ABSOLUTE_URL),
                title: $post->getTitle(),
                description: $post->getExcerpt(),
            );
        }

        yield new LlmsSection('Blog', $links);
    }
}

Providers implementing LlmsSectionProviderInterface are auto-tagged; their sections are appended after the static config sections. Set llms.enabled: false to return a 404 for the route.

Routes

The bundle provides routes for /sitemap.xml, /robots.txt, and /llms.txt. Import them in your application:

# config/routes/mulertech_seo.yaml
mulertech_seo:
    resource: "@MulerTechSeoBundle/config/routes.yaml"

6. SEO fields trait (optional)

Add metaDescription and metaKeywords fields to any entity:

use MulerTech\SeoBundle\Model\SeoFieldsTrait;

class BlogPost
{
    use SeoFieldsTrait;
    // Adds: metaDescription, metaKeywords with getters/setters
}

Testing

./vendor/bin/mtdocker test-ai

License

MIT