ibrows/simple-seo-bundle
A lightweight Symfony bundle for basic SEO management, helping you define page titles, meta descriptions, and related metadata through simple configuration. Easy to install and integrate into existing Symfony applications.
First, the reality check that shapes everything below: ibrows/simple-seo-bundle is a Symfony bundle, not a Laravel package. Its integration surface is config/bundles.php (or legacy AppKernel::registerBundles()), DI service definitions, Doctrine ORM entity mapping, a config tree, and a Twig extension. None of that boots under Laravel's service container. So as a Laravel developer, your day-to-day leverage is the pattern, not the package — you reimplement the equivalent in ~2–5 days and own it outright. The rest of this section shows both paths honestly: what using the bundle looks like if you're actually on (legacy) Symfony, and what the Laravel translation looks like.
composer.json before anything else. This bundle has 0 stars, 0 dependents, and no visible release cadence — expect a PHP 5.x/7.x floor and Symfony 2.x/3.x constraints. If composer require ibrows/simple-seo-bundle doesn't resolve against your lockfile, the decision is already made for you.IbrowsSimpleSeoBundle::class => ['all' => true] to config/bundles.php. Legacy: add new Ibrows\SimpleSeoBundle\IbrowsSimpleSeoBundle() to AppKernel::registerBundles().config/packages/ibrows_simple_seo.yaml or legacy config.yml under ibrows_simple_seo:). Put your global fallback title, description, and keywords here — this is the "every page has sane metadata even if nobody set anything" tier.DependencyInjection/ (config tree + service wiring), Entity/ (the metadata record — this tells you whether rows are keyed by URL/path, route name, or per-entity columns), Twig/ (the renderer), and Resources/ (templates and any admin routes).base.html.twig, confirm the override beats the default.You are not installing this package. You are extracting its schema idea into three files:
app/Models/SeoMetadata.php // route_name/url_path, locale, title, description, keywords, canonical, robots
app/Services/SeoResolver.php // route/URL lookup -> model-level -> global default -> null
resources/views/components/seo.blade.php // <title> + <meta> rendering, escaped
Ship the read path first with defaults so no page regresses, then add authoring (Filament/Nova CRUD) as a separate ticket. 80% of the real effort is admin authoring, and the Symfony bundle's UI cannot help you there.
The bundle's design is a fallback hierarchy. Copy the hierarchy, not the code:
seo_metadata for the current route name (or URL path) + locale.$post->title, $product->name) plus its excerpt.Whichever tier wins, the renderer always emits something. The single biggest failure mode of hand-rolled SEO is a tier returning null and the template rendering <title></title> with no error.
// In a controller — override for this page
$seo->setTitle('Checkout — Acme');
$seo->setDescription('Secure checkout in under two minutes.');
$seo->setCanonical($this->generateUrl('checkout', [], UrlGeneratorInterface::ABSOLUTE_URL));
{# base.html.twig — the extension emits tags with defaults applied #}
{{ seo_render_meta_tags() }}
The day-to-day workflow is: defaults in config → per-controller overrides for the handful of pages that matter → admin CRUD for the rest.
Schema: one table, unique index on (url_path, locale) — or better, (route_name, params_hash, locale) if you want slug-rename safety (see Gotchas).
Schema::create('seo_metadata', function (Blueprint $t) {
$t->id();
$t->string('route_name')->nullable()->index();
$t->string('url_path')->nullable()->index();
$t->string('locale', 10)->default('en');
$t->string('title')->nullable();
$t->text('description')->nullable();
$t->string('keywords')->nullable();
$t->string('canonical')->nullable();
$t->string('robots')->default('index,follow');
$t->timestamps();
$t->unique(['url_path', 'locale']);
});
Resolver service:
class SeoResolver
{
public function __construct(private SeoMetadata $model) {}
public function resolve(string $route, ?string $path = null, string $locale = 'en'): SeoData
{
$row = Cache::remember(
"seo:{$locale}:{$route}",
now()->addDay(),
fn () => $this->model->where('route_name', $route)
->orWhere('url_path', $path)
->where('locale', $locale)
->first()
);
return SeoData::from([
'title' => $row?->title ?? config('seo.default_title'),
'description' => $row?->description ?? config('seo.default_description'),
'canonical' => $row?->canonical ?? url()->current(),
'robots' => $row?->robots ?? 'index,follow',
]);
}
}
Blade component (escape-by-default):
<title>{{ $seo->title }}</title>
<meta name="description" content="{{ $seo->description }}">
<link rel="canonical" href="{{ $seo->canonical }}">
<meta name="robots" content="{{ $seo->robots }}">
Cache invalidation: forget seo:{$locale}:{$route} in the model's saved/deleted events and on any slug-change observer. A per-route cache key without invalidation is how stale metadata lives forever.
Add Filament/Nova resource (or a plain CRUD) for seo_metadata. This is the piece the bundle would have given you and the piece you most need to build. Backfill top-N revenue URLs first, then the long tail.
composer require this into a Laravel app "just to reference it." You'll pull in Symfony components you never invoke, expand the version-conflict surface for zero runtime benefit, and inherit an owner-less dependency. Copy the schema into a design doc; leave the package out of composer.json.route_name key, or add a rename hook that migrates rows on slug change.<title> with no exception. Mitigate with enforced config defaults plus a monitoring check (e.g. a scheduled assertion that your top-N URLs return non-empty title and description).composer update as the fix.og_image, og_type, hreflang) as nullable columns rather than a serialized JSON blob — they're queryable and migrate cleanly.How can I help you explore Laravel packages today?