├── .gitignore ├── src ├── Http │ ├── PermalinkController.php │ └── ResolvesPermalinkView.php ├── Contracts │ ├── ActionFactory.php │ ├── PathBuilder.php │ ├── Permalinkable.php │ └── SeoBuilder.php ├── Builders │ ├── BaseBuilder.php │ ├── TwitterBuilder.php │ ├── OpenGraphBuilder.php │ ├── MetaBuilder.php │ └── Builder.php ├── helpers.php ├── Middleware │ ├── BuildSeo.php │ └── ResolvePermalinkEntities.php ├── Routing │ ├── ReplacesRouter.php │ ├── Route.php │ └── Router.php ├── Services │ ├── ActionFactory.php │ └── PathBuilder.php ├── Console │ └── InstallRouter.php ├── EntityObserver.php ├── PermalinkObserver.php ├── PermalinkServiceProvider.php ├── PermalinkManager.php ├── PermalinkSeo.php ├── HasPermalinks.php └── Permalink.php ├── tests ├── Support │ ├── Models │ │ ├── Company.php │ │ ├── UserWithSoftDeletes.php │ │ ├── UserWithDisabledPermalinkHandling.php │ │ ├── User.php │ │ └── UserWithDefaultSeoAttributes.php │ ├── Kernel.php │ ├── factories │ │ ├── Company.php │ │ ├── User.php │ │ ├── UserWithSoftDeletes.php │ │ ├── UserWithDefaultSeoAttributes.php │ │ └── UserWithDsiabledPermalinkHandling.php │ ├── Controllers │ │ └── TestController.php │ └── migrations │ │ ├── 2014_10_12_000000_add_soft_deletes_to_users_table.php │ │ └── 2018_11_06_095050_create_companies_table.php ├── Unit │ ├── ArrayUndotTest.php │ ├── ActionFactoryTest.php │ └── PathBuilderTest.php ├── Feature │ ├── Routing │ │ ├── ResolvingTest.php │ │ └── RoutingTest.php │ ├── HasPermalinks │ │ ├── ReadingTest.php │ │ ├── NestingTest.php │ │ ├── DeletingTest.php │ │ ├── SeoAttributesTest.php │ │ ├── CreatingTest.php │ │ ├── ActionTest.php │ │ └── SlugTest.php │ └── Permalink │ │ ├── UpdatePermalinkTest.php │ │ └── CreatePermalinkTest.php └── TestCase.php ├── .travis.yml ├── LICENSE ├── phpunit.xml ├── migrations └── 2018_06_01_092537_create_permalinks_table.php ├── composer.json ├── config └── permalink.php └── readme.md /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | /.idea/ 3 | composer.lock 4 | .phpunit.result.cache 5 | -------------------------------------------------------------------------------- /src/Http/PermalinkController.php: -------------------------------------------------------------------------------- 1 | define(Company::class, function (Faker $faker) { 7 | return [ 8 | 'name' => $faker->name, 9 | ]; 10 | }); -------------------------------------------------------------------------------- /tests/Support/Models/UserWithSoftDeletes.php: -------------------------------------------------------------------------------- 1 | helper->disableTwitter(); 13 | } 14 | } -------------------------------------------------------------------------------- /src/Builders/OpenGraphBuilder.php: -------------------------------------------------------------------------------- 1 | helper->disableOpenGraph(); 13 | } 14 | } -------------------------------------------------------------------------------- /src/Contracts/PathBuilder.php: -------------------------------------------------------------------------------- 1 | define(User::class, function (Faker $faker) { 7 | return [ 8 | 'name' => $faker->name, 9 | 'email' => $faker->email, 10 | 'password' => '' 11 | ]; 12 | }); -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | php: 4 | - 7.3 5 | 6 | cache: 7 | directories: 8 | - $HOME/.composer/cache 9 | 10 | before_install: 11 | - travis_retry composer self-update 12 | 13 | install: travis_retry composer update --prefer-dist --no-interaction --prefer-stable --no-suggest 14 | 15 | script: vendor/bin/phpunit --verbose 16 | -------------------------------------------------------------------------------- /src/helpers.php: -------------------------------------------------------------------------------- 1 | route($permalink); 13 | } 14 | } -------------------------------------------------------------------------------- /tests/Support/factories/UserWithSoftDeletes.php: -------------------------------------------------------------------------------- 1 | define(UserWithSoftDeletes::class, function (Faker $faker) { 7 | return [ 8 | 'name' => $faker->name, 9 | 'email' => $faker->email, 10 | 'password' => '' 11 | ]; 12 | }); -------------------------------------------------------------------------------- /tests/Support/factories/UserWithDefaultSeoAttributes.php: -------------------------------------------------------------------------------- 1 | define(UserWithDefaultSeoAttributes::class, function (Faker $faker) { 7 | return [ 8 | 'name' => $faker->name, 9 | 'email' => $faker->email, 10 | 'password' => '' 11 | ]; 12 | }); -------------------------------------------------------------------------------- /tests/Support/factories/UserWithDsiabledPermalinkHandling.php: -------------------------------------------------------------------------------- 1 | define(UserWithDisabledPermalinkHandling::class, function (Faker $faker) { 7 | return [ 8 | 'name' => $faker->name, 9 | 'email' => $faker->email, 10 | 'password' => '' 11 | ]; 12 | }); 13 | -------------------------------------------------------------------------------- /src/Contracts/Permalinkable.php: -------------------------------------------------------------------------------- 1 | request($request)->runBuilders(); 19 | 20 | return $next($request); 21 | } 22 | } -------------------------------------------------------------------------------- /tests/Unit/ArrayUndotTest.php: -------------------------------------------------------------------------------- 1 | 1, 15 | 'foo.baz' => 2 16 | ]; 17 | 18 | $result = Arr::undot($given); 19 | 20 | $this->assertEquals(['foo' => ['bar' => 1, 'baz' => 2]], $result); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tests/Support/Controllers/TestController.php: -------------------------------------------------------------------------------- 1 | route()->permalink()->seo['title']; 22 | } 23 | 24 | public function typehinted(Permalink $permalink) 25 | { 26 | return $permalink->slug; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Routing/ReplacesRouter.php: -------------------------------------------------------------------------------- 1 | router = $this->app['router']; 15 | 16 | $this->router->replaceMiddleware( 17 | $this->routeMiddleware + $this->router->getMiddleware(), 18 | $this->middlewareGroups + $this->router->getMiddlewareGroups() 19 | ); 20 | 21 | return parent::dispatchToRouter(); 22 | } 23 | } -------------------------------------------------------------------------------- /tests/Unit/ActionFactoryTest.php: -------------------------------------------------------------------------------- 1 | 'foo', 'action' => 'welcome']); 16 | 17 | $this->assertEquals('Devio\Permalink\Http\PermalinkController@view', $factory->resolve($permalink)); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tests/Support/migrations/2014_10_12_000000_add_soft_deletes_to_users_table.php: -------------------------------------------------------------------------------- 1 | softDeletes(); 18 | }); 19 | } 20 | 21 | /** 22 | * Reverse the migrations. 23 | * 24 | * @return void 25 | */ 26 | public function down() 27 | { 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Http/ResolvesPermalinkView.php: -------------------------------------------------------------------------------- 1 | getShortName()); 23 | $data[$key] = $param; 24 | } 25 | 26 | return view($permalink->rawAction, $data); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tests/Support/migrations/2018_11_06_095050_create_companies_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 18 | 19 | $table->string('name'); 20 | 21 | $table->timestamps(); 22 | }); 23 | } 24 | 25 | /** 26 | * Reverse the migrations. 27 | * 28 | * @return void 29 | */ 30 | public function down() 31 | { 32 | Schema::dropIfExists('companies'); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tests/Support/Models/User.php: -------------------------------------------------------------------------------- 1 | 'entity.name' 35 | ]; 36 | } 37 | } -------------------------------------------------------------------------------- /tests/Support/Models/UserWithDefaultSeoAttributes.php: -------------------------------------------------------------------------------- 1 | route(); 19 | 20 | if ($route->hasPermalink()) { 21 | foreach ($route->signatureParameters() as $parameter) { 22 | $type = $parameter->getType(); 23 | 24 | if ($type && $type->getName() == Permalink::class) { 25 | $route->parameters[] = $route->permalink(); 26 | } else { 27 | $route->parameters[] = $route->permalink()->entity; 28 | } 29 | } 30 | } 31 | 32 | return $next($request); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Builders/MetaBuilder.php: -------------------------------------------------------------------------------- 1 | helper->meta()->getMiscEntity()->add( 15 | 'robots', implode(',', (array) $robots) 16 | ); 17 | } 18 | 19 | /** 20 | * Set the canonical URL. 21 | * 22 | * @param $content 23 | */ 24 | public function setCanonical($content) 25 | { 26 | $this->helper->meta()->getMiscEntity()->setUrl($content); 27 | } 28 | 29 | /** 30 | * @inheritdoc 31 | */ 32 | public function disable(): void 33 | { 34 | // In this case we won't perform any action for the Meta builder so it 35 | // can not be disabled as it would disable the page title. To modify 36 | // this behaviour, feel free to replace the class in the container. 37 | } 38 | } -------------------------------------------------------------------------------- /tests/Feature/Routing/ResolvingTest.php: -------------------------------------------------------------------------------- 1 | create(['name' => 'israel ortuno']); 14 | 15 | $this->assertEquals('http://localhost/israel-ortuno', permalink($user)); 16 | } 17 | 18 | /** @test */ 19 | public function it_can_resolve_route_using_integer() 20 | { 21 | factory(User::class)->create(['name' => 'israel ortuno']); 22 | 23 | $this->assertEquals('http://localhost/israel-ortuno', permalink(1)); 24 | } 25 | 26 | /** @test */ 27 | public function it_can_resolve_route_using_permalink() 28 | { 29 | $user = factory(User::class)->create(['name' => 'israel ortuno']); 30 | 31 | $this->assertEquals('http://localhost/israel-ortuno', permalink($user->permalink)); 32 | } 33 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Israel Ortuño 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | ./tests 15 | 16 | 17 | 18 | 19 | app/ 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/Services/ActionFactory.php: -------------------------------------------------------------------------------- 1 | rawAction) { 20 | $action = Permalink::getMappedaction($action) ?? $action; 21 | } elseif ($entity = $permalink->getRelationValue('entity')) { 22 | $action = $entity->permalinkAction(); 23 | } 24 | 25 | return $this->buildAction($action); 26 | } 27 | 28 | /** 29 | * Resolve the view or controller for the given action. 30 | * 31 | * @param $action 32 | * @return string 33 | */ 34 | protected function buildAction($action) 35 | { 36 | return view()->exists($action) 37 | ? PermalinkController::class . '@view' 38 | : $action; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /tests/Feature/HasPermalinks/ReadingTest.php: -------------------------------------------------------------------------------- 1 | create(['name' => 'foo']); 15 | 16 | $this->assertEquals('foo', $user->routeSlug); 17 | } 18 | 19 | /** @test */ 20 | public function it_returns_null_for_not_found_permalink_slug() 21 | { 22 | $user = factory(User::class)->make(['name' => 'foo']); 23 | $user->disablePermalinkHandling(); // = false; 24 | $user->save(); 25 | 26 | $this->assertNull($user->routeSlug); 27 | } 28 | 29 | /** @test */ 30 | public function it_can_get_path_from_permalink() 31 | { 32 | Permalink::create(['slug' => 'foo', 'parent_for' => User::class]); 33 | $user = factory(User::class)->create(['name' => 'foo']); 34 | 35 | $this->assertEquals('foo/foo', $user->routePath); 36 | } 37 | 38 | /** @test */ 39 | public function it_gets_the_related_permalink_key() 40 | { 41 | $user = factory(User::class)->create(['name' => 'foo']); 42 | 43 | $this->assertEquals(1, $user->permalinkKey); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /tests/Unit/PathBuilderTest.php: -------------------------------------------------------------------------------- 1 | '/users', 15 | 'parent_for' => User::class 16 | ]); 17 | 18 | $slugs = PathBuilder::parentPath(User::class); 19 | 20 | $this->assertEquals(['users'], $slugs); 21 | } 22 | 23 | /** @test */ 24 | public function it_can_generate_parent_path_with_object_instance() 25 | { 26 | Permalink::create([ 27 | 'slug' => '/users', 28 | 'parent_for' => User::class 29 | ]); 30 | 31 | $slugs = PathBuilder::parentPath(new User); 32 | 33 | $this->assertEquals(['users'], $slugs); 34 | } 35 | 36 | /** @test */ 37 | public function it_nest_paths_recursively() 38 | { 39 | Permalink::create([ 40 | 'slug' => 'account' 41 | ]); 42 | 43 | Permalink::create([ 44 | 'slug' => '/users', 45 | 'parent_for' => User::class, 46 | 'parent_id' => 1 47 | ]); 48 | 49 | 50 | $slugs = PathBuilder::parentPath(new User); 51 | 52 | $this->assertEquals(['account', 'users'], $slugs); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /migrations/2018_06_01_092537_create_permalinks_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 18 | 19 | $table->string('slug'); 20 | $table->unsignedInteger('parent_id')->nullable(); 21 | $table->string('parent_for')->nullable(); 22 | 23 | $table->string('entity_type')->nullable(); 24 | $table->unsignedBigInteger('entity_id')->nullable(); 25 | 26 | $table->string('action')->nullable(); 27 | $table->string('final_path')->nullable(); 28 | $table->json('seo')->nullable(); 29 | 30 | $table->softDeletes(); 31 | 32 | $table->timestamps(); 33 | 34 | $table->index(['entity_type', 'entity_id']); 35 | $table->unique(['slug', 'parent_id'], 'UNIQUE_SLUG_AND_PARENT'); 36 | $table->unique(['final_path'], 'UNIQUE_FULL_PATH'); 37 | $table->unique(['parent_for'], 'UNIQUE_PARENT_FOR'); 38 | }); 39 | } 40 | 41 | /** 42 | * Reverse the migrations. 43 | * 44 | * @return void 45 | */ 46 | public function down() 47 | { 48 | Schema::dropIfExists('permalinks'); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "devio/permalink", 3 | "description": "Permalink routing system for Laravel. Advanced database driven routes. Handle your permalinks + SEO parameters directly from database.", 4 | "keywords": [ 5 | "permalink", 6 | "laravel", 7 | "routing", 8 | "database routing", 9 | "dynamic routes", 10 | "wordpress" 11 | ], 12 | "license": "MIT", 13 | "authors": [ 14 | { 15 | "name": "Israel Ortuño", 16 | "email": "ai.ortuno@gmail.com" 17 | } 18 | ], 19 | "require": { 20 | "illuminate/support": "^6.0|^7.0|^8.0", 21 | "illuminate/database": "^6.0|^7.0|^8.0", 22 | "illuminate/routing": "^6.0|^7.0|^8.0", 23 | "cviebrock/eloquent-sluggable": "^6.0|^7.0|^8.0", 24 | "arcanedev/seo-helper": "^2.0|^3.0|^4.0", 25 | "laravel/legacy-factories": "^1.1" 26 | }, 27 | "require-dev": { 28 | "orchestra/testbench": "^4.0|^5.0|^6.0", 29 | "phpunit/phpunit": "~8.0", 30 | "mockery/mockery": "^1.1" 31 | }, 32 | "autoload": { 33 | "psr-4": { 34 | "Devio\\Permalink\\": "src/" 35 | }, 36 | "files": [ 37 | "src/helpers.php" 38 | ] 39 | }, 40 | "autoload-dev": { 41 | "psr-4": { 42 | "Devio\\Permalink\\Tests\\": "tests" 43 | } 44 | }, 45 | "extra": { 46 | "laravel": { 47 | "aliases": { 48 | }, 49 | "providers": [ 50 | "Devio\\Permalink\\PermalinkServiceProvider" 51 | ] 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /tests/Feature/HasPermalinks/NestingTest.php: -------------------------------------------------------------------------------- 1 | create(); 16 | $parent = Permalink::create(['slug' => 'foo', 'parent_for' => User::class]); 17 | $child = Permalink::create(['slug' => 'bar', 'entity_type' => User::class, 'entity_id' => 1]); 18 | 19 | $this->assertEquals($parent->getKey(), $child->parent_id); 20 | $this->assertCount(1, $parent->children); 21 | $this->assertEquals($parent->id, $child->parent->id); 22 | } 23 | 24 | /** @test */ 25 | public function it_will_have_unique_parent_for_records() 26 | { 27 | $this->expectException(QueryException::class); 28 | 29 | Permalink::create(['slug' => 'foo', 'parent_for' => User::class]); 30 | Permalink::create(['slug' => 'foo', 'parent_for' => User::class]); 31 | } 32 | 33 | /** @test */ 34 | public function it_wont_nest_permalink_if_disabled() 35 | { 36 | factory(User::class)->create(); 37 | config()->set('permalink.nest_to_parent_on_create', false); 38 | 39 | $parent = Permalink::create(['slug' => 'foo', 'parent_for' => User::class]); 40 | $child = Permalink::create(['slug' => 'bar', 'entity_type' => User::class, 'entity_id' => 1]); 41 | 42 | $this->assertNull($child->parent); 43 | $this->assertCount(0, $parent->children); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /tests/Feature/HasPermalinks/DeletingTest.php: -------------------------------------------------------------------------------- 1 | create(); 15 | $user->delete(); 16 | $this->assertFalse($user->permalink->exists); 17 | } 18 | 19 | /** @test */ 20 | public function it_softdeletes_permalink_in_cascade() 21 | { 22 | $user = factory(UserWithSoftDeletes::class)->create(); 23 | $user->delete(); 24 | $this->assertTrue($user->permalink->trashed()); 25 | } 26 | 27 | /** @test */ 28 | public function it_forces_permalink_deletion_if_entity_forces_deletion() 29 | { 30 | $user = factory(UserWithSoftDeletes::class)->create(); 31 | $user->forceDelete(); 32 | $this->assertFalse($user->permalink->exists); 33 | } 34 | 35 | /** @test */ 36 | public function it_restores_permalink() 37 | { 38 | $user = factory(UserWithSoftDeletes::class)->create(); 39 | $user->delete(); 40 | $user->restore(); 41 | $this->assertFalse($user->permalink->trashed()); 42 | } 43 | 44 | /** @test */ 45 | public function it_wont_delete_permalink_if_handling_is_disabled() 46 | { 47 | $user = factory(User::class)->create(); 48 | $user->disablePermalinkHandling(); 49 | $user->delete(); 50 | $this->assertTrue($user->hasPermalink()); 51 | $this->assertDatabaseHas('permalinks', ['entity_id' => $user->id]); 52 | } 53 | } -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | loadLaravelMigrations('testing'); 25 | $this->loadMigrationsFrom(__DIR__ . '/Support/migrations'); 26 | $this->withFactories(__DIR__ . '/Support/factories'); 27 | } 28 | 29 | protected function getEnvironmentSetUp($app) 30 | { 31 | $app['config']->set('database.default', 'testing'); 32 | $app['config']->set('database.connections.testing', [ 33 | 'driver' => 'sqlite', 34 | 'database' => ':memory:', 35 | 'prefix' => '', 36 | ]); 37 | } 38 | 39 | protected function resolveApplicationHttpKernel($app) 40 | { 41 | $app->singleton('Illuminate\Contracts\Http\Kernel', Kernel::class); 42 | } 43 | 44 | protected function getPackageProviders($app) 45 | { 46 | return [ 47 | SeoHelperServiceProvider::class, 48 | PermalinkServiceProvider::class, 49 | \Cviebrock\EloquentSluggable\ServiceProvider::class 50 | ]; 51 | } 52 | 53 | protected function reloadRoutes() 54 | { 55 | $this->app['router']->loadPermalinks(); 56 | } 57 | } -------------------------------------------------------------------------------- /tests/Feature/Permalink/UpdatePermalinkTest.php: -------------------------------------------------------------------------------- 1 | 'foo', 'action' => TestController::class]); 14 | $permalink2 = Permalink::create(['slug' => 'bar', 'action' => TestController::class]); 15 | 16 | $permalink2->update(['slug' => 'foo']); 17 | 18 | $this->assertEquals('foo-1', $permalink2->slug); 19 | $this->assertDatabaseHas('permalinks', ['slug' => 'foo']); 20 | $this->assertDatabaseHas('permalinks', ['slug' => 'foo-1']); 21 | } 22 | 23 | /** @test */ 24 | public function it_will_rebuild_children_path_if_slug_is_updated() 25 | { 26 | $permalink = Permalink::create(['slug' => 'foo', 'action' => TestController::class]); 27 | $permalink2 = Permalink::create(['slug' => 'bar', 'action' => TestController::class, 'parent_id' => $permalink->id]); 28 | 29 | $permalink->update(['slug' => 'baz']); 30 | 31 | $this->assertDatabaseHas('permalinks', ['final_path' => 'baz/bar']); 32 | } 33 | 34 | /** @test */ 35 | public function it_wont_rebuild_children_if_disabled() 36 | { 37 | config()->set('permalink.rebuild_children_on_update'); 38 | 39 | $permalink = Permalink::create(['slug' => 'foo', 'action' => TestController::class]); 40 | $permalink2 = Permalink::create(['slug' => 'bar', 'action' => TestController::class, 'parent_id' => $permalink->id]); 41 | 42 | $permalink->update(['slug' => 'baz']); 43 | 44 | $this->assertDatabaseHas('permalinks', ['final_path' => 'foo/bar']); 45 | } 46 | } -------------------------------------------------------------------------------- /tests/Feature/HasPermalinks/SeoAttributesTest.php: -------------------------------------------------------------------------------- 1 | create(); 14 | 15 | $this->assertEquals('title', $user->permalink->seo['title']); 16 | $this->assertEquals('description', $user->permalink->seo['description']); 17 | $this->assertEquals('twitter title', $user->permalink->seo['twitter']['title']); 18 | $this->assertEquals('twitter description', $user->permalink->seo['twitter']['description']); 19 | $this->assertEquals('og title', $user->permalink->seo['opengraph']['title']); 20 | $this->assertEquals('og description', $user->permalink->seo['opengraph']['description']); 21 | } 22 | 23 | /** @test */ 24 | public function it_overwrites_default_seo_values_if_provided() 25 | { 26 | $user = factory(UserWithDefaultSeoAttributes::class)->create([ 27 | 'permalink' => [ 28 | 'seo' => ['title' => 'foo', 'description' => 'bar baz'] 29 | ] 30 | ]); 31 | 32 | $this->assertEquals('foo', $user->permalink->seo['title']); 33 | $this->assertEquals('bar baz', $user->permalink->seo['description']); 34 | } 35 | 36 | /** @test */ 37 | public function it_populates_seo_attributes_only_when_creating() 38 | { 39 | $user = factory(UserWithDefaultSeoAttributes::class)->create([ 40 | 'permalink' => [ 41 | 'seo' => ['title' => 'foo'] 42 | ] 43 | ]); 44 | 45 | $user->update(['name' => 'bar baz']); 46 | 47 | $this->assertEquals('foo', $user->permalink->seo['title']); 48 | } 49 | } -------------------------------------------------------------------------------- /tests/Feature/Permalink/CreatePermalinkTest.php: -------------------------------------------------------------------------------- 1 | 'foo', 'action' => TestController::class]); 16 | $permalink2 = Permalink::create(['slug' => 'foo', 'action' => TestController::class]); 17 | 18 | $this->assertEquals('foo', $permalink->slug); 19 | $this->assertEquals('foo-1', $permalink2->slug); 20 | $this->assertDatabaseHas('permalinks', ['slug' => 'foo']); 21 | $this->assertDatabaseHas('permalinks', ['slug' => 'foo-1']); 22 | } 23 | 24 | /** @test */ 25 | public function it_can_create_a_nested_permalink() 26 | { 27 | $parent = factory(Company::class)->create(['name' => 'foo']); 28 | $user = factory(UserWithDisabledPermalinkHandling::class) 29 | ->create(['name' => 'bar',]); 30 | 31 | $user->createPermalink(['parent_id' => $parent->permalink->id]); 32 | 33 | $this->assertDatabaseHas('permalinks', ['final_path' => 'foo/bar']); 34 | } 35 | 36 | /** @test */ 37 | public function it_can_create_nested_permalink_from_manager() 38 | { 39 | $manager = new \Devio\Permalink\PermalinkManager; 40 | $parent = factory(Company::class)->create(['name' => 'foo']); 41 | $user = factory(UserWithDisabledPermalinkHandling::class) 42 | ->create(['name' => 'bar',]); 43 | 44 | $manager->create($user, ['parent_id' => $parent->permalink->id]); 45 | 46 | $this->assertDatabaseHas('permalinks', ['final_path' => 'foo/bar']); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Console/InstallRouter.php: -------------------------------------------------------------------------------- 1 | option('default') == true) { 17 | return $this->inKernel(); 18 | } 19 | 20 | $choice = $this->choice('Where do you want to replace the router?', ['Http/Kernel.php (Recommended)', 'bootstrap/app.php (Advanced)'], 0); 21 | 22 | Str::contains($choice, 'bootstrap') ? 23 | $this->inBootstrap() : $this->inKernel(); 24 | } 25 | 26 | public function inBootstrap() 27 | { 28 | $app = file_get_contents(base_path('bootstrap/app.php')); 29 | 30 | if (strpos($app, '\Devio\Permalink\Routing\Router::class') !== false) { 31 | $this->info('The router class has already been replaced.'); 32 | return; 33 | } 34 | 35 | $from = '/' . preg_quote('$app->singleton', '/') . '/'; 36 | $to = '$app->singleton(\'router\', \Devio\Permalink\Routing\Router::class);' . PHP_EOL . PHP_EOL . '$app->singleton'; 37 | 38 | file_put_contents( 39 | base_path('bootstrap/app.php'), 40 | preg_replace($from, $to, $app, 1) 41 | ); 42 | } 43 | 44 | public function inKernel() 45 | { 46 | $kernel = file_get_contents(app_path('Http/Kernel.php')); 47 | 48 | if (strpos($kernel, 'ReplacesRouter') !== false) { 49 | $this->info('The Kernel is already using the ReplacesRouter trait.'); 50 | return; 51 | } 52 | 53 | $from = '/' . preg_quote('{', '/') . '/'; 54 | $to = '{' . PHP_EOL . ' use \Devio\Permalink\Routing\ReplacesRouter;' . PHP_EOL; 55 | 56 | file_put_contents( 57 | app_path('Http/Kernel.php'), 58 | preg_replace($from, $to, $kernel, 1) 59 | ); 60 | } 61 | } -------------------------------------------------------------------------------- /config/permalink.php: -------------------------------------------------------------------------------- 1 | [ 17 | 'prefix' => '', 18 | 'middleware' => [ 19 | 'web', 20 | \Devio\Permalink\Middleware\ResolvePermalinkEntities::class 21 | ] 22 | ], 23 | 24 | /* 25 | |-------------------------------------------------------------------------- 26 | | Nesting Options 27 | |-------------------------------------------------------------------------- 28 | | 29 | | These options control the nesting automation. By default, if a permalink 30 | | has a parent_for value for a certain model, it'll be automatically set 31 | | as child of that record. Disable to manually control this behaviour. 32 | | 33 | | Also you can decide if the package should take care of the nested slug 34 | | consistency. If you update a parent slug, the package will make sure 35 | | all its nested (recursive) permalinks gets their paths updated to 36 | | match that slug. If you want to control ths behaviour, disable. 37 | | Check the NestingService class to understand how it works. 38 | */ 39 | 40 | 'nest_to_parent_on_create' => true, 41 | 'rebuild_children_on_update' => true, 42 | 43 | /* 44 | |-------------------------------------------------------------------------- 45 | | Automatically Refresh Routes 46 | |-------------------------------------------------------------------------- 47 | | 48 | | The route's collection has to be refreshed when a new permalink is added 49 | | to the router. If you are adding multiple permalinks in a row, you may 50 | | consider to disable this feature to prevent performance issues. 51 | | 52 | | Use Devio\Permalink\Routing\Router::refreshRoutes() to refresh the route look-ups. 53 | | 54 | */ 55 | 56 | 'refresh_route_lookups' => true 57 | ]; 58 | -------------------------------------------------------------------------------- /tests/Feature/HasPermalinks/CreatingTest.php: -------------------------------------------------------------------------------- 1 | create(); 16 | 17 | $this->assertNotNull($user->permalink); 18 | } 19 | 20 | /** @test */ 21 | public function it_wont_create_permalink_when_disabled_by_default() 22 | { 23 | $user = factory(UserWithDisabledPermalinkHandling::class)->create(); 24 | $user->save(); 25 | 26 | $this->assertNull($user->permalink); 27 | } 28 | 29 | /** @test */ 30 | public function it_wont_create_permalink_when_disabled() 31 | { 32 | $user = factory(User::class)->make(); 33 | $user->disablePermalinkHandling(); 34 | $user->save(); 35 | 36 | $this->assertNull($user->permalink); 37 | } 38 | 39 | /** @test */ 40 | public function it_will_create_a_permalink_when_key_exists_even_if_disabled() 41 | { 42 | $user = factory(User::class)->make([ 43 | 'permalink' => [ 44 | 'slug' => 'foo', 45 | 'action' => 'bar' 46 | ] 47 | ]); 48 | $user->disablePermalinkHandling(); 49 | $user->save(); 50 | 51 | $this->assertNotNull($user->permalink); 52 | } 53 | 54 | /** @test */ 55 | public function it_loads_permalink_relation() 56 | { 57 | $user = factory(User::class)->create(); 58 | 59 | $this->assertTrue($user->relationLoaded('permalink')); 60 | } 61 | 62 | /** @test */ 63 | public function it_can_set_permalink_attributes() 64 | { 65 | $user = factory(User::class)->create(['permalink' => ['slug' => 'foo', 'parent_id' => 1, 'parent_for' => 'user']]); 66 | 67 | $this->assertEquals('foo', $user->permalink->slug); 68 | $this->assertEquals(1, $user->permalink->parent_id); 69 | $this->assertEquals('user', $user->permalink->parent_for); 70 | } 71 | 72 | /** @test */ 73 | public function it_supports_morph_map() 74 | { 75 | Relation::morphMap(['user' => User::class]); 76 | $user = factory(User::class)->create(); 77 | 78 | $this->assertEquals('user', $user->permalink->entity_type); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /tests/Feature/Routing/RoutingTest.php: -------------------------------------------------------------------------------- 1 | 'foo', 'action' => TestController::class . '@index']); 16 | 17 | $this->get('/foo') 18 | ->assertSuccessful(); 19 | } 20 | 21 | /** @test */ 22 | public function it_can_access_an_existing_permalink_url() 23 | { 24 | Permalink::create(['slug' => 'foo', 'action' => TestController::class . '@index']); 25 | 26 | $this->get('/foo') 27 | ->assertSuccessful(); 28 | } 29 | 30 | /** @test */ 31 | public function it_can_return_the_content_from_action() 32 | { 33 | Permalink::create(['slug' => 'foo', 'action' => TestController::class . '@index']); 34 | 35 | $this->get('/foo') 36 | ->assertSee('ok'); 37 | } 38 | 39 | /** @test */ 40 | public function it_gets_500_code_if_action_cannot_be_resolved() 41 | { 42 | Permalink::create(['slug' => 'baz', 'action' => TestController::class . '@nonexisting']); 43 | 44 | $this->get('/baz') 45 | ->assertStatus(500); 46 | } 47 | 48 | /** @test */ 49 | public function it_can_override_permalink_routes() 50 | { 51 | Permalink::create(['slug' => 'overwritten', 'action' => TestController::class . '@index']); 52 | Route::get('overwritten', function () { 53 | return 'overwritten'; 54 | }); 55 | 56 | $this->get('/overwritten') 57 | ->assertSee('overwritten'); 58 | } 59 | 60 | /** @test */ 61 | public function it_can_set_a_permalink_when_creating_a_route() 62 | { 63 | Route::get('manual', function () { 64 | return request()->route()->permalink()->seo['title']; 65 | })->setPermalink([ 66 | 'seo' => ['title' => 'foo'] 67 | ]); 68 | 69 | $this->get('/manual') 70 | ->assertSee('foo'); 71 | } 72 | 73 | /** @test */ 74 | public function it_injects_the_permalink_instance_if_typehinted_as_parameter() 75 | { 76 | Permalink::create(['slug' => 'typehinted', 'action' => TestController::class . '@typehinted']); 77 | 78 | $this->get('/typehinted')->assertSee('typehinted'); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Routing/Route.php: -------------------------------------------------------------------------------- 1 | permalink = $permalink; 26 | 27 | if (! is_null($permalink) && $name = $permalink->name) { 28 | $this->name($name); 29 | } 30 | } 31 | 32 | /** 33 | * Get the permalink instance. 34 | * 35 | * @return Permalink|null 36 | */ 37 | public function permalink() 38 | { 39 | if (is_numeric($this->permalink)) { 40 | $this->permalink = Permalink::find($this->permalink); 41 | } 42 | 43 | return $this->permalink; 44 | } 45 | 46 | /** 47 | * Alias for permalink(). 48 | * 49 | * @return Permalink 50 | */ 51 | public function getPermalink() 52 | { 53 | return $this->permalink(); 54 | } 55 | 56 | /** 57 | * Set the permalink instance. 58 | * 59 | * @param Permalink $permalink 60 | */ 61 | public function setPermalink($permalink) 62 | { 63 | if (! $permalink instanceof Permalink) { 64 | $permalink = $this->buildPermalink($permalink); 65 | } 66 | 67 | $this->permalink = $permalink; 68 | } 69 | 70 | /** 71 | * Check if the current route has a permalink instance attached. 72 | * 73 | * @return bool 74 | */ 75 | public function hasPermalink(): bool 76 | { 77 | return (bool) $this->permalink; 78 | } 79 | 80 | /** 81 | * Get a new permalink instance. 82 | * 83 | * @param array $permalink 84 | * @return Permalink 85 | */ 86 | public function buildPermalink(array $permalink) 87 | { 88 | return new Permalink($permalink); 89 | } 90 | 91 | /** 92 | * {@inheritdoc} 93 | */ 94 | public function prepareForSerialization() 95 | { 96 | parent::prepareForSerialization(); 97 | 98 | // We will replace the permalink instance for its key when serializing 99 | // so we won't store the entire Permalink object which would result 100 | // into a large compiled routes file and lack of memory issues. 101 | $this->permalink = $this->permalink()->getKey(); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /tests/Feature/HasPermalinks/ActionTest.php: -------------------------------------------------------------------------------- 1 | create(); 16 | 17 | $this->assertEquals($user->permalinkAction(), $user->permalink->action); 18 | } 19 | 20 | /** @test */ 21 | public function it_can_map_actions() 22 | { 23 | $action = TestController::class . '@index'; 24 | Permalink::actionMap(['user.index' => $action]); 25 | $permalink = Permalink::create(['slug' => 'foo', 'action' => $action]); 26 | 27 | $this->assertEquals($action, $permalink->action); 28 | } 29 | 30 | /** @test */ 31 | public function it_reads_raw_actions() 32 | { 33 | Permalink::actionMap(['user.index' => TestController::class . '@index']); 34 | $permalink = Permalink::create(['slug' => 'foo', 'action' => 'user.index']); 35 | 36 | $this->assertEquals('user.index', $permalink->rawAction); 37 | } 38 | 39 | /** @test */ 40 | public function it_maps_actions_before_saving() 41 | { 42 | Permalink::actionMap(['user.index' => TestController::class . '@index']); 43 | $permalink = Permalink::create(['slug' => 'foo', 'action' => TestController::class . '@index']); 44 | 45 | $this->assertEquals('user.index', $permalink->rawAction); 46 | } 47 | 48 | /** @test */ 49 | public function it_can_override_default_actions() 50 | { 51 | $user = factory(User::class)->create([ 52 | 'permalink' => [ 53 | 'slug' => 'foo', 54 | 'action' => \Devio\Permalink\Tests\Support\Controllers\TestController::class . '@override' 55 | ] 56 | ]); 57 | 58 | $this->get('foo')->assertSee('override'); 59 | } 60 | 61 | /** @test */ 62 | public function it_supports_view_paths_as_actions() 63 | { 64 | Permalink::create(['slug' => 'foo', 'action' => 'welcome']); 65 | 66 | $this->get('foo')->assertViewIs('welcome'); 67 | } 68 | 69 | /** @test */ 70 | public function it_will_pass_the_entity_to_the_view() 71 | { 72 | $user = factory(User::class)->create([ 73 | 'permalink' => [ 74 | 'slug' => 'foo', 75 | 'action' => 'welcome' 76 | ] 77 | ]); 78 | 79 | $response = $this->get('foo'); 80 | $response->assertViewHas('user'); 81 | 82 | $this->assertEquals($user->getKey(), $response->viewData('user')->getKey()); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/EntityObserver.php: -------------------------------------------------------------------------------- 1 | slugService = $slugService; 22 | } 23 | 24 | public function saved(Model $model) 25 | { 26 | if (! $model->permalinkHandling() && is_null($model->getPermalinkAttributes())) { 27 | return; 28 | } 29 | 30 | $attributes = $this->gatherAttributes($model->getPermalinkAttributes()); 31 | 32 | // By checking if 'deleted_at' column has been modified, we can prevent 33 | // re-creating the permalink when the model has been restored because 34 | // this event is fired again and wasRecentlyCreated will be true. 35 | $softDeletingAction = method_exists($model, 'getDeletedAtColumn') && $model->isDirty($model->getDeletedAtColumn()); 36 | 37 | ($model->wasRecentlyCreated && ! $softDeletingAction) ? 38 | $model->createPermalink($attributes) : $model->updatePermalink($attributes); 39 | } 40 | 41 | /** 42 | * Restored model event handler. 43 | * 44 | * @param $model 45 | */ 46 | public function restored($model) 47 | { 48 | if (! $model->permalinkHandling() || ! $model->hasPermalink()) { 49 | return; 50 | } 51 | 52 | $model->permalink->restore(); 53 | } 54 | 55 | /** 56 | * Deleted model event handler. 57 | * 58 | * @param Model $model 59 | */ 60 | public function deleted(Model $model) 61 | { 62 | if (! $model->hasPermalink() || ! $model->permalinkHandling()) { 63 | return; 64 | } 65 | 66 | $method = 'forceDelete'; 67 | 68 | // The Permalink record should be removed if the main entity has been 69 | // deleted. Here we will check if we should perform a hard or soft 70 | // deletion depending on what is being used ont he main entity. 71 | if (method_exists($model, 'trashed') && ! $model->isForceDeleting()) { 72 | $method = 'delete'; 73 | } 74 | 75 | $model->permalink->{$method}(); 76 | } 77 | 78 | /** 79 | * Get the attributes from the request or 'permalink' key. 80 | * 81 | * @param null $attributes 82 | * @return array 83 | */ 84 | protected function gatherAttributes($attributes = null) 85 | { 86 | $attributes = $attributes ?: request(); 87 | 88 | return ($attributes instanceof Request ? $attributes->get('permalink') : $attributes) ?? []; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/PermalinkObserver.php: -------------------------------------------------------------------------------- 1 | path = $path; 33 | $this->slugService = $slugService; 34 | } 35 | 36 | /** 37 | * Creating event. 38 | * 39 | * @param $model 40 | */ 41 | public function creating($permalink) 42 | { 43 | $this->nestToParent($permalink); 44 | 45 | if ($permalink->isDirty('slug') && ! empty($permalink->slug)) { 46 | $this->ensureSlugIsUnique($permalink); 47 | } else { 48 | $this->slugService->slug($permalink); 49 | } 50 | 51 | $this->path->build($permalink); 52 | } 53 | 54 | /** 55 | * Updating event. 56 | * 57 | * @param $model 58 | */ 59 | public function updating($permalink) 60 | { 61 | if ($permalink->getOriginal('slug') !== $permalink->slug) { 62 | $this->ensureSlugIsUnique($permalink); 63 | 64 | config('permalink.rebuild_children_on_update') 65 | ? $this->path->recursive($permalink) 66 | : $this->path->single($permalink); 67 | } 68 | } 69 | 70 | /** 71 | * Creates an unique slug for the permalink. 72 | * 73 | * @param $model 74 | */ 75 | protected function ensureSlugIsUnique($permalink) 76 | { 77 | if (! $permalink->isDirty('slug') || empty($permalink->slug)) { 78 | return; 79 | } 80 | 81 | // If the user has provided an slug manually, we have to make sure 82 | // that that slug is unique. If it is not, the SlugService class 83 | // will append an incremental suffix to ensure its uniqueness. 84 | $permalink->slug = SlugService::createSlug($permalink, 'slug', $permalink->slug, []); 85 | } 86 | 87 | /** 88 | * Nest the permalink to a parent if found. 89 | * 90 | * @param $model 91 | */ 92 | protected function nestToParent($permalink) 93 | { 94 | $entity = $permalink->entity; 95 | 96 | if (! $entity || ! $entity->permalinkNestToParentOnCreate()) { 97 | return; 98 | } 99 | 100 | $parent = PathBuilder::parentFor($entity); 101 | 102 | if (! $permalink->exists && $entity && $parent) { 103 | $permalink->parent_id = $parent->getKey(); 104 | $permalink->setRelation('parent', $parent); 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/PermalinkServiceProvider.php: -------------------------------------------------------------------------------- 1 | mergeConfigFrom(__DIR__ . '/../config/permalink.php', 'permalink'); 20 | 21 | $this->publishes([__DIR__ . '/../config/permalink.php' => config_path('permalink.php')], 'permalink-config'); 22 | 23 | $this->loadMigrationsFrom(__DIR__ . '/../migrations'); 24 | 25 | $this->defineMacros(); 26 | } 27 | 28 | /** 29 | * Create the permalink macro. 30 | */ 31 | protected function defineMacros() 32 | { 33 | \Illuminate\Routing\Router::macro('replaceMiddleware', function ($middleware = [], $middlewareGroups = []) { 34 | $this->middleware = $middleware; 35 | $this->middlewareGroups = $middlewareGroups; 36 | }); 37 | 38 | \Illuminate\Http\Request::macro('permalink', function() { 39 | return method_exists($this->route(), 'permalink') ? $this->route()->permalink() : null; 40 | }); 41 | 42 | Arr::macro('undot', function (array $dotArray) { 43 | $array = []; 44 | foreach ($dotArray as $key => $value) { 45 | Arr::set($array, $key, $value); 46 | } 47 | 48 | return $array; 49 | }); 50 | } 51 | 52 | /** 53 | * Register the service provider. 54 | */ 55 | public function register() 56 | { 57 | $builders = [ 58 | 'base' => Builders\BaseBuilder::class, 59 | 'meta' => Builders\MetaBuilder::class, 60 | 'opengraph' => Builders\OpenGraphBuilder::class, 61 | 'twitter' => Builders\TwitterBuilder::class, 62 | ]; 63 | 64 | foreach ($builders as $alias => $builder) { 65 | $this->app->singleton("permalink.$alias", function ($app, $parameters) use ($builder) { 66 | $helper = $app->make(SeoHelper::class); 67 | 68 | return (new $builder($helper)) 69 | ->permalink($parameters[0]) 70 | ->data($parameters[1]); 71 | }); 72 | } 73 | 74 | $this->app->singleton('router', Router::class); 75 | 76 | $this->app->singleton(PermalinkSeo::class, function () { 77 | return new PermalinkSeo($this->app['request'], $this->app); 78 | }); 79 | 80 | $this->app->singleton(PermalinkManager::class, function () { 81 | return new PermalinkManager; 82 | }); 83 | 84 | $this->app->singleton(PathBuilder::class, function() { 85 | return new Services\PathBuilder; 86 | }); 87 | 88 | $this->app->singleton(ActionFactory::class, function () { 89 | return new Services\ActionFactory; 90 | }); 91 | 92 | $this->commands([ 93 | Console\InstallRouter::class, 94 | ]); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/Services/PathBuilder.php: -------------------------------------------------------------------------------- 1 | exists) { 15 | return $this->single($model); 16 | } elseif ($model->isDirty('slug')) { 17 | return $this->recursive($model); 18 | } 19 | } 20 | 21 | public function single($model) 22 | { 23 | $model->final_path = $this->getFullyQualifiedPath($model); 24 | } 25 | 26 | public function all() 27 | { 28 | $permalinks = Permalink::withTrashed()->get(); 29 | 30 | $permalinks->each(function ($permalink) { 31 | $this->single($permalink); 32 | $permalink->save(); 33 | }); 34 | } 35 | 36 | public function recursive($model) 37 | { 38 | $path = $model->isDirty('slug') ? 39 | $model->getOriginal('final_path') : $model->final_path; 40 | 41 | $nested = Permalink::withTrashed() 42 | ->where('final_path', 'LIKE', $path . '/%') 43 | ->get(); 44 | 45 | $this->single($model); 46 | 47 | $nested->each(function ($permalink) use ($model) { 48 | $permalink->final_path = $model->final_path . '/' . $permalink->slug; 49 | $permalink->save(); 50 | }); 51 | } 52 | 53 | /** 54 | * @param $model 55 | * @return string 56 | */ 57 | public function getFullyQualifiedPath($model) 58 | { 59 | $path = ($model->isNested() && $model->parent) ? $model->parent->final_path : ''; 60 | 61 | return trim($path . '/' . $model->slug, '/'); 62 | } 63 | 64 | /** 65 | * Find the parent for the given model. 66 | * 67 | * @param $model 68 | * @return mixed 69 | */ 70 | public static function parentFor($model) 71 | { 72 | if (is_null($model) || (! is_object($model) && ! class_exists($model))) { 73 | return null; 74 | } 75 | 76 | if (! is_object($model)) { 77 | $model = new $model; 78 | } 79 | 80 | $model = $model->getMorphClass(); 81 | 82 | return Permalink::where('parent_for', $model)->first(); 83 | } 84 | 85 | /** 86 | * Get the parent route path. 87 | * 88 | * @param $model 89 | * @return array 90 | */ 91 | public static function parentPath($model) 92 | { 93 | if ($model instanceof Permalink) { 94 | $model = $model->entity; 95 | } 96 | 97 | $slugs = []; 98 | 99 | $callable = function ($permalink) use (&$callable, &$slugs) { 100 | if (is_null($permalink)) { 101 | return; 102 | } 103 | 104 | array_push($slugs, $permalink->slug); 105 | 106 | if ($permalink->parent) { 107 | $callable($permalink->parent); 108 | } 109 | }; 110 | 111 | $callable(static::parentFor($model)); 112 | 113 | return array_reverse($slugs); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/PermalinkManager.php: -------------------------------------------------------------------------------- 1 | permalink; 23 | } 24 | 25 | return $item instanceof Permalink ? url($item->final_path) : '#'; 26 | } 27 | 28 | /** 29 | * Create a permalink for the given entity. 30 | * 31 | * @param $entity 32 | * @param array $attributes 33 | * @return mixed 34 | */ 35 | public function create($entity, $attributes = []) 36 | { 37 | $permalink = $entity->permalink() 38 | ->newRelatedInstanceFor($entity) 39 | // ->setRelation('entity', $entity) 40 | ->fill($this->prepareDefaultSeoAttributes($entity, $attributes)); 41 | 42 | $permalink->save(); 43 | 44 | return $entity->setRelation('permalink', $permalink); 45 | } 46 | 47 | /** 48 | * Update an existing permalink. 49 | * 50 | * @param $entity 51 | * @param array $attributes 52 | * @return mixed 53 | */ 54 | public function update($entity, $attributes = []) 55 | { 56 | if (! $entity->hasPermalink()) { 57 | return $entity; 58 | } 59 | 60 | return $entity->permalink->update($attributes); 61 | } 62 | 63 | /** 64 | * Prepare the default SEO attributes. 65 | * 66 | * @param $entity 67 | * @param $attributes 68 | * @return array 69 | */ 70 | protected function prepareDefaultSeoAttributes($entity, $attributes) 71 | { 72 | $attributes = Arr::undot($attributes); 73 | $values = Arr::dot($this->getSeoAttributesArray()); 74 | 75 | foreach ($values as $key => $value) { 76 | // We will generate the permalinkSeo* methods in order to populate the 77 | // premalink default SEO data when creating a new permalink record. 78 | // They will be overwritten if any seo data has been provided. 79 | $attribute = 'permalink' . Str::studly(str_replace('.', ' ', $key)); 80 | 81 | if (! Arr::get($attributes, $key) && $value = $entity->getAttribute($attribute)) { 82 | Arr::set($attributes, $key, $value); 83 | } 84 | } 85 | 86 | return $attributes; 87 | } 88 | 89 | /** 90 | * Get a value empty seo column structure. 91 | * 92 | * @return array 93 | */ 94 | protected function getSeoAttributesArray() 95 | { 96 | return [ 97 | 'seo' => [ 98 | 'title' => null, 99 | 'description' => null, 100 | 'twitter' => ['title' => null, 'description' => null], 101 | 'opengraph' => ['title' => null, 'description' => null], 102 | ], 103 | ]; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /tests/Feature/HasPermalinks/SlugTest.php: -------------------------------------------------------------------------------- 1 | create(['name' => 'Israel Ortuño']); 16 | 17 | $this->assertEquals('israel-ortuno', $user->permalink->slug); 18 | $this->assertDatabaseHas('permalinks', ['slug' => 'israel-ortuno']); 19 | } 20 | 21 | /** @test */ 22 | public function it_creates_unique_slugs() 23 | { 24 | $permalink1 = Permalink::create(['slug' => 'foo']); 25 | $permalink2 = Permalink::create(['slug' => 'foo']); 26 | 27 | $this->assertEquals('foo', $permalink1->slug); 28 | $this->assertEquals('foo-1', $permalink2->slug); 29 | } 30 | 31 | /** @test */ 32 | public function it_creates_unique_slugs_from_resource() 33 | { 34 | factory(User::class)->times(2)->create(['name' => 'foo']); 35 | 36 | $this->assertDatabaseHas('permalinks', ['slug' => 'foo']); 37 | $this->assertDatabaseHas('permalinks', ['slug' => 'foo-1']); 38 | } 39 | 40 | /** @test */ 41 | public function it_creates_same_slug_if_parent_is_different() 42 | { 43 | $permalink1 = Permalink::create(['slug' => 'foo']); 44 | $permalink2 = Permalink::create(['slug' => 'foo', 'parent_id' => 1]); 45 | 46 | $this->assertEquals('foo', $permalink1->slug); 47 | $this->assertEquals('foo', $permalink2->slug); 48 | } 49 | 50 | /** @test */ 51 | public function it_creates_same_slug_for_different_parents_from_resource() 52 | { 53 | Permalink::create(['slug' => 'user', 'parent_for' => User::class]); 54 | Permalink::create(['slug' => 'company', 'parent_for' => Company::class]); 55 | 56 | $user = factory(User::class)->create(['name' => 'foo']); 57 | $company = factory(Company::class)->create(['name' => 'foo']); 58 | 59 | $this->assertEquals('foo', $user->permalink->slug); 60 | $this->assertEquals('foo', $company->permalink->slug); 61 | } 62 | 63 | /** @test */ 64 | public function it_creates_unique_slug_when_passing_permalink_array() 65 | { 66 | Permalink::create(['slug' => 'foo']); 67 | $user = factory(User::class)->create(['permalink' => ['slug' => 'foo']]); 68 | 69 | $this->assertEquals('foo-1', $user->permalink->slug); 70 | } 71 | 72 | /** @test */ 73 | public function it_allows_same_slug_per_parent() 74 | { 75 | Permalink::create(['slug' => 'foo', 'parent_for' => User::class]); 76 | $user = factory(User::class)->create(['name' => 'foo']); 77 | 78 | $this->assertEquals('foo', $user->permalink->slug); 79 | } 80 | 81 | /** @test */ 82 | public function it_makes_slug_attribute_mandatory() 83 | { 84 | // Slug is mandatory as there's only one '' permalink => homepage 85 | $user = factory(User::class)->create(['name' => 'foo', 'permalink' => ['slug' => null]]); 86 | $user2 = factory(User::class)->create(['name' => 'bar', 'permalink' => ['slug' => '']]); 87 | 88 | $this->assertEquals('foo', $user->permalink->slug); 89 | $this->assertEquals('bar', $user2->permalink->slug); 90 | } 91 | } -------------------------------------------------------------------------------- /src/PermalinkSeo.php: -------------------------------------------------------------------------------- 1 | request = $request; 44 | $this->container = $container; 45 | } 46 | 47 | /** 48 | * @inheritdoc 49 | */ 50 | public function runBuilders() 51 | { 52 | if (is_null($permalink = $this->getCurrentPermalink())) { 53 | return; 54 | } 55 | 56 | $builders = $this->getBuildersCollection($permalink); 57 | 58 | foreach ($builders as $builder => $data) { 59 | if ($this->getContainer()->has($binding = 'permalink.' . $builder)) { 60 | $this->getContainer()->make($binding, [$permalink, $data])->build(); 61 | } 62 | } 63 | } 64 | 65 | /** 66 | * Prepare the builders array from the permalink SEO data. 67 | * 68 | * @param $seo 69 | * @return array 70 | */ 71 | protected function getBuildersCollection($permalink) 72 | { 73 | $seo = Arr::wrap($permalink->seo); 74 | 75 | if (count($base = Arr::except($seo, static::builders))) { 76 | $seo['base'] = $base; 77 | } 78 | 79 | return collect(static::builders)->mapWithKeys(function ($builder) use ($seo) { 80 | return [$builder => Arr::get($seo, $builder)]; 81 | }); 82 | } 83 | 84 | /** 85 | * Get the current route permalink if any. 86 | * 87 | * @return Permalink 88 | */ 89 | protected function getCurrentPermalink() 90 | { 91 | $route = $this->request->route(); 92 | 93 | if ($route instanceof Route && ($permalink = $route->permalink()) instanceof Permalink) { 94 | return $permalink; 95 | } 96 | 97 | if ($seo = $this->staticPermalinks[$route->getName()] ?? false) { 98 | return (new Permalink)->fill(compact('seo')); 99 | } 100 | 101 | return null; 102 | } 103 | 104 | /** 105 | * Set the permalink static collection. 106 | * 107 | * @param $permalinks 108 | * @return $this 109 | */ 110 | public function permalinks($permalinks) 111 | { 112 | $this->staticPermalinks = $permalinks; 113 | 114 | return $this; 115 | } 116 | 117 | /** 118 | * Set the request instance. 119 | * 120 | * @param $request 121 | * @return $this 122 | */ 123 | public function request($request) 124 | { 125 | $this->request = $request; 126 | 127 | return $this; 128 | } 129 | 130 | /** 131 | * Get the container instance. 132 | * 133 | * @return Container 134 | */ 135 | public function getContainer() 136 | { 137 | return $this->container; 138 | } 139 | 140 | /** 141 | * Set the container instance. 142 | * 143 | * @param $container 144 | */ 145 | public function setContainer($container) 146 | { 147 | $this->container = $container; 148 | } 149 | } -------------------------------------------------------------------------------- /src/HasPermalinks.php: -------------------------------------------------------------------------------- 1 | permalinkAttributes = $value; 40 | } 41 | 42 | /** 43 | * Get the permalink attributes if any. 44 | * 45 | * @return null 46 | */ 47 | public function getPermalinkAttributes() 48 | { 49 | return $this->permalinkAttributes; 50 | } 51 | 52 | /** 53 | * Relation to the permalinks table. 54 | * 55 | * @return mixed 56 | */ 57 | public function permalink() 58 | { 59 | return $this->morphOne(Permalink::class, 'entity')->withTrashed(); 60 | } 61 | 62 | /** 63 | * Create the permalink for the current entity. 64 | * 65 | * @param $attributes 66 | * @return $this 67 | */ 68 | public function createPermalink($attributes = []) 69 | { 70 | return app(PermalinkManager::class)->create($this, $attributes); 71 | } 72 | 73 | /** 74 | * Update the permalink for the current entity. 75 | * 76 | * @param $attributes 77 | * @return $this 78 | */ 79 | public function updatePermalink($attributes = []) 80 | { 81 | return app(PermalinkManager::class)->update($this, $attributes); 82 | } 83 | 84 | /** 85 | * Alias to get the existihg permalink ID. 86 | * 87 | * @return |null 88 | */ 89 | public function getPermalinkKeyAttribute() 90 | { 91 | return $this->hasPermalink() ? $this->permalink->getKey() : null; 92 | } 93 | 94 | /** 95 | * Resolve the full permalink route. 96 | * 97 | * @return string 98 | */ 99 | public function getRouteAttribute() 100 | { 101 | return ($this->exists && $this->hasPermalink()) ? 102 | url($this->permalink->final_path) : null; 103 | } 104 | 105 | /** 106 | * Get the entity slug. 107 | * 108 | * @return null 109 | */ 110 | public function getRouteSlugAttribute() 111 | { 112 | return $this->hasPermalink() ? $this->permalink->slug : null; 113 | } 114 | 115 | /** 116 | * Get the permalink nested path. 117 | * 118 | * @return mixed 119 | */ 120 | public function getRoutePathAttribute() 121 | { 122 | return $this->hasPermalink() ? trim(parse_url($this->route)['path'], '/') : null; 123 | } 124 | 125 | /** 126 | * Check if the page has a permalink relation. 127 | * 128 | * @return bool 129 | */ 130 | public function hasPermalink() 131 | { 132 | return (bool) ! is_null($this->getRelationValue('permalink')); 133 | } 134 | 135 | /** 136 | * Determine if this permalink should be nested to its parent when created. 137 | * 138 | * @return bool 139 | */ 140 | public function permalinkNestToParentOnCreate() 141 | { 142 | return config('permalink.nest_to_parent_on_create'); 143 | } 144 | 145 | /** 146 | * Determine if automatic permalink handling should be done. 147 | * 148 | * @return bool 149 | */ 150 | public function permalinkHandling() 151 | { 152 | return $this->permalinkHandling; 153 | } 154 | 155 | /** 156 | * Enable automatic permalink handling. 157 | * 158 | * @return $this 159 | */ 160 | public function enablePermalinkHandling() 161 | { 162 | $this->permalinkHandling = true; 163 | 164 | return $this; 165 | } 166 | 167 | /** 168 | * Disable automatic permalink handling. 169 | * 170 | * @return $this 171 | */ 172 | public function disablePermalinkHandling() 173 | { 174 | $this->permalinkHandling = false; 175 | 176 | return $this; 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /src/Routing/Router.php: -------------------------------------------------------------------------------- 1 | get(); 20 | 21 | $this->group(config('permalink.group'), function () use ($permalinks) { 22 | $this->addPermalinks($permalinks); 23 | }); 24 | } 25 | 26 | /** 27 | * @param \Illuminate\Http\Request $request 28 | * @return \Illuminate\Routing\Route|void 29 | */ 30 | public function findRoute($request) 31 | { 32 | // First we'll try to find any code defined route for the current request. 33 | // If no route was found, we can then attempt to find if the URL path 34 | // matches a existing permalink. If not just rise up the exception. 35 | try { 36 | return parent::findRoute($request); 37 | } catch (HttpException $e) { 38 | $this->findPermalink($request); 39 | } 40 | 41 | return parent::findRoute($request); 42 | } 43 | 44 | /** 45 | * Get the permalink for the current request if any. 46 | */ 47 | public function findPermalink($request) 48 | { 49 | $path = trim($request->getPathInfo(), '/'); 50 | 51 | if (! $permalink = Permalink::where('final_path', $path)->first()) { 52 | throw new NotFoundHttpException; 53 | } 54 | 55 | $this->group(config('permalink.group'), function () use ($permalink) { 56 | $this->addPermalinks($permalink); 57 | }); 58 | } 59 | 60 | protected function createPermalinkRoute($permalink) 61 | { 62 | $route = $this->newPermalinkRoute($permalink); 63 | 64 | // If we have groups that need to be merged, we will merge them now after this 65 | // route has already been created and is ready to go. After we're done with 66 | // the merge we will be ready to return the route back out to the caller. 67 | if ($this->hasGroupStack()) { 68 | $this->mergeGroupAttributesIntoRoute($route); 69 | } 70 | 71 | $this->addWhereClausesToRoute($route); 72 | 73 | return $route; 74 | } 75 | 76 | /** 77 | * Create a new Route for the given permalink. 78 | * 79 | * @param $permalink 80 | * @return Route 81 | */ 82 | protected function newPermalinkRoute($permalink) 83 | { 84 | $path = $this->prefix($permalink->final_path); 85 | $action = $this->convertToControllerAction($permalink->action); 86 | 87 | return tap($this->newRoute($permalink->method, $path, $action), function ($route) use ($permalink) { 88 | $route->setPermalink($permalink); 89 | }); 90 | } 91 | 92 | /** 93 | * Add a collection of permalinks to the router. 94 | * 95 | * @param array $permalinks 96 | * @param bool $forceRefresh 97 | * @return Router 98 | */ 99 | public function addPermalinks($permalinks = [], $forceRefresh = false) 100 | { 101 | if (! $permalinks instanceof Collection) { 102 | $permalinks = Arr::wrap($permalinks); 103 | } 104 | 105 | foreach ($permalinks as $permalink) { 106 | $this->addPermalink($permalink); 107 | } 108 | 109 | if ($forceRefresh || config('permalink.refresh_route_lookups')) { 110 | $this->refreshRouteLookups(); 111 | } 112 | 113 | return $this; 114 | } 115 | 116 | /** 117 | * Add a single permalink to the router. 118 | * 119 | * @param $permalink 120 | * @return Router 121 | */ 122 | protected function addPermalink($permalink) 123 | { 124 | if ($permalink->action) { 125 | $route = $this->createPermalinkRoute($permalink); 126 | 127 | $this->routes->add($route); 128 | } 129 | 130 | return $this; 131 | } 132 | 133 | /** 134 | * Create a new route. 135 | * 136 | * @param array|string $methods 137 | * @param string $uri 138 | * @param mixed $action 139 | * @return Route 140 | */ 141 | public function newRoute($methods, $uri, $action) 142 | { 143 | return (new Route($methods, $uri, $action)) 144 | ->setRouter($this) 145 | ->setContainer($this->container); 146 | } 147 | 148 | /** 149 | * Refresh the route name and action lookups. 150 | */ 151 | public function refreshRouteLookups() 152 | { 153 | $this->getRoutes()->refreshNameLookups(); 154 | $this->getRoutes()->refreshActionLookups(); 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/Builders/Builder.php: -------------------------------------------------------------------------------- 1 | helper = $helper; 42 | 43 | $this->permalink($permalink)->data($data); 44 | } 45 | 46 | /** 47 | * Tasks before the builder has run. 48 | */ 49 | protected function before(): void 50 | { 51 | } 52 | 53 | /** 54 | * Tasks after the buidler has run. 55 | */ 56 | protected function after(): void 57 | { 58 | } 59 | 60 | /** 61 | * Translate the current instance from database to the SEO helper. 62 | */ 63 | public function build(): void 64 | { 65 | // If the data is false, will mean that the object we are suppose to build 66 | // has a value of "false" and will be therefore disabled. This way we is 67 | // possible to control whether we want Twitter or OpenGraph meta tags. 68 | if ($this->data === false) { 69 | $this->disable(); 70 | return; 71 | } 72 | 73 | $this->before(); 74 | 75 | foreach ($this->data as $key => $content) { 76 | $this->call($key, $content); 77 | } 78 | 79 | $this->after(); 80 | } 81 | 82 | protected function call($name, $content) 83 | { 84 | // We will make sure we always provide an array as parameter to the 85 | // builder methods. This way we could pass multiple parameters to 86 | // functions like setTitle and addWebmaster. Flexibility on top! 87 | $content = Arr::wrap($content); 88 | 89 | $builder = $this->getBuilderName(); 90 | 91 | // Then we will check if there is a method with that name in this 92 | // class. If so, we'll use it as it may contain any extra logic 93 | // like compiling the content or doing some transformations. 94 | if ($method = $this->methodExists($this, $name)) { 95 | call_user_func_array([$this, $method], $content); 96 | } 97 | 98 | // If there is a matching method into the base SEO helper, we will 99 | // pass the data right to it. This is specially useful to avoid 100 | // specifying a title for every builder (meta, og & twitter). 101 | elseif ($method = $this->methodExists($this->helper, $name)) { 102 | call_user_func_array([$this->helper, $method], $content); 103 | } 104 | 105 | // If the key matches a method in the SEO helper we will just pass 106 | // the content as parameter. This gives a lot of flexibility as 107 | // it allows to manage the package directly from database. 108 | elseif (method_exists($this->helper, $builder) 109 | && $method = $this->methodExists($target = $this->helper->$builder(), $name)) { 110 | call_user_func_array([$target, $method], $content); 111 | } 112 | } 113 | 114 | protected function getBuilderName() 115 | { 116 | $class = (new \ReflectionClass(static::class))->getShortName(); 117 | 118 | return lcfirst(str_replace('Builder', '', $class)); 119 | } 120 | 121 | /** 122 | * Check if the method exists into the given object. 123 | * 124 | * @param $object 125 | * @param $name 126 | * @return bool|mixed 127 | */ 128 | protected function methodExists($object, $name) 129 | { 130 | $name = Str::studly($name); 131 | $methods = ["set{$name}", "add{$name}"]; 132 | 133 | foreach ($methods as $method) { 134 | if (method_exists($object, $method)) { 135 | return $method; 136 | } 137 | } 138 | 139 | return false; 140 | } 141 | 142 | /** 143 | * Set the builder Permalink. 144 | * 145 | * @param Permalink $permalink 146 | * @return $this 147 | */ 148 | public function permalink(Permalink $permalink = null) 149 | { 150 | $this->permalink = $permalink; 151 | 152 | return $this; 153 | } 154 | 155 | /** 156 | * Set the builder data. 157 | * 158 | * @param array $data 159 | */ 160 | public function data($data = []) 161 | { 162 | $this->data = $data = array_filter(Arr::wrap($data), function ($item) { 163 | return ! is_null($item); 164 | }); 165 | 166 | return $this; 167 | } 168 | 169 | /** 170 | * @inheritdoc 171 | */ 172 | abstract public function disable(): void; 173 | } 174 | -------------------------------------------------------------------------------- /src/Permalink.php: -------------------------------------------------------------------------------- 1 | 'json' 31 | ]; 32 | 33 | /** 34 | * Array to map action class paths to their alias names in database. 35 | * 36 | * @var 37 | */ 38 | public static $actionMap = []; 39 | 40 | /** 41 | * Parents cache. 42 | * 43 | * @var array 44 | */ 45 | public static $parents = []; 46 | 47 | /** 48 | * Booting the model. 49 | */ 50 | public static function boot() 51 | { 52 | parent::boot(); 53 | parent::flushEventListeners(); 54 | 55 | // Since we want to allow the user to specify an slug rather to always 56 | // generate it automatically, we've to remove the observers added by 57 | // the Sluggable package, so we control the slug creation/update. 58 | static::observe(PermalinkObserver::class); 59 | } 60 | 61 | /** 62 | * Polymorphic relationship to any entity. 63 | * 64 | * @return \Illuminate\Database\Eloquent\Relations\MorphTo 65 | */ 66 | public function entity() 67 | { 68 | return $this->morphTo(); 69 | } 70 | 71 | /** 72 | * Relationship to the parent permalink. 73 | * 74 | * @return \Illuminate\Database\Eloquent\Relations\BelongsTo+ 75 | */ 76 | public function parent() 77 | { 78 | return $this->belongsTo(static::class); 79 | } 80 | 81 | /** 82 | * Relationship to the permalink children. 83 | * 84 | * @return \Illuminate\Database\Eloquent\Relations\HasMany 85 | */ 86 | public function children() 87 | { 88 | return $this->hasMany(static::class, 'parent_id'); 89 | } 90 | 91 | /** 92 | * Return the sluggable configuration array for this model. 93 | * 94 | * @return array 95 | */ 96 | public function sluggable(): array 97 | { 98 | if (! $entity = $this->getRelationValue('entity')) { 99 | return []; 100 | } 101 | 102 | $source = (array) $entity->permalinkSlug(); 103 | 104 | // We will look for slug source at the permalinkable entity. That method 105 | // should return an array with a 'source' key in it. This way the user 106 | // will be able to provide more parameters to the sluggable options. 107 | return [ 108 | 'slug' => array_key_exists('source', $source) ? $source : compact('source') 109 | ]; 110 | } 111 | 112 | /** 113 | * Unique slug constraints. 114 | * 115 | * @param Builder $query 116 | * @param Model $model 117 | * @param $attribute 118 | * @param $config 119 | * @param $slug 120 | * @return Builder+ 121 | */ 122 | public function scopeWithUniqueSlugConstraints(Builder $query, Model $model, $attribute, $config, $slug) 123 | { 124 | if ($slug != '') { 125 | return $query->where('parent_id', $model->parent_id); 126 | } 127 | } 128 | 129 | /** 130 | * Check if the permalink is nested. 131 | * 132 | * @return bool 133 | */ 134 | public function isNested() 135 | { 136 | return (bool) ! is_null($this->parent_id); 137 | } 138 | 139 | /** 140 | * Get the default verbs. 141 | * 142 | * @return array 143 | */ 144 | public function getMethodAttribute() 145 | { 146 | return ['GET', 'HEAD']; 147 | } 148 | 149 | /** 150 | * Alias to get the entity type. 151 | * 152 | * @return mixed 153 | */ 154 | public function getTypeAttribute() 155 | { 156 | return $this->entity_type; 157 | } 158 | 159 | /** 160 | * Convert an alias to a full action path if any. 161 | * 162 | * @return null|string 163 | */ 164 | public function getActionAttribute() 165 | { 166 | return app(ActionFactory::class)->resolve($this); 167 | } 168 | 169 | /** 170 | * Replace the action by an alias if any. 171 | * 172 | * @param $value 173 | */ 174 | public function setActionAttribute($value) 175 | { 176 | $this->attributes['action'] = array_search($value, static::actionMap()) ?: $value; 177 | } 178 | 179 | /** 180 | * Get the raw action value. 181 | * 182 | * @return mixed 183 | */ 184 | public function getRawActionAttribute() 185 | { 186 | return $this->attributes['action'] ?? null; 187 | } 188 | 189 | /** 190 | * Set the parent for from the morph map if exists. 191 | * 192 | * @param $value 193 | */ 194 | public function setParentForAttribute($value) 195 | { 196 | if (! Relation::getMorphedModel($value)) { 197 | $value = array_search($value, Relation::morphMap()) ?: $value; 198 | } 199 | 200 | $this->attributes['parent_for'] = $value; 201 | } 202 | 203 | /** 204 | * Set or get the alias map for aliased actions 205 | * 206 | * @param array|null $map 207 | * @param bool $merge 208 | * @return array 209 | */ 210 | public static function actionMap(array $map = null, $merge = true) 211 | { 212 | if (! is_null($map)) { 213 | static::$actionMap = $merge && static::$actionMap 214 | ? $map + static::$actionMap : $map; 215 | } 216 | 217 | return static::$actionMap; 218 | } 219 | 220 | /** 221 | * Set all seo values without NULLs. 222 | * 223 | * @param $value 224 | */ 225 | public function setSeoAttribute($value) 226 | { 227 | $value = Arr::dot(Arr::wrap($value)); 228 | 229 | $this->attributes['seo'] = json_encode(Arr::undot( 230 | array_filter($value, function ($item) { 231 | return $item ?? false; 232 | }) 233 | )); 234 | } 235 | 236 | /** 237 | * Get the action associated with a custom alias. 238 | * 239 | * @param string $alias 240 | * @return string|null 241 | */ 242 | public static function getMappedAction($alias) 243 | { 244 | return static::$actionMap[$alias] ?? null; 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Advanced Laravel Permalinks and SEO Management from Database 2 | 3 | [![Build Status](https://travis-ci.com/IsraelOrtuno/permalink.svg?branch=master)](https://travis-ci.com/IsraelOrtuno/permalink) [![Latest Stable Version](https://poser.pugx.org/devio/permalink/version)](https://packagist.org/packages/devio/permalink) 4 | 5 | ## 2021-08-25: Looking for maintainer 6 | 7 | Despite it's pretty stable, I do not have time to keep maintaining this package for future Laravel releases and add more features so I am looking for anyone who found this useful and would like to maintain it. Feel free to contact! 8 | israel@devio.es 9 | 10 | ------- 11 | 12 | 13 | This package allows to create dynamic routes right from database, just like WordPress and other CMS do. 14 | 15 | **IMPORTANT** Despite the functionality of this package is not complex at all, there are a few things and good practices to consider. I really recommend to carefully read the entire documentation to deeply understand how this package works as you will be replacing the default Laravel Routing System and do not want to mess up with your URLs and SEO! 16 | 17 | ## Roadmap 18 | - [ ] [Resources for visual SEO management](https://github.com/IsraelOrtuno/permalink-form) (in progress) 19 | 20 | ## Documentation 21 | - [Getting the route for a resource](#getting-the-route-for-a-resource) 22 | - [Automatic SEO generation](#automatic-seo-generation) 23 | 24 | * [Installation](#installation) 25 | * [Getting Started](#getting-started) 26 | * [Replacing the Default Router](#replacing-the-default-router) 27 | * [Creating a Permalink](#creating-permalinks) 28 | * [Updating a Permalink](#updating-permalinks) 29 | * [Binding Models to Permalinks](#binding-models-to-permalinks) 30 | * [Automatically Handling Permalinks](#automatically-handling-permalinks) 31 | * [Nesting Permalinks](#nesting-permalinks) 32 | * [Deleting Permalinks](#deleting-permalinks) 33 | * [Caching Permalinks](#caching.-permalinks) 34 | * [Handling SEO Attributes](#handling-seo-attributes) 35 | 36 | ## Installation 37 | 38 | ### Install the package 39 | 40 | ```shell 41 | composer require devio/permalink 42 | ``` 43 | 44 | ### Run the migrations 45 | 46 | ```shell 47 | php artisan migrate 48 | ``` 49 | 50 | ## Getting started (PLEASE READ) 51 | 52 | This package handles dynamic routing directly from our database. Nested routes are also supported, so we can easily create routes like this `/jobs/frontend-web-developer`. 53 | 54 | Most of the solutions out there are totally bound to models with polymorphic relationships, however that's not flexible at all when dealing with routes without models. This package supports both, routes with bound models and regular routes. 55 | 56 | Basically, the package stores routes in a `permalinks` table which contains information about every route: 57 | - Slug 58 | - Parent (parent route for nesting) 59 | - Model (if any) 60 | - Action (controller action or model default action) 61 | - SEO options (title, metas...) 62 | 63 | By default, this package will try to find if there's a a permalink in the `permalinks` table matching the current request path in a single SQL query. This is ok for most of the use cases. If for some reason you want to cache your permalinks information into the Laravel Routing Cache, please refer to the [Caching Permalinks](#caching) section. 64 | 65 | ### Example 66 | 67 | Let check out a very basic example to understand how it internally works: 68 | 69 | | id | slug | parent_id | parent_for | entity_type | entity_id | action | final_path | 70 | | -- | ------------- | --------- | ---------- | ------------------ | ---------------- | -------------------- | --------------------- | 71 | | 1 | users | NULL | App\User | NULL | NULL | UserController@index | users 72 | | 2 | israel-ortuno | 1 | NULL | App\User | 1 | UserController@show | users/israel-ortuno 73 | 74 | It will run the following (this example tries to be as explicit as possible, internally it uses eager loading and some other performance optimizations): 75 | 76 | ```php 77 | $router->get('users', 'UserController@index'); 78 | $router->get('users/israel-ortuno', 'UserController@show'); 79 | 80 | // Which will produce: 81 | // /users UserController@index 82 | // /users/israel-ortuno 83 | ``` 84 | 85 | **NOTE:** The `show` method will receive the user as parameter `App\User::find(1)` the route is bound to that model. 86 | 87 | ## Replacing the Default Router 88 | This package has it's own router which extends the default Laravel router. To replace the default router for the one included in this package you have two options: 89 | 90 | ```shell 91 | php artisan permalink:install {--default} 92 | ``` 93 | 94 | The console will propmpt you with 2 options: 95 | ```shell 96 | [0] Http/Kernel.php (Default & Recommended) 97 | [1] bootstrap/app.php (Advanced) 98 | ``` 99 | 100 | Select the one that fits your needs. For most cases I recommend going through `Http\Kernel.php`. Use the `--default` option to avoid blocking prompts (could also use the default Laravel command's flag `--no-interaction`). 101 | 102 | Both of these methods will replace the default Laravel Router by an extended version provided by this package which contains the Permalink management logic. 103 | 104 | **IMPORTANT:** Use either `Http\Kernel.php` or `bootstrap/app.php`. **Do not** use both as it may cause unexpected behaviour. 105 | 106 | ## Creating Permalinks 107 | 108 | That's pretty much it for setting up the dynamic routing system. Let's create a Permalink record and test it out! 109 | 110 | ```php 111 | Permalink::create([ 112 | 'slug' => 'home', 113 | 'action' => 'App\Http\Controllers\HomeController@index' 114 | ]); 115 | // Then visit /home 116 | ``` 117 | 118 | If your permalink is bound to a model (read next section), you may create your permalink record as follows: 119 | 120 | ```php 121 | // Note: when using the User::create method, even if permalinkHandling (read more about it below) 122 | // is disabled, it will create the permalink record. 123 | $user = User::create([ 124 | 'name' => 'israel', 125 | 'permalink' => [ 126 | 'slug' => 'israel-ortuno', 127 | 'action' => 'user.show', 128 | 'seo' => [...] // omit this attribute until you read more about it 129 | ] 130 | ]); 131 | 132 | // Or 133 | 134 | $user->createPermalink([...); 135 | ``` 136 | 137 | If you do not provide any data to the `permalink` key when using `User::create` or `createPermalink`, it will automatcally use the default data. Any existing key in the data array will override its default value when creating the permalink. 138 | 139 | **NOTE:** This will only work if `permalinkHandling` has not been disabled, read more about it below. 140 | 141 | ## Updating Peramlinks 142 | 143 | You can easily update a permalink just like any other Eloquent model. **BE CAREFUL** when updating a permalink slug as the previous URL won't be available anymore and this package does not handle 301/302 redirections. 144 | 145 | ### Rebuilding Final Path (PLEASE READ) 146 | 147 | When updating a slug, the package will recursively update its nested permalinks `final_url` attribute reemplacing the previous slug semgment with the new one. You can control this behaviour from the `rebuild_children_on_update` option in your `config/permalink.php` config file. Disable this option if you wish to handle this task manually (NOT RECOMMENDED). 148 | 149 | Check out `Devio\Permalink\Services\PathBuilder` class to discover the methods available for performing the manual update. 150 | 151 | **NOTE:** Make sure to rebuild childen's final path in the current request lifecycle. 152 | 153 | ## Binding Models to Permalinks 154 | 155 | You may want to bind a permalink to a model resource, so you can create a unique URL to access that particular resource. If you want to do so, you just have to use the tait `HasPermalinks` and implement the contract `Permalinkable` to your model. 156 | 157 | ```php 158 | class User extends Model implements \Devio\Permalink\Contracts\Permalinkable; 159 | { 160 | use \Devio\Permalink\HasPermalinks; 161 | 162 | public function permalinkAction() 163 | { 164 | return UserController::class . '@show'; 165 | } 166 | 167 | public function permalinkSlug(): array 168 | { 169 | return ['entity.name']; 170 | } 171 | } 172 | ``` 173 | 174 | Once you have this setup, this package will generate a permalink for every new record of this model automatically. 175 | 176 | Also, the `Permalinkable` interface will force you to define two simple methods: 177 | 178 | **permalinkAction()** 179 | 180 | This method will return the default controller action responsible for handling the request for this particular model. The model itself will be injected into the action (as Laravel usually does for route model binding). 181 | 182 | ```php 183 | public function show($user) 184 | { 185 | return view('users.show', $user); 186 | } 187 | ``` 188 | 189 | **NOTE:** This action will be overwritten by any existing value on the `action` column in your permalink record, so you could have multiple actions for the same model in case you need them. 190 | 191 | **permalinkSlug()** 192 | 193 | This method is a bit more tricky. Since all the slugging task is being handled by the brilliant [Sluggable](https://github.com/cviebrock/eloquent-sluggable) package, we do have to provide the info this package requires on its [sluggable](https://github.com/cviebrock/eloquent-sluggable#updating-your-eloquent-models) method. 194 | 195 | The permalink model will expose an `entity` polymorphic relationship to this model. Since the slugging occurs in the `Permalink` model class, we do have to specify which is going to be the source for our slug. You can consider `entity` as `$this`, so in this case `entity.name` would be equivalent to `$this->name`. Return multiple items if you would like to concatenate multiple properties: 196 | 197 | ``` 198 | ['entity.name', 'entity.city'] 199 | ``` 200 | 201 | **NOTE:** This method should return an array compatible with the Sluggable package, please [check the package documentation](https://github.com/cviebrock/eloquent-sluggable#updating-your-eloquent-models) if you want to go deeper. 202 | 203 | ## Automatically Handling Permalinks 204 | 205 | By default, this package takes care of creating/updating/deleting your permalinks based on the actions performed in the bound model. If you do not want this to happen and want to decide when decide the precise moment the permalink has to be created/updated/deleted for this particular model. You can disable the permalink handling in two ways: 206 | 207 | ```php 208 | 209 | // Temporally disable/enable: 210 | $model->disablePermalinkHandling(); 211 | $model->enablePermalinkHandling(); 212 | 213 | // Permanent disable or return a condition. 214 | // Create this method in you model: 215 | public function permalinkHanlding() 216 | { 217 | return false; 218 | } 219 | ``` 220 | 221 | ### Creating 222 | 223 | A permalink will be created automatically when your resource fires a `saved` event. It will be populate with the default data unless you have provided a `peramlink` key array to the creation array or used the `setPermalinkAttribute` mutator. 224 | 225 | ```php 226 | User::create(['name' => 'israel', 'permalink' => ['slug' => 'israel']]); 227 | // 228 | $user = new User; 229 | $user->permalink = ['slug' => 'israel']; 230 | $user->save(); 231 | ``` 232 | 233 | If `permalinkHandling` is disabled, you will be able to decide when to create the permalink: 234 | 235 | ```php 236 | // Assume permalinkHanlding() returns false 237 | $user = User::create(['name' => 'israel']); 238 | // Perform other tasks... 239 | $user->createPermalink(); // Array is optional, provide data to override default values 240 | ``` 241 | 242 | **NOTE:** Be aware that the permalink record will be still created if the data provided for creation contains a `permalink` key. 243 | 244 | ### Updating 245 | 246 | You can update your permalink right like creating: 247 | 248 | ```php 249 | $user = User::find(1); 250 | 251 | $user->updatePermalink(['seo' => ['title' => 'changed']]); 252 | ``` 253 | 254 | **NOTE:** By default, if you update a permalink's slug, it will recursively update all its nested elements with the new segment. Read more about [updating permalinks](#updating-permalinks). 255 | 256 | ### Deleting 257 | 258 | If you delete a resource which is bound to a permalink record, the package will automatically destroy the permalink for us. Again, if you do not want this to happen and want to handle this yourself, disable the permalink handling in your model. 259 | 260 | ### Support for SoftDeleting 261 | 262 | SoftDeleting support comes out of the box, so if your resource is soft deleted, the permalink will be soft deleted too. If you restore your resource, it will be restored automatically too. Disable handling for dealing with this task manually. 263 | 264 | **NOTE:** If you `forceDelete()` your resource, the permalink will also be deleted permanently. 265 | 266 | ## Nesting Permalinks 267 | 268 | You may want to have a nested permalink structure, let's say, for your blog. Parent will be `/blog` and every post should be inside this path, so you can do things like: 269 | 270 | ``` 271 | /blog -> Blog index, show all blog posts 272 | /blog/post-1 273 | /blog/post-2 274 | ... 275 | ``` 276 | 277 | This package handles this for you out of the box: 278 | 279 | ### Automatic Permalink Nesting 280 | 281 | The `permalinks` table has a column for automatically nesting models: `parent_for`. This attribute should contain the FQN class name of the model you want it to be parent for. Once set, when you create a new permalink for the specified model, it will automatically nested to the given parent. 282 | 283 | This will usually be a manual procedure you will do in you database so it may look like like the [example above](#example). 284 | 285 | ### Disable Automatic Nesting 286 | 287 | If you are deep into this package and want to manage the nesting of your permalinks manually (why would you do so? but just in case...), feel free to disable this feature from the config: 288 | 289 | ```php 290 | // Globally disable this feature for all models in your permalink.php config file 291 | 'nest_to_parent_on_create' => false 292 | // or 293 | config()->set('permalink.nest_to_parent_on_create', false); 294 | 295 | // Disable this feature for a particular model. Define this method in your model class: 296 | public function permalinkNestToParentOnCreate() 297 | { 298 | return false; 299 | } 300 | ``` 301 | 302 | ### Manually Nesting 303 | 304 | If you wish to nest a permalink to other manually, all you have to do is to set the `id` of the parent permalink to the `parent_id` attribute on the child permalink: 305 | 306 | ```php 307 | Permalink::create(['slug' => 'my-article', 'parent_id' => 1, 'action' => '...']); 308 | ``` 309 | 310 | ## Permalink Actions 311 | 312 | The `action` attribute on your permalink record will be providing the information about what's going to handle the request when that permalink matches the current request URI. 313 | 314 | ### Controllers as Actions 315 | 316 | Every permalink should have a action, specifically those which are not bound to models. You should specify a `controller@action` into the `action` column of your permalink record. 317 | 318 | If there's a model bound to the permalink (entity), it will be passed as parameter to the controller action: 319 | 320 | ```php 321 | class UserController { 322 | public function show($user) 323 | { 324 | return view('users.show', compact('user')); 325 | } 326 | } 327 | ``` 328 | 329 | ### Views as Actions 330 | 331 | For simple use cases you could simply specify a view's path as an action for your permalink. The permalink entity (if bound to a model) will also be available in this view as mentioned above: 332 | 333 | ```php 334 | Permalink::create(['slug' => 'users', 'action' => 'users.index']); 335 | ``` 336 | 337 | If bound to a model... 338 | 339 | ```php 340 | Permalink::create(['slug' => 'israel-ortuno', 'entity_type' => User::class, 'entity_id' => 1, 'action' => 'users.show']); 341 | 342 | // And then in users/show.blade.php 343 |

Welcome {{ $user->name }}

344 | ``` 345 | 346 | #### Using a Custom Controller for View Actions 347 | 348 | Under the hood, view actions are handled by a controller provided by this package `Devio\Permalink\Http\PermalinkController`. You can update this controller with your own implementation if needed. Maybe you want to apply some middleware, or resolve views in a different way... 349 | 350 | All you have to do is to bind your implementation to the container in your `AppServiceProvider` (or other): 351 | 352 | ```php 353 | // In your AppServiceProvider.php 354 | public function register() 355 | { 356 | $this->bind('Devio\Permalink\Http\PermalinkController', YourController::class); 357 | } 358 | 359 | // And then... 360 | class YourController 361 | { 362 | use Devio\Permalink\Http\ResolvesPermalinkView; 363 | 364 | public function __construct() 365 | { 366 | // Do your stuff. 367 | } 368 | } 369 | ``` 370 | 371 | This way, Laravel will now resolve your implementation out of the container. 372 | 373 | If you wish to have your own implementation for resolving the views, do not use the `Devio\Permalink\Http\ResolvesPermalinkView` trait and create your own `view()` method. 374 | 375 | ### Default Actions (in Models) 376 | 377 | If you have a model bound to a permalink, you may define a default action in your model like this: 378 | 379 | ```php 380 | public function permalinkAction() 381 | { 382 | return UserController::class . '@show'; // Or a view 383 | } 384 | ``` 385 | 386 | This method is mandatory once you implement the `Permalinkable` interface. 387 | 388 | ### Overriding the Default Action 389 | 390 | By default, the permalink will resolve the action based on the `permlainkAction` method of the permalink entity. However, if you specifiy a value to the `action` column in the permalink record, it will override the default action. For example: 391 | 392 | ```php 393 | class User extends Model 394 | { 395 | use HasPermalinks; 396 | ... 397 | public function permalinkAction() 398 | { 399 | return UserController::class . '@index'; 400 | } 401 | ... 402 | } 403 | 404 | // And then... 405 | $user = User::create([ 406 | 'name' => 'israel', 407 | 'permalink' => [ 408 | 'action' => 'user.show' 409 | ] 410 | ]); 411 | // Or just update the action attribute as you like 412 | ``` 413 | 414 | When accessing the permalink for this particular entity, `user/show.blade.php` will be responsible for handling the request rather than the default controller. Isn't it cool? 415 | 416 | ## Deleting Permalinks 417 | 418 | By default, and if 419 | 420 | ### Support for SoftDeleting 421 | 422 | ## Caching Permalinks (Read Carefully!) 423 | 424 | As mentioned above, this package will perform a single SQL query on every request in order to find a matching permalink for the current URI. This is quite performant and should be ok for most use cases. This query may also be cached for super-fast access if needed. 425 | 426 | You may cache your permalink routes into the default Laravel Route Caching system, but be aware that it will generate a route for every single record in your `permalinks` table, so I **DO NOT** recommend it if you have a large amount of permalinks, as you may end up with a huge base64 encoded string in your `bootstrap/cache/routes.php` which may really slow down your application bootstrapping. Perform some tests to know if you are really improving performance for the amount of routes you pretend to cache. 427 | 428 | In order to cache you permalinks, all you have to do is to load the entire `permalinks` dataset into the Router and then run the Route Caching command: 429 | 430 | ```php 431 | Router::loadPermalinks(); 432 | Artisan::call('route:cache'); 433 | ``` 434 | 435 | You could create a command to perform this two actions or whatever you consider. From now on, you will have to manually update this cache every time a permalink record has been updated. 436 | 437 | ## Handling SEO Attributes 438 | 439 | This package wouldn't be complete if you could not configure your SEO attributes for every single permalink record, it would have been almost useless! 440 | 441 | ## Automatic SEO generation 442 | 443 | For SEO tags generation [ARCANDEV/SEO-Helper](https://github.com/ARCANEDEV/SEO-Helper) is being used. This package offers a powerful set of tools to manage your SEO meta tags. 444 | 445 | ``` 446 | { 447 | "meta": { 448 | "title": "Specific title", // The 449 | "description": "The meta description", // The page meta description 450 | "robots": "noindex,nofollow" // Robots control 451 | }, 452 | "opengraph":{ 453 | "title": "Specific OG title", // The og:title tag 454 | "description": "The og description", // The og:description tag 455 | "image": "path/to/og-image.jpg" // The og:image tag 456 | }, 457 | "twitter":{ 458 | "title": "Specific Twitter title", // The twitter:title tag 459 | "description": "The twitter description", // The twitter:description tag 460 | "image": "path/to/og-image.jpg" // The twitter:image tag 461 | } 462 | } 463 | ``` 464 | 465 | **NOTE:** This is just an example of the most common tags but you could any kind of tag supported (index, noindex...) by [ARCANDEV/SEO-Helper](https://github.com/ARCANEDEV/SEO-Helper), just make sure to nest it correctly. 466 | 467 | In order to have all this content rendered in your HTML you should add the following you your `<meta>`: 468 | 469 | ```blade 470 | <head> 471 | {!! seo_helper()->render() !!} 472 | </head> 473 | ``` 474 | 475 | ##### OR 476 | 477 | ```blade 478 | <head> 479 | {{ seo_helper()->renderHtml() }} 480 | </head> 481 | ``` 482 | 483 | Plase visit [SEO-Helper – Laravel Usage](https://github.com/ARCANEDEV/SEO-Helper/blob/master/_docs/3-Usage.md#4-laravel-usage) to know more about what and how to render. 484 | 485 | ### Understanding How it Works 486 | 487 | Under the hood, this JSON structure is calling to the different SEO helpers (meta, opengraph and twitter). Let's understand: 488 | 489 | ```json 490 | { 491 | "title": "Generic title", 492 | "image": "path/to/image.jpg", 493 | "description": "Generic description", 494 | 495 | "meta": { 496 | "title": "Default title", 497 | }, 498 | "opengraph": { 499 | "image": "path/to/og-image.jpg" 500 | } 501 | } 502 | ``` 503 | 504 | This structure will allow you to set a base value for the `title` in all the builders plus changing exclusively the title for the _Meta_ section. Same with the image, Twitter and OpenGraph will inherit the parent image but OpenGraph will replace its for the one on its builder. This way you will be able to display different information on every section! 505 | 506 | This will call [setTitle](https://github.com/ARCANEDEV/SEO-Helper/blob/master/src/Contracts/SeoMeta.php#L127) from the `SeoMeta` helper and [setImage](https://github.com/ARCANEDEV/SEO-Helper/blob/master/src/Contracts/SeoOpenGraph.php#L78) from the `SeoOpenGraph` helper. Same would happen with Twitter. Take some time to review these three contracts in order to know all the methods available: 507 | 508 | - [Metas](https://github.com/ARCANEDEV/SEO-Helper/blob/master/src/Contracts/SeoMeta.php) 509 | - [OpenGraph](https://github.com/ARCANEDEV/SEO-Helper/blob/master/src/Contracts/SeoOpenGraph.php) 510 | - [Twitter](https://github.com/ARCANEDEV/SEO-Helper/blob/master/src/Contracts/SeoTwitter.php) 511 | 512 | In order to match any of the helper methods, every JSON option will be transformed to `studly_case` prefixed by `set` and `add`, so `title` will be converted to `setTitle` and `google_analytics` to `setGoogleAnalytics`. How cool is that? 513 | 514 | All methods are called via `call_user_func_array`, so if an option contains an array, every key will be pased as parameter to the helper method. See `setTitle` or `addWebmaster` which allows multiple parameters. 515 | 516 | ### Populating SEO Attributes 517 | 518 | You can specify the SEO attributes for your permalink by just passing an array of data to the `seo` attribute: 519 | 520 | ```php 521 | Peramlink::create([ 522 | 'slug' => 'foo', 523 | 'seo' => [ 524 | 'title' => 'this is a title', 525 | 'description' => 'this is a description', 526 | 'opengraph' => [ 527 | 'title' => 'this is a custom title for og:title' 528 | ] 529 | ] 530 | ); 531 | ``` 532 | 533 | #### Populating SEO Attributes with Default Content 534 | 535 | You will usually want to automatically populate your SEO information directly from your bound model information. You can do so by creating fallback methods in you model as shown below: 536 | 537 | ```php 538 | public function getPeramlinkSeoTitleAttribute() 539 | { 540 | return $this->name; 541 | } 542 | 543 | public function getPermalinkSeoOpenGraphTitleAttribute() 544 | { 545 | return $this->name . ' for OpenGraph'; 546 | } 547 | ``` 548 | 549 | This fallbacks will be used if they indeed exist and the value for that field has not been provided when creating the permalink. Note that these methods should be called as an Eloquent accessor. Use the _permalinkSeo_ prefix and then the path to the default value in a _StudlyCase_, for example: 550 | 551 | ``` 552 | seo.title => getPermalinkSeoTitleAttribute() 553 | seo.description => getPermalinkSeoDescriptionAttribute() 554 | seo.twitter.title => getPermalinkSeoTwitterTitleAttribute() 555 | seo.twitter.description => getPermalinkSeoTwitterDescriptionAttribute() 556 | seo.opengraph.title => getPermalinkSeoTwitterOpenGraphAttribute() 557 | seo.opengraph.description => getPermalinkSeoOpenGraphDescriptionAttribute() 558 | ``` 559 | 560 | The package will look for any matching method, so you can create as many methods as your seo set-up may need, even if you are just creating custom meta tags so `getPermalinkMyCustomMetaDescriptionAttribute` would match if there's a `seo.my.custom.meta.description` object. 561 | 562 | ### SEO Builders 563 | 564 | To provide even more flexibility, the method calls are piped through 3 classes (one for each helper) called [Builders](https://github.com/IsraelOrtuno/permalink/tree/master/src/Builders). These builders are responsible for calling the right method on the [ARCANDEV/SEO-Helper](https://github.com/ARCANEDEV/SEO-Helper) package. 565 | 566 | If there is a method in this builders matching any of the JSON options, the package will execute that method instead of the default behaviour, which would be calling the method (if exists) from the *SEO-Helper* package. 567 | 568 | Review the [MetaBuilder](https://github.com/IsraelOrtuno/permalink/blob/master/src/Builders/MetaBuilder.php) as example. This builder contains a `setCanonical` method which is basically used as an alias for `setUrl` (just to be more explicit). 569 | 570 | #### Extending Builders 571 | 572 | In order to modify the behaviour of any of these builders, you can create your own Builder which should extend the `Devio\Permalink\Contracts\SeoBuilder` interface or inherit the `Devio\Permalink\Builders\Builder` class. 573 | 574 | Once you have created your own Builder, just replace the default one in the Container. Add the following to the `register` method of any Service Provider in your application: 575 | 576 | ```php 577 | // Singleton or not, whatever you require 578 | $this->app->singleton("permalink.meta", function ($app) { // meta, opengraph, twitter or base 579 | return new MyCustomBuilder; 580 | 581 | // Or if you are inheriting the default builder class 582 | 583 | return (new MyCustomBuilder($app->make(SeoHelper::class))); 584 | }); 585 | ``` 586 | 587 | If you wish to use other package for generating the SEO meta tags, extending and modifying the builders will do the trick. 588 | 589 | ### Disabling SEO generation 590 | 591 | If you wish to prevent the rendering of any of the three Builders (meta, OpenGraph or Twitter), just set its JSON option to false: 592 | 593 | ```json 594 | { 595 | "meta": { }, 596 | "opengraph": false, 597 | "twitter": false 598 | } 599 | ``` 600 | 601 | This will disable the execution of the OpenGraph and Twitter builders. 602 | --------------------------------------------------------------------------------