torann/laravel-meta-tags
Torann Laravel Meta Tags helps Laravel apps define and render page metadata from PHP or Blade, with support for titles, descriptions, Open Graph-style tags, custom tags, and configurable defaults for clean layout output.
Install and publish the config. The package is a facade + config + one Blade call. There is nothing else to wire.
composer require torann/laravel-meta-tags
php artisan vendor:publish --provider="Torann\LaravelMetaTags\MetaTagsServiceProvider"
Auto-discovery registers the service provider and the MetaTag facade alias on Laravel 5.5+, so no config/app.php edits. Confirm the published config/meta-tags.php exists — that file is the package. Everything else is a thin registry on top of it.
Look at the config first, not the README. The config file is where the package's real surface lives: defaults (site-wide title, description, keywords, image), open_graph (site name, type, image, URL base), twitter (site handle, card type), and templates (wrapping rules applied to values). Read it top to bottom before writing a single MetaTag::set() call; most "why is my tag wrong" questions are answered there.
Render once, in the layout <head>.
{{-- resources/views/layouts/app.blade.php --}}
<head>
<meta charset="utf-8">
{!! MetaTag::render() !!}
</head>
render() emits every registered tag as a block of <meta>/<title> markup. That single line replaces every hardcoded <title> and <meta name="description"> scattered across your views.
First use case: a public marketing or content site where <title> is currently hardcoded in five different layouts. Set the org defaults in config/meta-tags.php (site name, default description, default OG image, Twitter handle, canonical base URL), drop {!! MetaTag::render() !!} into the shared head partial, and override per route from the controller:
use Torann\LaravelMetaTags\Facades\MetaTag;
public function show(Product $product)
{
MetaTag::set('title', $product->name);
MetaTag::set('description', Str::limit($product->summary, 155));
MetaTag::set('image', $product->image_url); // absolute URL
MetaTag::set('canonical', $product->canonicalUrl());
return view('products.show', compact('product'));
}
That is the whole day-one workflow. If it takes more than an hour to get a correct <head> out of this, something is wrong.
Controller-set, layout-rendered is the canonical pattern. Tags are cross-cutting state: a controller (or middleware) writes into the registry, a shared <head> partial reads it once. Do not scatter MetaTag::set() calls across partials — the call site becomes impossible to reason about when two partials fight over title.
Middleware for route-family defaults. Anything that is true for a whole route group (canonical base, OG type article vs website, robots noindex on staging or filtered listings) belongs in a middleware that runs before the controller. The controller then overrides only the page-specific values.
public function handle($request, Closure $next)
{
MetaTag::set('og:type', $request->routeIs('blog.*') ? 'article' : 'website');
MetaTag::set('canonical', $request->url());
if (app()->environment('staging')) {
MetaTag::set('robots', 'noindex,nofollow');
}
return $next($request);
}
Per-view overrides in the child view, not the layout. Blade evaluates the child view before the layout, so a @php block at the top of a view (after @extends) runs before {!! MetaTag::render() !!} fires. This is fine for one-off pages but is worse than the controller pattern for anything model-backed, because the data now has two homes.
Centralize composition in a small service. The package is a key/value store — it does not know what a Product is. Wrap the mapping in one place so the title/description/image rules are testable and greppable:
class ProductMeta
{
public static function apply(Product $p): void
{
MetaTag::set([
'title' => $p->name,
'description' => Str::limit(strip_tags($p->summary), 155),
'image' => $p->og_image, // already absolute
'canonical' => route('products.show', $p),
'og:type' => 'product',
]);
}
}
Call ProductMeta::apply($product) from the controller. When marketing changes the description rule six months from now, it is one file.
Config-driven defaults handle multi-brand / multi-locale. This is the package's strongest fit. Set site_name, default description, default image, and Twitter handle in config (or per-tenant config in a multi-tenant app), then override the per-page bits. Every URL gets a coherent tag set even if the controller forgets to set one — which is exactly the failure mode hardcoded <head> markup produces.
Always set absolute URLs for image and canonical. The package renders what you give it. A relative /images/hero.png produces an OG image that Facebook and X will silently drop. Build a helper or a model accessor ($product->ogImage()) that calls url()/asset() and use only that.
Keep your own escaping at the boundary. Values derived from user content (listing titles, user display names, CMS fields) must be escaped before MetaTag::set(), because render() is raw output. Use e() on the string when setting, or wrap the whole set call:
MetaTag::set('description', e($user->bio));
Testing pattern. Feature tests should assert on rendered head content, not on the registry:
$response->assertSee('<meta property="og:title"', false);
$response->assertSee('Canonical Product Name');
For an existing app, use the diff-against-baseline approach: capture curl -s https://staging/... | grep -E '<meta|<title' for a representative route sample before and after, and treat the diff as the acceptance test.
When you need something the package doesn't do — JSON-LD, hreflang, sitemap links, admin-editable metadata — do not fight the package. Emit those directly in the layout outside MetaTag::render(). The package explicitly scopes to flat <meta> tags; extending it there is more work than a 30-line Blade partial.
Raw rendering is the sharpest edge. {!! MetaTag::render() !!} does not escape attribute values. A product name containing "><script> becomes markup. Test this with an adversarial string on day one, before any user-derived value touches the package. If it is unescaped and you cannot guarantee clean inputs, wrap render() in a thin helper that escapes the values in the registry first.
Ordering matters and is easy to get wrong. If MetaTag::set() runs after the layout has already rendered, the tag is silently lost — no error, no log. Symptoms: "works in the controller, doesn't work in a @section", or "works on the first page, not after a redirect". Rule of thumb: set in controller or middleware, never in a partial that renders inside the layout's body.
No dedupe guarantee. Setting title twice, or setting og:title separately from title, can produce competing tags. The package centralizes markup but does not appear to enforce uniqueness. Keep the "who owns title" rule explicit in code review — one call site per key, typically the controller after middleware defaults.
config:cache is a trap for the first deploy. After publishing and editing config/meta-tags.php, run php artisan config:clear locally. In production, a config edit without config:cache will keep serving the old defaults and the diff will look like the package ignoring you.
Relative URLs pass silently. Neither canonical nor image is absolutized. Facebook Sharing Debugger and the X Card validator are the only real tests for this — run them against staging, not localhost, for a sample of routes (home, detail, article, paginated, localized).
The >= Laravel constraint in 3.0.9 is a live risk. Composer will happily install this on Laravel 10/11/12 with no evidence it was exercised there. Pin it at the application level ("torann/laravel-meta-tags": "3.0.*") rather than inheriting the open constraint, and re-qualify it as an explicit line item on every future framework major upgrade.
Last release 2021, no dependents. Treat it as code you own. The BSD-2-Clause license and the tiny surface make it fork-friendly; the correct posture is "vendor it or fork it the moment the first unmet requirement appears," not "wait for upstream." Record that decision somewhere, because it will resurface as an audit finding otherwise.
Debugging tips:
dd(MetaTag::all()) in a controller dumps the registry — faster than view-source.MetaTag::tag('title') returns an individual tag object for inspecting state.og:type, not the renderer.composer require, check composer dump-autoload first, then whether Laravel's package:discover ran (it runs on install, but CI images sometimes skip it).Extension points worth knowing:
templates block in config is how you wrap values consistently (e.g. "{title} — {site_name}"). Change it there, not in each controller.defaults and open_graph/twitter blocks are the clean place to add a new globally-templated tag without touching application code.MetaTag::set('custom-name', ...) — no registration needed.ProductMeta-style service (see above) and the migration becomes "swap the MetaTag::set calls in three service classes."Scope reminders for day-to-day use: no JSON-LD, no sitemap, no hreflang, no robots policy, no admin-editable metadata, no CMS integration. Do not scope any of those against this package — they are separate work. If a ticket says "SEO is handled by the meta-tags package," reject it.
How can I help you explore Laravel packages today?