Repository: RetroAchievements/RAWeb
Pull request #3527: feat: allow games to have alternative/regional titles and release dates
Author: login: wescopeland
Labels: name: deployment/command
description: Includes an Artisan command that needs to be run before/during deployment
Created: 2025-05-19T22:58:27Z | Merged: 2025-05-23T11:21:22Z

Implements https://github.com/RetroAchievements/RAWeb/discussions/1861.

This PR allows alternative titles and release dates to be attached to games through the management app:
![Screenshot 2025-05-23 at 8 13 59 AM](https://github.com/user-attachments/assets/0d417553-a13d-4775-af2c-988aba5e1777)

* All games _must_ have a single canonical title.
* The canonical title is displayed on game pages and game lists.
* Alternative titles and release dates can get a treatment on the game page sidebar once https://github.com/RetroAchievements/RAWeb/pull/3522 is merged.
* The game's release date now reflects the earliest release date in the list of releases.

Alternative titles are searchable using Scout (Search 2.0, currently unreleased but sitting on an admin-only demo page):
<img width="335" alt="Screenshot 2025-05-19 at 6 19 58 PM" src="https://github.com/user-attachments/assets/541d2b9e-c967-4920-be2b-c8fb9511acf3" />

**How to test this PR:**
```bash
sail artisan horizon # restart your horizon instance

sail artisan migrate # create the game_titles table
sail artisan ra:sync:game-releases # sync all canonical titles, which all receive a "Worldwide" region

sail artisan scout:sync-index-settings
sail artisan scout:import "App\Model\Game" # reindex games with their alt titles
```
In the management app, you should now be able to manipulate alternative titles for games.
On the Scout demo page, you should be able to search for games by their alt titles.

**Things to note:**
* Junior Developers can manipulate a game's alt titles as long as they are either the sole developer of a game or have a claim on the game.


Diff:
diff --git a/app/Filament/Resources/GameResource.php b/app/Filament/Resources/GameResource.php
index 6b495d8633..51646dff66 100644
--- a/app/Filament/Resources/GameResource.php
+++ b/app/Filament/Resources/GameResource.php
@@ -11,16 +11,15 @@
 use App\Filament\Resources\GameResource\RelationManagers\CoreSetAuthorshipCreditsRelationManager;
 use App\Filament\Resources\GameResource\RelationManagers\LeaderboardsRelationManager;
 use App\Filament\Resources\GameResource\RelationManagers\MemoryNotesRelationManager;
+use App\Filament\Resources\GameResource\RelationManagers\ReleasesRelationManager;
 use App\Filament\Rules\ExistsInForumTopics;
 use App\Filament\Rules\IsAllowedGuideUrl;
 use App\Models\Game;
 use App\Models\System;
 use App\Models\User;
 use App\Platform\Enums\AchievementFlag;
-use App\Platform\Enums\ReleasedAtGranularity;
 use Filament\Forms;
 use Filament\Forms\Form;
-use Filament\Forms\Get;
 use Filament\Infolists;
 use Filament\Infolists\Infolist;
 use Filament\Pages\Page;
@@ -30,7 +29,6 @@
 use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\SoftDeletingScope;
-use Illuminate\Support\Carbon;
 use Illuminate\Support\Facades\Auth;
 
 class GameResource extends Resource
@@ -151,43 +149,6 @@ public static function infolist(Infolist $infolist): Infolist
                             ->limit(30),
                     ]),
 
