banulakwin / laravel-seo-engine
Portable polymorphic SEO meta for Eloquent models with trait-based auto-creation and sensible defaults.
Package info
github.com/banulalakwindu/laravel-seo-engine
pkg:composer/banulakwin/laravel-seo-engine
Requires
- php: ^8.2
- illuminate/contracts: ^11.0|^12.0|^13.0
- illuminate/database: ^11.0|^12.0|^13.0
- illuminate/filesystem: ^11.0|^12.0|^13.0
- illuminate/http: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
Requires (Dev)
- laravel/pint: ^1.21
- orchestra/testbench: ^9.0|^10.0|^11.0
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^11.0|^12.0
README
Portable Laravel package: a polymorphic SEO meta layer stored in seo_meta, attached to any Eloquent model via the Seoable trait. On created, a row is **firstOrCreate**d with defaults from SeoService (config fallbacks, sensible title/description/image/canonical guesses). Existing rows are never overwritten by the package after creation—only your app or admin UI should change them.
Designed to pair cleanly with Inertia + React (<Head> tags) or plain Blade.
Requirements
- PHP
^8.2 - Laravel
illuminate/*^11.0|^12.0|^13.0(seecomposer.jsonfor split packages)
Installation
Composer (path / VCS)
In a consuming app, add a path repository (or VCS) and require the package, then install:
composer require banulakwin/laravel-seo-engine
Registration is automatic via Composer extra.laravel.providers:
Banulakwin\SeoEngine\SeoEngineServiceProvider
Optional facade alias: Seo → Banulakwin\SeoEngine\Facades\Seo.
Publish config (optional)
php artisan vendor:publish --tag=seo-engine-config
| Tag | Copies |
|---|---|
seo-engine-config |
config/seo.php — defaults, canonical fallback, and migration registration |
seo-engine-migrations |
Package migration files → database/migrations/ (optional; see below) |
Database migrations (same pattern as banulakwin/laravel-page-builder)
By default, migrations are registered with loadMigrationsFrom() when config('seo.register_migrations') is true (default). Run php artisan migrate — no publish step is required.
To own migrations in the app: publish with php artisan vendor:publish --tag=seo-engine-migrations, then set register_migrations => false in config/seo.php (or SEO_ENGINE_REGISTER_MIGRATIONS=false in .env) so Laravel does not load the same files twice.
Configuration (config/seo.php)
Merged config key: seo.
| Key | Purpose |
|---|---|
register_migrations |
Load package migrations via loadMigrationsFrom() (default true). Set false after publishing migrations into the app. Env: SEO_ENGINE_REGISTER_MIGRATIONS. |
defaults.title |
Fallback page title (default: APP_NAME). |
defaults.description |
Fallback meta description (plain text; used when the model has no description). |
defaults.og_type |
Default Open Graph type (default: website). |
defaults.twitter_card |
Default Twitter card (default: summary_large_image). |
defaults.is_index / defaults.is_follow |
Default robots-friendly flags when generating a new row. |
fallback_canonical_url |
Global fallback when the model provides no canonical via $seoCanonicalUrlAttribute. Request URL discovery is not used. Defaults to APP_URL. |
pages |
Per-slug defaults for static routes (title, description, image, robots, OG/Twitter overrides). Never auto-discovered — you define keys explicitly. |
static_page_model_type |
Value stored in seo_meta.model_type for static pages (default static_page). Env: SEO_STATIC_PAGE_MODEL_TYPE. |
After publishing, adjust defaults.* for your product instead of hard-coding strings in controllers.
Static pages: seo:sync
php artisan seo:sync
Loops config('seo.pages') and firstOrCreates rows with model_type = static_page_model_type and model_id = page key. Does not overwrite existing rows (safe for production after editors change SEO).
Helper seoPage(string $key) returns the SeoMeta model for one static key. SeoService::forStaticPage(string $key) returns the same array shape as for($model), preferring the DB row when present and otherwise building a virtual row from config only.
Database
Table: seo_meta
| Column | Notes |
|---|---|
id |
Big integer primary key (standard Laravel). |
model_type, model_id |
Polymorphic owner (string model_id, e.g. Eloquent id as string or static slug like home); unique on (model_type, model_id). |
title, description, keywords |
Core meta; description / keywords are text, nullable. |
canonical_url |
Nullable string. |
image |
Nullable text (URL or path, your app decides). |
is_index, is_follow |
Booleans, default true. |
og_type, og_title, og_description, og_image |
Open Graph fields. |
twitter_card, twitter_title, twitter_description, twitter_image, author |
Twitter / attribution fields. |
timestamps |
created_at, updated_at. |
deleted_at |
Nullable timestamp — soft deletes (SoftDeletes on SeoMeta). |
The unique index on (model_type, model_id) still applies to soft-deleted rows (same as page_contents in page-builder). On created, Seoable uses withTrashed()->firstOrCreate() so a trashed row does not cause a duplicate-key insert; if a trashed row is found, it is **restore()**d (values are not overwritten by the package).
Note: model_id is a string column (Eloquent ids stringified or static slugs such as home).
Architecture
Model
Banulakwin\SeoEngine\Models\SeoMeta — fillable SEO columns; uses SoftDeletes (deleted_at); model() is a morphTo relation.
Trait (core feature)
Banulakwin\SeoEngine\Traits\Seoable:
bootSeoable()— oncreated, calls$this->seo()->withTrashed()->firstOrCreate([], $attributes)withSeoService::attributesForNewRecord($this). If a non-trashed row already exists, it is left unchanged; if only a trashed row exists (same unique key), it is **restore()**d without applying the default attributes over existing columns.seo()—morphOnetoSeoMeta(inverse morph namemodel).
Disable auto-creation on a specific model by declaring:
protected bool $autoSeo = false;
The package reads this protected property via reflection so your intent stays encapsulated.
Map auto SEO to different columns — on your model, optionally declare any of these protected properties (they are not on the trait, to avoid PHP trait property conflicts):
| Property | Purpose | Package default when omitted / null |
|---|---|---|
$seoTitleAttribute |
string or list<string> — attribute name(s); first non-empty value becomes the SEO title |
title, then name, then config('seo.defaults.title') |
$seoDescriptionAttribute |
string or list<string> — HTML is stripped, result limited to 160 characters |
description, then config default |
$seoImageAttribute |
string or list<string> — first non-empty value (URL or storage path) |
image |
$seoKeywordsAttribute |
string or list<string> |
keywords, then null |
$seoCanonicalUrlAttribute |
string or list<string> |
canonical_url, then null |
Eloquent accessors and casts still apply (getAttribute is used). Example:
protected array|string|null $seoTitleAttribute = ['headline', 'title', 'name']; protected array|string|null $seoDescriptionAttribute = ['excerpt', 'summary', 'description']; protected array|string|null $seoImageAttribute = 'og_image_path';
Service (singleton)
Banulakwin\SeoEngine\Services\SeoService:
| Method | Behaviour |
|---|---|
for(Model $model) |
If $model->seo exists, returns its values as a stable array; otherwise returns generateDefault($model) (no DB write). |
generateDefault(Model $model) |
Computes title, description, image, keywords, and canonical from the model using optional seo*Attribute properties, then config defaults for anything unset and OG/Twitter from config. Does not use request()->url() for canonical. |
attributesForNewRecord(Model $model) |
Same shape as generateDefault(), used by the trait for the initial insert. |
Prefer constructor or method injection of SeoService in application code; the seo() helper and Seo facade are optional conveniences.
Helper
seo(Model $model): array — delegates to SeoService::for(). Loaded via Composer autoload.files.
Facade (optional)
use Banulakwin\SeoEngine\Facades\Seo; $payload = Seo::for($product);
Design rules
- Do not manually force SEO row creation in controllers for normal flow—the trait handles the first insert on
created. - Do not rely on the package to overwrite user-edited SEO; it only
firstOrCreates on create. - Do use
SeoService::for()/seo($model)when passing props to Inertia so missing relations still get sensible defaults. - Do use the polymorphic relation for any Eloquent model that should own SEO.
Usage in models
<?php declare(strict_types=1); namespace App\Models; use Banulakwin\SeoEngine\Traits\Seoable; use Illuminate\Database\Eloquent\Model; class Product extends Model { use Seoable; }
Optional opt-out:
protected bool $autoSeo = false;
Optional column mapping (see Trait section above):
protected array|string|null $seoTitleAttribute = ['headline', 'name']; protected array|string|null $seoDescriptionAttribute = 'excerpt'; protected array|string|null $seoImageAttribute = 'featured_media_url'; protected array|string|null $seoKeywordsAttribute = 'meta_tags'; protected array|string|null $seoCanonicalUrlAttribute = 'public_url';
Laravel / Inertia (controller)
Pass a resolved seo prop for @banulakwin/inertia-seo-engine using inertia_seo() / inertia_seo_page() (from inertia_helpers.php). These map SeoService::for() / forStaticPage() into InertiaSeoPayload: image_path, og_image_path, twitter_image_path, canonical_url, OG/Twitter text overrides, robots flags, and config-backed Twitter site/creator.
use Inertia\Inertia; public function show(Product $product) { return Inertia::render('Product/Show', [ 'product' => $product, 'seo' => inertia_seo($product), ]); }
Static route key (must match config('seo.pages') / Filament static SEO):
'seo' => inertia_seo_page('home'),
Raw SeoService::for() / seo() arrays use image, og_image, etc. Prefer inertia_seo* for Inertia so URLs match InertiaSeoImageResolver.
React (Inertia <Head>)
Use InertiaSeoHead from @banulakwin/inertia-seo-engine in a root layout (with shared appUrl / name). It reads usePage().props.seo and applies fallbacks (e.g. OG/Twitter titles default to title when overrides are empty).
If you render <Head> manually, use the same keys as inertia_seo() (image_path, og_image_path, …), not the raw service image / og_image fields.
Add noindex / nofollow when seo.is_index or seo.is_follow is false (your app’s convention—e.g. robots meta or HTTP headers).
Testing
composer test # Run PHPUnit composer pint # Fix code style composer phpstan # Static analysis composer quality # Run all (pint + phpstan + test)
Changelog
See CHANGELOG.md for details.
Contributing
- Fork the repository
- Create your feature branch (
git checkout -b feature/your-feature) - Run
composer qualityto ensure tests and style pass - Commit and push
- Open a pull request
Package layout (reference)
config/
seo.php
database/migrations/
*_create_seo_meta_table.php
src/
Facades/Seo.php
Models/SeoMeta.php
Services/SeoService.php
Traits/Seoable.php
SeoEngineServiceProvider.php
helpers.php
composer.json
README.md
License
MIT