honeystone/laravel-seo
Honeystone Laravel SEO provides a fluent API for adding SEO metadata to Laravel apps, including titles, descriptions, canonical URLs, robots directives, Open Graph and Twitter Card tags, and JSON-LD structured data, with simple Blade rendering support.
Install and publish the config, then drop the render tag into your app layout's <head>.
composer require honeystone/laravel-seo
php artisan vendor:publish --tag=seo-config # if the tag isn't listed, run `php artisan vendor:publish` and pick the Honeystone provider
{{-- resources/views/layouts/app.blade.php --}}
<head>
...
<x-seo::meta />
{{-- or, depending on version: {!! seo() !!} --}}
</head>
Configure site-wide defaults in config/seo.php — title suffix/prefix, default description, fallback image, Twitter handle, robots policy, and (optionally) a per-route map of titles/descriptions keyed by route name.
First use case — a static marketing page. In the controller or a route model binding callback:
use Honeystone\Seo\Facades\Seo;
public function pricing()
{
Seo::title('Pricing — Acme')
->description('Simple, transparent pricing for teams of every size.')
->canonical(url()->current());
return view('pricing');
}
Where to look first in the source:
config/seo.php — defaults, route map, any schema base nodes.Honeystone\Seo\SeoManager (the class behind the facade) — the actual API surface; read this rather than guessing method names.Honeystone\Seo\HasSeo trait — only if you need per-model metadata.Defaults in config, overrides in code. Set the title suffix, description, robots, image, and Twitter handle once in config/seo.php. Every page inherits them; only override the parts that differ. This keeps controllers thin and avoids forgotten OG tags.
Route-name map for editor-owned copy. Many teams put titles/descriptions for marketing pages in the routes array of config/seo.php so non-developers can change copy without touching code. Resolve it once per request (middleware or a Route::matched listener) via the manager's route-resolution helper, then let controllers override the dynamic bits.
Model trait for content-heavy resources. For blog posts, products, and landing pages, add the package's HasSeo trait to the Eloquent model and define the fields it reads (title, description, image, etc.). Then in the controller:
Seo::fromModel($post); // consult the manager class for the exact method name in your version
This is the biggest day-to-day win: editors set SEO fields in the CMS, and every detail page inherits them automatically, including og:type=article if you configure it.
Middleware for cross-cutting concerns. Use a middleware to set robots based on context (noindex on staging, admin, search results, paginated archives) and to strip tracking query parameters from the canonical:
Seo::robots($request->is('admin/*') ? 'noindex,nofollow' : 'index,follow')
->canonical(url()->current()); // not url()->full()
Structured data. Add JSON-LD via the package's schema builder alongside the meta tags, and keep a base Organization / WebSite node in config so every page shares consistent entity data. For complex graphs, pair with spatie/schema-org and pass the built graph into the package.
Localization. For multi-locale sites, set the locale + hreflang alternates inside the same middleware that handles locale switching — this is the one place where the canonical URL must be locale-aware.
Testing. Assert against the rendered head with raw output, since meta tags are unescaped HTML:
$this->get('/pricing')
->assertSee('<title>Pricing — Acme</title>', false)
->assertSee('og:title', false)
->assertSee('canonical', false);
Add a smoke test that hits your top routes and asserts <title> and og:title are non-empty — that catches broken defaults before a deploy.
Config cache is the #1 "why isn't my title showing." The routes array and defaults live in config, so run php artisan config:clear (or config:cache) after editing them. In production, editing the file is not enough.
The facade is stateful. It's a singleton holding the current request's metadata. In Octane or queued jobs, verify the package resets between requests; if not, set every field explicitly per request rather than relying on defaults carrying over.
Precedence confusion. Mixing route-config resolution, middleware, and controller calls creates "which one wins?" bugs. Pick one source of truth per page (config route map for static, controller/model for dynamic) and stick to it.
Canonical discipline. Use url()->current(), never url()->full() — utm parameters, pagination, and sort params create duplicate-content noise. Decide explicitly how paginated archives should canonical (page 1 self, or page 1 → base URL).
Images must be absolute. Crawlers, especially Twitter's, won't follow relative paths. Wrap in asset() or Storage::url() before passing to image().
Don't wrap the render tag in {{ }}. {{ seo() }} escapes HTML and dumps the tags as visible text. Use the Blade component or {!! !!}.
Descriptions aren't truncated for you. Enforce ~155 chars at the data source; a long meta description gets chopped mid-sentence or ignored by Google, which rewrites it anyway.
Decide on "empty vs. null." An empty <title></title> is worse than falling back to the site default. Normalize empty strings to null before passing values in, or the fallback chain won't trigger.
Model attribute collisions. If your model already has title, description, or image columns, the trait may auto-pick them up — convenient, but easy to confuse with SEO-specific columns (e.g., meta_title, meta_description). Map them explicitly rather than relying on guessing.
Twitter card sizing. If you set image() per page, keep dimensions consistent (1200×630 for large cards) or Twitter falls back to a small card with a cropped image.
Don't repeat the site description on every page. Set the default, then override per page. Otherwise every SERP snippet looks identical and duplicate snippets hurt CTR.
Extension points. Publish the views to customize tag order/templates — some teams need <meta property> vs <meta name> tweaks for older crawlers. If you need custom behavior beyond config, swap the manager class in the container binding rather than forking the package.
Sitemaps are not this package's job. Pair with spatie/laravel-sitemap (or similar) and drive it from the same data source as your per-model SEO fields so they don't drift.
How can I help you explore Laravel packages today?