├── src ├── Enums │ ├── MetaRobots.php │ └── OpenGraphType.php ├── Concerns │ ├── HasSeoData.php │ └── HasSeo.php ├── Models │ └── Seo.php ├── Facades │ └── SeoKit.php ├── Data │ └── SeoData.php ├── SeoKitServiceProvider.php ├── Support │ └── Util.php ├── TwitterCards.php ├── MetaTags.php ├── JsonLD.php ├── SeoKitManager.php └── OpenGraph.php ├── CHANGELOG.md ├── LICENSE.md ├── database └── migrations │ └── create_seokit_table.php.stub ├── composer.json ├── config └── seokit.php └── README.md /src/Enums/MetaRobots.php: -------------------------------------------------------------------------------- 1 | toSeoData(); 26 | 27 | if (empty(array_filter(get_object_vars($data)))) { 28 | return; 29 | } 30 | 31 | SeoKit::fromSeoData($data); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Models/Seo.php: -------------------------------------------------------------------------------- 1 | morphTo(); 24 | } 25 | 26 | protected static function booted(): void 27 | { 28 | self::saved(function (Seo $seo): void { 29 | Cache::forget(Util::modelCacheKey($seo->model)); 30 | }); 31 | } 32 | 33 | protected function casts(): array 34 | { 35 | return [ 36 | 'structured_data' => 'json', 37 | 'is_cornerstone' => 'boolean', 38 | ]; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Facades/SeoKit.php: -------------------------------------------------------------------------------- 1 | 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /database/migrations/create_seokit_table.php.stub: -------------------------------------------------------------------------------- 1 | id(); 15 | $table->morphs('model'); 16 | 17 | // General SEO 18 | $table->string('title')->nullable(); 19 | $table->text('description')->nullable(); 20 | $table->string('canonical')->nullable(); 21 | $table->string('robots')->nullable(); 22 | 23 | // Social Media Tags 24 | $table->string('og_title')->nullable(); 25 | $table->text('og_description')->nullable(); 26 | $table->string('og_image')->nullable(); 27 | $table->string('twitter_image')->nullable(); 28 | 29 | // Structured Data (JSON-LD schema) 30 | $table->json('structured_data')->nullable(); 31 | 32 | // Internal Content Strategy 33 | $table->boolean('is_cornerstone')->default(false); 34 | 35 | $table->timestamps(); 36 | }); 37 | } 38 | 39 | public function down(): void 40 | { 41 | Schema::dropIfExists(config('seokit.table_name', 'seokit')); 42 | } 43 | }; 44 | -------------------------------------------------------------------------------- /src/SeoKitServiceProvider.php: -------------------------------------------------------------------------------- 1 | name('seokit') 18 | ->hasConfigFile() 19 | ->hasMigration('create_seokit_table') 20 | ->hasInstallCommand(function (InstallCommand $command): void { 21 | $command 22 | ->publishConfigFile() 23 | ->publishMigrations() 24 | ->askToRunMigrations() 25 | ->askToStarRepoOnGitHub('larament/seokit'); 26 | }); 27 | } 28 | 29 | public function bootingPackage(): void 30 | { 31 | Blade::directive('seoKit', fn (bool $minify = false): string => ""); 32 | } 33 | 34 | public function packageRegistered(): void 35 | { 36 | $this->app->singleton(SeoKitManager::class, fn (): SeoKitManager => new SeoKitManager( 37 | new MetaTags, 38 | new OpenGraph, 39 | new TwitterCards, 40 | new JsonLD 41 | )); 42 | } 43 | 44 | public function packageBooted(): void 45 | { 46 | // 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Concerns/HasSeo.php: -------------------------------------------------------------------------------- 1 | morphOne(Seo::class, 'model'); 26 | } 27 | 28 | /** 29 | * Prepares and applies SEO tags using the model's SEO data from the database. 30 | */ 31 | public function prepareSeoTags(): void 32 | { 33 | if (! $data = $this->seoData()) { 34 | return; 35 | } 36 | 37 | SeoKit::fromSeoData(SeoData::fromArray($data)); 38 | } 39 | 40 | /** 41 | * Get the SEO data from cache or database. 42 | */ 43 | public function seoData(): ?array 44 | { 45 | return Cache::rememberForever(Util::modelCacheKey($this), fn () => $this->seo?->toArray()); 46 | } 47 | 48 | /** 49 | * Check if the model is marked as cornerstone content. 50 | */ 51 | public function isCornerstone(): bool 52 | { 53 | return (bool) ($this->seoData()['is_cornerstone'] ?? false); 54 | } 55 | 56 | /** 57 | * Boot the HasSeo trait. 58 | * 59 | * Clears the cached SEO data when the model is saved or deleted. 60 | * Ensures the SEO data is always up to date. 61 | */ 62 | protected static function bootHasSeo(): void 63 | { 64 | static::deleted(function (Model $model): void { 65 | $model->seo()->delete(); 66 | Cache::forget(Util::modelCacheKey($model)); 67 | }); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Support/Util.php: -------------------------------------------------------------------------------- 1 | afterLast('/')->beforeLast('.'); 40 | $callback = config('seokit.title_inference_callback'); 41 | 42 | if ($callback instanceof Closure) { 43 | return $callback((string) $slug); 44 | } 45 | 46 | return $slug->headline()->trim()->toString(); 47 | } 48 | 49 | /** 50 | * Clean the string by removing http-equiv, url, and html tags. 51 | */ 52 | public static function cleanString(string $string): string 53 | { 54 | return strip_tags(e( 55 | str_replace(['http-equiv=', 'url='], '', $string) 56 | )); 57 | } 58 | 59 | /** 60 | * Get the unique cache key for the model's SEO data. 61 | */ 62 | public static function modelCacheKey(Model $model): string 63 | { 64 | return sprintf('seokit.%s.%s', str_replace('\\', '.', $model->getMorphClass()), $model->getKey()); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/TwitterCards.php: -------------------------------------------------------------------------------- 1 | properties[$property] = e($value); 14 | 15 | return $this; 16 | } 17 | 18 | public function remove(string $property): self 19 | { 20 | unset($this->properties[$property]); 21 | 22 | return $this; 23 | } 24 | 25 | public function title(string $title): self 26 | { 27 | return $this->add('title', $title); 28 | } 29 | 30 | public function card(string $card): self 31 | { 32 | $validCards = ['summary', 'summary_large_image', 'app', 'player']; 33 | 34 | if (! in_array($card, $validCards)) { 35 | $card = 'summary'; 36 | } 37 | 38 | return $this->add('card', $card); 39 | } 40 | 41 | public function site(string $username): self 42 | { 43 | return $this->add('site', $username); 44 | } 45 | 46 | public function creator(string $username): self 47 | { 48 | return $this->add('creator', $username); 49 | } 50 | 51 | public function description(string $description): self 52 | { 53 | return $this->add('description', $description); 54 | } 55 | 56 | public function image(string $url, ?string $alt = null): self 57 | { 58 | $this->add('image', $url); 59 | 60 | if ($alt) { 61 | $this->add('image:alt', $alt); 62 | } 63 | 64 | return $this; 65 | } 66 | 67 | public function player(string $url, int $width, int $height): self 68 | { 69 | return $this->add('player', $url) 70 | ->add('player:width', $width) 71 | ->add('player:height', $height); 72 | } 73 | 74 | public function toArray(): array 75 | { 76 | return $this->properties; 77 | } 78 | 79 | public function toHtml(bool $minify = false): string 80 | { 81 | $output = []; 82 | 83 | foreach ($this->properties as $property => $value) { 84 | $output[] = sprintf('', $property, $value); 85 | } 86 | 87 | return implode($minify ? '' : PHP_EOL, $output); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "larament/seokit", 3 | "description": "A complete SEO package for Laravel, covering everything from meta tags to social sharing and structured data.", 4 | "keywords": [ 5 | "laravel", 6 | "seokit", 7 | "seo", 8 | "seo-tools", 9 | "seo-optimization", 10 | "meta-tags", 11 | "open-graph", 12 | "twitter-cards", 13 | "structured-data", 14 | "json-ld", 15 | "schema-org", 16 | "social-sharing", 17 | "laravel-package", 18 | "laravel-seo", 19 | "seo-kit", 20 | "seo-manager", 21 | "seo-helper", 22 | "seo-integration", 23 | "web-seo", 24 | "search-engine-optimization" 25 | ], 26 | "homepage": "https://github.com/larament/seokit", 27 | "license": "MIT", 28 | "authors": [ 29 | { 30 | "name": "Raziul Islam", 31 | "email": "hello@raziul.dev" 32 | } 33 | ], 34 | "require": { 35 | "php": "^8.3", 36 | "illuminate/contracts": "^11.0||^12.0", 37 | "spatie/laravel-package-tools": "^1.16" 38 | }, 39 | "require-dev": { 40 | "laravel/pint": "^1.14", 41 | "nunomaduro/collision": "^8.8", 42 | "larastan/larastan": "^3.0", 43 | "orchestra/testbench": "^10.0.0||^9.0.0", 44 | "pestphp/pest": "^4.0", 45 | "pestphp/pest-plugin-arch": "^4.0", 46 | "pestphp/pest-plugin-laravel": "^4.0", 47 | "phpstan/extension-installer": "^1.4", 48 | "phpstan/phpstan-deprecation-rules": "^2.0", 49 | "phpstan/phpstan-phpunit": "^2.0" 50 | }, 51 | "autoload": { 52 | "psr-4": { 53 | "Larament\\SeoKit\\": "src/", 54 | "Larament\\SeoKit\\Database\\Factories\\": "database/factories/" 55 | } 56 | }, 57 | "autoload-dev": { 58 | "psr-4": { 59 | "Larament\\SeoKit\\Tests\\": "tests/", 60 | "Workbench\\App\\": "workbench/app/" 61 | } 62 | }, 63 | "scripts": { 64 | "post-autoload-dump": "@composer run prepare", 65 | "prepare": "@php vendor/bin/testbench package:discover --ansi", 66 | "analyse": "vendor/bin/phpstan analyse", 67 | "test": "vendor/bin/pest --parallel", 68 | "test-coverage": "vendor/bin/pest --coverage --parallel --min=100", 69 | "format": "vendor/bin/pint" 70 | }, 71 | "config": { 72 | "sort-packages": true, 73 | "allow-plugins": { 74 | "pestphp/pest-plugin": true, 75 | "phpstan/extension-installer": true 76 | } 77 | }, 78 | "extra": { 79 | "laravel": { 80 | "providers": [ 81 | "Larament\\SeoKit\\SeoKitServiceProvider" 82 | ], 83 | "aliases": { 84 | "SeoKit": "Larament\\SeoKit\\Facades\\SeoKit" 85 | } 86 | } 87 | }, 88 | "minimum-stability": "dev", 89 | "prefer-stable": true 90 | } 91 | -------------------------------------------------------------------------------- /config/seokit.php: -------------------------------------------------------------------------------- 1 | 'seokit', 12 | 13 | /* 14 | |-------------------------------------------------------------------------- 15 | | Auto Title From URL 16 | |-------------------------------------------------------------------------- 17 | | 18 | | Automatically generate a human-readable title based on the last segment 19 | | of the current URL when no explicit title is provided. This is useful for 20 | | dynamic pages or routes without associated models. 21 | | 22 | | Example: 23 | | URL: '/blog/getting-started' → Title: 'Getting Started' 24 | | 25 | | Note: 26 | | - Applies only when `title` is not set explicitly. 27 | | - Uses Str::of($slug)->headline() by default. 28 | | 29 | */ 30 | 'auto_title_from_url' => true, 31 | 32 | /* 33 | |-------------------------------------------------------------------------- 34 | | Title Inference Callback (Optional) 35 | |-------------------------------------------------------------------------- 36 | | 37 | | You can optionally provide a custom callable (closure, invokable class, etc.) 38 | | to control how inferred titles are generated from the URL segment. 39 | | 40 | | Example: 41 | | fn (string $slug): string => ucfirst(str_replace('-', ' ', $slug)) 42 | | 43 | | Leave null to use the default behavior. 44 | | 45 | */ 46 | 'title_inference_callback' => null, 47 | 48 | /* 49 | |-------------------------------------------------------------------------- 50 | | Default Meta Tags 51 | |-------------------------------------------------------------------------- 52 | | 53 | | Default meta tags that will be present across all pages unless overridden. 54 | | 55 | */ 56 | 'defaults' => [ 57 | // The default title to use if no explicit title is provided and `auto_title_from_url` is false 58 | 'title' => 'Be TALL or not at all', 59 | 60 | // Text to prepend before the title (string or callable) 61 | 'before_title' => null, 62 | 63 | // Text to append after the title (string or callable) 64 | 'after_title' => null, 65 | 66 | // Separator to use between before_title, title, and after_title 67 | 'title_separator' => ' - ', 68 | 69 | // Default meta description for pages 70 | 'description' => null, 71 | 72 | // Default canonical URL for pages 73 | // Use null for `URL::current()`, full for `URL::full()`, or false to remove 74 | 'canonical' => null, 75 | 76 | // Default robots meta tag directives (e.g. index, follow, etc.) 77 | 'robots' => 'index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1', 78 | ], 79 | 80 | /* 81 | |-------------------------------------------------------------------------- 82 | | Open Graph 83 | |-------------------------------------------------------------------------- 84 | | 85 | | Open Graph meta tags for better social sharing. 86 | | 87 | */ 88 | 'opengraph' => [ 89 | 'enabled' => true, 90 | 'defaults' => [ 91 | 'site_name' => config('app.name', 'Laravel'), 92 | 'type' => 'website', 93 | 'url' => null, // Use null for `URL::current()`, 'full' for `URL::full()`, or false to remove 94 | 'locale' => 'en_US', 95 | ], 96 | ], 97 | 98 | /* 99 | |-------------------------------------------------------------------------- 100 | | Twitter Card 101 | |-------------------------------------------------------------------------- 102 | | 103 | | Twitter Card meta tags for better Twitter sharing. 104 | | 105 | */ 106 | 'twitter' => [ 107 | 'enabled' => true, 108 | 'defaults' => [ 109 | 'card' => 'summary_large_image', 110 | 'site' => '@raziuldev', 111 | 'creator' => '@raziuldev', 112 | ], 113 | ], 114 | 115 | /* 116 | |-------------------------------------------------------------------------- 117 | | JSON-LD 118 | |-------------------------------------------------------------------------- 119 | | 120 | | JSON-LD meta tags for better structured data. 121 | | 122 | */ 123 | 'json_ld' => [ 124 | 'enabled' => true, 125 | 'defaults' => [], 126 | ], 127 | ]; 128 | -------------------------------------------------------------------------------- /src/MetaTags.php: -------------------------------------------------------------------------------- 1 | meta[$name] = e($content); 25 | 26 | return $this; 27 | } 28 | 29 | /** 30 | * Remove a meta tag by name. 31 | */ 32 | public function removeMeta(string $name): self 33 | { 34 | unset($this->meta[$name]); 35 | 36 | return $this; 37 | } 38 | 39 | /** 40 | * Add a link tag to the page. 41 | */ 42 | public function addLink(string $rel, string $href): self 43 | { 44 | $this->links[$rel] = e($href); 45 | 46 | return $this; 47 | } 48 | 49 | /** 50 | * Remove a link tag by rel attribute. 51 | */ 52 | public function removeLink(string $rel): self 53 | { 54 | unset($this->links[$rel]); 55 | 56 | return $this; 57 | } 58 | 59 | /** 60 | * Add an alternate language link tag. 61 | */ 62 | public function addLanguage(string $hreflang, string $href): self 63 | { 64 | $this->languages[$hreflang] = Util::cleanString($href); 65 | 66 | return $this; 67 | } 68 | 69 | /** 70 | * Remove an alternate language link tag. 71 | */ 72 | public function removeLanguage(string $hreflang): self 73 | { 74 | unset($this->languages[$hreflang]); 75 | 76 | return $this; 77 | } 78 | 79 | /** 80 | * Set the page title. 81 | */ 82 | public function title(string $title): self 83 | { 84 | $this->title = Util::affixTitle( 85 | Util::cleanString($title) 86 | ); 87 | 88 | return $this; 89 | } 90 | 91 | /** 92 | * Set the meta description. 93 | */ 94 | public function description(string $description): self 95 | { 96 | return $this->addMeta('description', $description); 97 | } 98 | 99 | /** 100 | * Set the meta keywords. 101 | */ 102 | public function keywords(array $keywords): self 103 | { 104 | return $this->addMeta('keywords', implode(', ', $keywords)); 105 | } 106 | 107 | /** 108 | * Set the robots. 109 | * 110 | * Supported values: 111 | * - `index` 112 | * - `noindex` 113 | * - `follow` 114 | * - `nofollow` 115 | * - `noarchive` 116 | * - `noimageindex` 117 | * - `nosnippet` 118 | */ 119 | public function robots(string|array $robots): self 120 | { 121 | return $this->addMeta('robots', is_array($robots) ? implode(', ', $robots) : $robots); 122 | } 123 | 124 | /** 125 | * Set the canonical URL. 126 | */ 127 | public function canonical(string $url): self 128 | { 129 | return $this->addLink('canonical', $url); 130 | } 131 | 132 | /** 133 | * Set the AMP HTML URL. 134 | */ 135 | public function ampHtml(string $url): self 136 | { 137 | return $this->addLink('amphtml', $url); 138 | } 139 | 140 | /** 141 | * Set the previous page URL (for pagination). 142 | */ 143 | public function prev(string $url, bool $condition = true): self 144 | { 145 | return $condition ? $this->addLink('prev', $url) : $this; 146 | } 147 | 148 | /** 149 | * Set the next page URL (for pagination). 150 | */ 151 | public function next(string $url, bool $condition = true): self 152 | { 153 | return $condition ? $this->addLink('next', $url) : $this; 154 | } 155 | 156 | /** 157 | * Return the meta tags as an array. 158 | */ 159 | public function toArray(): array 160 | { 161 | return [ 162 | 'meta' => $this->meta, 163 | 'links' => $this->links, 164 | 'languages' => $this->languages, 165 | ]; 166 | } 167 | 168 | /** 169 | * Render the meta tags to HTML. 170 | */ 171 | public function toHtml(bool $minify = false): string 172 | { 173 | $output = [ 174 | "{$this->title}", 175 | ]; 176 | 177 | foreach ($this->meta as $name => $content) { 178 | $output[] = sprintf('', $name, $content); 179 | } 180 | 181 | foreach ($this->links as $rel => $href) { 182 | $output[] = sprintf('', $rel, $href); 183 | } 184 | 185 | foreach ($this->languages as $hreflang => $href) { 186 | $output[] = sprintf('', $hreflang, $href); 187 | } 188 | 189 | return implode($minify ? '' : PHP_EOL, $output); 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /src/JsonLD.php: -------------------------------------------------------------------------------- 1 | schemas[] = $schema; 17 | 18 | return $this; 19 | } 20 | 21 | /** 22 | * Remove a JSON-LD schema from the collection by index. 23 | */ 24 | public function remove(int $index): self 25 | { 26 | if (isset($this->schemas[$index])) { 27 | unset($this->schemas[$index]); 28 | } 29 | 30 | return $this; 31 | } 32 | 33 | /** 34 | * Add a WebSite schema. 35 | */ 36 | public function website(array $data = []): self 37 | { 38 | $schema = array_merge([ 39 | '@context' => 'https://schema.org', 40 | '@type' => 'WebSite', 41 | 'url' => request()->url(), 42 | 'name' => config('app.name'), 43 | ], $data); 44 | 45 | return $this->add($schema); 46 | } 47 | 48 | /** 49 | * Add an Organization schema. 50 | */ 51 | public function organization(array $data = []): self 52 | { 53 | $schema = array_merge([ 54 | '@context' => 'https://schema.org', 55 | '@type' => 'Organization', 56 | 'name' => config('app.name'), 57 | 'url' => config('app.url'), 58 | ], $data); 59 | 60 | return $this->add($schema); 61 | } 62 | 63 | /** 64 | * Add a Person schema. 65 | */ 66 | public function person(array $data = []): self 67 | { 68 | $schema = array_merge([ 69 | '@context' => 'https://schema.org', 70 | '@type' => 'Person', 71 | 'name' => config('app.name'), 72 | ], $data); 73 | 74 | return $this->add($schema); 75 | } 76 | 77 | /** 78 | * Add an Article schema. 79 | */ 80 | public function article(array $data = []): self 81 | { 82 | $schema = array_merge([ 83 | '@context' => 'https://schema.org', 84 | '@type' => 'Article', 85 | 'headline' => '', 86 | 'description' => '', 87 | 'author' => [ 88 | '@type' => 'Person', 89 | 'name' => config('app.name'), 90 | ], 91 | 'datePublished' => now()->toISOString(), 92 | 'dateModified' => now()->toISOString(), 93 | ], $data); 94 | 95 | return $this->add($schema); 96 | } 97 | 98 | /** 99 | * Add a BlogPosting schema. 100 | */ 101 | public function blogPosting(array $data = []): self 102 | { 103 | $schema = array_merge([ 104 | '@context' => 'https://schema.org', 105 | '@type' => 'BlogPosting', 106 | 'headline' => '', 107 | 'description' => '', 108 | 'author' => [ 109 | '@type' => 'Person', 110 | 'name' => config('app.name'), 111 | ], 112 | 'datePublished' => now()->toISOString(), 113 | 'dateModified' => now()->toISOString(), 114 | ], $data); 115 | 116 | return $this->add($schema); 117 | } 118 | 119 | /** 120 | * Add a Product schema. 121 | */ 122 | public function product(array $data = []): self 123 | { 124 | $schema = array_merge([ 125 | '@context' => 'https://schema.org', 126 | '@type' => 'Product', 127 | 'name' => '', 128 | 'description' => '', 129 | 'offers' => [ 130 | '@type' => 'Offer', 131 | 'price' => '', 132 | 'priceCurrency' => 'USD', 133 | ], 134 | ], $data); 135 | 136 | return $this->add($schema); 137 | } 138 | 139 | /** 140 | * Add a LocalBusiness schema. 141 | */ 142 | public function localBusiness(array $data = []): self 143 | { 144 | $schema = array_merge([ 145 | '@context' => 'https://schema.org', 146 | '@type' => 'LocalBusiness', 147 | 'name' => config('app.name'), 148 | 'description' => '', 149 | 'address' => '', 150 | 'telephone' => '', 151 | 'openingHours' => '', 152 | ], $data); 153 | 154 | return $this->add($schema); 155 | } 156 | 157 | /** 158 | * Clear all schemas. 159 | */ 160 | public function clear(): self 161 | { 162 | $this->schemas = []; 163 | 164 | return $this; 165 | } 166 | 167 | /** 168 | * Get all schemas as an array. 169 | */ 170 | public function toArray(): array 171 | { 172 | return $this->schemas; 173 | } 174 | 175 | /** 176 | * Render the JSON-LD schemas to HTML script tags. 177 | */ 178 | public function toHtml(bool $minify = false): string 179 | { 180 | if (empty($this->schemas)) { 181 | return ''; 182 | } 183 | 184 | $output = []; 185 | 186 | foreach ($this->schemas as $schema) { 187 | $output[] = sprintf( 188 | '', 189 | json_encode($schema, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) 190 | ); 191 | } 192 | 193 | return implode($minify ? '' : PHP_EOL, $output); 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /src/SeoKitManager.php: -------------------------------------------------------------------------------- 1 | setDefaultMetaTags(); 23 | $this->setDefaultOpenGraph(); 24 | $this->setDefaultTwitter(); 25 | $this->setDefaultJsonLd(); 26 | } 27 | 28 | public function meta(): MetaTags 29 | { 30 | return $this->meta; 31 | } 32 | 33 | public function opengraph(): OpenGraph 34 | { 35 | return $this->opengraph; 36 | } 37 | 38 | public function twitter(): TwitterCards 39 | { 40 | return $this->twitter; 41 | } 42 | 43 | public function jsonld(): JsonLD 44 | { 45 | return $this->jsonld; 46 | } 47 | 48 | /** 49 | * Set the SEO data for the manager from a SeoData object. 50 | */ 51 | public function fromSeoData(SeoData $data): static 52 | { 53 | $this->meta 54 | ->title($data->title) 55 | ->description($data->description); 56 | 57 | if ($data->robots) { 58 | $this->meta->robots($data->robots); 59 | } 60 | if ($data->canonical) { 61 | $this->meta->canonical($data->canonical); 62 | } 63 | 64 | $this->opengraph 65 | ->title($data->og_title ?: $data->title) 66 | ->description($data->og_description ?: $data->description); 67 | if ($data->og_image) { 68 | $this->opengraph->image($data->og_image); 69 | } 70 | 71 | $this->twitter 72 | ->title($data->og_title ?: $data->title) 73 | ->description($data->og_description ?: $data->description); 74 | if ($twImage = $data->twitter_image ?? $data->og_image) { 75 | $this->twitter->image($twImage); 76 | } 77 | 78 | if ($data->structured_data) { 79 | $this->jsonld->add($data->structured_data); 80 | } 81 | 82 | return $this; 83 | } 84 | 85 | /** 86 | * Set the title for all the meta tags. 87 | */ 88 | public function title(string $title): static 89 | { 90 | $this->meta->title($title); 91 | $this->opengraph->title($title); 92 | $this->twitter->title($title); 93 | 94 | return $this; 95 | } 96 | 97 | /** 98 | * Set the description for all the meta tags. 99 | */ 100 | public function description(string $description): static 101 | { 102 | $this->meta->description($description); 103 | $this->opengraph->description($description); 104 | $this->twitter->description($description); 105 | 106 | return $this; 107 | } 108 | 109 | /** 110 | * Set the image for Open Graph and Twitter. 111 | */ 112 | public function image(string $image): static 113 | { 114 | $this->opengraph->image($image); 115 | $this->twitter->image($image); 116 | 117 | return $this; 118 | } 119 | 120 | /** 121 | * Set the canonical URL for the meta tags and Open Graph. 122 | */ 123 | public function canonical(string $canonical): static 124 | { 125 | $this->meta->canonical($canonical); 126 | $this->opengraph->url($canonical); 127 | 128 | return $this; 129 | } 130 | 131 | /** 132 | * Render the SEO tags to HTML. 133 | */ 134 | public function toHtml(bool $minify = false): string 135 | { 136 | $output = [ 137 | $this->meta->toHtml($minify), 138 | ]; 139 | 140 | if (config('seokit.opengraph.enabled')) { 141 | $output[] = $this->opengraph->toHtml($minify); 142 | } 143 | 144 | if (config('seokit.twitter.enabled')) { 145 | $output[] = $this->twitter->toHtml($minify); 146 | } 147 | 148 | if (config('seokit.json_ld.enabled')) { 149 | $output[] = $this->jsonld->toHtml($minify); 150 | } 151 | 152 | return implode($minify ? '' : PHP_EOL, $output); 153 | } 154 | 155 | /** 156 | * Set the default meta tags. 157 | */ 158 | private function setDefaultMetaTags(): void 159 | { 160 | $meta = config('seokit.defaults'); 161 | $this->meta->title(config('seokit.auto_title_from_url') ? Util::getTitleFromUrl() : $meta['title']); 162 | 163 | if ($meta['description']) { 164 | $this->meta->description($meta['description']); 165 | } 166 | 167 | if ($meta['robots']) { 168 | $this->meta->robots($meta['robots']); 169 | } 170 | 171 | match ($meta['canonical'] ?? null) { 172 | null => $this->meta->canonical(URL::current()), 173 | 'full' => $this->meta->canonical(URL::full()), 174 | default => null, 175 | }; 176 | 177 | } 178 | 179 | /** 180 | * Set the default open graph. 181 | */ 182 | private function setDefaultOpenGraph(): void 183 | { 184 | $opengraph = config('seokit.opengraph.defaults'); 185 | $this->opengraph 186 | ->type($opengraph['type']) 187 | ->siteName($opengraph['site_name']) 188 | ->locale($opengraph['locale']); 189 | 190 | match ($opengraph['url'] ?? null) { 191 | null => $this->opengraph->url(URL::current()), 192 | 'full' => $this->opengraph->url(URL::full()), 193 | default => null, 194 | }; 195 | 196 | } 197 | 198 | /** 199 | * Set the default twitter. 200 | */ 201 | private function setDefaultTwitter(): void 202 | { 203 | $twitter = config('seokit.twitter.defaults'); 204 | if ($twitter['card']) { 205 | $this->twitter->card($twitter['card']); 206 | } 207 | if ($twitter['site']) { 208 | $this->twitter->site($twitter['site']); 209 | } 210 | if ($twitter['creator']) { 211 | $this->twitter->creator($twitter['creator']); 212 | } 213 | 214 | } 215 | 216 | /** 217 | * Set the default json ld. 218 | */ 219 | private function setDefaultJsonLd(): void 220 | { 221 | $jsonld = config('seokit.json_ld.defaults'); 222 | if ($jsonld) { 223 | $this->jsonld->add($jsonld); 224 | } 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /src/OpenGraph.php: -------------------------------------------------------------------------------- 1 | properties[$property] = $values; 26 | 27 | return $this; 28 | } 29 | 30 | /** 31 | * Add an Open Graph property when the condition is true. 32 | */ 33 | public function addWhen(bool $condition, string $property, mixed $values): self 34 | { 35 | if ($condition) { 36 | return $this->add($property, $values); 37 | } 38 | 39 | return $this; 40 | } 41 | 42 | /** 43 | * Remove a property by name from the Open Graph properties. 44 | */ 45 | public function remove(string $property): self 46 | { 47 | unset($this->properties[$property]); 48 | 49 | return $this; 50 | } 51 | 52 | /** 53 | * Set the type of the object. 54 | */ 55 | public function type(string|OpenGraphType $type): self 56 | { 57 | if (is_string($type)) { 58 | $type = OpenGraphType::tryFrom($type) ?? OpenGraphType::Website; 59 | } 60 | 61 | return $this->add('og:type', $type->value); 62 | } 63 | 64 | /** 65 | * Set the canonical URL of the object. 66 | */ 67 | public function url(string $url): self 68 | { 69 | return $this->add('og:url', $url); 70 | } 71 | 72 | /** 73 | * Set the open graph title of the object. 74 | */ 75 | public function title(string $title): self 76 | { 77 | return $this->add('og:title', $title); 78 | } 79 | 80 | /** 81 | * Set the open graph description of the object. 82 | */ 83 | public function description(string $description): self 84 | { 85 | return $this->add('og:description', $description); 86 | } 87 | 88 | /** 89 | * Set the open graph site name. 90 | */ 91 | public function siteName(string $name): self 92 | { 93 | return $this->add('og:site_name', $name); 94 | } 95 | 96 | /** 97 | * Set the locale of the object. 98 | */ 99 | public function locale(string $locale): self 100 | { 101 | return $this->add('og:locale', $locale); 102 | } 103 | 104 | /** 105 | * Add alternate locales for the object. 106 | */ 107 | public function localeAlternate(array $locales): self 108 | { 109 | return $this->addWhen(! empty($locales), 'og:locale:alternate', $locales); 110 | } 111 | 112 | /** 113 | * Set the determiner for the object title. 114 | * Valid values: 'a', 'an', 'the', '', 'auto' 115 | */ 116 | public function determiner(string $determiner): self 117 | { 118 | if (in_array($determiner, ['a', 'an', 'the', '', 'auto'], true)) { 119 | return $this->add('og:determiner', $determiner); 120 | } 121 | 122 | return $this; 123 | } 124 | 125 | /** 126 | * Add an Open Graph image. 127 | */ 128 | public function image( 129 | string $url, 130 | ?string $secureUrl = null, 131 | ?string $type = null, 132 | ?int $width = null, 133 | ?int $height = null, 134 | ?string $alt = null 135 | ): self { 136 | $this->images[] = [ 137 | 'og:image' => $url, 138 | 'og:image:secure_url' => $secureUrl, 139 | 'og:image:type' => $type, 140 | 'og:image:width' => $width, 141 | 'og:image:height' => $height, 142 | 'og:image:alt' => $alt, 143 | ]; 144 | 145 | return $this; 146 | } 147 | 148 | /** 149 | * Add an Open Graph video. 150 | */ 151 | public function video( 152 | string $url, 153 | ?string $secureUrl = null, 154 | ?string $type = null, 155 | ?int $width = null, 156 | ?int $height = null 157 | ): self { 158 | $this->videos[] = [ 159 | 'og:video' => $url, 160 | 'og:video:secure_url' => $secureUrl, 161 | 'og:video:type' => $type, 162 | 'og:video:width' => $width, 163 | 'og:video:height' => $height, 164 | ]; 165 | 166 | return $this; 167 | } 168 | 169 | /** 170 | * Add an Open Graph audio. 171 | */ 172 | public function audio(string $url, ?string $secureUrl = null, ?string $type = null): self 173 | { 174 | $this->audios[] = [ 175 | 'og:audio' => $url, 176 | 'og:audio:secure_url' => $secureUrl, 177 | 'og:audio:type' => $type, 178 | ]; 179 | 180 | return $this; 181 | } 182 | 183 | /** 184 | * Set Article properties. 185 | */ 186 | public function article( 187 | ?string $publishedTime = null, 188 | ?string $modifiedTime = null, 189 | ?string $expirationTime = null, 190 | array $authors = [], 191 | ?string $section = null, 192 | array $tags = [] 193 | ): self { 194 | $this->type('article') 195 | ->addWhen(! empty($publishedTime), 'article:published_time', $publishedTime) 196 | ->addWhen(! empty($modifiedTime), 'article:modified_time', $modifiedTime) 197 | ->addWhen(! empty($expirationTime), 'article:expiration_time', $expirationTime) 198 | ->addWhen(! empty($section), 'article:section', $section) 199 | ->addWhen(! empty($authors), 'article:author', $authors) 200 | ->addWhen(! empty($tags), 'article:tag', $tags); 201 | 202 | return $this; 203 | } 204 | 205 | /** 206 | * Set Profile properties. 207 | */ 208 | public function profile( 209 | string $firstName, 210 | string $lastName, 211 | ?string $username = null, 212 | ?string $gender = null 213 | ): self { 214 | $this->type('profile') 215 | ->add('profile:first_name', $firstName) 216 | ->add('profile:last_name', $lastName) 217 | ->addWhen(! empty($username), 'profile:username', $username) 218 | ->addWhen($gender && in_array($gender, ['male', 'female']), 'profile:gender', $gender); 219 | 220 | return $this; 221 | } 222 | 223 | /** 224 | * Set Book properties. 225 | */ 226 | public function book( 227 | array $author = [], 228 | ?string $isbn = null, 229 | ?string $releaseDate = null, 230 | array $tags = [] 231 | ): self { 232 | $this->type('book') 233 | ->addWhen(! empty($author), 'book:author', $author) 234 | ->addWhen(! empty($isbn), 'book:isbn', $isbn) 235 | ->addWhen(! empty($releaseDate), 'book:release_date', $releaseDate) 236 | ->addWhen(! empty($tags), 'book:tag', $tags); 237 | 238 | return $this; 239 | } 240 | 241 | /** 242 | * Set Music Song properties. 243 | * 244 | * @param int $duration The song's length in seconds 245 | * @param array $album List of album URLs (music.album) 246 | * @param int|null $albumDisc Which disc of the album this song is on 247 | * @param int|null $albumTrack Which track this song is 248 | * @param array $musician List of musician profile URLs 249 | */ 250 | public function musicSong( 251 | int $duration, 252 | array $album = [], 253 | ?int $albumDisc = null, 254 | ?int $albumTrack = null, 255 | array $musician = [] 256 | ): self { 257 | $this->type('music.song') 258 | ->add('music:duration', $duration) 259 | ->addWhen(! empty($album), 'music:album', $album) 260 | ->addWhen(! empty($albumDisc), 'music:album:disc', $albumDisc) 261 | ->addWhen(! empty($albumTrack), 'music:album:track', $albumTrack) 262 | ->addWhen(! empty($musician), 'music:musician', $musician); 263 | 264 | return $this; 265 | } 266 | 267 | /** 268 | * Set Music Album properties. 269 | * 270 | * @param array $song List of song URLs on this album (music.song) 271 | * @param array $songDisc List of disc numbers (same as music:album:disc but in reverse) 272 | * @param array $songTrack List of track numbers (same as music:album:track but in reverse) 273 | * @param array $musician List of musician profile URLs 274 | * @param string|null $releaseDate The date the album was released (ISO 8601) 275 | */ 276 | public function musicAlbum( 277 | array $song = [], 278 | array $songDisc = [], 279 | array $songTrack = [], 280 | array $musician = [], 281 | ?string $releaseDate = null 282 | ): self { 283 | $this->type('music.album') 284 | ->addWhen(! empty($song), 'music:song', $song) 285 | ->addWhen(! empty($songDisc), 'music:song:disc', $songDisc) 286 | ->addWhen(! empty($songTrack), 'music:song:track', $songTrack) 287 | ->addWhen(! empty($musician), 'music:musician', $musician) 288 | ->addWhen(! empty($releaseDate), 'music:release_date', $releaseDate); 289 | 290 | return $this; 291 | } 292 | 293 | /** 294 | * Set Music Playlist properties. 295 | * 296 | * @param array $song List of song URLs in this playlist (music.song) 297 | * @param array $songDisc List of disc numbers 298 | * @param array $songTrack List of track numbers 299 | * @param array $creator List of creator profile URLs 300 | */ 301 | public function musicPlaylist( 302 | array $song = [], 303 | array $songDisc = [], 304 | array $songTrack = [], 305 | array $creator = [] 306 | ): self { 307 | $this->type('music.playlist') 308 | ->addWhen(! empty($song), 'music:song', $song) 309 | ->addWhen(! empty($songDisc), 'music:song:disc', $songDisc) 310 | ->addWhen(! empty($songTrack), 'music:song:track', $songTrack) 311 | ->addWhen(! empty($creator), 'music:creator', $creator); 312 | 313 | return $this; 314 | } 315 | 316 | /** 317 | * Set Music Radio Station properties. 318 | * 319 | * @param array $creator List of creator profile URLs 320 | */ 321 | public function musicRadioStation(array $creator = []): self 322 | { 323 | $this->type('music.radio_station') 324 | ->addWhen(! empty($creator), 'music:creator', $creator); 325 | 326 | return $this; 327 | } 328 | 329 | /** 330 | * Set Video Movie properties. 331 | * 332 | * @param array $actor List of actor profile URLs 333 | * @param array $actorRole List of roles the actors played 334 | * @param array $director List of director profile URLs 335 | * @param array $writer List of writer profile URLs 336 | * @param int|null $duration The movie's length in seconds 337 | * @param string|null $releaseDate The date the movie was released (ISO 8601) 338 | * @param array $tag List of tag words associated with this movie 339 | */ 340 | public function videoMovie( 341 | array $actor = [], 342 | array $actorRole = [], 343 | array $director = [], 344 | array $writer = [], 345 | ?int $duration = null, 346 | ?string $releaseDate = null, 347 | array $tag = [] 348 | ): self { 349 | $this->type('video.movie') 350 | ->addWhen(! empty($actor), 'video:actor', $actor) 351 | ->addWhen(! empty($actorRole), 'video:actor:role', $actorRole) 352 | ->addWhen(! empty($director), 'video:director', $director) 353 | ->addWhen(! empty($writer), 'video:writer', $writer) 354 | ->addWhen(! empty($duration), 'video:duration', $duration) 355 | ->addWhen(! empty($releaseDate), 'video:release_date', $releaseDate) 356 | ->addWhen(! empty($tag), 'video:tag', $tag); 357 | 358 | return $this; 359 | } 360 | 361 | /** 362 | * Set Video Episode properties. 363 | * 364 | * @param string|null $series The TV show this episode belongs to (video.tv_show URL) 365 | * @param array $actor List of actor profile URLs 366 | * @param array $actorRole List of roles the actors played 367 | * @param array $director List of director profile URLs 368 | * @param array $writer List of writer profile URLs 369 | * @param int|null $duration The episode's length in seconds 370 | * @param string|null $releaseDate The date the episode was released (ISO 8601) 371 | * @param array $tag List of tag words associated with this episode 372 | */ 373 | public function videoEpisode( 374 | ?string $series = null, 375 | array $actor = [], 376 | array $actorRole = [], 377 | array $director = [], 378 | array $writer = [], 379 | ?int $duration = null, 380 | ?string $releaseDate = null, 381 | array $tag = [] 382 | ): self { 383 | $this->type('video.episode') 384 | ->addWhen(! empty($series), 'video:series', $series) 385 | ->addWhen(! empty($actor), 'video:actor', $actor) 386 | ->addWhen(! empty($actorRole), 'video:actor:role', $actorRole) 387 | ->addWhen(! empty($director), 'video:director', $director) 388 | ->addWhen(! empty($writer), 'video:writer', $writer) 389 | ->addWhen(! empty($duration), 'video:duration', $duration) 390 | ->addWhen(! empty($releaseDate), 'video:release_date', $releaseDate) 391 | ->addWhen(! empty($tag), 'video:tag', $tag); 392 | 393 | return $this; 394 | } 395 | 396 | /** 397 | * Set Video TV Show properties. 398 | * A multi-episode TV show. The metadata is identical to video.movie. 399 | * 400 | * @param array $actor List of actor profile URLs 401 | * @param array $actorRole List of roles the actors played 402 | * @param array $director List of director profile URLs 403 | * @param array $writer List of writer profile URLs 404 | * @param int|null $duration The show's length in seconds 405 | * @param string|null $releaseDate The date the show was released (ISO 8601) 406 | * @param array $tag List of tag words associated with this show 407 | */ 408 | public function videoTvShow( 409 | array $actor = [], 410 | array $actorRole = [], 411 | array $director = [], 412 | array $writer = [], 413 | ?int $duration = null, 414 | ?string $releaseDate = null, 415 | array $tag = [] 416 | ): self { 417 | $this->type('video.tv_show') 418 | ->addWhen(! empty($actor), 'video:actor', $actor) 419 | ->addWhen(! empty($actorRole), 'video:actor:role', $actorRole) 420 | ->addWhen(! empty($director), 'video:director', $director) 421 | ->addWhen(! empty($writer), 'video:writer', $writer) 422 | ->addWhen(! empty($duration), 'video:duration', $duration) 423 | ->addWhen(! empty($releaseDate), 'video:release_date', $releaseDate) 424 | ->addWhen(! empty($tag), 'video:tag', $tag); 425 | 426 | return $this; 427 | } 428 | 429 | /** 430 | * Set Video Other properties. 431 | * A video that doesn't belong in any other category. 432 | * 433 | * @param array $actor Array of actor profile URLs 434 | * @param array $actorRole Array of roles the actors played 435 | * @param array $director Array of director profile URLs 436 | * @param array $writer Array of writer profile URLs 437 | * @param int|null $duration The video's length in seconds 438 | * @param string|null $releaseDate The date the video was released (ISO 8601) 439 | * @param array $tag Array of tag words associated with this video 440 | */ 441 | public function videoOther( 442 | array $actor = [], 443 | array $actorRole = [], 444 | array $director = [], 445 | array $writer = [], 446 | ?int $duration = null, 447 | ?string $releaseDate = null, 448 | array $tag = [] 449 | ): self { 450 | $this->type('video.other') 451 | ->addWhen(! empty($actor), 'video:actor', $actor) 452 | ->addWhen(! empty($actorRole), 'video:actor:role', $actorRole) 453 | ->addWhen(! empty($director), 'video:director', $director) 454 | ->addWhen(! empty($writer), 'video:writer', $writer) 455 | ->addWhen(! empty($duration), 'video:duration', $duration) 456 | ->addWhen(! empty($releaseDate), 'video:release_date', $releaseDate) 457 | ->addWhen(! empty($tag), 'video:tag', $tag); 458 | 459 | return $this; 460 | } 461 | 462 | public function toArray(): array 463 | { 464 | return $this->properties; 465 | } 466 | 467 | public function clear(): self 468 | { 469 | $this->properties = []; 470 | $this->images = []; 471 | $this->videos = []; 472 | $this->audios = []; 473 | 474 | return $this; 475 | } 476 | 477 | public function has(string $property): bool 478 | { 479 | return array_key_exists($property, $this->properties); 480 | } 481 | 482 | public function get(string $property): ?string 483 | { 484 | return $this->properties[$property] ?? null; 485 | } 486 | 487 | public function toHtml(bool $minify = false): string 488 | { 489 | $output = []; 490 | 491 | // Add regular properties 492 | foreach ($this->properties as $property => $values) { 493 | $values = is_array($values) ? $values : [$values]; 494 | foreach ($values as $value) { 495 | $output[] = sprintf('', $property, Util::cleanString((string) $value)); 496 | } 497 | } 498 | 499 | // Add structured image properties 500 | foreach ($this->images as $image) { 501 | foreach ($image as $property => $value) { 502 | if ($value) { 503 | $output[] = sprintf('', $property, Util::cleanString((string) $value)); 504 | } 505 | } 506 | } 507 | 508 | // Add structured video properties 509 | foreach ($this->videos as $video) { 510 | foreach ($video as $property => $value) { 511 | if (! empty($value)) { 512 | $output[] = sprintf('', $property, Util::cleanString((string) $value)); 513 | } 514 | } 515 | } 516 | 517 | // Add structured audio properties 518 | foreach ($this->audios as $audio) { 519 | foreach ($audio as $property => $value) { 520 | if (! empty($value)) { 521 | $output[] = sprintf('', $property, Util::cleanString((string) $value)); 522 | } 523 | } 524 | } 525 | 526 | return implode($minify ? '' : PHP_EOL, $output); 527 | } 528 | } 529 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SeoKit 2 | 3 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/larament/seokit.svg?style=flat-square)](https://packagist.org/packages/larament/seokit) 4 | [![GitHub Tests Action Status](https://img.shields.io/github/actions/workflow/status/larament/seokit/run-tests.yml?branch=main&label=tests&style=flat-square)](https://github.com/larament/seokit/actions?query=workflow%3Arun-tests+branch%3Amain) 5 | [![Total Downloads](https://img.shields.io/packagist/dt/larament/seokit.svg?style=flat-square)](https://packagist.org/packages/larament/seokit) 6 | [![License](https://img.shields.io/packagist/l/larament/seokit.svg?style=flat-square)](https://packagist.org/packages/larament/seokit) 7 | 8 | A complete SEO package for Laravel, covering everything from meta tags to social sharing and structured data. 9 | 10 | ## Introduction 11 | 12 | **SeoKit** is a comprehensive SEO solution for Laravel applications that makes it easy to manage all aspects of your site's search engine optimization. Whether you need basic meta tags, rich social media previews, or complex structured data markup, SeoKit has you covered. 13 | 14 | ### Why SeoKit? 15 | 16 | - **Complete SEO Solution**: Meta tags, Open Graph, Twitter Cards, and JSON-LD structured data in one package 17 | - **Database-backed**: Store SEO data in your database with polymorphic relationships 18 | - **Model Integration**: Simple traits to add SEO capabilities to any Eloquent model 19 | - **Flexible Configuration**: Sensible defaults with extensive customization options 20 | - **Performance**: Built-in caching for SEO data to minimize database queries 21 | - **Developer Friendly**: Clean, fluent API with chainable methods 22 | - **Modern Laravel**: Built for Laravel 11.x and 12.x with PHP 8.3+ 23 | 24 | ### Features 25 | 26 | - 🏷️ **Meta Tags Management** - Title, description, keywords, robots, canonical URLs and more 27 | - 🌐 **Open Graph Protocol** - Full support for Facebook and social sharing 28 | - 🐦 **Twitter Cards** - Summary, large image, player, and app cards 29 | - 📊 **JSON-LD Structured Data** - Schema.org markup for rich search results 30 | - 💾 **Database-backed SEO** - Store SEO data per model instance 31 | - 🎭 **Model Traits** - Easy integration with Eloquent models 32 | - ⚡ **Caching** - Automatic caching of database SEO data for better performance 33 | - 🎨 **Blade Directive** - Simple `@seoKit` directive for rendering 34 | - 🔧 **Highly Configurable** - Extensive configuration options 35 | 36 | ## Requirements 37 | 38 | - PHP 8.3 or higher 39 | - Laravel 11.x or 12.x 40 | 41 | ## Installation 42 | 43 | You can install the package via composer: 44 | 45 | ```bash 46 | composer require larament/seokit 47 | ``` 48 | 49 | ### Quick Installation 50 | 51 | The package comes with an install command that will publish the config file, migrations, and optionally run the migrations: 52 | 53 | ```bash 54 | php artisan seokit:install 55 | ``` 56 | 57 | ### Manual Installation 58 | 59 | Alternatively, you can publish the config file and migrations manually: 60 | 61 | ```bash 62 | php artisan vendor:publish --tag="seokit-config" 63 | php artisan vendor:publish --tag="seokit-migrations" 64 | php artisan migrate 65 | ``` 66 | 67 | ## Configuration 68 | 69 | The configuration file `config/seokit.php` provides extensive options for customizing the package behavior: 70 | 71 | ```php 72 | return [ 73 | // Database table name 74 | 'table_name' => 'seokit', 75 | 76 | // Auto-generate title from URL when not set 77 | 'auto_title_from_url' => true, 78 | 79 | // Custom title inference callback 80 | 'title_inference_callback' => null, 81 | 82 | // Default meta tags 83 | 'defaults' => [ 84 | 'title' => 'My Laravel App', 85 | 'before_title' => null, 86 | 'after_title' => null, 87 | 'title_separator' => ' - ', 88 | 'description' => null, 89 | 'canonical' => null, // null = current URL, 'full' = full URL, false = disabled 90 | 'robots' => 'index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1', 91 | ], 92 | 93 | // Open Graph settings 94 | 'opengraph' => [ 95 | 'enabled' => true, 96 | 'defaults' => [ 97 | 'site_name' => env('APP_NAME', 'Laravel'), 98 | 'type' => 'website', 99 | 'url' => null, 100 | 'locale' => 'en_US', 101 | ], 102 | ], 103 | 104 | // Twitter Card settings 105 | 'twitter' => [ 106 | 'enabled' => true, 107 | 'defaults' => [ 108 | 'card' => 'summary_large_image', 109 | 'site' => '@yourusername', 110 | 'creator' => '@yourusername', 111 | ], 112 | ], 113 | 114 | // JSON-LD settings 115 | 'json_ld' => [ 116 | 'enabled' => true, 117 | 'defaults' => [], 118 | ], 119 | ]; 120 | ``` 121 | 122 | ## Basic Usage 123 | 124 | ### Simple Usage 125 | 126 | The easiest way to set SEO tags is using the `SeoKit` facade: 127 | 128 | ```php 129 | use Larament\SeoKit\Facades\SeoKit; 130 | 131 | // In your controller 132 | public function show(Post $post) 133 | { 134 | SeoKit::title($post->title) 135 | ->description($post->excerpt) 136 | ->image($post->featured_image); 137 | 138 | return view('posts.show', compact('post')); 139 | } 140 | ``` 141 | 142 | Then in your layout file (e.g., `resources/views/layouts/app.blade.php`): 143 | 144 | ```blade 145 | 146 | 147 | 148 | 149 | 150 | 151 | @seoKit 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | ``` 160 | 161 | The `@seoKit` directive will render all configured SEO tags including meta tags, Open Graph, Twitter Cards, and JSON-LD. 162 | 163 | ### Accessing Individual Components 164 | 165 | You can also work with individual components for more control: 166 | 167 | ```php 168 | use Larament\SeoKit\Facades\SeoKit; 169 | 170 | // Meta tags 171 | SeoKit::meta() 172 | ->title('My Page Title') 173 | ->description('A great description') 174 | ->keywords(['laravel', 'seo', 'optimization']) 175 | ->canonical('https://example.com/page'); 176 | 177 | // Open Graph 178 | SeoKit::opengraph() 179 | ->title('My Page Title') 180 | ->description('A great description') 181 | ->image('https://example.com/image.jpg') 182 | ->type('article'); 183 | 184 | // Twitter Cards 185 | SeoKit::twitter() 186 | ->card('summary_large_image') 187 | ->title('My Page Title') 188 | ->description('A great description') 189 | ->image('https://example.com/image.jpg'); 190 | 191 | // JSON-LD 192 | SeoKit::jsonld() 193 | ->article([ 194 | 'headline' => 'My Article', 195 | 'description' => 'Article description', 196 | 'author' => [ 197 | '@type' => 'Person', 198 | 'name' => 'John Doe', 199 | ], 200 | ]); 201 | ``` 202 | 203 | ## Advanced Usage 204 | 205 | ### Meta Tags 206 | 207 | #### Custom Meta Tags 208 | 209 | Add any custom meta tag: 210 | 211 | ```php 212 | SeoKit::meta()->addMeta('author', 'John Doe'); 213 | SeoKit::meta()->addMeta('theme-color', '#ffffff'); 214 | ``` 215 | 216 | #### Robots Meta Tag 217 | 218 | Control how search engines index your pages: 219 | 220 | ```php 221 | // String format 222 | SeoKit::meta()->robots('noindex, nofollow'); 223 | 224 | // Array format 225 | SeoKit::meta()->robots(['noindex', 'nofollow', 'noarchive']); 226 | ``` 227 | 228 | #### Canonical URLs 229 | 230 | Specify the canonical URL for duplicate content: 231 | 232 | ```php 233 | SeoKit::meta()->canonical('https://example.com/canonical-page'); 234 | ``` 235 | 236 | #### Language Alternates 237 | 238 | Add alternate language versions of your page: 239 | 240 | ```php 241 | SeoKit::meta() 242 | ->addLanguage('en', 'https://example.com/en/page') 243 | ->addLanguage('es', 'https://example.com/es/page') 244 | ->addLanguage('fr', 'https://example.com/fr/page'); 245 | ``` 246 | 247 | #### Pagination 248 | 249 | For paginated content, add prev/next links: 250 | 251 | ```php 252 | SeoKit::meta() 253 | ->prev('https://example.com/page/1', condition: $currentPage > 1) 254 | ->next('https://example.com/page/3', condition: $currentPage < $totalPages); 255 | ``` 256 | 257 | ### Open Graph 258 | 259 | #### Article Metadata 260 | 261 | For blog posts and articles: 262 | 263 | ```php 264 | SeoKit::opengraph()->article( 265 | publishedTime: '2024-01-15T08:00:00+00:00', 266 | modifiedTime: '2024-01-16T10:30:00+00:00', 267 | authors: ['https://example.com/author/john-doe'], 268 | section: 'Technology', 269 | tags: ['Laravel', 'PHP', 'SEO'] 270 | ); 271 | ``` 272 | 273 | #### Video Content 274 | 275 | For video content: 276 | 277 | ```php 278 | // Video movie 279 | SeoKit::opengraph()->videoMovie( 280 | actor: ['https://example.com/actor/john'], 281 | director: ['https://example.com/director/jane'], 282 | duration: 7200, 283 | releaseDate: '2024-01-01', 284 | tag: ['action', 'adventure'] 285 | ); 286 | 287 | // Video episode 288 | SeoKit::opengraph()->videoEpisode( 289 | series: 'https://example.com/series/my-show', 290 | actor: ['https://example.com/actor/john'], 291 | duration: 2400, 292 | releaseDate: '2024-01-15' 293 | ); 294 | ``` 295 | 296 | #### Music Content 297 | 298 | For music-related content: 299 | 300 | ```php 301 | // Music song 302 | SeoKit::opengraph()->musicSong( 303 | duration: 240, 304 | album: ['https://example.com/album/my-album'], 305 | albumDisc: 1, 306 | albumTrack: 5, 307 | musician: ['https://example.com/artist/john-doe'] 308 | ); 309 | 310 | // Music album 311 | SeoKit::opengraph()->musicAlbum( 312 | song: ['https://example.com/song/track-1', 'https://example.com/song/track-2'], 313 | musician: ['https://example.com/artist/john-doe'], 314 | releaseDate: '2024-01-01' 315 | ); 316 | ``` 317 | 318 | #### Profile 319 | 320 | For profile pages: 321 | 322 | ```php 323 | SeoKit::opengraph()->profile( 324 | firstName: 'John', 325 | lastName: 'Doe', 326 | username: 'johndoe', 327 | gender: 'male' 328 | ); 329 | ``` 330 | 331 | #### Book 332 | 333 | For book-related content: 334 | 335 | ```php 336 | SeoKit::opengraph()->book( 337 | author: ['https://example.com/author/john-doe'], 338 | isbn: '978-3-16-148410-0', 339 | releaseDate: '2024-01-01', 340 | tags: ['fiction', 'thriller'] 341 | ); 342 | ``` 343 | 344 | ### Twitter Cards 345 | 346 | #### Summary Card 347 | 348 | ```php 349 | SeoKit::twitter() 350 | ->card('summary') 351 | ->site('@mysite') 352 | ->creator('@johndoe') 353 | ->title('Page Title') 354 | ->description('Page description') 355 | ->image('https://example.com/image.jpg', 'Image alt text'); 356 | ``` 357 | 358 | #### Large Image Summary Card 359 | 360 | ```php 361 | SeoKit::twitter() 362 | ->card('summary_large_image') 363 | ->title('Page Title') 364 | ->description('Page description') 365 | ->image('https://example.com/large-image.jpg'); 366 | ``` 367 | 368 | #### Player Card 369 | 370 | For video or audio content: 371 | 372 | ```php 373 | SeoKit::twitter() 374 | ->card('player') 375 | ->player('https://example.com/player.html', 640, 480); 376 | ``` 377 | 378 | ### JSON-LD Structured Data 379 | 380 | #### Website Schema 381 | 382 | ```php 383 | SeoKit::jsonld()->website([ 384 | 'url' => 'https://example.com', 385 | 'name' => 'My Website', 386 | 'description' => 'A great website', 387 | 'potentialAction' => [ 388 | '@type' => 'SearchAction', 389 | 'target' => 'https://example.com/search?q={search_term_string}', 390 | 'query-input' => 'required name=search_term_string', 391 | ], 392 | ]); 393 | ``` 394 | 395 | #### Organization Schema 396 | 397 | ```php 398 | SeoKit::jsonld()->organization([ 399 | 'name' => 'My Company', 400 | 'url' => 'https://example.com', 401 | 'logo' => 'https://example.com/logo.png', 402 | 'contactPoint' => [ 403 | '@type' => 'ContactPoint', 404 | 'telephone' => '+1-555-555-5555', 405 | 'contactType' => 'customer service', 406 | ], 407 | 'sameAs' => [ 408 | 'https://facebook.com/mycompany', 409 | 'https://twitter.com/mycompany', 410 | 'https://linkedin.com/company/mycompany', 411 | ], 412 | ]); 413 | ``` 414 | 415 | #### Article/Blog Post Schema 416 | 417 | ```php 418 | SeoKit::jsonld()->article([ 419 | 'headline' => 'My Blog Post Title', 420 | 'description' => 'A compelling description', 421 | 'image' => 'https://example.com/image.jpg', 422 | 'author' => [ 423 | '@type' => 'Person', 424 | 'name' => 'John Doe', 425 | 'url' => 'https://example.com/author/john-doe', 426 | ], 427 | 'publisher' => [ 428 | '@type' => 'Organization', 429 | 'name' => 'My Website', 430 | 'logo' => [ 431 | '@type' => 'ImageObject', 432 | 'url' => 'https://example.com/logo.png', 433 | ], 434 | ], 435 | 'datePublished' => '2024-01-15T08:00:00+00:00', 436 | 'dateModified' => '2024-01-16T10:30:00+00:00', 437 | ]); 438 | ``` 439 | 440 | #### Product Schema 441 | 442 | ```php 443 | SeoKit::jsonld()->product([ 444 | 'name' => 'Product Name', 445 | 'image' => 'https://example.com/product.jpg', 446 | 'description' => 'Product description', 447 | 'sku' => 'ABC123', 448 | 'brand' => [ 449 | '@type' => 'Brand', 450 | 'name' => 'Brand Name', 451 | ], 452 | 'offers' => [ 453 | '@type' => 'Offer', 454 | 'url' => 'https://example.com/product', 455 | 'priceCurrency' => 'USD', 456 | 'price' => '29.99', 457 | 'priceValidUntil' => '2024-12-31', 458 | 'availability' => 'https://schema.org/InStock', 459 | ], 460 | 'aggregateRating' => [ 461 | '@type' => 'AggregateRating', 462 | 'ratingValue' => '4.5', 463 | 'reviewCount' => '125', 464 | ], 465 | ]); 466 | ``` 467 | 468 | #### Local Business Schema 469 | 470 | ```php 471 | SeoKit::jsonld()->localBusiness([ 472 | 'name' => 'My Business', 473 | 'image' => 'https://example.com/business.jpg', 474 | 'description' => 'Business description', 475 | 'address' => [ 476 | '@type' => 'PostalAddress', 477 | 'streetAddress' => '123 Main St', 478 | 'addressLocality' => 'New York', 479 | 'addressRegion' => 'NY', 480 | 'postalCode' => '10001', 481 | 'addressCountry' => 'US', 482 | ], 483 | 'telephone' => '+1-555-555-5555', 484 | 'openingHours' => 'Mo-Fr 09:00-17:00', 485 | 'geo' => [ 486 | '@type' => 'GeoCoordinates', 487 | 'latitude' => '40.7128', 488 | 'longitude' => '-74.0060', 489 | ], 490 | 'priceRange' => '$$', 491 | ]); 492 | ``` 493 | 494 | #### Custom Schema 495 | 496 | Add any custom schema: 497 | 498 | ```php 499 | SeoKit::jsonld()->add([ 500 | '@context' => 'https://schema.org', 501 | '@type' => 'Event', 502 | 'name' => 'My Event', 503 | 'startDate' => '2024-06-15T19:00:00-05:00', 504 | 'endDate' => '2024-06-15T23:00:00-05:00', 505 | 'location' => [ 506 | '@type' => 'Place', 507 | 'name' => 'Event Venue', 508 | 'address' => [ 509 | '@type' => 'PostalAddress', 510 | 'streetAddress' => '123 Event St', 511 | 'addressLocality' => 'New York', 512 | 'addressRegion' => 'NY', 513 | 'postalCode' => '10001', 514 | ], 515 | ], 516 | ]); 517 | ``` 518 | 519 | ## Database-backed SEO 520 | 521 | SeoKit provides two approaches for storing SEO data in your database. 522 | 523 | ### Using the HasSeo Trait 524 | 525 | This approach stores SEO data in a separate polymorphic table, allowing you to manage SEO independently from your model's main attributes. 526 | 527 | #### Step 1: Add the Trait 528 | 529 | Add the `HasSeo` trait to your model: 530 | 531 | ```php 532 | use Illuminate\Database\Eloquent\Model; 533 | use Larament\SeoKit\Concerns\HasSeo; 534 | 535 | class Post extends Model 536 | { 537 | use HasSeo; 538 | } 539 | ``` 540 | 541 | #### Step 2: Create SEO Data 542 | 543 | ```php 544 | $post = Post::find(1); 545 | 546 | $post->seo()->create([ 547 | 'title' => 'Custom SEO Title', 548 | 'description' => 'Custom SEO description for search engines', 549 | 'canonical' => 'https://example.com/posts/custom-url', 550 | 'robots' => 'index, follow', 551 | 'og_title' => 'Custom OG Title', 552 | 'og_description' => 'Custom OG description for social sharing', 553 | 'og_image' => 'https://example.com/images/og-image.jpg', 554 | 'twitter_image' => 'https://example.com/images/twitter-image.jpg', 555 | 'structured_data' => [ 556 | '@context' => 'https://schema.org', 557 | '@type' => 'Article', 558 | 'headline' => 'My Article', 559 | ], 560 | 'is_cornerstone' => true, // Mark as cornerstone content 561 | ]); 562 | ``` 563 | 564 | #### Step 3: Apply SEO Tags 565 | 566 | In your controller, call `prepareSeoTags()`: 567 | 568 | ```php 569 | public function show(Post $post) 570 | { 571 | $post->prepareSeoTags(); 572 | 573 | return view('posts.show', compact('post')); 574 | } 575 | ``` 576 | 577 | The method will automatically retrieve cached SEO data and apply it to the page. 578 | 579 | #### Updating SEO Data 580 | 581 | ```php 582 | $post->seo()->update([ 583 | 'title' => 'Updated SEO Title', 584 | 'description' => 'Updated description', 585 | ]); 586 | ``` 587 | 588 | #### Checking Cornerstone Content 589 | 590 | ```php 591 | if ($post->isCornerstone()) { 592 | // This is cornerstone content 593 | } 594 | ``` 595 | 596 | #### Caching Behavior 597 | 598 | The `HasSeo` trait automatically caches SEO data using Laravel's cache system. The cache is automatically invalidated when: 599 | 600 | - The SEO data is updated 601 | - The model is deleted 602 | 603 | ### Using the HasSeoData Trait 604 | 605 | This approach is ideal when you want to derive SEO data from your model's existing attributes without storing separate SEO records. 606 | 607 | #### Step 1: Add the Trait and Implement Method 608 | 609 | ```php 610 | use Illuminate\Database\Eloquent\Model; 611 | use Larament\SeoKit\Concerns\HasSeoData; 612 | use Larament\SeoKit\Data\SeoData; 613 | 614 | class Post extends Model 615 | { 616 | use HasSeoData; 617 | 618 | private function toSeoData(): SeoData 619 | { 620 | return new SeoData( 621 | title: $this->title, 622 | description: $this->excerpt, 623 | canonical: route('posts.show', $this), 624 | robots: $this->is_published ? 'index, follow' : 'noindex, nofollow', 625 | og_image: $this->featured_image, 626 | structured_data: [ 627 | '@context' => 'https://schema.org', 628 | '@type' => 'BlogPosting', 629 | 'headline' => $this->title, 630 | 'datePublished' => $this->published_at?->toIso8601String(), 631 | 'dateModified' => $this->updated_at?->toIso8601String(), 632 | ], 633 | ); 634 | } 635 | } 636 | ``` 637 | 638 | #### Step 2: Apply SEO Tags 639 | 640 | ```php 641 | public function show(Post $post) 642 | { 643 | $post->prepareSeoTags(); 644 | 645 | return view('posts.show', compact('post')); 646 | } 647 | ``` 648 | 649 | ## Complete Examples 650 | 651 | ### Blog Post Example 652 | 653 | Controller: 654 | 655 | ```php 656 | namespace App\Http\Controllers; 657 | 658 | use App\Models\Post; 659 | use Larament\SeoKit\Facades\SeoKit; 660 | 661 | class PostController extends Controller 662 | { 663 | public function show(Post $post) 664 | { 665 | // Option 1: Using database SEO (if using HasSeo or HasSeoData trait) 666 | $post->prepareSeoTags(); 667 | 668 | // Option 2: Manual SEO setup 669 | SeoKit::title($post->title) 670 | ->description($post->excerpt) 671 | ->image($post->featured_image) 672 | ->canonical(route('posts.show', $post)); 673 | 674 | SeoKit::opengraph()->article( 675 | publishedTime: $post->published_at?->toIso8601String(), 676 | modifiedTime: $post->updated_at?->toIso8601String(), 677 | authors: [$post->author->profile_url], 678 | section: $post->category->name, 679 | tags: $post->tags->pluck('name')->toArray() 680 | ); 681 | 682 | SeoKit::jsonld()->article([ 683 | 'headline' => $post->title, 684 | 'description' => $post->excerpt, 685 | 'image' => $post->featured_image, 686 | 'author' => [ 687 | '@type' => 'Person', 688 | 'name' => $post->author->name, 689 | ], 690 | 'datePublished' => $post->published_at?->toIso8601String(), 691 | 'dateModified' => $post->updated_at?->toIso8601String(), 692 | ]); 693 | 694 | return view('posts.show', compact('post')); 695 | } 696 | } 697 | ``` 698 | 699 | ### E-commerce Product Example 700 | 701 | ```php 702 | namespace App\Http\Controllers; 703 | 704 | use App\Models\Product; 705 | use Larament\SeoKit\Facades\SeoKit; 706 | 707 | class ProductController extends Controller 708 | { 709 | public function show(Product $product) 710 | { 711 | SeoKit::title($product->name) 712 | ->description($product->short_description) 713 | ->image($product->primary_image) 714 | ->canonical(route('products.show', $product)); 715 | 716 | SeoKit::opengraph() 717 | ->type('product') 718 | ->add('product:price:amount', $product->price) 719 | ->add('product:price:currency', 'USD'); 720 | 721 | SeoKit::jsonld()->product([ 722 | 'name' => $product->name, 723 | 'image' => $product->images->pluck('url')->toArray(), 724 | 'description' => $product->description, 725 | 'sku' => $product->sku, 726 | 'brand' => [ 727 | '@type' => 'Brand', 728 | 'name' => $product->brand->name, 729 | ], 730 | 'offers' => [ 731 | '@type' => 'Offer', 732 | 'url' => route('products.show', $product), 733 | 'priceCurrency' => 'USD', 734 | 'price' => $product->price, 735 | 'availability' => $product->in_stock 736 | ? 'https://schema.org/InStock' 737 | : 'https://schema.org/OutOfStock', 738 | ], 739 | 'aggregateRating' => [ 740 | '@type' => 'AggregateRating', 741 | 'ratingValue' => $product->average_rating, 742 | 'reviewCount' => $product->reviews_count, 743 | ], 744 | ]); 745 | 746 | return view('products.show', compact('product')); 747 | } 748 | } 749 | ``` 750 | 751 | ### Homepage with Organization Schema 752 | 753 | ```php 754 | namespace App\Http\Controllers; 755 | 756 | use Larament\SeoKit\Facades\SeoKit; 757 | 758 | class HomeController extends Controller 759 | { 760 | public function index() 761 | { 762 | SeoKit::title('Welcome to Our Website') 763 | ->description('Discover amazing products and services'); 764 | 765 | SeoKit::jsonld()->organization([ 766 | 'name' => config('app.name'), 767 | 'url' => config('app.url'), 768 | 'logo' => asset('images/logo.png'), 769 | 'contactPoint' => [ 770 | '@type' => 'ContactPoint', 771 | 'telephone' => '+1-555-555-5555', 772 | 'contactType' => 'customer service', 773 | 'email' => 'support@example.com', 774 | ], 775 | 'sameAs' => [ 776 | 'https://facebook.com/yourpage', 777 | 'https://twitter.com/yourhandle', 778 | 'https://linkedin.com/company/yourcompany', 779 | 'https://instagram.com/yourprofile', 780 | ], 781 | ]); 782 | 783 | SeoKit::jsonld()->website([ 784 | 'url' => config('app.url'), 785 | 'name' => config('app.name'), 786 | 'potentialAction' => [ 787 | '@type' => 'SearchAction', 788 | 'target' => route('search') . '?q={search_term_string}', 789 | 'query-input' => 'required name=search_term_string', 790 | ], 791 | ]); 792 | 793 | return view('home'); 794 | } 795 | } 796 | ``` 797 | 798 | ## Testing 799 | 800 | Run the tests with: 801 | 802 | ```bash 803 | composer test 804 | ``` 805 | 806 | Run tests with coverage: 807 | 808 | ```bash 809 | composer test-coverage 810 | ``` 811 | 812 | Run static analysis: 813 | 814 | ```bash 815 | composer analyse 816 | ``` 817 | 818 | Format code: 819 | 820 | ```bash 821 | composer format 822 | ``` 823 | 824 | ## Changelog 825 | 826 | Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. 827 | 828 | ## Contributing 829 | 830 | Contributions are welcome! Please feel free to submit a Pull Request. 831 | 832 | ## Security Vulnerabilities 833 | 834 | Please review [our security policy](../../security/policy) on how to report security vulnerabilities. 835 | 836 | ## Credits 837 | 838 | - [Raziul Islam](https://github.com/iRaziul) 839 | - [All Contributors](../../contributors) 840 | 841 | ## License 842 | 843 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 844 | --------------------------------------------------------------------------------