Repository: ibexa/core
Pull request #561: IBX-9727: Add missing type hints to PAPI exceptions
Author: login: adamwojs
Created: 2025-05-30T14:47:31Z | Merged: 2025-05-31T04:28:53Z

| :ticket: Issue | IBX-9727  |
|----------------|-----------|

<!-- 
#### Related PRs: 
- https://github.com/ibexa/core/pull/1
-->

#### Description:
* Add missing type hints to Ibexa\Contracts\Core\Repository\Exceptions\**
* Allowed passing \Throwable previous exception
* Aligned implementation and usage with contracts (https://github.com/ibexa/core/pull/561/files#diff-31587e12c8a8853f75a6f3bb37b2f3b6271250bf8287520450d341b7e80b4da8R290)     

#### For QA:
N/A

#### Documentation:
N/A


<!-- 
Before you click submit:
    - Test the solution manually
    - Provide automated test coverage
    - Confirm that target branch is set correctly
    - For new features, confirm that you have suitable access control and injection prevention
    - Run PHP CS Fixer for new PHP code (use $ composer fix-cs)
    - Run ESLint and Prettier for new JS/SCSS code (use $ yarn fix)
    - Ask for a review (ping @ibexa/php-dev or @ibexa/javascript-dev depending on the changes) 
--> 



Diff:
diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon
index 1a65bc2d88..78724c053b 100644
--- a/phpstan-baseline.neon
+++ b/phpstan-baseline.neon
@@ -6990,54 +6990,6 @@ parameters:
 			count: 1
 			path: src/contracts/Repository/Events/UserPreference/SetUserPreferenceEvent.php
 
-		-
-			message: '#^Method Ibexa\\Contracts\\Core\\Repository\\Exceptions\\ContentFieldValidationException\:\:getFieldErrors\(\) return type has no value type specified in iterable type array\.$#'
-			identifier: missingType.iterableValue
-			count: 1
-			path: src/contracts/Repository/Exceptions/ContentFieldValidationException.php
-
-		-
-			message: '#^Method Ibexa\\Contracts\\Core\\Repository\\Exceptions\\ContentTypeFieldDefinitionValidationException\:\:getFieldErrors\(\) return type has no value type specified in iterable type array\.$#'
-			identifier: missingType.iterableValue
-			count: 1
-			path: src/contracts/Repository/Exceptions/ContentTypeFieldDefinitionValidationException.php
-
-		-
-			message: '#^Method Ibexa\\Contracts\\Core\\Repository\\Exceptions\\InvalidCriterionArgumentException\:\:__construct\(\) has parameter \$criterion with no type specified\.$#'
-			identifier: missingType.parameter
-			count: 1
-			path: src/contracts/Repository/Exceptions/InvalidCriterionArgumentException.php
-
-		-
-			message: '#^Method Ibexa\\Contracts\\Core\\Repository\\Exceptions\\InvalidCriterionArgumentException\:\:__construct\(\) has parameter \$key with no type specified\.$#'
-			identifier: missingType.parameter
-			count: 1
-			path: src/contracts/Repository/Exceptions/InvalidCriterionArgumentException.php
-
-		-
-			message: '#^Method Ibexa\\Contracts\\Core\\Repository\\Exceptions\\InvalidVariationException\:\:__construct\(\) has parameter \$code with no type specified\.$#'
-			identifier: missingType.parameter
-			count: 1
-			path: src/contracts/Repository/Exceptions/InvalidVariationException.php
-
-		-
-			message: '#^Method Ibexa\\Contracts\\Core\\Repository\\Exceptions\\InvalidVariationException\:\:__construct\(\) has parameter \$variationName with no type specified\.$#'
-			identifier: missingType.parameter
-			count: 1
-			path: src/contracts/Repository/Exceptions/InvalidVariationException.php
-
-		-
-			message: '#^Method Ibexa\\Contracts\\Core\\Repository\\Exceptions\\InvalidVariationException\:\:__construct\(\) has parameter \$variationType with no type specified\.$#'
-			identifier: missingType.parameter
-			count: 1
-			path: src/contracts/Repository/Exceptions/InvalidVariationException.php
-
-		-
-			message: '#^Method Ibexa\\Contracts\\Core\\Repository\\Exceptions\\LimitationValidationException\:\:getLimitationErrors\(\) return type has no value type specified in iterable type array\.$#'
-			identifier: missingType.iterableValue
-			count: 1
-			path: src/contracts/Repository/Exceptions/LimitationValidationException.php
-
 		-
 			message: '#^Method Ibexa\\Contracts\\Core\\Repository\\FieldType\:\:fieldSettingsFromHash\(\) has parameter \$fieldSettingsHash with no value type specified in iterable type array\.$#'
 			identifier: missingType.iterableValue
@@ -20850,12 +20802,6 @@ parameters:
 			count: 2
 			path: src/lib/Repository/ContentTypeService.php
 
-		-
-			message: '#^Parameter \#1 \$errors of class Ibexa\\Core\\Base\\Exceptions\\ContentTypeFieldDefinitionValidationException constructor expects array\<Ibexa\\Core\\FieldType\\ValidationError\>, array\<string, array\<Ibexa\\Contracts\\Core\\FieldType\\ValidationError\>\> given\.$#'
-			identifier: argument.type
-			count: 2
-			path: src/lib/Repository/ContentTypeService.php
-
 		-
 			message: '#^Property Ibexa\\Contracts\\Core\\Repository\\Values\\ContentType\\ContentTypeCreateStruct\:\:\$descriptions \(array\) on left side of \?\? is not nullable\.$#'
 			identifier: nullCoalesce.property
@@ -21216,12 +21162,6 @@ parameters:
 			count: 2
 			path: src/lib/Repository/Mapper/ContentTypeDomainMapper.php
 
-		-
-			message: '#^Parameter \#1 \$errors of class Ibexa\\Core\\Base\\Exceptions\\ContentTypeFieldDefinitionValidationException constructor expects array\<Ibexa\\Core\\FieldType\\ValidationError\>, array\<Ibexa\\Contracts\\Core\\FieldType\\ValidationError\> given\.$#'
-			identifier: argument.type
-			count: 1
-			path: src/lib/Repository/Mapper/ContentTypeDomainMapper.php
-
 		-
 			message: '#^Property Ibexa\\Core\\Repository\\Mapper\\ProxyAwareDomainMapper\:\:\$proxyFactory \(Ibexa\\Core\\Repository\\ProxyFactory\\ProxyDomainMapperInterface\) does not accept Ibexa\\Core\\Repository\\ProxyFactory\\ProxyDomainMapperInterface\|null\.$#'
 			identifier: assign.propertyType
diff --git a/src/contracts/Repository/Exceptions/ContentFieldValidationException.php b/src/contracts/Repository/Exceptions/ContentFieldValidationException.php
index b65856a79b..d5ecdd9cf5 100644
--- a/src/contracts/Repository/Exceptions/ContentFieldValidationException.php
+++ b/src/contracts/Repository/Exceptions/ContentFieldValidationException.php
@@ -16,7 +16,9 @@ abstract class ContentFieldValidationException extends ForbiddenException
     /**
      * Returns an array of field validation error messages.
      *
-     * @return array
+     * The array is indexed by field definition ID and language code.
+     *
+     * @return array<int, array<string, \Ibexa\Contracts\Core\FieldType\ValidationError[]>>
      */
-    abstract public function getFieldErrors();
+    abstract public function getFieldErrors(): array;
 }
