'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'; } /** * Resolve a category by a content-type slug and a category path (e.g. "audio/winamp"). * This will locate the category with the final slug and verify its parent chain * matches the provided path and that the category belongs to the given content type. * * @param string $contentTypeSlug * @param string|array $categoryPath * @return Category|null */ public static function findByPath(string $contentTypeSlug, $categoryPath): ?Category { $parts = is_array($categoryPath) ? array_values(array_map('strtolower', array_filter($categoryPath))) : array_values(array_map('strtolower', array_filter(explode('/', (string) $categoryPath)))); if (empty($parts)) { return null; } $last = end($parts); $category = static::where('slug', $last) ->whereHas('contentType', function ($q) use ($contentTypeSlug) { $q->where('slug', strtolower($contentTypeSlug)); }) ->first(); if (! $category) { return null; } // Verify parent chain matches the preceding parts in the path $idx = count($parts) - 2; $current = $category; while ($idx >= 0) { $parent = $current->parent; if (! $parent || $parent->slug !== $parts[$idx]) { return null; } $current = $parent; $idx--; } return $category; } }