mulertech / seo-bundle
Symfony bundle for SEO management: meta tags (OpenGraph, Twitter Cards), Schema.org JSON-LD structured data, sitemap XML generation, and robots.txt
Requires
- php: ^8.4
- symfony/config: ^6.4 || ^7.0 || ^8.0
- symfony/dependency-injection: ^6.4 || ^7.0 || ^8.0
- symfony/http-foundation: ^6.4 || ^7.0 || ^8.0
- symfony/http-kernel: ^6.4 || ^7.0 || ^8.0
- symfony/routing: ^6.4 || ^7.0 || ^8.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.0
- mulertech/docker-dev: ^3.2
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^12.0
- roave/security-advisories: dev-latest
- symfony/framework-bundle: ^6.4 || ^7.0 || ^8.0
- symfony/yaml: ^6.4 || ^7.0 || ^8.0
- twig/twig: ^3.0
Suggests
- symfony/framework-bundle: Required to use the built-in SitemapController and RobotsController
- twig/twig: Required to use the schema_org_json_ld() Twig function and seo_meta.html.twig template
Provides
None
Conflicts
None
Replaces
None
README
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