apility/seotools
Apility SEO Tools is a Laravel package for managing SEO metadata from PHP and Blade. Configure meta tags, Open Graph and Twitter cards, canonical URLs, robots directives, and structured data through a fluent API for cleaner, more maintainable templates.
Install
composer require apility/seotools
Laravel's package auto-discovery registers Apility\SEOTools\SEOToolsServiceProvider and the SEOTools facade alias. On Lumen or with discovery disabled, register the provider manually and add 'SEOTools' => Apility\SEOTools\Facades\SEOTools::class to your aliases.
Publish the config first — this is the file you'll actually live in
php artisan vendor:publish --provider="Apility\SEOTools\SEOToolsServiceProvider"
This drops config/seotools.php with meta, opengraph, twitter and json-ld sections. Every section has a defaults array; defaults ship as false, so nothing renders until you either fill them in or set values at runtime. Spending ten minutes on this file up front saves you from repeating the same setSite() / default-image calls in every controller.
Render once, in your layout
{{-- resources/views/layouts/app.blade.php --}}
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
{!! SEOTools::generate() !!}
</head>
generate() returns a concatenated HTML string — it must be output unescaped ({!! !!}), and it should appear exactly once per response.
First use case: per-page tags from a controller
use Apility\SEOTools\Facades\SEOTools;
public function show(Post $post)
{
SEOTools::setTitle($post->title);
SEOTools::setDescription(Str::limit(strip_tags($post->excerpt), 155));
SEOTools::setCanonical(route('posts.show', $post));
SEOTools::opengraph()
->setType('article')
->setTitle($post->title)
->setUrl(route('posts.show', $post))
->addProperty('article:published_time', $post->published_at->toIso8601String());
SEOTools::twitter()
->setType('summary_large_image')
->setTitle($post->title);
return view('posts.show', compact('post'));
}
Config for the 90% case, facades for the exceptions. Put site name, twitter handle, default OG image, locale and separator in config/seotools.php. Override per route in the controller. Anything more clever than that belongs in a middleware or view composer, not scattered through Blade.
Global defaults via middleware. The most common real-world pattern is a SeoDefaults middleware that sets site-level tags before the controller runs, so controllers only touch page-specific values:
public function handle($request, Closure $next)
{
SEOTools::setTitle(config('app.name'), false);
SEOTools::opengraph()->setSiteName(config('app.name'));
SEOTools::twitter()->setSite('@myhandle');
return $next($request);
}
Model-driven SEO. A small HasSeo trait that maps model columns (or a seo_meta JSON column, or a polymorphic seo_metas table) onto the facades keeps controllers thin and makes the tags testable. This is the pattern that scales when marketing asks for editable titles per record.
Blade component wrapper. If designers need to inject tags mid-page (e.g. article body), expose a <x-seo :model="$post" /> component that calls the trait, rather than letting Blade templates reach into the facade directly.
JSON-LD as a first-class concern. Use SEOTools::jsonLd()->setType('Article')->addValue('headline', $post->title) and dot-notation for nesting (addValue('author.name', $post->author->name)). When a page needs more than one graph node (Article + BreadcrumbList + Organization), use the multi-JSON-LD helper rather than merging arrays by hand.
Testing. Because the facade mutates a shared container, assert against SEOTools::generate() inside a feature test that hits a real route — that catches both "forgot to set the title" and "rendered the wrong thing twice". Reset the singleton in setUp() (or a base TestCase) so state doesn't bleed between tests.
Sitemap and robots. These are opt-in, config-driven features. Register their routes only in the environments where they matter (usually not local), and drive them from the config file rather than hardcoding paths.
'title' => false in config/seotools.php means <title> is simply absent. If tags are missing, check the config before you check your code.addMeta() argument order is (name, content, attribute) — the third parameter is the attribute name ('property' for OG, 'name' by default). Getting this backwards produces tags that look fine but are invisible to crawlers.setType('article') before adding article:* properties; otherwise the og:type tag is missing or wrong and article metadata gets ignored by validators.terminate() or at the top of each request/job.generate() output is raw HTML. Never interpolate user input into setTitle() / setDescription() without sanitising — you are writing directly into <head>.separator) and in the second argument of setTitle($title, $appendDefault). Decide once whether page titles get the site name appended; retrofitting it later means touching every controller.assertSee('og:title', false)), not full string equality — or you'll be rewriting tests on every bump.Cache::remember("seo:{$route}:{$locale}", ...) around the values you feed in is safe; caching the SEOTools instance itself is not.metatags(), opengraph(), twitter(), jsonLd()) are resolved from the container, so you can bind your own implementation or decorate them if you need custom tag sets. Published views can be overridden if you need to reorder output.^2.0 (or even 2.0.1) and skim the diff before upgrading anything that renders customer-facing markup you can't easily re-verify.How can I help you explore Laravel packages today?