lara-zeus/laravel-seo
Lara Zeus Laravel SEO is a lightweight toolkit for adding search-friendly meta tags, Open Graph and Twitter cards, canonical URLs, robots directives, sitemaps, and structured data to Laravel apps, with sensible defaults and simple per-page overrides. Keep titles, descriptions, and social previews co...
Install and publish.
composer require lara-zeus/laravel-seo
php artisan vendor:publish --tag="laravel-seo-config"
The publish tag is the first thing to verify — the README is minimal and the v1.0.1 changelog is entirely Dependabot bumps, so treat src/, config/, and composer.json as the real documentation. Run php artisan vendor:publish with no tag and pick the laravel-seo provider from the list if the tagged variant fails.
Read these four things before writing code (30 minutes, saves a day):
composer.json — PHP/Laravel constraints and whether it transitively pulls Filament or other Lara Zeus packages. For an API-only or non-Filament app this single file decides whether the package is a two-line dependency or a stack commitment.src/ — locate the service provider, any facade, and the builder class. Every method name below (title(), description(), canonical(), image(), type()) is illustrative; confirm each exists with the signature you expect.database/migrations/ — if the package ships a polymorphic SEO table, you inherit a schema you must migrate, back up, and eventually own. If it doesn't, it's a pure builder over data you already have and is essentially free to remove later. This is the difference between "one afternoon" and "a schema decision."First use case: a content detail page. Wire one render point into your layout <head>:
{{-- resources/views/layouts/app.blade.php --}}
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@seo {{-- confirm exact directive/component name in src/ --}}
@vite(['resources/css/app.css'])
</head>
Then set metadata in the controller before returning the view:
public function show(Post $post)
{
seo()
->title($post->title)
->description(Str::limit(strip_tags($post->excerpt), 155))
->canonical(route('posts.show', $post))
->image(asset('storage/'.$post->hero_path))
->type('article');
return view('posts.show', compact('post'));
}
Load the page, view source, and confirm: tags present, tags absolute, and exactly one of each. That single check catches the most common integration failure before it reaches staging.
Config defaults + surgical per-page overrides. Configure site name, title suffix, fallback description, and default OG image in the config. Controllers only override what differs. New pages with no SEO work still ship credible tags, and your diff stays two lines per method.
Never call the package API from Blade directly. Wrap it.
// app/Support/Seo/PageSeo.php
final class PageSeo
{
public static function forModel(Model $model): void
{
seo()
->title($model->seoTitle())
->description($model->seoDescription())
->canonical($model->seoCanonical())
->image($model->seoImage());
}
public static function defaults(string $title): void
{
seo()->title($title)->canonical(url()->current());
}
}
One file to edit if you fork, vendor, or swap in artesaos/seotools. With a single-maintainer, effectively unproven package, reversibility is the whole value proposition — don't spend it by spreading seo() calls across fifty views.
Push metadata onto the models, not into every controller. A trait with three accessors beats repetition:
trait HasSeo
{
public function seoTitle(): string
{
return $this->meta_title ?: $this->title;
}
public function seoDescription(): string
{
return Str::limit(strip_tags($this->meta_description ?: $this->body), 155);
}
public function seoImage(): ?string
{
return $this->hero_path ? asset('storage/'.$this->hero_path) : null;
}
}
If the package ships its own Eloquent trait, read it first and decide whether it adds anything over these three accessors — and critically, whether it writes to a table you'd then have to own.
Middleware for route-group defaults. For a marketing or docs section where every page shares canonical and a section title prefix, set it in middleware rather than repeating in fifteen controllers. Page controllers refine afterwards. This is also the right place to enforce "canonical is always absolute and always the current URL."
public function handle(Request $request, Closure $next)
{
$response = $next($request);
seo()->canonical($request->url());
return $response;
}
Handle Inertia and SPA front ends deliberately. The directive renders server-side in Blade only. With Inertia, your root template renders once, so per-page meta has to come from a shared prop passed via HandleInertiaRequests and injected into the root Blade view — or you manage <head> client-side with a separate library and use this package only for the initial server render. Decide before building; retrofitting is painful.
Caching layers. If you use full-page or response caching, rendered meta freezes with the HTML. Either key the cache on the SEO-relevant model state (updated_at), or set meta in middleware that runs before the cache write. Fragment caching around the <head> is usually the wrong trade — meta is cheap.
Snapshot-test the output in CI. Non-negotiable for a low-signal dependency:
it('renders meta for a post', function () {
$post = Post::factory()->create(['title' => 'Hello & "World"']);
$this->get(route('posts.show', $post))
->assertOk()
->assertSee('<link rel="canonical"', false)
->assertSee('og:title', false)
->assertSee('twitter:card', false);
});
Note the deliberately nasty title — it exercises escaping and will fail loudly if the package double-escapes or under-escapes.
Filament / Lara Zeus leverage. If you're already on that admin stack, the payoff is real: editors own titles and descriptions through familiar form fields, and the same maintainer's package renders them. No new admin paradigm, no second supply chain. If you're not on that stack, confirm Filament isn't dragged in before committing.
app.blade.php plus an override component in posts/show.blade.php gives you two <title> tags and two og:image tags; crawlers pick one unpredictably. Enforce one render point and grep for the directive during code review.", &, and an emoji on day one. If the directive escapes by default, don't double-wrap it in e(). If it doesn't escape, you have an XSS vector via user-supplied meta descriptions — check the source, don't assume.route() can emit http:// or an internal hostname. Configure TrustProxies and verify APP_URL before trusting any canonical. A wrong canonical is worse than none.og:image must be absolute. Relative paths are silently dropped by most scrapers. Always pass asset() or url(), and size to 1200×630.— Site Name and the controller also appends it, you get it twice. Rule: config owns the suffix, controllers pass the bare title.php artisan config:cache locally to match production, or you'll chase a "config ignored" bug that only exists in your dev environment.src/ proves otherwise. If locales are on the roadmap, model translated metadata yourself and use this package only to render it.<head>, not swallowed by a @section/@push mismatch; (2) config published and cached correctly; (3) the seo() call runs before return view() — a call after the view renders does nothing; (4) the parent layout @yields / @stacks the right section; (5) a middleware or response listener isn't clearing the builder state.Blade::macro wrapper, and the config stub's default templates. Most "we need to change how this renders" requests are solvable at these three seams instead of editing vendor code."lara-zeus/laravel-seo": "1.0.*" and assign someone to smoke-test upgrades against your snapshot tests. The exit cost is genuinely low — a trait, a presenter, one directive — so keep it that way by never letting package calls escape your PageSeo wrapper.How can I help you explore Laravel packages today?