three_oh_eight / seo
Lean, Laravel-native SEO tag management
Requires
- php: ^8.5
- illuminate/support: ^12.0 || ^13.0
- illuminate/view: ^12.0 || ^13.0
Requires (Dev)
- orchestra/testbench: ^10.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
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')rendersDashboard - 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