diff --git a/src/contracts/Repository/Exceptions/ContentTypeFieldDefinitionValidationException.php b/src/contracts/Repository/Exceptions/ContentTypeFieldDefinitionValidationException.php
index c2921772fe..e1a97aca22 100644
--- a/src/contracts/Repository/Exceptions/ContentTypeFieldDefinitionValidationException.php
+++ b/src/contracts/Repository/Exceptions/ContentTypeFieldDefinitionValidationException.php
@@ -16,7 +16,9 @@ abstract class ContentTypeFieldDefinitionValidationException extends ForbiddenEx
     /**
      * Returns an array of field definition validation error messages.
      *
-     * @return array
+     * The array is indexed by field definition identifier.
+     *
+     * @return array<string, \Ibexa\Contracts\Core\FieldType\ValidationError[]>
      */
-    abstract public function getFieldErrors();
+    abstract public function getFieldErrors(): array;
 }
diff --git a/src/contracts/Repository/Exceptions/InvalidCriterionArgumentException.php b/src/contracts/Repository/Exceptions/InvalidCriterionArgumentException.php
index 13a7f1b6e1..4084564dcf 100644
--- a/src/contracts/Repository/Exceptions/InvalidCriterionArgumentException.php
+++ b/src/contracts/Repository/Exceptions/InvalidCriterionArgumentException.php
@@ -10,7 +10,7 @@
 
 final class InvalidCriterionArgumentException extends InvalidArgumentException
 {
-    public function __construct($key, $criterion, string $expectedCriterionFQCN)
+    public function __construct(string|int $key, mixed $criterion, string $expectedCriterionFQCN)
     {
         if ($criterion === null) {
             $type = 'null';
diff --git a/src/contracts/Repository/Exceptions/InvalidVariationException.php b/src/contracts/Repository/Exceptions/InvalidVariationException.php
index 1e0e3eff9c..3532b7647d 100644
--- a/src/contracts/Repository/Exceptions/InvalidVariationException.php
+++ b/src/contracts/Repository/Exceptions/InvalidVariationException.php
@@ -8,11 +8,11 @@
 
 namespace Ibexa\Contracts\Core\Repository\Exceptions;
 
-use Exception;
+use Throwable;
 
 class InvalidVariationException extends InvalidArgumentException
 {
-    public function __construct($variationName, $variationType, $code = 0, Exception $previous = null)
+    public function __construct(string $variationName, string $variationType, int $code = 0, ?Throwable $previous = null)
     {
         parent::__construct("Invalid variation '$variationName' for $variationType", $code, $previous);
     }
diff --git a/src/contracts/Repository/Exceptions/LimitationValidationException.php b/src/contracts/Repository/Exceptions/LimitationValidationException.php
index fe5494a2a0..600fbb1ddb 100644
--- a/src/contracts/Repository/Exceptions/LimitationValidationException.php
+++ b/src/contracts/Repository/Exceptions/LimitationValidationException.php
@@ -17,7 +17,7 @@ abstract class LimitationValidationException extends ForbiddenException
     /**
      * Returns an array of limitation validation error messages.
      *
-     * @return array
+     * @return \Ibexa\Contracts\Core\FieldType\ValidationError[]
      */
-    abstract public function getLimitationErrors();
+    abstract public function getLimitationErrors(): array;
 }
diff --git a/src/contracts/Repository/Exceptions/NotImplementedException.php b/src/contracts/Repository/Exceptions/NotImplementedException.php
index edca5879b3..c4da8a9b33 100644
--- a/src/contracts/Repository/Exceptions/NotImplementedException.php
+++ b/src/contracts/Repository/Exceptions/NotImplementedException.php
@@ -8,7 +8,7 @@
 
 namespace Ibexa\Contracts\Core\Repository\Exceptions;
 
-use Exception;
+use Throwable;
 
 /**
  * This Exception is thrown if a feature has not been implemented
@@ -19,12 +19,8 @@ class NotImplementedException extends ForbiddenException
 {
     /**
      * Generates: Intentionally not implemented: {$feature}.
-     *
-     * @param string $feature
-     * @param int $code
-     * @param \Exception|null $previous
      */
-    public function __construct($feature, $code = 0, Exception $previous = null)
+    public function __construct(string $feature, int $code = 0, ?Throwable $previous = null)
     {
         parent::__construct("Intentionally not implemented: {$feature}", $code, $previous);
     }
diff --git a/src/contracts/Repository/Exceptions/PasswordInUnsupportedFormatException.php b/src/contracts/Repository/Exceptions/PasswordInUnsupportedFormatException.php
index ad02e233d0..d11db4e1f0 100644
--- a/src/contracts/Repository/Exceptions/PasswordInUnsupportedFormatException.php
+++ b/src/contracts/Repository/Exceptions/PasswordInUnsupportedFormatException.php
@@ -13,7 +13,7 @@
 
 class PasswordInUnsupportedFormatException extends AuthenticationException
 {
-    public function __construct(Throwable $previous = null)
+    public function __construct(?Throwable $previous = null)
     {
         parent::__construct("User's password is in a format which is not supported any more.", 0, $previous);
     }
diff --git a/src/contracts/Repository/Exceptions/PropertyNotFoundException.php b/src/contracts/Repository/Exceptions/PropertyNotFoundException.php
index 13ef626795..d262ca05ba 100644
--- a/src/contracts/Repository/Exceptions/PropertyNotFoundException.php
+++ b/src/contracts/Repository/Exceptions/PropertyNotFoundException.php
@@ -10,6 +10,7 @@
 
 use Exception;
 use Ibexa\Contracts\Core\Repository\Exceptions\Exception as RepositoryException;
+use Throwable;
 
 /**
  * This Exception is thrown if an accessed property in a value object was not found.
@@ -19,11 +20,9 @@ class PropertyNotFoundException extends Exception implements RepositoryException
     /**
      * Generates: Property '{$propertyName}' not found.
      *
-     * @param string $propertyName
      * @param string|null $className Optionally to specify class in abstract/parent classes
-     * @param \Exception|null $previous
      */
-    public function __construct($propertyName, $className = null, Exception $previous = null)
+    public function __construct(string $propertyName, ?string $className = null, ?Throwable $previous = null)
     {
         if ($className === null) {
             parent::__construct("Property '{$propertyName}' not found", 0, $previous);
diff --git a/src/contracts/Repository/Exceptions/PropertyReadOnlyException.php b/src/contracts/Repository/Exceptions/PropertyReadOnlyException.php
index 9adfd2c5c5..7d0db51630 100644
--- a/src/contracts/Repository/Exceptions/PropertyReadOnlyException.php
+++ b/src/contracts/Repository/Exceptions/PropertyReadOnlyException.php
@@ -10,6 +10,7 @@
 
 use Exception;
 use Ibexa\Contracts\Core\Repository\Exceptions\Exception as RepositoryException;
+use Throwable;
 
 /**
  * This Exception is thrown on a write attempt in a read only property in a value object.
@@ -19,11 +20,9 @@ class PropertyReadOnlyException extends Exception implements RepositoryException
     /**
      * Generates: Property '{$propertyName}' is readonly[ on class '{$className}'].
      *
-     * @param string $propertyName
      * @param string|null $className Optionally to specify class in abstract/parent classes
-     * @param \Exception|null $previous
      */
-    public function __construct($propertyName, $className = null, Exception $previous = null)
+    public function __construct(string $propertyName, ?string $className = null, ?Throwable $previous = null)
     {
         if ($className === null) {
             parent::__construct("Property '{$propertyName}' is readonly", 0, $previous);
diff --git a/src/lib/Base/Exceptions/ContentFieldValidationException.php b/src/lib/Base/Exceptions/ContentFieldValidationException.php
index 9c7c15055f..823cf2aa1e 100644
--- a/src/lib/Base/Exceptions/ContentFieldValidationException.php
+++ b/src/lib/Base/Exceptions/ContentFieldValidationException.php
@@ -29,7 +29,7 @@ class ContentFieldValidationException extends APIContentFieldValidationException
      *  $fieldErrors[43]["eng-GB"]->getTranslatableMessage();
      * </code>
      *
-     * @var array<int, array<string, \Ibexa\Contracts\Core\FieldType\ValidationError|\Ibexa\Contracts\Core\FieldType\ValidationError[]>>
+     * @var array<int, array<string, \Ibexa\Contracts\Core\FieldType\ValidationError[]>>
      */
     protected $errors;
 
@@ -41,7 +41,7 @@ class ContentFieldValidationException extends APIContentFieldValidationException
      *
      * Also sets the given $fieldErrors to the internal property, retrievable by getFieldErrors()
      *
-     * @param array<int, array<string, \Ibexa\Contracts\Core\FieldType\ValidationError|\Ibexa\Contracts\Core\FieldType\ValidationError[]>> $errors
+     * @param array<int, array<string, \Ibexa\Contracts\Core\FieldType\ValidationError[]>> $errors
      */
     public function __construct(array $errors)
     {
@@ -53,7 +53,7 @@ public function __construct(array $errors)
     /**
      * Generates: Content fields did not validate exception with additional information on affected fields.
      *
-     * @param array<int, array<string, \Ibexa\Contracts\Core\FieldType\ValidationError|\Ibexa\Contracts\Core\FieldType\ValidationError[]>> $errors
+     * @param array<int, array<string, \Ibexa\Contracts\Core\FieldType\ValidationError[]>> $errors
      */
     public static function createNewWithMultiline(array $errors, ?string $contentName = null): self
     {
@@ -73,9 +73,9 @@ public static function createNewWithMultiline(array $errors, ?string $contentNam
     /**
      * Returns an array of field validation error messages.
      *
-     * @return array<int, array<string, \Ibexa\Contracts\Core\FieldType\ValidationError|\Ibexa\Contracts\Core\FieldType\ValidationError[]>>
+     * @return array<int, array<string, \Ibexa\Contracts\Core\FieldType\ValidationError[]>>
      */
-    public function getFieldErrors()
+    public function getFieldErrors(): array
     {
         return $this->errors;
     }
diff --git a/src/lib/Base/Exceptions/ContentTypeFieldDefinitionValidationException.php b/src/lib/Base/Exceptions/ContentTypeFieldDefinitionValidationException.php
index 2e4e8814cf..b188971f74 100644
--- a/src/lib/Base/Exceptions/ContentTypeFieldDefinitionValidationException.php
+++ b/src/lib/Base/Exceptions/ContentTypeFieldDefinitionValidationException.php
@@ -18,25 +18,13 @@ class ContentTypeFieldDefinitionValidationException extends APIContentTypeFieldD
 {
     use TranslatableBase;
 
-    /**
-     * Contains an array of field ValidationError objects indexed with FieldDefinition id and language code.
-     *
-     * Example:
-     * <code>
-     *  $fieldErrors = $exception->getFieldErrors();
-     *  $fieldErrors["43"]["eng-GB"]->getTranslatableMessage();
-     * </code>
-     *
-     * @var \Ibexa\Core\FieldType\ValidationError[]
-     */
-    protected $errors;
+    /** @var array<string, \Ibexa\Contracts\Core\FieldType\ValidationError[]> */
+    protected array $errors;
 
     /**
-     * Generates: Content fields did not validate.
+     * Generates: Content type field definitions did not validate.
      *
-     * Also sets the given $fieldErrors to the internal property, retrievable by getFieldErrors()
-     *
-     * @param \Ibexa\Core\FieldType\ValidationError[] $errors
+     * @param array<string, \Ibexa\Contracts\Core\FieldType\ValidationError[]> $errors
      */
     public function __construct(array $errors)
     {
@@ -45,12 +33,7 @@ public function __construct(array $errors)
         parent::__construct($this->getBaseTranslation());
     }
 
-    /**
-     * Returns an array of field validation error messages.
-     *
-     * @return \Ibexa\Core\FieldType\ValidationError[]
-     */
-    public function getFieldErrors()
+    public function getFieldErrors(): array
     {
         return $this->errors;
     }
diff --git a/src/lib/Base/Exceptions/LimitationValidationException.php b/src/lib/Base/Exceptions/LimitationValidationException.php
index a1000bde5d..8556b15e4a 100644
--- a/src/lib/Base/Exceptions/LimitationValidationException.php
+++ b/src/lib/Base/Exceptions/LimitationValidationException.php
@@ -45,7 +45,7 @@ public function __construct(array $errors)
      *
      * @return \Ibexa\Core\FieldType\ValidationError[]
      */
-    public function getLimitationErrors()
+    public function getLimitationErrors(): array
     {
         return $this->errors;
     }
diff --git a/src/lib/Repository/Mapper/ContentTypeDomainMapper.php b/src/lib/Repository/Mapper/ContentTypeDomainMapper.php
index e208c5538a..ab53d12c65 100644
--- a/src/lib/Repository/Mapper/ContentTypeDomainMapper.php
+++ b/src/lib/Repository/Mapper/ContentTypeDomainMapper.php
@@ -287,7 +287,7 @@ public function buildSPIFieldDefinitionFromUpdateStruct(
         );
 
         if (!empty($validationErrors)) {
-            throw new ContentTypeFieldDefinitionValidationException($validationErrors);
+            throw new ContentTypeFieldDefinitionValidationException([$fieldDefinition->identifier => $validationErrors]);
         }
 
         $spiFieldDefinition = new SPIFieldDefinition(


Reviews:
user: login: alongosz
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-30T21:49:47Z
user: login: adamwojs
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-31T04:28:40Z

Review comments:
diff_hunk: @@ -16,7 +16,9 @@ abstract class ContentFieldValidationException extends ForbiddenException
     /**
      * Returns an array of field validation error messages.
      *
-     * @return array
+     * The array is indexed by field definition ID and language code.
+     *
+     * @return array<int, array<string, \Ibexa\Contracts\Core\FieldType\ValidationError|\Ibexa\Contracts\Core\FieldType\ValidationError[]>>
path: src/contracts/Repository/Exceptions/ContentFieldValidationException.php
user: login: alongosz
body: This shouldn't be an union type but always an array. If something is returning that IMHO we should rather fix it. It caused some hassle in the past because some implementations didn't follow the structure.
```suggestion
     * @return array<int, array<string, \Ibexa\Contracts\Core\FieldType\ValidationError[]>>
```
created_at: 2025-05-30T17:34:25Z
author_association: MEMBER
diff_hunk: @@ -75,7 +75,7 @@ public static function createNewWithMultiline(array $errors, ?string $contentNam
      *
      * @return array<int, array<string, \Ibexa\Contracts\Core\FieldType\ValidationError|\Ibexa\Contracts\Core\FieldType\ValidationError[]>>
path: src/lib/Base/Exceptions/ContentFieldValidationException.php
user: login: alongosz
body: I'm assuming it's coming from here. I still think we should fix it ;)
created_at: 2025-05-30T21:48:38Z
author_association: MEMBER
diff_hunk: @@ -75,7 +75,7 @@ public static function createNewWithMultiline(array $errors, ?string $contentNam
      *
      * @return array<int, array<string, \Ibexa\Contracts\Core\FieldType\ValidationError|\Ibexa\Contracts\Core\FieldType\ValidationError[]>>
path: src/lib/Base/Exceptions/ContentFieldValidationException.php
user: login: adamwojs
body: Applied in https://github.com/ibexa/core/pull/561/commits/8917651df7459abc37114e82208b2cc98a795af9
created_at: 2025-05-31T04:28:40Z
author_association: MEMBER

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-05-31T03:54:34Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=ibexa_core&pullRequest=561) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=ibexa_core&pullRequest=561&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=ibexa_core&pullRequest=561&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=ibexa_core&pullRequest=561&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=ibexa_core&pullRequest=561&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=ibexa_core&pullRequest=561&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=ibexa_core&pullRequest=561)


author_association: NONE