Files
SkinbaseNova/app/Models/Category.php
2026-02-14 15:14:12 +01:00

163 lines
4.4 KiB
PHP

<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\{BelongsTo, HasMany, BelongsToMany, HasOne};
use Illuminate\Database\Eloquent\SoftDeletes;
class Category extends Model
{
use SoftDeletes;
protected $fillable = [
'content_type_id','parent_id','name','slug',
'description','image','is_active','sort_order'
];
protected $casts = ['is_active' => '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;
}
}