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

Laravel Seoable Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.

Implementation Patterns

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

Gotchas and Tips

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.

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.
payerurl/telegrambot-crypto-payment-instant-payout
dskripchenko/laravel-hybrid-search
riorizkyrainey/lara-ced
mehedimi/static-vel
zharp/zharp
nonsapiens/bigquery-model-sync
lgu-bayambang-ict-office/lgu-laravel-development-standards
andydefer/laravel-support
se7enxweb/flysystem
lampwick/laravel
jeslage/statamic-emojis
micahdshackelford/rmap-laravel
draftsmaninc/draftsman
square-bit/laravel-volition
himito/laravel-mailman
htinlynn/genius-helper
maco-studios/hephaestus
elmogy/larafast
xeviant/laravel-paystack
gorilladash/laravel-website-sdk