diff --git a/extra/validate-property-read.php b/extra/validate-property-read.php
new file mode 100644
index 00000000000..ed6e1f0ee72
--- /dev/null
+++ b/extra/validate-property-read.php
@@ -0,0 +1,677 @@
+#!/usr/bin/env php
+]
+ *
+ * When no argument is given, the script validates the WCF core (default).
+ * When a package directory is given, it looks for:
+ * - Install files:
/files{_suffix}/acp/database/install_*.php
+ * - Data dirs: /files{_suffix}/lib/data
+ *
+ * Exit codes:
+ * 0 = all valid
+ * 1 = issues found
+ * 2 = configuration error
+ */
+
+$baseDir = __DIR__;
+
+if (isset($argv[1])) {
+ $packageDir = realpath($argv[1]);
+ if ($packageDir === false || !is_dir($packageDir)) {
+ fwrite(STDERR, "Error: Directory not found: {$argv[1]}\n");
+ exit(2);
+ }
+
+ // Find install file(s) in files*/acp/database/.
+ $installFiles = glob($packageDir . '/files*/acp/database/install_*.php');
+ if (empty($installFiles)) {
+ fwrite(STDERR, "Error: No files*/acp/database/install_*.php found in {$packageDir}\n");
+ exit(2);
+ }
+
+ $installFile = $installFiles[0];
+
+ // Find data directories in files*/lib/data/.
+ $dataDirs = glob($packageDir . '/files*/lib/data', \GLOB_ONLYDIR);
+ if (empty($dataDirs)) {
+ fwrite(STDERR, "Error: No files*/lib/data/ directory found in {$packageDir}\n");
+ exit(2);
+ }
+} else {
+ $installFile = $baseDir . '/../wcfsetup/setup/db/install_com.woltlab.wcf.php';
+ $dataDirs = [$baseDir . '/../wcfsetup/install/files/lib/data'];
+}
+
+// Column types that are NOT NULL by default (convenience/factory classes).
+const NOT_NULL_COLUMN_TYPES = [
+ 'ObjectIdDatabaseTableColumn',
+ 'NotNullInt10DatabaseTableColumn',
+ 'NotNullVarchar191DatabaseTableColumn',
+ 'NotNullVarchar255DatabaseTableColumn',
+ 'DefaultFalseBooleanDatabaseTableColumn',
+ 'DefaultTrueBooleanDatabaseTableColumn',
+];
+
+// Column types that represent booleans and should be documented as "0|1".
+const BOOLEAN_COLUMN_TYPES = [
+ 'DefaultFalseBooleanDatabaseTableColumn',
+ 'DefaultTrueBooleanDatabaseTableColumn',
+];
+
+// Map column type class prefix to PHP base type.
+const COLUMN_PHP_TYPE_MAP = [
+ 'ObjectId' => 'int',
+ 'NotNullInt10' => 'int',
+ 'Int' => 'int',
+ 'Bigint' => 'int',
+ 'Mediumint' => 'int',
+ 'Smallint' => 'int',
+ 'Tinyint' => 'int',
+ 'DefaultFalseBoolean' => 'int',
+ 'DefaultTrueBoolean' => 'int',
+ 'NotNullVarchar191' => 'string',
+ 'NotNullVarchar255' => 'string',
+ 'Varchar' => 'string',
+ 'Char' => 'string',
+ 'Text' => 'string',
+ 'Mediumtext' => 'string',
+ 'Mediumblob' => 'string',
+ 'Binary' => 'string',
+ 'Varbinary' => 'string',
+ 'Date' => 'string',
+ 'Datetime' => 'string',
+ 'Decimal' => 'string',
+ 'Enum' => 'string',
+ 'Float' => 'float',
+];
+
+// ─── Install File Parsing ────────────────────────────────────────────
+
+/**
+ * Parses the install file and returns a map of table name → column definitions.
+ *
+ * @return array>
+ */
+function parseInstallFile(string $filePath): array
+{
+ $content = file_get_contents($filePath);
+ if ($content === false) {
+ fwrite(STDERR, "Error: Cannot read install file: {$filePath}\n");
+ exit(2);
+ }
+
+ $tables = [];
+ $offset = 0;
+
+ while (($pos = strpos($content, "DatabaseTable::create('", $offset)) !== false) {
+ $nameStart = $pos + \strlen("DatabaseTable::create('");
+ $nameEnd = strpos($content, "'", $nameStart);
+ if ($nameEnd === false) {
+ break;
+ }
+ $tableName = substr($content, $nameStart, $nameEnd - $nameStart);
+
+ // Extract text until the next DatabaseTable::create or end of file.
+ $nextTablePos = strpos($content, "DatabaseTable::create('", $nameEnd);
+ $tableBlock = $nextTablePos !== false
+ ? substr($content, $nameEnd, $nextTablePos - $nameEnd)
+ : substr($content, $nameEnd);
+
+ // Find the ->columns([...]) section.
+ $columnsMarker = '->columns([';
+ $columnsStart = strpos($tableBlock, $columnsMarker);
+ if ($columnsStart !== false) {
+ $columnsStart += \strlen($columnsMarker);
+
+ // Find matching ]).
+ $depth = 1;
+ $p = $columnsStart;
+ $len = \strlen($tableBlock);
+ while ($depth > 0 && $p < $len) {
+ if ($tableBlock[$p] === '[') {
+ $depth++;
+ } elseif ($tableBlock[$p] === ']') {
+ $depth--;
+ }
+ $p++;
+ }
+ $columnsContent = substr($tableBlock, $columnsStart, $p - $columnsStart - 1);
+ $newColumns = parseColumns($columnsContent);
+ $tables[$tableName] = isset($tables[$tableName])
+ ? array_merge($tables[$tableName], $newColumns)
+ : $newColumns;
+ }
+
+ $offset = $nameEnd + 1;
+ }
+
+ return $tables;
+}
+
+/**
+ * Parses individual column definitions from the content of a ->columns([...]) block.
+ *
+ * @return array
+ */
+function parseColumns(string $content): array
+{
+ $columns = [];
+
+ \preg_match_all(
+ '/(\w+DatabaseTableColumn)::create\(\'(\w+)\'\)/',
+ $content,
+ $matches,
+ \PREG_SET_ORDER | \PREG_OFFSET_CAPTURE,
+ );
+
+ foreach ($matches as $i => $match) {
+ $columnType = $match[1][0];
+ $columnName = $match[2][0];
+
+ // Extract the method chain following this column definition.
+ $chainStart = $match[0][1] + \strlen($match[0][0]);
+ $chainEnd = isset($matches[$i + 1])
+ ? $matches[$i + 1][0][1]
+ : \strlen($content);
+ $chain = substr($content, $chainStart, $chainEnd - $chainStart);
+
+ // Determine NOT NULL status.
+ $isNotNull = \in_array($columnType, NOT_NULL_COLUMN_TYPES, true)
+ || (bool)\preg_match('/->notNull\(\s*\)/', $chain)
+ || (bool)\preg_match('/->notNull\(\s*true\s*\)/', $chain);
+
+ if (\preg_match('/->notNull\(\s*false\s*\)/', $chain)) {
+ $isNotNull = false;
+ }
+
+ $shortType = str_replace('DatabaseTableColumn', '', $columnType);
+ $basePhpType = COLUMN_PHP_TYPE_MAP[$shortType] ?? 'mixed';
+
+ $columns[$columnName] = [
+ 'columnType' => $columnType,
+ 'notNull' => $isNotNull,
+ 'isBoolean' => \in_array($columnType, BOOLEAN_COLUMN_TYPES, true),
+ 'basePhpType' => $basePhpType,
+ ];
+ }
+
+ return $columns;
+}
+
+// ─── Class Discovery ─────────────────────────────────────────────────
+
+/**
+ * Scans all PHP class files in the data directory and collects raw class info.
+ *
+ * @return array
+ * Keyed by FQCN.
+ */
+function scanAllClassFiles(string $dataDir): array
+{
+ $allClasses = [];
+ $iterator = new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator($dataDir, RecursiveDirectoryIterator::SKIP_DOTS),
+ );
+
+ foreach ($iterator as $file) {
+ /** @var SplFileInfo $file */
+ if ($file->getExtension() !== 'php') {
+ continue;
+ }
+
+ $content = file_get_contents($file->getPathname());
+ if ($content === false) {
+ continue;
+ }
+
+ // Extract namespace.
+ if (!\preg_match('/namespace\s+([\w\\\\]+)\s*;/', $content, $nsMatch)) {
+ continue;
+ }
+ $namespace = $nsMatch[1];
+
+ // Extract class declaration (anchored to line start to avoid matching comments).
+ $isAbstract = false;
+ if (\preg_match('/^\s*((?:(?:abstract|final|readonly)\s+)*)class\s+(\w+)(?:\s+extends\s+([\w\\\\]+))?/m', $content, $classMatch)) {
+ $modifiers = $classMatch[1];
+ $className = $classMatch[2];
+ $parentRef = $classMatch[3] ?? null;
+ $isAbstract = str_contains($modifiers, 'abstract');
+ } else {
+ continue;
+ }
+
+ // Collect use imports for FQCN resolution.
+ $useImports = [];
+ \preg_match_all('/^\s*use\s+([\w\\\\]+)(?:\s+as\s+(\w+))?\s*;/m', $content, $useMatches, \PREG_SET_ORDER);
+ foreach ($useMatches as $useMatch) {
+ $importFqcn = $useMatch[1];
+ $alias = $useMatch[2] ?? null;
+ $shortName = $alias ?? substr($importFqcn, strrpos($importFqcn, '\\') + 1);
+ $useImports[$shortName] = $importFqcn;
+ }
+
+ // Extract @property-read annotations.
+ $ownProperties = parsePropertyReadAnnotations($content);
+
+ // Check for custom $databaseTableName.
+ $customTableName = null;
+ if (\preg_match('/protected\s+static\s+\$databaseTableName\s*=\s*\'([^\']+)\'/', $content, $tblMatch)) {
+ $customTableName = $tblMatch[1];
+ }
+
+ $fqcn = $namespace . '\\' . $className;
+ $allClasses[$fqcn] = [
+ 'className' => $className,
+ 'namespace' => $namespace,
+ 'file' => $file->getPathname(),
+ 'parentRef' => $parentRef,
+ 'isAbstract' => $isAbstract,
+ 'ownProperties' => $ownProperties,
+ 'useImports' => $useImports,
+ 'customTableName' => $customTableName,
+ ];
+ }
+
+ return $allClasses;
+}
+
+/**
+ * Resolves a parent class reference to a FQCN.
+ */
+function resolveParentFqcn(string $parentRef, string $currentNamespace, array $useImports): string
+{
+ // Fully qualified.
+ if (str_starts_with($parentRef, '\\')) {
+ return ltrim($parentRef, '\\');
+ }
+
+ // Check use imports (match the first part before any backslash).
+ $firstPart = str_contains($parentRef, '\\')
+ ? substr($parentRef, 0, strpos($parentRef, '\\'))
+ : $parentRef;
+
+ if (isset($useImports[$firstPart])) {
+ if ($firstPart === $parentRef) {
+ return $useImports[$firstPart];
+ }
+ // Aliased namespace prefix.
+ return $useImports[$firstPart] . substr($parentRef, \strlen($firstPart));
+ }
+
+ // Same namespace.
+ return $currentNamespace . '\\' . $parentRef;
+}
+
+/**
+ * Recursively collects all @property-read annotations including inherited ones.
+ *
+ * @param array $allClasses
+ * @param array $visited Cycle guard
+ * @return array
+ */
+function collectInheritedProperties(string $fqcn, array $allClasses, array &$visited = []): array
+{
+ if (isset($visited[$fqcn]) || !isset($allClasses[$fqcn])) {
+ return [];
+ }
+ $visited[$fqcn] = true;
+
+ $classInfo = $allClasses[$fqcn];
+ $parentProperties = [];
+
+ if ($classInfo['parentRef'] !== null) {
+ $parentFqcn = resolveParentFqcn(
+ $classInfo['parentRef'],
+ $classInfo['namespace'],
+ $classInfo['useImports'],
+ );
+ $parentProperties = collectInheritedProperties($parentFqcn, $allClasses, $visited);
+ }
+
+ // Child properties override parent.
+ return array_merge($parentProperties, $classInfo['ownProperties']);
+}
+
+/**
+ * Builds the table name → class info map with merged (inherited) properties.
+ *
+ * @return array
+ */
+function buildClassMap(array $allClasses, string $tablePrefix): array
+{
+ $classes = [];
+
+ foreach ($allClasses as $fqcn => $info) {
+ // Skip abstract classes.
+ if ($info['isAbstract']) {
+ continue;
+ }
+
+ $tableName = computeTableName($info['className'], $info['customTableName'], $tablePrefix);
+
+ // Collect own + inherited properties.
+ $visited = [];
+ $mergedProperties = collectInheritedProperties($fqcn, $allClasses, $visited);
+
+ if (empty($mergedProperties)) {
+ continue;
+ }
+
+ $classes[$tableName] = [
+ 'className' => $info['className'],
+ 'namespace' => $info['namespace'],
+ 'file' => $info['file'],
+ 'properties' => $mergedProperties,
+ ];
+ }
+
+ return $classes;
+}
+
+/**
+ * Computes the database table name from a class name, mirroring
+ * DatabaseObject::getDatabaseTableName().
+ */
+function computeTableName(string $className, ?string $customTableName, string $tablePrefix): string
+{
+ if ($customTableName !== null && $customTableName !== '') {
+ return $tablePrefix . $customTableName;
+ }
+
+ $parts = \preg_split(
+ '~(?=[A-Z](?=[a-z]))~',
+ $className,
+ -1,
+ \PREG_SPLIT_DELIM_CAPTURE | \PREG_SPLIT_NO_EMPTY,
+ );
+
+ return $tablePrefix . strtolower(implode('_', $parts));
+}
+
+/**
+ * Detects the most common table prefix from the parsed install file tables.
+ *
+ * Falls back to 'wcf1_' if no clear prefix is found.
+ */
+function detectTablePrefix(array $tables): string
+{
+ $prefixCounts = [];
+ foreach (array_keys($tables) as $tableName) {
+ if (\preg_match('/^(\w+1_)/', $tableName, $m)) {
+ $prefix = $m[1];
+ $prefixCounts[$prefix] = ($prefixCounts[$prefix] ?? 0) + 1;
+ }
+ }
+
+ if (empty($prefixCounts)) {
+ return 'wcf1_';
+ }
+
+ arsort($prefixCounts);
+
+ return array_key_first($prefixCounts);
+}
+
+/**
+ * Extracts @property-read annotations from a class file's PHPDoc block.
+ *
+ * @return array
+ */
+function parsePropertyReadAnnotations(string $content): array
+{
+ $properties = [];
+
+ if (\preg_match_all('/@property-read\s+(\S+)\s+\$(\w+)/', $content, $matches, \PREG_SET_ORDER)) {
+ foreach ($matches as $match) {
+ $type = $match[1];
+ $properties[$match[2]] = [
+ 'type' => $type,
+ 'nullable' => isNullableType($type),
+ ];
+ }
+ }
+
+ return $properties;
+}
+
+// ─── Type Helpers ────────────────────────────────────────────────────
+
+function isNullableType(string $type): bool
+{
+ return str_starts_with($type, '?')
+ || str_contains($type, '|null')
+ || str_starts_with($type, 'null|');
+}
+
+/**
+ * Extracts the base type from a PHPDoc type, stripping nullable markers.
+ *
+ * Examples: "?int" → "int", "int|null" → "int", "1|0" → "1|0"
+ */
+function extractBaseType(string $type): string
+{
+ $type = ltrim($type, '?');
+ $parts = explode('|', $type);
+ $parts = array_values(array_filter($parts, static fn(string $p): bool => $p !== 'null'));
+
+ return implode('|', $parts);
+}
+
+/**
+ * Checks whether a documented type is compatible with the expected base PHP type.
+ */
+function isBaseTypeCompatible(string $docType, string $expectedBaseType): bool
+{
+ $base = extractBaseType($docType);
+
+ if ($base === $expectedBaseType) {
+ return true;
+ }
+
+ // Accept "1|0" and "0|1" as compatible with int (boolean-style columns).
+ if ($expectedBaseType === 'int' && \in_array($base, ['1|0', '0|1'], true)) {
+ return true;
+ }
+
+ return false;
+}
+
+// ─── Validation ──────────────────────────────────────────────────────
+
+/**
+ * @return list
+ */
+function validate(array $tables, array $classes): array
+{
+ $issues = [];
+
+ foreach ($classes as $tableName => $classInfo) {
+ if (!isset($tables[$tableName])) {
+ // Table not defined in install file — may be from another package.
+ continue;
+ }
+
+ $dbColumns = $tables[$tableName];
+ $docProperties = $classInfo['properties'];
+ $label = $classInfo['namespace'] . '\\' . $classInfo['className'];
+ $file = $classInfo['file'];
+
+ // 1. Columns missing from @property-read.
+ foreach ($dbColumns as $colName => $colDef) {
+ if (!isset($docProperties[$colName])) {
+ $expectedType = $colDef['notNull']
+ ? $colDef['basePhpType']
+ : '?' . $colDef['basePhpType'];
+ $issues[] = [
+ 'class' => $label,
+ 'file' => $file,
+ 'type' => 'MISSING',
+ 'column' => $colName,
+ 'detail' => "Column \${$colName} has no @property-read (expected type: {$expectedType})",
+ ];
+ }
+ }
+
+ // 2. @property-read entries that have no matching column.
+ foreach ($docProperties as $propName => $propDef) {
+ if (!isset($dbColumns[$propName])) {
+ $issues[] = [
+ 'class' => $label,
+ 'file' => $file,
+ 'type' => 'EXTRA',
+ 'column' => $propName,
+ 'detail' => "@property-read \${$propName} has no matching column in the schema",
+ ];
+ }
+ }
+
+ // 3. Type/nullability mismatches.
+ foreach ($dbColumns as $colName => $colDef) {
+ if (!isset($docProperties[$colName])) {
+ continue;
+ }
+
+ $docType = $docProperties[$colName]['type'];
+ $docNullable = $docProperties[$colName]['nullable'];
+ $dbNullable = !$colDef['notNull'];
+
+ if ($docNullable !== $dbNullable) {
+ $expected = $dbNullable
+ ? '?' . $colDef['basePhpType']
+ : $colDef['basePhpType'];
+ $issues[] = [
+ 'class' => $label,
+ 'file' => $file,
+ 'type' => 'NULLABILITY',
+ 'column' => $colName,
+ 'detail' => "\${$colName} is documented as {$docType} but column is "
+ . ($dbNullable ? 'nullable' : 'NOT NULL')
+ . " (expected: {$expected})",
+ ];
+ }
+
+ if (!isBaseTypeCompatible($docType, $colDef['basePhpType'])) {
+ $issues[] = [
+ 'class' => $label,
+ 'file' => $file,
+ 'type' => 'TYPE',
+ 'column' => $colName,
+ 'detail' => "\${$colName} is documented as {$docType} but column type {$colDef['columnType']} maps to {$colDef['basePhpType']}",
+ ];
+ }
+
+ // 4. Boolean columns must be documented as "0|1".
+ if ($colDef['isBoolean'] && extractBaseType($docType) !== '0|1') {
+ $issues[] = [
+ 'class' => $label,
+ 'file' => $file,
+ 'type' => 'BOOLEAN',
+ 'column' => $colName,
+ 'detail' => "\${$colName} is documented as {$docType} but boolean columns should use 0|1",
+ ];
+ }
+ }
+ }
+
+ // Sort by class, then type, then column.
+ usort($issues, static function (array $a, array $b): int {
+ return $a['class'] <=> $b['class']
+ ?: $a['type'] <=> $b['type']
+ ?: $a['column'] <=> $b['column'];
+ });
+
+ return $issues;
+}
+
+// ─── Reporting ───────────────────────────────────────────────────────
+
+function report(array $issues, array $tables, array $classes, string $baseDir): void
+{
+ $matchedTables = 0;
+ $unmatchedClasses = [];
+ foreach ($classes as $tableName => $classInfo) {
+ if (isset($tables[$tableName])) {
+ $matchedTables++;
+ } else {
+ $unmatchedClasses[] = $classInfo['namespace'] . '\\' . $classInfo['className']
+ . " (computed table: {$tableName})";
+ }
+ }
+
+ echo "Database @property-read Validation\n";
+ echo str_repeat('=', 60) . "\n\n";
+ echo "Tables in install file: " . \count($tables) . "\n";
+ echo "Classes with @property-read: " . \count($classes) . "\n";
+ echo "Matched (table ↔ class): {$matchedTables}\n";
+ echo "\n";
+
+ if (empty($issues)) {
+ echo "\033[32m✓ All @property-read annotations are up to date.\033[0m\n";
+ return;
+ }
+
+ // Group issues by class.
+ $grouped = [];
+ foreach ($issues as $issue) {
+ $grouped[$issue['class']][] = $issue;
+ }
+
+ $counts = ['MISSING' => 0, 'EXTRA' => 0, 'NULLABILITY' => 0, 'TYPE' => 0, 'BOOLEAN' => 0];
+
+ foreach ($grouped as $class => $classIssues) {
+ $file = str_replace($baseDir . '/', '', $classIssues[0]['file']);
+ echo "\033[1m{$class}\033[0m\n";
+ echo " {$file}\n\n";
+
+ foreach ($classIssues as $issue) {
+ $counts[$issue['type']]++;
+ $color = match ($issue['type']) {
+ 'MISSING' => "\033[31m", // red
+ 'EXTRA' => "\033[33m", // yellow
+ 'NULLABILITY' => "\033[35m", // magenta
+ 'TYPE' => "\033[36m", // cyan
+ 'BOOLEAN' => "\033[34m", // blue
+ };
+ echo " {$color}[{$issue['type']}]\033[0m {$issue['detail']}\n";
+ }
+ echo "\n";
+ }
+
+ echo str_repeat('─', 60) . "\n";
+ echo "Summary:\n";
+ echo " Missing @property-read: {$counts['MISSING']}\n";
+ echo " Extra @property-read: {$counts['EXTRA']}\n";
+ echo " Nullability mismatches: {$counts['NULLABILITY']}\n";
+ echo " Base type mismatches: {$counts['TYPE']}\n";
+ echo " Boolean type mismatches: {$counts['BOOLEAN']}\n";
+ echo " Total issues: " . \count($issues) . "\n";
+
+ if (!empty($unmatchedClasses)) {
+ echo "\nClasses with @property-read but no matching table in install file:\n";
+ foreach ($unmatchedClasses as $c) {
+ echo " - {$c}\n";
+ }
+ }
+}
+
+// ─── Main ────────────────────────────────────────────────────────────
+
+$tables = parseInstallFile($installFile);
+$tablePrefix = detectTablePrefix($tables);
+$allClasses = [];
+foreach ($dataDirs as $dataDir) {
+ $allClasses = array_merge($allClasses, scanAllClassFiles($dataDir));
+}
+$classes = buildClassMap($allClasses, $tablePrefix);
+$issues = validate($tables, $classes);
+report($issues, $tables, $classes, $baseDir);
+
+exit(empty($issues) ? 0 : 1);