tomatophp/filament-meta
TomatoPHP Filament Meta adds flexible meta data fields to Eloquent models and Filament resources, letting you manage SEO tags, custom attributes, and extra per-record settings directly from Filament forms and tables. A lightweight way to extend models without schema changes.
What this package actually is (and is not). tomatophp/filament-meta is a thin polymorphic key/value side table (model_type, model_id, key, value) with a HasMeta-style trait, a Meta Eloquent model, a published migration/config, and a Filament relation manager that edits those rows inline on any resource record. It is roughly a one-to-two-day implementation packaged for reuse. The capability ceiling is important for planning: it stores and surfaces metadata. It does not validate, type, filter, aggregate, localize natively, or enforce a schema. Any product requirement beyond "attach SEO fields to a record and edit them in the panel" is something you are building, not adopting.
Architecture fit. Strong fit for a single, bounded use case: per-record SEO authoring (title, description, keywords, Open Graph, Twitter card) inside an existing Filament resource. It is additive — a side table and a trait, no changes to existing models beyond use HasMeta and no changes to existing tables. That makes it reversible in a way most dependencies are not, which is the main reason it is worth a spike. It is a weak fit as a general-purpose metadata layer, as a query surface (filtering/sorting/reporting on meta values degrades immediately into EAV joins), and as a replacement for anything you already run for structured content.
Integration feasibility. Low-effort, high-verifiability. Install, publish migration/config, migrate, add the trait to one model, register the relation manager on the owning resource, and you have an editor-visible tab. The day-one decision gate is a ten-minute read of four files, and every one of them is a real risk question a TPM should own:
composer.json — does it constrain your Laravel, PHP, Filament, and Livewire majors? Version drift here is the most common reason a Filament package dies on contact.(model_type, model_id, key)? Is value a string or JSON? Is down() reversible? No unique index means duplicate keys are a when, not an if, and reads become nondeterministic.src/Traits/HasMeta.php — what is the relation name, what helpers ship (meta, putMeta, hasMeta), does it honor morph maps?Technical risk. Concentrated and enumerable, which is good news for a plan:
seo_titel/seo-title/seoTitle within a sprint), no validation (editors become the schema), string-only values (booleans round-trip as "1"), no automatic policy enforcement on the relation manager, no guaranteed cascade delete (orphan rows are silent rot), and no native per-locale model.$page->meta('seo_title') inside a table or Blade loop. This is entirely preventable with eager loading but must be an explicit acceptance criterion, not an assumption.Key questions to resolve before approving a spike.
meta column, Spatie medialibrary, a settings package? If yes, which keys does each own? Two homes for "metadata" is the most common way this quietly fails six months later.seo_title.en) — costs nothing now, migrates cleanly later.Stack fit. Assumes a Laravel + Filament panel already in place. The integration surface is two lines of code plus a migration — use HasMeta on the model, and the relation manager in getRelations() on the resource whose records own the meta (not a standalone resource). The intended production surface is exactly that: an editor opens a Page, sees a "Meta"/"SEO" tab, edits fields inline.
Migration path — a one-week spike, not a rollout. Sequence it as a bounded artifact with a go/no-go at the end:
composer require, publish migration and config (verify the publish tag names against the README), migrate. Pin to an exact tag or 1.0.*; do not float. Vendor the source and confirm the fork builds.meta($key, $default), putMeta() via updateOrCreate (never create), key normalization, and cache/relation flushing. Every call site — Blade, controllers, sitemap generators, seeders, imports, backfills — reads and writes through this one file. When you later move to a JSON column or typed fields, you change the wrapper and a backfill, not every call site.Select on key, not a free TextInput). Normalize and validate at the write helper, not only in the form — seeders and imports bypass the form.seoTitle() / seoDescription() with fallbacks so every template behaves identically, and reuse them in the sitemap. The same helpers double as a cheap coverage metric.with('meta'). Both are pass/fail, not best-effort.migrate:rollback in the sandbox. Scope the cost of leaving (migration to a JSON column or dedicated columns) now, while there are forty test rows instead of forty thousand.Compatibility. Define Relation::enforceMorphMap() in AppServiceProvider — a meta table full of FQCNs is a refactor trap you inherit permanently. Add a deleted observer or scheduled cleanup if the package does not cascade. If value is not cast to JSON, restrict meta to genuinely textual data and keep booleans/numbers in real columns, or cast deliberately in the wrapper with a marker.
Sequencing relative to product. This sits cleanly ahead of a broader content/CMS initiative: it converts a "make vs. buy vs. defer" debate over SEO into a one-week spike with a real artifact instead of an estimate. It should not be sequenced ahead of a decision about whether SEO is in scope at all, and it should not be sequenced in parallel with a Filament major upgrade.
Deliberate non-goals for the spike. Do not build a keyed repeater or custom field-group UI on top of the relation manager in week one; the row-per-key UX is acceptable for ad-hoc keys, and a prettier editor is a follow-up. Do not plan to filter, sort, export, or report on meta values — if a key needs that at volume, promote it to a real column and say so in the conventions doc before the first such request arrives.
Maintenance. Highest-ongoing-cost item is not the table, it is the upstream relationship. Plan for the fork to be the support contract: assign an internal owner by name, keep the fork buildable, and do not expect upstream for urgent fixes. Budget a small recurring calendar item for Filament upgrade windows — branch, sandbox the relation manager, check for deprecated APIs. If upstream is abandoned, the vendored copy is the fix and the license permits it.
Support surface. Editor-facing support volume is driven by two things, both addressable cheaply. First, key drift — solved by the allowlist and a short conventions doc covering keys and their formats (title ~60 chars, description ~155, comma-separated keywords, strip tags). Second, "why does this page look wrong" — solved by making completeness visible: a boolean badge or icon column on the resource table showing whether title and description are populated. Eager-load meta on the listing query or the badge itself becomes the N+1.
Scaling. Adequate to very good for the use case it targets: a handful of key-value rows per content record, read through an eager-loaded relation. Degradation is predictable and comes from three places — N+1 reads in loops (preventable), filtering/sorting/aggregating on meta values (a design boundary, documented up front), and unbounded key growth from free-text entry (prevented by the allowlist). Note that a polymorphic meta row carries no tenant column; tenancy is inherited from the owner and only if the owner lookup is scoped, so multi-tenant panels need that verified explicitly rather than assumed.
Failure modes, ranked by likelihood and blast radius.
create-based write path — presents as "works on my machine, wrong title in production," miserable to trace because the first row wins nondeterministically. Fix is a unique index plus updateOrCreate everywhere.canViewForRecord() / canEdit() gated on update of the owner.Ramp-up. Low for a Laravel/Filament-fluent engineer: one to two days to a working spike, with the four-file read being the ten-minute version. The wrapper trait is the only piece requiring real design judgment and is worth a short review. Debugging has a clean, ordered checklist worth putting in the runbook: SHOW CREATE TABLE on the meta table (indexes, column types) → the relation manager's getQuery() (scoping) → DB::listen() during a list-view render (N+1) → row count per model_type to spot orphans and duplicates → policy check as a non-owner user.
Exit strategy. Cheap and known: the wrapper trait confines the blast radius to one file, MIT licensing makes fork-and-patch trivial, and the data migration to a JSON column or dedicated columns is a scoped exercise — provided you scope it during the spike rather than after the table has grown. The real answer to "what does this cost us if it dies" is roughly "a day of migration work plus a fork we already own," which is a reasonable price for removing an estimate from the SEO decision.
How can I help you explore Laravel packages today?