-                Infolists\Components\Section::make('Earliest Release Date')
-                    ->icon('heroicon-c-calendar-days')
-                    ->description("
-                        The game's earliest known release date. This is used to improve searching,
-                        sorting, and filtering on the site.
-                    ")
-                    ->columns(['md' => 2, 'xl' => 3, '2xl' => 4])
-                    ->schema([
-                        Infolists\Components\TextEntry::make('released_at')
-                            ->label('Earliest Release Date')
-                            ->placeholder('unknown')
-                            ->formatStateUsing(function (Game $game): string {
-                                $releasedAt = $game->released_at;
-                                $releasedAtGranularity = $game->released_at_granularity;
-
-                                if (!$releasedAt) {
-                                    return 'No release date.';
-                                }
-
-                                switch ($releasedAtGranularity) {
-                                    case 'year':
-                                        return Carbon::parse($releasedAt)->format('Y');
-
-                                    case 'month':
-                                        return Carbon::parse($releasedAt)->format('F Y');
-
-                                    default:
-                                        return Carbon::parse($releasedAt)->format('F j, Y');
-                                }
-                            }),
-
-                        Infolists\Components\TextEntry::make('released_at_granularity')
-                            ->label('Release Date Precision')
-                            ->placeholder('none')
-                            ->formatStateUsing(fn (ReleasedAtGranularity $state): string => ucfirst($state->value)),
-                    ]),
-
                 Infolists\Components\Section::make('Metrics')
                     ->icon('heroicon-s-arrow-trending-up')
                     ->description("
@@ -249,6 +210,7 @@ public static function form(Form $form): Form
                     ->columns(['md' => 2, 'xl' => 3, '2xl' => 4])
                     ->schema([
                         Forms\Components\TextInput::make('Title')
+                            ->label('Canonical Title')
                             ->required()
                             ->minLength(2)
                             ->maxLength(80)
@@ -297,63 +259,6 @@ public static function form(Form $form): Form
                             ->disabled(!$user->can('updateField', [$form->model, 'GuideURL'])),
                     ]),
 
-                Forms\Components\Section::make('Earliest Release Date')
-                    ->icon('heroicon-c-calendar-days')
-                    ->description("
-                        Provide the game's earliest known release date to improve searching, sorting, and filtering on the site.
-                        Use the Precision control to specify if the release day or month are unknown.
-                    ")
-                    ->columns(['md' => 2, 'xl' => 3, '2xl' => 4])
-                    ->schema([
-                        Forms\Components\Placeholder::make('released_at_display')
-                            ->label('Preview')
-                            ->columnSpan(['md' => 2, 'xl' => 3, '2xl' => 4])
-                            ->content(function (Get $get): string {
-                                $releasedAt = $get('released_at');
-                                $releasedAtGranularity = $get('released_at_granularity');
-
-                                if (!$releasedAt) {
-                                    return 'No release date.';
-                                }
-
-                                switch ($releasedAtGranularity) {
-                                    case 'year':
-                                        return Carbon::parse($releasedAt)->format('Y');
-
-                                    case 'month':
-                                        return Carbon::parse($releasedAt)->format('F Y');
-
-                                    default:
-                                        return Carbon::parse($releasedAt)->format('F j, Y');
-                                }
-                            }),
-
-                        Forms\Components\DatePicker::make('released_at')
-                            ->label('Earliest Release Date')
-                            ->native(false)
-                            ->minDate('1970-01-01')
-                            ->maxDate(now())
-                            ->displayFormat('F j, Y')
-                            ->reactive()
-                            ->afterStateUpdated(function (callable $set, $state) {
-                                // Set the granularity to 'day' if this is the first time released_at is set.
-                                if (!empty($state)) {
-                                    $set('released_at_granularity', 'day');
-                                }
-                            }),
-
-                        Forms\Components\ToggleButtons::make('released_at_granularity')
-                            ->label('Release Date Precision')
-                            ->options([
-                                'day' => 'Day',
-                                'month' => 'Month',
-                                'year' => 'Year',
-                            ])
-                            ->inline()
-                            ->reactive()
-                            ->required(fn (callable $get) => !empty($get('released_at'))),
-                    ]),
-
                 Forms\Components\Section::make('Media')
                     ->icon('heroicon-s-photo')
                     ->schema([
@@ -676,6 +581,7 @@ public static function getRelations(): array
         return [
             AchievementsRelationManager::class,
             AchievementSetsRelationManager::class,
+            ReleasesRelationManager::class,
             LeaderboardsRelationManager::class,
             MemoryNotesRelationManager::class,
             CoreSetAuthorshipCreditsRelationManager::class,
diff --git a/app/Filament/Resources/GameResource/RelationManagers/ReleasesRelationManager.php b/app/Filament/Resources/GameResource/RelationManagers/ReleasesRelationManager.php
new file mode 100644
index 0000000000..2776fd6fca
--- /dev/null
+++ b/app/Filament/Resources/GameResource/RelationManagers/ReleasesRelationManager.php
@@ -0,0 +1,270 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Filament\Resources\GameResource\RelationManagers;
+
+use App\Filament\Resources\GameResource;
+use App\Models\Game;
+use App\Models\GameRelease;
+use App\Models\User;
+use App\Platform\Enums\GameReleaseRegion;
+use App\Platform\Enums\ReleasedAtGranularity;
+use Filament\Forms;
+use Filament\Forms\Form;
+use Filament\Forms\Get;
+use Filament\Resources\RelationManagers\RelationManager;
+use Filament\Tables;
+use Filament\Tables\Table;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Support\Carbon;
+use Illuminate\Support\Facades\Auth;
+
+class ReleasesRelationManager extends RelationManager
+{
+    protected static string $relationship = 'releases';
+
+    protected static ?string $recordTitleAttribute = 'title';
+
+    public static function canViewForRecord(Model $ownerRecord, string $pageClass): bool
+    {
+        /** @var User $user */
+        $user = Auth::user();
+
+        if ($ownerRecord instanceof Game) {
+            return $user->can('manage', $ownerRecord);
+        }
+
+        return false;
+    }
+
+    public function form(Form $form): Form
+    {
+        return $form
+            ->schema([
+                Forms\Components\TextInput::make('title')
+                    ->required()
+                    ->maxLength(80)
+                    ->label('Title'),
+
+                Forms\Components\Toggle::make('is_canonical_game_title')
+                    ->label('Is Canonical Title')
+                    ->helperText('If enabled, this will be used as the main title for the game on game pages and game lists. Only one title per game can be canonical.')
+                    ->disabled(fn (?GameRelease $record) => $record && $record->is_canonical_game_title),
+
+                Forms\Components\Select::make('region')
+                    ->searchable()
+                    ->options([
+                        'Common Regions' => [
+                            GameReleaseRegion::NorthAmerica->value => GameReleaseRegion::NorthAmerica->label(),
+                            GameReleaseRegion::Japan->value => GameReleaseRegion::Japan->label(),
+                            GameReleaseRegion::Europe->value => GameReleaseRegion::Europe->label(),
+                            GameReleaseRegion::Worldwide->value => GameReleaseRegion::Worldwide->label(),
+                        ],
+                        'Other Regions' => collect(GameReleaseRegion::cases())
+                            ->filter(fn (GameReleaseRegion $region) => !in_array($region, [
+                                GameReleaseRegion::NorthAmerica,
+                                GameReleaseRegion::Japan,
+                                GameReleaseRegion::Europe,
+                                GameReleaseRegion::Worldwide,
+                            ]))
+                            ->mapWithKeys(fn (GameReleaseRegion $region) => [$region->value => $region->label()])
+                            ->toArray(),
+                    ])
+                    ->columnSpan(2)
+                    ->label('Region'),
+
+                Forms\Components\DatePicker::make('released_at')
+                    ->label('Release Date')
+                    ->native(false)
+                    ->minDate('1970-01-01')
+                    ->maxDate(now())
+                    ->displayFormat('F j, Y')
+                    ->reactive()
+                    ->afterStateUpdated(function (callable $set, $state) {
+                        // Set the granularity to 'day' if this is the first time released_at is set.
+                        if (!empty($state)) {
+                            $set('released_at_granularity', 'day');
+                        }
+                    }),
+
+                Forms\Components\ToggleButtons::make('released_at_granularity')
+                    ->label('Release Date Precision')
+                    ->options([
+                        ReleasedAtGranularity::Day->value => 'Day',
+                        ReleasedAtGranularity::Month->value => 'Month',
+                        ReleasedAtGranularity::Year->value => 'Year',
+                    ])
+                    ->inline()
+                    ->reactive()
+                    ->required(fn (callable $get) => !empty($get('released_at'))),
+
+                Forms\Components\Placeholder::make('released_at_display')
+                    ->label('Preview')
+                    ->content(function (Get $get): string {
+                        $releasedAt = $get('released_at');
+                        $releasedAtGranularity = $get('released_at_granularity');
+
+                        if (!$releasedAt) {
+                            return 'No release date.';
+                        }
+
+                        switch ($releasedAtGranularity) {
+                            case 'year':
+                                return Carbon::parse($releasedAt)->format('Y');
+
+                            case 'month':
+                                return Carbon::parse($releasedAt)->format('F Y');
+
+                            default:
+                                return Carbon::parse($releasedAt)->format('F j, Y');
+                        }
+                    }),
+            ]);
+    }
+
+    public function table(Table $table): Table
+    {
+        return $table
+            ->recordTitleAttribute('title')
+            ->columns([
+                Tables\Columns\TextColumn::make('released_at')
+                    ->label('Release Date')
+                    ->placeholder('unknown')
+                    ->formatStateUsing(function (GameRelease $gameRelease): ?string {
+                        $releasedAt = $gameRelease->released_at;
+                        $releasedAtGranularity = $gameRelease->released_at_granularity;
+
+                        if (!$releasedAt) {
+                            return null;
+                        }
+
+                        switch ($releasedAtGranularity) {
+                            case ReleasedAtGranularity::Year:
+                                return Carbon::parse($releasedAt)->format('Y');
+
+                            case ReleasedAtGranularity::Month:
+                                return Carbon::parse($releasedAt)->format('F Y');
+
+                            default:
+                                return Carbon::parse($releasedAt)->format('F j, Y');
+                        }
+                    })
+                    ->sortable(query: function ($query, string $direction): Builder {
+                        return $query
+                            /** @see BuildsGameListQueries.php */
+                            ->selectRaw(<<<SQL
+                                *,
+                                CASE
+                                    WHEN released_at_granularity = 'year' THEN
+                                        DATE(CONCAT(SUBSTR(released_at, 1, 4), '-01-01'))
+                                    WHEN released_at_granularity = 'month' THEN
+                                        DATE(CONCAT(SUBSTR(released_at, 1, 7), '-01'))
+                                    ELSE
+                                        COALESCE(released_at, '9999-12-31')
+                                END AS normalized_released_at,
+                                CASE released_at_granularity
+                                    WHEN 'year' THEN 1
+                                    WHEN 'month' THEN 2
+                                    WHEN 'day' THEN 3
+                                    ELSE 4
+                                END AS granularity_order
+                            SQL)
+                            // Ensure NULL release dates always sort to the end, regardless of sort direction.
+                            ->orderByRaw('released_at IS NULL')
+                            ->orderBy('normalized_released_at', $direction)
+                            ->orderBy('granularity_order', $direction);
+                    }),
+
+                Tables\Columns\TextColumn::make('region')
+                    ->formatStateUsing(function ($state): ?string {
+                        if (!$state) {
+                            return null;
+                        }
+
+                        if ($state instanceof GameReleaseRegion) {
+                            return $state->label();
+                        }
+
+                        return GameReleaseRegion::tryFrom($state)?->label() ?? $state;
+                    })
+                    ->sortable(),
+
+                Tables\Columns\TextColumn::make('title')
+                    ->sortable(),
+
+                Tables\Columns\IconColumn::make('is_canonical_game_title')
+                    ->label('Is Canonical Title')
+                    ->boolean()
+                    ->sortable(),
+            ])
+            ->filters([
+
+            ])
+            ->headerActions([
+                Tables\Actions\CreateAction::make()
+                    ->authorize(function () {
+                        /** @var User $user */
+                        $user = Auth::user();
+                        $game = $this->getOwnerRecord();
+
+                        return $user->can('create', [GameRelease::class, $game]);
+                    })
+                    ->mutateFormDataUsing(function (array $data) {
+                        // If this is marked as canonical, unmark any other canonical titles.
+                        if ($data['is_canonical_game_title'] ?? false) {
+                            GameRelease::where('game_id', $this->getOwnerRecord()->id)
+                                ->where('is_canonical_game_title', true)
+                                ->update(['is_canonical_game_title' => false]);
+                        }
+
+                        return $data;
+                    })
+                    ->after(function (GameRelease $record) {
+                        // If the new title is canonical, update the main game title.
+                        if ($record->is_canonical_game_title) {
+                            /** @var Game $game */
+                            $game = $this->getOwnerRecord();
+                            $game->Title = $record->title;
+                            $game->save();
+
+                            // Redirect to refresh the page and show the updated title.
+                            $this->redirect(GameResource::getUrl('view', ['record' => $game, 'activeRelationManager' => 2]));
+                        }
+                    }),
+            ])
+            ->actions([
+                Tables\Actions\EditAction::make()
+                    ->mutateFormDataUsing(function (array $data, GameRelease $record) {
+                        // If this is now marked as canonical and wasn't before, unmark any other canonical titles.
+                        if (($data['is_canonical_game_title'] ?? false) && !$record->is_canonical_game_title) {
+                            GameRelease::where('game_id', $this->getOwnerRecord()->id)
+                                ->where('id', '!=', $record->id)
+                                ->where('is_canonical_game_title', true)
+                                ->update(['is_canonical_game_title' => false]);
+                        }
+
+                        return $data;
+                    })
+                    ->after(function (GameRelease $record) {
+                        // If this title is canonical, update the main game title.
+                        if ($record->is_canonical_game_title) {
+                            /** @var Game $game */
+                            $game = $this->getOwnerRecord();
+                            $game->Title = $record->title;
+                            $game->save();
+
+                            // Redirect to refresh the page and show the updated title.
+                            $this->redirect(GameResource::getUrl('view', ['record' => $game, 'activeRelationManager' => 2]));
+                        }
+                    }),
+
+                Tables\Actions\DeleteAction::make(),
+            ])
+            ->bulkActions([
+
+            ])
+            ->defaultSort('released_at', 'asc');
+    }
+}
diff --git a/app/Helpers/database/game.php b/app/Helpers/database/game.php
index 52463544e4..13203f0378 100644
--- a/app/Helpers/database/game.php
+++ b/app/Helpers/database/game.php
@@ -632,6 +632,14 @@ function modifyGameTitle(string $username, int $gameId, string $value): bool
 
     if ($game->isDirty()) {
         $game->save();
+
+        // Update the canonical title in game_releases.
+        $canonicalTitle = $game->releases()->where('is_canonical_game_title', true)->first();
+        if ($canonicalTitle) {
+            $canonicalTitle->title = $value;
+            $canonicalTitle->save();
+        }
+
         addArticleComment('Server', ArticleType::GameModification, $gameId, "{$user->display_name} changed the game name");
     }
 
@@ -662,37 +670,6 @@ function modifyGameForumTopic(string $username, int $gameId, int $newForumTopicI
     return true;
 }
 
-function createNewGame(string $title, int $systemId): ?array
-{
-    try {
-        $game = new Game();
-        $game->Title = $title;
-        $game->ConsoleID = $systemId;
-        $game->ForumTopicID = null;
-        $game->Flags = 0;
-        $game->ImageIcon = '/Images/000001.png';
-        $game->ImageTitle = '/Images/000002.png';
-        $game->ImageIngame = '/Images/000002.png';
-        $game->ImageBoxArt = '/Images/000002.png';
-        $game->Publisher = null;
-        $game->Developer = null;
-        $game->Genre = null;
-        $game->RichPresencePatch = null;
-        $game->TotalTruePoints = 0;
-
-        $game->save();
-
-        return [
-            'ID' => $game->id,
-            'Title' => $title,
-        ];
-    } catch (Exception $e) {
-        Log::error('Failed to create new game', ['error' => $e->getMessage()]);
-
-        return null;
-    }
-}
-
 function submitNewGameTitleJSON(
     string $username,
     string $md5,
@@ -749,7 +726,14 @@ function submitNewGameTitleJSON(
             $game->TotalTruePoints = 0;
 
             $game->save();
-            // create an empty GameAchievementSet and AchievementSet
+
+            // Create the initial canonical title in game_releases.
+            $game->releases()->create([
+                'title' => $titleIn,
+                'is_canonical_game_title' => true,
+            ]);
+
+            // Create an empty GameAchievementSet and AchievementSet.
             (new UpsertGameCoreAchievementSetFromLegacyFlagsAction())->execute($game);
         }
 
diff --git a/app/Models/Game.php b/app/Models/Game.php
index 48bdf16c44..fb9ac18604 100644
--- a/app/Models/Game.php
+++ b/app/Models/Game.php
@@ -170,6 +170,15 @@ public static function boot()
                     $originalTitle,
                     $freshGame->title
                 );
+
+                // Update the canonical title in game_releases.
+                if (!$game->wasRecentlyCreated) {
+                    $canonicalTitle = $freshGame->releases()->where('is_canonical_game_title', true)->first();
+                    if ($canonicalTitle) {
+                        $canonicalTitle->title = $freshGame->title;
+                        $canonicalTitle->save();
+                    }
+                }
             }
 
             // Handle game badge changes.
@@ -292,9 +301,19 @@ public function registerMediaCollections(): void
 
     public function toSearchableArray(): array
     {
+        if (!$this->relationLoaded('releases')) {
+            $this->load('releases');
+        }
+
+        $altTitles = $this->releases
+            ->where('is_canonical_game_title', false)
+            ->pluck('title')
+            ->toArray();
+
         return [
             'id' => (int) $this->ID,
             'title' => $this->title,
+            'alt_titles' => $altTitles,
         ];
     }
 
@@ -676,6 +695,14 @@ public function playerSessions(): HasMany
         return $this->hasMany(PlayerSession::class, 'game_id');
     }
 
+    /**
+     * @return HasMany<GameRelease>
+     */
+    public function releases(): HasMany
+    {
+        return $this->hasMany(GameRelease::class, 'game_id', 'ID');
+    }
+
     /**
      * @return HasMany<GameAchievementSet>
      */
diff --git a/app/Models/GameRelease.php b/app/Models/GameRelease.php
new file mode 100644
index 0000000000..63cbb23dd1
--- /dev/null
+++ b/app/Models/GameRelease.php
@@ -0,0 +1,78 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Models;
+
+use App\Platform\Actions\SyncGameReleaseDatesAction;
+use App\Platform\Enums\GameReleaseRegion;
+use App\Platform\Enums\ReleasedAtGranularity;
+use App\Support\Database\Eloquent\BaseModel;
+use Database\Factories\GameReleaseFactory;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\SoftDeletes;
+
+class GameRelease extends BaseModel
+{
+    /** @use HasFactory<GameReleaseFactory> */
+    use HasFactory;
+    use SoftDeletes;
+
+    protected $table = 'game_releases';
+
+    // Re-index games on save.
+    protected $touches = ['game'];
+
+    protected $fillable = [
+        'game_id',
+        'released_at',
+        'released_at_granularity',
+        'title',
+        'region',
+        'is_canonical_game_title',
+    ];
+
+    protected $casts = [
+        'is_canonical_game_title' => 'boolean',
+        'region' => GameReleaseRegion::class,
+        'released_at_granularity' => ReleasedAtGranularity::class,
+        'released_at' => 'datetime',
+    ];
+
+    protected static function newFactory(): GameReleaseFactory
+    {
+        return GameReleaseFactory::new();
+    }
+
+    public static function boot()
+    {
+        parent::boot();
+
+        static::saved(function (GameRelease $gameRelease) {
+            // Sync game release dates whenever a GameRelease is created or updated
+            (new SyncGameReleaseDatesAction())->execute($gameRelease->game);
+        });
+
+        static::deleted(function (GameRelease $gameRelease) {
+            // Sync game release dates when a GameRelease is deleted
+            (new SyncGameReleaseDatesAction())->execute($gameRelease->game);
+        });
+    }
+
+    // == accessors
+
+    // == mutators
+
+    // == relations
+
+    /**
+     * @return BelongsTo<Game, GameRelease>
+     */
+    public function game(): BelongsTo
+    {
+        return $this->belongsTo(Game::class, 'game_id', 'ID');
+    }
+
+    // == scopes
+}
diff --git a/app/Platform/Actions/SyncGameReleaseDatesAction.php b/app/Platform/Actions/SyncGameReleaseDatesAction.php
new file mode 100644
index 0000000000..ccfab6ade1
--- /dev/null
+++ b/app/Platform/Actions/SyncGameReleaseDatesAction.php
@@ -0,0 +1,61 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Platform\Actions;
+
+use App\Models\Game;
+use App\Platform\Enums\ReleasedAtGranularity;
+
+class SyncGameReleaseDatesAction
+{
+    public function execute(Game $game): void
+    {
+        // Get all releases with dates and find the truly earliest one.
+        $releasesWithDates = $game->releases()
+            ->whereNotNull('released_at')
+            ->get();
+
+        $currentReleasedAt = $game->released_at;
+        $currentReleasedAtGranularity = $game->released_at_granularity;
+
+        if ($releasesWithDates->isEmpty()) {
+            // There aren't any releases with dates. Clear the game's release date.
+            $game->released_at = null;
+            $game->released_at_granularity = null;
+        } else {
+            // Taking granularity into account, find the earliest release date.
+            $earliestRelease = $releasesWithDates->sortBy(function ($release) {
+                $date = $release->released_at;
+
+                // Normalize dates based on granularity to find the true earliest.
+                switch ($release->released_at_granularity) {
+                    case ReleasedAtGranularity::Year:
+                        // Year granularity means beginning of that year.
+                        return $date->startOfYear();
+
+                    case ReleasedAtGranularity::Month:
+                        // Month granularity means beginning of that month.
+                        return $date->startOfMonth();
+
+                    case ReleasedAtGranularity::Day:
+                    default:
+                        // Day granularity uses the exact date.
+                        return $date->startOfDay();
+                }
+            })->first();
+
+            // Set the game's release date to the earliest release we could find.
+            $game->released_at = $earliestRelease->released_at;
+            $game->released_at_granularity = $earliestRelease->released_at_granularity;
+        }
+
+        // Only save if values actually changed.
+        if (
+            $currentReleasedAt != $game->released_at
+            || $currentReleasedAtGranularity != $game->released_at_granularity
+        ) {
+            $game->saveQuietly();
+        }
+    }
+}
diff --git a/app/Platform/AppServiceProvider.php b/app/Platform/AppServiceProvider.php
index e7abe354cd..a67851e7e1 100644
--- a/app/Platform/AppServiceProvider.php
+++ b/app/Platform/AppServiceProvider.php
@@ -37,6 +37,7 @@
 use App\Platform\Commands\SyncEvents;
 use App\Platform\Commands\SyncGameAchievementSets;
 use App\Platform\Commands\SyncGameHashes;
+use App\Platform\Commands\SyncGameReleases;
 use App\Platform\Commands\SyncGames;
 use App\Platform\Commands\SyncLeaderboards;
 use App\Platform\Commands\SyncLeaderboardTopEntries;
@@ -124,6 +125,7 @@ public function boot(): void
                 SyncGameAchievementSets::class,
                 SyncGameHashes::class,
                 SyncGames::class,
+                SyncGameReleases::class,
                 SyncLeaderboards::class,
                 SyncLeaderboardTopEntries::class,
                 SyncLegacyGameTags::class,
diff --git a/app/Platform/Commands/SyncGameReleases.php b/app/Platform/Commands/SyncGameReleases.php
new file mode 100644
index 0000000000..4e957ea7c1
--- /dev/null
+++ b/app/Platform/Commands/SyncGameReleases.php
@@ -0,0 +1,56 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Platform\Commands;
+
+use App\Models\Game;
+use App\Models\GameRelease;
+use App\Platform\Enums\GameReleaseRegion;
+use Illuminate\Console\Command;
+
+class SyncGameReleases extends Command
+{
+    protected $signature = 'ra:sync:game-releases';
+    protected $description = 'Syncs all GameData title and release date entries to the game_releases table';
+
+    public function handle(): void
+    {
+        $this->info('Starting game releases sync...');
+
+        $total = Game::count();
+        $bar = $this->output->createProgressBar($total);
+        $bar->start();
+
+        Game::chunk(1000, function ($games) use ($bar) {
+            foreach ($games as $game) {
+                $exists = GameRelease::where('game_id', $game->id)
+                    ->where('is_canonical_game_title', true)
+                    ->exists();
+
+                if (!$exists) {
+                    GameRelease::create([
+                        'game_id' => $game->id,
+
+                        'title' => $game->title,
+
+                        'released_at' => $game->released_at,
+                        'released_at_granularity' => $game->released_at && $game->released_at_granularity
+                                ? $game->released_at_granularity
+                                : null,
+
+                        'is_canonical_game_title' => true, // everything will initially be canonical
+                        'region' => GameReleaseRegion::Worldwide,
+                    ]);
+                }
+
+                $bar->advance();
+            }
+        });
+
+        $bar->finish();
+        $this->newLine(2);
+
+        $this->info("Done.");
+    }
+}
diff --git a/app/Platform/Enums/GameReleaseRegion.php b/app/Platform/Enums/GameReleaseRegion.php
new file mode 100644
index 0000000000..71b4d04288
--- /dev/null
+++ b/app/Platform/Enums/GameReleaseRegion.php
@@ -0,0 +1,39 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Platform\Enums;
+
+enum GameReleaseRegion: string
+{
+    case Asia = 'as';
+    case Australia = 'au';
+    case Brazil = 'br';
+    case China = 'cn';
+    case Europe = 'eu';
+    case Japan = 'jp';
+    case Korea = 'kr';
+    case NewZealand = 'nz';
+    case NorthAmerica = 'na';
+
+    case Worldwide = 'worldwide';
+    case Other = 'other';
+
+    public function label(): string
+    {
+        return match ($this) {
+            self::Asia => 'Asia (AS)',
+            self::Australia => 'Australia (AU)',
+            self::Brazil => 'Brazil (BR)',
+            self::China => 'China (CN)',
+            self::Europe => 'Europe (EU)',
+            self::Japan => 'Japan (JP)',
+            self::Korea => 'Korea (KR)',
+            self::NewZealand => 'New Zealand (NZ)',
+            self::NorthAmerica => 'North America (NA)',
+
+            self::Worldwide => 'Worldwide (WW)',
+            self::Other => 'Other',
+        };
+    }
+}
diff --git a/app/Policies/GamePolicy.php b/app/Policies/GamePolicy.php
index 802ea5ec64..27fbe637aa 100644
--- a/app/Policies/GamePolicy.php
+++ b/app/Policies/GamePolicy.php
@@ -165,7 +165,7 @@ public function viewDeveloperInterest(User $user, Game $game): bool
         ]);
     }
 
-    private function canDeveloperJuniorUpdateGame(User $user, Game $game): bool
+    public static function canDeveloperJuniorUpdateGame(User $user, Game $game): bool
     {
         // If the user has a DEVELOPER_JUNIOR role, they need to have a claim
         // on the game or be the sole author of its achievements to be able to
diff --git a/app/Policies/GameReleasePolicy.php b/app/Policies/GameReleasePolicy.php
new file mode 100644
index 0000000000..b9159b1d9f
--- /dev/null
+++ b/app/Policies/GameReleasePolicy.php
@@ -0,0 +1,118 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Policies;
+
+use App\Models\Game;
+use App\Models\GameRelease;
+use App\Models\Role;
+use App\Models\User;
+use Illuminate\Auth\Access\HandlesAuthorization;
+
+class GameReleasePolicy
+{
+    use HandlesAuthorization;
+
+    public function manage(User $user): bool
+    {
+        return $user->hasAnyRole([
+            Role::DEVELOPER_JUNIOR,
+            Role::DEVELOPER,
+            Role::GAME_EDITOR,
+            Role::GAME_HASH_MANAGER,
+        ]);
+    }
+
+    public function viewAny(?User $user): bool
+    {
+        return true;
+    }
+
+    public function view(?User $user, GameRelease $gameRelease): bool
+    {
+        return true;
+    }
+
+    public function create(User $user, ?Game $game = null): bool
+    {
+        $canAlwaysCreate = $user->hasAnyRole([
+            Role::DEVELOPER,
+            Role::GAME_EDITOR,
+            Role::GAME_HASH_MANAGER,
+        ]);
+
+        if ($canAlwaysCreate) {
+            return true;
+        }
+
+        // If the user has a DEVELOPER_JUNIOR role, they need to have a claim
+        // on the game or be the sole author of its achievements to be able to
+        // update any of its metadata.
+        if ($game && $user->hasRole(Role::DEVELOPER_JUNIOR)) {
+            return GamePolicy::canDeveloperJuniorUpdateGame($user, $game);
+        }
+
+        return false;
+    }
+
+    public function update(User $user, GameRelease $gameRelease): bool
+    {
+        $canAlwaysUpdate = $user->hasAnyRole([
+            Role::DEVELOPER,
+            Role::GAME_EDITOR,
+            Role::GAME_HASH_MANAGER,
+        ]);
+
+        if ($canAlwaysUpdate) {
+            return true;
+        }
+
+        // If the user has a DEVELOPER_JUNIOR role, they need to have a claim
+        // on the game or be the sole author of its achievements to be able to
+        // update any of its metadata.
+        if ($user->hasRole(Role::DEVELOPER_JUNIOR)) {
+            $gameRelease->loadMissing('game');
+
+            return GamePolicy::canDeveloperJuniorUpdateGame($user, $gameRelease->game);
+        }
+
+        return false;
+    }
+
+    public function delete(User $user, GameRelease $gameRelease): bool
+    {
+        // The canonical title can be changed, but cannot be deleted.
+        if ($gameRelease->is_canonical_game_title) {
+            return false;
+        }
+
+        $canAlwaysDelete = $user->hasAnyRole([
+            Role::DEVELOPER,
+            Role::GAME_EDITOR,
+            Role::GAME_HASH_MANAGER,
+        ]);
+
+        if ($canAlwaysDelete) {
+            return true;
+        }
+
+        if ($user->hasRole(Role::DEVELOPER_JUNIOR)) {
+            $gameRelease->loadMissing('game');
+
+            return GamePolicy::canDeveloperJuniorUpdateGame($user, $gameRelease->game);
+        }
+
+        return false;
+    }
+
+    public function restore(User $user, GameRelease $gameRelease): bool
+    {
+        return false;
+    }
+
+    public function forceDelete(User $user, GameRelease $gameRelease): bool
+    {
+        return false;
+    }
+}
diff --git a/config/scout.php b/config/scout.php
index afd4d02110..29676a2c19 100644
--- a/config/scout.php
+++ b/config/scout.php
@@ -167,7 +167,7 @@
 
             Game::class => [
                 'filterableAttributes' => ['id', 'title'],
-                'searchableAttributes' => ['title', 'id'],
+                'searchableAttributes' => ['title', 'alt_titles', 'id'],
                 'sortableAttributes' => ['id', 'title'],
             ],
 
diff --git a/database/factories/GameFactory.php b/database/factories/GameFactory.php
index c956816939..64fc38b917 100644
--- a/database/factories/GameFactory.php
+++ b/database/factories/GameFactory.php
@@ -49,6 +49,14 @@ public function configure(): static
             if ($game->sort_title === null) {
                 $game->sort_title = (new ComputeGameSortTitleAction())->execute($game->title);
             }
+
+            // Create a canonical title if one doesn't already exist.
+            if (!$game->releases()->where('is_canonical_game_title', true)->exists()) {
+                $game->releases()->create([
+                    'title' => $game->title,
+                    'is_canonical_game_title' => true,
+                ]);
+            }
         });
     }
 }
