gwd/seo-meta-nova-field
gwd/seo-meta-nova-field adds a ready-to-use SEO metadata field to Laravel Nova. Manage page titles, meta descriptions, keywords, Open Graph and Twitter card tags from a clean Nova form, keeping SEO content alongside your Eloquent models. Reduce custom code and keep metadata editable by non-developer...
Verify the exact API against the README before wiring anything up — the package is small (24 stars, no dependents) and the docs are the only source of truth for the field class name and expected schema.
Install into an existing Nova app:
composer require gwd/seo-meta-nova-field
Check for a publish step (php artisan vendor:publish --provider="Gwd\SeoMeta\...") — most Nova fields publish nothing, but confirm before assuming.
Add storage. The field bundles multiple SEO inputs into one Nova field, which almost always means a single JSON column. Confirm the expected column in the README, then:
Schema::table('pages', function (Blueprint $table) {
$table->json('seo')->nullable();
});
Cast it so Blade can read it as an array:
protected $casts = ['seo' => 'array'];
Register on the resource:
use Gwd\SeoMeta\SeoMeta; // confirm namespace from README
public function fields(Request $request)
{
return [
ID::make()->sortable(),
Text::make('Title'),
Slug::make('Slug'),
SeoMeta::make('SEO', 'seo')
->hideFromIndex(),
];
}
Where to look first: src/ for the field class (tells you the column contract and whether it uses fillUsing/resolveUsing), and resources/js/ for the Vue component (tells you every sub-field it actually renders — title, description, OG image, robots, canonical, etc.). The component is the real documentation.
First use case: a Page or Post resource where an editor sets meta title, meta description, OG image, and robots directives without leaving the Nova form — then you render those values in your Blade <head>.
Render from a head partial. Keep SEO markup in one place and always fall back:
@php($seo = $page->seo ?? [])
<title>{{ $seo['title'] ?? $page->title }}</title>
<meta name="description" content="{{ $seo['description'] ?? Str::limit(strip_tags($page->body), 155) }}">
@if(!empty($seo['og_image']))
<meta property="og:image" content="{{ Storage::disk('public')->url($seo['og_image']) }}">
@endif
<meta name="robots" content="{{ ($seo['robots'] ?? 'index,follow') }}">
Centralise defaults with an accessor so controllers and views never repeat fallback logic:
public function getSeoMetaAttribute(): array
{
$seo = $this->seo ?? [];
return [
'title' => $seo['title'] ?? $this->title,
'description' => $seo['description'] ?? Str::limit(strip_tags($this->body), 155),
'og_image' => isset($seo['og_image'])
? Storage::disk('public')->url($seo['og_image'])
: null,
];
}
Multi-locale sites: the package has no i18n concept. Use one column per locale (seo_en, seo_fr) and one field per column, rather than nesting locales inside a single JSON blob — Nova will resolve each column cleanly and you avoid migration pain later.
Hide it from index views. The field renders a whole form of inputs; on an index table it is noise. ->hideFromIndex() is almost always what you want. On Nova 4, wrap it in a Tab::make('SEO') or Panel so the resource form stays readable.
Validate the nested payload. Enforce length limits so editors don't paste 400-character descriptions:
SeoMeta::make('SEO', 'seo')
->rules('array')
->hideFromIndex(),
If per-key rules aren't supported by the field, add a FormRequest/model mutator that trims the array before save.
Keep image handling explicit. Social platforms need absolute URLs. Store the relative path (Nova's Image-style storage does this), and convert to a full URL at render time as shown above — never emit the raw path into og:image.
Text/Textarea/File fields plus a JSON column if needed.null, not []. Always ?? [] before array access, or you'll get "undefined index" on pages that never had SEO filled in.make() doesn't match the actual column, saves appear to succeed but nothing persists. dd($model->getRawOriginal('seo')) after saving to confirm.fillUsing/resolveUsing in the field class before writing your migration — getting this wrong means the field renders but never round-trips./nova-api/... response in the network tab to see exactly what the field emits, and confirm the Vue component name matches what's registered in Nova::script()/service provider. A blank field is nearly always a component-name or asset-registration mismatch, not a database problem.$component, or publishing the compiled assets and patching the Vue component directly. Avoid editing vendor/.<meta name="description"> — a common cause of "SEO looks broken" bug reports.How can I help you explore Laravel packages today?