*/ protected array $revisionIgnores = ['updated_at', 'created_at', 'remember_token']; public static function bootHasRevisions(): void { static::created(fn (Model $model) => $model->recordRevision('created')); static::updated(function (Model $model): void { $changes = $model->revisionChanges(); if ($changes === []) { return; } // Publishing gets its own name in the history. It is a separate // permission because it is a bigger act than editing, and a list // where it reads the same as any other change hides exactly the // thing somebody came looking for. $model->recordRevision($model->revisionEvent($changes), $changes); }); static::deleted(fn (Model $model) => $model->recordRevision( method_exists($model, 'isForceDeleting') && $model->isForceDeleting() ? 'erased' : 'deleted', )); if (method_exists(static::class, 'restored')) { static::restored(fn (Model $model) => $model->recordRevision('restored')); } } /** * @return MorphMany */ public function revisions(): MorphMany { // Ordered by id as well as by time: two revisions of the same // record routinely land in the same second — create, then edit — and // a timestamp alone leaves "latest" up to the database. SQLite and // Postgres disagreed, which is how this was found. return $this->morphMany(Revision::class, 'revisionable') ->orderByDesc('created_at') ->orderByDesc('id'); } /** * @param array|null $changes */ public function recordRevision(string $event, ?array $changes = null): void { $user = Auth::user(); $this->revisions()->create([ 'user_id' => $user?->getAuthIdentifier(), // Kept alongside the id because people leave and accounts get // deleted, and "somebody published this" is worth less than a name. 'user_name' => $user?->name, 'event' => $event, 'changeset' => $changes, ]); } /** * What changed on this save, old value beside new. * * @return array */ public function revisionChanges(): array { $changes = []; foreach ($this->getChanges() as $attribute => $new) { if (in_array($attribute, $this->revisionIgnores, true)) { continue; } $old = $this->getOriginal($attribute); // Casts turn these into objects and enums; the history is read as // text, and JSON is the only shape both ends agree on. $changes[$attribute] = [ 'old' => $this->revisionValue($old), 'new' => $this->revisionValue($new), ]; } return $changes; } /** * @param array $changes */ protected function revisionEvent(array $changes): string { // Which fields put this kind of record on the site: a project's // `status` is whether the work is active, not whether it is public, // and a service is published by a flag. $publication = AdminContent::publicationFields($this) ?: ['published_at', 'status']; if (array_intersect(array_keys($changes), $publication) === []) { return 'updated'; } // Ask the model itself: "published" means different things to a page // with a status and a project with only a date. if (! method_exists($this, 'isPublishedNow')) { return 'updated'; } // Before and after, because the change is what has a name: going live, // coming off, or being given a date — which is neither, and which a // history that only asked "is it live now?" called "unpublished". $before = (clone $this)->setRawAttributes($this->getRawOriginal()); $now = $this->isPublishedNow(); $was = $before->isPublishedNow(); return match (true) { $now && ! $was => 'published', ! $now && $was => 'unpublished', ! $now && method_exists($this, 'isScheduled') && $this->isScheduled() => 'scheduled', default => 'updated', }; } protected function revisionValue(mixed $value): mixed { return match (true) { $value instanceof \BackedEnum => $value->value, $value instanceof \DateTimeInterface => $value->format('c'), is_object($value) => json_decode((string) json_encode($value), true), is_string($value) && str_starts_with($value, '{') => json_decode($value, true) ?? $value, default => $value, }; } }