kunstmaan/seo-bundle
Kunstmaan SEO Bundle provides SEO management for Symfony/Kunstmaan CMS sites, adding editable metadata and meta tags to pages plus sitemap support to help improve search engine visibility and control how content appears in search results.
Read this first: kunstmaan/seo-bundle is not a Laravel package — it is a Symfony 2.x bundle. There is no service provider, no facade, no config/seo.php. Its last release was April 2014, it targets PHP 5.3/5.4 and Symfony 2.3-era APIs, and it has no downstream dependents. On a modern Laravel/PHP 8+ app composer require kunstmaan/seo-bundle will fail or pull a dependency tree you do not want. Treat this assessment as "port the model, not the package."
What actually ships in it: a Seo entity (title, meta description, meta keywords, robots directives, OpenGraph fields, Twitter card fields), a Symfony form type for editing them, a Doctrine association so any content entity can own one, a Twig extension/partial that emits the <head> tags, and a free-text "extra header" field for things like Metriweb/analytics snippets.
Minimal orientation if you are reading the source:
Entity/Seo.php — the data model. This is the only file worth copying.Form/Type/SeoType.php — which fields the editor exposes and in what groups.Twig/ + Resources/views/ — the tag emission order and escaping behaviour.DependencyInjection/Configuration.php — the handful of knobs (defaults for title/description, whether to render OG).First use case to implement (in Laravel): a seo_metadata table with a polymorphic seoable relation, a HasSeo trait on your page/post models, a SeoMetadata data object, and a Blade partial included in your layout head. That is a two-hour job and gives you 90% of what this bundle provides, with none of the Symfony 2 baggage.
// database/migrations/..._create_seo_metadata_table.php
$table->id();
$table->morphs('seoable');
$table->string('title')->nullable();
$table->string('meta_description', 320)->nullable();
$table->string('meta_keywords')->nullable();
$table->string('robots')->default('index,follow');
$table->string('og_title')->nullable();
$table->text('og_description')->nullable();
$table->string('og_image')->nullable();
$table->string('og_type')->default('website');
$table->text('extra_head')->nullable();
$table->timestamps();
{{-- resources/views/partials/seo.blade.php --}}
@php($seo = $seo ?? null)
<title>{{ $seo?->title ?: ($title ?? config('app.name')) }}</title>
@if ($seo?->meta_description)
<meta name="description" content="{{ $seo->meta_description }}">
@endif
@if ($seo?->robots)
<meta name="robots" content="{{ $seo->robots }}">
@endif
<meta property="og:title" content="{{ $seo?->og_title ?: ($seo?->title ?: $title) }}">
<meta property="og:image" content="{{ $seo?->og_image ? url($seo->og_image) : '' }}">
Sidecar entity, never inline columns. The bundle's core design decision — SEO fields live in their own entity joined to the content — is the right one and worth keeping. It keeps your page table clean, lets you null the whole thing out when untouched (so you can distinguish "editor left it blank, use fallback" from "editor explicitly cleared it"), and lets one content type have one row per locale.
trait HasSeo
{
public function seo(): MorphOne
{
return $this->morphOne(SeoMetadata::class, 'seoable');
}
public function seoOrDefaults(): SeoMetadata
{
return $this->seo ?? SeoMetadata::fromModel($this);
}
}
Fallback resolution is the highest-value pattern. The bundle is thin on this, but it is what makes an SEO field set actually usable. Editors leave things blank; your renderer should degrade gracefully: description ← excerpt ← first 155 chars of body; og:image ← explicit image ← first image in the body ← site default; title ← seo title ← page title ← "Page — Site".
public static function fromModel(Model $model): self
{
return new self([
'title' => $model->title,
'meta_description' => Str::limit(strip_tags($model->body ?? ''), 155),
'og_image' => $model->hero_image ? url($model->hero_image) : config('seo.default_image'),
]);
}
Compose the head, don't echo it raw. The bundle emits tags from a Twig template with a fixed order. Keep that: charset → title → description → robots → canonical → OG → Twitter → extra_head last. Include the partial from layouts/app.blade.php and pass $seo explicitly from each controller — implicit view-shared state is how you end up with the homepage's OG image on every product page.
Treat the "custom header" field as a plugin point, not a textarea. The Metriweb/custom-header field is the one part of this bundle that ages well (tracking vendors change constantly), but rendering user-supplied HTML into <head> is an XSS vector and a cache-poisoning vector. Either run it through a whitelist sanitizer (HTMLPurifier with only meta, link, script[src] allowed) or replace it with named slots: analytics_head, verification_tags, custom_meta.
Admin UX: one collapsible "SEO" tab, not fields strewn through the form. The bundle's SeoType is embedded as a sub-form in the page form. In Laravel do the same with a nested seo field group, Livewire component, or Nova/Filament fieldset — and add live character counters on title (~60) and description (~155). That single UI touch reduces bad metadata more than any validation rule.
Multi-locale means one row per translation. The Kunstmaan bundles pair each NodeTranslation with its own Seo. If you have spatie/laravel-translatable content, give the SEO row a locale column or make it a child of the translation record — never share one metadata row across languages.
Pair it with sitemap/robots output. The robots directive column and the canonical data are exactly what your sitemap.xml generator and per-page noindex need. Read from the same table so an editor marking a page noindex removes it from the sitemap automatically.
Cache deliberately. The head block must vary by entity and locale. If you HTTP-cache full pages, put the SEO fragment in the page cache with the right Vary/cache key, or you will serve one language's OG tags to another.
kernel.root_dir, $container->get() in controllers, Twig 1 syntax) will not run under Symfony 5+/6+, let alone Laravel. Do not budget for "just wiring it up"; budget for a port.Seo entity's property names (ogTitle vs facebookTitle, metaKeywords presence) differ across Kunstmaan releases. Read the actual Entity/Seo.php in the tag you install before writing templates against it.'', so a naive {% if seo.metaDescription is defined %} renders <meta name="description" content="">. Always check !empty() / filled().og:image must be absolute. Symfony's asset() and Laravel's asset() both return scheme-relative or path-only URLs depending on config. Facebook and Slack silently drop relative image URLs. Force url() or Storage::url() with the full host.og:title and twitter:title are inside attribute values; a title containing " breaks the tag unless escaped (e() / {{ }} is correct — but don't wrap the whole tag in {!! !!} to "fix" a stray entity).og:url from the current request will throw RuntimeException in a queued job or artisan command. Pass the URL in or resolve it from the model's route, never from request().<title> and <h1> is a common editor mistake; label them clearly ("Browser tab title" vs "Page heading").Resources/views/ in app/Resources/KunstmaanSeoBundle/views/, decorate the Twig extension service, or add your own Twig function for JSON-LD (which the bundle predates entirely — no structured data support). In Laravel, the equivalents are a Blade component, a SeoService binding, and a @jsonld directive.assertSee('<meta name="description"', false) in Laravel, Crawler in Symfony — including the empty-SEO case, because that is where fallbacks and stray empty tags live.How can I help you explore Laravel packages today?