dominion-solutions / filament-seo
Clean page titles, unique meta descriptions, and Open Graph/Twitter tags for Laravel + Filament, managed from the Filament admin panel.
Package info
github.com/dominion-solutions/filament-seo
pkg:composer/dominion-solutions/filament-seo
Requires
- php: ^8.2
- filament/filament: ^5.0
- illuminate/contracts: ^11.28|^12.0|^13.0
- illuminate/support: ^11.28|^12.0|^13.0
- livewire/livewire: ^4.4.2
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.24
- league/commonmark: ^2.0
- orchestra/testbench: ^9.2|^10.0|^11.0
- phpunit/phpunit: ^11.0|^12.0
Suggests
- league/commonmark: Required for Text::fromMarkdown(), which strips Markdown syntax from generated meta descriptions.
Provides
None
Conflicts
None
Replaces
None
README
Per-record SEO metadata for Laravel and Filament apps, exposed as form fields and rendered as a clean <head>.
Every indexable page gets its own title and meta description — authored in the admin panel, or derived from the record when an editor has not written one.
What it does
- Stores title, description, canonical URL, image,
robotsand Open Graph type in a single polymorphic table, so it works with any model. - Adds a reusable
Sectionto Filament create/edit forms, with sensible defaults and no extra wiring per resource. - Renders
description,robots,canonical, Open Graph and Twitter tags from a Filament render hook — no Blade view to publish. - Falls back to values you derive from the record (
getSeoData()), so a new product or article is indexable before anyone touches the admin panel. - Leaves records that have never been given metadata untouched: no row, no behaviour change.
Requirements
| PHP | 8.2+ |
| Laravel | 11.28, 12 or 13 |
| Filament | 5 |
| Livewire | 4.4+ |
| Tailwind CSS | 4.1+ (Filament's requirement) |
Installation
composer require dominion-solutions/filament-seo php artisan migrate
Publish the config if you want to change defaults. This copies the config file and the migration into your application; the package loads its own migration either way.
php artisan vendor:publish --tag=filament-seo
Register the plugin on each panel that renders public pages:
use DominionSolutions\FilamentSeo\FilamentSeoPlugin; public function panel(Panel $panel): Panel { return $panel ->plugins([ FilamentSeoPlugin::make(), ]); }
The plugin deliberately does not render a
<title>element. Filament already renders one from the page's title, andHasSeoMetadatapoints that at your SEO title — so exactly one<title>ends up in the head. Two titles is worse than none. Setrender_titletotruein the config only if your layout emits no title of its own.
Storing metadata on a model
Add the trait to any model you want to describe, and declare the HasSeo contract so the metadata
is nameable and type-hintable:
use DominionSolutions\FilamentSeo\Concerns\InteractsWithSeo; use DominionSolutions\FilamentSeo\Contracts\HasSeo; use Illuminate\Database\Eloquent\Model; class Product extends Model implements HasSeo { use InteractsWithSeo; }
getSeoData() returns the effective metadata: your stored row where it has a value, falling back to
whatever the overrides below provide. saveSeo() creates the row on first save, and updates it in
place after that.
To give a model sensible defaults, override the fallbacks:
use DominionSolutions\FilamentSeo\Support\Text; protected function getSeoFallbackTitle(): ?string { return $this->name; } protected function getSeoFallbackDescription(): ?string { return Text::excerpt($this->tagline); } protected function getSeoFallbackImageUrl(): ?string { return $this->screenshot_url; }
getSeoFallbackImageAlt(), getSeoFallbackCanonicalUrl() and getSeoFallbackType() work the same
way. robots has no fallback hook — it is index, follow until an editor changes it. The Text
helper strips markup and clips a string to a budget that fits a meta description — use
Text::fromMarkdown() when the source is Markdown.
With no overrides at all, the trait uses the first attribute it finds: name, title or headline
for the title, and description, summary, excerpt, tagline, body or content for the
description.
Editing metadata in Filament
Add the schema to a resource form:
use DominionSolutions\FilamentSeo\Schemas\SeoSchema; use Filament\Schemas\Schema; public static function form(Schema $schema): Schema { return $schema->components([ // ... SeoSchema::make(), ]); }
That is the current Filament resource API; see the Filament guide for the surrounding form, and for what the plugin does on a panel with no resource.
Then hook persistence into the create and edit pages:
use DominionSolutions\FilamentSeo\Filament\Concerns\CreatesSeoMetadata; use DominionSolutions\FilamentSeo\Filament\Concerns\UpdatesSeoMetadata; use Filament\Resources\Pages\CreateRecord; use Filament\Resources\Pages\EditRecord; class CreateProduct extends CreateRecord { use CreatesSeoMetadata; } class EditProduct extends EditRecord { use UpdatesSeoMetadata; }
The seo.* form state is captured and stripped before the record is saved, so the nested array never reaches the model's attributes.
For records created or edited in a modal — a relation manager, or a CreateAction/EditAction — there are no resource page hooks to use. InteractsWithSeoActions covers those:
use DominionSolutions\FilamentSeo\Filament\Concerns\InteractsWithSeoActions; use DominionSolutions\FilamentSeo\Schemas\SeoSchema; use Filament\Actions\CreateAction; use Filament\Resources\RelationManagers\RelationManager; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Model; class CaseStudiesRelationManager extends RelationManager { use InteractsWithSeoActions; // A relation on the owning model; replace with your actual relationship name. protected static string $relationship = 'caseStudies'; public function table(Table $table): Table { return $table ->headerActions([ CreateAction::make('createCaseStudy') ->form([SeoSchema::make()]) ->mutateDataUsing($this->captureSeoFormData(...)) ->after(fn (Model $record) => $this->applyPendingSeoFormData($record)), ]); } }
On edit actions, load the existing values with fillSeoFormData(...) in fillForm().
Describing a page
Public Filament pages hand their metadata to the render hook with HasSeoMetadata:
use DominionSolutions\FilamentSeo\Concerns\HasSeoMetadata; use Filament\Pages\Page; class About extends Page { use HasSeoMetadata; protected ?string $heading = ''; protected static ?string $seoTitle = 'About Us'; protected static ?string $seoDescription = 'Twenty years of engineering leadership, applied to software small businesses can rely on.'; }
$headingis?string, because that is how Filament declares it. Declaring it asstringis a fatal error — PHP does not allow a subclass to narrow a property's type. Set it whenever the page renders a heading, or Filament will fall back togetTitle()and put your SEO title in the page body as well as the<head>.
Add a separator at the end of getTitle() for longer titles, e.g. 'About Us | My Company'. If a
computed title needs to return an Htmlable, override getSeoTitle(): string|Htmlable; the
description property is a string.
For a page that renders a record, override getSeoData() and fall back to the record's metadata — the hook picks up whatever it returns:
public function getSeoData(): SeoData { return $this->record->getSeoData()->merge([ 'type' => SeoData::TYPE_ARTICLE, ]); }
Panel-wide defaults
Metadata that applies to a whole panel goes in the config's site_name, or per page:
FilamentSeoPlugin::make() ->seoData([ 'siteName' => 'Acme', 'imageUrl' => asset('images/acme.png'), ]);
seoData() also accepts a SeoData, or a closure returning either — use a closure for
multi-tenant panels, where the metadata depends on the current tenant.
Resolution order is page, then panel, then the request URL for canonical and app.name for the site
name. The last two are layered underneath rather than merged over, so a page that sets its own site
name or canonical URL keeps it.
Documentation
Detailed guides live in docs/:
| Guide | What it covers |
|---|---|
| Installation | Composer, the plugin, the migration |
| Configuration | Every config option, and when to reach for it |
| Models | InteractsWithSeo, the HasSeo contract, generated fallbacks |
| Filament | Panel defaults, pages, resource forms, modal actions |
| Architecture | How the pieces fit together, with diagrams |
Testing
use DominionSolutions\FilamentSeo\Support\SeoData; $product->saveSeo(['title' => 'A better title']); expect($product->fresh()->getSeoData()->title)->toBe('A better title');
Contributing
See CONTRIBUTING.md. In short: composer install, then composer check for
lint, static analysis and tests.
License
MIT.