Skip to content

Instantly share code, notes, and snippets.

@azzazkhan
Last active November 27, 2023 17:45
Show Gist options
  • Save azzazkhan/c88d456b83ee8d0b18b90fe443f09959 to your computer and use it in GitHub Desktop.
Save azzazkhan/c88d456b83ee8d0b18b90fe443f09959 to your computer and use it in GitHub Desktop.
Self-healing URL trait inspired by Aaron Francis
<?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),
};
}
}
<?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