murdercode/seo-title
A Laravel package for managing SEO titles and meta tags. Generate dynamic, model-aware page titles and descriptions with customizable templates, length limits, and sensible fallbacks, so every route or Eloquent record gets consistent, search-friendly metadata.
murdercode/seo-title is a small Laravel Nova 4 field that wraps a text input with SEO-oriented UI (character counting and a search-result style preview) so editors can see how a title will look in Google while typing.
Install
composer require murdercode/seo-title
php artisan nova:publish # only if the package ships compiled assets
Requirements to confirm before installing: PHP 8.1+, Laravel 9/10, and Nova 4 (this package targets the Nova 4 field API; it will not work on Nova 3).
Where to look first
vendor/murdercode/seo-title/README.md — the canonical usage snippet and the exact field class/namespace (verify it; the namespace does not always mirror the Composer name).vendor/murdercode/seo-title/src/ — usually a single SeoTitle.php extending Laravel\Nova\Fields\Text. Read component() to confirm the Vue component name, and check whether it adds rules, maxlength, or a preview option.vendor/murdercode/seo-title/resources/js/ — the preview/counter logic lives here, which tells you what the field actually validates client-side.First use case
Add a seo_title string column and drop the field into a Resource:
// database/migrations/...
$table->string('seo_title', 70)->nullable();
// app/Nova/Page.php
use Murdercode\SeoTitle\SeoTitle; // confirm namespace from src/SeoTitle.php
public function fields(NovaRequest $request)
{
return [
SeoTitle::make('SEO Title', 'seo_title')
->rules('nullable', 'string', 'max:60')
->help('Aim for 50–60 characters.'),
];
}
Then render it in your front end:
<title>{{ $page->seo_title ?? $page->title }}</title>
That fallback pattern (SEO field, then content title) is the single most useful habit with a field like this — it means an empty SEO title never produces an empty <title>.
Pair it with a meta description field. The field is only useful if the surrounding SEO block is coherent. Group title, description, and slug in a Nova panel or a separate "SEO" tab so editors see them together:
Panel::make('SEO', [
SeoTitle::make('SEO Title', 'seo_title')->rules('nullable', 'max:60'),
Text::make('Meta Description', 'meta_description')
->rules('nullable', 'max:160')
->hideFromIndex(),
Text::make('Slug', 'slug')->rules('required', 'alpha_dash'),
]),
Store SEO data in a JSON column when you have many fields or locales. A single seo JSON column keeps migrations stable, and Nova handles it with dot notation:
SeoTitle::make('SEO Title', 'seo.en.title')->rules('nullable', 'max:60'),
SeoTitle::make('SEO Title (FR)', 'seo.fr.title')->rules('nullable', 'max:60'),
Centralize rendering with an accessor or a small helper rather than repeating ?? $model->title in every Blade view:
// app/Models/Page.php
public function getSeoTitleAttribute(): string
{
return $this->attributes['seo_title']
?? $this->title;
}
Use it on the index view sparingly. The SERP preview is a form component; on index tables it degrades to a plain value. If you want a quick editorial audit, show the field on index and sort by it to find empty SEO titles:
SeoTitle::make('SEO Title', 'seo_title')->sortable()->exceptOn('detail'),
Generate a default from content on create. A common workflow is to prefill the SEO title from the page title via a creating observer so the field is never blank, while still allowing editors to override it.
Integrate with sitemap/SEO packages. If you use spatie/laravel-sitemap or a seoable trait, map this column into the model's meta accessor rather than calling the field anywhere outside Nova — the field is strictly an admin-side concern.
Validation belongs outside the field. Treat the Nova field as presentation; put the length limits in a Form Request or in the model so API/import paths get the same protection.
This package is very small (2 stars, 0 dependents, last release Feb 2024). Assume minimal maintenance. Pin the version in composer.json, and be prepared to vendor the field class into app/Nova/Fields/ if you need a fix — it is almost certainly a thin Text subclass with a custom Vue component, so copying it is a 20-minute job.
Do not rely on the client-side character counter for validation. Counters are cosmetic. Always add ->rules('max:60') (or a model-level rule) — otherwise an editor pasting a long string will happily save 300 characters.
Character count is not pixel width. Google truncates by rendered pixel width, not characters. A 60-character string of Ws and a 60-character string with narrow letters render very differently. If the preview looks wrong, that is why — it is a heuristic, not a guarantee.
Multibyte characters may be miscounted. If the package's counter uses strlen rather than a Unicode-aware length, CJK titles and emoij will report inflated counts. Check the JS component; if it is wrong, override the Vue component or accept the inaccuracy and document it.
Confirm the actual namespace and component name. The Composer name (murdercode/seo-title) and the PHP namespace (Murdercode\SeoTitle in the example above) do not always match, and the repo name (Nova4-SeoTitle) differs again. A Class not found error almost always means you guessed the namespace — open src/ and copy the real one.
Missing assets after install. If the field renders as a plain input or the JS errors, the compiled assets were never published. Run php artisan nova:publish and, if the package registers assets via a service provider, confirm Nova::script() / Nova::style() calls exist. Clear storage/app/nova-* and re-publish if you upgraded.
Nova 4 only. Installing alongside Nova 3 will either fail dependency resolution or silently break the field. Check the composer.json require block for the Nova constraint before adopting.
Empty vs null. ->nullable() in the migration plus a fallback accessor prevents the <title></title> problem. Also decide whether an empty string should be treated as "use the default" — normalize '' to null in a mutator so the fallback logic stays simple.
Extension points. Because it extends Text, you can chain the usual Nova modifiers — ->placeholder(), ->help(), ->hideFromIndex(), ->required() — and you can subclass it to add your own default rules:
class SeoTitle extends \Murdercode\SeoTitle\SeoTitle
{
public function __construct($name, $attribute = null, $resolveCallback = null)
{
parent::__construct($name, $attribute, $resolveCallback);
$this->rules('nullable', 'string', 'max:60');
}
}
Testing. In Nova tests, resolve the field and assert against the underlying model attribute rather than the component; there is no meaningful rendering to assert for a custom field in a feature test. Use Nova::actingAs() and put() against the resource endpoint.
Have an exit plan. Given the adoption numbers, budget for replacing this with a plain Text::make('SEO Title')->maxlength(60)->help(...) plus your own Blade preview if the package goes stale. The functional delta is small, and the migration is a one-line field swap plus deleting a column of custom markup.
How can I help you explore Laravel packages today?