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

Seo Laravel Package

artisanpack-ui/seo

A Laravel package for adding SEO controls to your application. It helps manage page titles, meta descriptions, and other search metadata through a simple, configurable interface, making it easier to keep pages search-friendly and consistent.

View on GitHub
Deep Wiki
Context7

Getting Started

1. Install and publish

composer require artisanpack-ui/seo
php artisan vendor:publish --tag=seo-config
php artisan vendor:publish --tag=seo-migrations
php artisan migrate

That gives you the seo_meta / sitemap_entries tables (plus hreflang and redirect tables, depending on your config) and config/seo.php.

2. Look at these three things first

  • config/seo.php — sitemap sources, hreflang defaults, cache TTLs, and (new in 1.8.0) the place where you document your morph-map / resolver strategy.
  • ArtisanPackUI\SEO\Models\SeoMeta and SitemapEntry — they are plain Eloquent models with seoable() / sitemapable() morph-to relations. Everything you render ultimately comes from these rows.
  • The filter list (ap.seo.resolveMorphModel and friends). The package is filter-driven; if something renders as null or throws, it's usually a missing filter registration rather than a bug.

3. First use case: SEO meta on a page model

Add the package's SEO trait to the model you want to be indexable, then render in your layout:

use ArtisanPackUI\SEO\Concerns\HasSeoMeta; // confirm exact trait name in the quickstart

class Page extends Model
{
    use HasSeoMeta;
}
{{-- resources/views/layouts/app.blade.php --}}
<head>
    <x-seo-meta :model="$page" />
</head>

Then enable the sitemap source in config and hit /sitemap.xml. That's the happy path: one model, one table, class-name morph type. If that works, everything else in this package is configuration.

4. If your morph types aren't class names, start here

v1.8.0 adds the ap.seo.resolveMorphModel filter for models whose getMorphClass() returns something like App\Models\Record:products. Without it, SeoMeta::seoable and SitemapEntry::sitemapable throw Class "…" not found. Register the filter during boot before anything reads meta:

// app/Providers/AppServiceProvider.php :: boot()
addFilter('ap.seo.resolveMorphModel', function ($model, string $type) {
    return match ($type) {
        'App\Models\Record:products' => app(\App\Models\Record::class),
        'App\Models\Record:posts'    => app(\App\Models\Record::class),
        default                      => $model, // leave unresolved -> legacy behaviour
    };
});

(Confirm the exact argument order/signature against the filter docs — the resolver wraps it in ArtisanPackUI\SEO\Support\MorphModelResolver.)

Implementation Patterns

Single-model, single-table (the default)

Trait on the model, $model->seoMeta in the controller, Blade component in the layout, model registered as a sitemap source. Prefer eager loading on index pages:

$pages = Page::with('seoable')->paginate(20);

Shared model across several tables (the 1.8.0 pattern)

One Record model, several morph aliases:

class Record extends Model
{
    public function getMorphClass(): string
    {
        return 'App\Models\Record:' . $this->table;
    }
}

Register the resolver filter once, mapping every alias you emit to a model instance. Then attach meta exactly as you would normally — the write side just stores the alias in seoable_type, and the read side asks the filter for the class to instantiate. with('seoable') and lazy loads both go through the new ResolvesMorphModels trait / ResolvableMorphTo relation, so eager loading now works for these types where it previously blew up.

Keep the resolver cheap: return a cached new Record / app(Record::class) rather than running a query, because it's hit once per relation resolution.

Sitemap generation

Register sitemapable models/types in config, generate on a schedule, and let SitemapEntry handle the URL/changefreq/priority rows:

$schedule->command('seo:sitemap:generate')->daily();

If you generate a sitemap from a multi-table model, the resolver filter must cover all aliases or entries for the uncovered tables will fail to hydrate when the sitemap is rendered.

Cache management

Meta and analysis results are cached under meta:<class>:<table>:<id> and analysis:<class>:<table>:<id> as of 1.8.0 (previously meta:<class>:<id>). Patterns that work well:

  • Let the package bust the cache on model save/update rather than doing it manually.
  • After bulk imports (seeders, CSV imports), flush once explicitly instead of relying on per-row events — model events don't fire for insert()/upsert.
  • If you build keys yourself in a dashboard, job, or Inertia prop, build them through the package's key helper rather than string-concatenating.