diff --git a/database/factories/GameReleaseFactory.php b/database/factories/GameReleaseFactory.php
new file mode 100644
index 0000000000..cbcbd18856
--- /dev/null
+++ b/database/factories/GameReleaseFactory.php
@@ -0,0 +1,42 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Database\Factories;
+
+use App\Models\Game;
+use App\Models\GameRelease;
+use App\Platform\Enums\GameReleaseRegion;
+use Illuminate\Database\Eloquent\Factories\Factory;
+
+/**
+ * @extends Factory<GameRelease>
+ */
+class GameReleaseFactory extends Factory
+{
+    protected $model = GameRelease::class;
+
+    /**
+     * @return array<string, mixed>
+     */
+    public function definition(): array
+    {
+        $game = Game::inRandomOrder()->first();
+        $regions = [
+            GameReleaseRegion::NorthAmerica,
+            GameReleaseRegion::Japan,
+            GameReleaseRegion::Europe,
+            GameReleaseRegion::Australia,
+            GameReleaseRegion::Korea,
+            GameReleaseRegion::China,
+            GameReleaseRegion::Worldwide,
+        ];
+
+        return [
+            'game_id' => $game->id,
+            'title' => $this->faker->words(3, true),
+            'region' => $this->faker->randomElement($regions),
+            'is_canonical_game_title' => false,
+        ];
+    }
+}
diff --git a/database/migrations/2025_05_19_000000_create_game_releases_table.php b/database/migrations/2025_05_19_000000_create_game_releases_table.php
new file mode 100644
index 0000000000..a40b61fdf8
--- /dev/null
+++ b/database/migrations/2025_05_19_000000_create_game_releases_table.php
@@ -0,0 +1,36 @@
+<?php
+
+declare(strict_types=1);
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class() extends Migration {
+    public function up(): void
+    {
+        Schema::create('game_releases', function (Blueprint $table) {
+            $table->id();
+            $table->unsignedBigInteger('game_id');
+            $table->timestamp('released_at')->nullable(); // matches GameData.released_at
+            $table->string('released_at_granularity')->nullable(); // matches GameData.released_at_granularity
+            $table->string('title', 80); // matches GameData.Title
+            $table->string('region', 20)->nullable();
+            $table->boolean('is_canonical_game_title')->default(false);
+            $table->timestamps();
+            $table->softDeletes();
+
+            $table->index('title');
+
+            $table->foreign('game_id')
+                ->references('ID')
+                ->on('GameData')
+                ->onDelete('cascade');
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('game_releases');
+    }
+};
diff --git a/resources/views/pages-legacy/gameInfo.blade.php b/resources/views/pages-legacy/gameInfo.blade.php
index 246f1699ce..3afa079f15 100644
--- a/resources/views/pages-legacy/gameInfo.blade.php
+++ b/resources/views/pages-legacy/gameInfo.blade.php
@@ -661,7 +661,7 @@ function resize() {
                     echo csrf_field();
                     echo "<input type='hidden' name='game' value='$gameID' />";
                     echo "<div class='md:grid grid-cols-[180px_1fr_100px] gap-1 items-center mb-1'>";
-                    echo "<label for='game_title'>Name</label>";
+                    echo "<label for='game_title'>Canonical Title</label>";
                     echo "<input type='text' name='title' id='game_title' value='$escapedGameTitle' maxlength='80' class='w-full'>";
                     echo "<div class='text-right'><button class='btn'>Submit</button></div>";
                     echo "</div>";
diff --git a/tests/Feature/Api/V1/GameExtendedTest.php b/tests/Feature/Api/V1/GameExtendedTest.php
index ec0c12bece..84c4344705 100644
--- a/tests/Feature/Api/V1/GameExtendedTest.php
+++ b/tests/Feature/Api/V1/GameExtendedTest.php
@@ -8,9 +8,12 @@
 use App\Models\Achievement;
 use App\Models\AchievementSetClaim;
 use App\Models\Game;
+use App\Models\GameRelease;
 use App\Models\System;
 use App\Models\User;
 use App\Platform\Enums\AchievementFlag;
+use App\Platform\Enums\GameReleaseRegion;
+use App\Platform\Enums\ReleasedAtGranularity;
 use Illuminate\Foundation\Testing\RefreshDatabase;
 use Illuminate\Support\Carbon;
 use Tests\Feature\Platform\Concerns\TestsPlayerAchievements;
@@ -46,9 +49,17 @@ public function testGetGame(): void
             'Publisher' => 'WePublishStuff',
             'Developer' => 'WeDevelopStuff',
             'Genre' => 'Action',
+        ]);
+
+        GameRelease::factory()->create([
+            'game_id' => $game->ID,
+            'title' => $game->Title,
             'released_at' => $releasedAt,
-            'released_at_granularity' => 'day',
+            'released_at_granularity' => ReleasedAtGranularity::Day,
+            'region' => GameReleaseRegion::NorthAmerica,
+            'is_canonical_game_title' => true,
         ]);
+
         /** @var Achievement $achievement1 */
         $achievement1 = Achievement::factory()->published()->create(['GameID' => $game->ID, 'BadgeName' => '12345', 'DisplayOrder' => 1]);
         /** @var Achievement $achievement2 */
@@ -191,8 +202,15 @@ public function testGetGameClaimed(): void
             'Publisher' => 'WePublishStuff',
             'Developer' => 'WeDevelopStuff',
             'Genre' => 'Action',
+        ]);
+
+        GameRelease::factory()->create([
+            'game_id' => $game->ID,
+            'title' => $game->Title,
             'released_at' => $releasedAt,
-            'released_at_granularity' => 'day',
+            'released_at_granularity' => ReleasedAtGranularity::Day,
+            'region' => GameReleaseRegion::NorthAmerica,
+            'is_canonical_game_title' => true,
         ]);
 
         /** @var User $user2 */
diff --git a/tests/Feature/Api/V1/GameInfoAndUserProgressTest.php b/tests/Feature/Api/V1/GameInfoAndUserProgressTest.php
index b76407caea..5b307292cc 100644
--- a/tests/Feature/Api/V1/GameInfoAndUserProgressTest.php
+++ b/tests/Feature/Api/V1/GameInfoAndUserProgressTest.php
@@ -6,9 +6,12 @@
 
 use App\Models\Achievement;
 use App\Models\Game;
+use App\Models\GameRelease;
 use App\Models\System;
 use App\Models\User;
 use App\Platform\Enums\AchievementType;
+use App\Platform\Enums\GameReleaseRegion;
+use App\Platform\Enums\ReleasedAtGranularity;
 use App\Platform\Enums\UnlockMode;
 use Illuminate\Foundation\Testing\RefreshDatabase;
 use Illuminate\Support\Carbon;
@@ -47,9 +50,17 @@ public function testGetGameInfoAndUserProgressByName(): void
             'Publisher' => 'WePublishStuff',
             'Developer' => 'WeDevelopStuff',
             'Genre' => 'Action',
+        ]);
+
+        GameRelease::factory()->create([
+            'game_id' => $game->ID,
+            'title' => $game->Title,
             'released_at' => $releasedAt,
-            'released_at_granularity' => 'day',
+            'released_at_granularity' => ReleasedAtGranularity::Day,
+            'region' => GameReleaseRegion::NorthAmerica,
+            'is_canonical_game_title' => true,
         ]);
+
         /** @var Achievement $achievement1 */
         $achievement1 = Achievement::factory()->published()->create(['GameID' => $game->ID, 'BadgeName' => '12345', 'DisplayOrder' => 1]);
         /** @var Achievement $achievement2 */
@@ -173,9 +184,17 @@ public function testGetGameInfoAndUserProgressByUlid(): void
             'Publisher' => 'WePublishStuff',
             'Developer' => 'WeDevelopStuff',
             'Genre' => 'Action',
+        ]);
+
+        GameRelease::factory()->create([
+            'game_id' => $game->ID,
+            'title' => $game->Title,
             'released_at' => $releasedAt,
-            'released_at_granularity' => 'day',
+            'released_at_granularity' => ReleasedAtGranularity::Day,
+            'region' => GameReleaseRegion::NorthAmerica,
+            'is_canonical_game_title' => true,
         ]);
+
         /** @var Achievement $achievement1 */
         $achievement1 = Achievement::factory()->published()->create(['GameID' => $game->ID, 'BadgeName' => '12345', 'DisplayOrder' => 1]);
         /** @var Achievement $achievement2 */
@@ -299,8 +318,15 @@ public function testGetGameInfoAndUserProgressNoAchievements(): void
             'Publisher' => 'WePublishStuff',
             'Developer' => 'WeDevelopStuff',
             'Genre' => 'Action',
+        ]);
+
+        GameRelease::factory()->create([
+            'game_id' => $game->ID,
+            'title' => $game->Title,
             'released_at' => $releasedAt,
-            'released_at_granularity' => 'day',
+            'released_at_granularity' => ReleasedAtGranularity::Day,
+            'region' => GameReleaseRegion::NorthAmerica,
+            'is_canonical_game_title' => true,
         ]);
 
         // issue #484: empty associative array should still return {}, not []
@@ -353,9 +379,17 @@ public function testGetGameInfoAndUserProgressWithHighestAwardMetadata(): void
             'Publisher' => 'WePublishStuff',
             'Developer' => 'WeDevelopStuff',
             'Genre' => 'Action',
+        ]);
+
+        GameRelease::factory()->create([
+            'game_id' => $game->ID,
+            'title' => $game->Title,
             'released_at' => $releasedAt,
-            'released_at_granularity' => 'day',
+            'released_at_granularity' => ReleasedAtGranularity::Day,
+            'region' => GameReleaseRegion::NorthAmerica,
+            'is_canonical_game_title' => true,
         ]);
+
         /** @var Achievement $achievement1 */
         $achievement1 = Achievement::factory()->published()->create(['GameID' => $game->ID, 'BadgeName' => '12345', 'DisplayOrder' => 1, 'type' => AchievementType::Progression]);
         /** @var Achievement $achievement2 */
diff --git a/tests/Feature/Api/V1/GameTest.php b/tests/Feature/Api/V1/GameTest.php
index c2798f20ac..638dc4f9a9 100644
--- a/tests/Feature/Api/V1/GameTest.php
+++ b/tests/Feature/Api/V1/GameTest.php
@@ -5,7 +5,10 @@
 namespace Tests\Feature\Api\V1;
 
 use App\Models\Game;
+use App\Models\GameRelease;
 use App\Models\System;
+use App\Platform\Enums\GameReleaseRegion;
+use App\Platform\Enums\ReleasedAtGranularity;
 use Illuminate\Foundation\Testing\RefreshDatabase;
 use Illuminate\Support\Carbon;
 use Tests\TestCase;
