slug)) { $model->slug = static::uniqueSlugFrom($model->name); } }); static::deleted(function (self $model): void { static::vacateSlug($model); }); } /** * @param int|null $ignoreId the row being renamed, which must not * collide with the slug it already holds */ public static function uniqueSlugFrom(string $name, ?int $ignoreId = null): string { $base = Str::slug($name) ?: static::slugFallback(); $slug = $base; $suffix = 2; $collides = fn (string $candidate): bool => static::query()->withTrashed() ->where('slug', $candidate) ->when($ignoreId !== null, fn (Builder $query) => $query->whereKeyNot($ignoreId)) ->exists(); while ($collides($slug)) { $slug = $base.'-'.$suffix; $suffix++; } return $slug; } /** * Hands a soft-deleted row's slug back to the names still in use. * * Written through the query builder on purpose: this is bookkeeping, not * an edit somebody made, so it must not move updated_at or fire a second * round of model events on a row that is already on its way out. */ protected static function vacateSlug(self $model): void { // A row that is really gone took its slug with it. `deleted` fires // for forceDelete() too, and there is nothing left to rewrite. if ($model->isForceDeleting()) { return; } if (blank($model->slug)) { return; } $vacated = VacatedSlug::for($model->slug, $model->getKey()); if ($vacated === $model->slug) { return; } static::query()->withTrashed()->whereKey($model->getKey())->toBase()->update(['slug' => $vacated]); // Keep the in-memory row telling the truth for whatever the caller // does with it after the delete returns. $model->slug = $vacated; $model->syncOriginalAttribute('slug'); } /** * Stands in when the name slugs to nothing at all — a name of only * punctuation or of characters Str::slug() drops entirely. */ abstract protected static function slugFallback(): string; }