Files
SkinbaseNova/docs/SkinbaseNova_SN-CAT-001_Multi-Category_Artwork.md
T
klevze 6b00652a20 Allow artworks to have one primary category and extra secondaries.
Keep artwork_category as membership, store a deterministic primary_category_id, and let upload plus studio pick up to five additional same-type categories without changing canonical URLs.
2026-09-20 14:50:12 +02:00

25 KiB
Raw Blame History

SkinbaseNova — Multi-Category Artwork Classification

Milestone: SN-CAT-001
Status: Implemented
Scope: Categories, artwork classification, upload flow, Nova AI suggestions, API, SEO, migration, tests
Date: 2026-09-20


1. Objective

Upgrade SkinbaseNova so that an artwork can belong to more than one category while preserving one unambiguous primary category.

The final model must support:

  • exactly one content type per artwork;
  • one primary category where a category is required;
  • zero to five secondary categories by default;
  • all selected categories belonging to the same content type as the artwork;
  • existing canonical artwork URLs;
  • deterministic breadcrumbs and SEO;
  • category browsing through all memberships;
  • Nova AI category suggestions;
  • backward-compatible migration from the current single-category application behavior.

This feature must not turn categories into tags. Categories remain a curated taxonomy with strong semantic meaning. Tags continue to provide fine-grained descriptors.


2. Current State

The existing database is already partly prepared for multi-category artwork classification.

Relevant tables

categories

The category taxonomy contains, among other fields:

  • id
  • content_type_id
  • parent_id
  • name
  • slug

The taxonomy is scoped by content type.

A category slug can therefore exist under different content types, for example:

  • Wallpapers / Landscape
  • Photography / Landscape

These are distinct category records.

artwork_category

The existing pivot table already models an artwork-to-category many-to-many relationship.

Conceptually:

artworks
    ↕
artwork_category
    ↕
categories

The composite primary key prevents duplicate memberships:

PRIMARY KEY (artwork_id, category_id)

The current schema contains a foreign key for category_id -> categories.id, but the implementation should verify whether a matching FK exists for:

artwork_category.artwork_id -> artworks.id

If missing, it must be added with ON DELETE CASCADE.

uploads

The current upload staging model contains a single:

uploads.category_id

This reflects the current application's single-category behavior and must be adapted.

artwork_ai_assists

The existing AI-assist structure contains:

category_suggestions_json

This is already suitable for multiple ranked category suggestions and should be reused rather than replaced unless the current code proves otherwise.


3. Terminology

Do not call categories "groups" in this implementation.

Skinbase has separate concepts and terminology.

Use:

Content Type
Category
Primary Category
Secondary Category
Tag

Example:

Artwork: Morning Coffee in Venice

Content Type:
  Wallpapers

Primary Category:
  Cityscape

Secondary Categories:
  Architecture
  Travel

Tags:
  Venice
  Italy
  Grand Canal
  coffee
  sunrise
  golden hour

4. Product Rules

4.1 Content type

An artwork belongs to one content type.

Examples:

  • Wallpapers
  • Photography
  • Digital Art
  • Skins

Multi-category classification must not implicitly make one artwork belong to multiple content types.

4.2 Primary category

An artwork has at most one primary category.

The primary category is used for:

  • breadcrumb generation;
  • primary classification in artwork UI;
  • default SEO/category context;
  • legacy single-category compatibility;
  • admin/editor display;
  • any place that requires one deterministic category.

4.3 Secondary categories

An artwork may have multiple secondary categories.

Default application rule:

1 primary category
0–5 secondary categories

The exact maximum should be configurable rather than hard-coded throughout the application.

Suggested config:

'max_secondary_categories' => 5,

Per-content-type overrides may be introduced if the existing architecture already has an appropriate configuration mechanism.

For example, the application may eventually choose stricter rules for skin formats than for wallpapers or photography.

4.4 Same-content-type invariant

Every selected category must belong to the same content type as the artwork.

Valid:

Content Type: Wallpapers

Primary:
  Wallpapers / Cityscape

Secondary:
  Wallpapers / Architecture
  Wallpapers / Sci-Fi

Invalid:

Content Type: Wallpapers

Primary:
  Wallpapers / Cityscape

Secondary:
  Photography / Landscape
  Digital Art / 3D

This invariant must be enforced server-side.

Never rely only on frontend filtering.

4.5 Categories are not tags

Do not permit arbitrary category spam.

