17. ID vs UUID vs ULID. Laravel slug with edit option. Booting a trait. Sqids vs Hashids
Video version
(Leave your feedback on YouTube)
Never, NEVER expose numerical IDs on the front end.
IDOR vulnerability is not resolved by hiding IDs. However, it does reduce the immediate damage from the vulnerability.
Numerical IDs reveal the real statistics of your project, so it's important to hide them from regular users.
Squid converts an array of integers into a unique string. The string can be decrypted even without knowing the alphabet. Therefore, do not use it for transmitting sensitive data.
With ULID and newer UUID specifications, you can retrieve the creation date of the record.
Problems with numerical ID replication exist but are not critical.
Eloquent allows using boot in traits. Just name the function
{TraitName}Boot
.
Think about the links to your site when developing a slug.
If the slug can be edited, it is important for SEO to have a single link to the page. Therefore, redirect old URLs to the new one with a 302 status.
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;
}
}