|null $og_data * @property array|null $twitter_data * @property array|null $schema_markup * @property string|null $robots * @property string|null $focus_keyword * @property int|null $seo_score * @property array|null $seo_issues * @property array|null $seo_suggestions * @property Carbon|null $created_at * @property Carbon|null $updated_at */ class SeoMetadata extends Model { protected $table = 'seo_metadata'; protected $fillable = [ 'seoable_type', 'seoable_id', 'title', 'description', 'canonical_url', 'og_data', 'twitter_data', 'schema_markup', 'robots', 'focus_keyword', 'seo_score', 'seo_issues', 'seo_suggestions', ]; protected $casts = [ 'og_data' => 'array', 'twitter_data' => 'array', 'seo_issues' => 'array', 'seo_suggestions' => 'array', 'seo_score' => 'integer', // Note: schema_markup uses lazy loading via accessor - not cast here ]; /** * Cached parsed schema markup (lazy loaded). * * @var array|null */ protected ?array $parsedSchemaMarkup = null; /** * Whether schema markup has been loaded. */ protected bool $schemaMarkupLoaded = false; /** * Attributes that should be deferred (not loaded eagerly). * * @var array */ protected array $deferredAttributes = ['schema_markup']; /** * Get the schema markup with lazy loading. * * The schema_markup is parsed only when first accessed, improving * performance for queries that don't need the schema data. * * @return array|null */ public function getSchemaMarkupAttribute(): ?array { if ($this->schemaMarkupLoaded) { return $this->parsedSchemaMarkup; } $this->schemaMarkupLoaded = true; $rawValue = $this->attributes['schema_markup'] ?? null; if ($rawValue === null) { $this->parsedSchemaMarkup = null; return null; } // If it's already an array (from direct assignment), return it if (is_array($rawValue)) { $this->parsedSchemaMarkup = $rawValue; return $this->parsedSchemaMarkup; } // Parse JSON string $decoded = json_decode($rawValue, true); $this->parsedSchemaMarkup = is_array($decoded) ? $decoded : null; return $this->parsedSchemaMarkup; } /** * Set the schema markup attribute. * * @param array|string|null $value */ public function setSchemaMarkupAttribute(array|string|null $value): void { // Reset the lazy loading cache $this->parsedSchemaMarkup = null; $this->schemaMarkupLoaded = false; if ($value === null) { $this->attributes['schema_markup'] = null; return; } if (is_array($value)) { $this->attributes['schema_markup'] = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); return; } // Assume it's already a JSON string $this->attributes['schema_markup'] = $value; } /** * Check if schema markup is loaded without triggering lazy load. */ public function isSchemaMarkupLoaded(): bool { return $this->schemaMarkupLoaded; } /** * Check if this model has schema markup (without fully parsing it). */ public function hasSchemaMarkup(): bool { return ! empty($this->attributes['schema_markup'] ?? null); } /** * Get the parent seoable model. */ public function seoable(): MorphTo { return $this->morphTo(); } /** * Generate JSON-LD script tag. * * Uses JSON_HEX_TAG to prevent XSS via in content. */ public function getJsonLdAttribute(): string { if (empty($this->schema_markup)) { return ''; } return ''; } /** * Generate all meta tags as HTML. */ public function getMetaTagsAttribute(): string { $tags = []; if ($this->title) { $tags[] = ''.e($this->title).''; } if ($this->description) { $tags[] = ''; } if ($this->canonical_url) { $tags[] = ''; } if ($this->robots) { $tags[] = ''; } // Open Graph tags if (! empty($this->og_data)) { foreach ($this->og_data as $property => $content) { if ($content) { $tags[] = ''; } } } // Twitter Card tags if (! empty($this->twitter_data)) { foreach ($this->twitter_data as $name => $content) { if ($content) { $tags[] = ''; } } } return implode("\n ", $tags); } /** * Get SEO score colour for UI display. */ public function getScoreColorAttribute(): string { if ($this->seo_score === null) { return 'zinc'; } return match (true) { $this->seo_score >= 80 => 'green', $this->seo_score >= 50 => 'amber', default => 'red', }; } /** * Check if there are issues to address. */ public function hasIssues(): bool { return ! empty($this->seo_issues); } /** * Get the count of issues. */ public function getIssueCountAttribute(): int { return count($this->seo_issues ?? []); } /** * Validate Open Graph image dimensions. * * @param bool $fetchRemote Whether to fetch remote images * @return array{valid: bool, errors: array, warnings: array, dimensions: array{width: int|null, height: int|null}} */ public function validateOgImage(bool $fetchRemote = true): array { $validator = new Validation\OgImageValidator; return $validator->validateOgData($this->og_data); } /** * Check if the OG image meets minimum requirements. */ public function hasValidOgImage(): bool { return $this->validateOgImage()['valid']; } /** * Get OG image validation warnings. * * @return array */ public function getOgImageWarnings(): array { return $this->validateOgImage()['warnings']; } /** * Validate the canonical URL format. * * @return array{valid: bool, errors: array, warnings: array} */ public function validateCanonicalUrl(): array { if (empty($this->canonical_url)) { return [ 'valid' => true, 'errors' => [], 'warnings' => ['No canonical URL specified'], ]; } $validator = new Validation\CanonicalUrlValidator; return $validator->validateUrl($this->canonical_url); } /** * Check if this canonical URL conflicts with other records. * * @return array{has_conflict: bool, count: int, records: Collection} */ public function checkCanonicalConflict(): array { if (empty($this->canonical_url)) { return [ 'has_conflict' => false, 'count' => 0, 'records' => collect(), ]; } $validator = new Validation\CanonicalUrlValidator; $result = $validator->checkUrl($this->canonical_url); // Exclude self from conflict check $otherRecords = $result['records']->filter(fn ($r) => $r->id !== $this->id); return [ 'has_conflict' => $otherRecords->isNotEmpty(), 'count' => $otherRecords->count(), 'records' => $otherRecords, ]; } /** * Check if the canonical URL is valid and has no conflicts. */ public function hasValidCanonicalUrl(): bool { $validation = $this->validateCanonicalUrl(); $conflict = $this->checkCanonicalConflict(); return $validation['valid'] && ! $conflict['has_conflict']; } /** * Record the current score for trend tracking. * * @param bool $force Force recording even if within minimum interval * @return Models\SeoScoreHistory|null The created record or null if skipped */ public function recordScore(bool $force = false): ?Models\SeoScoreHistory { $trend = app(Analytics\SeoScoreTrend::class); return $trend->recordScore($this, $force); } /** * Get score history for this metadata. * * @param int $limit Maximum records to return * @return Collection */ public function getScoreHistory(int $limit = 100): Collection { $trend = app(Analytics\SeoScoreTrend::class); return $trend->getHistory($this, $limit); } /** * Get daily score trend for this metadata. * * @param int $days Days to look back * @return Collection */ public function getDailyScoreTrend(int $days = 30): Collection { $trend = app(Analytics\SeoScoreTrend::class); return $trend->getDailyTrend($this, $days); } /** * Get weekly score trend for this metadata. * * @param int $weeks Weeks to look back * @return Collection */ public function getWeeklyScoreTrend(int $weeks = 12): Collection { $trend = app(Analytics\SeoScoreTrend::class); return $trend->getWeeklyTrend($this, $weeks); } /** * Check if score has improved since last recording. * * @return bool|null True if improved, false if declined, null if no history */ public function hasScoreImproved(): ?bool { $latest = Models\SeoScoreHistory::latestForModel( $this->seoable_type, $this->seoable_id ); if ($latest === null) { return null; } return $this->seo_score > $latest->score; } /** * Get the score change since last recording. * * @return int|null Change amount or null if no history */ public function getScoreChange(): ?int { $latest = Models\SeoScoreHistory::latestForModel( $this->seoable_type, $this->seoable_id ); if ($latest === null) { return null; } return $this->seo_score - $latest->score; } /** * Validate the structured data (schema markup). * * @return array{valid: bool, errors: array, warnings: array, info: array, rich_results: array, types_found: array} */ public function validateStructuredData(): array { if (! $this->hasSchemaMarkup()) { return [ 'valid' => true, 'errors' => [], 'warnings' => [['code' => 'no_schema', 'message' => 'No structured data defined', 'path' => 'schema_markup', 'fix' => 'Add schema.org structured data to improve SEO.']], 'info' => [], 'rich_results' => [], 'types_found' => [], ]; } $tester = new Validation\StructuredDataTester; return $tester->test($this->schema_markup); } /** * Get a detailed structured data report. * * @return array{summary: array, types: array, rich_results: array, errors: array, warnings: array, recommendations: array, score: int} */ public function getStructuredDataReport(): array { if (! $this->hasSchemaMarkup()) { return [ 'summary' => ['valid' => true, 'error_count' => 0, 'warning_count' => 1], 'types' => [], 'rich_results' => [], 'errors' => [], 'warnings' => [[ 'code' => 'no_schema', 'message' => 'No structured data defined', 'path' => 'schema_markup', 'explanation' => 'Structured data helps search engines understand your content.', 'fix' => 'Add schema.org structured data to improve SEO and enable rich results.', ]], 'recommendations' => ['Add schema.org structured data to enable rich results in search.'], 'score' => 50, ]; } $tester = new Validation\StructuredDataTester; return $tester->generateReport($this->schema_markup); } /** * Check if this page is eligible for rich results. * * @return array List of eligible rich result features */ public function getRichResultsEligibility(): array { if (! $this->hasSchemaMarkup()) { return []; } $tester = new Validation\StructuredDataTester; return $tester->checkRichResultsEligibility($this->schema_markup); } }