druidvav / page-metadata-bundle
Package info
github.com/druidvav/sf-page-metadata-bundle
Type:symfony-bundle
pkg:composer/druidvav/page-metadata-bundle
Requires
- php: >=7.4.0
- symfony/framework-bundle: ^5.0 || ^6.0 || ^7.0
- symfony/twig-bundle: ^5.0 || ^6.0 || ^7.0
- twig/twig: ^2.7 || ^3.0
Requires (Dev)
- phpunit/phpunit: ^9.6 || ^10.5 || ^11.0
- rector/rector: ^2.4
- symfony/monolog-bundle: ^3.1
- symfony/translation: ^5.0 || ^6.0 || ^7.0
Suggests
- meniam/autotext: ^4.0
README
Symfony bundle for managing page titles, meta tags, canonical URLs, Open Graph and Twitter metadata, breadcrumbs, and Schema.org JSON-LD from one request-scoped metadata object.
Requirements
- PHP 7.4 or later
- Symfony 5, 6, or 7
- Twig 2.7 or later
Installation
composer require druidvav/page-metadata-bundle
When Symfony Flex is not available, register the bundle manually:
// config/bundles.php return [ // ... Druidvav\PageMetadataBundle\DvPageMetadataBundle::class => ['all' => true], ];
Render the metadata in the document head and breadcrumbs where they belong in the page body:
<head> {{ page_meta() }} </head> <body> {{ page_breadcrumbs() }} </body>
page_meta() renders the title, standard meta tags, canonical link, Open Graph and Twitter tags, and structured data.
Configuration
base_url is required. All other sections are optional:
# config/packages/dv_page_metadata.yaml dv_page_metadata: base_url: '%env(DEFAULT_URI)%' canonical: alternate_locales: [hy, ru, en] title: default: 'Example' delimiter: ' - ' locale: null translation_domain: null meta: description: null keywords: null opengraph: site_name: 'Example' type: website image: '/default-cover.jpg' twitter_image: '/default-cover.jpg' twitter_site: '@example' twitter_card: summary breadcrumbs: listId: '' listClass: '' itemClass: '' linkRel: '' locale: null translation_domain: null viewTemplate: '@DvPageMetadata/breadcrumbs/bootstrap.html.twig' structured_data: enabled: true breadcrumbs: true nodes: { }
base_url is the canonical site root shared by metadata features that need to turn a path into an absolute URL. Values with and without a trailing slash are accepted; PageMetadata::setBaseUrl() strips trailing slashes before storing the value. A missing or empty value is rejected. Relative metadata paths such as /cover.jpg and structured-data identifiers such as /#organization are resolved against this URL; absolute URIs are left unchanged.
The locale and translation_domain configuration keys are retained by the bundle configuration. Set the active translation domain at runtime with PageMetadata::setTransDomain() when translated titles, descriptions, or breadcrumb labels are required.
Canonical URL, canonical language, Open Graph URL, and Twitter card type have no configuration defaults and are normally set per request through the PHP API. canonical.alternate_locales controls which locale variants are rendered as rel="alternate" links when canonical metadata is initialized from a request.
Using PageMetadata
Inject PageMetadata into a controller, event listener, or another service:
use Druidvav\PageMetadataBundle\PageMetadata; final class ArticleController { private PageMetadata $pageMetadata; public function __construct(PageMetadata $pageMetadata) { $this->pageMetadata = $pageMetadata; } public function __invoke(Article $article): Response { $this->pageMetadata ->setTitle($article->getTitle()) ->setDescription($article->getDescription()) ->setImage($article->getCoverUrl()) ->setLinkCanonical($article->getCanonicalUrl()) ->setOgUrl($article->getCanonicalUrl()) ->setOgTwitterCard(PageMetadata::TWITTER_CARD_SUMMARY_LARGE_IMAGE); // Render the response. } }
Setter methods return the same PageMetadata instance and can be chained, except for setTransDomain() and setTitleDelimiter().
Setter injection trait
Autoconfigured Symfony services can use PageMetadataAwareTrait instead of constructor injection:
use Druidvav\PageMetadataBundle\PageMetadataAwareTrait; final class ArticleController { use PageMetadataAwareTrait; }
Constructor injection is preferable when page metadata is a required dependency. The trait is useful for optional integration or existing classes where changing the constructor is inconvenient.
Titles
setTitle() updates both the HTML page title and the Open Graph/Twitter title:
$pageMetadata->setTitle('Article title');
Titles can be composed using a configured delimiter:
use Druidvav\PageMetadataBundle\PageMetadata; $pageMetadata ->setTitle('Example') ->addTitle('Air quality', [], PageMetadata::MODE_PREPEND); // Air quality - Example
Available modes are:
PageMetadata::MODE_SET— replace all title parts;PageMetadata::MODE_PREPEND— add a part before the existing title;PageMetadata::MODE_APPEND— add a part after the existing title.
Use setPageTitle() and addPageTitle() when only the HTML <title> should change. Use setOgTitle() and addOgTitle() when only Open Graph and Twitter title tags should change.
Descriptions and keywords
setDescription() updates both the standard meta description and the Open Graph/Twitter description:
$pageMetadata->setDescription('Current air quality and pollution measurements.');
They can also be controlled independently:
$pageMetadata->setMetaDescription('Search result description.'); $pageMetadata->setOgDescription('Social sharing description.');
setMetaKeywords() accepts a comma-separated string, trims individual values, and removes duplicates:
$pageMetadata->setMetaKeywords('air quality, Armenia, air quality'); // air quality, Armenia
Translation
Set a translation domain before passing translation IDs:
$pageMetadata->setTransDomain('messages'); $pageMetadata->setTitle('article.page_title', [ '%title%' => $article->getTitle(), ]); $pageMetadata->addRouteItem('navigation.home', 'homepage');
The bundle treats lowercase identifiers containing letters, numbers, _, -, or . as translation IDs. Strings containing % are also passed through the translator. Other strings are used as provided.
An explicit translation domain can be passed to description setters:
$pageMetadata->setDescription('article.description', [], 'articles'); $pageMetadata->setMetaDescription('article.search_description', [], 'articles'); $pageMetadata->setOgDescription('article.share_description', [], 'articles');
Canonical metadata
$pageMetadata ->setLinkCanonical('https://example.com/en/articles/air-quality') ->setLinkCanonicalLang('en') ->setOgUrl('https://example.com/en/articles/air-quality');
The values are available in Twig through page_link_canonical(), page_link_canonical_lang(), and page_og_url().
Canonical and alternate links can be generated from the same request. Route parameters are always retained; query parameters are excluded unless explicitly marked as canonical:
$pageMetadata ->setCanonicalFromRequest($request) ->addCanonicalParameter('page');
The request locale is used for the canonical link. Locales configured under canonical.alternate_locales are generated from the same route and canonical parameters.
When no explicit Open Graph URL is set, og:url uses the canonical URL. setOgUrl() remains available for pages whose Open Graph object intentionally has a different permanent URL.
Open Graph and Twitter
Convenience setters keep common values synchronized:
$pageMetadata->setTitle('Page title'); // HTML title and OG/Twitter title $pageMetadata->setDescription('Summary'); // meta and OG/Twitter description $pageMetadata->setImage($absoluteImageUrl); // OG and Twitter image
Individual fields can be changed separately:
$pageMetadata ->setOgType('article') ->setOgSiteName('Example') ->setOgTitle('Social title') ->setOgDescription('Social description') ->setOgImage('https://example.com/og-cover.jpg') ->setOgTwitterImage('https://example.com/twitter-cover.jpg') ->setOgTwitterSite('@example') ->setOgTwitterCard(PageMetadata::TWITTER_CARD_SUMMARY_LARGE_IMAGE);
Supported Twitter card constants are:
PageMetadata::TWITTER_CARD_SUMMARY;PageMetadata::TWITTER_CARD_SUMMARY_LARGE_IMAGE.
Configure or set og:site_name when Open Graph output is used. The default metadata template renders og:type together with og:site_name.
Breadcrumbs
Add breadcrumb labels and routes in display order:
use Symfony\Component\Routing\Generator\UrlGeneratorInterface; $pageMetadata ->addRouteItem('navigation.home', 'homepage') ->addRouteItem( 'navigation.article', 'article_show', ['slug' => $article->getSlug()], UrlGeneratorInterface::ABSOLUTE_PATH, ['%title%' => $article->getTitle()] );
Arguments of addRouteItem() are:
- label or translation ID;
- Symfony route name;
- route parameters;
- URL reference type, defaulting to
ABSOLUTE_PATH; - translation parameters for the label.
Render the default breadcrumb namespace with:
{{ page_breadcrumbs() }}
Template options can be overridden for one rendering:
{{ page_breadcrumbs({
listId: 'page-breadcrumbs',
listClass: 'mb-4',
itemClass: 'text-secondary',
linkRel: 'nofollow'
}) }}
The default template contains Bootstrap 4/5-compatible markup. A Bootstrap 3 variant is available at @DvPageMetadata/breadcrumbs/bootstrap3.html.twig. A custom template receives the configured options and a breadcrumbs array containing Breadcrumb objects with text and url properties.
Breadcrumb namespaces are supported by getNsBreadcrumbs() and by the rendering function's namespace option. Adding items to a custom namespace requires extending PageMetadata and using its protected addBreadcrumbToNs() method. The public addRouteItem() method adds to the default namespace.
Structured data
The bundle renders Schema.org data as one JSON-LD object containing a single @context and an @graph with all configured and dynamic nodes.
Static nodes
Site-wide nodes can be configured once:
dv_page_metadata: base_url: '%env(DEFAULT_URI)%' structured_data: enabled: true breadcrumbs: true nodes: organization: '@type': Organization '@id': '/#organization' name: Example url: '/' logo: '/logo.jpg' website: '@type': WebSite '@id': '/#website' name: Example url: '/' publisher: '@id': '/#organization' inLanguage: [en, fr]
The YAML keys organization and website are internal node names. They are not included in the generated JSON-LD.
Dynamic nodes
Add page-specific data through PageMetadata:
$pageMetadata->setStructuredData('article', [ '@type' => 'BlogPosting', '@id' => $article->getCanonicalUrl() . '#article', 'headline' => $article->getTitle(), 'description' => $article->getDescription(), 'datePublished' => $article->getPublishedAt(), 'dateModified' => $article->getModifiedAt(), 'publisher' => [ '@id' => 'https://example.com/#organization', ], ]);
Calling setStructuredData() with the same internal name replaces the previous node without changing its graph position. This can be used to override a node loaded from configuration.
The node-level @context key is removed because the bundle owns the graph context. Other Schema.org properties remain generic, so the API can represent any current or future Schema.org type.
Date and value normalization
Any DateTimeInterface value is converted recursively to DATE_ATOM ISO 8601 format:
$pageMetadata->setStructuredData('event', [ '@type' => 'Event', 'startDate' => new DateTimeImmutable('2026-08-01 10:00:00+04:00'), 'subEvent' => [ 'startDate' => new DateTime('2026-08-01 12:00:00+04:00'), ], ]);
Structured values may contain strings, integers, floats, booleans, null, nested arrays, and DateTimeInterface instances. Strings starting with / or # are resolved against base_url; absolute URIs and other strings are preserved. Unsupported objects or resources cause an InvalidArgumentException whose message includes the property path.
Removing and reading nodes
$pageMetadata->removeStructuredData('article'); $pageMetadata->clearStructuredData(); $namedNodes = $pageMetadata->getStructuredData(); $graph = $pageMetadata->getStructuredDataGraph(); $graphWithoutBreadcrumbs = $pageMetadata->getStructuredDataGraph(false);
getStructuredData() preserves internal names. getStructuredDataGraph() returns the sequential array used as @graph and optionally appends generated breadcrumb data.
BreadcrumbList
When structured_data.breadcrumbs is enabled and the default namespace contains at least two breadcrumbs, the bundle appends a BreadcrumbList node automatically. Relative breadcrumb paths use the global base_url; the current request is not used to determine the host.
Rendering and escaping
Structured data is included automatically by page_meta():
{{ page_meta() }}
page_structured_data() is available for custom head templates that render individual metadata functions instead of calling page_meta():
<title>{{ page_title() }}</title> {{ page_structured_data() }}
Do not call both page_meta() and page_structured_data() in the same document, because page_meta() already includes the JSON-LD block.
JSON is encoded with Unicode and URLs left readable, while HTML-sensitive characters are escaped to prevent values such as </script> from leaving the JSON-LD script element.
Autotext integration
The optional meniam/autotext package can generate title, description, and keyword variants:
composer require meniam/autotext
Available methods are:
$pageMetadata->setTitleAutotext($title, [], $stableId); $pageMetadata->setMetaDescriptionAutotext($description, [], $stableId); $pageMetadata->setMetaKeywordsAutotext($keywords, [], $stableId);
The optional stable ID controls deterministic variant selection according to the Autotext package behavior.
When the optional package is not installed, calling any Autotext method throws a LogicException containing the installation command instead of causing a missing-class error.
Twig functions
| Function | Result |
|---|---|
page_meta() |
Complete metadata HTML, including structured data |
page_breadcrumbs(options = {}) |
Rendered breadcrumb HTML |
page_structured_data() |
JSON-LD script for custom metadata templates |
page_locale_url(request, locale) |
Absolute request-route URL in another locale, preserving all query parameters |
page_title() |
Composed HTML page title |
page_description(default = null) |
Meta description or supplied default |
page_keywords(default = null) |
Meta keywords or supplied default |
page_link_canonical() |
Canonical URL |
page_link_canonical_lang() |
Canonical language value |
page_link_canonical_alternates() |
Locale-to-URL map generated from canonical request metadata |
page_og_type() |
Open Graph type |
page_og_url() |
Open Graph URL |
page_og_site_name() |
Open Graph site name |
page_og_image() |
Open Graph image |
page_og_title() |
Composed Open Graph/Twitter title |
page_og_description() |
Open Graph/Twitter description |
page_twitter_image() |
Twitter image |
page_twitter_site() |
Twitter account |
page_twitter_card() |
Twitter card type |
Custom templates
The built-in templates are:
@DvPageMetadata/meta.html.twig— complete metadata output;@DvPageMetadata/structured_data.html.twig— JSON-LD script wrapper;@DvPageMetadata/breadcrumbs/bootstrap.html.twig— Bootstrap 4/5 breadcrumbs;@DvPageMetadata/breadcrumbs/bootstrap3.html.twig— Bootstrap 3 breadcrumbs.
Configure breadcrumbs.viewTemplate to use an application template. Symfony's standard bundle template overriding mechanism can also be used to customize metadata output globally.
Testing
Install development dependencies and run:
composer install
composer test