romanzipp/laravel-seo
romanzipp/laravel-seo is a Laravel package for adding SEO metadata to Eloquent models and Blade views. It generates meta titles, descriptions, canonical URLs, Open Graph and Twitter Card tags through a simple fluent API, making pages easier to optimize for search and social sharing.
The package is a storage + rendering helper for per-model SEO metadata, not an SEO generator. It adds a polymorphic seo table and a trait so any Eloquent model can carry a title, description, keywords, canonical, and robots directives.
Minimal setup:
composer require romanzipp/laravel-seo
php artisan vendor:publish --provider="RomanZipp\LaravelSeo\SeoServiceProvider"
php artisan migrate
The publish step drops a migration (and typically a config/seo.php). Check database/migrations/*_create_seo_table.php before running it in production — the schema is the real contract of this package, and it is the first place to look.
2.6.0 adds optional EmbedX support for generating rich preview thumbnails for X/Twitter. Treat it as opt-in: check config/seo.php for any embedx key / API token and leave it disabled until you have validated it against your content. If you don't need X/Twitter previews, ignore this feature entirely — it does not change the core storage/rendering behavior.
Then attach the trait to a model you already render publicly:
use RomanZipp\LaravelSeo\Traits\Seoable;
class Post extends Model
{
use Seoable;
}
First use case — give editors control over the <head> of a Post page:
$post->seo()->create([
'title' => 'How we shaved 400ms off TTFB',
'description' => 'A walkthrough of our Laravel caching changes.',
'keywords' => 'laravel, performance, caching',
]);
Render it in your layout partial with fallbacks:
<title>{{ $post->seo?->title ?? $post->title }}</title>
<meta name="description" content="{{ $post->seo?->description ?? Str::limit(strip_tags($post->body), 155) }}">
@if ($post->seo?->canonical)
<link rel="canonical" href="{{ $post->seo->canonical }}">
@endif
Where to look first: the package is small (a trait, a Seo model, a migration, a service provider). Read all four files in ~15 minutes before adopting it — you will almost certainly need to extend the migration or the trait, and knowing the exact column names saves a debugging cycle. With 2.6.0, also read whatever EmbedX helper/config the release added if you plan to use it.
Treat the package as a data layer, not a feature. The most common working pattern is:
seo table holds editorial overrides.title, slug, excerpt, updated_at).Encapsulate that resolution so you don't repeat null-safe chains in every view:
// app/Support/SeoPresenter.php
class SeoPresenter
{
public static function for(Model $model): array
{
$seo = $model->seo;
return [
'title' => $seo?->title ?? $model->title,
'description' => $seo?->description ?? Str::limit(strip_tags($model->body ?? ''), 155),
'canonical' => $seo?->canonical ?? url()->current(),
'robots' => $seo?->robots ?? 'index,follow',
'image' => $seo?->image ?? $model->hero_image_url,
];
}
}
Then a single <x-seo-meta :model="$post" /> component renders everything. This is where 90% of the day-to-day value lives, and it lets you swap the package later without touching views.
Use updateOrCreate on the morphOne relation, not create — editors will save twice, and a duplicate row per model is the most common bug report with this pattern:
$post->seo()->updateOrCreate([], $request->validated('seo'));
Backend form: expose the SEO fields as a nested array (seo[title], seo[description], …) and validate them as nullable|string|max:255. Since the package provides no UI, you are wiring this into Nova, Filament, or a plain Blade form either way — plan for that work explicitly.
Eager load everywhere you list models. The relation is one query per model if you forget:
$posts = Post::with('seo')->paginate();
And if you generate a sitemap or an RSS feed, use Post::with('seo')->cursor() rather than loading thousands of SEO rows at once.
Multi-model coverage is the real reason to use a polymorphic package. Apply the trait to Post, Page, Product, Category; one migration covers all of them, and your presenter/component works uniformly. That consistency is worth more than the table itself.
Robots directives deserve their own policy. Rather than letting an editor type arbitrary strings, map a small enum (index, noindex, noindex,nofollow) in the form and store the serialized value. Malformed robots meta is silently ignored by crawlers and hard to notice.
If you enable EmbedX (2.6.0): don't call the preview generation inline in the request that saves the model. Dispatch a queued job that writes the resulting image URL back onto the SEO row, and have your presenter fall back to $model->hero_image_url when the generated image is missing or generation failed. This keeps saves fast and prevents a third-party outage from breaking your editor UX.
Testing pattern: with RefreshDatabase, assert rendered output rather than internals:
$this->get($post->url)
->assertSee('<title>Custom SEO title</title>', false)
->assertSee('name="description"', false);
Add one test for the fallback path (no seo row) and one for the override path. If you adopt EmbedX, add a third test with the EmbedX call faked so the preview path is covered without hitting the network. That trio catches nearly all regressions.
<meta property="og:image"> etc. yourself. Budget for pairing with spatie/laravel-sitemap and a schema package.config/seo.php is off by default and that it doesn't collide with any image/og_image column you added yourself to the published seo table.composer.lock, and keep your own presenter layer (above) thin enough that replacing the package means rewriting one file, not fifty views.$model->seo is null until someone creates a row. Always use ?-> and always have a fallback, or you will ship pages with an empty <title>.Relation::enforceMorphMap(), existing seoable_type values storing FQCNs will stop resolving. Either add the morph aliases before seeding SEO data or run a one-off UPDATE seo SET seoable_type = ....{{ }} for all SEO values. Editors paste HTML into description fields constantly; {!! !!} here turns your meta tags into an injection vector.locale column to the migration and include it in the updateOrCreate key, or you'll overwrite the French description with the German one.url in the request and normalize to https:// + host in the presenter, or you'll emit relative canonicals that crawlers mishandle.og:image, twitter:card, an EmbedX-generated preview, or schema, add columns to your own migration that alters the published table rather than editing the vendor file — keeps upgrades trivial.$model->fresh()->seo?->toArray() and the raw response HTML. With EmbedX in the mix, also check the queue (failed_jobs, worker logs) — a silently failed preview job is the newest flavor of "the image isn't updating." Nine times out of ten otherwise it's a stale updateOrCreate key, a cached view fragment, or a model instance loaded before the SEO row was written.How can I help you explore Laravel packages today?