artesaos/seotools
SEOTools provides Laravel and Lumen helpers for common SEO tasks: setting titles and meta tags, Twitter Cards, Open Graph, and JSON-LD structured data through a simple, friendly interface.
Install and wire up (Laravel 5.5+ / 11+)
composer require artesaos/seotools
php artisan vendor:publish --provider="Artesaos\SEOTools\Providers\SEOToolsServiceProvider"
SEOToolsServiceProvider). If you disable discovery, add it to config/app.php.Artesaos\SEOTools\Providers\SEOToolsServiceProvider::class to bootstrap/providers.php.$app->register(...) in bootstrap/app.php, copy src/resources/config/seotools.php to config/seotools.php manually, and use app('seotools') — facades are not supported.SEOMeta, OpenGraph, Twitter, JsonLd, JsonLdMulti, SEO) in config/app.php.Where to look first: config/seotools.php. Each service (meta, opengraph, twitter, json-ld) has a defaults block that renders when you don't set anything, plus meta.webmaster for verification tags. Set false on any default you don't want emitted.
First use case — put the render call in your layout, set values in the controller.
resources/views/layouts/app.blade.php:
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
{!! SEOTools::generate() !!}
</head>
app/Http/Controllers/PostController.php:
public function show(Post $post)
{
SEOMeta::setTitle($post->title);
SEOMeta::setDescription(Str::limit(strip_tags($post->resume), 155));
SEOMeta::setCanonical($post->url());
OpenGraph::setTitle($post->title)
->setDescription(Str::limit(strip_tags($post->resume), 155))
->setUrl($post->url())
->setType('article')
->addImage($post->cover_url, ['width' => 1200, 'height' => 630]);
TwitterCard::setType('summary_large_image')
->setTitle($post->title)
->setImage($post->cover_url);
return view('posts.show', compact('post'));
}
SEOTools::generate() returns the concatenated meta + Open Graph + Twitter + JSON-LD output as a string. Render services individually ({!! SEOMeta::generate() !!}) when you want to control ordering or skip one.
1. Layout owns rendering, controllers own data. Keep exactly one {!! ... !!} block in the <head> partial. If you render in more than one place you will get duplicated og:title/canonical tags, and crawlers will pick unpredictably.
2. Global defaults via middleware or view composer, page data via controller. Anything set in a view composer runs after the controller and silently overwrites it. Pick one precedence rule and stick to it — e.g. composer sets site-wide fallbacks only when a value is missing.
3. Encapsulate in one place. For anything past a toy app, build a SeoPresenter/DTO so controllers stay thin:
class SeoBuilder
{
public function apply(SeoData $data): void
{
SEOMeta::setTitle($data->title, $data->appendSiteName)
->setDescription($data->description)
->setCanonical($data->canonical);
OpenGraph::setTitle($data->title)
->setDescription($data->description)
->setUrl($data->canonical)
->setImages($data->images);
JsonLd::setType($data->schemaType)
->setTitle($data->title)
->setDescription($data->description)
->setUrl($data->canonical);
}
}
Back it with a HasSeo trait or seo() accessor on models (Article, Product, Page) so seo() returns a hydrated DTO and each model owns its own schema type.
4. Multiple JSON-LD blocks: JsonLdMulti is a cursor over a list. Call newJsonLd() to start a new block, otherwise subsequent setType()/setTitle() calls mutate the first object.
JsonLdMulti::setType('Article')->setTitle($post->title)->addImage($post->cover_url);
if (! JsonLdMulti::isEmpty()) {
JsonLdMulti::newJsonLd();
JsonLdMulti::setType('BreadcrumbList')->setTitle('Breadcrumb');
}
5. Rich Open Graph types. For articles/products use the typed setters — setArticle([...]), setBook([...]), setProfile([...]), setVideoMovie([...]) — plus addVideo() / addAudio() for media. addProperty() is the escape hatch for anything not covered (addProperty('locale:alternate', ['pt-pt', 'en-us']) fans out into multiple tags).
6. Blade component for reusable pages. <x-seo :model="$post" /> calling the builder in booted()/render() keeps marketing landing pages consistent without touching controllers.
7. What this package does not do: sitemaps and robots.txt. Pair it with spatie/laravel-sitemap and set SEOMeta::setRobots('noindex,follow') for thin pages (search results, filtered listings, staging).
8. Testing. Assert on the rendered HTML in feature tests ($response->assertSee('<meta name="description"', false)), or unit-test the builder by calling SEOMeta::generate() and asserting the string. This is the cheapest regression net for SEO — someone will delete the <head> block during a redesign.
Nothing renders unless you echo it. The package only builds strings. A missing {!! SEOTools::generate() !!} — or putting it after @yield('content') — is the #1 "it doesn't work" cause.
Use {!! !!}, and sanitize anyway. {{ }} double-escapes quotes into ". Conversely, the generators do not strip HTML from titles/descriptions, so a post title containing < or a stray " can break the tag. Normalize input once: strip_tags(), collapse whitespace, Str::limit() to ~60 chars for titles / ~155 for descriptions.
The services are container singletons. Values accumulate across the whole request. Consequences:
generate() twice duplicates tags.scoped, forgetting the instances in a middleware, or rebuilding them per request.setProperty() (overwrite) over addProperty() (append) when you're not intentionally emitting multiple values.Empty defaults are a common false alarm. config('seotools.meta.defaults.description') being null/empty means no tag; false explicitly disables. Check the published config before debugging code.
Absolute URLs only for og:image and canonical. Storage::url() gives /storage/...; wrap with url() or asset(). Facebook/Twitter/Slack silently drop relative image paths. Always pass width/height for summary_large_image (addImage($url, ['width' => 1200, 'height' => 630])) or you'll get a generic preview.
setTitle() appends the configured default title. SEOMeta::setTitle('Home') yields Home - Site Name; pass false as the second argument (setTitle('Home', false)) to stop that for the homepage, and set the glue once with SEOMeta::setTitleSeparator('|').
Config caching freezes defaults. After editing config/seotools.php run php artisan config:cache (and config:clear when debugging). Avoid putting closures or url()/request() calls into that config file, or the cached config will serve stale values.
meta.webmaster must be populated. null means no verification tags at all — that's the intended "off" switch, not a bug.
JsonLd::addImage() accepts a string or an array; passing an array of URLs from a collection works ($post->images->pluck('url')->all()), but a single nested array will be JSON-encoded as one object. Dump JsonLd::generate() and validate with Google's Rich Results Test rather than eyeballing it.
Localization / hreflang isn't a first-class helper. Canonical is built in; for hreflang, prev/next, and other <link rel> tags you'll emit them yourself or extend SEOMeta and rebind seotools.metatags in a service provider — that's the cleanest extension point if you need custom methods, since the facades resolve by container key.
Lumen differences bite during copy-paste. No facades, no vendor:publish, no config/app.php aliases — any tutorial snippet using SEOMeta:: must become app('seotools.metatags')->.
Debugging loop: php artisan tinker → SEOMeta::setTitle('x'); echo SEOMeta::generate();. If the string is correct there but wrong in the browser, the problem is your Blade block, the composer ordering, or a full-page cache serving a different page's head.
How can I help you explore Laravel packages today?