marshmallow/seoable
marshmallow/seoable is a Laravel package that adds SEO metadata to Eloquent models, letting you manage meta titles, descriptions, and social sharing tags through a simple, reusable trait for consistent per-record SEO.
Install the package via Composer and confirm which Nova versions you are on before wiring it into a production resource — the v5.2.0 release widened marshmallow/nova-fields-help to ^v2.0 || ^3.0, which is the dependency that actually decides your Nova compatibility. Check which branch Composer resolves for you:
composer require marshmallow/seoable
composer show marshmallow/nova-fields-help
Typical first steps:
Understand the storage model before touching migrations. The v5.2.0 notes reference a seoable relationship (an N+1 fix landed on it), so the payload is not necessarily a flat column on your model — it may be a polymorphic/morphOne side table. Publish and read the migration stub rather than guessing:
php artisan vendor:publish --provider="Marshmallow\Seoable\SeoableServiceProvider"
php artisan migrate
Then inspect the created table(s). If it is a relation, you do not add a seo column yourself; if it is a JSON column, you do. Confirm by saving one record and looking at the schema.
Add the field to a resource. The intended usage is a single Nova field that renders all the SEO inputs at once:
use Marshmallow\Seoable\Seoable; // verify exact namespace
public function fields(Request $request)
{
return [
ID::make()->sortable(),
Text::make('Title'),
Slug::make('Slug'),
Seoable::make('SEO'),
];
}
First use case. Point it at a Page or Post resource where editors manage title, description, Open Graph image, canonical URL, and robots directives per record. Once the field renders, save and dump the record ($page->seoable vs $page->seo) — that single check tells you whether you are working with a relation or a cast column, which determines everything downstream.
Eager-load the SEO relation on index screens. v5.2.0 specifically fixes an N+1 on the seoable relationship, which means the naive index/list path was querying per row. Make the eager load explicit in your resource so you are not relying on the upstream fix alone:
public static $with = ['seoable'];
// or, per-query:
Page::with('seoable')->paginate();
Use $with/with() rather than lazy access in fields(), and re-check your own custom resolve/indexQuery overrides — those are the usual places this regresses.
Store the whole SEO block on the model, render it in Blade. The field is only the admin half. Either the package ships a partial, or you write a small one that reads the SEO payload and emits <title>, <meta name="description">, canonical, and the og:* tags. Whichever route, resolve the payload through one place — a seo() accessor or a view composer — not inline in ten templates.
Pair og_image with a media library. Nova's File field stores a path, not a URL. Resolve it through your disk and make it absolute:
'og_image' => $page->seo['og_image']
? Storage::disk('public')->url($page->seo['og_image'])
: asset('images/default-og.png'),
Fallbacks beat empty tags. Editors leave fields blank. Layer them in an accessor so the head is never broken:
meta_title → $model->titlemeta_description → Str::limit(strip_tags($model->body), 155)og_title → meta_titlecanonical → url()->current()Per-locale SEO. If the app is multilingual, decide up front whether the payload is {"en": {...}, "nl": {...}} or one relation row per locale, because changing it later is a data migration. Nova will not merge locales for you.
Reusable trait on resources. Wrap the resource-side wiring (field + $with + rules + defaults) in a trait so every content resource gets identical SEO setup without copy-paste drift.
Respect robots at render time. Honour index/follow toggles in the partial (<meta name="robots" content="noindex, nofollow">) and exclude those records from any sitemap generator.
nova-fields-help constraint is the real compatibility switch. ^2.0 || ^3.0 means the package can resolve against two different Nova generations depending on what your app already requires. After composer update, run composer show marshmallow/nova-fields-help and verify you landed on the branch matching your Nova version — a silent downgrade here presents as a blank field card, not an error.->with('seoable') on them; the release only covers the package's own paths.seoable relation is in play, $fillable is irrelevant but $with and cascade deletes are not. If you also have a legacy seo JSON column from an earlier version, plan the backfill before you delete anything.php artisan nova:publish and rebuild assets; check whether the package ships pre-built dist/ or requires npm run prod inside vendor/.null on records created before the migration ran. Guard with $page->seo ?? [] or array_merge($defaults, $page->seo ?? []) rather than indexing directly.meta_description at ~155–160 chars for display, but keep the column TEXT/JSON — a VARCHAR(160) will eventually eat an editor's work.saved/deleted events (and on the related SEO model if it is a separate table), not inside the Nova field.og:image values are silently ignored by social crawlers — test with a real share preview, not just the DOM.Nova::script() in a service provider plus the browser console usually reveals a Vue prop mismatch. In tinker, compare $page->seo / $page->seoable with Seoable::make('seo')->resolve($page) to separate a data problem from a rendering one.resources/js/fields and register it rather than patching inside vendor/.Nova::actingAs($admin)->get('/nova/resources/pages/1/edit')) plus an index test asserting query count stays flat, so both a Nova upgrade and an N+1 regression are caught in CI rather than by an editor.How can I help you explore Laravel packages today?