'boolean', 'published_at' => 'datetime', 'views' => 'integer', ]; // ── Relations ──────────────────────────────────────────────────────── public function author() { return $this->belongsTo(StoryAuthor::class, 'author_id'); } public function tags() { return $this->belongsToMany(StoryTag::class, 'stories_tag_relation', 'story_id', 'tag_id'); } // ── Scopes ─────────────────────────────────────────────────────────── public function scopePublished($query) { return $query->where('status', 'published') ->where(fn ($q) => $q->whereNull('published_at')->orWhere('published_at', '<=', now())); } public function scopeFeatured($query) { return $query->where('featured', true); } // ── Accessors ──────────────────────────────────────────────────────── public function getUrlAttribute(): string { return url('/stories/' . $this->slug); } public function getCoverUrlAttribute(): ?string { if (! $this->cover_image) { return null; } return str_starts_with($this->cover_image, 'http') ? $this->cover_image : asset($this->cover_image); } /** * Estimated reading time in minutes based on word count. */ public function getReadingTimeAttribute(): int { $wordCount = str_word_count(strip_tags((string) $this->content)); return max(1, (int) ceil($wordCount / 200)); } /** * Short excerpt for meta descriptions / cards. * Strips HTML, truncates to ~160 characters. */ public function getMetaExcerptAttribute(): string { $text = $this->excerpt ?: strip_tags((string) $this->content); return \Illuminate\Support\Str::limit($text, 160); } }