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 Seo Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.

Implementation Patterns

Treat the package as a data layer, not a feature. The most common working pattern is:

  1. seo table holds editorial overrides.
  2. Model holds canonical source-of-truth fields (title, slug, excerpt, updated_at).
  3. A Blade component or accessor resolves override → fallback in one place.

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.

Gotchas and Tips

  • It still does not generate metadata. No Open Graph tags, no Twitter cards, no sitemap, no JSON-LD, no automatic descriptions. 2.6.0's EmbedX integration generates preview thumbnails for X/Twitter, not the meta tags themselves — you still build <meta property="og:image"> etc. yourself. Budget for pairing with spatie/laravel-sitemap and a schema package.
  • EmbedX is an external service, not a bundled library. Expect an API token, outbound HTTP calls, rate limits, and a per-generation cost. Don't send unreleased or private content to it without confirming the terms; wrap the calls behind a queued job and feature-flag so local/test environments never hit the network.
  • 2.6.0 is a minor version bump, not a breaking rewrite. It should be a drop-in upgrade from 2.x, but confirm the EmbedX entry in 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.
  • 349 stars / no dependents is a real bus-factor signal. Pin the version in composer.lock, and keep your own presenter layer (above) thin enough that replacing the package means rewriting one file, not fifty views.
  • Null relation is the default state. $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>.
  • Morph map conflicts. If you use 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 = ....
  • Escape in Blade. Use {{ }} for all SEO values. Editors paste HTML into description fields constantly; {!! !!} here turns your meta tags into an injection vector.
  • Keywords are decorative. Google ignores them; some internal search and CMS integrations still read them. Don't spend design time on a keyword UI unless something consumes it.
  • No i18n. For localized sites, add a locale column to the migration and include it in the updateOrCreate key, or you'll overwrite the French description with the German one.
  • Canonical URLs need absolute values. The package stores whatever you give it; validate with url in the request and normalize to https:// + host in the presenter, or you'll emit relative canonicals that crawlers mishandle.
  • Extension points are the trait and the migration. There's no plugin system. If you need 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.
  • Debugging tip: when a meta tag "isn't updating," dump $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.
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.
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core