xorgxx / neox-seo-bundle
simply seo for your project
Package info
github.com/xorgxx/NeoxSeoBundle
Type:symfony-bundle
pkg:composer/xorgxx/neox-seo-bundle
Requires
- php: >=8.1
- symfony/framework-bundle: 6.3.*
This package is auto-updated.
Last update: 2026-07-28 17:05:01 UTC
README
This bundle provides a simple and flexible to provide Seo functionality for developers. Tow way to use : 1 - Attribute 2 - Injection & 1+2 ๐
Installation !!
Install the bundle for Composer !! as is still on beta version !!
composer require xorgxx/neox-seo-bundle
or
composer require xorgxx/neox-seo-bundle:0.*
Make sure that is register the bundle in your AppKernel:
Bundles.php
<?php
return [
.....
NeoxSeo\NeoxSeoBundle\neoxSeoBundle::class => ['all' => true],
.....
];
NOTE: You may need to use [ symfony composer dump-autoload ] to reload autoloading
..... Done ๐
!! you style need to make configuration !!
it at this time we ded not optimize all !!
Configuration
- Install and configure ==> Symfony config
- Creat neox_seo.yaml in config folder
โโโโ config
โ โโโโ packages
โ โโโโ neox_seo.yaml
| โโโโ .....
neox_seo.yaml
It set automatique but you can custom
neox_seo:
seo:
title: '%name_projet% Bienvenue | Creation de site web, mobile, application station'
charset: "utf-8"
link:
canonical: auto #href="%web_site%"
alternate@FR: hreflang="fr" href="https://www.xxxxx.wip/fr"
alternate@En: hreflang="en" href="https://www.xxxxx.wip/en"
icon: href="data:image/svg+xml,<svg xmlns=http://www.w3.org/2000/svg viewBox=0 0 128 128><text y=1.2em font-size=96>โซ๏ธ</text></svg>"
metas:
metasHttpEquiv:
content-type: "text/html; charset=utf-8"
x-ua-compatible: "IE=edge"
metasName:
viewport: "width=device-width, initial-scale=1"
description: |
%name_projet% ....
image: '%web_site%/images/logo/logoWhite.png' # This one is shared by open graph and twitter only
# TWITTER
"twitter:card": summary
metasProperty:
# FACEBOOK
"og:description": blablabla.
metasItemprop:
html:
lang: fr
How to use ?
By attribute name
myController.php
<?php
use NeoxSeo\NeoxSeoBundle\Attribute\NeoxSeo;
....
#[NeoxSeo( title: 'home page', charset: 'UTF-8', metasHttpEquiv: ['Content-Type'=>'text/html; charset=utf-4', 'Accept'=>'text/html; charset=utf-8'])]
class HomeController extends _CoreController
{
#[Route('/{id}/send', name: 'app_admin_tokyo_crud_send', methods: ['GET'])]
#[NeoxSeo( title: 'home index', metasName: ['keywords' =>'bar', 'description' => 'foo', 'robots' => 'index,follow'])]
public function index( Request $request): Response
{
....
}
By injection
myController.php
<?php
#[NeoxSeo( title: 'home page', charset: 'UTF-8', metasHttpEquiv: ['Content-Type'=>'text/html; charset=utf-4', 'Accept'=>'text/html; charset=utf-8'])]
class HomeController extends _CoreController
{
/**
* @param NeoxSeoService $neoxSeoService
*
* @return Response
*/
#[Route('/{id}/send', name: 'app_admin_tokyo_crud_send', methods: ['GET'])]
#[NeoxSeo( title: 'home index', metasName: ['keywords' =>'bar', 'description' => 'foo', 'robots' => 'index,follow'])]
public function index(NeoxSeoService $neoxSeoService): Response
{
$neoxSeoService->getSeoBag()
->setTitle("home-page")
->setMetasHttpEquiv([
'Content-Type'=>'text/html; charset=utf-56',
'Accept'=>'text/html; charset=utf-8',
'capnord'=>'text/html; charset=utf-8',
])
;
}
.....
Setup twig balise
<html {{ neoxSeoHtmlAttributes()|raw }}> <head> {{ neoxSeoTitle()|raw }} {{ neoxSeoMetadata()|raw }} {{ neoxSeoLink()|raw }}
..... Done ๐๐๐๐
Sitemap
The sitemap module generates a sitemap.xml that search engines (Google, Bing...) can crawl to discover all your pages.
How it works
Googlebot โ GET /sitemap.xml โ SitemapController โ SitemapService
โโโ Scans routes with #[NeoxSitemap] (static pages)
โโโ Collects URLs from providers (dynamic pages)
โโโ Filters excluded patterns
โโโ Generates XML
Step 1: Enable and import routes
In config/packages/neox_seo.yaml:
neox_seo: sitemap: enabled: true mode: "on_demand" # "on_demand" = route /sitemap.xml, "static" = CLI only route: "/sitemap.xml" defaults: changefreq: "weekly" priority: 0.5 exclude_patterns: - "/_profiler" - "/_wdt" - "/admin/*"
In config/routes.yaml:
neox_seo: resource: '@NeoxSeoBundle/Resources/config/routes.yaml'
Step 2a: Add static pages with the attribute
For pages with no URL parameters (e.g. /about, /contact), add the #[NeoxSitemap] attribute on your controller method:
use NeoxSeo\NeoxSeoBundle\Attribute\NeoxSitemap; class PageController extends AbstractController { #[Route('/about', name: 'app_about')] #[NeoxSitemap(priority: 0.8, changefreq: 'monthly')] public function about(): Response { // This page will automatically appear in /sitemap.xml return $this->render('page/about.html.twig'); } #[Route('/contact', name: 'app_contact')] #[NeoxSitemap(priority: 0.6, changefreq: 'yearly')] public function contact(): Response { // This one too return $this->render('page/contact.html.twig'); } }
Result in /sitemap.xml:
<url> <loc>https://yoursite.fr/about</loc> <changefreq>monthly</changefreq> <priority>0.8</priority> </url> <url> <loc>https://yoursite.fr/contact</loc> <changefreq>yearly</changefreq> <priority>0.6</priority> </url>
Note: Routes with parameters (e.g.
/produits/{id}) are ignored by the attribute scanner because the bundle can't guess the IDs. Use a provider for those (see Step 2b).
Step 2b: Add dynamic pages with a provider
For pages with URL parameters (e.g. product pages, blog articles), create a class that implements SitemapProviderInterface:
namespace App\Sitemap; use NeoxSeo\NeoxSeoBundle\Sitemap\SitemapProviderInterface; use App\Repository\ProductRepository; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; class ProductSitemapProvider implements SitemapProviderInterface { public function __construct( private readonly ProductRepository $productRepository, private readonly UrlGeneratorInterface $urlGenerator, ) {} public function getUrls(): array { $products = $this->productRepository->findAll(); $urls = []; foreach ($products as $product) { $urls[] = [ 'loc' => $this->urlGenerator->generate('app_product_show', ['id' => $product->getId()], UrlGeneratorInterface::ABSOLUTE_URL), 'lastmod' => $product->getUpdatedAt()->format('Y-m-d'), 'changefreq' => 'weekly', 'priority' => 0.7, ]; } return $urls; } }
Register it in config/services.yaml with the neox_seo.sitemap_provider tag:
services: App\Sitemap\ProductSitemapProvider: tags: ['neox_seo.sitemap_provider']
You can create multiple providers (products, blog posts, categories...). Each one tagged with neox_seo.sitemap_provider will be automatically collected.
Step 3: Test it
Visit https://yoursite.fr/sitemap.xml in your browser. You should see the XML with all your URLs.
Step 4 (optional): Use CLI for large sites
If you have thousands of URLs, generating the sitemap on every request is expensive. Use the CLI to generate a static file:
php bin/console neox:seo:sitemap:generate
# Writes to public/sitemap.xml by default
php bin/console neox:seo:sitemap:generate --output=public/sitemap.xml
Add a cron to keep it fresh:
0 3 * * * cd /var/www && php bin/console neox:seo:sitemap:generate
Breadcrumb
The breadcrumb module generates a fil d'Ariane (e.g. Accueil > Produits > Boรฎte pizza) with both visible HTML and JSON-LD structured data for Google.
How it works
User visits /produits/boite-pizza
โ BreadcrumbService::getItems()
1. Reads #[NeoxBreadcrumb] attributes on the controller/method
2. Prepends "Accueil" (if prepend_home enabled)
3. Appends current page (if append_current enabled)
โ Twig calls neoxSeoBreadcrumb()
โ Renders HTML (visible) + JSON-LD (for Google)
Step 1: Enable the module
In config/packages/neox_seo.yaml:
neox_seo: breadcrumb: enabled: true json_ld: true # generate Schema.org JSON-LD for Google html: true # generate visible HTML separator: ">" # separator between items wrapper: "nav" # HTML wrapper tag prepend_home: enabled: true # add "Accueil" as first item label: "Accueil" route: "app_home" # route name for the home link append_current: true # add current page as last item link_last: false # last item is not clickable (best practice)
Step 2a: Use the attribute (static pages)
Add #[NeoxBreadcrumb] on your controller methods. You only need to set the label โ the bundle automatically deduces the parent chain from the route name convention.
Convention: Route names use _ as hierarchy separator. The bundle removes the last segment to find the parent route, and repeats recursively.
use NeoxSeo\NeoxSeoBundle\Attribute\NeoxBreadcrumb; class ProductController extends AbstractController { // Route: app_produits โ /produits // No parent route with breadcrumb โ Breadcrumb: Accueil > Produits #[Route('/produits', name: 'app_produits')] #[NeoxBreadcrumb(label: 'Produits')] public function produits(): Response { return $this->render('product/list.html.twig'); } // Route: app_produits_boite โ /produits/boite // Auto-deduction: app_produits_boite โ app_produits (has #[NeoxBreadcrumb] "Produits") // Breadcrumb: Accueil > Produits > Boรฎte #[Route('/produits/boite', name: 'app_produits_boite')] #[NeoxBreadcrumb(label: 'Boรฎte')] public function boite(): Response { return $this->render('product/boite.html.twig'); } // Route: app_produits_boite_pizza โ /produits/boite/pizza // Auto-deduction: app_produits_boite_pizza โ app_produits_boite โ app_produits // Breadcrumb: Accueil > Produits > Boรฎte > Pizza #[Route('/produits/boite/pizza', name: 'app_produits_boite_pizza')] #[NeoxBreadcrumb(label: 'Pizza')] public function pizza(): Response { return $this->render('product/pizza.html.twig'); } }
How auto-deduction works:
Route: app_produits_boite_pizza
1. Remove "_pizza" โ app_produits_boite โ exists? yes โ has #[NeoxBreadcrumb]? yes โ "Boรฎte"
2. Remove "_boite" โ app_produits โ exists? yes โ has #[NeoxBreadcrumb]? yes โ "Produits"
3. Remove "_produits" โ app โ exists? no โ stop
4. Prepend "Accueil" (if prepend_home enabled)
Result: Accueil > Produits > Boรฎte > Pizza
Note: The
routeparameter on#[NeoxBreadcrumb]makes the item clickable (generates a link). Without it, the item is plain text. The last item is never clickable (best practice for breadcrumbs).
Override the parent explicitly
If the auto-deduction doesn't work for your route naming, you can override it with parent:
// Route: app_catalog_special โ /catalog/special // Auto-deduction: app_catalog_special โ app_catalog โ doesn't exist โ stop // Override: parent: 'app_produits' forces "Produits" as parent #[Route('/catalog/special', name: 'app_catalog_special')] #[NeoxBreadcrumb(label: 'Spรฉcial', parent: 'app_produits')] public function special(): Response { // Breadcrumb: Accueil > Produits > Spรฉcial return $this->render('catalog/special.html.twig'); }
Disable breadcrumb on a specific route
If a route would inherit a breadcrumb from its parent but you don't want one, use enabled: false:
// app_produits has #[NeoxBreadcrumb(label: 'Produits')] // This route would auto-deduct "Produits" as parent, but we don't want a breadcrumb here #[Route('/produits/api', name: 'app_produits_api')] #[NeoxBreadcrumb(enabled: false)] public function api(): Response { // No breadcrumb at all on this page โ not even "Accueil > Produits" return $this->json([...]); }
Step 2b: Use injection (dynamic pages)
For pages where the breadcrumb depends on data from the database (e.g. a product name), inject BreadcrumbService directly:
use NeoxSeo\NeoxSeoBundle\Breadcrumb\BreadcrumbService; class ProductController extends AbstractController { // URL: /produits/{id} // Breadcrumb: Accueil > Produits > Fond pizza #[Route('/produits/{id}', name: 'app_product_show')] public function show(BreadcrumbService $breadcrumb, Product $product): Response { // addRoute() = clickable item (generates URL from route name) $breadcrumb->addRoute('Produits', 'app_produits'); // add() = plain text item (last item, not clickable) $breadcrumb->add($product->getName()); return $this->render('product/show.html.twig', ['product' => $product]); } }
Available methods:
add(string $label, ?string $url = null)โ add an item with a raw URL or plain textaddRoute(string $label, string $route, array $params = [])โ add a clickable item from a route nameclear()โ reset the breadcrumb (rarely needed)
Step 3: Render in Twig
Add this in your template where you want the breadcrumb to appear:
{{ neoxSeoBreadcrumb()|raw }}
HTML output (visible by the user):
<nav> <ol> <li><a href="/">Accueil</a></li> <li>></li> <li><a href="/produits">Produits</a></li> <li>></li> <li>Fond pizza</li> </ol> </nav>
JSON-LD output (invisible, for Google rich results):
<script type="application/ld+json"> {"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[ {"@type":"ListItem","position":1,"name":"Accueil","item":"https://yoursite.fr/"}, {"@type":"ListItem","position":2,"name":"Produits","item":"https://yoursite.fr/produits"}, {"@type":"ListItem","position":3,"name":"Fond pizza"} ]} </script>
Combining attribute + injection
You can use both on the same controller. The attribute sets the label (and auto-deduces parents), and the injection adds dynamic items:
// Route: app_produits_show โ /produits/{id} // Auto-deduction: app_produits_show โ app_produits โ "Produits" #[Route('/produits/{id}', name: 'app_produits_show')] #[NeoxBreadcrumb(label: '')] // label will be overridden by injection public function show(BreadcrumbService $breadcrumb, Product $product): Response { // Parent "Produits" was auto-deduced from app_produits // Now add the dynamic product name as the last item $breadcrumb->add($product->getName()); }
Seo Translator
The translator module automatically loads SEO data from translation files based on the route name.
Configuration
neox_seo: translator: enabled: true route_prefix: "seo_" # routes must start with this prefix translation_domain: "seo" # translation domain to use
Route naming convention
Routes must follow the pattern: seo_{domain}_{action}
#[Route('/', name: 'seo_home')] public function index(): Response { ... } #[Route('/contact', name: 'seo_contact')] public function contact(): Response { ... }
Translation files
Create translation files in the translations/ directory:
# translations/seo.fr.yaml home: seo: title: 'My Site | Welcome' description: 'Welcome to my website' keywords: 'site, web, design' robots: 'index,follow' # Unified social section โ auto-maps to Open Graph + Twitter Cards social: title: 'My Site' # โ og:title + twitter:title (fallback: seo.title) description: 'Discover my website' # โ og:description + twitter:description (fallback: seo.description) image: 'https://site.fr/logo.png' # โ og:image + twitter:image (fallback: seo.image) url: 'https://site.fr' # โ og:url type: 'website' # โ og:type site_name: 'My Site' # โ og:site_name locale: 'fr_FR' # โ og:locale twitter: card: 'summary_large_image' # โ twitter:card (specific to Twitter) site: '@mysite' # โ twitter:site (specific to Twitter) creator: '@author' # โ twitter:creator (specific to Twitter) contact: seo: title: 'Contact Us' description: 'Get in touch with our team' social: image: 'https://site.fr/contact.png'
Social media mapping
| Translation key | Open Graph | Twitter Card |
|---|---|---|
social.title |
og:title |
twitter:title |
social.description |
og:description |
twitter:description |
social.image |
og:image |
twitter:image |
social.url |
og:url |
โ |
social.type |
og:type |
โ |
social.site_name |
og:site_name |
โ |
social.locale |
og:locale |
โ |
social.twitter.card |
โ | twitter:card |
social.twitter.site |
โ | twitter:site |
social.twitter.creator |
โ | twitter:creator |
Fallback logic
If social.title is not defined, the bundle automatically uses seo.title. Same for social.description โ seo.description and social.image โ seo.image. This avoids duplication.
Global social config
For values common to all pages (site_name, twitter:site, locale), define them once in neox_seo.yaml:
neox_seo: seo: metas: metasProperty: "og:site_name": "My Site" "og:locale": "fr_FR" metasName: "twitter:site": "@mysite" "twitter:card": "summary_large_image"
These are applied globally. Per-page translations only override title, description, image, etc.
Priority order
- Translation files (applied automatically by SeoSubscriber)
- Attributes
#[NeoxSeo](override translations) - Manual injection via
$neoxSeoService->getSeoBag()(override everything)
The translator only applies if the route name starts with the configured prefix (seo_ by default). Routes without the prefix are ignored.
Contributing
If you want to contribute (thank you!) to this bundle, here are some guidelines:
- Please respect the Symfony guidelines
- Test everything! Please add tests cases to the tests/ directory when:
- You fix a bug that wasn't covered before
- You add a new feature
- You see code that works but isn't covered by any tests (there is a special place in heaven for you)
Todo list
- Sitemap โ
- Breadcrumb โ
- Seo Translator โ