aerni/advanced-seo
A Laravel package for advanced SEO management: define meta tags, Open Graph and Twitter Card data, canonical URLs, and robots directives in code. It keeps SEO configuration centralized, consistent, and easy to customize across your application.
aerni/advanced-seo is a Statamic addon, not a generic Laravel package. Do not try to install it into a plain Laravel app — it requires Statamic 6, Laravel 12, and PHP 8.4 (per its composer.json). Install with:
composer require aerni/advanced-seo
Then run the install command / publish step the docs describe (config + any Eloquent migrations if you use the Eloquent driver), and clear caches (php artisan cache:clear, statamic:stache:clear) before touching the Control Panel, or you will chase ghosts on first boot.
Where to look first
src/Facades/Seo.php in vendor/aerni/advanced-seo — the facade is the front door to the whole domain. Everything you need programmatically is reachable from it.First use case: render meta tags in your layout.
Open your Antlers layout (resources/views/layout.antlers.html or your Blade equivalent) and output the computed seo variable inside <head>:
<head>
{{ seo }}
</head>
That single variable is the product. It is assembled by a CascadeComposer from site defaults → collection/taxonomy defaults → entry overrides, and it already contains the title, canonical, OG/Twitter tags, robots directives, and JSON-LD schema. Get this working on one collection before you configure anything else.
The second thing you will do is open the CP, go to Sites → your site → SEO, and fill in the site-level defaults (site name, title template, social image). Everything else layers on top of that.
The layering is fixed: site defaults → collection/taxonomy defaults → entry/term overrides. In practice this means:
Article schema", "products get noindex when out of stock") on the collection SEO set.When a field is empty at one level, it falls through to the next. That fallthrough is the feature — don't duplicate site defaults onto every collection "just to be safe", or editors lose the ability to change one thing in one place.
Seo facadeNever instantiate SeoSet, SeoSetConfig, or SeoSetLocalization yourself in application code. The facade gives you:
use Aerni\AdvancedSeo\Facades\Seo;
$seoSet = Seo::find('collections::pages');
$config = $seoSet->config(); // SeoSetConfig
$localization = $seoSet->localization(); // SeoSetLocalization for current site
The handle convention is {type}::{handle} — collections::pages, taxonomies::tags, sites::default. Only reach for the SeoConfig / SeoLocalization facades when you genuinely need the low-level repositories (bulk reads, custom tooling), not in controllers, middleware, or templates.
Context when you are outside the request lifecycleAnything that resolves SEO for a model — a queued job regenerating sitemaps, a CLI command, an API endpoint — should build a Context explicitly rather than relying on the current request:
use Aerni\AdvancedSeo\Context;
$context = Context::from($entry); // parent, type, handle, scope, site
This is the difference between "works in the browser, breaks in the queue worker" and a reliable integration. If you write an Artisan command that touches SEO, Context::from($entry) is your first line.
The {{ seo }} variable is a ViewCascade. If you need to inject anything (a custom <meta> tag, an analytics snippet, a per-page JSON-LD block), the supported extension point is augmenting the view data before CascadeComposer builds the cascade, not string-manipulating the rendered output. Rendering {{ seo }} and doing str_replace on it is fragile — the tag order and whitespace are not a contract.
For schema, the package wraps spatie/schema-org v3. If you need a custom type (e.g. Recipe, Event, or a nested Organization reference), build the Spatie\SchemaOrg object and attach it via the cascade data; do not hand-write a second <script type="application/ld+json"> in the layout, or you will emit two competing graphs.
Every SeoSet has a SeoSetLocalization per site. If you are tempted to write @if (Statamic::currentSite() === 'de') in your layout to tweak a title, stop — set the localized value on the SEO set instead. This is the single biggest maintainability win of the package over hand-rolled meta tag partials, and the most common way teams accidentally give it up.
If your project uses statamic/eloquent-driver, the SEO data moves to the database. The repository contracts in Contracts/ are identical, so your application code should not care. But if you are extending the package (adding a new data type or field), you must implement it in both Stache/Repositories/ and Eloquent/. A PR that only implements Stache will break half the install base.
Social share previews are generated on the fly with spatie/browsershot v5 (headless Chromium). This means:
This is stated explicitly in the package's own agent guidelines and it is the rule people break first. There is no $entry->seo_title. There is no $entry->save(['meta_description' => ...]). If you find yourself writing $entry->set('seo_...'), you have left the domain layer and are now coupled to the storage schema. Go through Seo::find(...) and the SeoSet API.
SEO resolution is cached in Blink under namespaced keys: advanced-seo::{type}::{handle}::{suffix}. Consequences:
SeoSet::flushBlink() (or flush the specific prefix) after programmatic writes, or you will ship stale titles.{{ seo }} output at the template/full-page level on top of Blink, you now have two caches to invalidate. Don't add a third.setUp.If spatie/browsershot cannot launch Chromium, social previews break — but the rest of the SEO output still renders fine. This means previews can be broken in production for weeks without anyone noticing. Add an explicit smoke test or health check for the preview endpoint if social sharing matters to your client.
saveQuietly() in setup/migration codeWhen you are provisioning SEO sets programmatically (seeders, deploy scripts, custom Artisan commands), the normal save path fires Statamic events, which can recursively trigger re-indexing or cache flushes. Use saveQuietly() in setup code — the package's own guidance says to.
vendor/bin/pest from the package root.PreventsSavingStacheItemsToDisk trait in any test that creates Statamic content, or your test suite will write YAML files into the project's content/ directory and pollute the repo.vendor/bin/pint (Laravel preset) before finalizing any contribution — CI will catch you otherwise.This is an 18-star package on a very recent release track with hard floors of PHP 8.4 / Laravel 12 / Statamic 6. That is excellent for greenfield Statamic 6 projects, and a non-starter for anything on Statamic 5 or PHP 8.3. Treat the public API (facade methods, Context signature, cascade data shape) as stabilizing rather than frozen: pin the version in composer.json, and prefer the documented extension points over reaching into Stache/ or Eloquent/ internals, since those are the parts most likely to move between minor releases.
When a title or description is not what you expect, resolve in this order:
Seo::find('collections::X')->config() return what the CP shows? If yes, the problem is downstream in the cascade, not in storage.SeoSet::flushBlink()) and re-check before assuming a code bug.How can I help you explore Laravel packages today?