Lean, Laravel-native SEO tag management

Maintainers

Package info

github.com/Three-Oh-Eight/seo

pkg:composer/three_oh_eight/seo

Transparency log

Statistics

Installs: 311

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.4.0 2026-08-27 13:04 UTC

This package is auto-updated.

Last update: 2026-08-27 13:05:19 UTC


README

Lean, Laravel-native SEO tag management. One facade, one config file, zero bloat.

Built for Laravel 12+ and PHP 8.5+. Octane-safe (scoped binding).

Installation

composer require three_oh_eight/seo

The service provider and Seo facade are auto-discovered.

Publish the config:

php artisan vendor:publish --tag=seo-config

Quick start

Set SEO data anywhere (controller, middleware, Livewire component):

use ThreeOhEight\Seo\Facades\Seo;

Seo::title('Dashboard')
    ->description('Your account overview')
    ->image('https://example.com/og.jpg');

Render in your layout:

<head>
    @seo
</head>

That's it. The @seo directive renders <title>, meta description, canonical, robots, Open Graph, Twitter Card, and JSON-LD tags.

Configuration

All defaults live in config/seo.php:

return [
    'site_name'      => env('APP_NAME', 'My App'),
    'separator'      => ' - ',
    'title'          => null,          // default page title (null = site name only)
    'description'    => null,          // default meta description
    'auto_canonical' => true,          // auto-generate from url()->current()
    'robots'         => null,          // null = don't render robots meta
    'og_type'        => 'website',
    'og_image'       => null,          // fallback OG image
    'twitter_card'   => 'summary_large_image',
    'twitter_image'  => null,          // fallback Twitter image
    'twitter_site'   => null,          // @handle
];

API reference

Basic tags

Seo::title('About Us');                     // <title>About Us - Site Name</title>
Seo::description('Learn about us');         // <meta name="description" ...>
Seo::canonical('https://example.com/about');// <link rel="canonical" ...>
Seo::image('https://example.com/og.jpg');   // og:image + twitter:image
Seo::robots('noindex, follow');             // <meta name="robots" ...>
Seo::noindex();                             // shortcut: "noindex, nofollow"
Seo::meta('author', 'Christoph');           // arbitrary <meta> tags

Titles are formatted as {page title}{separator}{site name}; pass exact: true to skip that:

Seo::title('Standalone Title', exact: true);  // <title>Standalone Title</title>

robots() also accepts the RobotsRule enum, or a mixed array:

use ThreeOhEight\Seo\RobotsRule;

Seo::robots(RobotsRule::NoIndex);
Seo::robots([RobotsRule::NoIndex, 'nofollow']);

Route-level metadata

Declare SEO metadata on routes; values survive route:cache:

Route::get('/pricing', PricingController::class)
    ->seo(title: 'Pricing', description: 'Plans and pricing', ogType: 'website');

Route::get('/internal', InternalController::class)->seo(noindex: true);

// Group syntax (plain scalars only):
Route::group(['seo' => ['robots' => 'noindex']], function () {
    Route::get('/app/dashboard', DashboardController::class);
});

The cascade resolves field by field: runtime Seo::...() calls beat route metadata, route metadata beats config defaults. Supported fields: title, description, robots, canonical, noindex (bool shorthand), ogType. Note: fluent chaining before group() (Route::seo(...)->group(...)) is not supported; use the group-attribute syntax.

Conditionals

Seo is Conditionable:

Seo::when($post->isDraft(), fn ($seo) => $seo->noindex());

Pagination

Seo::prev('https://example.com?page=1');
Seo::next('https://example.com?page=3');

// Or from a paginator:
Seo::paginate($posts);  // auto-sets prev/next from LengthAwarePaginator

Open Graph & Twitter overrides

Override platform-specific titles/descriptions when they should differ from the <title> tag:

Seo::og()->title('Custom OG title');
Seo::og()->description('Custom OG description');
Seo::og()->type('article');                 // per-page og:type (default from config og_type)

Seo::twitter()->title('Custom Twitter title');
Seo::twitter()->description('Custom Twitter description');

Both proxies return the Seo instance, so you can chain:

Seo::title('Page')
    ->og()->title('OG override')
    ->twitter()->title('Twitter override')
    ->description('Shared description');

Set the Open Graph locale (and any alternates) for multilingual pages:

Seo::og()->locale('en_US')->og()->alternateLocale('nl_NL');

// alternateLocale also accepts an array, and dedupes:
Seo::og()->alternateLocale(['nl_NL', 'de_DE']);

Renders:

<meta property="og:locale" content="en_US">
<meta property="og:locale:alternate" content="nl_NL">

og:locale is emitted only when set; one og:locale:alternate tag is emitted per alternate.

JSON-LD

Seo::jsonLd('Organization')
    ->title('Acme Corp')
    ->description('We make things')
    ->value('url', 'https://acme.com')
    ->value('logo', 'https://acme.com/logo.png');

Multiple blocks render as a @graph array:

