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: 174

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.3.0 2026-07-11 10:42 UTC

This package is auto-updated.

Last update: 2026-07-11 10:43:13 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

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::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();

Fallback cascade

Tags resolve in this order:

Tag Resolution
<title> title() > config title > site name only
meta description description() > config description > omitted
canonical canonical() > url()->current() (if auto_canonical) > omitted
robots robots()/noindex() > config robots > omitted
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