Categories are broad curated browse/classification signals.

Tags remain the detailed semantic descriptors.

A category must not be automatically created merely because an AI suggestion contains an unknown label.


5. Target Data Model

5.1 artworks.primary_category_id

Add a nullable primary category reference:

ALTER TABLE artworks
    ADD COLUMN primary_category_id BIGINT UNSIGNED NULL;

Add an indexed FK to categories.id.

Expected behavior:

artworks.primary_category_id
    -> categories.id

Recommended deletion behavior:

  • prefer ON DELETE SET NULL for artworks.primary_category_id;
  • category deletion logic should separately remove the pivot membership through the existing pivot FK/cascade behavior.

The implementation must follow existing project migration conventions.

Why store the primary category on artworks?

The pivot remains the canonical set of memberships, while primary_category_id gives the application a deterministic and efficient answer to:

Which category represents this artwork in a single-category context?

This avoids ordering-dependent behavior such as:

$artwork->categories->first()

which is not a valid definition of "primary".

5.2 artwork_category

Keep the existing many-to-many table.

Add or verify:

FK artwork_id -> artworks.id ON DELETE CASCADE
FK category_id -> categories.id ON DELETE CASCADE

Retain the composite uniqueness:

PRIMARY KEY (artwork_id, category_id)

Consider adding metadata if it is consistent with existing project conventions:

source
confidence
created_at
updated_at

Suggested semantics:

source:
  user
  ai
  moderator
  system
  migration

confidence should be nullable and should only have meaning for machine-generated or machine-assisted classification.

Do not add unnecessary metadata if the current application already stores equivalent provenance elsewhere.

5.3 Membership invariant

If:

artworks.primary_category_id = X

then the row:

(artwork_id, X)

must exist in artwork_category.

All writes must preserve this invariant.

5.4 Legacy category_source

If artworks.category_source currently describes the single selected category, do not remove it in the first deployment unless all usages have been audited.

Prefer a staged migration:

  1. support the new model;
  2. migrate provenance to the new relation if appropriate;
  3. update all readers/writers;
  4. deprecate the legacy field;
  5. remove it only in a later cleanup migration.

6. Central Domain Service

Do not distribute category synchronization logic across controllers, jobs and model observers.

Create or extend one domain/service-layer entry point, for example:

ArtworkCategoryService

Suggested operation:

sync(
    Artwork $artwork,
    int $primaryCategoryId,
    array $secondaryCategoryIds = [],
    ?string $source = null
): void

The exact class/signature should follow current SkinbaseNova architecture.

The service must

  1. load all requested categories;
  2. reject nonexistent IDs;
  3. remove duplicates;
  4. verify the primary category is included exactly once;
  5. validate the category content type against the artwork;
  6. enforce the configured secondary-category limit;
  7. update the pivot and primary_category_id in one DB transaction;
  8. preserve or update provenance metadata correctly;
  9. leave the artwork in a valid state if any operation fails;
  10. emit existing domain events/cache invalidation/search-index updates if required by the codebase.

Controllers, upload finalization, admin editing, imports and AI acceptance should call this service rather than implementing their own sync behavior.


7. Eloquent Model Changes

Audit the existing models before changing them.

The final Artwork model should conceptually support:

public function categories(): BelongsToMany
{
    return $this->belongsToMany(Category::class);
}

public function primaryCategory(): BelongsTo
{
    return $this->belongsTo(Category::class, 'primary_category_id');
}

The exact pivot configuration must match the existing schema.

Audit for problematic patterns such as:

$artwork->categories()->first();
$artwork->categories->first();
sync([$categoryId]);
firstOrFail();

where the code assumes there can only be one category.

Replace such assumptions only where they are category-related; do not make unrelated refactors.


8. Upload Flow

8.1 UX

The upload/edit UI should present:

Primary category
[ single select ]

Additional categories
[ multi-select, max 3 ]

Requirements:

  • secondary selection excludes the primary category;
  • only categories from the selected content type are available;
  • changing content type must revalidate/clear incompatible selections;
  • duplicate selections are impossible;
  • the UI displays the configured maximum;
  • server validation remains authoritative.

Suggested copy:

Primary category
Choose the category that best represents the artwork.

Additional categories
Optional. Add up to 5 other relevant categories.

8.2 Upload staging schema

Audit how the current uploads.category_id is used before changing it.

Preferred target model:

uploads.primary_category_id

plus either:

