diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 2b2fa35..75f062b 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -9,7 +9,7 @@ jobs: fail-fast: false matrix: php: [8.1, 8.0] - laravel: [8.*] + laravel: [8.76.1] dependency-version: [prefer-lowest, prefer-stable] name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.dependency-version }} diff --git a/README.md b/README.md index 22e4db4..bde1d80 100644 --- a/README.md +++ b/README.md @@ -338,8 +338,50 @@ class YourEloquentModel extends Model ->saveSlugsTo('slug'); } } + ``` +#### Implicit route model binding + +You can also use Laravels [implicit route model binding](https://laravel.com/docs/8.x/routing#implicit-binding) inside your controller to automatically resolve the model. To use this feature, make sure that the slug column matches the `routeNameKey`. +Currently, only some database types support JSON opterations. Further information about which databases support JSON can be found in the [Laravel docs](https://laravel.com/docs/8.x/queries#json-where-clauses). + +```php +namespace App; + +use Spatie\Sluggable\HasSlug; +use Spatie\Sluggable\SlugOptions; +use Illuminate\Database\Eloquent\Model; + +class YourEloquentModel extends Model +{ + use HasTranslations, HasTranslatableSlug; + + public $translatable = ['name', 'slug']; + + /** + * Get the options for generating the slug. + */ + public function getSlugOptions() : SlugOptions + { + return SlugOptions::create() + ->generateSlugsFrom('name') + ->saveSlugsTo('slug'); + } + + /** + * Get the route key for the model. + * + * @return string + */ + public function getRouteKeyName() + { + return 'slug'; + } +} +``` + + ## Changelog Please see [CHANGELOG](CHANGELOG.md) for more information what has changed recently. diff --git a/composer.json b/composer.json index 7af2683..56f03e7 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,7 @@ ], "require": { "php": "^8.0", - "illuminate/database": "^8.0", + "illuminate/database": "^8.76.1", "illuminate/support": "^8.0" }, "require-dev": { diff --git a/src/HasTranslatableSlug.php b/src/HasTranslatableSlug.php index 619c557..2c1cdab 100644 --- a/src/HasTranslatableSlug.php +++ b/src/HasTranslatableSlug.php @@ -2,6 +2,8 @@ namespace Spatie\Sluggable; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\Collection; use Illuminate\Support\Str; use Illuminate\Support\Traits\Localizable; @@ -94,7 +96,7 @@ protected function slugIsBasedOnTitle(): bool $slugSeparator = $currentSlug[strlen($titleSlug)]; $slugIdentifier = substr($currentSlug, strlen($titleSlug) + 1); - return $slugSeparator === $this->slugOptions->slugSeparator && is_numeric($slugIdentifier); + return $slugSeparator === $this->slugOptions->slugSeparator && is_numeric($slugIdentifier); } protected function getOriginalSourceString(): string @@ -120,4 +122,15 @@ protected function hasCustomSlugBeenUsed(): bool return $originalSlug !== $newSlug; } + + public function resolveRouteBindingQuery($query, $value, $field = null): Builder|Relation + { + $field = $field ?? $this->getRouteKeyName(); + + if ($field !== $this->getSlugOptions()->slugField) { + return parent::resolveRouteBindingQuery($query, $value, $field); + } + + return $query->where("{$field}->{$this->getLocale()}", $value); + } } diff --git a/tests/HasTranslatableSlugTest.php b/tests/HasTranslatableSlugTest.php index f62afcd..37fcdca 100644 --- a/tests/HasTranslatableSlugTest.php +++ b/tests/HasTranslatableSlugTest.php @@ -2,6 +2,7 @@ namespace Spatie\Sluggable\Tests; +use Illuminate\Support\Facades\Route; use Spatie\Sluggable\SlugOptions; class HasTranslatableSlugTest extends TestCase @@ -302,4 +303,103 @@ public function it_can_update_slug_with_non_unique_names_multiple() $this->assertSame($expectedSlug, $model->getTranslation('slug', 'en')); } } + + /** @test */ + public function it_can_resolve_route_binding() + { + $model = new TranslatableModel(); + + $model->setTranslation('name', 'en', 'Test value EN'); + $model->setTranslation('name', 'nl', 'Test value NL'); + $model->setTranslation('slug', 'en', 'updated-value-en'); + $model->setTranslation('slug', 'nl', 'updated-value-nl'); + $model->save(); + + // Test for en locale + $result = (new TranslatableModel())->resolveRouteBinding('updated-value-en', 'slug'); + + $this->assertNotNull($result); + $this->assertEquals($model->id, $result->id); + + // Test for nl locale + $this->app->setLocale('nl'); + + $result = (new TranslatableModel())->resolveRouteBinding('updated-value-nl', 'slug'); + + $this->assertNotNull($result); + $this->assertEquals($model->id, $result->id); + + // Test for fr locale - should fail + $this->app->setLocale('fr'); + $result = (new TranslatableModel())->resolveRouteBinding('updated-value-nl', 'slug'); + $this->assertNull($result); + } + + /** @test */ + public function it_can_resolve_route_binding_even_when_soft_deletes_are_on() + { + foreach (range(1, 10) as $i) { + $model = new TranslatableModelSoftDeletes(); + $model->setTranslation('name', 'en', 'Test value EN'); + $model->setTranslation('slug', 'en', 'updated-value-en-' . $i); + $model->save(); + $model->delete(); + + $result = (new TranslatableModelSoftDeletes())->resolveSoftDeletableRouteBinding( + 'updated-value-en-' . $i, + 'slug' + ); + + $this->assertNotNull($result); + $this->assertEquals($model->id, $result->id); + } + } + /** @test */ + public function it_can_bind_route_model_implicit() + { + $model = new TranslatableModel(); + $model->setTranslation('name', 'en', 'Test value EN'); + $model->setTranslation('slug', 'en', 'updated-value-en'); + $model->save(); + + Route::get( + '/translatable-model/{test:slug}', + function (TranslatableModel $test) use ($model) { + $this->assertNotNull($test); + $this->assertEquals($model->id, $test->id); + } + )->middleware('bindings'); + + $response = $this->get("/translatable-model/updated-value-en"); + + $response->assertStatus(200); + } + + /** @test */ + public function it_can_bind_child_route_model_implicit() + { + $model = new TranslatableModel(); + $model->setTranslation('name', 'en', 'Test value EN'); + $model->setTranslation('slug', 'en', 'updated-value-en'); + $model->test_model_id = 1; + $model->save(); + + $parent = new TestModel(); + $parent->name = 'parent'; + $parent->save(); + + Route::get( + '/test-model/{test_model:url}/translatable-model/{translatable_model:slug}', + function (TestModel $testModel, TranslatableModel $translatableModel) use ($parent, $model) { + $this->assertNotNull($parent); + $this->assertNotNull($translatableModel); + $this->assertEquals($parent->id, $testModel->id); + $this->assertEquals($model->id, $translatableModel->id); + } + )->middleware('bindings'); + + $response = $this->get("/test-model/parent/translatable-model/updated-value-en"); + + $response->assertStatus(200); + } } diff --git a/tests/TestCase.php b/tests/TestCase.php index 6884953..3f77600 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -5,6 +5,7 @@ use File; use Illuminate\Database\Schema\Blueprint; use Illuminate\Foundation\Application; +use Illuminate\Support\Facades\Schema; use Orchestra\Testbench\TestCase as Orchestra; abstract class TestCase extends Orchestra @@ -26,29 +27,29 @@ protected function getEnvironmentSetUp($app) { $this->initializeDirectory($this->getTempDirectory()); - $app['config']->set('database.default', 'sqlite'); - $app['config']->set('database.connections.sqlite', [ + config()->set('database.default', 'sqlite'); + config()->set('database.connections.sqlite', [ 'driver' => 'sqlite', - 'database' => $this->getTempDirectory().'/database.sqlite', + 'database' => $this->getTempDirectory() . '/database.sqlite', 'prefix' => '', ]); } /** - * @param $app + * @param Application $app */ protected function setUpDatabase(Application $app) { - file_put_contents($this->getTempDirectory().'/database.sqlite', null); + file_put_contents($this->getTempDirectory() . '/database.sqlite', null); - $app['db']->connection()->getSchemaBuilder()->create('test_models', function (Blueprint $table) { + Schema::create('test_models', function (Blueprint $table) { $table->increments('id'); $table->string('name')->nullable(); $table->string('other_field')->nullable(); $table->string('url')->nullable(); }); - $app['db']->connection()->getSchemaBuilder()->create('test_model_soft_deletes', function (Blueprint $table) { + Schema::create('test_model_soft_deletes', function (Blueprint $table) { $table->increments('id'); $table->string('name')->nullable(); $table->string('other_field')->nullable(); @@ -56,15 +57,25 @@ protected function setUpDatabase(Application $app) $table->softDeletes(); }); - $app['db']->connection()->getSchemaBuilder()->create('translatable_models', function (Blueprint $table) { + Schema::create('translatable_models', function (Blueprint $table) { $table->increments('id'); $table->text('name')->nullable(); $table->text('other_field')->nullable(); $table->text('non_translatable_field')->nullable(); $table->text('slug')->nullable(); + $table->foreignId('test_model_id')->nullable()->index(); + }); + + Schema::create('translatable_model_soft_deletes', function (Blueprint $table) { + $table->increments('id'); + $table->text('name')->nullable(); + $table->text('other_field')->nullable(); + $table->text('non_translatable_field')->nullable(); + $table->text('slug')->nullable(); + $table->softDeletes(); }); - $app['db']->connection()->getSchemaBuilder()->create('scopeable_models', function (Blueprint $table) { + Schema::create('scopeable_models', function (Blueprint $table) { $table->increments('id'); $table->text('name')->nullable(); $table->text('slug')->nullable(); @@ -82,6 +93,6 @@ protected function initializeDirectory(string $directory) public function getTempDirectory(): string { - return __DIR__.'/temp'; + return __DIR__ . '/temp'; } } diff --git a/tests/TestModel.php b/tests/TestModel.php index 8dc1570..0bc1436 100644 --- a/tests/TestModel.php +++ b/tests/TestModel.php @@ -3,6 +3,7 @@ namespace Spatie\Sluggable\Tests; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; use Spatie\Sluggable\HasSlug; use Spatie\Sluggable\SlugOptions; @@ -43,4 +44,9 @@ public function getDefaultSlugOptions(): SlugOptions ->generateSlugsFrom('name') ->saveSlugsTo('url'); } + + public function translatableModels(): HasMany + { + return $this->hasMany(TranslatableModel::class); + } } diff --git a/tests/TranslatableModel.php b/tests/TranslatableModel.php index 09ec19d..ef5ac50 100644 --- a/tests/TranslatableModel.php +++ b/tests/TranslatableModel.php @@ -3,6 +3,7 @@ namespace Spatie\Sluggable\Tests; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Spatie\Sluggable\HasTranslatableSlug; use Spatie\Sluggable\SlugOptions; use Spatie\Translatable\HasTranslations; @@ -17,9 +18,9 @@ class TranslatableModel extends Model protected $guarded = []; public $timestamps = false; - public $translatable = ['name', 'other_field', 'slug']; + protected $translatable = ['name', 'other_field', 'slug']; - private $customSlugOptions; + protected $customSlugOptions; public function useSlugOptions($slugOptions) { @@ -32,4 +33,9 @@ public function getSlugOptions(): SlugOptions ->generateSlugsFrom('name') ->saveSlugsTo('slug'); } + + public function testModel(): BelongsTo + { + return $this->belongsTo(TestModel::class); + } } diff --git a/tests/TranslatableModelSoftDeletes.php b/tests/TranslatableModelSoftDeletes.php new file mode 100644 index 0000000..154a7aa --- /dev/null +++ b/tests/TranslatableModelSoftDeletes.php @@ -0,0 +1,37 @@ +customSlugOptions = $slugOptions; + } + + public function getSlugOptions(): SlugOptions + { + return $this->customSlugOptions ?: SlugOptions::create() + ->generateSlugsFrom('name') + ->saveSlugsTo('slug'); + } +}