Repository: swisnl/json-api-client (php)

Issue:
Add PHPStan types
We all know that PHP is super awesome, of course! But it lacks some handy tooling, like generics, for example. While they are currently [being re-evaluated](https://github.com/arnaud-lb/php-src/pull/4), we're still years away from any kind of actual implementation. 

Tools like [Psalm](https://psalm.dev/) or [PHPStan](https://phpstan.org/) (living in the phpdocs) would help our IDEs when dealing with the `Collection` and `Item`responses, providing better type inference for the entities they entail.

**Psalm vs PHPStan**
PHPStan is already supported by Illuminate\Support\Collection (and has more stars on GitHub), making it my preferred option.

For clarity, I'm not opting for making this package target some PHPStan level, just adding some `@template`-annotations for making our lives (and that of our IDE's) a little bit easier.

Pull request description:
Add support for generics
## Description

Adds support for using generics (using PHPStan types), by adding the correct phpdoc annotations to Interfaces. No actual code has been changed, so this could be released as a minor.

The next step would be completely incorporating PHPStan and use it as a QA-tool in the pipeline.

## Motivation and context

Modern IDE's have support for them and this greatly enhances the DX of using this package.

fixes #107 

## How has this been tested?

N/A

## Types of changes

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)

## Checklist:

- [x] I have read the **[CONTRIBUTING](CONTRIBUTING.md)** document.
- [x] My pull request addresses exactly one patch/feature.
- [x] I have created a branch for this patch/feature.
- [x] Each individual commit in the pull request is meaningful.
- [x] I have added tests to cover my changes.
- [x] If my change requires a change to the documentation, I have updated it accordingly.


