gurtok/seo-bundle
gurtok/seo-bundle is a Laravel/PHP package for adding SEO metadata to applications. It offers helpers for meta tags, titles, descriptions, canonical links, Open Graph tags, and sitemap support to streamline basic on-page SEO setup.
gurtok/seo-bundle gives you a single SeoManager service that renders meta tags (title, description, canonical, Open Graph, Twitter cards) from data you set per request. Start here:
composer update gurtok/seo-bundle (v2.2.0 is fully backward-compatible; nothing to migrate).SeoManager is the entry point. Inject it into controllers, view composers, or middleware — everything else in the bundle hangs off it.<head> (typically {!! $seo !!} or the package's dedicated Blade component/directive — check the package view or README for the exact render call; it is the only line you usually need).public function show(Post $post, SeoManager $seo)
{
$seo->setTitle($post->title)
->setDescription($post->excerpt)
->setCanonical(route('posts.show', $post))
->setImage('/images/posts/'.$post->slug.'.jpg'); // relative — resolved to absolute in 2.2.0
return view('posts.show', compact('post'));
}
Before 2.2.0 you had to hand-build absolute URLs for og:image/twitter:image and repeat title/description three times. Now one set of values is enough.
Set once, inherit everywhere. The 2.2.0 fallback chain means you should stop specifying OG/Twitter variants explicitly. og:title/twitter:title derive from title; og:description/twitter:description derive from description; og:url derives from the canonical URL. Override only when the social copy genuinely differs (e.g. a shortened headline for sharing).
Centralize in a view composer or middleware. Rather than injecting SeoManager into every controller, resolve defaults once for the whole request:
// app/Providers/AppServiceProvider.php (boot)
View::composer('layouts.app', function ($view) {
$seo = app(SeoManager::class);
$seo->setTitle(config('app.name'))
->setImage('/images/og-default.jpg'); // still works for pages that never override
$view->with('seo', $seo);
});
Then per-page overrides in the controller mutate the same singleton instance. This is the pattern that gets you sane defaults on every page with near-zero per-page code.
Swap the resolver for a CDN or S3 disk. This is the main extension point of the release. Implement ImageUrlResolverInterface and bind it — do not edit vendor code or pre-absolutize paths at every call site:
namespace App\Seo;
use Gurtok\SeoBundle\Contracts\ImageUrlResolverInterface;
class CdnImageUrlResolver implements ImageUrlResolverInterface
{
public function resolve(string $path): string
{
if (preg_match('#^(https?:)?//#', $path)) {
return $path; // already absolute or protocol-relative
}
return rtrim(config('filesystems.disks.s3.url'), '/').'/'.ltrim($path, '/');
}
}
// AppServiceProvider::register()
$this->app->bind(ImageUrlResolverInterface::class, \App\Seo\CdnImageUrlResolver::class);
Bind in AppServiceProvider (not boot) so the override is registered after the package's own default binding — last binding wins for bind(), and the package registers its default during its own provider registration. Verify with app(ImageUrlResolverInterface::class) in php artisan tinker.
Queue-safe / CLI-safe images. Anything that generates SEO output in a job or console command has no HTTP host — the default resolver's absolute-URL logic has to lean on APP_URL / a configured base. If you render SEO tags inside emails, PDFs, or sitemaps, supply your own resolver so you never depend on the ambient request.
Multi-locale and canonical hygiene. Because og:url now silently falls back to canonical, make sure setCanonical() is actually called (or that the package's default derives it from the current route). A missing canonical used to mean a missing og:url; now it can mean nothing is emitted for either — the fallback is a fallback, not a guarantee.
og:title / twitter:description / og:url are missing when unset, 2.2.0 now emits them via fallbacks. This is a test-suite breakage, not a runtime breakage — update assertions to expect the inherited value.images/logo.jpg, /images/logo.jpg, //cdn.example.com/x.jpg, and https://... are different inputs. Check how your resolver (and the bundled one) treats each before assuming a leading slash is required. Protocol-relative URLs (//host/...) are the classic source of a doubled https://https:// or a stray // — guard for them explicitly in any custom resolver.base_url . '/' . $path blows those up.SeoManager constructor changed (optional argument). Backward-compatible for container-resolved instances, but if you new SeoManager(...) manually or have a subclass with a hard-coded constructor signature, update it. Prefer app(SeoManager::class) or a proper binding over manual instantiation.SeoManager to tweak image URLs will fight the new resolver. ImageUrlResolverInterface exists precisely so you don't have to touch the manager.app(ImageUrlResolverInterface::class)->resolve('/images/logo.jpg'). If the wrong class comes back, you have a binding-order problem in your service provider, not a package bug.config() or tenant context means you cannot share cached HTML across domains.og:* → base field). They won't rescue an empty description. If you need computed fallbacks (excerpt from body, title from route name), set them yourself in a view composer.How can I help you explore Laravel packages today?