banulakwin/laravel-seo-engine

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

Maintainers

Package info

github.com/banulalakwindu/laravel-seo-engine

pkg:composer/banulakwin/laravel-seo-engine

Statistics

Installs: 5

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

v1.1.0 2026-05-17 21:19 UTC

This package is auto-updated.

Last update: 2026-05-17 22:21:30 UTC


README

Latest Version on Packagist Tests Total Downloads 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 (<Head> tags) or plain Blade.

Requirements

  • PHP ^8.2
  • Laravel illuminate/* ^11.0|^12.0|^13.0 (see composer.json for 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: SeoBanulakwin\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() — 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):

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

  1. Fork the repository
  2. Create your feature branch (git checkout -b feature/your-feature)
  3. Run composer quality to ensure tests and style pass
  4. Commit and push
  5. 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