$title * @property array $slug */ #[Fillable([ 'category_id', 'author_id', 'byline_member_id', 'title', 'slug', 'excerpt', 'body', 'meta_title', 'meta_description', 'status', 'featured_image', 'featured_image_alt', 'is_featured', 'published_at', 'unpublish_at', ])] class Page extends Model { /** @use HasFactory */ use BumpsContentVersion, HasContentViews, HasFactory, HasImages, HasPublicationWindow, HasReviews, HasRevisions, HasTranslations, RecordsSlugChanges, SoftDeletes; /** * Columns stored as a locale => value JSON map. * * @var array */ public array $translatable = [ 'title', 'slug', 'excerpt', 'body', 'meta_title', 'meta_description', // What the featured image shows, described in each language. 'featured_image_alt', ]; /** * Mirrors the column default so `status` is a PageStatus from the moment a * model is instantiated. Relying on the database default alone leaves the * attribute null until the row is re-read, which anything reading * `$page->status->value` would trip over. * * @var array */ protected $attributes = [ 'status' => PageStatus::Draft->value, ]; protected function casts(): array { return [ 'status' => PageStatus::class, 'is_featured' => 'boolean', 'published_at' => 'datetime', 'unpublish_at' => 'datetime', ]; } /** * Whether the site is showing this right now — what "published" means for * a page, which has both a status and a window. */ public function isPublishedNow(): bool { return $this->status === PageStatus::Published && ! $this->isScheduled() && ! $this->hasExpired(); } /** * A page is the one model here whose status can already say "in review", * and that value is shown in the table and offered as a filter. Asking for * a review writes it, so the column keeps telling the truth; sending the * work back returns it to a draft. * * Only ever called from the review methods, which is what stops the two * from disagreeing. An archived page is left alone: it is not a draft, and * silently turning it into one would throw away a deliberate decision. */ protected function syncReviewStatus(bool $awaiting): void { if ($awaiting) { if ($this->status === PageStatus::Draft) { $this->status = PageStatus::Review; } return; } if ($this->status === PageStatus::Review) { $this->status = PageStatus::Draft; } } /** * The `site.paths` key this model publishes under, so a slug change knows * which URL it just broke. */ public function publicPathType(): string { return 'page'; } /** * @return array */ public function imageAttributes(): array { return ['featured_image']; } /** * @return BelongsTo */ public function featuredMedia(): BelongsTo { return $this->imageRelation('featured_image'); } /** * @return BelongsTo */ public function category(): BelongsTo { return $this->belongsTo(Category::class); } /** * @return BelongsTo */ public function author(): BelongsTo { return $this->belongsTo(User::class, 'author_id'); } /** * Who signs it on the site: a member, with a profile to link to. Not the * account that typed it in, which is `author` and stays in the panel. * * @return BelongsTo */ public function byline(): BelongsTo { return $this->belongsTo(Member::class, 'byline_member_id'); } /** * @return BelongsToMany */ public function tags(): BelongsToMany { return $this->belongsToMany(Tag::class); } /** * Publicly visible pages: published and not scheduled for the future. * * @param Builder<$this> $query * @return Builder<$this> */ public function scopePublished(Builder $query): Builder { return $query ->where('status', PageStatus::Published) ->where(fn (Builder $q) => $q ->whereNull('published_at') ->orWhere('published_at', '<=', now())) // Both dates are enforced in one place, so nothing that reads // published content has to remember the second one. ->where(fn (Builder $q) => $q ->whereNull('unpublish_at') ->orWhere('unpublish_at', '>', now())); } /** * Match a slug within one locale. Slugs live inside a JSON map, so this * targets the locale's key rather than the column as a whole. * * @param Builder<$this> $query * @return Builder<$this> */ public function scopeWhereSlug(Builder $query, string $slug, ?string $locale = null): Builder { $locale ??= app()->getLocale(); return $query->where("slug->{$locale}", $slug); } /** * Restrict to records that actually carry a translation for a locale — * used by the API's `?translated_in=ca` filter so clients can avoid * fallback content. * * @param Builder<$this> $query * @return Builder<$this> */ public function scopeTranslatedIn(Builder $query, string $locale): Builder { return $query ->whereNotNull("title->{$locale}") ->where("title->{$locale}", '!=', ''); } }