Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Seo Bundle Laravel Package

nucleos/seo-bundle

Nucleos SEO Bundle is a PHP package for adding SEO metadata to your site, offering configurable titles, descriptions, and related settings to help pages render search-friendly metadata. It integrates cleanly into Symfony applications and keeps SEO configuration simple and reusable.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and enable the bundle. The package targets modern PHP/Symfony — as of 1.7.0 the Symfony matrix extends through Symfony 8, but confirm the exact PHP/Symfony constraints in composer.json before bumping your framework:

composer require nucleos/seo-bundle
// config/bundles.php
Nucleos\SeoBundle\NucleosSeoBundle::class => ['all' => true],

Where to look first. The bundle is small — read these three things before anything else, in this order:

  1. SeoPageInterface / SeoPage (src/Seo/) — the request-scoped object holding title, metas, links, html/head attributes. This is the API you will use every day.
  2. The Twig extension (src/Twig/) — shows the exact helper names and what markup each emits.
  3. DependencyInjection/Configuration.php — the nucleos_seo config tree, which is where you set site-wide defaults.

Confirm the real names in your installed version instead of trusting blog posts:

bin/console debug:config nucleos_seo
bin/console debug:container --tag=twig.extension | grep -i seo
bin/console list | grep -i seo   # sitemap dump command, if shipped

Minimal config — set the site-wide fallbacks once so pages without explicit SEO still render something sane:

# config/packages/nucleos_seo.yaml
nucleos_seo:
    page:
        title: "Acme — Industrial Widgets"
        metas:
            name:
                description: "Acme builds widgets."
                robots: "index, follow"
            property:
                "og:site_name": "Acme"
                "og:type": "website"

Wire the head once, in the base layout. This is the single integration point 95% of your work flows through:

{# templates/base.html.twig #}
<!DOCTYPE html>
<html {{ nucleos_seo_html_attributes() }}>
<head {{ nucleos_seo_head_attributes() }}>
    <meta charset="UTF-8">
    {{ nucleos_seo_title() }}
    {{ nucleos_seo_metadatas() }}
    {{ nucleos_seo_link_canonical() }}
    {{ nucleos_seo_lang_alternates() }}
    {% block stylesheets %}{% endblock %}
</head>

First use case — a product page. Inject the interface (it is autowirable via the nucleos_seo.page service) and set values before rendering:

public function show(Product $product, SeoPageInterface $seoPage): Response
{
    $seoPage
        ->setTitle(sprintf('%s — Acme', $product->getName()))
        ->addMeta('name', 'description', $product->getSummary())
        ->addMeta('property', 'og:title', $product->getName())
        ->addMeta('property', 'og:image', $product->getImageUrl())
        ->setLinkCanonical($this->router->generate('product_show', ['slug' => $product->getSlug()], UrlGeneratorInterface::ABSOLUTE_URL));

    return $this->render('product/show.html.twig', ['product' => $product]);
}

That is the whole loop: mutate the SEO page in PHP → render helpers in the layout. Everything below is variations and sharp edges on that loop.

Implementation Patterns

Keep SEO data out of controllers with a kernel.controller listener. Per-page addMeta() calls rot fast once you have 40 controllers. The idiomatic approach with this bundle is one subscriber that recognises SEO-bearing entities from route attributes and populates the page:

#[AsEventListener(event: KernelEvents::CONTROLLER)]
final class SeoMetadataListener
{
    public function __construct(
        private SeoPageInterface $seoPage,
        private RouterInterface $router,
    ) {}

    public function __invoke(ControllerEvent $event): void
    {
        $request = $event->getRequest();
        $entity  = $request->attributes->get('seo_entity'); // set by a route default / param converter
        if (!$entity instanceof SeoAwareInterface) {
            return;
        }

        $this->seoPage
            ->setTitle($entity->getSeoTitle())
            ->addMeta('name', 'description', $entity->getSeoDescription())
            ->setLinkCanonical($request->getUri());
    }
}

Pair it with a tiny SeoAwareInterface on your entities (getSeoTitle(), getSeoDescription(), optional getSeoImage()). This keeps the logic testable without HTTP.

Centralise defaults; treat config as the floor and controllers as overrides. The config-defined metas are pre-loaded into the SeoPage. Because metas are keyed by (type, name), calling addMeta('name', 'description', …) later simply replaces the default — so you get inheritance for free. Do not re-declare descriptions in every template.

Use a small application-level factory for repetitive shapes. Almost every page needs the same OpenGraph quartet. Wrap it:

final class OpenGraph
{
    public function __construct(private SeoPageInterface $page, private RouterInterface $router) {}

    public function apply(string $type, string $title, string $description, ?string $image = null): void
    {
        $this->page
            ->addMeta('property', 'og:type', $type)
            ->addMeta('property', 'og:title', $title)
            ->addMeta('property', 'og:description', $description)
            ->addMeta('property', 'og:url', $this->router->generate('app_home', [], UrlGeneratorInterface::ABSOLUTE_URL));

        if ($image) {
            $this->page->addMeta('property', 'og:image', $image);
        }
    }
}

Pagination and faceted navigation. Page 2+ of a list should not compete with page 1 in the index:

if ($page > 1) {
    $seoPage->addMeta('name', 'robots', 'noindex, follow');
}
$seoPage->setLinkCanonical($this->generateAbsoluteUrl($page)); // self-referencing, per page

Self-referencing canonicals per page are correct; a canonical pointing page 2 back at page 1 is a common (and wrong) shortcut.

Hreflang for a localised site. Emit alternates from the same listener, using the route + _locale so URLs stay consistent with your routing:

foreach ($this->enabledLocales as $locale) {
    $seoPage->addLangAlternate($locale, $this->router->generate($route, $params + ['_locale' => $locale], UrlGeneratorInterface::ABSOLUTE_URL));
}

Sitemaps. The bundle's sitemap layer is a manager plus N SitemapInterface implementations. You register your own sitemaps in config, implement SitemapInterface (typically wrapping a repository query builder or iterating a Query), and expose them through a controller route. Keep it explicit:

nucleos_seo:
    sitemap:
        services:
            - app.sitemap.products
            - app.sitemap.categories
final class ProductSitemap implements SitemapInterface
{
    public function __construct(private ProductRepository $products, private RouterInterface $router) {}

    public function getRoutes(): iterable
    {
        foreach ($this->products->streamPublished() as $product) {
            yield [
                'url'      => $this->router->generate('product_show', ['slug' => $product->getSlug()], UrlGeneratorInterface::ABSOLUTE_URL),
                'lastmod'  => $product->getUpdatedAt(),
                'changefreq' => 'weekly',
                'priority' => 0.8,
            ];
        }
    }
}

Then either serve /sitemap.xml dynamically from the bundle's controller (fine for small/medium sites) or dump to a static file on deploy/cron and let nginx serve it — the latter scales better, survives an app outage, and is trivial to purge from a CDN. If your version ships a dump command, run it in your deploy script; otherwise a one-line console command that renders the same template works.

For catalogues above the sitemap protocol limits (50 000 URLs / 50 MB uncompressed per file, 50 000 sitemaps per index), shard by ID range and emit a sitemap index.

Integration tips that pay off:

  • Add an /robots.txt route or static file that references the sitemap index — the bundle does not do this for you.
  • Assert the head in functional tests (assertSelectorTextContains('title', …), assertSelectorExists('link[rel=canonical]')). Cheaper than eyeballing it, and catches the "two <title> tags" class of bug the moment it appears.
  • In dev, composer require --dev nothing extra needed; view-source once per release. SEO bugs are invisible in normal browsing.

Gotchas and Tips

The SEO page is request state, and it is a shared service. This is the number-one source of weird bugs. Under PHP-FPM it is fine. Under long-running workers (RoadRunner, Swoole, FrankenPHP worker mode, Messenger-driven rendering) the title and metas from the previous request will bleed into the next one unless the service is reset. Check whether the implementation implements ResetInterface/kernel.reset; if not, register a resetter or rebuild the page per request yourself.

Metas are keyed, so "add" behaves like "set". Calling addMeta('name', 'description', 'A') then 'B' leaves B. That is usually what you want (config default → page override), but it also means two feature branches both writing og:title silently fight, and the loser gets no warning. If you genuinely need repeats (multiple og:image), verify your version supports it — many implementations overwrite.

Ordering: the parent layout renders last. Twig child templates execute before the parent, so setting SEO data inside a {% block %} that lives in the child body usually does run before <head> is produced — but any include/embed/fragment rendered via ESI or a sub-request is a separate request and will not see your controller's mutations. Keep SEO writes in the controller/subscriber, not in templates.

Absolute URLs or nothing. setLinkCanonical() and sitemap URLs must be absolute with scheme and host. In CLI/worker context RouterInterface falls back to http://localhost. Fix it properly:

framework:
    router:
        default_uri: 'https://www.example.com'

This one line makes sitemap dumps and queued mail/URL generation correct. Relative canonicals in production are a silent, hard-to-diagnose ranking problem.

Escaping. Meta content is HTML-attribute context: quotes in a product title will break the markup or get double-escaped. Make sure the Twig extension escapes properly and never slap |raw on a helper to "fix" the output — that converts an encoding bug into an XSS hole. Strip control characters from user-supplied titles/descriptions before they reach the SEO page, and truncate descriptions (~155–160 chars) at the source.

lastmod format. Sitemap dates must be W3C/ISO 8601; a DateTime rendered through ->format('d.m.Y') gets the whole <url> entry rejected by Search Console with an unhelpful message. Format once, centrally, or pass DateTimeInterface and let the sitemap renderer format it.

CLI vs web config divergence. Sitemap generation in a cron job runs with a different env than your web requests. Watch for: missing framework.router.default_uri (see above), APP_ENV differences in your sitemap services list, and the database connection pointing somewhere else. Run the dump manually once with -vvv after deploying.

Sitemap content is your responsibility, not the bundle's. The bundle serialises what you give it. It will happily emit soft-404 URLs, noindexed pages, and redirects. Filter on published = true, exclude pages you marked noindex, and never enumerate routes blindly — bin/console debug:router output is not a sitemap.

Extension points worth knowing. SeoPageInterface is an alias you can override in services.yaml with your own decorator (useful for logging, validation, or enforcing max lengths in dev). Custom SitemapInterface implementations are the intended extension mechanism for sitemaps. The Twig extension itself can be decorated if you need different markup — but decorating is usually worse than fixing your template, since the helpers exist precisely to keep <head> consistent.

Testing and debugging. The SeoPageInterface is trivially mockable, so unit-test your metadata listener in isolation rather than asserting through the container. When a page renders the wrong title, work backwards: bin/console debug:config nucleos_seo (defaults) → dump the page in a kernel.response listener → check for a second render/sub-request → check the reset behaviour if you are on a worker runtime.

Vendor risk. This is a low-adoption package (single-digit stars, effectively no dependents) and a continuation of the older Sonata SEO bundle, so most historical Sonata documentation and StackOverflow answers apply — but nothing else guarantees the API surface stays put. 1.7.0 was pure dependency maintenance (Symfony 8 support plus a dev-only symfony/phpunit-bridge bump, no API or behaviour changes), so upgrading from 1.6.x is a plain composer update — but it also tells you the project's own change surface is currently framework plumbing rather than SEO features. Practical mitigation: pin to a minor range, keep your SEO logic behind your own SeoAwareInterface + factory rather than sprinkled addMeta() calls, and write the two functional tests that assert <title> and <link rel="canonical">. If you ever need to swap bundles, that is a day's work instead of a week's.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
mrnewport/laravel-plaid
mrnewport/laravel-repo
mrnewport/laravel-docsign
mrnewport/laravel-stow
mrnewport/laravel-flow
mrnewport/laravel-priceable
mrnewport/laravel-nestedterms
fredmeng/laravel-apidoc-generator
itstructure/laravel-multilingual-tools
pdustdar/elasticsearch
markupai/php-sdk
rlacerda83/lumem-microservice-shipment
uni-method/symfony-lib
tacticmedia/scim-server-bundle
neika-ai/symfony-bundle
mattleads/agent-publishing-bundle
glavweb/framework-bundle
glavweb/extra-bundle
glavweb/api-bundle
glavweb/admin-bundle