'boolean', 'published_at' => 'datetime', 'scheduled_for' => 'datetime', 'submitted_for_review_at' => 'datetime', 'reviewed_at' => 'datetime', 'views' => 'integer', 'likes_count' => 'integer', 'comments_count' => 'integer', 'reading_time' => 'integer', ]; // ── Relations ──────────────────────────────────────────────────────── public function creator(): BelongsTo { return $this->belongsTo(User::class, 'creator_id'); } // Legacy alias used by older views/controllers. public function author(): BelongsTo { return $this->creator(); } public function tags(): BelongsToMany { return $this->belongsToMany(StoryTag::class, 'relation_story_tags', 'story_id', 'tag_id'); } public function storyViews(): HasMany { return $this->hasMany(StoryView::class, 'story_id'); } public function storyLikes(): HasMany { return $this->hasMany(StoryLike::class, 'story_id'); } // ── Scopes ─────────────────────────────────────────────────────────── public function scopePublished($query) { return $query ->where(function ($q): void { $q->where('status', 'published') ->orWhere(function ($scheduled): void { $scheduled->where('status', 'scheduled') ->whereNotNull('published_at') ->where('published_at', '<=', now()); }); }) ->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 { if (! empty($this->attributes['reading_time'])) { return max(1, (int) $this->attributes['reading_time']); } $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); } }