@@ -40,8 +43,14 @@ public function testGetGame(): void
             'Publisher' => 'WePublishStuff',
             'Developer' => 'WeDevelopStuff',
             'Genre' => 'Action',
+        ]);
+        GameRelease::factory()->create([
+            'game_id' => $gameOne->ID,
+            'title' => $gameOne->Title,
             'released_at' => $releasedAt,
-            'released_at_granularity' => 'day',
+            'released_at_granularity' => ReleasedAtGranularity::Day,
+            'region' => GameReleaseRegion::NorthAmerica,
+            'is_canonical_game_title' => true,
         ]);
 
         // Ensure that null released_at values are properly handled.
diff --git a/tests/Feature/Api/V1/UserSummaryTest.php b/tests/Feature/Api/V1/UserSummaryTest.php
index 1981ed8ce7..fbbd243b15 100644
--- a/tests/Feature/Api/V1/UserSummaryTest.php
+++ b/tests/Feature/Api/V1/UserSummaryTest.php
@@ -7,10 +7,13 @@
 use App\Community\Enums\Rank;
 use App\Models\Achievement;
 use App\Models\Game;
+use App\Models\GameRelease;
 use App\Models\PlayerGame;
 use App\Models\System;
 use App\Models\User;
 use App\Platform\Actions\UpdateGameMetricsAction;
+use App\Platform\Enums\GameReleaseRegion;
+use App\Platform\Enums\ReleasedAtGranularity;
 use Illuminate\Foundation\Testing\RefreshDatabase;
 use Illuminate\Support\Carbon;
 use Tests\Feature\Platform\Concerns\TestsPlayerAchievements;
@@ -110,8 +113,6 @@ public function testGetUserSummary(): void
     {
         $game = $this->seedGame(achievements: 3);
         $game->fill([
-            'released_at' => '1992-05-06',
-            'released_at_granularity' => 'day',
             'ForumTopicID' => 222334,
             'Publisher' => 'WeSellGames',
             'Developer' => 'WeMakeGames',
@@ -122,6 +123,16 @@ public function testGetUserSummary(): void
             'ImageBoxArt' => '/Images/001237.png',
         ]);
         $game->save();
+
+        GameRelease::factory()->create([
+            'game_id' => $game->ID,
+            'title' => $game->Title,
+            'released_at' => '1992-05-06',
+            'released_at_granularity' => ReleasedAtGranularity::Day,
+            'region' => GameReleaseRegion::NorthAmerica,
+            'is_canonical_game_title' => true,
+        ]);
+
         $publishedAchievements = $game->achievements;
         (new UpdateGameMetricsAction())->execute($game);
 
@@ -136,8 +147,6 @@ public function testGetUserSummary(): void
         ]);
         $game2 = $this->seedGame();
         $game2->fill([
-            'released_at' => '1994-05-07',
-            'released_at_granularity' => 'day',
             'ForumTopicID' => 23543,
             'Publisher' => 'WeAlsoSellGames',
             'Developer' => 'WeAlsoMakeGames',
@@ -148,6 +157,16 @@ public function testGetUserSummary(): void
             'ImageBoxArt' => '/Images/002348.png',
         ]);
         $game2->save();
+
+        GameRelease::factory()->create([
+            'game_id' => $game2->ID,
+            'title' => $game2->Title,
+            'released_at' => '1994-05-07',
+            'released_at_granularity' => ReleasedAtGranularity::Day,
+            'region' => GameReleaseRegion::NorthAmerica,
+            'is_canonical_game_title' => true,
+        ]);
+
         (new UpdateGameMetricsAction())->execute($game2);
 
         $earnedAchievement = $publishedAchievements->get(0);
diff --git a/tests/Feature/Platform/Actions/BuildGameListActionTest.php b/tests/Feature/Platform/Actions/BuildGameListActionTest.php
index a3d9fb0995..780c1faba3 100644
--- a/tests/Feature/Platform/Actions/BuildGameListActionTest.php
+++ b/tests/Feature/Platform/Actions/BuildGameListActionTest.php
@@ -10,6 +10,7 @@
 use App\Community\Enums\UserGameListType;
 use App\Models\Achievement;
 use App\Models\Game;
+use App\Models\GameRelease;
 use App\Models\Leaderboard;
 use App\Models\PlayerGame;
 use App\Models\Role;
@@ -20,6 +21,8 @@
 use App\Platform\Enums\AchievementFlag;
 use App\Platform\Enums\GameListProgressFilterValue;
 use App\Platform\Enums\GameListType;
+use App\Platform\Enums\GameReleaseRegion;
+use App\Platform\Enums\ReleasedAtGranularity;
 use App\Platform\Enums\UnlockMode;
 use Illuminate\Foundation\Testing\RefreshDatabase;
 use Illuminate\Support\Carbon;
@@ -310,8 +313,24 @@ public function testItCanSortByLastUpdated(): void
         $this->seedGamesForLists();
         $this->addGameIdsToUserPlayList($user, gameIds: [1000, 1001, 1002, 1003, 1004, 1005]);
 