Seo::jsonLd('WebSite')->title('Acme')->value('url', 'https://acme.com');
Seo::jsonLd('Organization')->title('Acme Corp');
// Outputs: {"@context":"https://schema.org","@graph":[...]}

Nested blocks:

$address = JsonLdBlock::make('PostalAddress')->value('addressLocality', 'Rotterdam');
Seo::jsonLd('Organization')->title('Acme')->value('address', $address);

Breadcrumbs

Seo::breadcrumbs([
    'Home'     => '/',
    'Products' => '/products',
    'Widget'   => null,  // null = current page (no URL in output)
]);

Renders a BreadcrumbList JSON-LD block with auto-incrementing positions.

Seoable models

Implement the Seoable interface on your models:

use ThreeOhEight\Seo\Contracts\Seoable;
use ThreeOhEight\Seo\Seo;

class Post extends Model implements Seoable
{
    public function toSeo(Seo $seo): void
    {
        $seo->title($this->title)
            ->description($this->excerpt)
            ->image($this->cover_image);
    }
}

Then in a controller:

Seo::from($post);

Or use the HasSeo trait for convention-based mapping (pulls from meta_title/title and meta_description/description/excerpt):

use ThreeOhEight\Seo\Concerns\HasSeo;
use ThreeOhEight\Seo\Contracts\Seoable;

class Post extends Model implements Seoable
{
    use HasSeo;
}

Livewire integration

Add WithSeo to your Livewire component:

use ThreeOhEight\Seo\Concerns\WithSeo;

class ShowPost extends Component
{
    use WithSeo;

    public Post $post; // auto-detected if Post implements Seoable

    // Or define a custom seo() method:
    public function seo(Seo $seo): void
    {
        $seo->title($this->post->title)
            ->description($this->post->excerpt);
    }
}

The trait hooks into rendering() and applies SEO data automatically. If a seo() method exists, it takes priority over auto-detection of Seoable properties.

Macros

The Seo class uses Laravel's Macroable trait:

Seo::macro('article', function (string $author, string $published) {
    return $this->meta('article:author', $author)
                ->meta('article:published_time', $published);
});

Seo::article('Christoph', '2026-01-01');

Rendering

Blade directive (recommended)

<head>
    @seo
</head>

Blade components

<x-seo::tags />          {{-- all sections --}}
<x-seo::meta />          {{-- title, description, canonical, robots, prev/next, custom meta --}}
<x-seo::opengraph />     {{-- og:* tags --}}
<x-seo::twitter />       {{-- twitter:* tags --}}
<x-seo::json-ld />       {{-- JSON-LD script --}}

Programmatic

$html = Seo::render();          // SeoOutput (Htmlable + Stringable)
$html = Seo::renderMeta();      // meta section only
$html = Seo::renderOpenGraph();
$html = Seo::renderTwitter();
$html = Seo::renderJsonLd();

$data = Seo::toArray();         // all resolved values as a structured array (handy in tests)

Fallback cascade

Tags resolve in this order:

Route-level metadata (set via Route::seo() or the seo group attribute) sits between runtime calls and config defaults for title, description, robots, canonical and og:type.

Tag Resolution
<title> title() > route title > config title > site name only
meta description description() > route description > config description > omitted
canonical canonical() > route canonical > url()->current() (if auto_canonical) > omitted
robots robots()/noindex() > route robots > config robots > omitted
og:type og()->type() > route ogType > config og_type
og:title og()->title() > formatted page title > site name
og:description og()->description() > description() > config description > omitted
og:image image() > config og_image > omitted
og:locale og()->locale() > omitted
og:locale:alternate og()->alternateLocale() (one tag per entry) > omitted
twitter:title twitter()->title() > formatted page title > site name
twitter:description twitter()->description() > description() > config description > omitted
twitter:image image() > config twitter_image > omitted

Title format

Titles are formatted as {page title}{separator}{site name}:

  • Seo::title('Dashboard') renders Dashboard - My App
  • No page title renders My App

OG and Twitter titles use the same formatted title unless overridden via their proxies.

MCP server card (agent discovery)

If the application hosts an MCP server, the package can serve its discovery document. Enable it and provide the card verbatim (the shape follows SEP-2127, which is still a draft; the package never rewrites your payload):

// config/seo.php
'server_card' => [
    'enabled' => true,
    'card' => [
        'protocolVersion' => '2025-06-18',
        'serverInfo' => ['name' => 'my-app', 'version' => '1.0.0'],
        'transport' => ['type' => 'streamable-http', 'endpoint' => 'https://example.com/mcp'],
        'capabilities' => ['tools' => true],
        'authentication' => ['required' => true, 'method' => 'oauth2'],
    ],
],

The card is then served at /.well-known/mcp.json, /.well-known/mcp and /.well-known/mcp/server-card.json (identical payload; the canonical path has moved between spec revisions) with hourly caching, nosniff and permissive CORS. Disabled by default: no routes are registered without the flag.

Testing

composer test
# or
./vendor/bin/pest

License

MIT