Hreflang

HreflangService clears its cache on model changes. With 1.8.0, invalidation also works for morph-map aliases and resolver-backed types (it now checks MorphModelResolver::canResolve() instead of skipping non-class seoable_type values). Two things to get right:

  • Register the resolver filter in a provider that boots early and unconditionally — invalidation depends on canResolve() returning true at save time.
  • Don't guard the filter registration behind !app()->runningInConsole(); queued imports will then skip invalidation.

Testing

protected function setUp(): void
{
    parent::setUp();
    Cache::flush(); // cache keys are per-class+table+id; stale array cache between tests is confusing
}

Assert on the rendered meta output rather than on cache contents, and add one test that hydrates a shared-table record through seoable() so a broken resolver filter fails loudly.

Integration tips

  • Put the resolver filter registration in a dedicated SeoServiceProvider rather than AppServiceProvider if you also want to register more filters later.
  • If you use a morph map, run Relation::enforceMorphMap([...]) in a provider — it makes alias mismatches fail in tests rather than in production meta rendering.
  • Publish the Blade component and override it if your meta output needs project-specific tags (OG, Twitter, JSON-LD) — it's the sanctioned extension point.

Gotchas and Tips

Morph alias migration is a data problem, not just a config problem. After introducing aliases, your seo_meta / sitemap_entries tables contain a mix of class names (old rows) and aliases (new rows). Types the filter doesn't resolve fall back to the old behaviour, so class-name rows keep working — but write a one-off backfill command if you want consistency, and test both shapes until you've run it.

The resolver filter is called on every read path. Lazy loading, with('seoable'), and with('sitemapable') all go through it. A match returning app(Model::class) is fine; a DB lookup or a firstOrFail() is not. Memoise if you need per-table model configuration.

Custom cache-key code is the most likely silent breakage in 1.8.0. Grep for meta: and analysis: string building in your app. Anything that manually clears or reads meta:{class}:{id} now misses and will look like "caching stopped working". This is not a BC break in the package API — the first read simply misses once and rebuilds — but hand-rolled invalidation will silently no-op.

Expect a one-time cold-cache hit after upgrading. Every meta/analysis entry misses once because the key shape changed. On a large site this can be a thundering herd against a slow meta generator; warm it with a queued job after deploy if that matters.

whereHas('seoable') across mixed morph types is still the weak spot. The new ResolvableMorphTo fixes hydration for aliased types, but constraining a polymorphic relation that spans multiple underlying tables is inherently awkward. Prefer querying SeoMeta directly (SeoMeta::where('seoable_type', $alias)->whereIn('seoable_id', $ids)) or filtering on the concrete model.

Multi-table sharing used to leak SEO data — verify it's actually fixed for you. The old meta:<class>:<id> key meant two records of Record with the same id in different tables could serve each other's meta. If you wrote a workaround (custom key prefix, separate model classes), you can now remove it; leaving it in place will just double-cache.

Debugging hydration failures

Log::debug('seo resolve', ['type' => $seoableType, 'resolves' => MorphModelResolver::canResolve($seoableType)]);

Also inspect the raw column — SeoMeta::distinct()->pluck('seoable_type') — to spot orphan aliases left behind after a table rename or a removed morph map entry.

Config publishing after an upgrade. vendor:publish --force will clobber your customisations. Diff the shipped config against yours (new keys are usually additive, but sitemap source arrays are easy to lose).

Register the filter once. Double-registering the ap.seo.resolveMorphModel filter in two providers is harmless-ish but the last one wins in some hook implementations and makes behaviour order-dependent in tests — guard with a once flag if your provider can boot twice (Octane, some test setups).

Extension points worth knowing: the resolver filter, the config-driven model overrides (if you need to subclass SeoMeta/SitemapEntry), the published Blade meta component, and HreflangService for programmatic hreflang sets. Reach for those before patching vendor code.

No breaking changes in 1.8.0. It's additive (new filter, new trait/relation classes) plus two fixes. Upgrading is low risk; the only thing that needs deliberate attention is the cache-key format change if you touch cache keys yourself.

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.
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
frankyso/ipaymu
3mdikram/worldmoneywords
snowsoft/config
itmosoft/laravel-subscriptions
larva/laravel-ai-qwen
adachsoft/api-version-bundle
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core