wotz/filament-seo
Filament SEO brings SEO management into your Filament admin panels, letting you edit meta titles, descriptions, and Open Graph tags alongside your content. Add SEO fields to any resource and keep pages search-ready without leaving Filament.
Install and publish. The package is small (0 stars, 0 dependents, single maintainer), so treat the source as the documentation before trusting the README alone:
composer require wotz/filament-seo
php artisan vendor:publish --tag="filament-seo-config"
php artisan vendor:publish --tag="filament-seo-migrations"
php artisan migrate
If the tags aren't listed, publish by provider instead (--provider="Wotz\FilamentSeo\FilamentSeoServiceProvider") and check src/ for the actual tag strings.
Read these three files first, in this order:
config/filament-seo.php — tells you the storage strategy (dedicated polymorphic seo table vs. a JSON column on the model), the media disk for OG images, and any locale handling.database/migrations/* — confirms what's actually persisted and whether existing rows need backfilling.src/ — the Filament field/component class. This is your real API surface; there's no ecosystem of blog posts to fall back on.Wire it into a model. The package almost certainly ships a trait (commonly HasSeo) plus a relation or accessor:
use Wotz\FilamentSeo\Concerns\HasSeo;
class Page extends Model
{
use HasSeo;
}
Wire it into a resource. Add the SEO field as its own collapsed section so editors don't fight with content fields:
use Wotz\FilamentSeo\Filament\Fields\SeoField;
public static function form(Form $form): Form
{
return $form->schema([
// ...content fields...
Section::make('SEO')
->collapsed()
->schema([
SeoField::make(),
]),
]);
}
First use case: a Page or Post model where a marketer needs to override the meta title, meta description, and OG image without a deploy. That's the 80% case — get that working end-to-end (edit in the panel → tags appear in the rendered <head>) before exploring canonical URLs or robots directives.
Render it. Look for a Blade component or a helper in src/; typical shapes are <x-filament-seo::tags :model="$page" /> or {!! $page->seo_tags !!}. Drop it inside <head> in your layout and inspect the HTML before going further.
Fallback chain, not raw output. The most common production pattern is: explicit SEO override → sensible model attribute → app-wide default. Do this in your own accessor rather than relying on the package, so you control the precedence:
public function metaTitle(): string
{
return $this->seo?->title
?? $this->title
?? config('app.name');
}
This also protects you from null SEO records on legacy rows (see Gotchas).
Collapsed section + table indicator. Editors forget SEO exists. Pair the form section with a table column showing completeness so gaps are visible at a glance:
Tables\Columns\IconColumn::make('seo.description')
->label('Meta')
->boolean()
->getStateUsing(fn (Page $record) => filled($record->seo?->description)),
Multi-locale. If the package stores one row per locale, loop the field inside a locale tab. If it doesn't, don't fight it — either add a locale column via a fork, or use separate resources per locale. Verify against the migration before designing the editor UI around it.
Plugin registration. If the package ships a Filament plugin class, register it in your panel provider so any global config (disk, tag rendering, navigation) resolves consistently:
public function panel(Panel $panel): Panel
{
return $panel
->plugin(FilamentSeoPlugin::make());
}
Separate the concerns. Keep SEO state in the package's storage, but keep presentation in your app: a SeoPresenter or a Blade partial that builds the tag array. When (not if) you need to add JSON-LD, Twitter cards, or hreflang, you extend your presenter rather than patching vendor code.
Form request validation. If the package's field doesn't enforce length limits, add them yourself. Google truncates titles around 60 chars and descriptions around 155–160. Filament's ->maxLength() on the field is cheap insurance against a 400-character description shipping to production.
Pair with a sitemap. SEO tags without crawlable URLs are half a job. Generate sitemap.xml from the same models (Spatie's laravel-sitemap or a simple route) and make sure pages marked noindex are excluded from it.
Zero stars and zero dependents means you are the QA. Pin the exact version in composer.json ("wotz/filament-seo": "1.0.3", not ^1.0), read the diff before every update, and budget for the possibility that you fork it. The MIT license makes that painless — check whether the migration and field class are clean enough to maintain.
Check the Filament major version constraint. Filament v3 → v4 was a breaking change for form/table APIs. Look at composer.json for filament/filament and confirm it matches your panel. Installing a v3-only field into a v4 panel produces confusing "class not found" or silent-render errors, not a clean composer conflict.
Storage strategy has long-term consequences. A polymorphic seo table scales cleanly and supports queries (whereHas('seo', fn ($q) => $q->where('robots', 'noindex'))); a JSON column on the model is simpler but awkward to query and index. Confirm which you have before writing reporting or filtering logic — and if it's a polymorphic table, verify the (seoable_type, seoable_id) index exists.
Null-safety on existing data. Every model created before the migration has no SEO row. Anywhere you access $model->seo->title directly, you'll get "attempt to read property on null" in production but not in your seeder-filled local DB. Always ?-> plus a fallback, or add a ->withDefault() relation if the package's relation supports it.
Backfill before launch. After migrating, write a one-off command that populates title/description from existing model attributes for all rows. Otherwise your first crawl after deploy sees empty <title> tags.
OG image paths are a classic breakage point. Uploaded media usually stores a relative path; social crawlers need absolute URLs. Wrap output in Storage::disk(config('filament-seo.disk'))->url($path) and make sure APP_URL is correct in each environment. Also verify the image's dimensions — Facebook and Twitter both refuse small images silently.
noindex is a loaded gun. If the package exposes a robots field, restrict it to super-admins or remove it from non-production panels entirely. It's very easy for an editor to hide your homepage from Google and take a week to notice. Consider logging changes to that field.
Cache invalidation. If you cache rendered tags (worth doing — the head partial runs on every request), hook saved/deleted on the model to bust the cache key. A stale meta description after an edit is a bug report you will get.
Debugging rendering issues. Work outward: dump $model->seo in tinker → dump the presenter output → inspect the raw HTML in view-source: (not devtools, which shows the DOM after JS) → check for a duplicate <title> from your layout. Duplicate tags are the single most common integration bug here.
Extension points worth knowing. vendor:publish --tag="filament-seo-views" lets you override tag markup without forking; the config file usually controls tag order and which tags render. Use those before editing vendor/, and commit published views to your repo so they survive composer install.
Compatibility risk with Filament upgrades. Because there's no community pressure on this package, a Filament minor release can break it and stay broken. Add a smoke test — a Pest feature test hitting a resource's edit page and asserting a 200 — so upgrades fail loudly in CI rather than in the editor's browser.
How can I help you explore Laravel packages today?