17. ID vs UUID vs ULID. Laravel slug з можливістю редагування. Booting a trait. Sqids vs Hashids
Відео версія
(YouTube - для зворотнього зв'язку)
Ніколи НІКОЛИ не віддавайте числовий ID на фронт.
IDOR вразливість не вирішується приховуванням ID. Але все ж зменшує миттєву шкоду від вразливості.
Цифрові ID видають реальну статистику вашого проєкту, тому важливо їх приховувати від звичайних користувачів.
Squid перетворює масив цілих чисел в унікальний рядок. Рядок можна розшифрувати навіть не знаючи алфавіту. Тому не використовуйте його для передачі чутливих даних.
З ULID та новіших специфікацій UUID можна дістати дату створення запису.
Проблеми з реплікацією числових ID існують, але не критичні.
Eloquent дозволяє використовувати boot у трейтах. Просто назвіть функцію
{TraitName}Boot
.
Думайте про посилання на ваш сайт, коли розробляєте slug.
Якщо slug може редагуватися, для SEO важливо, щоб було одне посилання на сторінку. Тому перенаправляйте старі URL на новий з 302 статусом.
RUN apk add gmp-dev && docker-php-ext-install gmp
<?php
namespace App\Services;
use Sqids\Sqids;
class Squid
{
public static function encode(int $id): string
{
$alphabet = config('squid.alphabet');
$sqids = new Sqids(alphabet: $alphabet);
return $sqids->encode([$id]);
}
public static function decode(string $hash): int
{
$alphabet = config('squid.alphabet');
$sqids = new Sqids(alphabet: $alphabet);
[$id] = $sqids->decode($hash);
return $id;
}
}
<?php
namespace Tests\Unit\Service;
use App\Services\Squid;
use Tests\TestCase;
class SquidTest extends TestCase
{
/**
* @test
*/
public function canEncodeId(): void
{
$id = rand(2, 1000000);
$controlId = 1;
$hash = Squid::encode($id);
$controlHash = Squid::encode($controlId);
$this->assertNotEmpty($hash);
$this->assertNotEquals($controlId, $controlHash);
}
/**
* @test
*/
public function canDecodeHash(): void
{
$id = rand(1, 1000000);
$hash = Squid::encode($id);
$result = Squid::decode($hash);
$this->assertEquals($id, $result);
}
}
<?php
namespace App\Traits;
use App\Services\Squid;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
use Throwable;
/**
* @mixin Model
*
* @method Builder|static whereSlug(?string $slug = null)
*
* @see Sluggable::scopeWhereSlug()
*
* @method Builder|static whereSlugs(array $slug = [])
*
* @see Sluggable::scopeWhereSlugs()
*
* @property string $slug
*/
trait Sluggable
{
public static function bootSluggable(): void
{
static::creating(function (self $model) {
$model->slug = Str::ulid();
});
static::created(function (self $model) {
$model->afterCreate();
});
static::updating(function (self $model) {
$model->slug = $model->makeSlug();
});
}
public function scopeWhereSlug(Builder $query, ?string $slug = null): void
{
if (! $slug) {
$query->whereRaw('1 != 1');
return;
}
$table = $this->getTable();
$key = $this->getKeyName();
$id = $this->getIdFromSlug($slug);
$query->where($table . '.' .$key, $id);
}
public function scopeWhereSlugs(Builder $query, array $slugs = []): void
{
$table = $this->getTable();
$key = $this->getKeyName();
$ids = array_map(fn ($slug) => $this->getIdFromSlug($slug), $slugs);
$query->whereIn($table . '.' .$key, $ids);
}
/**
* @throws Throwable
*
* @phpcsSuppress SlevomatCodingStandard.Functions.UnusedParameter
*/
public function resolveRouteBinding($slug, $field = null): self
{
$id = $this->getIdFromSlug($slug);
return $this->findOrFail($id);
}
abstract protected function slugSource(): ?string;
private function afterCreate(): void
{
$this->slug = $this->makeSlug();
$this->saveQuietly();
}
private function makeSlug(): string
{
$id = $this->getKey();
$hash = Squid::encode($id);
$slugSrc = $this->slugSource();
if (! $slugSrc) {
return $hash;
}
return $hash . '_' . Str::slug($this[$slugSrc]);
}
private function getIdFromSlug(string $slug): int
{
[$hash] = explode('_', $slug);
return Squid::decode($hash);
}
}
<?php
namespace Tests\Unit\Traits\Sluggable;
use App\Services\Squid;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Str;
use Tests\TestCase;
use Tests\Unit\Traits\Sluggable\Resource\ModelForTestSluggable;
use Tests\Unit\Traits\Sluggable\Resource\ModelForTestSluggableWithoutSource;
use Throwable;
class SluggableTest extends TestCase
{
/**
* @test
*/
public function canMakeSlugFromSource(): void
{
$name = 'test name';
$model = ModelForTestSluggable::create([
'name' => $name,
]);
$hash = Squid::encode($model->id);
$this->assertEquals(
$hash . '_' . Str::slug($name),
$model->slug
);
}
/**
* @test
*/
public function canMakeSlugWithNullableSource(): void
{
$model = ModelForTestSluggableWithoutSource::create();
$hash = Squid::encode($model->id);
$this->assertEquals($hash, $model->slug);
}
/**
* @test
*/
public function canGetModelBySlug(): void
{
$modelFirst = ModelForTestSluggable::create();
$modelMiddle = ModelForTestSluggable::create();
$modelLast = ModelForTestSluggable::create();
$firstResult = ModelForTestSluggable::whereSlug($modelFirst->slug)->first();
$this->assertEquals($modelFirst->id, $firstResult->id);
$middleResult = ModelForTestSluggable::whereSlug($modelMiddle->slug)->first();
$this->assertEquals($modelMiddle->id, $middleResult->id);
$lastResult = ModelForTestSluggable::whereSlug($modelLast->slug)->first();
$this->assertEquals($modelLast->id, $lastResult->id);
}
/**
* @test
*/
public function canGetModelBySlugWithCorrectHasOnly(): void
{
ModelForTestSluggable::create();
$model = ModelForTestSluggable::create();
ModelForTestSluggable::create();
[$hash] = explode('_', $model->slug);
$result = ModelForTestSluggable::whereSlug($hash)->first();
$this->assertEquals($model->id, $result->id);
}
/**
* @test
*/
public function canGetModelBySlugWithOldSourceText(): void
{
ModelForTestSluggable::create();
$model = ModelForTestSluggable::create();
ModelForTestSluggable::create();
[$hash] = explode('_', $model->slug);
$result = ModelForTestSluggable::whereSlug($hash . '_some_wrong_text')->first();
$this->assertEquals($model->id, $result->id);
}
/**
* @test
*/
public function canGetModelsBySlugs(): void
{
$modelFirst = ModelForTestSluggable::create();
$modelMiddle = ModelForTestSluggable::create();
ModelForTestSluggable::create();
$models = ModelForTestSluggable::whereSlugs([$modelFirst->slug, $modelMiddle->slug])
->orderBy('id')
->get();
$this->assertCount(2, $models);
$this->assertEquals($models[0]->slug, $modelFirst->slug);
$this->assertEquals($models[1]->slug, $modelMiddle->slug);
}
/**
* @test
*/
public function canGetModelsByHashSlugs(): void
{
$modelFirst = ModelForTestSluggable::create();
$modelMiddle = ModelForTestSluggable::create();
ModelForTestSluggable::create();
[$hashFirst] = explode('_', $modelFirst->slug);
[$hashMiddle] = explode('_', $modelMiddle->slug);
$models = ModelForTestSluggable::whereSlugs([$hashFirst, $hashMiddle])
->orderBy('id')
->get();
$this->assertCount(2, $models);
$this->assertEquals($models[0]->slug, $modelFirst->slug);
$this->assertEquals($models[1]->slug, $modelMiddle->slug);
}
/**
* @test
*/
public function canGetModelsByOldSlug(): void
{
$modelFirst = ModelForTestSluggable::create();
$modelMiddle = ModelForTestSluggable::create();
ModelForTestSluggable::create();
[$hashFirst] = explode('_', $modelFirst->slug);
[$hashMiddle] = explode('_', $modelMiddle->slug);
$models = ModelForTestSluggable::whereSlugs([
$hashFirst.'_some_old',
$hashMiddle.'_some_old',
])
->orderBy('id')
->get();
$this->assertCount(2, $models);
$this->assertEquals($models[0]->slug, $modelFirst->slug);
$this->assertEquals($models[1]->slug, $modelMiddle->slug);
}
/**
* @test
*/
public function canUpdateSlug()
{
$originalName = 'original name';
$newName = 'new name';
$model = ModelForTestSluggable::create([
'name' => $originalName,
]);
$model->name = $newName;
$model->save();
$expectedSlug = Squid::encode($model->id) . '_' . Str::slug($newName);
$this->assertEquals($expectedSlug, $model->slug);
}
/**
* @test
*/
public function canResolveRouteBindingBySlug()
{
$model = ModelForTestSluggable::create();
$controlModel = ModelForTestSluggable::create();
$result = $model->resolveRouteBinding($model->slug);
$this->assertEquals($model->id, $result->id);
$controlResult = $model->resolveRouteBinding($controlModel->slug);
$this->assertEquals($controlModel->id, $controlResult->id);
}
/**
* @test
*/
public function canResolveRouteBindingByHashOnly()
{
$model = ModelForTestSluggable::create();
[$hash] = explode('_', $model->slug);
$result = $model->resolveRouteBinding($hash);
$this->assertEquals($model->id, $result->id);
}
/**
* @test
*/
public function canResolveRouteBindingByOldSlug()
{
$model = ModelForTestSluggable::create();
[$hash] = explode('_', $model->slug);
$result = $model->resolveRouteBinding($hash . '_some_wrong_text');
$this->assertEquals($model->id, $result->id);
}
/**
* @test
*
* @throws Throwable
*/
public function canHandleResolveRouteBindingForTotExistingEntity()
{
$this->expectException(ModelNotFoundException::class);
$model = ModelForTestSluggable::create();
$model->resolveRouteBinding('some_fake_slug');
}
/**
* @test
*/
public function canHandleEmptySlugOnWhereSlug()
{
ModelForTestSluggable::create();
$result = ModelForTestSluggable::whereSlug()->first();
$this->assertNull($result);
}
}
<?php
namespace Tests\Unit\Traits\Sluggable\Resource;
use App\Traits\Sluggable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class ModelForTestSluggable extends Model
{
use Sluggable;
public $guarded = ['id'];
public $timestamps = false;
public static function booted(): void
{
Schema::dropIfExists('model_for_test_sluggables');
Schema::create('model_for_test_sluggables', function (Blueprint $table) {
$table->id();
$table->string('name')->nullable();
$table->string('slug')->unique();
});
}
protected function slugSource(): ?string
{
return 'name';
}
}
<?php
namespace Tests\Unit\Traits\Sluggable\Resource;
use App\Traits\Sluggable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class ModelForTestSluggableWithoutSource extends Model
{
use Sluggable;
public $guarded = ['id'];
public $timestamps = false;
public static function booted(): void
{
Schema::dropIfExists('model_for_test_sluggable_without_sources');
Schema::create('model_for_test_sluggable_without_sources', function (Blueprint $table) {
$table->id();
$table->string('slug')->unique();
});
}
protected function slugSource(): ?string
{
return null;
}
}