upload_category
---------------
upload_id
category_id

or another existing staging mechanism that cleanly supports multiple category IDs.

Do not introduce a second competing staging architecture if the project already has reusable draft metadata storage.

8.3 Safe migration strategy

A staged approach is acceptable:

Phase A

  • existing uploads.category_id continues to mean primary category;
  • add secondary-category staging support;
  • update finalization to use the central category service.

Phase B

  • rename/deprecate uploads.category_id to primary_category_id if appropriate;
  • remove compatibility code after all writers/readers are migrated.

Prefer deployability and rollback safety over a large destructive migration.


9. Nova AI Category Suggestions

Reuse the existing AI-assist category suggestion facility where possible.

Suggested normalized suggestion payload:

[
  {
    "category_id": 123,
    "name": "Cityscape",
    "confidence": 0.96
  },
  {
    "category_id": 456,
    "name": "Architecture",
    "confidence": 0.89
  }
]

The implementation should prefer IDs from the known taxonomy rather than free-form names.

AI rules

Nova may:

  • rank existing categories;
  • suggest a likely primary category;
  • suggest secondary categories;
  • show confidence where available.

Nova must not:

  • create new categories automatically;
  • silently replace a user's confirmed primary category;
  • attach categories from another content type;
  • exceed the configured maximum.

Initial rollout should require explicit user/editor confirmation for AI category changes.

Future auto-accept behavior can be added separately after measuring accuracy.


10. API Contract

Audit current endpoints and serializers/resources.

The API should expose both the deterministic primary category and all category memberships.

Conceptual response:

{
  "primary_category": {
    "id": 10,
    "name": "Cityscape",
    "slug": "cityscape"
  },
  "categories": [
    {
      "id": 10,
      "name": "Cityscape",
      "slug": "cityscape",
      "is_primary": true
    },
    {
      "id": 12,
      "name": "Architecture",
      "slug": "architecture",
      "is_primary": false
    }
  ]
}

If the current public API has a singular legacy category property:

  • keep it backward-compatible;
  • map it to the primary category;
  • add the plural structure additively;
  • document deprecation only if the project intends to remove the singular field later.

For create/update operations, prefer an explicit contract such as:

{
  "primary_category_id": 10,
  "secondary_category_ids": [12, 18]
}

This is clearer than relying on array order.


11. Category Pages and Browsing

A category page such as:

/wallpapers/cityscape

must include every artwork attached to that category, regardless of whether it is the artwork's primary or secondary category.

Conceptually query through:

artwork_category

not only:

artworks.primary_category_id

This is the main discovery benefit of the feature.

Review:

  • pagination;
  • category counts;
  • sorting;
  • cache keys;
  • search indexing;
  • sitemap generation;
  • related-artwork queries;
  • category statistics.

Any category count that currently assumes one category per artwork must be audited.


12. Artwork Page, Breadcrumbs and SEO

12.1 Canonical artwork URL

Do not change the canonical artwork URL.

Keep the current model:

/art/{id}/{slug}

An artwork must not gain a different canonical URL for every category.

12.2 Breadcrumb

Use the primary category only.

Example:

Home
→ Wallpapers
→ Cityscape
→ Morning Coffee in Venice

Do not select the breadcrumb category by pivot ordering.

12.3 Category display

Artwork UI may display:

Category
Cityscape

Also in
Architecture
Travel

or a combined component where the primary category is visibly distinguished.

12.4 Structured data

If the project emits BreadcrumbList or other structured data:

  • use the primary category path;
  • preserve one stable breadcrumb path;
  • avoid multiple competing breadcrumb definitions for the same artwork.

Secondary category links are beneficial discovery links, but they must all point to their normal category pages.

All category pages must link back to the same canonical artwork URL.


13. Parent Categories

The schema supports categories.parent_id.

Do not automatically duplicate membership into ancestor categories unless the existing taxonomy explicitly requires this.

Example:

Nature
└── Flowers

If the artwork is directly classified as:

Flowers

store the direct membership once.

If the Nature category page is intended to include descendants, compute or query descendant membership rather than writing redundant pivot rows.

Audit current category hierarchy behavior before changing it.


14. Existing Data Migration

The migration must not make arbitrary semantic choices silently.

14.1 Preflight report

Before backfilling primary_category_id, inspect current production data.

Required report:

SELECT
    artwork_id,
    COUNT(*) AS category_count
