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.
Architecture fit. The package is a Filament panel plugin, not a framework-level concern. That is the single most important architectural fact: it inserts an editorial surface into the admin panel and a thin Blade partial into the public <head>, and touches nothing else. The persistent model is a polymorphic seos table reached through a HasSeo trait exposing a morphOne — idiomatic Eloquent, familiar to any Laravel engineer on the team, and decoupled from the package's view layer. That decoupling is what makes the adoption decision reversible: if the Blade component drifts or breaks, the data model still works and we swap the partial.
Fit is high for a server-rendered Laravel + Filament monolith. Fit is low for headless or SPA front ends (Inertia, Next, Nuxt) — the editing surface transfers, but the rendering half, which is where the SEO value is realised, does not. Confirm the front-end render path before this goes on a roadmap.
Integration feasibility. The happy path is genuinely small: composer require, publish config and migrations, php artisan migrate, register the plugin in the PanelProvider, add the trait to one model, compose a collapsed Section::make('SEO')->relationship('seo') into an existing resource form, include the view in the layout. A single engineer can complete this end to end on one model inside two days. There is no service tier, no external API dependency for the baseline capability, and no data migration of existing content required — SEO records simply start existing per model.
The two things that make feasibility non-trivial are both pre-existing conditions in our codebase, not defects in the package:
artesaos/seotools, a hand-rolled partials.meta, or a hard-coded title in app.blade.php is already in place, we will ship two <title> tags or contradictory OG tags and no test will fail. The package can legitimately be the editing surface while the existing renderer remains the output — that split is often the correct migration — but it must be an explicit architectural decision, documented, not an accident of sequencing.Technical risk. Ranked by expected cost, not by likelihood:
| Risk | Severity | Mitigation |
|---|---|---|
Silent output regression — duplicate robots, missing canonical, 404 OG image. Nothing throws; traffic erodes over weeks. |
High | Snapshot test on the head block, plus a crawl diff on every release touching the head. This is the one thing built on day one, not later. |
| Bus factor of one. 15 stars, 0 dependents, single maintainer, MIT. | High | Exact version pin, named internal owner, confirmed ability to fork the ~small surface (trait, model, form schema, partial). |
| Filament major-version drift. Panel plugins break on Filament majors more reliably than on anything else. | Medium-High | Read composer.json constraints in vendor/ on day one; that file is a better roadmap signal than the README. |
| Google integration placing an outbound API call in the request path, or storing OAuth secrets in an admin-editable settings page. | Medium | Any Google call runs as scheduled work with backoff; secrets in .env/config(), only the property ID admin-editable; staging runs against a non-production property to observe the revoked-token failure mode. |
| Multi-tenant bleed through an unscoped polymorphic relation. | Medium (binary if multi-tenant) | Verified tenant isolation before pilot, or a tenant_id column plus global scope. |
| Unclean exit — no documented export path; authored metadata is stranded. | Low-Medium | Write a one-off seo:export to JSON before committing. Cheap insurance that makes "we can walk away" literally true. |
Key questions to resolve before approval.
Stack fit. Laravel + Filament panel, Composer-managed, publishable config and migrations, Blade-rendered public output. Nothing exotic. The package assumes it is the only SEO plugin in the panel and that the front end is server-rendered — both assumptions must be validated against our codebase rather than assumed from the README.
Migration path — dual-run, rendering last. The product value is editorial workflow; workflow has no blast radius. Rendering does. Sequence accordingly:
| Phase | Scope | Gate to proceed | Effort |
|---|---|---|---|
| 0 — Spike | Install pinned version, register plugin, trait on one Post/Page, SEO section in the resource form, legacy head renderer untouched. Walk one record end to end, including a non-ASCII record. |
Head output unchanged; editor can save and reload metadata; no N+1 in the admin list. | 1–2 eng-days |
| 1 — Admin-only in production | One model. Editors author metadata that is stored but not rendered. | Percentage of published records with filled title + description trending up. | < 1 day |
| 2 — Shadow render | Package partial renders behind a flag for one route group; diff emitted <head> against legacy across a page sample. |
Zero unexpected diffs; snapshot test committed and green. | 1–2 eng-days |
| 3 — Flip per route group | Marketing pages, then blog, then product. Legacy partial retained for one release cycle as a documented rollback. | Crawl diff clean per group. | Incremental |
| 4 — Extend model by model | Blog → pages → products. One entity per change so a regression is attributable. | No new N+1; no editor confusion reported. | Incremental |
| 5 — Google integration | Jobs, backoff, staging against a non-production property. | Revoked-token failure mode observed and alerts wired. | Deferred; separate decision |
Do not compress phases 2 and 3. Phase 1 can ship on a Tuesday afternoon.
Compatibility work that is ours, not theirs. Publish the views and edit rather than overriding in the layout — it keeps the diff against upstream legible on the next bump. Compose the package's own form schema class into our Section rather than hand-rolling the field list, so fields the maintainer adds appear after composer update. Pin the exact version; do not inherit a caret range. Add the seo relation to base queries (controller, index, sitemap loop) rather than sprinkling ->load('seo'). Derive canonical URLs in code from route + slug and expose them read-only; editable canonical strings produce self-referencing canonicals on paginated pages.
Scope discipline. Adopting this funds editorial hygiene — per-record metadata, social cards, verification tags — not search strategy. Hreflang, JSON-LD authoring, redirect management, and programmatic page generation stay explicitly out of scope and out of the "we shipped SEO" milestone. Making that line visible in the roadmap is the point: it lets us defer the strategic bucket deliberately rather than by omission.
Maintenance. This is a single-maintainer MIT dependency at 15 stars with 0 dependents. It will not be maintained on our cadence. Assume an internal owner with a standing allocation of roughly two engineer-days per quarter for version bumps and head-output verification, plus ad-hoc time when a Filament major lands. The code surface is small enough that forking the view layer is an afternoon's work — that is the real contingency, and it should be stated in the adoption record so it isn't rediscovered under pressure. Do not let this end up unowned in the dependency graph; an unowned SEO plugin is how you get a canonical tag silently dropped for a quarter.
Support. There is no vendor. Upstream responsiveness at this star count is unknown and should not be on the critical path. Editor-facing support is ours: the failure they will hit is the blank field, since editors skip inputs they don't understand. Seed SEO title and description from the content fields (->default(fn (?Post $record) => $record?->title)) so editors edit rather than author, truncate with Str::limit(..., 155) at render time, and guard each tag with filled() rather than isset() — an empty record stores '', not null, and truthiness passes for whitespace. <meta name="description" content=""> is worse than no tag.
Scaling. Two cost centres, both predictable:
morphOne. Every listing, index, or sitemap loop touching $model->seo fires a query per row. Eager-load on the base query and cache the resolved tag array per model with a short TTL, busted by a model observer on save — head output changes rarely and is read on every request. This is the most common performance regression from any polymorphic SEO package and it is entirely within our control.robots.txt generation. Fine at modest route volumes; at scale, generate as a scheduled job into cache or static output rather than on request, and confirm whether the package's sitemap handles our dynamic route set or whether we retain the existing generator and use this package for metadata only. robots.txt is effectively static and low-risk.Multi-byte content is a five-minute check during the spike: a "155-character" description in CJK or emoji can exceed a downstream byte limit. Test with one non-ASCII record and close the class of bug before production.
Failure modes and detection. The defining property of this integration is that wrong output produces no error. Duplicate robots tags, a missing canonical, an OG image that 404s, two <title> tags from a surviving legacy emitter — all render a 200 and log nothing. Detection has to be manufactured:
Ramp-up. Engineering: two days to the end of Phase 0, two more to shadow render. Editors: roughly an hour of enablement, focused on "these fields default from your content — edit them, don't skip them." First measurable value is available in week one if Phase 1 ships to production: the share of published records with a filled, unique title and description. That metric, plus a clean crawl diff and zero pages missing a canonical, is a sufficient success definition for the milestone. The exit plan — seo:export to JSON, legacy partial retained for one release — costs an afternoon and is what makes the adoption decision genuinely reversible.
How can I help you explore Laravel packages today?