mad-web/laravel-seoable
A Laravel package that adds SEO metadata to Eloquent models, letting you manage titles, meta descriptions, Open Graph and Twitter card tags, and other search-friendly fields directly from your models and database.
1. Install and publish
composer require mad-web/laravel-seoable
php artisan vendor:publish --provider="MadWeb\Seoable\SeoableServiceProvider"
php artisan migrate
Publishing should give you a config/seo.php (field mapping / table name) and a migration creating a polymorphic seos table. If auto-discovery is disabled in your app, register the provider manually in config/app.php.
2. Attach the trait to a model
use MadWeb\Seoable\HasSeo;
class Post extends Model
{
use HasSeo;
}
Confirm the exact namespace/trait name against vendor/mad-web/laravel-seoable/src before wiring it into many models — the package is low-traffic, so treat the source as the source of truth over any README snippet.
3. Map your existing columns
The selling point is "map your eloquent fields to seo properties." In config/seo.php you point SEO keys at the columns you already have, so a post with title and excerpt produces SEO metadata without a second copy of the data:
// config/seo.php (shape depends on the package version — read the published file)
'fields' => [
'title' => 'title',
'description' => 'excerpt',
'image' => 'cover_path',
],
4. First use case
The fastest win is a blog/detail page: let the model supply defaults, then override per-record when marketing wants a different meta title. Read it in Blade via the seo relation, with a fallback to the mapped column:
<title>{{ $post->seo->title ?? $post->title }}</title>
<meta name="description" content="{{ $post->seo->description ?? $post->excerpt }}">
Look first at: the published migration (column list), config/seo.php (mapping keys), and the Seo model's fillable array.
Write SEO data through the relation. Treat the seo record as a child of the parent model and use updateOrCreate so the first save and every edit hit the same code path:
$post->seo()->updateOrCreate([], [
'title' => $request->input('seo.title'),
'description' => $request->input('seo.description'),
'keywords' => $request->input('seo.keywords'),
'image' => $request->input('seo.image'),
]);
This belongs in a form request + action, or in an admin resource (Filament, Nova, Backpack) as a nested fieldset rather than in the controller.
Create alongside the parent. For seeded or imported content, create the SEO row right after the model so $post->seo is never null:
$post = Post::create($data);
$post->seo()->create(['title' => $data['title']]);
Or use a model created observer so the row exists for every record and downstream Blade can drop the ?? fallbacks.
Eager load on index/listing pages. The relation is polymorphic and lazy-loaded by default — a listing of 50 posts will fire 50 extra queries:
$posts = Post::with('seo')->paginate(20);
Keep the mapping as the fallback contract. In Blade, prefer a small accessor so the fallback logic lives in one place:
// on the Post model
public function getMetaTitleAttribute(): string
{
return $this->seo->title ?? $this->title;
}
Then {{ $post->meta_title }} everywhere. When marketing later asks for a different fallback column, you change one line.
Sitemap and Open Graph generation. Iterate Post::with('seo')->get() (or chunk) in a spatie/laravel-sitemap generator, and reuse the same accessor for og:title / og:description. Because the metadata lives in a relation rather than hard-coded in views, the same source feeds HTML head tags, sitemaps, and any JSON-LD you build.
Filtering content by SEO completeness. Useful for CMS "missing metadata" reports:
Post::whereDoesntHave('seo')
->orWhereHas('seo', fn ($q) => $q->whereNull('description'))
->get();
Localization. If you serve multiple locales, either add the locale to the SEO row and query per locale, or keep one seo row per translated parent model. Check whether the migration has a locale column before designing around it — if not, put the locale on the parent (PostTranslation) and let each translation own its SEO row.
Factories and tests. Add a SeoFactory (or ->hasSeo()) so tests can assert meta output without hand-building rows:
$post = Post::factory()->hasSeo(['title' => 'Custom'])->create();
$this->get($post->url)->assertSee('<title>Custom</title>', false);
Morph map drift will orphan rows. The seos table stores seoable_type as the fully-qualified class name unless you register a morph map. Introducing Relation::enforceMorphMap([...]) after data exists silently breaks lookups — either add the morph map before seeding production data, or write a migration that rewrites seoable_type values.
No cascading delete out of the box. morphs() doesn't create a foreign key, so deleting a Post leaves a stale seos row. Add cleanup explicitly:
static::deleting(fn ($model) => $model->seo()->delete());
or a deleted observer. Verify your migration actually has an index on (seoable_type, seoable_id) — it matters for both lookup speed and cleanup queries.
$post->seo is null until you create it. Fresh records have no SEO row, so $post->seo->title throws. Always use ?-> or create the row in an observer. This is the single most common source of "works on seeded data, breaks in production" bugs.
Config caching hides mapping changes. After editing config/seo.php, run php artisan config:clear in local and config:cache in deploy. A stale cached config with old field names is easy to misread as "the trait isn't working."
Publish tags and migration collisions. If the publish command emits a migration with a timestamp older than your existing tables, run php artisan migrate and check ordering; rename the file if it must run after a table it references.
Watch the package's maturity. Zero dependents and no stars means no ecosystem battle-testing: pin an exact version in composer.json ("mad-web/laravel-seoable": "1.0.*"), read the ~5 source files once so you know what you're depending on, and wrap the trait access behind your own accessors (meta_title, meta_description) so swapping to spatie/laravel-seo or a hand-rolled seo() morphOne later is a contained change rather than a find-and-replace across every Blade file.
It stores paths, not uploads. The image field is a string; combine with spatie/laravel-medialibrary or your existing upload pipeline and store the resulting URL/path. Don't expect the package to handle conversions or responsive images.
Extension point: the Seo model is the seam. If you need extra fields (canonical URL, noindex flag, OG type), extend the package's model, point config at your subclass, and publish/alter the migration. Doing this early is cheaper than another migration after launch.
Debugging tip: when meta tags come out blank, dump $post->getAttributes() and $post->seo?->toArray() together. Most "the package is broken" reports are either a null relation or a mapping key that doesn't match the real column name.
How can I help you explore Laravel packages today?