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

Seo Bundle Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. Entity/Seo.php — the data model. This is the only file worth copying.
  2. Form/Type/SeoType.php — which fields the editor exposes and in what groups.
  3. Twig/ + Resources/views/ — the tag emission order and escaping behaviour.
  4. 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) : '' }}">

Implementation Patterns

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.

Gotchas and Tips

  • Abandonware risk is the headline gotcha. Last release 2014, 24 stars, 0 dependents, score 0.18. Symfony 2 service definitions (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.
  • Field names drift between versions. The 2014 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.
  • Empty string is not null. Untouched form fields arrive as '', 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.
  • Double escaping. 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).
  • Canonical URL generation from console/queue context. Anything that builds canonical or 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().
  • Don't reuse the SEO title as the H1. The bundle has a separate page title field for a reason. Duplicating them across <title> and <h1> is a common editor mistake; label them clearly ("Browser tab title" vs "Page heading").
  • Raw custom header = stored XSS. Sanitize on save and on render, and remember it will be cached — a poisoned snippet lives in Varnish until purge.
  • Extension points, if you must stay on Symfony: override 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.
  • No JSON-LD, no canonical field, no hreflang. The bundle covers meta/OG/Twitter only. Modern SEO needs all three; plan to add them yourself.
  • Test the output, not the entity. Assert on rendered HTML — 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.
  • Verdict: steal the schema and the fallback-resolution idea, leave the code. A ~200-line Eloquent model, trait, Blade partial and form request will outperform this bundle on every axis a Laravel developer cares about.
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.
coleus/frame
thtg88/journalism
filagora/filagora-ai-forums
rlacerda83/query-parser
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings