├── 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 | [](https://packagist.org/packages/larament/seokit)
4 | [](https://github.com/larament/seokit/actions?query=workflow%3Arun-tests+branch%3Amain)
5 | [](https://packagist.org/packages/larament/seokit)
6 | [](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 |
--------------------------------------------------------------------------------