Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Seotools Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and wire up (Laravel 5.5+ / 11+)

composer require artesaos/seotools
php artisan vendor:publish --provider="Artesaos\SEOTools\Providers\SEOToolsServiceProvider"
  • Laravel ≤10: nothing else to do (package discovery registers SEOToolsServiceProvider). If you disable discovery, add it to config/app.php.
  • Laravel 11+: add Artesaos\SEOTools\Providers\SEOToolsServiceProvider::class to bootstrap/providers.php.
  • Lumen: $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.
  • Optional but common: short aliases (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.

Implementation Patterns

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.

Gotchas and Tips

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 &quot;. 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:

  • Echoing generate() twice duplicates tags.
  • Setting a value in a controller and again in a view composer: last writer wins, silently.
  • Under Octane / long-running workers, state persists between requests unless the container is flushed — you'll see page A's title on page B. Fix by binding the services as scoped, forgetting the instances in a middleware, or rebuilding them per request.
  • Prefer 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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
thuraaung2493/laravel-rulecraft
cyberwizard/schedule-catchup
livetennisapi/livetennisapi-laravel
roundly-consulting/notifications-for-laravel
flightsadmin/bootstrap-starter-kit
lasserafn/laravel-intempus
likyly/sdk
tdt/input
coreation/php-shapefile
coreation/gisconverter
primix/widgets
primix/notifications
primix/actions
primix/tables
primix/details
primix/forms
primix/support
marcelgwerder/laravel-api-handler
firebrikdotcom/conveyor-laravel-broadcaster
kanata-php/conveyor-laravel-broadcaster