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

Filament Seo Laravel Package

tomatophp/filament-seo

A Filament plugin for Laravel admin panels that lets you manage SEO titles, meta descriptions, Open Graph tags, and related metadata for your models and pages directly from the dashboard.

View on GitHub
Deep Wiki
Context7

Getting Started

Install, publish, migrate. The package follows the standard Tomatophp shape, so the first ten minutes are predictable:

composer require tomatophp/filament-seo
php artisan vendor:publish --tag=filament-seo-config
php artisan vendor:publish --tag=filament-seo-migrations
php artisan migrate

Pin an exact version in composer.json rather than accepting the caret range Composer picks. Single maintainer, low star count, no dependents — you want the version you tested, not whatever lands next Tuesday.

Read four files in vendor/tomatophp/filament-seo/ before writing any code. In order: the service provider in src/ (for the exact plugin class and namespace — Tomatophp renames entry points between minors more often than you'd like), the migration (column names and the polymorphic shape of the seos table), the form schema class (the canonical field list you should compose rather than re-author), and the Blade view under resources/views/ (what actually gets emitted into <head>). Those four files answer 90% of the questions you'd otherwise ask in a Slack channel.

Register the plugin in your PanelProvider:

->plugin(\TomatoPHP\FilamentSeo\FilamentSeoPlugin::make())

Attach the trait to one model — literally one:

use TomatoPHP\FilamentSeo\Traits\HasSeo;

class Post extends Model
{
    use HasSeo;
}

Put the SEO section inside the resource form you already have, not in a separate resource. Editors should never leave the page they're editing:

Section::make('SEO')
    ->relationship('seo')
    ->collapsed()
    ->schema([
        // compose the package's own field schema here; see src/
    ]),

Render the tags once, in your layout's <head>, using the package's published view. If the include path shifts on an update, fall back to a hand-rolled partial over $model->seo — the data model is stable, the view path is the part that drifts.

First use case: admin-only mode. Editors fill in titles and descriptions; your existing <head> template keeps rendering exactly what it renders today. Walk a single Post record through creation, edit, and front-end view. That's the day-one spike, and it's shippable on an afternoon because nothing user-facing changed. Flipping the render path is a separate, later decision.

Implementation Patterns

The daily loop is: editors author inside the resource, developers stay out of the way. The value of this package is that it deletes a ticket from the publish path. Protect that by seeding fields from content rather than making editors retype:

TextInput::make('title')
    ->default(fn (?Post $record) => $record?->title)
    ->maxLength(60),

Seed on create, let editors edit on update, and truncate at render time (Str::limit($seo->description, 155)) so nobody is counting characters in a form field.

Eager-load seo everywhere it renders. This is the single most likely performance regression you will introduce. Any index, listing, or sitemap loop that touches the relation fires a query per row:

Post::with('seo')->published()->paginate();

Better still, resolve the head tag array once per model in a small presenter or cached accessor and bust it in an observer on save. Head output changes when an editor saves and is read on every request — that's a cache shaped like a gift.

Compose the package's field schema, don't re-type it. Tomatophp packages generally ship a reusable schema builder alongside the plugin. Importing it means a field the maintainer adds appears in your admin after a composer update. Hand-rolling the field list is how you silently fall behind upstream and then can't tell whether a bug is yours or theirs.

OG images resolve from your media library, not a second upload path. If you already use filament/spatie-laravel-media-library-plugin, point og_image at the model's existing featured image collection with a sensible fallback. Two image upload paths for the same visual asset is the most common way a drop-in SEO plugin produces subtly wrong social previews.

Canonical is derived, not typed. If the field is editable, editors will set it inconsistently and you'll ship self-referencing canonicals on paginated pages. Compute it from route + slug, strip query strings, normalize ?page=1 away, and expose it read-only. Manual override only for genuinely syndicated content — and log every override so you can audit them later.

Generate sitemaps and robots.txt as scheduled output, not request-time work. Run generation in a queued job and write to storage or a cache key. Wire the job to model save events so slug changes propagate. On a site with more than a few thousand URLs, chunk the query — a sitemap job that loads every published record into memory is the failure mode that takes down the queue worker.

Google Search Console / Analytics work belongs in jobs with backoff. Whatever the package wires up, treat any outbound Google call as asynchronous, scheduled, and rate-limit-aware. A synchronous call inside a Filament page render turns a Google quota response into an admin outage. Test against a non-production property first and deliberately revoke the token in staging to see the failure mode before production credentials exist.

Snapshot the head output on day one. SEO regressions are silent — a dropped canonical or duplicated robots tag throws nothing and shows up as traffic loss weeks later. A test asserting the exact head block against a fixture is the only guard you get:

$this->get(route('posts.show', $post))
    ->assertSee('<title>', false)
    ->assertSee('<link rel="canonical"', false)
    ->assertSee('<meta property="og:title"', false);

Extend it to a second test that fetches a non-ASCII record, so truncation and encoding bugs surface at CI time instead of in Search Console.

Roll out model by model. Blog first, then pages, then products. Batch-adding the trait to a dozen models makes any regression unattributable, and editors need a release to adjust to a new section in their forms.

Gotchas and Tips

Wrong output produces no error. Nothing logs, nothing throws. Budget a head-crawl diff (Screaming Frog, or a twenty-line script over your sitemap) as part of any release that touches the layout, and review the SEO partial as code — not as content.

Audit for a competing meta emitter before you install. An existing artesaos/seotools setup, a hand-rolled partials.meta include, or a hard-coded <title> in app.blade.php will give you duplicate tags and nobody will notice for a month. The package can be the editing surface while your existing renderer stays the output — that split is legitimate and often the right migration path — but it has to be a decision, written down, not an accident.

filled(), not isset() or truthiness. The morphOne returns null when no SEO record exists, so $model->seo?->title is mandatory. But once a record exists, empty fields are frequently '', not null, and @if($seo->description) happily passes for whitespace. Guard every tag with filled().

A static public/robots.txt wins over the route. If your repo already ships one, the package's generated version never gets served and you'll spend an hour convinced the feature is broken. Delete the static file, or serve it from the package route, and verify with curl on the deployed environment rather than locally.

Multi-tenant scoping is unverified. The polymorphic seos table has no tenant column by default. If you run tenants in one database, add a tenant_id migration plus a global scope on the SEO model, or prove the morphOne resolves within the tenant boundary. Test this before the pilot, since retrofitting it after editors have authored content is genuinely painful.

Uninstall leaves data stranded. There's no documented export path. Write a one-off seo:export command that dumps the table to JSON before you commit. It's an hour of work and it makes the "we can walk away" story true rather than aspirational.

Keep Google secrets out of the settings page. If the package stores Search Console config in the database, confirm what's in there. Client secret and refresh token belong in .env and config(); only the property ID should be admin-editable. An admin panel holding an OAuth secret is a lateral-movement target.

Publish the views if the output is close but not right. Duplicate OG defaults or a missing twitter:card are one-line edits in a published view, and publishing makes your next composer update diffable. Overriding from your layout instead is how you lose track of what's upstream and what's yours.

Treat the render path as replaceable. The data model — a polymorphic relation with a handful of text columns — will outlive any Blade component. Write your partial against $model->seo, swap the include when upstream drifts, and the "fork it in an afternoon" claim stays true. Depend on the data model more than the view layer; that one decision is what makes adopting a 15-star dependency defensible.

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.
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
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