FROM artwork_category
GROUP BY artwork_id
HAVING COUNT(*) > 1
ORDER BY category_count DESC;

Distribution:

SELECT
    category_count,
    COUNT(*) AS artworks
FROM (
    SELECT
        artwork_id,
        COUNT(*) AS category_count
    FROM artwork_category
    GROUP BY artwork_id
) x
GROUP BY category_count
ORDER BY category_count;

Also report artworks with no category if category membership is expected for their content type.

14.2 Backfill rules

For artwork with exactly one category:

primary_category_id = that category

For artwork with zero categories:

primary_category_id = NULL

and include it in an audit report if the content type normally requires a category.

For artwork with more than one category:

  • do not silently use MIN(category_id), first() or database row order as semantic truth;
  • first inspect whether existing application data provides a deterministic primary source;
  • if no reliable source exists, produce an actionable report for explicit resolution.

An Artisan command is preferred for migration/preflight because it can support dry-run and reporting.

Suggested capabilities:

--dry-run
--report
--apply

Follow the project's command naming conventions.

14.3 Idempotency

The migration/backfill process must be safe to run more than once.

Never overwrite an already-confirmed primary category unless explicitly requested.


15. Database Integrity

Verify/add appropriate indexes.

At minimum:

artworks.primary_category_id
artwork_category (artwork_id, category_id) PK/unique
artwork_category category lookup index if not already covered

Verify both pivot FKs.

Application-level validation must enforce:

  • primary is a member of the pivot;
  • same content type;
  • category-count limit.

If project conventions use database constraints or triggers for these invariants, evaluate them, but avoid introducing database-specific complexity without a strong reason.


16. Search and Recommendation Systems

Audit all consumers of artwork categories.

Potential consumers include:

  • full-text/search indexing;
  • Meilisearch/Elasticsearch/OpenSearch if present;
  • recommendation scoring;
  • related artworks;
  • Nova embeddings/classification;
  • feeds;
  • filters;
  • analytics;
  • category statistics.

Suggested representation in a search document:

{
  "primary_category_id": 10,
  "category_ids": [10, 12, 18]
}

Recommendation systems may weight primary and secondary membership differently, for example:

primary category: strong signal
secondary category: medium signal
tags: fine-grained signal
embeddings: semantic similarity signal

Do not introduce arbitrary scoring changes in this milestone unless the existing recommendation architecture clearly needs them. Make the data available first.


17. Admin / Moderation

Admin/editor UI should clearly show:

Primary
Secondary
Source
AI confidence

where those metadata exist.

Editors must be able to:

  • replace the primary category;
  • add/remove secondary categories;
  • promote a secondary category to primary;
  • see incompatible selections rejected;
  • save everything transactionally.

When promoting a secondary category to primary, the previous primary may remain as a secondary category unless the editor removes it.


18. Caching and Invalidation

Audit cached data related to:

  • artwork detail;
  • category pages;
  • category artwork counts;
  • breadcrumbs;
  • related artwork;
  • search documents.

Changing category membership must invalidate/rebuild all affected representations.

A change from:

Cityscape

to:

Primary: Architecture
Secondary: Cityscape

must invalidate both relevant category views as well as the artwork detail/breadcrumb cache.

Use existing cache/event patterns rather than creating a parallel mechanism.


19. Authorization and Security

The new fields must use the same authorization rules as the current category edit action.

Do not allow users to assign:

  • inaccessible/private taxonomy entries;
  • disabled categories;
  • categories belonging to another content type;
  • more than the allowed maximum.

Validate IDs server-side.

Avoid mass-assignment of unvalidated category arrays.


20. Tests

Add tests at the level appropriate to the current project.

Database / model

  • artwork can have multiple categories;
  • duplicate pivot membership is rejected/prevented;
  • deleting an artwork removes pivot rows;
  • deleting a category handles pivot rows and primary reference safely;
  • primary category relationship works.

Domain service

  • one primary only;
  • primary is always in the pivot;
  • secondary categories sync correctly;
  • duplicate IDs are normalized/rejected consistently;
  • category from another content type is rejected;
  • more than configured secondary categories is rejected;
  • transaction rolls back on failure;
  • source/confidence metadata is preserved if implemented.

API

  • legacy singular category still maps to primary where required;
  • plural categories are returned;
  • create/update accepts primary + secondaries;
  • invalid cross-content-type categories return validation errors;
  • maximum is enforced.

