Last active
November 27, 2023 17:45
-
-
Save azzazkhan/c88d456b83ee8d0b18b90fe443f09959 to your computer and use it in GitHub Desktop.
Self-healing URL trait inspired by Aaron Francis
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
namespace App\Models\Content; | |
use App\Concerns\{HasLikes, HasSelfHealingUrls, HasUlid, Reportable, Searchable}; | |
use App\Traits\Book\{HasRelations, HasScopes, HasAttributes}; | |
use Illuminate\Database\Eloquent\Factories\HasFactory; | |
use Illuminate\Database\Eloquent\Model; | |
use Illuminate\Database\Eloquent\SoftDeletes; | |
use Illuminate\Support\Carbon; | |
use Spatie\Sitemap\Contracts\Sitemapable; | |
use Spatie\Sitemap\Tags\Url; | |
use Staudenmeir\EloquentHasManyDeep\HasRelationships as HasManyDeepRelationships; | |
class Book extends Model implements Sitemapable | |
{ | |
use HasFactory, | |
HasUlid, | |
SoftDeletes, | |
HasAttributes, | |
HasScopes, | |
HasRelations, | |
HasManyDeepRelationships, | |
HasLikes, | |
Searchable, | |
Reportable, | |
HasSelfHealingUrls; | |
/** | |
* The attributes that are mass assignable. | |
* | |
* @var array<string> | |
*/ | |
protected $fillable = [ | |
// ... | |
]; | |
/** | |
* The attributes that should be hidden. | |
* | |
* @var array<string> | |
*/ | |
protected $hidden = [ | |
// ... | |
]; | |
/** | |
* The attributes that should be cast. | |
* | |
* @var array<string, string> | |
*/ | |
protected $casts = [ | |
// ... | |
]; | |
/** | |
* Generate sitemap representation of this model. | |
* | |
* @return \Spatie\Sitemap\Tags\Url|string|array | |
*/ | |
public function toSitemapTag(): Url | string | array | |
{ | |
return Url::create(route('books.show', $this->getSlug())) | |
->setChangeFrequency(Url::CHANGE_FREQUENCY_DAILY) | |
->setLastModificationDate(Carbon::create($this->revised_at ?: $this->updated_at)); | |
} | |
/** | |
* Get the legacy static slug column which will be matched when resolving | |
* the model. | |
* | |
* @return string|null | |
*/ | |
public static function getLegacySlugColumn(): ?string | |
{ | |
return 'slug'; | |
} | |
/** | |
* Retrieve the child model for a bound value. | |
* | |
* @param string $childType | |
* @param mixed $value | |
* @param string|null $field | |
* @return \Illuminate\Database\Eloquent\Model|null | |
*/ | |
public function resolveChildRouteBinding($childType, $value, $field) | |
{ | |
return match ($childType) { | |
// Chapter also has the `HasSelfHealingUrls` concern and the concern is overriding | |
// `resolveRouteBinding` method with an additional third parameter that accepts a | |
// Eloquent builder or relationship instance which we can pass to enforce scoped bindings | |
// of child models like we're passing `$this->chapters()` | |
'chapter' => app(Chapter::class)->resolveRouteBinding($value, $field, $this->chapters()), | |
default => parent::resolveChildRouteBinding($childType, $value, $field), | |
}; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
namespace App\Concerns; | |
use Illuminate\Database\Eloquent\Builder; | |
use Illuminate\Database\Eloquent\Casts\Attribute; | |
use Illuminate\Database\Eloquent\Model; | |
use Illuminate\Database\Eloquent\Relations\Relation; | |
use Illuminate\Http\Response; | |
trait HasSelfHealingUrls | |
{ | |
/** | |
* Get the database column which will hold the unique slug key. | |
* | |
* @return string | |
*/ | |
protected static function getSlugKeyColumn(): string | |
{ | |
return 'uid'; | |
} | |
/** | |
* Get the attribute name which will be used to generate modal slug. | |
* | |
* @return string | |
*/ | |
protected function getSlugTitleColumn(): string | |
{ | |
return 'name'; | |
} | |
/** | |
* The slug title and key separator. | |
* | |
* @return string | |
*/ | |
protected static function getSlugSeparator(): string | |
{ | |
return '_'; | |
} | |
/** | |
* The attribute name which will trigger route key binding resolution via | |
* model slug. | |
* | |
* @return string | |
*/ | |
protected static function getSlugResolutionKey(): string | |
{ | |
return 'slug'; | |
} | |
/** | |
* Returns the permalink slug. | |
* | |
* @return string|null | |
*/ | |
public function getSlug(): ?string | |
{ | |
if (($title = $this->getSlugTitle()) && ($key = $this->getSlugKey())) { | |
return $title . static::getSlugSeparator() . $key; | |
} | |
return $this->getAttribute($this->getLegacySlugColumn()) ?: $this->getKey(); | |
} | |
/** | |
* Get the slug title from specified model attribute. | |
* | |
* @return string|null | |
*/ | |
protected function getSlugTitle(): ?string | |
{ | |
$title = $this->{$this->getSlugTitleColumn()}; | |
return $title ? slugify($title, words: 10) : null; | |
} | |
/** | |
* Get the slug key from specified model attribute. | |
* | |
* @return string|null | |
*/ | |
protected function getSlugKey(): ?string | |
{ | |
return $this->{static::getSlugKeyColumn()}; | |
} | |
/** | |
* Resolves the model instance through provided model slug. | |
* | |
* @param string $slug | |
* @param \Illuminate\Database\Eloquent\Relations\Relation|\Illuminate\Database\Eloquent\Builder|null $query | |
* @return static|null | |
*/ | |
public static function getModelThroughSlug(string $slug, Relation|Builder $query = null): static|null | |
{ | |
return static::getModelSlugResolvingQuery($slug, $query)->first(); | |
} | |
/** | |
* Get the Eloquent query for resolving the model through provided slug. | |
* | |
* @param string $slug | |
* @param \Illuminate\Database\Eloquent\Relations\Relation|\Illuminate\Database\Eloquent\Builder|null $query | |
* @return \Illuminate\Database\Eloquent\Relations\Relation|\Illuminate\Database\Eloquent\Builder | |
*/ | |
public static function getModelSlugResolvingQuery(string $slug, Relation|Builder $query = null): Relation|Builder | |
{ | |
/** @var \Illuminate\Database\Eloquent\Relations\Relation|\Illuminate\Database\Eloquent\Builder */ | |
$query = $query ?: static::query(); | |
return $query->where(function (Relation|Builder $query) use (&$slug) { | |
$legacyColumn = static::getLegacySlugColumn(); | |
$model = app(static::class); | |
$table = $model->getTable(); | |
$key = $model->getKeyName(); | |
$query | |
->when($legacyColumn, fn (Relation|Builder $query) => $query->where("{$table}.{$legacyColumn}", $slug)) | |
->orWhere(prefix_table($table, static::getSlugKeyColumn())[0], last(explode(static::getSlugSeparator(), $slug))) | |
// If we compare the original value with ID of the model and if | |
// the ID of model is integer based then MySQL with collapse | |
// the provided value into integer breaking model resolution | |
// logic. | |
// For example if provided slug is `6ebb8054` then MySQL | |
// will compare the ID column as (WHERE `id` = 6) | |
// truncating rest of the string which is invalid | |
// !Note: We're assuming the primary key will be numeric | |
->when(is_numeric($slug), fn (Relation|Builder $query) => $query->orWhere($key, $slug)); | |
}); | |
} | |
/** | |
* Get the legacy static slug column which will be matched when resolving | |
* the model. | |
* | |
* @return string|null | |
*/ | |
protected static function getLegacySlugColumn(): ?string | |
{ | |
return null; | |
} | |
/** | |
* Generates a random key used for model identification in provided slug. | |
* | |
* @return string | |
*/ | |
public static function generateSlugKey(): string | |
{ | |
return hash('crc32b', microtime()) . substr(hash('crc32b', microtime()), 0, 2); | |
} | |
/** | |
* Reroutes the request to appropriate URL. | |
* | |
* @param string $provided | |
* @param string $original | |
* @return never | |
*/ | |
protected static function reroute(string $parameterValue, string $actualValue): never | |
{ | |
$route = request()->route(); | |
$originalParameters = $route->originalParameters(); | |
$paramName = collect($originalParameters)->search(fn ($value) => $parameterValue === $value); | |
$url = route( | |
$route->getName(), | |
[...$originalParameters, $paramName => $actualValue] | |
); | |
abort(redirect($url, status: Response::HTTP_PERMANENTLY_REDIRECT)); | |
} | |
/** | |
* Retrieve the model for a bound value. | |
* | |
* @param mixed $value | |
* @param string|null $field | |
* @param \Illuminate\Database\Eloquent\Relations\Relation|\Illuminate\Database\Eloquent\Builder|null $query | |
* @return \Illuminate\Database\Eloquent\Model|null | |
*/ | |
public function resolveRouteBinding($value, $field = null, Relation|Builder $query = null) | |
{ | |
// Pass the resolution logic parent if we're not resolving the model | |
// by provided slug key | |
if ($field !== static::getSlugResolutionKey()) { | |
return parent::resolveRouteBinding($value, $field); | |
} | |
$model = static::getModelThroughSlug($value, $query); | |
// We can safely return null and Laravel will handle the not found | |
// exception for us | |
if (!$model || $model->getSlug() === $value) { | |
return $model; | |
} | |
// Redirect to appropriate URL if the provided slug does not matches | |
// with the intended one | |
$this->reroute($value, $model->getSlug()); | |
} | |
/** | |
* Hooks custom event into Eloquent model for persisting unique slug key. | |
* | |
* @return void | |
*/ | |
public static function bootHasSelfHealingUrls(): void | |
{ | |
static::creating(function (Model $model) { | |
$key = ($model->{static::getSlugKeyColumn()} = static::generateSlugKey()); | |
// Generate a new slug key if one already exists | |
while (static::query()->where(static::getSlugKeyColumn(), $key)->exists()) { | |
$key = ($model->{static::getSlugKeyColumn()} = static::generateSlugKey()); | |
} | |
}); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment