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 Meta Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

What you are actually installing. tomatophp/filament-meta is a thin polymorphic key/value side table (model_type, model_id, key, value) plus a trait that gives any Eloquent model a meta relation, a Meta model, a published migration and config, and a Filament relation manager that lets panel users edit those keys on any record. It is roughly the one-to-two-day implementation you would write yourself, packaged. Treat it as speed and convention, not capability — nothing here types, filters, validates, or aggregates your metadata.

Install and publish.

composer require tomatophp/filament-meta
php artisan vendor:publish --tag="filament-meta-migrations"
php artisan vendor:publish --tag="filament-meta-config"   # confirm the tag name in the README
php artisan migrate

Pin the version ("tomatophp/filament-meta": "1.0.*" or an exact tag) rather than floating it. With ~15 stars and 0 dependents there is no community buffer for a bad release, so also confirm the release date on Packagist before committing. MIT means fork-and-own is a one-command exit strategy — plan on it.

Read four files before you run anything. This is the entire risk assessment and it takes ten minutes:

  1. composer.json — which Laravel, PHP, Filament, and Livewire majors are constrained? Does it target your Filament major?
  2. database/migrations/* — is value a plain string or JSON? Is there a unique index on (model_type, model_id, key)? Is down() reversible?
  3. src/Traits/HasMeta.php — what is the relation called, which helpers exist (putMeta/getMeta/hasMeta/meta), and does it honor morph maps?
  4. The relation manager class — what is its FQCN, does it call into Filament policies, and does it scope to tenancy?

If there is no unique index on (model_type, model_id, key), add one in your own migration before writing a single row. Without it duplicate keys are a matter of when, and reads become nondeterministic.

Wire the trait onto your first model.

use Tomatophp\FilamentMeta\Traits\HasMeta; // confirm namespace in src/

class Page extends Model
{
    use HasMeta;
}

Register the relation manager on the resource whose records own the meta — not on a standalone resource. That is the intended day-to-day surface: an editor opens a Page, sees an "SEO"/"Meta" tab, and edits title, description, keywords, OG and Twitter values inline.

public static function getRelations(): array
{
    return [
        \Tomatophp\FilamentMeta\Filament\RelationManagers\MetaRelationManager::class, // confirm FQCN
    ];
}

Pick a deliberately small first use case: one content model (Page or Post), a handful of SEO keys, one named owner. Exercise create / edit / delete end to end, then once as a user who should not have permission. Measure the query count on the resource list view. That is your go/no-go spike, not a rollout.


Implementation Patterns

1. Relation manager for authoring, the trait for reading

The relation manager is the editor UI. Never let it become the read API — reads in your app go through the trait so the storage strategy stays swappable.

// Blade
<title>{{ $page->meta('seo_title') ?: $page->title }}</title>
<meta name="description" content="{{ $page->meta('seo_description') }}">
// Controller, sitemap builder, API resource — always eager load
$pages = Page::with('meta')->paginate(50);

You want one boring accessor that every read site calls. If the package doesn't ship one, add it in your wrapper (next pattern).

2. Wrap the package behind your own trait — this is the exit strategy

Never let package types leak into models, resources, or Blade. One file, one swap later:

namespace App\Support\Concerns;

use Tomatophp\FilamentMeta\Traits\HasMeta as BaseHasMeta;

trait HasMeta
{
    use BaseHasMeta;

    public function meta(string $key, mixed $default = null): mixed
    {
        return $this->metaRecords->firstWhere('key', $key)?->value ?? $default;
    }

    public function putMeta(string $key, mixed $value): void
    {
        $this->metaRecords()->updateOrCreate(
            ['key' => $key],
            ['value' => $value],
        );

        $this->unsetRelation('metaRecords');
    }
}

updateOrCreate, never create — that is the belt to the unique index's braces. When you later move to a JSON column or typed fields, you change this file and the backfill, not every call site.

3. Constrain keys to an allowlist, even though the package won't

Free-text keys become seo_titel, seo-title, and seoTitle within a sprint. Define the approved set as an enum and validate inside putMeta and in the relation manager form:

enum MetaKey: string
{
    case SeoTitle       = 'seo_title';
    case SeoDescription = 'seo_description';
    case SeoKeywords    = 'seo_keywords';
    case OgImage        = 'og_image';
    case TwitterCard    = 'twitter_card';
}
public function putMeta(MetaKey $key, mixed $value): void
{
    $value = match ($key) {
        MetaKey::SeoTitle, MetaKey::SeoDescription => Str::limit(strip_tags((string) $value), 160),
        MetaKey::SeoKeywords => implode(', ', array_filter(array_map('trim', explode(',', (string) $value)))),
        default => $value,
    };

    $this->metaRecords()->updateOrCreate(['key' => $key->value], ['value' => $value]);
    $this->unsetRelation('metaRecords');
}

Document the keys and their formats in a short conventions file. Enforce the same list in the relation manager's form() (a Select on key rather than a free TextInput) so editors cannot invent keys from the panel.

4. SEO fallback chain — the pattern that makes this feel finished

Raw meta values are almost never complete. Editors fill 60% and expect the rest to look right. Centralize the fallbacks so every template behaves identically:

public function seoTitle(): string
{
    return $this->meta(MetaKey::SeoTitle->value) ?: $this->title . ' — ' . config('app.name');
}

public function seoDescription(): string
{
    return $this->meta(MetaKey::SeoDescription->value) ?: Str::limit(strip_tags($this->body), 155);
}

Use these in Blade and in the sitemap generator. It doubles as a cheap coverage metric: Page::with('meta')->get()->filter(fn ($p) => $p->meta('seo_title'))->count().

5. Per-locale metadata without a schema change

If localized SEO is a real requirement, model it in the key, not in new columns: seo_title.en, seo_title.fr. It costs nothing today, leaves the package untouched, and migrates cleanly into a localized column later. Verify the package doesn't already ship per-locale variants before designing read helpers around this.

public function seoTitle(?string $locale = null): string
{
    $locale ??= app()->getLocale();

    return $this->meta("seo_title.{$locale}") ?: $this->title;
}

6. Seeders, imports, and backfills go through the helper

Bulk content must not bypass the allowlist and the updateOrCreate. Write once, reuse:

Page::with('meta')->chunkById(200, function ($pages) {
    DB::transaction(function () use ($pages) {
        foreach ($pages as $page) {
            $page->putMeta(MetaKey::SeoDescription, Str::limit(strip_tags($page->body), 155));
        }
    });
});

Chunk plus transaction-per-chunk so a failure doesn't leave half-migrated keys.

7. When the relation manager is the wrong UI

The relation manager is row-per-key. For a fixed 4–6 key SEO schema, a keyed repeater or a plain field group in the resource's own form is a nicer editor experience. Either accept the row-per-key UI, or put the important fields in your own form and write through putMeta — but route both write paths through the same validation, or you'll get two dialects of the same data.

8. Make completeness visible in the table

A badge column saves a lot of support tickets:

Tables\Columns\IconColumn::make('meta_complete')
    ->label('SEO')
    ->state(fn (Page $record) => $record->meta('seo_title') && $record->meta('seo_description'))
    ->boolean(),

Eager load meta on the listing query or this is an N+1 per row.

9. Coexistence with existing meta plumbing

If you already run Spatie medialibrary, a settings package, or a JSON meta column, decide now which store owns which keys and write it down. Two storage locations for "metadata" is the most common way this pilot quietly rots six months later.


Gotchas and Tips

The unique index is not optional. Check the migration first; if (model_type, model_id, key) isn't unique, ship your own migration adding it, then make every write path updateOrCreate. Duplicate keys produce "works locally, wrong title in production" bugs that are miserable to trace because the first row wins nondeterministically.

Eager load or die. $page->meta('seo_title') inside a table loop or Blade loop is an N+1 per row. Use with('meta') on every listing query, and flush the loaded relation after writes. Add a query-count assertion to the pilot's acceptance criteria — this is the single most likely performance regression.

Meta is not a query surface. Write it down. The moment someone asks to filter, sort, export, or report on a meta value, the EAV join degrades and the abstraction leaks. If a key must be filtered or sorted at volume, promote it to a real column (or a JSON column with a generated index). Say this in the conventions doc before the first request arrives.

Authorization is not automatic. Registering a relation manager does not guarantee it routes through your policies. Test explicitly: log in as a user who can view a Page but should not edit it, and try to edit its meta. If the relation manager ignores policies, override canViewForRecord() / canEdit() and gate on update of the owner record.

Tenancy scoping. With Filament tenancy, confirm the relation manager respects the owner's tenant. A polymorphic meta row has no tenant column of its own — it inherits from the owner, and only if the owner lookup is scoped.

Morph maps and orphaned rows. Define Relation::enforceMorphMap() in AppServiceProvider; a meta table full of FQCNs is a refactor trap. Then confirm cascade behavior — does deleting a Page delete its meta, or leave orphans? If unsure, add a model observer or a scheduled cleanup. Silent data rot otherwise.

Values are strings unless you make them otherwise. Check whether the migration casts value to JSON. If not, putMeta('is_featured', true) round-trips as "1". Either cast deliberately in your helper (encode with a marker) or restrict meta to genuinely textual data and keep booleans/numbers in real columns.

No validation means your editors are the schema. Enforce max lengths (title ~60, description ~155), strip tags, normalize separators in the write helper — not only in the form, because seeders and imports bypass the form entirely.

Filament major upgrades are the recurring risk. Relation managers have broken across v3→v4 before. Before any Filament upgrade window: branch, run the relation manager in a sandbox, check for deprecated APIs. Calendar item, named owner.

Pin, vendor, own it. Single maintainer, no dependents, no public production evidence. Assign an internal owner by name, pin the version, keep the fork buildable. Do not plan on upstream for urgent fixes — the fork is the support contract.

Debugging checklist, in order: 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 counts per model (Meta::query()->selectRaw('model_type, count(*)')->groupBy('model_type')) to spot orphans and duplicates → policy check as a non-owner user.

Know your exit before rollout. Run migrate:rollback in the sandbox. The cost of leaving is the data migration to a JSON column or dedicated columns — scope that as a known exercise now, while there are forty test rows, not later when there are forty thousand.

Extension points worth knowing: a config-driven custom Meta model (if exposed), subclassing the relation manager to override form()/table() and the policy hooks, and your own wrapper trait. Everything else should stay untouched so a fork-and-patch remains a five-minute job.

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.
robertmarney/lara-hierarchial-collections
simonschaufi/rule-doc-generator
emissaoja/emissaoja
blackbricksoftware/gravityforms-civicrm-form-processor
al-rahabi/fancy-crud
jinas/dhivatar-component
mltstephane/laravel-quality
bernskiold/laravel-talentlms
bernskioldmedia/laravel-form-architect
youbar/easy-crud
c-point-labs/laravel-hostyourai
dbravoan/dbcms-admin-theme-dbcms
dbravoan/dbcms-theme-db-cyber
dbravoan/dbcms-blocks-basic
dbravoan/dbcms-module-content-audit
dbravoan/dbcms-module-ai-assistant
paygate-to/paygate-php
padosoft/askmydocs-connector-base
defstudio/supercharged-carbon
bernskioldmedia/laravel-matomo