Upload

  • primary category selection works;
  • multiple secondary categories persist through staging/finalization;
  • changing content type invalidates incompatible categories;
  • upload finalization uses central domain service.

Category pages

  • artwork appears on its primary category page;
  • artwork also appears on secondary category pages;
  • pagination/counts do not duplicate the same artwork;
  • category URL behavior remains unchanged.

Artwork page / SEO

  • canonical artwork URL remains unchanged;
  • breadcrumb uses the primary category;
  • changing secondary categories does not change breadcrumb;
  • promoting a secondary category updates breadcrumb;
  • structured data uses the primary path.

AI assist

  • suggestions are limited to known categories;
  • suggestions are filtered to matching content type;
  • AI cannot exceed the maximum;
  • confirmed user selection is not silently overwritten.

Migration

  • zero-category artwork is handled;
  • one-category artwork is backfilled deterministically;
  • multi-category legacy artwork is reported instead of arbitrarily resolved;
  • rerunning backfill is idempotent.

21. Backward Compatibility

This implementation must not break existing public artwork/category URLs.

Do not change:

/art/{id}/{slug}

Do not change existing category route structure merely for this feature.

If legacy API/UI code expects a singular category, map that concept to:

primary_category

during the compatibility period.

Prefer additive schema/API changes first, cleanup later.


22. Observability / Audit

During rollout, log or report:

  • artwork with no primary category where one is required;
  • primary category missing from pivot;
  • cross-content-type memberships;
  • artwork exceeding the configured category limit;
  • migration conflicts;
  • failed AI suggestion mappings.

If the project already has health/audit commands, integrate with them rather than creating unnecessary infrastructure.


23. Rollout Plan

Phase 1 — Audit

  • inspect current code paths;
  • inspect current data distribution;
  • identify all single-category assumptions;
  • confirm missing/existing foreign keys;
  • confirm API compatibility requirements.

Phase 2 — Additive database changes

  • add artworks.primary_category_id;
  • add/verify pivot integrity;
  • add pivot metadata only if needed;
  • add upload staging support.

Phase 3 — Domain logic

  • centralize category sync;
  • update models;
  • update validation;
  • update events/cache/search integration.

Phase 4 — Backfill

  • run dry-run report;
  • automatically backfill artworks with exactly one category;
  • explicitly resolve any legacy multi-category conflicts;
  • verify invariants.

Phase 5 — UI/API

  • upload editor;
  • artwork editor/admin;
  • API resources;
  • artwork display;
  • category pages;
  • breadcrumb/SEO.

Phase 6 — Nova integration

  • reuse ranked category suggestions;
  • support primary + secondary acceptance;
  • preserve explicit user control.

Phase 7 — Cleanup

Only after production verification:

  • remove obsolete single-category assumptions;
  • deprecate/remove legacy fields where safe;
  • update documentation.

24. Acceptance Criteria

The milestone is complete only when all of the following are true:

  • One artwork can belong to multiple categories.
  • Exactly one selected category can be marked as primary.
  • Primary category is always present in artwork_category.
  • Secondary category limit is enforced server-side.
  • Cross-content-type category assignment is impossible.
  • Existing artwork URLs remain unchanged.
  • Breadcrumb uses only the primary category.
  • Artwork is discoverable through every assigned category page.
  • Category pages do not duplicate an artwork.
  • Existing single-category API behavior remains compatible where required.
  • Upload/edit flow supports primary + secondary categories.
  • Nova category suggestions work with the new model.
  • Existing data can be migrated without arbitrary silent primary selection.
  • Missing artwork_category.artwork_id FK is fixed if confirmed absent.
  • Cache/search/index consumers are updated.
  • Automated tests cover the new invariants.
  • No unrelated refactors are included.
  • Documentation reflects the final implementation.

25. Definition of Done

Before declaring the milestone finished:

  1. run the complete relevant automated test suite;
  2. run lint/static analysis/type checks used by the repository;
  3. run the migration/backfill in dry-run mode against representative data;
  4. verify at least one artwork with one primary and two secondary categories;
  5. verify that the artwork appears on all three category pages;
  6. verify its canonical URL is unchanged;
  7. verify breadcrumb follows only the primary category;
  8. verify a cross-content-type assignment is rejected;
  9. verify category deletion/artwork deletion preserves DB integrity;
  10. document migration evidence, test results and any compatibility decisions.

Do not mark the feature complete if the database supports multiple categories but any main write path still collapses them back to one.