'boolean']; /** * Ensure slug is always lowercase and valid before saving. */ protected static function boot() { parent::boot(); static::saving(function (Category $model) { if (isset($model->slug)) { $model->slug = strtolower($model->slug); if (!preg_match('/^[a-z0-9-]+$/', $model->slug)) { throw new \InvalidArgumentException('Category slug must be lowercase and contain only a-z, 0-9, and dashes.'); } } }); } public function contentType(): BelongsTo { return $this->belongsTo(ContentType::class); } public function parent(): BelongsTo { return $this->belongsTo(Category::class, 'parent_id'); } public function children(): HasMany { return $this->hasMany(Category::class, 'parent_id') ->orderBy('sort_order')->orderBy('name'); } public function descendants(): HasMany { return $this->children()->with('descendants'); } public function seo(): HasOne { return $this->hasOne(CategorySeo::class); } public function artworks(): BelongsToMany { return $this->belongsToMany(Artwork::class, 'artwork_category'); } public function scopeActive($query) { return $query->where('is_active', true); } public function scopeRoots($query) { return $query->whereNull('parent_id'); } public function getFullSlugPathAttribute(): string { return $this->parent ? $this->parent->full_slug_path . '/' . $this->slug : $this->slug; } /** * Get the full public URL for this category (authoritative spec). * Example: /photography/abstract/dark */ public function getUrlAttribute(): string { $contentTypeSlug = strtolower($this->contentType->slug); $path = strtolower($this->full_slug_path); return '/' . $contentTypeSlug . ($path ? '/' . $path : ''); } /** * Get the canonical URL for SEO (authoritative spec). * Example: https://skinbase.org/photography/abstract/dark */ public function getCanonicalUrlAttribute(): string { return 'https://skinbase.org' . $this->url; } public function getBreadcrumbsAttribute(): array { return $this->parent ? array_merge($this->parent->breadcrumbs, [$this]) : [$this]; } public function getRouteKeyName(): string { return 'slug'; } }