Patch:
diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml
new file mode 100644
index 0000000..ddf1aaa
--- /dev/null
+++ b/.github/workflows/static-analysis.yml
@@ -0,0 +1,26 @@
+name: Run static analysis
+
+on:
+  - push
+  - pull_request
+
+jobs:
+  types:
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@v4
+
+      - name: Setup PHP
+        uses: shivammathur/setup-php@v2
+        with:
+          php-version: 8.3
+          tools: composer:v2
+          coverage: none
+
+      - name: Install dependencies
+        run: composer update --prefer-stable --prefer-dist --no-interaction --no-progress
+
+      - name: Execute type checking
+        run: vendor/bin/phpstan --configuration="phpstan.types.neon.dist" --no-progress
diff --git a/README.md b/README.md
index 7bcee31..572cee1 100644
--- a/README.md
+++ b/README.md
@@ -477,6 +477,52 @@ N.B. This example uses our [swisnl/php-http-fixture-client](https://github.com/s
 This package allows you to easily mock requests with static fixtures.
 Definitely worth a try!
 
+## Using generics
+
+This package provides support for generic types in repositories and relationships,
+so your IDE can provide type hinting and auto-completion for the items you are working with.
+
+This is achieved by using [generics in PHPDoc annotations](https://phpstan.org/blog/generics-in-php-using-phpdocs).
+
+### Repositories
+
+```php
+
+/** @extends \Swis\JsonApi\Client\Repository<BlogItem> */
+class BlogRepository extends \Swis\JsonApi\Client\Repository {...}
+
+```
+
+Now, when you use the `BlogRepository` class, your IDE understands the correct return types for the `all()`, `find()` and `save()` methods.
+
+### Relationships
+
+You can also use generics in your relationships to specify the type of the related item.
+Just use the `OneRelationInterface` or `ManyRelationInterface` interfaces in your relation method and specify the type of the related item:
+
+```php
+
+/** @return \Swis\JsonApi\Client\Interfaces\OneRelationInterface<AuthorItem> */
+public function author(): OneRelationInterface
+{
+    return $this->hasOne(AuthorItem::class);
+}
+
+```
+
+This way, when accessing the `$blog->author()->getData()`, your IDE will understand that it returns an `AuthorItem` instance.
+
+The same can be achieved for ManyRelations (`HasMany`, `MorphToMany`):
+
+```php
+
+/** @return \Swis\JsonApi\Client\Interfaces\ManyRelationInterface<AuthorItem> */
+public function comments(): ManyRelationInterface
+{
+    return $this->hasMany(CommentItem::class);
+}
+
+```
 
 ## Advanced usage
 
diff --git a/composer.json b/composer.json
index b30a6af..af34252 100644
--- a/composer.json
+++ b/composer.json
@@ -18,6 +18,7 @@
         "guzzlehttp/guzzle": "^7.3",
         "laravel/pint": "^1.5",
         "php-http/mock-client": "^1.2",
+        "phpstan/phpstan": "^2.1",
         "phpunit/phpunit": "^9.5"
     },
     "suggest": {
@@ -36,10 +37,6 @@
     },
     "license": "MIT",
     "authors": [
-        {
-            "name": "Hendrik Nijmeijer",
-            "email": "hendrik@swis.nl"
-        },
         {
             "name": "Jasper Zonneveld",
             "email": "jasper@swis.nl",
@@ -52,9 +49,10 @@
         }
     ],
     "scripts": {
-        "test": "phpunit",
         "check-style": "pint --test",
-        "fix-style": "pint"
+        "check-types": "phpstan --configuration=\"phpstan.types.neon.dist\"",
+        "fix-style": "pint",
+        "test": "phpunit"
     },
     "extra": {
         "branch-alias": {
diff --git a/phpstan.types.neon.dist b/phpstan.types.neon.dist
new file mode 100644
index 0000000..e0db3e6
--- /dev/null
+++ b/phpstan.types.neon.dist
@@ -0,0 +1,5 @@
+parameters:
+  level: max
+  paths:
+    - tests\_mocks
+    - types
diff --git a/src/Actions/Create.php b/src/Actions/Create.php
index 629c949..09a8886 100644
--- a/src/Actions/Create.php
+++ b/src/Actions/Create.php
@@ -6,10 +6,13 @@ namespace Swis\JsonApi\Client\Actions;
 
 use Swis\JsonApi\Client\Interfaces\ItemInterface;
 
+/**
+ * @template TItem of \Swis\JsonApi\Client\Interfaces\ItemInterface
+ */
 trait Create
 {
     /**
-     * @return \Swis\JsonApi\Client\Interfaces\DocumentInterface
+     * @return \Swis\JsonApi\Client\Interfaces\ItemDocumentInterface<TItem>
      */
     public function create(ItemInterface $item, array $parameters = [], array $headers = [])
     {
diff --git a/src/Actions/FetchMany.php b/src/Actions/FetchMany.php
index 2bbe69a..2a1e72b 100644
--- a/src/Actions/FetchMany.php
+++ b/src/Actions/FetchMany.php
@@ -4,10 +4,13 @@ declare(strict_types=1);
 
 namespace Swis\JsonApi\Client\Actions;
 
+/**
+ * @template TItem of \Swis\JsonApi\Client\Interfaces\ItemInterface
+ */
 trait FetchMany
 {
     /**
-     * @return \Swis\JsonApi\Client\Interfaces\DocumentInterface
+     * @return \Swis\JsonApi\Client\Interfaces\CollectionDocumentInterface<TItem>
      */
     public function all(array $parameters = [], array $headers = [])
     {
diff --git a/src/Actions/FetchOne.php b/src/Actions/FetchOne.php
index f71819b..c6e8d2e 100644
--- a/src/Actions/FetchOne.php
+++ b/src/Actions/FetchOne.php
@@ -4,10 +4,13 @@ declare(strict_types=1);
 
 namespace Swis\JsonApi\Client\Actions;
 
+/**
+ * @template TItem of \Swis\JsonApi\Client\Interfaces\ItemInterface
+ */
 trait FetchOne
 {
     /**
-     * @return \Swis\JsonApi\Client\Interfaces\DocumentInterface
+     * @return \Swis\JsonApi\Client\Interfaces\ItemDocumentInterface<TItem>
      */
     public function find(string $id, array $parameters = [], array $headers = [])
     {
diff --git a/src/Actions/Save.php b/src/Actions/Save.php
index 7936506..c9e0c4d 100644
--- a/src/Actions/Save.php
+++ b/src/Actions/Save.php
@@ -6,13 +6,19 @@ namespace Swis\JsonApi\Client\Actions;
 
 use Swis\JsonApi\Client\Interfaces\ItemInterface;
 
+/**
+ * @template TItem of \Swis\JsonApi\Client\Interfaces\ItemInterface
+ */
 trait Save
 {
+    /** @use Create<TItem> */
     use Create { create as protected saveNew; }
+
+    /** @use Update<TItem> */
     use Update { update as protected saveExisting; }
 
     /**
-     * @return \Swis\JsonApi\Client\Interfaces\DocumentInterface
+     * @return \Swis\JsonApi\Client\Interfaces\ItemDocumentInterface<TItem>
      */
     public function save(ItemInterface $item, array $parameters = [], array $headers = [])
     {
diff --git a/src/Actions/TakeOne.php b/src/Actions/TakeOne.php
index c7a1c72..4ec0b5e 100644
--- a/src/Actions/TakeOne.php
+++ b/src/Actions/TakeOne.php
@@ -4,10 +4,13 @@ declare(strict_types=1);
 
 namespace Swis\JsonApi\Client\Actions;
 
+/**
+ * @template TItem of \Swis\JsonApi\Client\Interfaces\ItemInterface
+ */
 trait TakeOne
 {
     /**
-     * @return \Swis\JsonApi\Client\Interfaces\DocumentInterface
+     * @return \Swis\JsonApi\Client\Interfaces\ItemDocumentInterface<TItem>
      */
     public function take(array $parameters = [], array $headers = [])
     {
diff --git a/src/Actions/Update.php b/src/Actions/Update.php
index 8e972b3..f9e7d13 100644
--- a/src/Actions/Update.php
+++ b/src/Actions/Update.php
@@ -6,10 +6,13 @@ namespace Swis\JsonApi\Client\Actions;
 
 use Swis\JsonApi\Client\Interfaces\ItemInterface;
 
+/**
+ * @template TItem of \Swis\JsonApi\Client\Interfaces\ItemInterface
+ */
 trait Update
 {
     /**
-     * @return \Swis\JsonApi\Client\Interfaces\DocumentInterface
+     * @return \Swis\JsonApi\Client\Interfaces\ItemDocumentInterface<TItem>
      */
     public function update(ItemInterface $item, array $parameters = [], array $headers = [])
     {
diff --git a/src/Collection.php b/src/Collection.php
index e929a99..c93675f 100644
--- a/src/Collection.php
+++ b/src/Collection.php
@@ -6,6 +6,12 @@ namespace Swis\JsonApi\Client;
 
 use Swis\JsonApi\Client\Interfaces\DataInterface;
 
+/**
+ * @template TKey of int
+ * @template TValue of \Swis\JsonApi\Client\Interfaces\ItemInterface
+ *
+ * @extends \Illuminate\Support\Collection<TKey, TValue>
+ */
 class Collection extends \Illuminate\Support\Collection implements DataInterface
 {
     /**
diff --git a/src/Concerns/HasRelations.php b/src/Concerns/HasRelations.php
index 5fe9be2..33cd137 100644
--- a/src/Concerns/HasRelations.php
+++ b/src/Concerns/HasRelations.php
@@ -26,8 +26,10 @@ trait HasRelations
     /**
      * Create a singular relation to another item.
      *
+     * @template TItem of \Swis\JsonApi\Client\Interfaces\ItemInterface
      *
-     * @return \Swis\JsonApi\Client\Relations\HasOneRelation
+     * @param  class-string<TItem>  $itemClass
+     * @return \Swis\JsonApi\Client\Interfaces\OneRelationInterface<TItem>
      */
     public function hasOne(string $itemClass, ?string $name = null): OneRelationInterface
     {
@@ -48,8 +50,10 @@ trait HasRelations
     /**
      * Create a plural relation to another item.
      *
+     * @template TItem of \Swis\JsonApi\Client\Interfaces\ItemInterface
      *
-     * @return \Swis\JsonApi\Client\Relations\HasManyRelation
+     * @param  class-string<TItem>  $itemClass
+     * @return \Swis\JsonApi\Client\Interfaces\ManyRelationInterface<TItem>
      */
     public function hasMany(string $itemClass, ?string $name = null): ManyRelationInterface
     {
diff --git a/src/Interfaces/CollectionDocumentInterface.php b/src/Interfaces/CollectionDocumentInterface.php
index c8b21d8..a847a12 100644
--- a/src/Interfaces/CollectionDocumentInterface.php
+++ b/src/Interfaces/CollectionDocumentInterface.php
@@ -4,10 +4,13 @@ declare(strict_types=1);
 
 namespace Swis\JsonApi\Client\Interfaces;
 
+/**
+ * @template TItem of \Swis\JsonApi\Client\Interfaces\ItemInterface
+ */
 interface CollectionDocumentInterface extends DocumentInterface
 {
     /**
-     * @return \Swis\JsonApi\Client\Collection
+     * @return \Swis\JsonApi\Client\Collection<int, TItem>
      */
     public function getData();
 }
diff --git a/src/Interfaces/ItemDocumentInterface.php b/src/Interfaces/ItemDocumentInterface.php
index 54f266e..d34431a 100644
--- a/src/Interfaces/ItemDocumentInterface.php
+++ b/src/Interfaces/ItemDocumentInterface.php
@@ -4,10 +4,13 @@ declare(strict_types=1);
 
 namespace Swis\JsonApi\Client\Interfaces;
 
+/**
+ * @template TItem of \Swis\JsonApi\Client\Interfaces\ItemInterface
+ */
 interface ItemDocumentInterface extends DocumentInterface
 {
     /**
-     * @return \Swis\JsonApi\Client\Interfaces\ItemInterface
+     * @return TItem
      */
     public function getData();
 }
diff --git a/src/Interfaces/ManyRelationInterface.php b/src/Interfaces/ManyRelationInterface.php
index e7fd663..ebc34f4 100644
--- a/src/Interfaces/ManyRelationInterface.php
+++ b/src/Interfaces/ManyRelationInterface.php
@@ -8,10 +8,20 @@ use Swis\JsonApi\Client\Collection;
 use Swis\JsonApi\Client\Links;
 use Swis\JsonApi\Client\Meta;
 
+/**
+ * @template TItem of \Swis\JsonApi\Client\Interfaces\ItemInterface
+ */
 interface ManyRelationInterface
 {
+    /**
+     * @param  \Swis\JsonApi\Client\Collection<int, TItem>|null  $data
+     * @return static
+     */
     public function setData(?Collection $data);
 
+    /**
+     * @return \Swis\JsonApi\Client\Collection<int, TItem>|null
+     */
     public function getData(): ?Collection;
 
     public function hasData(): bool;
@@ -23,6 +33,7 @@ interface ManyRelationInterface
     public function hasIncluded(): bool;
 
     /**
+     * @param  \Swis\JsonApi\Client\Collection<int, TItem>  $included
      * @return static
      */
     public function associate(Collection $included);
diff --git a/src/Interfaces/OneRelationInterface.php b/src/Interfaces/OneRelationInterface.php
index 10f26b1..414c9b3 100644
--- a/src/Interfaces/OneRelationInterface.php
+++ b/src/Interfaces/OneRelationInterface.php
@@ -7,21 +7,38 @@ namespace Swis\JsonApi\Client\Interfaces;
 use Swis\JsonApi\Client\Links;
 use Swis\JsonApi\Client\Meta;
 
+/**
+ * @template TItem of \Swis\JsonApi\Client\Interfaces\ItemInterface
+ */
 interface OneRelationInterface
 {
+    /**
+     * @param  TItem|null  $data
+     * @return static
+     */
     public function setData(?ItemInterface $data);
 
+    /**
+     * @return TItem|null
+     */
     public function getData(): ?ItemInterface;
 
     public function hasData(): bool;
 
+    /**
+     * @param  TItem|null  $included
+     */
     public function setIncluded(?ItemInterface $included);
 
+    /**
+     * @return TItem|null
+     */
     public function getIncluded(): ?ItemInterface;
 
     public function hasIncluded(): bool;
 
     /**
+     * @param  TItem  $included
      * @return static
      */
     public function associate(ItemInterface $included);
@@ -31,6 +48,9 @@ interface OneRelationInterface
      */
     public function dissociate();
 
+    /**
+     * @return TItem|null
+     */
     public function getAssociated(): ?ItemInterface;
 
     public function hasAssociated(): bool;
diff --git a/src/Interfaces/RepositoryInterface.php b/src/Interfaces/RepositoryInterface.php
index 80b7a41..df2de87 100644
--- a/src/Interfaces/RepositoryInterface.php
+++ b/src/Interfaces/RepositoryInterface.php
@@ -4,20 +4,23 @@ declare(strict_types=1);
 
 namespace Swis\JsonApi\Client\Interfaces;
 
+/**
+ * @template TItem of \Swis\JsonApi\Client\Interfaces\ItemInterface
+ */
 interface RepositoryInterface
 {
     /**
-     * @return \Swis\JsonApi\Client\Interfaces\CollectionDocumentInterface
+     * @return \Swis\JsonApi\Client\Interfaces\CollectionDocumentInterface<TItem>
      */
     public function all();
 
     /**
-     * @return \Swis\JsonApi\Client\Interfaces\ItemDocumentInterface
+     * @return \Swis\JsonApi\Client\Interfaces\ItemDocumentInterface<TItem>
      */
     public function find(string $id);
 
     /**
-     * @return \Swis\JsonApi\Client\Interfaces\ItemDocumentInterface
+     * @return \Swis\JsonApi\Client\Interfaces\ItemDocumentInterface<TItem>
      */
     public function save(ItemInterface $item);
 
diff --git a/src/Repository.php b/src/Repository.php
index 7516c9a..542a291 100644
--- a/src/Repository.php
+++ b/src/Repository.php
@@ -11,11 +11,24 @@ use Swis\JsonApi\Client\Actions\Save;
 use Swis\JsonApi\Client\Actions\TakeOne;
 use Swis\JsonApi\Client\Interfaces\RepositoryInterface;
 
+/**
+ * @template TItem of \Swis\JsonApi\Client\Interfaces\ItemInterface
+ *
+ * @implements RepositoryInterface<TItem>
+ */
 class Repository extends BaseRepository implements RepositoryInterface
 {
     use Delete;
+
+    /** @use FetchMany<TItem> */
     use FetchMany;
+
+    /** @use FetchOne<TItem> */
     use FetchOne;
+
+    /** @use Save<TItem> */
     use Save;
+
+    /** @use TakeOne<TItem> */
     use TakeOne;
 }
diff --git a/types/Item.php b/types/Item.php
new file mode 100644
index 0000000..73a5289
--- /dev/null
+++ b/types/Item.php
@@ -0,0 +1,13 @@
+<?php
+
+use Swis\JsonApi\Client\Tests\Mocks\Items\ParentItem;
+
+use function PHPStan\Testing\assertType;
+
+$item = new ParentItem;
+
+assertType('Swis\JsonApi\Client\Tests\Mocks\Items\ChildItem|null', $item->child()->getData());
+assertType('Swis\JsonApi\Client\Collection<int, Swis\JsonApi\Client\Tests\Mocks\Items\ChildItem>|null', $item->children()->getData());
+
+assertType('Swis\JsonApi\Client\Interfaces\ItemInterface|null', $item->morph()->getData());
+assertType('Swis\JsonApi\Client\Collection<int, Swis\JsonApi\Client\Interfaces\ItemInterface>|null', $item->morphmany()->getData());
diff --git a/types/Repository.php b/types/Repository.php
new file mode 100644
index 0000000..1e074dd
--- /dev/null
+++ b/types/Repository.php
@@ -0,0 +1,27 @@
+<?php
+
+use Swis\JsonApi\Client\DocumentClient;
+use Swis\JsonApi\Client\DocumentFactory;
+use Swis\JsonApi\Client\Tests\Mocks\ItemStub;
+use Swis\JsonApi\Client\Tests\Mocks\MockRepository;
+
+use function PHPStan\Testing\assertType;
+
+$repository = new MockRepository(DocumentClient::create(), new DocumentFactory);
+
+$all = $repository->all();
+assertType('Swis\JsonApi\Client\Interfaces\CollectionDocumentInterface<Swis\JsonApi\Client\Tests\Mocks\ItemStub>', $all);
+assertType('Swis\JsonApi\Client\Collection<int, Swis\JsonApi\Client\Tests\Mocks\ItemStub>', $all->getData());
+assertType('Swis\JsonApi\Client\Tests\Mocks\ItemStub|null', $all->getData()->first());
+
+$find = $repository->find('foo');
+assertType('Swis\JsonApi\Client\Interfaces\ItemDocumentInterface<Swis\JsonApi\Client\Tests\Mocks\ItemStub>', $find);
+assertType('Swis\JsonApi\Client\Tests\Mocks\ItemStub', $find->getData());
+
+$take = $repository->take();
+assertType('Swis\JsonApi\Client\Interfaces\ItemDocumentInterface<Swis\JsonApi\Client\Tests\Mocks\ItemStub>', $take);
+assertType('Swis\JsonApi\Client\Tests\Mocks\ItemStub', $take->getData());
+
+$save = $repository->save(new ItemStub);
+assertType('Swis\JsonApi\Client\Interfaces\ItemDocumentInterface<Swis\JsonApi\Client\Tests\Mocks\ItemStub>', $save);
+assertType('Swis\JsonApi\Client\Tests\Mocks\ItemStub', $save->getData());


Test patch:
diff --git a/tests/_mocks/ItemStub.php b/tests/_mocks/ItemStub.php
index 7387b22..581f631 100644
--- a/tests/_mocks/ItemStub.php
+++ b/tests/_mocks/ItemStub.php
@@ -8,8 +8,14 @@ use Swis\JsonApi\Client\Item;
 
 class ItemStub extends Item
 {
+    /**
+     * @var array<int, string>
+     */
     protected $hidden = ['password'];
 
+    /**
+     * @var array<string, string>
+     */
     protected $casts = [
         'age' => 'integer',
         'score' => 'float',
@@ -25,10 +31,16 @@ class ItemStub extends Item
         'foo' => 'bar',
     ];
 
+    /**
+     * @var array<int, string>
+     */
     protected $guarded = [
         'secret',
     ];
 
+    /**
+     * @var array<int, string>
+     */
     protected $fillable = [
         'name',
         'city',
@@ -42,34 +54,46 @@ class ItemStub extends Item
         'collection_data',
     ];
 
-    public function getListItemsAttribute($value)
+    /**
+     * @return array<int, string>
+     */
+    public function getListItemsAttribute(string $value): array
     {
-        return json_decode($value, true);
+        $result = json_decode($value, true);
+        assert(is_array($result));
+
+        /** @var array<int, string> $result */
+        return $result;
     }
 
-    public function setListItemsAttribute($value)
+    /**
+     * @param  array<int, string>  $value
+     */
+    public function setListItemsAttribute(array $value): void
     {
         $this->attributes['list_items'] = json_encode($value);
     }
 
-    public function setBirthdayAttribute($value)
+    public function setBirthdayAttribute(string $value): void
     {
         $this->attributes['birthday'] = strtotime($value);
     }
 
-    public function getBirthdayAttribute($value)
+    public function getBirthdayAttribute(int $value): string
     {
         return date('Y-m-d', $value);
     }
 
-    public function getAgeAttribute($value)
+    public function getAgeAttribute(mixed $value): int
     {
+        assert(is_int($this->attributes['birthday']));
         $date = \DateTime::createFromFormat('U', (string) $this->attributes['birthday']);
+        assert($date instanceof \DateTime);
 
         return $date->diff(new \DateTime('now'))->y;
     }
 
-    public function getTestAttribute($value)
+    public function getTestAttribute(mixed $value): string
     {
         return 'test';
     }
diff --git a/tests/_mocks/Items/AnotherRelatedItem.php b/tests/_mocks/Items/AnotherRelatedItem.php
index 7d56827..6887933 100644
--- a/tests/_mocks/Items/AnotherRelatedItem.php
+++ b/tests/_mocks/Items/AnotherRelatedItem.php
@@ -8,13 +8,10 @@ use Swis\JsonApi\Client\Item;
 
 class AnotherRelatedItem extends Item
 {
-    /**
-     * @var string
-     */
     protected $type = 'another-related-item';
 
     /**
-     * @var array
+     * @var array<int, string>
      */
     protected $visible = [
         'test_related_attribute1',
diff --git a/tests/_mocks/Items/ChildItem.php b/tests/_mocks/Items/ChildItem.php
index f167e7f..5643dc3 100644
--- a/tests/_mocks/Items/ChildItem.php
+++ b/tests/_mocks/Items/ChildItem.php
@@ -8,13 +8,10 @@ use Swis\JsonApi\Client\Item;
 
 class ChildItem extends Item
 {
-    /**
-     * @var string
-     */
     protected $type = 'child';
 
     /**
-     * @var array
+     * @var array<int, string>
      */
     protected $visible = [
         'active',
@@ -22,12 +19,15 @@ class ChildItem extends Item
     ];
 
     /**
-     * @var array
+     * @var array<string, string>
      */
     protected $casts = [
         'active' => 'bool',
     ];
 
+    /**
+     * @var array<string, mixed>
+     */
     protected $attributes = [
         'active' => true,
     ];
diff --git a/tests/_mocks/Items/ParentItem.php b/tests/_mocks/Items/ParentItem.php
index 8a09b11..6dfa612 100644
--- a/tests/_mocks/Items/ParentItem.php
+++ b/tests/_mocks/Items/ParentItem.php
@@ -4,17 +4,16 @@ declare(strict_types=1);
 
 namespace Swis\JsonApi\Client\Tests\Mocks\Items;
 
+use Swis\JsonApi\Client\Interfaces\ManyRelationInterface;
+use Swis\JsonApi\Client\Interfaces\OneRelationInterface;
 use Swis\JsonApi\Client\Item;
 
 class ParentItem extends Item
 {
-    /**
-     * @var string
-     */
     protected $type = 'parent';
 
     /**
-     * @var array
+     * @var array<int, string>
      */
     protected $visible = [
         'active',
@@ -23,21 +22,21 @@ class ParentItem extends Item
     ];
 
     /**
-     * @var array
+     * @var array<string, string>
      */
     protected $casts = [
         'active' => 'bool',
     ];
 
     /**
-     * @var array
+     * @var array<string, mixed>
      */
     protected $attributes = [
         'active' => true,
     ];
 
     /**
-     * @var array
+     * @var array<int, string>
      */
     protected $availableRelations = [
         'child',
@@ -47,27 +46,42 @@ class ParentItem extends Item
         'does_not_exist',
     ];
 
-    public function child()
+    /**
+     * @return \Swis\JsonApi\Client\Interfaces\OneRelationInterface<\Swis\JsonApi\Client\Tests\Mocks\Items\ChildItem>
+     */
+    public function child(): OneRelationInterface
     {
         return $this->hasOne(ChildItem::class);
     }
 
-    public function children()
+    /**
+     * @return \Swis\JsonApi\Client\Interfaces\ManyRelationInterface<\Swis\JsonApi\Client\Tests\Mocks\Items\ChildItem>
+     */
+    public function children(): ManyRelationInterface
     {
         return $this->hasMany(ChildItem::class);
     }
 
-    public function morph()
+    /**
+     * @return \Swis\JsonApi\Client\Interfaces\OneRelationInterface<\Swis\JsonApi\Client\Interfaces\ItemInterface>
+     */
+    public function morph(): OneRelationInterface
     {
         return $this->morphTo();
     }
 
-    public function morphmany()
+    /**
+     * @return \Swis\JsonApi\Client\Interfaces\ManyRelationInterface<\Swis\JsonApi\Client\Interfaces\ItemInterface>
+     */
+    public function morphmany(): ManyRelationInterface
     {
         return $this->morphToMany();
     }
 
-    public function empty()
+    /**
+     * @return \Swis\JsonApi\Client\Interfaces\OneRelationInterface<\Swis\JsonApi\Client\Tests\Mocks\Items\ChildItem>
+     */
+    public function empty(): OneRelationInterface
     {
         return $this->hasOne(ChildItem::class);
     }
diff --git a/tests/_mocks/Items/RelatedItem.php b/tests/_mocks/Items/RelatedItem.php
index 5f8de74..3144ff4 100644
--- a/tests/_mocks/Items/RelatedItem.php
+++ b/tests/_mocks/Items/RelatedItem.php
@@ -4,17 +4,15 @@ declare(strict_types=1);
 
 namespace Swis\JsonApi\Client\Tests\Mocks\Items;
 
+use Swis\JsonApi\Client\Interfaces\OneRelationInterface;
 use Swis\JsonApi\Client\Item;
 
 class RelatedItem extends Item
 {
-    /**
-     * @var string
-     */
     protected $type = 'related-item';
 
     /**
-     * @var array
+     * @var array<int, string>
      */
     protected $visible = [
         'test_related_attribute1',
@@ -22,13 +20,16 @@ class RelatedItem extends Item
     ];
 
     /**
-     * @var array
+     * @var array<int, string>
      */
     protected $availableRelations = [
         'parent_relation',
     ];
 
-    public function parentRelation()
+    /**
+     * @return \Swis\JsonApi\Client\Interfaces\OneRelationInterface<\Swis\JsonApi\Client\Tests\Mocks\Items\WithRelationshipItem>
+     */
+    public function parentRelation(): OneRelationInterface
     {
         return $this->hasOne(WithRelationshipItem::class);
     }
diff --git a/tests/_mocks/Items/WithHiddenItem.php b/tests/_mocks/Items/WithHiddenItem.php
index 38dae29..11b73f3 100644
--- a/tests/_mocks/Items/WithHiddenItem.php
+++ b/tests/_mocks/Items/WithHiddenItem.php
@@ -8,6 +8,9 @@ use Swis\JsonApi\Client\Item;
 
 class WithHiddenItem extends Item
 {
+    /**
+     * @var array<int, string>
+     */
     protected $hidden = [
         'testKey',
     ];
diff --git a/tests/_mocks/Items/WithRelationshipItem.php b/tests/_mocks/Items/WithRelationshipItem.php
index 9f4cc04..a1cb8f1 100644
--- a/tests/_mocks/Items/WithRelationshipItem.php
+++ b/tests/_mocks/Items/WithRelationshipItem.php
@@ -4,17 +4,16 @@ declare(strict_types=1);
 
 namespace Swis\JsonApi\Client\Tests\Mocks\Items;
 
+use Swis\JsonApi\Client\Interfaces\ManyRelationInterface;
+use Swis\JsonApi\Client\Interfaces\OneRelationInterface;
 use Swis\JsonApi\Client\Item;
 
 class WithRelationshipItem extends Item
 {
-    /**
-     * @var string
-     */
     protected $type = 'item-with-relationship';
 
     /**
-     * @var array
+     * @var array<int, string>
      */
     protected $visible = [
         'test_attribute_1',
@@ -22,7 +21,7 @@ class WithRelationshipItem extends Item
     ];
 
     /**
-     * @var array
+     * @var array<int, string>
      */
     protected $availableRelations = [
         'hasone_relation',
@@ -31,22 +30,34 @@ class WithRelationshipItem extends Item
         'morphtomany_relation',
     ];
 
-    public function hasoneRelation()
+    /**
+     * @return \Swis\JsonApi\Client\Interfaces\OneRelationInterface<\Swis\JsonApi\Client\Tests\Mocks\Items\RelatedItem>
+     */
+    public function hasoneRelation(): OneRelationInterface
     {
         return $this->hasOne(RelatedItem::class);
     }
 
-    public function hasmanyRelation()
+    /**
+     * @return \Swis\JsonApi\Client\Interfaces\ManyRelationInterface<\Swis\JsonApi\Client\Tests\Mocks\Items\RelatedItem>
+     */
+    public function hasmanyRelation(): ManyRelationInterface
     {
         return $this->hasMany(RelatedItem::class);
     }
 
-    public function morphtoRelation()
+    /**
+     * @return \Swis\JsonApi\Client\Interfaces\OneRelationInterface<\Swis\JsonApi\Client\Interfaces\ItemInterface>
+     */
+    public function morphtoRelation(): OneRelationInterface
     {
         return $this->morphTo();
     }
 
-    public function morphtomanyRelation()
+    /**
+     * @return \Swis\JsonApi\Client\Interfaces\ManyRelationInterface<\Swis\JsonApi\Client\Interfaces\ItemInterface>
+     */
+    public function morphtomanyRelation(): ManyRelationInterface
     {
         return $this->morphToMany();
     }
diff --git a/tests/_mocks/Items/WithoutRelationshipsItem.php b/tests/_mocks/Items/WithoutRelationshipsItem.php
index 3c8f291..a6a509f 100644
--- a/tests/_mocks/Items/WithoutRelationshipsItem.php
+++ b/tests/_mocks/Items/WithoutRelationshipsItem.php
@@ -8,8 +8,5 @@ use Swis\JsonApi\Client\Item;
 
 class WithoutRelationshipsItem extends Item
 {
-    /**
-     * @var string
-     */
     protected $type = 'item-without-relationships';
 }
diff --git a/tests/_mocks/MockRepository.php b/tests/_mocks/MockRepository.php
index 9697e1d..d5d94ef 100644
--- a/tests/_mocks/MockRepository.php
+++ b/tests/_mocks/MockRepository.php
@@ -6,6 +6,9 @@ namespace Swis\JsonApi\Client\Tests\Mocks;
 
 use Swis\JsonApi\Client\Repository;
 
+/**
+ * @extends Repository<\Swis\JsonApi\Client\Tests\Mocks\ItemStub>
+ */
 class MockRepository extends Repository
 {
     protected $endpoint = 'mocks';