-        Achievement::factory()->create(['GameID' => 1001, 'DateModified' => Carbon::parse('2015-06-01')]);
-        Achievement::factory()->create(['GameID' => 1003, 'DateModified' => Carbon::parse('2024-03-02')]);
+        // ... ensure all games have explicit update timestamps to avoid test flakiness due to GameTitle touches ...
+        $dates = [
+            1000 => '2020-01-01',
+            1001 => '2015-06-01', // earliest - will be first
+            1002 => '2023-01-01',
+            1003 => '2024-03-02', // latest - will be last
+            1004 => '2022-01-01',
+            1005 => '2021-01-01',
+        ];
+
+        Achievement::factory()->create(['GameID' => 1001, 'DateModified' => Carbon::parse($dates[1001])]);
+        Achievement::factory()->create(['GameID' => 1003, 'DateModified' => Carbon::parse($dates[1003])]);
+
+        foreach ($dates as $gameId => $date) {
+            $game = Game::find($gameId);
+            $game->Updated = Carbon::parse($date);
+            $game->save(['timestamps' => false]); // prevent updated_at from being set to now
+        }
 
         // Act
         $result = (new BuildGameListAction())->execute(
@@ -965,13 +984,11 @@ private function seedGamesForLists(): void
         $systemGb = System::factory()->create(['ID' => 1, 'name' => 'Game Boy', 'name_short' => 'GB']);
         $systemNes = System::factory()->create(['ID' => 2, 'name' => 'NES/Famicom', 'name_short' => 'NES']);
 
-        Game::factory()->create([
+        $game1000 = Game::factory()->create([
             'ID' => 1000,
             'ConsoleID' => $systemGb->id,
             'Title' => 'A-Mazing Tater | Puzzle Boy II',
             'ImageIcon' => '/Images/090884.png',
-            'released_at' => '1991-08-02 00:55:29',
-            'released_at_granularity' => 'day',
             'players_total' => 969,
             'players_hardcore' => 641,
             'achievements_published' => 45,
@@ -979,14 +996,20 @@ private function seedGamesForLists(): void
             'TotalTruePoints' => 813,
             'Updated' => Carbon::parse('2023-06-06'),
         ]);
+        GameRelease::factory()->create([
+            'game_id' => $game1000->id,
+            'title' => 'A-Mazing Tater | Puzzle Boy II',
+            'released_at' => '1991-08-02 00:55:29',
+            'released_at_granularity' => ReleasedAtGranularity::Day,
+            'region' => GameReleaseRegion::NorthAmerica,
+            'is_canonical_game_title' => true,
+        ]);
 
-        Game::factory()->create([
+        $game1001 = Game::factory()->create([
             'ID' => 1001,
             'ConsoleID' => $systemGb->id,
             'Title' => '~Hack~ Twitch Plays Pokemon: Anniversary Red',
             'ImageIcon' => '/Images/094381.png',
-            'released_at' => '2015-01-01 00:00:00',
-            'released_at_granularity' => 'year',
             'players_total' => 120,
             'players_hardcore' => 97,
             'achievements_published' => 47,
@@ -994,14 +1017,20 @@ private function seedGamesForLists(): void
             'TotalTruePoints' => 363,
             'Updated' => Carbon::parse('2023-06-06'),
         ]);
+        GameRelease::factory()->create([
+            'game_id' => $game1001->id,
+            'title' => '~Hack~ Twitch Plays Pokemon: Anniversary Red',
+            'released_at' => '2015-01-01 00:00:00',
+            'released_at_granularity' => ReleasedAtGranularity::Year,
+            'region' => GameReleaseRegion::NorthAmerica,
+            'is_canonical_game_title' => true,
+        ]);
 
-        Game::factory()->create([
+        $game1002 = Game::factory()->create([
             'ID' => 1002,
             'ConsoleID' => $systemNes->id,
             'Title' => 'Final Fantasy [Subset - Solo Class]',
             'ImageIcon' => '/Images/071115.png',
-            'released_at' => '1987-12-18 00:55:31',
-            'released_at_granularity' => 'day',
             'players_total' => 2856,
             'players_hardcore' => 2054,
             'achievements_published' => 144,
@@ -1009,14 +1038,20 @@ private function seedGamesForLists(): void
             'TotalTruePoints' => 35962,
             'Updated' => Carbon::parse('2023-06-06'),
         ]);
+        GameRelease::factory()->create([
+            'game_id' => $game1002->id,
+            'title' => 'Final Fantasy [Subset - Solo Class]',
+            'released_at' => '1987-12-18 00:55:31',
+            'released_at_granularity' => ReleasedAtGranularity::Day,
+            'region' => GameReleaseRegion::NorthAmerica,
+            'is_canonical_game_title' => true,
+        ]);
 
-        Game::factory()->create([
+        $game1003 = Game::factory()->create([
             'ID' => 1003,
             'ConsoleID' => $systemNes->id,
             'Title' => 'Dragon Quest III | Dragon Warrior III',
             'ImageIcon' => '/Images/026797.png',
-            'released_at' => '1992-10-15 00:00:00',
-            'released_at_granularity' => 'month',
             'players_total' => 826,
             'players_hardcore' => 623,
             'achievements_published' => 50,
@@ -1024,8 +1059,16 @@ private function seedGamesForLists(): void
             'TotalTruePoints' => 548,
             'Updated' => Carbon::parse('2023-06-06'),
         ]);
+        GameRelease::factory()->create([
+            'game_id' => $game1003->id,
+            'title' => 'Dragon Quest III | Dragon Warrior III',
+            'released_at' => '1992-10-15 00:00:00',
+            'released_at_granularity' => ReleasedAtGranularity::Month,
+            'region' => GameReleaseRegion::NorthAmerica,
+            'is_canonical_game_title' => true,
+        ]);
 
-        Game::factory()->create([
+        $game1004 = Game::factory()->create([
             'ID' => 1004,
             'ConsoleID' => $systemNes->id,
             'Title' => 'Cycle Race: Road Man',
@@ -1040,13 +1083,11 @@ private function seedGamesForLists(): void
             'Updated' => Carbon::parse('2023-06-06'),
         ]);
 
-        Game::factory()->create([
+        $game1005 = Game::factory()->create([
             'ID' => 1005,
             'ConsoleID' => $systemNes->id,
             'Title' => 'Double Moon Densetsu',
             'ImageIcon' => '/Images/071237.png',
-            'released_at' => '1992-10-14 00:00:00',
-            'released_at_granularity' => 'day',
             'players_total' => 18,
             'players_hardcore' => 17,
             'achievements_published' => 38,
@@ -1054,6 +1095,14 @@ private function seedGamesForLists(): void
             'TotalTruePoints' => 282,
             'Updated' => Carbon::parse('2023-06-06'),
         ]);
+        GameRelease::factory()->create([
+            'game_id' => $game1005->id,
+            'title' => 'Double Moon Densetsu',
+            'released_at' => '1992-10-14 00:00:00',
+            'released_at_granularity' => ReleasedAtGranularity::Day,
+            'region' => GameReleaseRegion::NorthAmerica,
+            'is_canonical_game_title' => true,
+        ]);
     }
 
     private function addGameIdsToUserPlayList(User $user, array $gameIds): void


Reviews:
user: login: Jamiras
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-20T19:57:28Z
user: login: wescopeland
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-20T20:29:38Z
user: login: wescopeland
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-20T20:41:24Z
user: login: Jamiras
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-20T20:43:13Z
user: login: Jamiras
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-21T16:27:36Z
user: login: wescopeland
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-22T00:35:00Z
user: login: wescopeland
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-22T00:35:39Z
user: login: wescopeland
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-22T00:36:01Z
user: login: Jamiras
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-22T15:45:15Z
user: login: Jamiras
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-22T15:51:53Z
user: login: wescopeland
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-22T22:11:00Z
user: login: wescopeland
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-22T22:11:24Z
user: login: wescopeland
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-22T22:12:08Z
user: login: Jamiras
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-23T01:03:41Z
user: login: Jamiras
body: I'm still a bit confused around the behavior of changing the canonical release. But since the column is `is_canonical_game_title`, I guess it's just the title that's affected by changing that value, so everything appears to be working.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-23T01:05:51Z
user: login: wescopeland
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-23T11:16:21Z

Review comments:
diff_hunk: @@ -170,6 +170,15 @@ public static function boot()
                     $originalTitle,
                     $freshGame->title
                 );
+
+                // Update the canonical title in game_titles.
+                if (!$game->wasRecentlyCreated) {
+                    $canonicalTitle = $freshGame->titles()->where('is_canonical', true)->first();
+                    if ($canonicalTitle) {
+                        $canonicalTitle->title = $freshGame->title;
+                        $canonicalTitle->save();
+                    }
+                }
path: app/Models/Game.php
user: login: Jamiras
body: Event name changes seem to be getting synced to game_titles, but hub name changes are not.
created_at: 2025-05-20T19:31:30Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,34 @@
+<?php
+
+declare(strict_types=1);
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class() extends Migration {
+    public function up(): void
+    {
+        Schema::create('game_titles', function (Blueprint $table) {
path: database/migrations/2025_05_19_000000_create_game_titles_table.php
user: login: Jamiras
body: Looking forward, I wonder if this should be 'game_releases' where we could have regional release dates in addition to regional titles.
created_at: 2025-05-20T19:33:40Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,49 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Platform\Enums;
+
+enum GameTitleRegion: string
+{
+    case Africa = 'af';
+    case Australia = 'au';
+    case Brazil = 'br';
+    case China = 'cn';
+    case Europe = 'eu';
+    case India = 'in';
+    case Japan = 'jp';
+    case LatinAmerica = 'latam';
+    case MiddleEast = 'me';
+    case NorthAmerica = 'na';
+    case Russia = 'ru';
+    case SoutheastAsia = 'sea';
+    case SouthKorea = 'kr';
+    case TaiwanHongKong = 'tw_hk';
path: app/Platform/Enums/GameTitleRegion.php
user: login: Jamiras
body: I'm curious how you decided on these regions.

NA/EU/JP seem obvious (I might even argue US over NA), but are there really that many releases unique to India and the Middle East? And what's left for Southeast Asia after you take out China, Korea, Taiwan and Hong Kong?
created_at: 2025-05-20T19:48:53Z
author_association: MEMBER
diff_hunk: @@ -141,7 +141,7 @@
         'index-settings' => [
             Game::class => [
                 'filterableAttributes' => ['id', 'title'],
-                'searchableAttributes' => ['title', 'id'],
+                'searchableAttributes' => ['title', 'alt_titles', 'id'],
path: config/scout.php
user: login: Jamiras
body: More of a future problem, but when the match is only on the alt_title, it's hard to tell why the record matched.

For example, if I add an alternate title for Sonic the Hedgehog as "Speedy Blue Devil" (as maybe some made-up release for Africa) and search on "Speedy", I get a bunch of exact matches against Speedy Gonzales, then the Sonic game, and then some light matches against Speed, which is logically correct. But if the alternate title is not well known, wouldn't seeing "Sonic" in the middle of that list confuse the user?
created_at: 2025-05-20T19:55:47Z
author_association: MEMBER
diff_hunk: @@ -170,6 +170,15 @@ public static function boot()
                     $originalTitle,
                     $freshGame->title
                 );
+
+                // Update the canonical title in game_titles.
+                if (!$game->wasRecentlyCreated) {
+                    $canonicalTitle = $freshGame->titles()->where('is_canonical', true)->first();
+                    if ($canonicalTitle) {
+                        $canonicalTitle->title = $freshGame->title;
+                        $canonicalTitle->save();
+                    }
+                }
path: app/Models/Game.php
user: login: wescopeland
body: May need help to understand this. Developers shouldn't be touching hub games anymore, and they aren't generated anymore when creating new hubs. They only exist to back redirects to modern "Hubs 2.0".

Alternative titles for hubs is not in scope for this PR, and I'm not sure if it should be generally supported.
created_at: 2025-05-20T20:29:38Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,49 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Platform\Enums;
+
+enum GameTitleRegion: string
+{
+    case Africa = 'af';
+    case Australia = 'au';
+    case Brazil = 'br';
+    case China = 'cn';
+    case Europe = 'eu';
+    case India = 'in';
+    case Japan = 'jp';
+    case LatinAmerica = 'latam';
+    case MiddleEast = 'me';
+    case NorthAmerica = 'na';
+    case Russia = 'ru';
+    case SoutheastAsia = 'sea';
+    case SouthKorea = 'kr';
+    case TaiwanHongKong = 'tw_hk';
path: app/Platform/Enums/GameTitleRegion.php
user: login: wescopeland
body: This is probably way too wide of a net.

In latest, I've renamed this enum to `GameReleaseRegion` and modeled it after IGDB's region choices:
<img width="186" alt="Screenshot 2025-05-20 at 4 34 52 PM" src="https://github.com/user-attachments/assets/ad415ea3-d53a-4708-9016-43f21c3c09b3" />

created_at: 2025-05-20T20:41:24Z
author_association: MEMBER
diff_hunk: @@ -170,6 +170,15 @@ public static function boot()
                     $originalTitle,
                     $freshGame->title
                 );
+
+                // Update the canonical title in game_titles.
+                if (!$game->wasRecentlyCreated) {
+                    $canonicalTitle = $freshGame->titles()->where('is_canonical', true)->first();
+                    if ($canonicalTitle) {
+                        $canonicalTitle->title = $freshGame->title;
+                        $canonicalTitle->save();
+                    }
+                }
path: app/Models/Game.php
user: login: Jamiras
body: I'm not saying they _should_ be supported. I'm just letting you know that they exist (because there's a backing GameData record). 

After digging into a bit more - the event Title is pulled from (and updated to) the `GameData` record, so it's getting sync'd to the `game_titles` row. When editing a hub Title, the `game_set.title` is updated, but not the `GameData.Title` 

So it appears the behavior I've noticed is the result of a "detached" `GameData` record for the hub.
created_at: 2025-05-20T20:43:13Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,186 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Filament\Resources\GameResource\RelationManagers;
+
+use App\Filament\Resources\GameResource;
+use App\Models\Game;
+use App\Models\GameTitle;
+use App\Models\User;
+use App\Platform\Enums\GameReleaseRegion;
+use Filament\Forms;
+use Filament\Forms\Form;
+use Filament\Resources\RelationManagers\RelationManager;
+use Filament\Tables;
+use Filament\Tables\Table;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Support\Facades\Auth;
+
+class TitlesRelationManager extends RelationManager
+{
+    protected static string $relationship = 'titles';
+
+    protected static ?string $recordTitleAttribute = 'title';
+
+    public static function canViewForRecord(Model $ownerRecord, string $pageClass): bool
+    {
+        /** @var User $user */
+        $user = Auth::user();
+
+        if ($ownerRecord instanceof Game) {
+            return $user->can('manage', $ownerRecord);
+        }
+
+        return false;
+    }
+
+    public function form(Form $form): Form
+    {
+        return $form
+            ->schema([
+                Forms\Components\TextInput::make('title')
+                    ->required()
+                    ->maxLength(80)
+                    ->label('Title'),
+
+                Forms\Components\Select::make('region')
+                    ->searchable()
+                    ->options([
+                        'Common Regions' => [
+                            GameReleaseRegion::NorthAmerica->value => GameReleaseRegion::NorthAmerica->label(),
+                            GameReleaseRegion::Japan->value => GameReleaseRegion::Japan->label(),
+                            GameReleaseRegion::Europe->value => GameReleaseRegion::Europe->label(),
+                            GameReleaseRegion::Worldwide->value => GameReleaseRegion::Worldwide->label(),
+                        ],
+                        'Other Regions' => collect(GameReleaseRegion::cases())
+                            ->filter(fn (GameReleaseRegion $region) => !in_array($region, [
+                                GameReleaseRegion::NorthAmerica,
+                                GameReleaseRegion::Japan,
+                                GameReleaseRegion::Europe,
+                                GameReleaseRegion::Worldwide,
+                                GameReleaseRegion::Other,
+                            ]))
+                            ->mapWithKeys(fn (GameReleaseRegion $region) => [$region->value => $region->label()])
+                            ->toArray(),
+                        'Special' => [
+                            GameReleaseRegion::Other->value => GameReleaseRegion::Other->label(),
+                        ],
path: app/Filament/Resources/GameResource/RelationManagers/TitlesRelationManager.php
user: login: Jamiras
body: Would it make sense to just have Other as the last item of the Other Regions list? It feels weird seeing Other Regions and the Other separately. Do you foresee having any other Special regions?
created_at: 2025-05-21T16:26:09Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,34 @@
+<?php
+
+declare(strict_types=1);
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class() extends Migration {
+    public function up(): void
+    {
+        Schema::create('game_titles', function (Blueprint $table) {
path: database/migrations/2025_05_19_000000_create_game_titles_table.php
user: login: wescopeland
body: I think this is an excellent idea.

This prompted a whole bunch of changes into the PR, but I've implemented it in latest.

"Titles" has been changed to "Releases":
![Screenshot 2025-05-21 at 8 31 18 PM](https://github.com/user-attachments/assets/f6a624c4-4dc5-4068-bc11-79915392c693)

Additionally, `GameData.released_at` and `GameData.released_at_granularity` are now treated as denormalized values, derived from the new `game_releases` table. The values in `GameData` automatically update according to whatever is in the `->releases()` relation.

Accordingly, the "First Released At" section in `GameResource` has been removed.
created_at: 2025-05-22T00:35:00Z
author_association: MEMBER
diff_hunk: @@ -141,7 +141,7 @@
         'index-settings' => [
             Game::class => [
                 'filterableAttributes' => ['id', 'title'],
-                'searchableAttributes' => ['title', 'id'],
+                'searchableAttributes' => ['title', 'alt_titles', 'id'],
path: config/scout.php
user: login: wescopeland
body: Yes, we'll have to figure out something for this when we expose the search results to the UI, which shouldn't be too long from now.
created_at: 2025-05-22T00:35:38Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,186 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Filament\Resources\GameResource\RelationManagers;
+
+use App\Filament\Resources\GameResource;
+use App\Models\Game;
+use App\Models\GameTitle;
+use App\Models\User;
+use App\Platform\Enums\GameReleaseRegion;
+use Filament\Forms;
+use Filament\Forms\Form;
+use Filament\Resources\RelationManagers\RelationManager;
+use Filament\Tables;
+use Filament\Tables\Table;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Support\Facades\Auth;
+
+class TitlesRelationManager extends RelationManager
+{
+    protected static string $relationship = 'titles';
+
+    protected static ?string $recordTitleAttribute = 'title';
+
+    public static function canViewForRecord(Model $ownerRecord, string $pageClass): bool
+    {
+        /** @var User $user */
+        $user = Auth::user();
+
+        if ($ownerRecord instanceof Game) {
+            return $user->can('manage', $ownerRecord);
+        }
+
+        return false;
+    }
+
+    public function form(Form $form): Form
+    {
+        return $form
+            ->schema([
+                Forms\Components\TextInput::make('title')
+                    ->required()
+                    ->maxLength(80)
+                    ->label('Title'),
+
+                Forms\Components\Select::make('region')
+                    ->searchable()
+                    ->options([
+                        'Common Regions' => [
+                            GameReleaseRegion::NorthAmerica->value => GameReleaseRegion::NorthAmerica->label(),
+                            GameReleaseRegion::Japan->value => GameReleaseRegion::Japan->label(),
+                            GameReleaseRegion::Europe->value => GameReleaseRegion::Europe->label(),
+                            GameReleaseRegion::Worldwide->value => GameReleaseRegion::Worldwide->label(),
+                        ],
+                        'Other Regions' => collect(GameReleaseRegion::cases())
+                            ->filter(fn (GameReleaseRegion $region) => !in_array($region, [
+                                GameReleaseRegion::NorthAmerica,
+                                GameReleaseRegion::Japan,
+                                GameReleaseRegion::Europe,
+                                GameReleaseRegion::Worldwide,
+                                GameReleaseRegion::Other,
+                            ]))
+                            ->mapWithKeys(fn (GameReleaseRegion $region) => [$region->value => $region->label()])
+                            ->toArray(),
+                        'Special' => [
+                            GameReleaseRegion::Other->value => GameReleaseRegion::Other->label(),
+                        ],
path: app/Filament/Resources/GameResource/RelationManagers/TitlesRelationManager.php
user: login: wescopeland
body: This is actually some code I forgot to remove. I've eliminated this "Special" section in latest.
created_at: 2025-05-22T00:36:01Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,186 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Filament\Resources\GameResource\RelationManagers;
+
+use App\Filament\Resources\GameResource;
+use App\Models\Game;
+use App\Models\GameTitle;
+use App\Models\User;
+use App\Platform\Enums\GameReleaseRegion;
+use Filament\Forms;
+use Filament\Forms\Form;
+use Filament\Resources\RelationManagers\RelationManager;
+use Filament\Tables;
+use Filament\Tables\Table;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Support\Facades\Auth;
+
+class TitlesRelationManager extends RelationManager
+{
+    protected static string $relationship = 'titles';
+
+    protected static ?string $recordTitleAttribute = 'title';
+
+    public static function canViewForRecord(Model $ownerRecord, string $pageClass): bool
+    {
+        /** @var User $user */
+        $user = Auth::user();
+
+        if ($ownerRecord instanceof Game) {
+            return $user->can('manage', $ownerRecord);
+        }
+
+        return false;
+    }
+
+    public function form(Form $form): Form
+    {
+        return $form
+            ->schema([
+                Forms\Components\TextInput::make('title')
+                    ->required()
+                    ->maxLength(80)
+                    ->label('Title'),
+
+                Forms\Components\Select::make('region')
+                    ->searchable()
+                    ->options([
+                        'Common Regions' => [
+                            GameReleaseRegion::NorthAmerica->value => GameReleaseRegion::NorthAmerica->label(),
+                            GameReleaseRegion::Japan->value => GameReleaseRegion::Japan->label(),
+                            GameReleaseRegion::Europe->value => GameReleaseRegion::Europe->label(),
+                            GameReleaseRegion::Worldwide->value => GameReleaseRegion::Worldwide->label(),
+                        ],
+                        'Other Regions' => collect(GameReleaseRegion::cases())
+                            ->filter(fn (GameReleaseRegion $region) => !in_array($region, [
+                                GameReleaseRegion::NorthAmerica,
+                                GameReleaseRegion::Japan,
+                                GameReleaseRegion::Europe,
+                                GameReleaseRegion::Worldwide,
+                                GameReleaseRegion::Other,
+                            ]))
+                            ->mapWithKeys(fn (GameReleaseRegion $region) => [$region->value => $region->label()])
+                            ->toArray(),
+                        'Special' => [
+                            GameReleaseRegion::Other->value => GameReleaseRegion::Other->label(),
+                        ],
path: app/Filament/Resources/GameResource/RelationManagers/TitlesRelationManager.php
user: login: Jamiras
body: "Other" doesn't appear to be selectable any more. 
created_at: 2025-05-22T15:45:15Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,271 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Filament\Resources\GameResource\RelationManagers;
+
+use App\Filament\Resources\GameResource;
+use App\Models\Game;
+use App\Models\GameRelease;
+use App\Models\User;
+use App\Platform\Enums\GameReleaseRegion;
+use App\Platform\Enums\ReleasedAtGranularity;
+use Filament\Forms;
+use Filament\Forms\Form;
+use Filament\Forms\Get;
+use Filament\Resources\RelationManagers\RelationManager;
+use Filament\Tables;
+use Filament\Tables\Table;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Support\Carbon;
+use Illuminate\Support\Facades\Auth;
+
+class ReleasesRelationManager extends RelationManager
+{
+    protected static string $relationship = 'releases';
+
+    protected static ?string $recordTitleAttribute = 'title';
+
+    public static function canViewForRecord(Model $ownerRecord, string $pageClass): bool
+    {
+        /** @var User $user */
+        $user = Auth::user();
+
+        if ($ownerRecord instanceof Game) {
+            return $user->can('manage', $ownerRecord);
+        }
+
+        return false;
+    }
+
+    public function form(Form $form): Form
+    {
+        return $form
+            ->schema([
+                Forms\Components\TextInput::make('title')
+                    ->required()
+                    ->maxLength(80)
+                    ->label('Title'),
+
+                Forms\Components\Toggle::make('is_canonical_game_title')
+                    ->label('Is Canonical Title')
+                    ->helperText('If enabled, this will be used as the main title for the game on game pages and game lists. Only one title per game can be canonical.')
+                    ->disabled(fn (?GameRelease $record) => $record && $record->is_canonical_game_title),
+
+                Forms\Components\Select::make('region')
+                    ->searchable()
+                    ->options([
+                        'Common Regions' => [
+                            GameReleaseRegion::NorthAmerica->value => GameReleaseRegion::NorthAmerica->label(),
+                            GameReleaseRegion::Japan->value => GameReleaseRegion::Japan->label(),
+                            GameReleaseRegion::Europe->value => GameReleaseRegion::Europe->label(),
+                            GameReleaseRegion::Worldwide->value => GameReleaseRegion::Worldwide->label(),
+                        ],
+                        'Other Regions' => collect(GameReleaseRegion::cases())
+                            ->filter(fn (GameReleaseRegion $region) => !in_array($region, [
+                                GameReleaseRegion::NorthAmerica,
+                                GameReleaseRegion::Japan,
+                                GameReleaseRegion::Europe,
+                                GameReleaseRegion::Worldwide,
+                                GameReleaseRegion::Other,
+                            ]))
+                            ->mapWithKeys(fn (GameReleaseRegion $region) => [$region->value => $region->label()])
+                            ->toArray(),
+                    ])
+                    ->columnSpan(2)
+                    ->label('Region'),
+
+                Forms\Components\DatePicker::make('released_at')
+                    ->label('Release Date')
+                    ->native(false)
+                    ->minDate('1970-01-01')
+                    ->maxDate(now())
+                    ->displayFormat('F j, Y')
+                    ->reactive()
+                    ->afterStateUpdated(function (callable $set, $state) {
+                        // Set the granularity to 'day' if this is the first time released_at is set.
+                        if (!empty($state)) {
+                            $set('released_at_granularity', 'day');
+                        }
+                    }),
+
+                Forms\Components\ToggleButtons::make('released_at_granularity')
+                    ->label('Release Date Precision')
+                    ->options([
+                        ReleasedAtGranularity::Day->value => 'Day',
+                        ReleasedAtGranularity::Month->value => 'Month',
+                        ReleasedAtGranularity::Year->value => 'Year',
+                    ])
+                    ->inline()
+                    ->reactive()
+                    ->required(fn (callable $get) => !empty($get('released_at'))),
+
+                Forms\Components\Placeholder::make('released_at_display')
+                    ->label('Preview')
+                    ->content(function (Get $get): string {
+                        $releasedAt = $get('released_at');
+                        $releasedAtGranularity = $get('released_at_granularity');
+
+                        if (!$releasedAt) {
+                            return 'No release date.';
+                        }
+
+                        switch ($releasedAtGranularity) {
+                            case 'year':
+                                return Carbon::parse($releasedAt)->format('Y');
+
+                            case 'month':
+                                return Carbon::parse($releasedAt)->format('F Y');
+
+                            default:
+                                return Carbon::parse($releasedAt)->format('F j, Y');
+                        }
+                    }),
+            ]);
+    }
+
+    public function table(Table $table): Table
+    {
+        return $table
+            ->recordTitleAttribute('title')
+            ->columns([
+                Tables\Columns\TextColumn::make('released_at')
+                    ->label('Release Date')
+                    ->placeholder('unknown')
+                    ->formatStateUsing(function (GameRelease $gameRelease): ?string {
+                        $releasedAt = $gameRelease->released_at;
+                        $releasedAtGranularity = $gameRelease->released_at_granularity;
+
+                        if (!$releasedAt) {
+                            return null;
+                        }
+
+                        switch ($releasedAtGranularity) {
+                            case ReleasedAtGranularity::Year->value:
+                                return Carbon::parse($releasedAt)->format('Y');
+
+                            case ReleasedAtGranularity::Month->value:
+                                return Carbon::parse($releasedAt)->format('F Y');
+
+                            default:
+                                return Carbon::parse($releasedAt)->format('F j, Y');
+                        }
path: app/Filament/Resources/GameResource/RelationManagers/ReleasesRelationManager.php
user: login: Jamiras
body: This doesn't appear to be working.
http://localhost:64000/manage/games/32104/edit?activeRelationManager=2
![image](https://github.com/user-attachments/assets/9315f0e2-61f6-4e6a-b1ac-b72f621949f0)
![image](https://github.com/user-attachments/assets/2b3094cf-013d-41bd-9251-4ca08498268d)

created_at: 2025-05-22T15:47:27Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,271 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Filament\Resources\GameResource\RelationManagers;
+
+use App\Filament\Resources\GameResource;
+use App\Models\Game;
+use App\Models\GameRelease;
+use App\Models\User;
+use App\Platform\Enums\GameReleaseRegion;
+use App\Platform\Enums\ReleasedAtGranularity;
+use Filament\Forms;
+use Filament\Forms\Form;
+use Filament\Forms\Get;
+use Filament\Resources\RelationManagers\RelationManager;
+use Filament\Tables;
+use Filament\Tables\Table;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Support\Carbon;
+use Illuminate\Support\Facades\Auth;
+
+class ReleasesRelationManager extends RelationManager
+{
+    protected static string $relationship = 'releases';
+
+    protected static ?string $recordTitleAttribute = 'title';
+
+    public static function canViewForRecord(Model $ownerRecord, string $pageClass): bool
+    {
+        /** @var User $user */
+        $user = Auth::user();
+
+        if ($ownerRecord instanceof Game) {
+            return $user->can('manage', $ownerRecord);
+        }
+
+        return false;
+    }
+
+    public function form(Form $form): Form
+    {
+        return $form
+            ->schema([
+                Forms\Components\TextInput::make('title')
+                    ->required()
+                    ->maxLength(80)
+                    ->label('Title'),
+
+                Forms\Components\Toggle::make('is_canonical_game_title')
+                    ->label('Is Canonical Title')
+                    ->helperText('If enabled, this will be used as the main title for the game on game pages and game lists. Only one title per game can be canonical.')
+                    ->disabled(fn (?GameRelease $record) => $record && $record->is_canonical_game_title),
+
+                Forms\Components\Select::make('region')
+                    ->searchable()
+                    ->options([
+                        'Common Regions' => [
+                            GameReleaseRegion::NorthAmerica->value => GameReleaseRegion::NorthAmerica->label(),
+                            GameReleaseRegion::Japan->value => GameReleaseRegion::Japan->label(),
+                            GameReleaseRegion::Europe->value => GameReleaseRegion::Europe->label(),
+                            GameReleaseRegion::Worldwide->value => GameReleaseRegion::Worldwide->label(),
+                        ],
+                        'Other Regions' => collect(GameReleaseRegion::cases())
+                            ->filter(fn (GameReleaseRegion $region) => !in_array($region, [
+                                GameReleaseRegion::NorthAmerica,
+                                GameReleaseRegion::Japan,
+                                GameReleaseRegion::Europe,
+                                GameReleaseRegion::Worldwide,
+                                GameReleaseRegion::Other,
+                            ]))
+                            ->mapWithKeys(fn (GameReleaseRegion $region) => [$region->value => $region->label()])
+                            ->toArray(),
+                    ])
+                    ->columnSpan(2)
+                    ->label('Region'),
+
+                Forms\Components\DatePicker::make('released_at')
+                    ->label('Release Date')
+                    ->native(false)
+                    ->minDate('1970-01-01')
+                    ->maxDate(now())
+                    ->displayFormat('F j, Y')
+                    ->reactive()
+                    ->afterStateUpdated(function (callable $set, $state) {
+                        // Set the granularity to 'day' if this is the first time released_at is set.
+                        if (!empty($state)) {
+                            $set('released_at_granularity', 'day');
+                        }
+                    }),
+
+                Forms\Components\ToggleButtons::make('released_at_granularity')
+                    ->label('Release Date Precision')
+                    ->options([
+                        ReleasedAtGranularity::Day->value => 'Day',
+                        ReleasedAtGranularity::Month->value => 'Month',
+                        ReleasedAtGranularity::Year->value => 'Year',
+                    ])
+                    ->inline()
+                    ->reactive()
+                    ->required(fn (callable $get) => !empty($get('released_at'))),
+
+                Forms\Components\Placeholder::make('released_at_display')
+                    ->label('Preview')
+                    ->content(function (Get $get): string {
+                        $releasedAt = $get('released_at');
+                        $releasedAtGranularity = $get('released_at_granularity');
+
+                        if (!$releasedAt) {
+                            return 'No release date.';
+                        }
+
+                        switch ($releasedAtGranularity) {
+                            case 'year':
+                                return Carbon::parse($releasedAt)->format('Y');
+
+                            case 'month':
+                                return Carbon::parse($releasedAt)->format('F Y');
+
+                            default:
+                                return Carbon::parse($releasedAt)->format('F j, Y');
+                        }
+                    }),
+            ]);
+    }
+
+    public function table(Table $table): Table
+    {
+        return $table
+            ->recordTitleAttribute('title')
+            ->columns([
+                Tables\Columns\TextColumn::make('released_at')
+                    ->label('Release Date')
+                    ->placeholder('unknown')
+                    ->formatStateUsing(function (GameRelease $gameRelease): ?string {
+                        $releasedAt = $gameRelease->released_at;
+                        $releasedAtGranularity = $gameRelease->released_at_granularity;
+
+                        if (!$releasedAt) {
+                            return null;
+                        }
+
+                        switch ($releasedAtGranularity) {
+                            case ReleasedAtGranularity::Year->value:
+                                return Carbon::parse($releasedAt)->format('Y');
+
+                            case ReleasedAtGranularity::Month->value:
+                                return Carbon::parse($releasedAt)->format('F Y');
+
+                            default:
+                                return Carbon::parse($releasedAt)->format('F j, Y');
+                        }
+                    })
+                    ->sortable(query: function ($query, string $direction): Builder {
+                        return $query
+                            /** @see BuildsGameListQueries.php */
+                            ->selectRaw(<<<SQL
+                                *,
+                                CASE
+                                    WHEN released_at_granularity = 'year' THEN
+                                        DATE(CONCAT(SUBSTR(released_at, 1, 4), '-01-01'))
+                                    WHEN released_at_granularity = 'month' THEN
+                                        DATE(CONCAT(SUBSTR(released_at, 1, 7), '-01'))
+                                    ELSE
+                                        COALESCE(released_at, '9999-12-31')
+                                END AS normalized_released_at,
+                                CASE released_at_granularity
+                                    WHEN 'year' THEN 1
+                                    WHEN 'month' THEN 2
+                                    WHEN 'day' THEN 3
+                                    ELSE 4
+                                END AS granularity_order
+                            SQL)
+                            // Ensure NULL release dates always sort to the end, regardless of sort direction.
+                            ->orderByRaw('released_at IS NULL')
+                            ->orderBy('normalized_released_at', $direction)
+                            ->orderBy('granularity_order', $direction);
+                    }),
+
+                Tables\Columns\TextColumn::make('region')
+                    ->formatStateUsing(function ($state): ?string {
+                        if (!$state) {
+                            return null;
+                        }
+
+                        if ($state instanceof GameReleaseRegion) {
+                            return $state->label();
+                        }
+
+                        return GameReleaseRegion::tryFrom($state)?->label() ?? $state;
+                    })
+                    ->sortable(),
+
+                Tables\Columns\TextColumn::make('title')
+                    ->sortable(),
+
+                Tables\Columns\IconColumn::make('is_canonical_game_title')
+                    ->label('Is Canonical Title')
+                    ->boolean()
+                    ->sortable(),
+            ])
+            ->filters([
+
+            ])
+            ->headerActions([
+                Tables\Actions\CreateAction::make()
+                    ->authorize(function () {
+                        /** @var User $user */
+                        $user = Auth::user();
+                        $game = $this->getOwnerRecord();
+
+                        return $user->can('create', [GameRelease::class, $game]);
+                    })
+                    ->mutateFormDataUsing(function (array $data) {
+                        // If this is marked as canonical, unmark any other canonical titles.
+                        if ($data['is_canonical_game_title'] ?? false) {
+                            GameRelease::where('game_id', $this->getOwnerRecord()->id)
+                                ->where('is_canonical_game_title', true)
+                                ->update(['is_canonical_game_title' => false]);
+                        }
+
+                        return $data;
+                    })
+                    ->after(function (GameRelease $record) {
+                        // If the new title is canonical, update the main game title.
+                        if ($record->is_canonical_game_title) {
+                            /** @var Game $game */
+                            $game = $this->getOwnerRecord();
+                            $game->Title = $record->title;
+                            $game->save();
path: app/Filament/Resources/GameResource/RelationManagers/ReleasesRelationManager.php
user: login: Jamiras
body: release data needs to be copied when changing canonical selection too.
created_at: 2025-05-22T15:51:17Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,271 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Filament\Resources\GameResource\RelationManagers;
+
+use App\Filament\Resources\GameResource;
+use App\Models\Game;
+use App\Models\GameRelease;
+use App\Models\User;
+use App\Platform\Enums\GameReleaseRegion;
+use App\Platform\Enums\ReleasedAtGranularity;
+use Filament\Forms;
+use Filament\Forms\Form;
+use Filament\Forms\Get;
+use Filament\Resources\RelationManagers\RelationManager;
+use Filament\Tables;
+use Filament\Tables\Table;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Support\Carbon;
+use Illuminate\Support\Facades\Auth;
+
+class ReleasesRelationManager extends RelationManager
+{
+    protected static string $relationship = 'releases';
+
+    protected static ?string $recordTitleAttribute = 'title';
+
+    public static function canViewForRecord(Model $ownerRecord, string $pageClass): bool
+    {
+        /** @var User $user */
+        $user = Auth::user();
+
+        if ($ownerRecord instanceof Game) {
+            return $user->can('manage', $ownerRecord);
+        }
+
+        return false;
+    }
+
+    public function form(Form $form): Form
+    {
+        return $form
+            ->schema([
+                Forms\Components\TextInput::make('title')
+                    ->required()
+                    ->maxLength(80)
+                    ->label('Title'),
+
+                Forms\Components\Toggle::make('is_canonical_game_title')
+                    ->label('Is Canonical Title')
+                    ->helperText('If enabled, this will be used as the main title for the game on game pages and game lists. Only one title per game can be canonical.')
+                    ->disabled(fn (?GameRelease $record) => $record && $record->is_canonical_game_title),
+
+                Forms\Components\Select::make('region')
+                    ->searchable()
+                    ->options([
+                        'Common Regions' => [
+                            GameReleaseRegion::NorthAmerica->value => GameReleaseRegion::NorthAmerica->label(),
+                            GameReleaseRegion::Japan->value => GameReleaseRegion::Japan->label(),
+                            GameReleaseRegion::Europe->value => GameReleaseRegion::Europe->label(),
+                            GameReleaseRegion::Worldwide->value => GameReleaseRegion::Worldwide->label(),
+                        ],
+                        'Other Regions' => collect(GameReleaseRegion::cases())
+                            ->filter(fn (GameReleaseRegion $region) => !in_array($region, [
+                                GameReleaseRegion::NorthAmerica,
+                                GameReleaseRegion::Japan,
+                                GameReleaseRegion::Europe,
+                                GameReleaseRegion::Worldwide,
+                                GameReleaseRegion::Other,
+                            ]))
+                            ->mapWithKeys(fn (GameReleaseRegion $region) => [$region->value => $region->label()])
+                            ->toArray(),
+                    ])
+                    ->columnSpan(2)
+                    ->label('Region'),
+
+                Forms\Components\DatePicker::make('released_at')
+                    ->label('Release Date')
+                    ->native(false)
+                    ->minDate('1970-01-01')
+                    ->maxDate(now())
+                    ->displayFormat('F j, Y')
+                    ->reactive()
+                    ->afterStateUpdated(function (callable $set, $state) {
+                        // Set the granularity to 'day' if this is the first time released_at is set.
+                        if (!empty($state)) {
+                            $set('released_at_granularity', 'day');
+                        }
+                    }),
+
+                Forms\Components\ToggleButtons::make('released_at_granularity')
+                    ->label('Release Date Precision')
+                    ->options([
+                        ReleasedAtGranularity::Day->value => 'Day',
+                        ReleasedAtGranularity::Month->value => 'Month',
+                        ReleasedAtGranularity::Year->value => 'Year',
+                    ])
+                    ->inline()
+                    ->reactive()
+                    ->required(fn (callable $get) => !empty($get('released_at'))),
+
+                Forms\Components\Placeholder::make('released_at_display')
+                    ->label('Preview')
+                    ->content(function (Get $get): string {
+                        $releasedAt = $get('released_at');
+                        $releasedAtGranularity = $get('released_at_granularity');
+
+                        if (!$releasedAt) {
+                            return 'No release date.';
+                        }
+
+                        switch ($releasedAtGranularity) {
+                            case 'year':
+                                return Carbon::parse($releasedAt)->format('Y');
+
+                            case 'month':
+                                return Carbon::parse($releasedAt)->format('F Y');
+
+                            default:
+                                return Carbon::parse($releasedAt)->format('F j, Y');
+                        }
+                    }),
+            ]);
+    }
+
+    public function table(Table $table): Table
+    {
+        return $table
+            ->recordTitleAttribute('title')
+            ->columns([
+                Tables\Columns\TextColumn::make('released_at')
+                    ->label('Release Date')
+                    ->placeholder('unknown')
+                    ->formatStateUsing(function (GameRelease $gameRelease): ?string {
+                        $releasedAt = $gameRelease->released_at;
+                        $releasedAtGranularity = $gameRelease->released_at_granularity;
+
+                        if (!$releasedAt) {
+                            return null;
+                        }
+
+                        switch ($releasedAtGranularity) {
+                            case ReleasedAtGranularity::Year->value:
+                                return Carbon::parse($releasedAt)->format('Y');
+
+                            case ReleasedAtGranularity::Month->value:
+                                return Carbon::parse($releasedAt)->format('F Y');
+
+                            default:
+                                return Carbon::parse($releasedAt)->format('F j, Y');
+                        }
+                    })
+                    ->sortable(query: function ($query, string $direction): Builder {
+                        return $query
+                            /** @see BuildsGameListQueries.php */
+                            ->selectRaw(<<<SQL
+                                *,
+                                CASE
+                                    WHEN released_at_granularity = 'year' THEN
+                                        DATE(CONCAT(SUBSTR(released_at, 1, 4), '-01-01'))
+                                    WHEN released_at_granularity = 'month' THEN
+                                        DATE(CONCAT(SUBSTR(released_at, 1, 7), '-01'))
+                                    ELSE
+                                        COALESCE(released_at, '9999-12-31')
+                                END AS normalized_released_at,
+                                CASE released_at_granularity
+                                    WHEN 'year' THEN 1
+                                    WHEN 'month' THEN 2
+                                    WHEN 'day' THEN 3
+                                    ELSE 4
+                                END AS granularity_order
+                            SQL)
+                            // Ensure NULL release dates always sort to the end, regardless of sort direction.
+                            ->orderByRaw('released_at IS NULL')
+                            ->orderBy('normalized_released_at', $direction)
+                            ->orderBy('granularity_order', $direction);
+                    }),
+
+                Tables\Columns\TextColumn::make('region')
+                    ->formatStateUsing(function ($state): ?string {
+                        if (!$state) {
+                            return null;
+                        }
+
+                        if ($state instanceof GameReleaseRegion) {
+                            return $state->label();
+                        }
+
+                        return GameReleaseRegion::tryFrom($state)?->label() ?? $state;
+                    })
+                    ->sortable(),
+
+                Tables\Columns\TextColumn::make('title')
+                    ->sortable(),
+
+                Tables\Columns\IconColumn::make('is_canonical_game_title')
+                    ->label('Is Canonical Title')
+                    ->boolean()
+                    ->sortable(),
+            ])
+            ->filters([
+
+            ])
+            ->headerActions([
+                Tables\Actions\CreateAction::make()
+                    ->authorize(function () {
+                        /** @var User $user */
+                        $user = Auth::user();
+                        $game = $this->getOwnerRecord();
+
+                        return $user->can('create', [GameRelease::class, $game]);
+                    })
+                    ->mutateFormDataUsing(function (array $data) {
+                        // If this is marked as canonical, unmark any other canonical titles.
+                        if ($data['is_canonical_game_title'] ?? false) {
+                            GameRelease::where('game_id', $this->getOwnerRecord()->id)
+                                ->where('is_canonical_game_title', true)
+                                ->update(['is_canonical_game_title' => false]);
+                        }
+
+                        return $data;
+                    })
+                    ->after(function (GameRelease $record) {
+                        // If the new title is canonical, update the main game title.
+                        if ($record->is_canonical_game_title) {
+                            /** @var Game $game */
+                            $game = $this->getOwnerRecord();
+                            $game->Title = $record->title;
+                            $game->save();
path: app/Filament/Resources/GameResource/RelationManagers/ReleasesRelationManager.php
user: login: wescopeland
body: Unless I'm misinterpreting, this doesn't seem right.

`$game->released_at` and `$game->released_at_granularity` should always be the earliest release dates, which may not necessarily be the canonical title's release.
created_at: 2025-05-22T22:11:00Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,271 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Filament\Resources\GameResource\RelationManagers;
+
+use App\Filament\Resources\GameResource;
+use App\Models\Game;
+use App\Models\GameRelease;
+use App\Models\User;
+use App\Platform\Enums\GameReleaseRegion;
+use App\Platform\Enums\ReleasedAtGranularity;
+use Filament\Forms;
+use Filament\Forms\Form;
+use Filament\Forms\Get;
+use Filament\Resources\RelationManagers\RelationManager;
+use Filament\Tables;
+use Filament\Tables\Table;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Support\Carbon;
+use Illuminate\Support\Facades\Auth;
+
+class ReleasesRelationManager extends RelationManager
+{
+    protected static string $relationship = 'releases';
+
+    protected static ?string $recordTitleAttribute = 'title';
+
+    public static function canViewForRecord(Model $ownerRecord, string $pageClass): bool
+    {
+        /** @var User $user */
+        $user = Auth::user();
+
+        if ($ownerRecord instanceof Game) {
+            return $user->can('manage', $ownerRecord);
+        }
+
+        return false;
+    }
+
+    public function form(Form $form): Form
+    {
+        return $form
+            ->schema([
+                Forms\Components\TextInput::make('title')
+                    ->required()
+                    ->maxLength(80)
+                    ->label('Title'),
+
+                Forms\Components\Toggle::make('is_canonical_game_title')
+                    ->label('Is Canonical Title')
+                    ->helperText('If enabled, this will be used as the main title for the game on game pages and game lists. Only one title per game can be canonical.')
+                    ->disabled(fn (?GameRelease $record) => $record && $record->is_canonical_game_title),
+
+                Forms\Components\Select::make('region')
+                    ->searchable()
+                    ->options([
+                        'Common Regions' => [
+                            GameReleaseRegion::NorthAmerica->value => GameReleaseRegion::NorthAmerica->label(),
+                            GameReleaseRegion::Japan->value => GameReleaseRegion::Japan->label(),
+                            GameReleaseRegion::Europe->value => GameReleaseRegion::Europe->label(),
+                            GameReleaseRegion::Worldwide->value => GameReleaseRegion::Worldwide->label(),
+                        ],
+                        'Other Regions' => collect(GameReleaseRegion::cases())
+                            ->filter(fn (GameReleaseRegion $region) => !in_array($region, [
+                                GameReleaseRegion::NorthAmerica,
+                                GameReleaseRegion::Japan,
+                                GameReleaseRegion::Europe,
+                                GameReleaseRegion::Worldwide,
+                                GameReleaseRegion::Other,
+                            ]))
+                            ->mapWithKeys(fn (GameReleaseRegion $region) => [$region->value => $region->label()])
+                            ->toArray(),
+                    ])
+                    ->columnSpan(2)
+                    ->label('Region'),
+
+                Forms\Components\DatePicker::make('released_at')
+                    ->label('Release Date')
+                    ->native(false)
+                    ->minDate('1970-01-01')
+                    ->maxDate(now())
+                    ->displayFormat('F j, Y')
+                    ->reactive()
+                    ->afterStateUpdated(function (callable $set, $state) {
+                        // Set the granularity to 'day' if this is the first time released_at is set.
+                        if (!empty($state)) {
+                            $set('released_at_granularity', 'day');
+                        }
+                    }),
+
+                Forms\Components\ToggleButtons::make('released_at_granularity')
+                    ->label('Release Date Precision')
+                    ->options([
+                        ReleasedAtGranularity::Day->value => 'Day',
+                        ReleasedAtGranularity::Month->value => 'Month',
+                        ReleasedAtGranularity::Year->value => 'Year',
+                    ])
+                    ->inline()
+                    ->reactive()
+                    ->required(fn (callable $get) => !empty($get('released_at'))),
+
+                Forms\Components\Placeholder::make('released_at_display')
+                    ->label('Preview')
+                    ->content(function (Get $get): string {
+                        $releasedAt = $get('released_at');
+                        $releasedAtGranularity = $get('released_at_granularity');
+
+                        if (!$releasedAt) {
+                            return 'No release date.';
+                        }
+
+                        switch ($releasedAtGranularity) {
+                            case 'year':
+                                return Carbon::parse($releasedAt)->format('Y');
+
+                            case 'month':
+                                return Carbon::parse($releasedAt)->format('F Y');
+
+                            default:
+                                return Carbon::parse($releasedAt)->format('F j, Y');
+                        }
+                    }),
+            ]);
+    }
+
+    public function table(Table $table): Table
+    {
+        return $table
+            ->recordTitleAttribute('title')
+            ->columns([
+                Tables\Columns\TextColumn::make('released_at')
+                    ->label('Release Date')
+                    ->placeholder('unknown')
+                    ->formatStateUsing(function (GameRelease $gameRelease): ?string {
+                        $releasedAt = $gameRelease->released_at;
+                        $releasedAtGranularity = $gameRelease->released_at_granularity;
+
+                        if (!$releasedAt) {
+                            return null;
+                        }
+
+                        switch ($releasedAtGranularity) {
+                            case ReleasedAtGranularity::Year->value:
+                                return Carbon::parse($releasedAt)->format('Y');
+
+                            case ReleasedAtGranularity::Month->value:
+                                return Carbon::parse($releasedAt)->format('F Y');
+
+                            default:
+                                return Carbon::parse($releasedAt)->format('F j, Y');
+                        }
path: app/Filament/Resources/GameResource/RelationManagers/ReleasesRelationManager.php
user: login: wescopeland
body: Fixed in latest.
created_at: 2025-05-22T22:11:24Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,186 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Filament\Resources\GameResource\RelationManagers;
+
+use App\Filament\Resources\GameResource;
+use App\Models\Game;
+use App\Models\GameTitle;
+use App\Models\User;
+use App\Platform\Enums\GameReleaseRegion;
+use Filament\Forms;
+use Filament\Forms\Form;
+use Filament\Resources\RelationManagers\RelationManager;
+use Filament\Tables;
+use Filament\Tables\Table;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Support\Facades\Auth;
+
+class TitlesRelationManager extends RelationManager
+{
+    protected static string $relationship = 'titles';
+
+    protected static ?string $recordTitleAttribute = 'title';
+
+    public static function canViewForRecord(Model $ownerRecord, string $pageClass): bool
+    {
+        /** @var User $user */
+        $user = Auth::user();
+
+        if ($ownerRecord instanceof Game) {
+            return $user->can('manage', $ownerRecord);
+        }
+
+        return false;
+    }
+
+    public function form(Form $form): Form
+    {
+        return $form
+            ->schema([
+                Forms\Components\TextInput::make('title')
+                    ->required()
+                    ->maxLength(80)
+                    ->label('Title'),
+
+                Forms\Components\Select::make('region')
+                    ->searchable()
+                    ->options([
+                        'Common Regions' => [
+                            GameReleaseRegion::NorthAmerica->value => GameReleaseRegion::NorthAmerica->label(),
+                            GameReleaseRegion::Japan->value => GameReleaseRegion::Japan->label(),
+                            GameReleaseRegion::Europe->value => GameReleaseRegion::Europe->label(),
+                            GameReleaseRegion::Worldwide->value => GameReleaseRegion::Worldwide->label(),
+                        ],
+                        'Other Regions' => collect(GameReleaseRegion::cases())
+                            ->filter(fn (GameReleaseRegion $region) => !in_array($region, [
+                                GameReleaseRegion::NorthAmerica,
+                                GameReleaseRegion::Japan,
+                                GameReleaseRegion::Europe,
+                                GameReleaseRegion::Worldwide,
+                                GameReleaseRegion::Other,
+                            ]))
+                            ->mapWithKeys(fn (GameReleaseRegion $region) => [$region->value => $region->label()])
+                            ->toArray(),
+                        'Special' => [
+                            GameReleaseRegion::Other->value => GameReleaseRegion::Other->label(),
+                        ],
path: app/Filament/Resources/GameResource/RelationManagers/TitlesRelationManager.php
user: login: wescopeland
body: Fixed in latest.
created_at: 2025-05-22T22:12:08Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,271 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Filament\Resources\GameResource\RelationManagers;
+
+use App\Filament\Resources\GameResource;
+use App\Models\Game;
+use App\Models\GameRelease;
+use App\Models\User;
+use App\Platform\Enums\GameReleaseRegion;
+use App\Platform\Enums\ReleasedAtGranularity;
+use Filament\Forms;
+use Filament\Forms\Form;
+use Filament\Forms\Get;
+use Filament\Resources\RelationManagers\RelationManager;
+use Filament\Tables;
+use Filament\Tables\Table;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Support\Carbon;
+use Illuminate\Support\Facades\Auth;
+
+class ReleasesRelationManager extends RelationManager
+{
+    protected static string $relationship = 'releases';
+
+    protected static ?string $recordTitleAttribute = 'title';
+
+    public static function canViewForRecord(Model $ownerRecord, string $pageClass): bool
+    {
+        /** @var User $user */
+        $user = Auth::user();
+
+        if ($ownerRecord instanceof Game) {
+            return $user->can('manage', $ownerRecord);
+        }
+
+        return false;
+    }
+
+    public function form(Form $form): Form
+    {
+        return $form
+            ->schema([
+                Forms\Components\TextInput::make('title')
+                    ->required()
+                    ->maxLength(80)
+                    ->label('Title'),
+
+                Forms\Components\Toggle::make('is_canonical_game_title')
+                    ->label('Is Canonical Title')
+                    ->helperText('If enabled, this will be used as the main title for the game on game pages and game lists. Only one title per game can be canonical.')
+                    ->disabled(fn (?GameRelease $record) => $record && $record->is_canonical_game_title),
+
+                Forms\Components\Select::make('region')
+                    ->searchable()
+                    ->options([
+                        'Common Regions' => [
+                            GameReleaseRegion::NorthAmerica->value => GameReleaseRegion::NorthAmerica->label(),
+                            GameReleaseRegion::Japan->value => GameReleaseRegion::Japan->label(),
+                            GameReleaseRegion::Europe->value => GameReleaseRegion::Europe->label(),
+                            GameReleaseRegion::Worldwide->value => GameReleaseRegion::Worldwide->label(),
+                        ],
+                        'Other Regions' => collect(GameReleaseRegion::cases())
+                            ->filter(fn (GameReleaseRegion $region) => !in_array($region, [
+                                GameReleaseRegion::NorthAmerica,
+                                GameReleaseRegion::Japan,
+                                GameReleaseRegion::Europe,
+                                GameReleaseRegion::Worldwide,
+                                GameReleaseRegion::Other,
+                            ]))
+                            ->mapWithKeys(fn (GameReleaseRegion $region) => [$region->value => $region->label()])
+                            ->toArray(),
+                    ])
+                    ->columnSpan(2)
+                    ->label('Region'),
+
+                Forms\Components\DatePicker::make('released_at')
+                    ->label('Release Date')
+                    ->native(false)
+                    ->minDate('1970-01-01')
+                    ->maxDate(now())
+                    ->displayFormat('F j, Y')
+                    ->reactive()
+                    ->afterStateUpdated(function (callable $set, $state) {
+                        // Set the granularity to 'day' if this is the first time released_at is set.
+                        if (!empty($state)) {
+                            $set('released_at_granularity', 'day');
+                        }
+                    }),
+
+                Forms\Components\ToggleButtons::make('released_at_granularity')
+                    ->label('Release Date Precision')
+                    ->options([
+                        ReleasedAtGranularity::Day->value => 'Day',
+                        ReleasedAtGranularity::Month->value => 'Month',
+                        ReleasedAtGranularity::Year->value => 'Year',
+                    ])
+                    ->inline()
+                    ->reactive()
+                    ->required(fn (callable $get) => !empty($get('released_at'))),
+
+                Forms\Components\Placeholder::make('released_at_display')
+                    ->label('Preview')
+                    ->content(function (Get $get): string {
+                        $releasedAt = $get('released_at');
+                        $releasedAtGranularity = $get('released_at_granularity');
+
+                        if (!$releasedAt) {
+                            return 'No release date.';
+                        }
+
+                        switch ($releasedAtGranularity) {
+                            case 'year':
+                                return Carbon::parse($releasedAt)->format('Y');
+
+                            case 'month':
+                                return Carbon::parse($releasedAt)->format('F Y');
+
+                            default:
+                                return Carbon::parse($releasedAt)->format('F j, Y');
+                        }
+                    }),
+            ]);
+    }
+
+    public function table(Table $table): Table
+    {
+        return $table
+            ->recordTitleAttribute('title')
+            ->columns([
+                Tables\Columns\TextColumn::make('released_at')
+                    ->label('Release Date')
+                    ->placeholder('unknown')
+                    ->formatStateUsing(function (GameRelease $gameRelease): ?string {
+                        $releasedAt = $gameRelease->released_at;
+                        $releasedAtGranularity = $gameRelease->released_at_granularity;
+
+                        if (!$releasedAt) {
+                            return null;
+                        }
+
+                        switch ($releasedAtGranularity) {
+                            case ReleasedAtGranularity::Year->value:
+                                return Carbon::parse($releasedAt)->format('Y');
+
+                            case ReleasedAtGranularity::Month->value:
+                                return Carbon::parse($releasedAt)->format('F Y');
+
+                            default:
+                                return Carbon::parse($releasedAt)->format('F j, Y');
+                        }
+                    })
+                    ->sortable(query: function ($query, string $direction): Builder {
+                        return $query
+                            /** @see BuildsGameListQueries.php */
+                            ->selectRaw(<<<SQL
+                                *,
+                                CASE
+                                    WHEN released_at_granularity = 'year' THEN
+                                        DATE(CONCAT(SUBSTR(released_at, 1, 4), '-01-01'))
+                                    WHEN released_at_granularity = 'month' THEN
+                                        DATE(CONCAT(SUBSTR(released_at, 1, 7), '-01'))
+                                    ELSE
+                                        COALESCE(released_at, '9999-12-31')
+                                END AS normalized_released_at,
+                                CASE released_at_granularity
+                                    WHEN 'year' THEN 1
+                                    WHEN 'month' THEN 2
+                                    WHEN 'day' THEN 3
+                                    ELSE 4
+                                END AS granularity_order
+                            SQL)
+                            // Ensure NULL release dates always sort to the end, regardless of sort direction.
+                            ->orderByRaw('released_at IS NULL')
+                            ->orderBy('normalized_released_at', $direction)
+                            ->orderBy('granularity_order', $direction);
+                    }),
+
+                Tables\Columns\TextColumn::make('region')
+                    ->formatStateUsing(function ($state): ?string {
+                        if (!$state) {
+                            return null;
+                        }
+
+                        if ($state instanceof GameReleaseRegion) {
+                            return $state->label();
+                        }
+
+                        return GameReleaseRegion::tryFrom($state)?->label() ?? $state;
+                    })
+                    ->sortable(),
+
+                Tables\Columns\TextColumn::make('title')
+                    ->sortable(),
+
+                Tables\Columns\IconColumn::make('is_canonical_game_title')
+                    ->label('Is Canonical Title')
+                    ->boolean()
+                    ->sortable(),
+            ])
+            ->filters([
+
+            ])
+            ->headerActions([
+                Tables\Actions\CreateAction::make()
+                    ->authorize(function () {
+                        /** @var User $user */
+                        $user = Auth::user();
+                        $game = $this->getOwnerRecord();
+
+                        return $user->can('create', [GameRelease::class, $game]);
+                    })
+                    ->mutateFormDataUsing(function (array $data) {
+                        // If this is marked as canonical, unmark any other canonical titles.
+                        if ($data['is_canonical_game_title'] ?? false) {
+                            GameRelease::where('game_id', $this->getOwnerRecord()->id)
+                                ->where('is_canonical_game_title', true)
+                                ->update(['is_canonical_game_title' => false]);
+                        }
+
+                        return $data;
+                    })
+                    ->after(function (GameRelease $record) {
+                        // If the new title is canonical, update the main game title.
+                        if ($record->is_canonical_game_title) {
+                            /** @var Game $game */
+                            $game = $this->getOwnerRecord();
+                            $game->Title = $record->title;
+                            $game->save();
path: app/Filament/Resources/GameResource/RelationManagers/ReleasesRelationManager.php
user: login: Jamiras
body: I could be misunderstanding too. Changing the canonical release makes the game page show the title from that release. Should it not show everything related to that release?

I can confirm that the game page is showing the earliest release.
created_at: 2025-05-23T01:03:41Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,271 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Filament\Resources\GameResource\RelationManagers;
+
+use App\Filament\Resources\GameResource;
+use App\Models\Game;
+use App\Models\GameRelease;
+use App\Models\User;
+use App\Platform\Enums\GameReleaseRegion;
+use App\Platform\Enums\ReleasedAtGranularity;
+use Filament\Forms;
+use Filament\Forms\Form;
+use Filament\Forms\Get;
+use Filament\Resources\RelationManagers\RelationManager;
+use Filament\Tables;
+use Filament\Tables\Table;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Support\Carbon;
+use Illuminate\Support\Facades\Auth;
+
+class ReleasesRelationManager extends RelationManager
+{
+    protected static string $relationship = 'releases';
+
+    protected static ?string $recordTitleAttribute = 'title';
+
+    public static function canViewForRecord(Model $ownerRecord, string $pageClass): bool
+    {
+        /** @var User $user */
+        $user = Auth::user();
+
+        if ($ownerRecord instanceof Game) {
+            return $user->can('manage', $ownerRecord);
+        }
+
+        return false;
+    }
+
+    public function form(Form $form): Form
+    {
+        return $form
+            ->schema([
+                Forms\Components\TextInput::make('title')
+                    ->required()
+                    ->maxLength(80)
+                    ->label('Title'),
+
+                Forms\Components\Toggle::make('is_canonical_game_title')
+                    ->label('Is Canonical Title')
+                    ->helperText('If enabled, this will be used as the main title for the game on game pages and game lists. Only one title per game can be canonical.')
+                    ->disabled(fn (?GameRelease $record) => $record && $record->is_canonical_game_title),
+
+                Forms\Components\Select::make('region')
+                    ->searchable()
+                    ->options([
+                        'Common Regions' => [
+                            GameReleaseRegion::NorthAmerica->value => GameReleaseRegion::NorthAmerica->label(),
+                            GameReleaseRegion::Japan->value => GameReleaseRegion::Japan->label(),
+                            GameReleaseRegion::Europe->value => GameReleaseRegion::Europe->label(),
+                            GameReleaseRegion::Worldwide->value => GameReleaseRegion::Worldwide->label(),
+                        ],
+                        'Other Regions' => collect(GameReleaseRegion::cases())
+                            ->filter(fn (GameReleaseRegion $region) => !in_array($region, [
+                                GameReleaseRegion::NorthAmerica,
+                                GameReleaseRegion::Japan,
+                                GameReleaseRegion::Europe,
+                                GameReleaseRegion::Worldwide,
+                                GameReleaseRegion::Other,
+                            ]))
+                            ->mapWithKeys(fn (GameReleaseRegion $region) => [$region->value => $region->label()])
+                            ->toArray(),
+                    ])
+                    ->columnSpan(2)
+                    ->label('Region'),
+
+                Forms\Components\DatePicker::make('released_at')
+                    ->label('Release Date')
+                    ->native(false)
+                    ->minDate('1970-01-01')
+                    ->maxDate(now())
+                    ->displayFormat('F j, Y')
+                    ->reactive()
+                    ->afterStateUpdated(function (callable $set, $state) {
+                        // Set the granularity to 'day' if this is the first time released_at is set.
+                        if (!empty($state)) {
+                            $set('released_at_granularity', 'day');
+                        }
+                    }),
+
+                Forms\Components\ToggleButtons::make('released_at_granularity')
+                    ->label('Release Date Precision')
+                    ->options([
+                        ReleasedAtGranularity::Day->value => 'Day',
+                        ReleasedAtGranularity::Month->value => 'Month',
+                        ReleasedAtGranularity::Year->value => 'Year',
+                    ])
+                    ->inline()
+                    ->reactive()
+                    ->required(fn (callable $get) => !empty($get('released_at'))),
+
+                Forms\Components\Placeholder::make('released_at_display')
+                    ->label('Preview')
+                    ->content(function (Get $get): string {
+                        $releasedAt = $get('released_at');
+                        $releasedAtGranularity = $get('released_at_granularity');
+
+                        if (!$releasedAt) {
+                            return 'No release date.';
+                        }
+
+                        switch ($releasedAtGranularity) {
+                            case 'year':
+                                return Carbon::parse($releasedAt)->format('Y');
+
+                            case 'month':
+                                return Carbon::parse($releasedAt)->format('F Y');
+
+                            default:
+                                return Carbon::parse($releasedAt)->format('F j, Y');
+                        }
+                    }),
+            ]);
+    }
+
+    public function table(Table $table): Table
+    {
+        return $table
+            ->recordTitleAttribute('title')
+            ->columns([
+                Tables\Columns\TextColumn::make('released_at')
+                    ->label('Release Date')
+                    ->placeholder('unknown')
+                    ->formatStateUsing(function (GameRelease $gameRelease): ?string {
+                        $releasedAt = $gameRelease->released_at;
+                        $releasedAtGranularity = $gameRelease->released_at_granularity;
+
+                        if (!$releasedAt) {
+                            return null;
+                        }
+
+                        switch ($releasedAtGranularity) {
+                            case ReleasedAtGranularity::Year->value:
+                                return Carbon::parse($releasedAt)->format('Y');
+
+                            case ReleasedAtGranularity::Month->value:
+                                return Carbon::parse($releasedAt)->format('F Y');
+
+                            default:
+                                return Carbon::parse($releasedAt)->format('F j, Y');
+                        }
+                    })
+                    ->sortable(query: function ($query, string $direction): Builder {
+                        return $query
+                            /** @see BuildsGameListQueries.php */
+                            ->selectRaw(<<<SQL
+                                *,
+                                CASE
+                                    WHEN released_at_granularity = 'year' THEN
+                                        DATE(CONCAT(SUBSTR(released_at, 1, 4), '-01-01'))
+                                    WHEN released_at_granularity = 'month' THEN
+                                        DATE(CONCAT(SUBSTR(released_at, 1, 7), '-01'))
+                                    ELSE
+                                        COALESCE(released_at, '9999-12-31')
+                                END AS normalized_released_at,
+                                CASE released_at_granularity
+                                    WHEN 'year' THEN 1
+                                    WHEN 'month' THEN 2
+                                    WHEN 'day' THEN 3
+                                    ELSE 4
+                                END AS granularity_order
+                            SQL)
+                            // Ensure NULL release dates always sort to the end, regardless of sort direction.
+                            ->orderByRaw('released_at IS NULL')
+                            ->orderBy('normalized_released_at', $direction)
+                            ->orderBy('granularity_order', $direction);
+                    }),
+
+                Tables\Columns\TextColumn::make('region')
+                    ->formatStateUsing(function ($state): ?string {
+                        if (!$state) {
+                            return null;
+                        }
+
+                        if ($state instanceof GameReleaseRegion) {
+                            return $state->label();
+                        }
+
+                        return GameReleaseRegion::tryFrom($state)?->label() ?? $state;
+                    })
+                    ->sortable(),
+
+                Tables\Columns\TextColumn::make('title')
+                    ->sortable(),
+
+                Tables\Columns\IconColumn::make('is_canonical_game_title')
+                    ->label('Is Canonical Title')
+                    ->boolean()
+                    ->sortable(),
+            ])
+            ->filters([
+
+            ])
+            ->headerActions([
+                Tables\Actions\CreateAction::make()
+                    ->authorize(function () {
+                        /** @var User $user */
+                        $user = Auth::user();
+                        $game = $this->getOwnerRecord();
+
+                        return $user->can('create', [GameRelease::class, $game]);
+                    })
+                    ->mutateFormDataUsing(function (array $data) {
+                        // If this is marked as canonical, unmark any other canonical titles.
+                        if ($data['is_canonical_game_title'] ?? false) {
+                            GameRelease::where('game_id', $this->getOwnerRecord()->id)
+                                ->where('is_canonical_game_title', true)
+                                ->update(['is_canonical_game_title' => false]);
+                        }
+
+                        return $data;
+                    })
+                    ->after(function (GameRelease $record) {
+                        // If the new title is canonical, update the main game title.
+                        if ($record->is_canonical_game_title) {
+                            /** @var Game $game */
+                            $game = $this->getOwnerRecord();
+                            $game->Title = $record->title;
+                            $game->save();
path: app/Filament/Resources/GameResource/RelationManagers/ReleasesRelationManager.php
user: login: wescopeland
body: What you're seeing is indeed the expected behavior. Imagine this scenario:

In 1982, Activision releases a game called "Speedy Car" on some console in Europe.
In 1983, they release the same game in the US and call it "Dragster".

All future marketing materials change the game's name to "Dragster", and the box label in Europe for future sales are also changed to the new name. The game's earliest release was in 1982, but the canonical title is "Dragster".
created_at: 2025-05-23T11:16:21Z
author_association: MEMBER

PR comments:
user: login: Jamiras
created_at: 2025-05-20T19:57:43Z
body: > sail artisan import "App\Model\Game" # reindex games with their alt titles

should be
```
sail artisan scout:import "App\Models\Game" # reindex games with their alt titles
```
author_association: MEMBER
user: login: Jamiras
created_at: 2025-05-22T15:38:57Z
body: ```
sail artisan ra:sync:game-titles # sync all canonical titles, which all receive a "Worldwide" region
```
needs to be updated to `ra:sync:game-releases`
author_association: MEMBER
user: login: sentry[bot]
created_at: 2025-05-23T11:28:48Z
body: ## Suspect Issues
This pull request was deployed and Sentry observed the following issues:

- ‼️ **Error: Call to a member function where() on null** `/app/Models/Game.php in App\Models\Game::toSear...` [View Issue](https://retroachievementsorg.sentry.io/issues/43001211/?referrer=github-pr-bot)
- ‼️ **Illuminate\Database\UniqueConstraintViolationException: SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry 'Mario Kart 64-2' for key 'games_title_system_id_unique' (Connection: mysql, SQL: update `GameData` set `Title` = Mario Kart 64, `GameData`.`Updated` = 2025-05-25 01:38:37 where `ID` ...** `livewire?component=app.filament.resources.game-...` [View Issue](https://retroachievementsorg.sentry.io/issues/43188336/?referrer=github-pr-bot)

<sub>Did you find this useful? React with a 👍 or 👎</sub>
author_association: NONE