PHPackages                             banulakwin/laravel-seo-engine - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Database &amp; ORM](/categories/database)
4. /
5. banulakwin/laravel-seo-engine

ActiveLibrary[Database &amp; ORM](/categories/database)

banulakwin/laravel-seo-engine
=============================

Portable polymorphic SEO meta for Eloquent models with trait-based auto-creation and sensible defaults.

v1.1.0(3w ago)061MITPHPPHP ^8.2CI passing

Since May 17Pushed 3w agoCompare

[ Source](https://github.com/banulalakwindu/laravel-seo-engine)[ Packagist](https://packagist.org/packages/banulakwin/laravel-seo-engine)[ Docs](https://github.com/banulalakwindu/laravel-seo-engine)[ RSS](/packages/banulakwin-laravel-seo-engine/feed)WikiDiscussions main Synced 1w ago

READMEChangelogDependencies (9)Versions (2)Used By (1)

Laravel SEO Engine (`banulakwin/laravel-seo-engine`)
====================================================

[](#laravel-seo-engine-banulakwinlaravel-seo-engine)

[![Latest Version on Packagist](https://camo.githubusercontent.com/79508dab2a9fb4f3b91429eb301d8281bcf4181b5a1cd7c31e6cc30f8fb44a88/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f62616e756c616b77696e2f6c61726176656c2d73656f2d656e67696e652e7376673f7374796c653d666c61742d737175617265)](https://packagist.org/packages/banulakwin/laravel-seo-engine)[![Tests](https://github.com/banulalakwindu/laravel-seo-engine/actions/workflows/tests.yml/badge.svg)](https://github.com/banulalakwindu/laravel-seo-engine/actions/workflows/tests.yml)[![Total Downloads](https://camo.githubusercontent.com/6b0eb9272032ba7d0d474805221aab56c36e400ab21e9ca033ee5945c692fa0e/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f64742f62616e756c616b77696e2f6c61726176656c2d73656f2d656e67696e652e7376673f7374796c653d666c61742d737175617265)](https://packagist.org/packages/banulakwin/laravel-seo-engine)[![License](https://camo.githubusercontent.com/942e017bf0672002dd32a857c95d66f28c5900ab541838c6c664442516309c8a/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4d49542d626c75652e7376673f7374796c653d666c61742d737175617265)](LICENSE)

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** (`` tags) or plain Blade.

---

Requirements
------------

[](#requirements)

- PHP `^8.2`
- Laravel `illuminate/*` `^11.0|^12.0|^13.0` (see `composer.json` for split packages)

---

Installation
------------

[](#installation)

### Composer (path / VCS)

[](#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)

[](#publish-config-optional)

```
php artisan vendor:publish --tag=seo-engine-config
```

TagCopies`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`)

[](#database-migrations-same-pattern-as-banulakwinlaravel-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` =&gt; 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`)
--------------------------------

[](#configuration-configseophp)

Merged config key: **`seo`**.

KeyPurpose`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`

[](#static-pages-seosync)

```
php artisan seo:sync
```

Loops **`config('seo.pages')`** and **`firstOrCreate`s** 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
--------

[](#database)

Table: **`seo_meta`**

ColumnNotes`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
------------

[](#architecture)

### Model

[](#model)

`Banulakwin\SeoEngine\Models\SeoMeta` — fillable SEO columns; uses **`SoftDeletes`** (`deleted_at`); **`model()`** is a **`morphTo`** relation.

### Trait (core feature)

[](#trait-core-feature)

`Banulakwin\SeoEngine\Traits\Seoable`:

- **`bootSeoable()`** — on **`created`**, calls **`$this->seo()->withTrashed()->firstOrCreate([], $attributes)`** with **`SeoService::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()`** — **`morphOne`** to `SeoMeta` (inverse morph name **`model`**).

**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):

PropertyPurposePackage default when omitted / `null`**`$seoTitleAttribute`**`string` or `list` — attribute name(s); first non-empty value becomes the SEO title`title`, then `name`, then `config('seo.defaults.title')`**`$seoDescriptionAttribute`**`string` or `list` — HTML is stripped, result limited to **160** characters`description`, then config default**`$seoImageAttribute`**`string` or `list` — first non-empty value (URL or storage path)`image`**`$seoKeywordsAttribute`**`string` or `list``keywords`, then `null`**`$seoCanonicalUrlAttribute`**`string` or `list``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)

[](#service-singleton)

`Banulakwin\SeoEngine\Services\SeoService`:

MethodBehaviour`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

[](#helper)

`seo(Model $model): array` — delegates to **`SeoService::for()`**. Loaded via Composer **`autoload.files`**.

### Facade (optional)

[](#facade-optional)

```
use Banulakwin\SeoEngine\Facades\Seo;

$payload = Seo::for($product);
```

---

Design rules
------------

[](#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 **`firstOrCreate`s** 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
---------------

[](#usage-in-models)

```
