ali-awwad/fine-seo
Fine SEO is a Laravel package that simplifies page-level SEO management. It helps you set titles, meta descriptions, canonical URLs, Open Graph and Twitter Card tags through a clean, configurable API, making it easy to add consistent metadata across your application.
Install and publish.
composer require ali-awwad/fine-seo
php artisan vendor:publish --tag="fine-seo-config"
Check the package README for the exact tag names, migration/asset publishing flags, and whether the service provider is auto-discovered (Laravel 11+ package discovery usually covers it).
Read config/fine-seo.php first. In an SEO package the config is the API surface: default title/description, title separator, Open Graph defaults, Twitter card type, canonical settings, robots defaults, and — as of v0.2.2 — the sitemap section (output path, URL source, caching, ping/search-engine settings if present). Every day-to-day decision flows from here, so skim it before writing any markup.
Wire the <head> once. Add the package's render call to your single base layout (resources/views/layouts/app.blade.php) — typically a Blade component, directive, or {!! fineSeo()->render() !!} style call. Doing this once in the layout rather than per-page is what makes the rest of the package pleasant.
First use case — a static page. Set title, description, and canonical on your home page (via controller, facade, or a Blade component), then view-source to confirm tags render before </head> and that escaping is correct.
First use case — the new sitemap (v0.2.2). This is the headline feature of the release. Register the sitemap route (or hit the artisan command the README documents), confirm /sitemap.xml returns application/xml, then add your static marketing routes first before attempting model-driven entries. Validate the output with a linter or xmllint --noout before submitting anywhere.
Verify the version. v0.2.2 is pre-1.0 and skips v0.2.1 in the changelog link (v0.2.0...v0.2.2). Pin it exactly ("ali-awwad/fine-seo": "0.2.2") until you've read the full diff for the "few bugs" it mentions — the notes do not say which bugs, so treat the diff as required reading if you were on 0.2.0.
Layout-level defaults + per-page overrides. Configure sensible site-wide defaults in config/fine-seo.php (title suffix, default OG image, Twitter handle) and let individual pages override only what differs. This keeps controllers clean and prevents every new page from shipping a blank <title>.
Per-model SEO via a dedicated columns/relations approach. For blog posts, products, and categories, store seo_title, seo_description, and og_image either as columns or in a JSON seo column (or a polymorphic seo_meta table). Expose them through accessors with model-attribute fallbacks, so a missing seo_title degrades to the post title instead of rendering nothing. This is the single highest-leverage pattern: it makes SEO editable by non-developers and testable by you.
Blade component for the page-level call. Rather than calling the facade from every controller, use a <x-seo :title="..." :description="..." /> style component at the top of each view (or in the layout with slots). Views already know their content; controllers usually don't. Reserve facade calls for cases where the controller genuinely owns the data (redirects, API-backed pages).
Sitemap as a scheduled, cached artifact. Generate the sitemap on a schedule rather than per-request:
// routes/console.php
Schedule::command('fine-seo:sitemap') // confirm exact signature in README
->hourly()
->withoutOverlapping();
Then serve the cached file from public storage or a thin controller. For large sites this keeps response times flat and avoids hammering the DB on every crawler hit. Wire cache invalidation into model events (saved/deleted on Post, Product) if you prefer near-real-time updates — but debounce it, or a bulk import will regenerate the sitemap 5,000 times.
Separate sitemap indexes early. Even if v0.2.2's sitemap is a single-file generator, structure your URL sources by type (pages, posts, products, categories). When you cross the 50,000-URL or 50MB limit you'll need a sitemap index, and having the sources already separated makes that a config change rather than a rewrite. Check whether the package supports indexes; if not, plan to emit per-type sitemaps yourself and hand-write the index.
Multilingual and canonical discipline. For localized routes, emit hreflang alternates and a self-referencing canonical per locale. Canonical should always point at the canonical host + path, never the current query string — strip UTM/pagination params explicitly. Paginated archives should be noindex,follow with self-canonicals or proper rel=prev/next depending on your SEO stance; pick one and enforce it centrally so it doesn't drift page by page.
Environment and staging protection. Add noindex globally when app()->environment() !== 'production' (a middleware or a config override in AppServiceProvider). Staging sites leaking into search results is the most common and most embarrassing failure mode of any SEO package.
Testing. Assert on rendered head output in feature tests: title present, description length sane, canonical absolute, OG tags not duplicated. For the sitemap, assert the route returns 200, Content-Type is XML, a known URL appears, and a noindex model does not appear. Snapshot-test the sitemap for a small fixture set — it catches accidental ordering and escaping regressions cheaply.
Integration notes. With Inertia or a JS frontend, server-rendered head tags from a Laravel package won't reach the client unless you serialize them into the page props and render them in your root template — crawlers that don't execute JS will see nothing. With Livewire, per-page tags set in a component can persist across navigations; force a reset on navigation. If you run a CDN, remember the sitemap is a cacheable asset and needs a purge hook on regeneration.
composer.json, read the diff before bumping, and wrap calls behind a small app-level helper (app/Support/Seo.php) so a breakage is one file to fix rather than fifty.config/fine-seo.php, run php artisan config:clear (and remember config:cache in deploy scripts). Sitemap path and URL-source changes silently won't apply otherwise — this is the number one "why is nothing happening" cause.Model::all() will OOM on large tables — use lazy()/chunkById(), and check whether the package already streams. Also verify the sitemap only contains 200-status, canonical, indexable URLs; noindex pages in a sitemap is a self-inflicted warning in Search Console./sitemap.xml in public/ or a catch-all route. Route order matters. And if you serve a cached file, ensure regeneration actually overwrites it — stale sitemaps pointing at deleted URLs are worse than no sitemap.{!! !!}. That means any user-supplied meta title/description is an XSS vector. Sanitize or e() at the point of storage/accessor, not at render time where it's easy to forget.<title>/<meta name="description"> from both your layout and the package is common. Grep your layout for hardcoded SEO tags after installing, and add a test that asserts exactly one <title>.APP_URL correctly per environment, and beware proxies/load balancers: use URL::forceScheme('https') in AppServiceProvider behind TLS-terminating infrastructure, or you'll ship http:// canonicals.Str::limit respects mb_ via helpers; verify) and prefer word boundaries. Description guidance is ~150–160 chars; titles ~50–60.<head> of the rendered layout, not a child view that isn't extending it; (2) curl -s https://site.test/page | grep -i '<meta' to bypass browser caching and JS; (3) dump the resolved SEO object (dd(fineSeo()->toArray()) or equivalent) to see whether values are set but not rendered, or never set; (4) check storage/logs/laravel.log — many SEO packages log validation/URL warnings there rather than throwing.robots.txt, and add the Sitemap: line manually if not.How can I help you explore Laravel packages today?