diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml
index ca503f0a2..5bf14ab4a 100644
--- a/.github/workflows/pr.yaml
+++ b/.github/workflows/pr.yaml
@@ -104,6 +104,70 @@ jobs:
- name: Validate Doctrine schema
run: APP_ENV=prod php bin/console doctrine:schema:validate
+ validate-doctrine-schema-postgres:
+ runs-on: ubuntu-latest
+ env:
+ DATABASE_URL: postgresql://db:db@127.0.0.1:5432/db?serverVersion=16&charset=utf8
+ strategy:
+ fail-fast: false
+ matrix:
+ php: ["8.3"]
+ name: Validate Doctrine Schema on Postgres (PHP ${{ matrix.php }})
+ services:
+ postgres:
+ image: postgres:16-alpine
+ env:
+ POSTGRES_USER: db
+ POSTGRES_PASSWORD: db
+ POSTGRES_DB: db
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd "pg_isready -U db -d db"
+ --health-interval=5s
+ --health-timeout=3s
+ --health-retries=10
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup PHP, with composer and extensions
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php}}
+ # pgsql/pdo_pgsql added on top of the standard extension list so
+ # Doctrine can connect to Postgres for the portability gate.
+ extensions: apcu, ctype, iconv, imagick, json, pdo_pgsql, pgsql, redis, soap, xmlreader, zip
+ coverage: none
+
+ - name: Get composer cache directory
+ id: composer-cache
+ run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
+
+ - name: Cache composer dependencies
+ uses: actions/cache@v4
+ with:
+ path: ${{ steps.composer-cache.outputs.dir }}
+ key: ${{ matrix.php }}-composer-${{ hashFiles('**/composer.lock') }}
+ restore-keys: ${{ matrix.php }}-composer-
+
+ - name: 'Composer install with exported .env variables'
+ run: |
+ set -a && source .env && set +a
+ APP_ENV=prod composer install --no-dev -o
+
+ # The 2.x historical migrations use raw MariaDB SQL and can't run on
+ # Postgres — that's the whole reason this PR exists. We instead apply
+ # the entity layer directly via schema:update, which tests the
+ # load-bearing claim that the *metadata* (after the rename) is
+ # platform-portable. doctrine:schema:validate then catches any drift
+ # between Postgres' generated DDL and what Doctrine expects.
+ - name: Apply entity metadata to Postgres (schema:update)
+ run: APP_ENV=prod php bin/console doctrine:schema:update --force --complete --no-interaction
+
+ - name: Validate Doctrine schema (Postgres)
+ run: APP_ENV=prod php bin/console doctrine:schema:validate
+
php-cs-fixer:
runs-on: ubuntu-latest
strategy:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9fa2d98a3..3f6d8436e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,10 @@ All notable changes to this project will be documented in this file.
## [2.7.0] - 2026-05-01
+- [#444](https://github.com/os2display/display-api-service/pull/444)
+ - Renamed 15 `changed_idx` indexes to `
_changed_idx` for cross-platform portability (Postgres scopes index names schema-wide).
+ - Quoted `user` table identifier in entity metadata so Doctrine emits the platform-native quote on every reference.
+ - Added Postgres CI gate that runs `doctrine:schema:update --force --complete` + `doctrine:schema:validate` against a Postgres 16 service container.
- [#363](https://github.com/os2display/display-api-service/pull/363)
- Added optional 'area' and 'facility' configuration fields
- [#362](https://github.com/os2display/display-api-service/pull/362)
diff --git a/migrations/Version20260507120000.php b/migrations/Version20260507120000.php
new file mode 100644
index 000000000..0b9f36d03
--- /dev/null
+++ b/migrations/Version20260507120000.php
@@ -0,0 +1,59 @@
+addSql(sprintf('ALTER TABLE `%s` RENAME INDEX `changed_idx` TO `%s_changed_idx`', $table, $table));
+ }
+ }
+
+ public function down(Schema $schema): void
+ {
+ foreach (self::CHANGED_IDX_TABLES as $table) {
+ $this->addSql(sprintf('ALTER TABLE `%s` RENAME INDEX `%s_changed_idx` TO `changed_idx`', $table, $table));
+ }
+ }
+}
diff --git a/src/Entity/ScreenLayout.php b/src/Entity/ScreenLayout.php
index 89b66e5af..fea344753 100644
--- a/src/Entity/ScreenLayout.php
+++ b/src/Entity/ScreenLayout.php
@@ -17,7 +17,7 @@
#[ORM\Entity(repositoryClass: ScreenLayoutRepository::class)]
#[ORM\EntityListeners([\App\EventListener\ScreenLayoutDoctrineEventListener::class])]
-#[ORM\Index(fields: ['changed'], name: 'changed_idx')]
+#[ORM\Index(fields: ['changed'], name: 'screen_layout_changed_idx')]
class ScreenLayout extends AbstractBaseEntity implements MultiTenantInterface, RelationsChecksumInterface
{
use MultiTenantTrait;
diff --git a/src/Entity/ScreenLayoutRegions.php b/src/Entity/ScreenLayoutRegions.php
index 0c5466511..e27a7cfda 100644
--- a/src/Entity/ScreenLayoutRegions.php
+++ b/src/Entity/ScreenLayoutRegions.php
@@ -17,7 +17,7 @@
#[ORM\Entity(repositoryClass: ScreenLayoutRegionsRepository::class)]
#[ORM\EntityListeners([\App\EventListener\ScreenLayoutRegionsDoctrineEventListener::class])]
-#[ORM\Index(fields: ['changed'], name: 'changed_idx')]
+#[ORM\Index(fields: ['changed'], name: 'screen_layout_regions_changed_idx')]
class ScreenLayoutRegions extends AbstractBaseEntity implements MultiTenantInterface, RelationsChecksumInterface
{
use MultiTenantTrait;
diff --git a/src/Entity/Template.php b/src/Entity/Template.php
index b85245388..1539ed124 100644
--- a/src/Entity/Template.php
+++ b/src/Entity/Template.php
@@ -17,7 +17,7 @@
#[ORM\Entity(repositoryClass: TemplateRepository::class)]
#[ORM\EntityListeners([\App\EventListener\TemplateDoctrineEventListener::class])]
-#[ORM\Index(fields: ['changed'], name: 'changed_idx')]
+#[ORM\Index(fields: ['changed'], name: 'template_changed_idx')]
class Template extends AbstractBaseEntity implements MultiTenantInterface, RelationsChecksumInterface
{
use MultiTenantTrait;
diff --git a/src/Entity/Tenant/Feed.php b/src/Entity/Tenant/Feed.php
index 4c7ed2cd6..70cc438e5 100644
--- a/src/Entity/Tenant/Feed.php
+++ b/src/Entity/Tenant/Feed.php
@@ -11,7 +11,7 @@
#[ORM\Entity(repositoryClass: FeedRepository::class)]
#[ORM\EntityListeners([\App\EventListener\FeedDoctrineEventListener::class])]
-#[ORM\Index(fields: ['changed'], name: 'changed_idx')]
+#[ORM\Index(fields: ['changed'], name: 'feed_changed_idx')]
class Feed extends AbstractTenantScopedEntity implements RelationsChecksumInterface
{
use RelationsChecksumTrait;
diff --git a/src/Entity/Tenant/FeedSource.php b/src/Entity/Tenant/FeedSource.php
index b580bfe7d..26edbb3ca 100644
--- a/src/Entity/Tenant/FeedSource.php
+++ b/src/Entity/Tenant/FeedSource.php
@@ -14,7 +14,7 @@
#[ORM\Entity(repositoryClass: FeedSourceRepository::class)]
#[ORM\EntityListeners([\App\EventListener\FeedSourceDoctrineEventListener::class])]
-#[ORM\Index(fields: ['changed'], name: 'changed_idx')]
+#[ORM\Index(fields: ['changed'], name: 'feed_source_changed_idx')]
class FeedSource extends AbstractTenantScopedEntity implements RelationsChecksumInterface
{
use EntityTitleDescriptionTrait;
diff --git a/src/Entity/Tenant/Media.php b/src/Entity/Tenant/Media.php
index 1b285648e..dff8894c0 100644
--- a/src/Entity/Tenant/Media.php
+++ b/src/Entity/Tenant/Media.php
@@ -18,7 +18,7 @@
#[Vich\Uploadable]
#[ORM\Entity(repositoryClass: MediaRepository::class)]
#[ORM\EntityListeners([\App\EventListener\MediaDoctrineEventListener::class])]
-#[ORM\Index(fields: ['changed'], name: 'changed_idx')]
+#[ORM\Index(fields: ['changed'], name: 'media_changed_idx')]
class Media extends AbstractTenantScopedEntity implements RelationsChecksumInterface
{
use EntityTitleDescriptionTrait;
diff --git a/src/Entity/Tenant/Playlist.php b/src/Entity/Tenant/Playlist.php
index d565ff84a..f81f19731 100644
--- a/src/Entity/Tenant/Playlist.php
+++ b/src/Entity/Tenant/Playlist.php
@@ -17,7 +17,7 @@
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: PlaylistRepository::class)]
-#[ORM\Index(fields: ['changed'], name: 'changed_idx')]
+#[ORM\Index(fields: ['changed'], name: 'playlist_changed_idx')]
class Playlist extends AbstractTenantScopedEntity implements MultiTenantInterface, RelationsChecksumInterface
{
use EntityPublishedTrait;
diff --git a/src/Entity/Tenant/PlaylistScreenRegion.php b/src/Entity/Tenant/PlaylistScreenRegion.php
index 97a7a92b8..59cc1282c 100644
--- a/src/Entity/Tenant/PlaylistScreenRegion.php
+++ b/src/Entity/Tenant/PlaylistScreenRegion.php
@@ -12,7 +12,7 @@
#[ORM\UniqueConstraint(name: 'unique_playlist_screen_region', columns: ['playlist_id', 'screen_id', 'region_id'])]
#[ORM\Entity(repositoryClass: PlaylistScreenRegionRepository::class)]
-#[ORM\Index(fields: ['changed'], name: 'changed_idx')]
+#[ORM\Index(fields: ['changed'], name: 'playlist_screen_region_changed_idx')]
class PlaylistScreenRegion extends AbstractTenantScopedEntity implements RelationsChecksumInterface
{
use RelationsChecksumTrait;
diff --git a/src/Entity/Tenant/PlaylistSlide.php b/src/Entity/Tenant/PlaylistSlide.php
index ae8f0a1da..a3a444909 100644
--- a/src/Entity/Tenant/PlaylistSlide.php
+++ b/src/Entity/Tenant/PlaylistSlide.php
@@ -10,7 +10,7 @@
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: PlaylistSlideRepository::class)]
-#[ORM\Index(fields: ['changed'], name: 'changed_idx')]
+#[ORM\Index(fields: ['changed'], name: 'playlist_slide_changed_idx')]
class PlaylistSlide extends AbstractTenantScopedEntity implements RelationsChecksumInterface
{
use RelationsChecksumTrait;
diff --git a/src/Entity/Tenant/Screen.php b/src/Entity/Tenant/Screen.php
index 9fde5553e..d735c09c1 100644
--- a/src/Entity/Tenant/Screen.php
+++ b/src/Entity/Tenant/Screen.php
@@ -15,7 +15,7 @@
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: ScreenRepository::class)]
-#[ORM\Index(fields: ['changed'], name: 'changed_idx')]
+#[ORM\Index(fields: ['changed'], name: 'screen_changed_idx')]
class Screen extends AbstractTenantScopedEntity implements RelationsChecksumInterface
{
use EntityTitleDescriptionTrait;
diff --git a/src/Entity/Tenant/ScreenCampaign.php b/src/Entity/Tenant/ScreenCampaign.php
index 1101cbf31..599f8ac91 100644
--- a/src/Entity/Tenant/ScreenCampaign.php
+++ b/src/Entity/Tenant/ScreenCampaign.php
@@ -10,7 +10,7 @@
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: ScreenCampaignRepository::class)]
-#[ORM\Index(fields: ['changed'], name: 'changed_idx')]
+#[ORM\Index(fields: ['changed'], name: 'screen_campaign_changed_idx')]
class ScreenCampaign extends AbstractTenantScopedEntity implements RelationsChecksumInterface
{
use RelationsChecksumTrait;
diff --git a/src/Entity/Tenant/ScreenGroup.php b/src/Entity/Tenant/ScreenGroup.php
index 228ebe82d..f36047614 100644
--- a/src/Entity/Tenant/ScreenGroup.php
+++ b/src/Entity/Tenant/ScreenGroup.php
@@ -13,7 +13,7 @@
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: ScreenGroupRepository::class)]
-#[ORM\Index(fields: ['changed'], name: 'changed_idx')]
+#[ORM\Index(fields: ['changed'], name: 'screen_group_changed_idx')]
class ScreenGroup extends AbstractTenantScopedEntity implements RelationsChecksumInterface
{
use EntityTitleDescriptionTrait;
diff --git a/src/Entity/Tenant/ScreenGroupCampaign.php b/src/Entity/Tenant/ScreenGroupCampaign.php
index 8337d3795..8d530575b 100644
--- a/src/Entity/Tenant/ScreenGroupCampaign.php
+++ b/src/Entity/Tenant/ScreenGroupCampaign.php
@@ -10,7 +10,7 @@
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: ScreenGroupCampaignRepository::class)]
-#[ORM\Index(fields: ['changed'], name: 'changed_idx')]
+#[ORM\Index(fields: ['changed'], name: 'screen_group_campaign_changed_idx')]
class ScreenGroupCampaign extends AbstractTenantScopedEntity implements RelationsChecksumInterface
{
use RelationsChecksumTrait;
diff --git a/src/Entity/Tenant/Slide.php b/src/Entity/Tenant/Slide.php
index 82e1fbd7f..43e5e933f 100644
--- a/src/Entity/Tenant/Slide.php
+++ b/src/Entity/Tenant/Slide.php
@@ -15,7 +15,7 @@
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: SlideRepository::class)]
-#[ORM\Index(fields: ['changed'], name: 'changed_idx')]
+#[ORM\Index(fields: ['changed'], name: 'slide_changed_idx')]
class Slide extends AbstractTenantScopedEntity implements RelationsChecksumInterface
{
use EntityPublishedTrait;
diff --git a/src/Entity/Tenant/Theme.php b/src/Entity/Tenant/Theme.php
index 915e48c4d..25af364b7 100644
--- a/src/Entity/Tenant/Theme.php
+++ b/src/Entity/Tenant/Theme.php
@@ -14,7 +14,7 @@
#[ORM\Entity(repositoryClass: ThemeRepository::class)]
#[ORM\EntityListeners([\App\EventListener\ThemeDoctrineEventListener::class])]
-#[ORM\Index(fields: ['changed'], name: 'changed_idx')]
+#[ORM\Index(fields: ['changed'], name: 'theme_changed_idx')]
class Theme extends AbstractTenantScopedEntity implements RelationsChecksumInterface
{
use EntityTitleDescriptionTrait;
diff --git a/src/Entity/User.php b/src/Entity/User.php
index 6f5b191a9..8e3a1932b 100644
--- a/src/Entity/User.php
+++ b/src/Entity/User.php
@@ -18,6 +18,7 @@
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: UserRepository::class)]
+#[ORM\Table(name: '`user`')]
class User extends AbstractBaseEntity implements UserInterface, PasswordAuthenticatedUserInterface, \JsonSerializable, TenantScopedUserInterface
{
#[Assert\NotBlank]