butschster/meta-tags
Laravel package for managing SEO meta tags with a fluent API. Define page titles, descriptions, canonical URLs, Open Graph and Twitter Card tags, and custom meta tags, with Blade directives, JSON-LD structured data, and per-page configuration.
Install the package and publish its config:
composer require butschster/meta-tags
php artisan vendor:publish --tag=meta-tags-config # or the provider tag shown in the README
The service provider is auto-discovered on Laravel 5.5+. If you're on an older scaffold (or a package that disables discovery), register Butschster\MetaTags\MetaTagsServiceProvider in config/app.php and add the MetaTag facade alias.
Next, open your layout's <head> and render the collected tags once:
{{-- resources/views/layouts/app.blade.php --}}
<head>
<meta charset="utf-8">
{!! MetaTag::render() !!}
</head>
Then set tags from wherever you have the data — a controller, a view composer, or the Blade view itself:
// app/Http/Controllers/PostController.php
public function show(Post $post)
{
MetaTag::set('title', $post->title);
MetaTag::set('description', Str::limit(strip_tags($post->excerpt), 155));
MetaTag::set('image', $post->cover->url());
return view('posts.show', compact('post'));
}
Your first end-to-end use case is a blog/detail page: set title, description, and image on the controller action, render in the layout, and check the page source to confirm the OG/Twitter tags land where you expect. That single flow (defaults in layout → overrides in controller) is the pattern you'll repeat everywhere.
Where to look first:
config/meta_tags.php after publishing — this is where site-wide defaults, OpenGraph/Twitter toggles, and separators live.MetaTag facade class if you need to know what set()/get()/render() actually return.Site-wide defaults in a base layout or middleware. Set og:site_name, twitter:site, default image, and robots once, then override per page. Doing it in a View::composer('layouts.*') or a SetDefaultMetaTags middleware keeps controllers clean and guarantees every response has sane values:
// app/Http/Middleware/DefaultMetaTags.php
public function handle($request, Closure $next)
{
MetaTag::set('og:site_name', config('app.name'));
MetaTag::set('twitter:card', 'summary_large_image');
MetaTag::set('robots', 'index,follow');
return $next($request);
}
Defaults first, overrides second. Because the package stores tags keyed by name, call the default-setting code before the per-action code, or use the config file's defaults rather than calling set() twice. Order of set() calls is your override mechanism — the last write wins for a given key.
Keep model formatting out of the controller. If a model has SEO concerns, add a small method or a dedicated presenter:
// app/Models/Post.php
public function metaTags(): array
{
return [
'title' => $this->title,
'description' => Str::limit(strip_tags($this->excerpt), 155),
'image' => $this->cover?->url(),
'og:type' => 'article',
];
}
foreach ($post->metaTags() as $name => $value) {
MetaTag::set($name, $value);
}
Blade partial for page-specific tags. When the meta data is only known inside the view (e.g. inside a @foreach or after expensive eager loads), set at the top of the view rather than in the controller. It's still rendered before the layout's <head> because Blade compiles the child view first — but don't rely on this from components that render later in the body.
OpenGraph and Twitter as first-class. OG titles are usually not the same as the <title> (you often want og:title without the site suffix). Set both explicitly:
MetaTag::set('title', $post->title . ' — ' . config('app.name'));
MetaTag::set('og:title', $post->title);
MetaTag::set('og:description', $post->excerpt);
MetaTag::set('og:image', $post->cover->url());
MetaTag::set('og:url', $post->canonicalUrl());
MetaTag::set('twitter:title', $post->title);
Canonical URLs and paginated pages. Set canonical on every indexable page and vary it for pagination (?page=2) where appropriate. MetaTag::set('canonical', $url) is enough if the package renders it as <link rel="canonical">; if not, render it manually alongside {!! MetaTag::render() !!}.
JSON-LD alongside the package. The package handles meta/link tags, not structured data. Add a Blade stack for @push('schema') and emit your own <script type="application/ld+json">. Don't try to force Article/BreadcrumbList through MetaTag.
Inertia/Livewire caution. For a fully client-rendered SPA, these server-side tags will only ever describe the initial shell. Either accept that (and let the frontend manage <head>), or add SSR. For Livewire, re-set tags in updated()/mount() because navigating between components doesn't rerun the controller.
{!! MetaTag::render() !!}, never {{ }}. render() returns HTML; escaping it will print <meta ...> as visible text in the page. This is the single most common "it's broken" report.description, title, and image typically flow straight into attribute values. Run them through e() or Blade's {{ }} semantics before handing to set(), otherwise a post title containing " breaks the tag.og:image. asset('...') is fine, but a relative path or a Storage::url() without a domain will fail most crawlers and social debuggers. Use url()/asset() or a configured CDN base.php artisan config:cache in deploy scripts is expected; if tags vanish or defaults look stale, php artisan config:clear && php artisan cache:clear before assuming a bug.dump(MetaTag::get('title')) tells you what was set, but the rendered order is what crawlers see. Always verify with "View Page Source" and a couple of social debuggers (Facebook Sharing Debugger, X Card Validator).title, whichever runs last wins — silently. Name your tag keys consistently (title vs og:title vs meta_title) or you'll get duplicate/conflicting tags.<head>, or include the meta data in the cache key.remove() is your friend for opt-outs. For "noindex" admin or filtered listing pages, explicitly MetaTag::remove('canonical') and set robots to noindex,nofollow rather than leaving defaults in place.MetaTag::set() with arbitrary names (the renderer emits them as <meta name="...">). If you need <link> tags beyond canonical, either extend the package's view or render those manually — don't fight the tool.AppServiceProvider::boot() unconditionally. Setting tags globally there runs even for API routes and queues; scope to web routes/middleware so jobs don't accumulate meta state.assertSee('<meta property="og:title"', false) on your post page catches the escaping and ordering mistakes that are otherwise only visible in production.How can I help you explore Laravel packages today?