Laravel 13 + Filament 5 admin panel and REST API for a non-profit developer collective, backed by the Pireon OAuth identity server. The public site is a separate, server-rendered application consuming /api/public/v1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
94 lines
3.1 KiB
PHP
94 lines
3.1 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Models\Concerns;
|
|
|
|
use App\Models\Redirect;
|
|
use App\Support\SiteUrls;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
|
|
/**
|
|
* Leaves a redirect behind when a slug changes.
|
|
*
|
|
* Renaming a slug breaks every link anybody shared, every bookmark and every
|
|
* search result — silently, because nothing notices and the frontend only
|
|
* finds out when a visitor gets a 404. The moment the slug changes is the only
|
|
* moment the old address is still known, so that is when the note is written.
|
|
*/
|
|
trait RecordsSlugChanges
|
|
{
|
|
/**
|
|
* The `site.paths` key this model publishes under.
|
|
*/
|
|
abstract public function publicPathType(): string;
|
|
|
|
public static function bootRecordsSlugChanges(): void
|
|
{
|
|
static::updated(function (Model $model): void {
|
|
if (! $model->wasChanged('slug')) {
|
|
return;
|
|
}
|
|
|
|
$model->recordSlugChange();
|
|
});
|
|
}
|
|
|
|
protected function recordSlugChange(): void
|
|
{
|
|
$type = $this->publicPathType();
|
|
$previous = (array) json_decode((string) json_encode($this->getOriginal('slug')), true);
|
|
$current = (array) $this->getTranslations('slug');
|
|
|
|
foreach (array_keys((array) config('locales.supported')) as $locale) {
|
|
$was = (string) ($previous[$locale] ?? '');
|
|
$now = (string) ($current[$locale] ?? '');
|
|
|
|
// Nothing to redirect from when the record had no address in this
|
|
// language, and nowhere to redirect to when it no longer has one.
|
|
if (blank($was) || blank($now) || $was === $now) {
|
|
continue;
|
|
}
|
|
|
|
$from = Redirect::normalise(SiteUrls::path($type, $locale, $was));
|
|
$to = Redirect::normalise(SiteUrls::path($type, $locale, $now));
|
|
|
|
if ($from === $to) {
|
|
continue;
|
|
}
|
|
|
|
$this->writeRedirect($from, $to, $locale);
|
|
}
|
|
}
|
|
|
|
protected function writeRedirect(string $from, string $to, string $locale): void
|
|
{
|
|
$scope = fn () => Redirect::query()->where('locale', $locale);
|
|
|
|
// The new address resolves to this record again, so any rule pointing
|
|
// away from it is now wrong — this is what renaming something back to
|
|
// its original slug looks like.
|
|
$scope()->where('from', $to)->delete();
|
|
|
|
// Whoever pointed at the old address now points at the new one, so a
|
|
// frontend never has to follow two hops.
|
|
$scope()->where('to', $from)->update(['to' => $to]);
|
|
|
|
$redirect = Redirect::query()->firstOrNew(['from' => $from, 'locale' => $locale]);
|
|
|
|
// `source` is set here rather than through mass assignment: it records
|
|
// what the application did, not anything somebody submitted.
|
|
$redirect->forceFill([
|
|
'to' => $to,
|
|
'status' => 301,
|
|
'is_enabled' => true,
|
|
'source_type' => $this->getMorphClass(),
|
|
'source_id' => $this->getKey(),
|
|
])->save();
|
|
|
|
// Belt and braces: a rule pointing at its own address would send a
|
|
// browser round for ever.
|
|
Redirect::query()->whereColumn('from', 'to')->delete();
|
|
}
|
|
}
|