xorgxx/neox-seo-bundle

simply seo for your project

Maintainers

Package info

github.com/xorgxx/NeoxSeoBundle

Type:symfony-bundle

pkg:composer/xorgxx/neox-seo-bundle

Transparency log

Statistics

Installs: 54

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

0.1 2023-09-30 08:34 UTC

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 ๐Ÿ˜‰

2023-09-29-12-02-44.png

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 route parameter 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 text
  • addRoute(string $label, string $route, array $params = []) โ€” add a clickable item from a route name
  • clear() โ€” 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

  1. Translation files (applied automatically by SeoSubscriber)
  2. Attributes #[NeoxSeo] (override translations)
  3. 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 โœ…

Thanks