diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 000000000..67bd3b5ec
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,16 @@
+* text=auto
+
+/tests export-ignore
+/vendor-bin export-ignore
+/.gitattributes export-ignore
+/.github export-ignore
+/.gitignore export-ignore
+/.styleci.yml export-ignore
+/Makefile export-ignore
+/phpstan-baseline.neon export-ignore
+/phpstan.neon.dist export-ignore
+/phpunit.xml.dist export-ignore
+/psalm-baseline.xml export-ignore
+/psalm.xml export-ignore
+/CHANGELOG.md export-ignore
+/README.md export-ignore
diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md
new file mode 100644
index 000000000..590c95931
--- /dev/null
+++ b/.github/CODE_OF_CONDUCT.md
@@ -0,0 +1,132 @@
+# CONTRIBUTOR COVENANT CODE OF CONDUCT
+
+## Our Pledge
+
+We as members, contributors, and leaders pledge to make participation in our
+community a harassment-free experience for everyone, regardless of age, body
+size, visible or invisible disability, ethnicity, sex characteristics, gender
+identity and expression, level of experience, education, socio-economic status,
+nationality, personal appearance, race, caste, color, religion, or sexual identity
+and orientation.
+
+We pledge to act and interact in ways that contribute to an open, welcoming,
+diverse, inclusive, and healthy community.
+
+## Our Standards
+
+Examples of behavior that contributes to a positive environment for our
+community include:
+
+* Demonstrating empathy and kindness toward other people
+* Being respectful of differing opinions, viewpoints, and experiences
+* Giving and gracefully accepting constructive feedback
+* Accepting responsibility and apologizing to those affected by our mistakes,
+  and learning from the experience
+* Focusing on what is best not just for us as individuals, but for the
+  overall community
+
+Examples of unacceptable behavior include:
+
+* The use of sexualized language or imagery, and sexual attention or
+  advances of any kind
+* Trolling, insulting or derogatory comments, and personal or political attacks
+* Public or private harassment
+* Publishing others' private information, such as a physical or email
+  address, without their explicit permission
+* Other conduct which could reasonably be considered inappropriate in a
+  professional setting
+
+## Enforcement Responsibilities
+
+Community leaders are responsible for clarifying and enforcing our standards of
+acceptable behavior and will take appropriate and fair corrective action in
+response to any behavior that they deem inappropriate, threatening, offensive,
+or harmful.
+
+Community leaders have the right and responsibility to remove, edit, or reject
+comments, commits, code, wiki edits, issues, and other contributions that are
+not aligned to this Code of Conduct, and will communicate reasons for moderation
+decisions when appropriate.
+
+## Scope
+
+This Code of Conduct applies within all community spaces, and also applies when
+an individual is officially representing the community in public spaces.
+Examples of representing our community include using an official e-mail address,
+posting via an official social media account, or acting as an appointed
+representative at an online or offline event.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be
+reported to the community leaders responsible for enforcement at
+hello@gjcampbell.co.uk.
+All complaints will be reviewed and investigated promptly and fairly.
+
+All community leaders are obligated to respect the privacy and security of the
+reporter of any incident.
+
+## Enforcement Guidelines
+
+Community leaders will follow these Community Impact Guidelines in determining
+the consequences for any action they deem in violation of this Code of Conduct:
+
+### 1. Correction
+
+**Community Impact**: Use of inappropriate language or other behavior deemed
+unprofessional or unwelcome in the community.
+
+**Consequence**: A private, written warning from community leaders, providing
+clarity around the nature of the violation and an explanation of why the
+behavior was inappropriate. A public apology may be requested.
+
+### 2. Warning
+
+**Community Impact**: A violation through a single incident or series
+of actions.
+
+**Consequence**: A warning with consequences for continued behavior. No
+interaction with the people involved, including unsolicited interaction with
+those enforcing the Code of Conduct, for a specified period of time. This
+includes avoiding interactions in community spaces as well as external channels
+like social media. Violating these terms may lead to a temporary or
+permanent ban.
+
+### 3. Temporary Ban
+
+**Community Impact**: A serious violation of community standards, including
+sustained inappropriate behavior.
+
+**Consequence**: A temporary ban from any sort of interaction or public
+communication with the community for a specified period of time. No public or
+private interaction with the people involved, including unsolicited interaction
+with those enforcing the Code of Conduct, is allowed during this period.
+Violating these terms may lead to a permanent ban.
+
+### 4. Permanent Ban
+
+**Community Impact**: Demonstrating a pattern of violation of community
+standards, including sustained inappropriate behavior,  harassment of an
+individual, or aggression toward or disparagement of classes of individuals.
+
+**Consequence**: A permanent ban from any sort of public interaction within
+the community.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant][homepage],
+version 2.1, available at
+[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
+
+Community Impact Guidelines were inspired by
+[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
+
+For answers to common questions about this code of conduct, see the FAQ at
+[https://www.contributor-covenant.org/faq][FAQ]. Translations are available
+at [https://www.contributor-covenant.org/translations][translations].
+
+[homepage]: https://www.contributor-covenant.org
+[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
+[Mozilla CoC]: https://github.com/mozilla/diversity
+[FAQ]: https://www.contributor-covenant.org/faq
+[translations]: https://www.contributor-covenant.org/translations
diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
new file mode 100644
index 000000000..5570c626b
--- /dev/null
+++ b/.github/CONTRIBUTING.md
@@ -0,0 +1,31 @@
+# CONTRIBUTION GUIDELINES
+
+Contributions are **welcome** and will be fully **credited**.
+
+We accept contributions via pull requests on GitHub. Please review these guidelines before continuing.
+
+## Guidelines
+
+* Please follow the [PSR-12 Coding Style Guide](https://www.php-fig.org/psr/psr-12/), enforced by [StyleCI](https://styleci.io/).
+* Ensure that the current tests pass, and if you've added something new, add the tests where relevant.
+* Send a coherent commit history, making sure each commit in your pull request is meaningful.
+* You may need to [rebase](https://git-scm.com/book/en/v2/Git-Branching-Rebasing) to avoid merge conflicts.
+* If you are changing or adding to the behaviour or public API, you may need to update the docs.
+* Please remember that we follow [Semantic Versioning](https://semver.org/).
+
+## Running Tests
+
+First, install the dependencies:
+
+```bash
+$ make install
+```
+
+Then run the test suite and static analyzers:
+
+```bash
+$ make test
+```
+
+* The tests will be automatically run by [GitHub Actions](https://github.com/features/actions) against pull requests.
+* We also have [StyleCI](https://styleci.io/) set up to automatically fix any code style issues.
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 000000000..0c5598bfa
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1 @@
+github: [GrahamCampbell]
diff --git a/.github/SECURITY.md b/.github/SECURITY.md
new file mode 100644
index 000000000..7236d4b14
--- /dev/null
+++ b/.github/SECURITY.md
@@ -0,0 +1,14 @@
+# SECURITY POLICY
+
+## Supported Versions
+
+After each new major release, the previous release will be supported for no
+less than 2 years, unless explicitly stated otherwise. This may mean that there
+are multiple supported versions at any given time.
+
+## Reporting a Vulnerability
+
+If you discover a security vulnerability within this package, please send an
+email to Graham Campbell at hello@gjcampbell.co.uk. All security vulnerabilities
+will be promptly addressed. Please do not disclose security-related issues
+publicly until a fix has been announced.
diff --git a/.github/stale.yml b/.github/stale.yml
new file mode 100644
index 000000000..09ab97f8b
--- /dev/null
+++ b/.github/stale.yml
@@ -0,0 +1,8 @@
+daysUntilStale: 120
+daysUntilClose: 30
+staleLabel: stale
+markComment: >
+  This issue has been automatically marked as stale because there has been no
+  recent activity. It will be closed after 30 days if no further activity
+  occurs. Thank you for your contributions.
+closeComment: false
diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml
new file mode 100644
index 000000000..b37fe5d04
--- /dev/null
+++ b/.github/workflows/static.yml
@@ -0,0 +1,74 @@
+name: Static Analysis
+
+on:
+  push:
+  pull_request:
+
+jobs:
+  phpstan:
+    name: PHPStan
+    runs-on: ubuntu-24.04
+
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@v4
+
+      - name: Setup PHP
+        uses: shivammathur/setup-php@v2
+        with:
+          php-version: '8.1'
+          tools: composer:v2
+          coverage: none
+        env:
+          update: true
+
+      - name: Install Dependencies
+        uses: nick-invision/retry@v3
+        with:
+          timeout_minutes: 5
+          max_attempts: 5
+          command: composer update --no-interaction --no-progress
+
+      - name: Install PHPStan
+        uses: nick-invision/retry@v3
+        with:
+          timeout_minutes: 5
+          max_attempts: 5
+          command: composer bin phpstan update --no-interaction --no-progress
+
+      - name: Execute PHPStan
+        run: vendor/bin/phpstan analyze --no-progress
+
+  psalm:
+    name: Psalm
+    runs-on: ubuntu-24.04
+
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@v4
+
+      - name: Setup PHP
+        uses: shivammathur/setup-php@v2
+        with:
+          php-version: '8.1'
+          tools: composer:v2
+          coverage: none
+        env:
+          update: true
+
+      - name: Install Dependencies
+        uses: nick-invision/retry@v3
+        with:
+          timeout_minutes: 5
+          max_attempts: 5
+          command: composer update --no-interaction --no-progress
+
+      - name: Install Psalm
+        uses: nick-invision/retry@v3
+        with:
+          timeout_minutes: 5
+          max_attempts: 5
+          command: composer bin psalm update --no-interaction --no-progress
+
+      - name: Execute Psalm
+        run: vendor/bin/psalm.phar --no-progress --output-format=github
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 000000000..f2a04d294
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,47 @@
+name: Tests
+
+on:
+  push:
+  pull_request:
+
+jobs:
+  tests:
+    name: PHP ${{ matrix.php }}
+    runs-on: ubuntu-24.04
+
+    strategy:
+      matrix:
+        php: ['8.1', '8.2', '8.3', '8.4']
+
+    steps:
+      - name: Checkout Code
+        uses: actions/checkout@v4
+
+      - name: Setup PHP
+        uses: shivammathur/setup-php@v2
+        with:
+          php-version: ${{ matrix.php }}
+          tools: composer:v2
+          coverage: none
+        env:
+          update: true
+
+      - name: Setup Problem Matchers
+        run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json"
+
+      - name: Install Dependencies
+        uses: nick-invision/retry@v3
+        with:
+          timeout_minutes: 5
+          max_attempts: 5
+          command: composer update --no-interaction --no-progress
+
+      - name: Install PHPUnit
+        uses: nick-invision/retry@v3
+        with:
+          timeout_minutes: 5
+          max_attempts: 5
+          command: composer bin phpunit update --no-interaction --no-progress
+
+      - name: Execute PHPUnit
+        run: vendor/bin/phpunit
diff --git a/.gitignore b/.gitignore
index d1502b087..04626b2fe 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,5 @@
-vendor/
+.phpunit.result.cache
 composer.lock
+phpstan.neon
+phpunit.xml
+vendor
diff --git a/.styleci.yml b/.styleci.yml
new file mode 100644
index 000000000..87da8d093
--- /dev/null
+++ b/.styleci.yml
@@ -0,0 +1,21 @@
+preset: symfony
+
+risky: true
+
+enabled:
+  - align_phpdoc
+  - alpha_ordered_imports
+  - array_indentation
+  - const_visibility_required
+  - declare_strict_types
+  - native_constant_invocation
+  - native_function_invocation
+  - phpdoc_order
+  - void_return
+
+disabled:
+  - native_constant_invocation_symfony
+  - native_function_invocation_symfony
+  - phpdoc_align
+  - phpdoc_to_comment
+  - phpdoc_var_without_name
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index d6b864cc0..000000000
--- a/.travis.yml
+++ /dev/null
@@ -1,15 +0,0 @@
-language: php
-
-php:
-  - 5.3
-  - 5.4
-  - 5.5
-  - 5.6
-  - 7.0
-
-before_script:
-  - travis_retry composer self-update
-  - travis_retry composer install --no-interaction --prefer-source
-
-script:
-  - vendor/bin/phpunit --verbose --coverage-text
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 000000000..65398fb44
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,330 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [12.0.0] - 2025-02-23
+
+* Add PHP 8.4 support
+* Drop support for PHP earlier than 8.1
+* Moved various param types to native PHP types
+
+## [11.14.0] - 2024-03-11
+
+* Add support for `php-http/cache-plugin:^2.0`
+* Add support for `'approved'` `status` in `Project::events`
+* Add support for `name` in `createRelease` and `updateRelease`
+* Add support for date filtering to `GroupsMilestones::all()`
+* Update `MergeRequests::all` to use millisecond precision for date filters
+
+## [11.13.0] - 2023-12-03
+
+* Add support for `symfony/options-resolver:^7.0`
+* Add support for `status` and `environment` in `Deployments::all`
+* Add support for `Groups::search`, `Projects::search`, and `Search::all`
+
+## [11.12.0] - 2023-10-08
+
+* Add PHP 8.3 support
+* Add `Projects::updateProtectedBranch` and `Projects::updateApprovalsConfiguration`
+* Add support for `environment_scope` in `Projects::removeVariable`
+* Add support for `filter` in `Projects::variable`
+* Add support for `author` in `Repositories::commits`
+* Add support for additional parameters in `Projects::labels` and `Groups::labels`
+
+## [11.11.1] - 2023-10-08
+
+* Fixed double encoding of job name in artifacts download
+
+## [11.11.0] - 2023-07-17
+
+* Add support for `author_id` in `Issues::all`
+* Add support for `tier` in `Environments::create`
+* Add support for `expires_at` in `Groups::addMember`
+* Add support for `include_retried` in `Jobs::pipelineBridges`
+* Add support for additional parameters in `Projects::deployment`
+* Add support for additional parameters in `Projects::forks`
+* Add support for `Events::all`
+* Add support for `Users::removeUserIdentity`
+* Add support for `MergeRequests::showParticipants`
+
+## [11.10.0] - 2023-04-30
+
+* Add support for `Packages::addGenericFile`
+* Add support for `Milestones::mergeRequests`
+* Add support for `Project::removeTrigger`
+* Add support for `Schedules::takeOwnership` and `Schedules::play`
+* Add support for `access_level` in `Projects::createProjectAccessToken`
+* Add support for `expires_at` in `Projects::addMember` and `Projects::saveMember`
+* Add support for `order_by` `version` in `Tags::all`
+* Added support for `psr/http-message` v2
+
+## [11.9.1] - 2023-04-30
+
+* Corrected upload avatar endpoint
+
+## [11.9.0] - 2023-03-06
+
+* Add PHP 8.2 support
+* Add support for group and project deploy tokens
+* Add source parameter to pipelines API
+* Add support for `Jobs::artifactByJobId`
+* Add support for `Users::usersStarredProjects`
+* Add support for `Groups::issues`
+* Add support for `Groups::iterations`
+* Add support for `Projects::iterations`
+* Add support for `Projects::projectAccessToken`
+* Add support for `Projects::pipelineTestReport`
+* Add support for `Projects::pipelineTestReportSummary`
+* Add support for `allowed_to_create` in `Projects::addProtectedTag`
+* Add support for `update_at` order by in `Projects::pipelines`
+* Added additional parameters to `Issues::all`
+* Added additional parameters to `Issues::group`
+* Added the ability to authenticate with a job token
+
+## [11.8.0] - 2022-04-24
+
+* Add support for `reviewer_id` and `wip` params in `MergeRequests::all()`
+* Add support for `GroupEpics::issues()`
+* Add support for `Projects::pipelineJobs()` and protected tags
+* Add support for the confidential filter in `Issues:all()`
+* Allow specifying params in `Wiki::showAll()`
+* Allow specifying params in `SystemHooks::create()`
+* Allow `chmod` action and `execute_filemode` attribute
+* Implement group merge requests endpoints
+* Implement event endpoints
+
+[11.8.0]: https://github.com/GitLabPHP/Client/compare/11.7.1...11.8.0
+
+## [11.7.1] - 2022-04-24
+
+* Fixed `GroupsEpic::all()` method
+* Fixed `Projects::createPipeline()` method
+
+[11.7.1]: https://github.com/GitLabPHP/Client/compare/11.7.0...11.7.1
+
+## [11.7.0] - 2022-01-24
+
+* Dropped PHP 7.2 and 7.3 support
+
+[11.7.0]: https://github.com/GitLabPHP/Client/compare/11.6.0...11.7.0
+
+## [11.6.0] - 2022-01-23
+
+* Added support for for workspace repository permissions
+* Added support for `psr/cache:^3.0`
+
+[11.6.0]: https://github.com/GitLabPHP/Client/compare/11.5.1...11.6.0
+
+## [11.5.1] - 2022-01-23
+
+* Fixed release API paths
+
+[11.5.1]: https://github.com/GitLabPHP/Client/compare/11.5.0...11.5.1
+
+## [11.5.0] - 2021-12-26
+
+* Added support for filtering environments by state
+* Added support for approval rules endpoints
+* Added support for toggling the activate state of users
+* Added support for managing packages
+* Added support for filtering projects by topics
+* Added support for locked merge requests
+* Added support for filtering groups and projects by user
+* Added support for removing protected branches
+* Added support for `psr/cache:^2.0`
+* Added support for `symfony/options-resolver:^6.0`
+* Added support for PHP 8.1
+
+[11.5.0]: https://github.com/GitLabPHP/Client/compare/11.4.1...11.5.0
+
+## [11.4.1] - 2021-12-26
+
+* Fixed creating environments
+* Fixed double encoding of query parameters when comparing commits
+
+[11.4.1]: https://github.com/GitLabPHP/Client/compare/11.4.0...11.4.1
+
+## [11.4.0] - 2021-03-27
+
+* Added parameters to the list of project repository tags
+* Added support for the epics endpoints
+* Added support for project access tokens
+* Added support for reverting commits
+
+[11.4.0]: https://github.com/GitLabPHP/Client/compare/11.3.0...11.4.0
+
+## [11.3.0] - 2021-03-14
+
+* Added support for disabling and enabling runners
+* Added support for a single inherited members
+* Added support for tag search
+
+[11.3.0]: https://github.com/GitLabPHP/Client/compare/11.2.1...11.3.0
+
+## [11.2.1] - 2021-03-14
+
+* Fixed commit order validation
+
+[11.2.1]: https://github.com/GitLabPHP/Client/compare/11.2.0...11.2.1
+
+## [11.2.0] - 2021-02-20
+
+* Added support for user memberships
+* Added support for the following projects parameters: id_after, id_before, last_activity_after, last_activity_before, repository_checksum_failed, repository_storage, wiki_checksum_failed, with_custom_attributes, with_programming_language
+
+[11.2.0]: https://github.com/GitLabPHP/Client/compare/11.1.0...11.2.0
+
+## [11.1.0] - 2021-01-25
+
+* Added CI schedule variables endpoints
+* Added support for triggering a pipeline
+* Added support for the search_namespaces projects parameter
+* Added support for order_by and sort deployments parameters
+
+[11.1.0]: https://github.com/GitLabPHP/Client/compare/11.0.0...11.1.0
+
+## [11.0.0] - 2020-12-22
+
+* Removed models API
+* Dropped support for PHP 7.1
+* Updated to latest labels API
+* Made builder class final
+* Re-worked pagination
+* Client authenticate and setUrl now return void
+* Added additional return type enforcement
+
+[11.0.0]: https://github.com/GitLabPHP/Client/compare/10.4.0...11.0.0
+
+## [10.4.0] - 2020-12-22
+
+[10.4.0]: https://github.com/GitLabPHP/Client/compare/10.3.1...10.4.0
+
+* Add min_access_level option to group search
+* Added support for additional issue order clauses
+* Added params array to remove user method to support hard_delete
+
+## [10.3.1] - 2020-12-04
+
+* Work around GitLab's API returning bad JSON for some endpoints
+
+[10.3.1]: https://github.com/GitLabPHP/Client/compare/10.3.0...10.3.1
+
+## [10.3.0] - 2020-11-27
+
+* Support PHP 8.0
+
+[10.3.0]: https://github.com/GitLabPHP/Client/compare/10.2.0...10.3.0
+
+## [10.2.0] - 2020-11-09
+
+* Added variable_type to addVariable and updateVariable
+* Added get pipeline bridget jobs method
+
+[10.2.0]: https://github.com/GitLabPHP/Client/compare/10.1.2...10.2.0
+
+## [10.1.2] - 2020-11-09
+
+* Fixed comparing repositories
+
+[10.1.2]: https://github.com/GitLabPHP/Client/compare/10.1.1...10.1.2
+
+## [10.1.1] - 2020-10-26
+
+* Fixed phpdoc typo
+* Fixed broken query builder
+
+[10.1.1]: https://github.com/GitLabPHP/Client/compare/10.1.0...10.1.1
+
+## [10.1.0] - 2020-10-24
+
+* Added method to get protected branches for a project
+* Added with_merge_status_recheck option for fetching MRs
+* Added commit cherry-pick API
+* Added support for optional Note parameters 
+* Deprecated models API
+
+[10.1.0]: https://github.com/GitLabPHP/Client/compare/10.0.1...10.1.0
+
+## [10.0.1] - 2020-10-24
+
+* Fixed using the name of a group as an ID
+* Fixed various phpdoc issues
+* Reverted query builder changes
+
+[10.0.1]: https://github.com/GitLabPHP/Client/compare/10.0.0...10.0.1
+
+## [10.0.0] - 2020-08-15
+
+* Added void return types to void methods
+
+[10.0.0]: https://github.com/GitLabPHP/Client/compare/10.0.0-RC2...10.0.0
+
+## [10.0.0-RC2] - 2020-07-23
+
+* Restored 9.x behaviour for empty JSON responses
+* Support the issue link link_type parameter
+
+[10.0.0-RC2]: https://github.com/GitLabPHP/Client/compare/10.0.0-RC1...10.0.0-RC2
+
+## [10.0.0-RC1] - 2020-07-22
+
+* Removed all deprecated functionality
+* Switched to PSR-17 and PSR-18
+* Encode URIs according to RFC 3986
+* Send request bodies as JSON to GitLab
+* Redesigned pagination
+* Added array types where missing
+* Added scalar param types
+* Added user events API
+
+[10.0.0-RC1]: https://github.com/GitLabPHP/Client/compare/9.18.1...10.0.0-RC1
+
+## [9.18.1] - 2020-07-22
+
+* Fixed error in getHeader function
+* Fixed incorrect param type doc
+
+[9.18.1]: https://github.com/GitLabPHP/Client/compare/9.18.0...9.18.1
+
+## [9.18.0] - 2020-07-11
+
+* Deprecated all APIs that are deprecated or removed as of GitLab 13.1
+* Deprecated old authentication methods and deprecated not specifying an authentication mode
+* Deprecated dynamic property access on the client, `Client::api()`, `Client::create()`, and `Client::getResponseHistory()`
+* Deprecated passing a stream factory to the Api classes: get it from the client instance instead
+* Soft marked various classes as final and/or internal
+* Added support for HTTP caching
+* Implement removing award emojis
+* Implemented notes APIs
+* Extended pipeline APIs
+* Extended MR approvals APIs
+* Add subscribe/unsubscribe methods to issue API
+* Add scope and allow all projects to MR API
+* Add method to access project discussions
+* Update parameters for repository/commits APIs
+* Added delete merged branches API function
+* Allow to search and find issues by "assignee_id"
+* Updated Issues to support updated_after
+
+[9.18.0]: https://github.com/GitLabPHP/Client/compare/9.17.1...9.18.0
+
+## [9.17.1] - 2020-02-17
+
+* Fixed text encoding for `Repositories::createCommit()`
+* Corrected lots of phpdoc errors and edges cases
+
+[9.17.1]: https://github.com/GitLabPHP/Client/compare/9.17.0...9.17.1
+
+## [9.17.0] - 2020-02-17
+
+* Added support for the wiki APIs
+* Implemented `Environments::show()`
+* Implemented `Issues::showParticipants()`
+* Add method to get issues for a group
+* Add forks API call to return all forked projects
+* Added users projects request parameters normalization
+
+[9.17.0]: https://github.com/GitLabPHP/Client/compare/9.16.0...9.17.0
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 000000000..9cebae09c
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,22 @@
+MIT License
+
+Copyright (c) 2012-2018 Matt Humphrey <matth@windsor-telecom.co.uk>
+Copyright (c) 2018-2025 Graham Campbell <hello@gjcampbell.co.uk>
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/Makefile b/Makefile
new file mode 100644
index 000000000..a451bbc12
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,26 @@
+install:
+	@docker run -it -w /data -v ${PWD}:/data:delegated -v ~/.composer:/root/.composer:delegated --entrypoint composer --rm registry.gitlab.com/grahamcampbell/php:8.1-base update
+	@docker run -it -w /data -v ${PWD}:/data:delegated -v ~/.composer:/root/.composer:delegated --entrypoint composer --rm registry.gitlab.com/grahamcampbell/php:8.1-base bin all update
+
+phpunit:
+	@docker run -it -w /data -v ${PWD}:/data:delegated --entrypoint vendor/bin/phpunit --rm registry.gitlab.com/grahamcampbell/php:8.1-cli
+
+phpstan-analyze:
+	@docker run -it -w /data -v ${PWD}:/data:delegated --entrypoint vendor/bin/phpstan --rm registry.gitlab.com/grahamcampbell/php:8.1-cli analyze
+
+phpstan-baseline:
+	@docker run -it -w /data -v ${PWD}:/data:delegated --entrypoint vendor/bin/phpstan --rm registry.gitlab.com/grahamcampbell/php:8.1-cli analyze --generate-baseline
+
+psalm-analyze:
+	@docker run -it -w /data -v ${PWD}:/data:delegated --entrypoint vendor/bin/psalm.phar --rm registry.gitlab.com/grahamcampbell/php:8.1-cli
+
+psalm-baseline:
+	@docker run -it -w /data -v ${PWD}:/data:delegated --entrypoint vendor/bin/psalm.phar --rm registry.gitlab.com/grahamcampbell/php:8.1-cli --set-baseline=psalm-baseline.xml
+
+psalm-show-info:
+	@docker run -it -w /data -v ${PWD}:/data:delegated --entrypoint vendor/bin/psalm.phar --rm registry.gitlab.com/grahamcampbell/php:8.1-cli --show-info=true
+
+test: phpunit phpstan-analyze psalm-analyze
+
+clean:
+	@rm -rf .phpunit.result.cache composer.lock vendor vendor-bin/*/composer.lock vendor-bin/*/vendor
diff --git a/README.md b/README.md
index 33bb96e45..35065ecdc 100644
--- a/README.md
+++ b/README.md
@@ -1,98 +1,118 @@
-A PHP wrapper to be used with [Gitlab's API](https://github.com/gitlabhq/gitlabhq/tree/master/doc/api).
-==============
+# GitLab PHP API Client
 
-[![Build Status](https://travis-ci.org/m4tthumphrey/php-gitlab-api.svg?branch=master)](https://travis-ci.org/m4tthumphrey/php-gitlab-api)
+We present a modern [GitLab API v4](https://docs.gitlab.com/ce/api/) client for PHP.
 
-Based on [php-github-api](https://github.com/m4tthumphrey/php-github-api) and code from [KnpLabs](https://github.com/KnpLabs/php-github-api).
+![Banner](https://user-images.githubusercontent.com/2829600/86969006-fc2e3b00-c164-11ea-80b7-8750160a65c4.png)
 
-Installation
-------------
-1. Install Composer
+<p align="center">
+<a href="https://github.com/GitLabPHP/Client/actions?query=workflow%3ATests"><img src="https://img.shields.io/github/actions/workflow/status/GitLabPHP/Client/tests.yml?label=Tests&style=flat-square" alt="Build Status"></img></a>
+<a href="https://github.styleci.io/repos/6816335"><img src="https://github.styleci.io/repos/6816335/shield" alt="StyleCI Status"></img></a>
+<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-brightgreen?style=flat-square" alt="Software License"></img></a>
+<a href="https://packagist.org/packages/m4tthumphrey/php-gitlab-api"><img src="https://img.shields.io/packagist/dt/m4tthumphrey/php-gitlab-api?style=flat-square" alt="Packagist Downloads"></img></a>
+<a href="https://github.com/GitLabPHP/Client/releases"><img src="https://img.shields.io/github/release/GitLabPHP/Client?style=flat-square" alt="Latest Version"></img></a>
+</p>
 
-    ```bash
-    $ curl -sS https://getcomposer.org/installer | php
-    $ sudo mv composer.phar /usr/local/bin/composer
-    ```
+This is strongly based on [php-github-api](https://github.com/KnpLabs/php-github-api) by [KnpLabs](https://github.com/KnpLabs). With this in mind, we now have **very similar** clients for:
 
-2. Add the following to your require block in composer.json config.
+* [Bitbucket](https://bitbucket.org/) - [bitbucket/client](https://packagist.org/packages/bitbucket/client) by [Graham Campbell](https://github.com/GrahamCampbell).
+* [GitHub](https://github.com/) - [knplabs/github-api](https://packagist.org/packages/knplabs/github-api) by [KnpLabs](https://github.com/KnpLabs/php-github-api).
+* [GitLab](https://gitlab.com/) - [m4tthumphrey/php-gitlab-api](https://packagist.org/packages/m4tthumphrey/php-gitlab-api) which is this package!
 
-    > Note: be careful when using the `dev-master` tag as this may have unexpected results depending on your version of
-    Gitlab. See the Versioning section below for more information.
+Check out the [change log](CHANGELOG.md), [releases](https://github.com/GitLabPHP/Client/releases), [security policy](https://github.com/GitLabPHP/Client/security/policy), [license](LICENSE), [code of conduct](.github/CODE_OF_CONDUCT.md), and [contribution guidelines](.github/CONTRIBUTING.md).
 
-    ```json
-    "m4tthumphrey/php-gitlab-api": "dev-master"
-    ```
 
-3. Include Composer's autoloader:
+## Installation
 
-    ```php
-    require_once dirname(__DIR__).'/vendor/autoload.php';
-    ```
+This version supports [PHP](https://php.net) 8.1-8.4. To get started, simply require the project using [Composer](https://getcomposer.org). You will also need to install packages that "provide" [`psr/http-client-implementation`](https://packagist.org/providers/psr/http-client-implementation) and [`psr/http-factory-implementation`](https://packagist.org/providers/psr/http-factory-implementation).
 
-Versioning
-----------
+### Standard Installation
 
-From the 6.0 stable release of Gitlab, I shall now be matching the client version with the Gitlab version. For example
-when Gitlab 6.1 is released I will release version 6.1.0 of the API client. If I need to make future updates to the client
-before the next API version is released, I'll simply use a 3rd build version - `6.1.1`, `6.1.2` etc for example.
+```bash
+$ composer require "m4tthumphrey/php-gitlab-api:^12.0" "guzzlehttp/guzzle:^7.9.2"
+```
+
+### Framework Integration
+
+#### Laravel:
+
+```bash
+$ composer require "graham-campbell/gitlab:^8.0"
+```
+
+#### Symfony:
 
-It is recommended that you keep your composer file in sync with whatever version of Gitlab you are currently running:
-if you are using 6.0, you should require `6.0.*`; 6.1 should be `6.1.*`...
+```bash
+$ composer require "zeichen32/gitlabapibundle:^7.0"
+```
+
+We are decoupled from any HTTP messaging client by using [PSR-7](https://www.php-fig.org/psr/psr-7/), [PSR-17](https://www.php-fig.org/psr/psr-17/), [PSR-18](https://www.php-fig.org/psr/psr-18/), and [HTTPlug](https://httplug.io/). You can visit [HTTPlug for library users](https://docs.php-http.org/en/latest/httplug/users.html) to get more information about installing HTTPlug related packages. The framework integration [graham-campbell/gitlab](https://github.com/GrahamCampbell/Laravel-GitLab) is by [Graham Campbell](https://github.com/GrahamCampbell) and [zeichen32/gitlabapibundle](https://github.com/Zeichen32/GitLabApiBundle) is by [Jens Averkamp](https://github.com/Zeichen32).
+
+## General API Usage
+
+```php
+// Token authentication
+$client = new Gitlab\Client();
+$client->authenticate('your_http_token', Gitlab\Client::AUTH_HTTP_TOKEN);
+
+// OAuth2 authentication
+$client = new Gitlab\Client();
+$client->authenticate('your_oauth_token', Gitlab\Client::AUTH_OAUTH_TOKEN);
+
+// An example API call
+$project = $client->projects()->create('My Project', [
+    'description' => 'This is a project',
+    'issues_enabled' => false,
+]);
+```
 
-General API Usage
------------------
+### Self-Hosted GitLab
 
 ```php
-$client = new \Gitlab\Client('http://git.yourdomain.com/api/v3/');               // change here
-$client->authenticate('your_gitlab_token_here', \Gitlab\Client::AUTH_URL_TOKEN); // change here
+$client = new Gitlab\Client();
+$client->setUrl('https://git.yourdomain.com');
+$client->authenticate('your_http_token', Gitlab\Client::AUTH_HTTP_TOKEN);
+```
 
-$project = $client->api('projects')->create('My Project', array(
-  'description' => 'This is a project',
-  'issues_enabled' => false
-));
+### Example with Pager
 
+```php
+$pager = new Gitlab\ResultPager($client);
+$issues = $pager->fetchAll($client->issues(), 'all', [null, ['state' => 'closed']]);
 ```
 
-Model Usage
------------
+### HTTP Client Builder
 
-You can also use the library in an object oriented manner:
+By providing a `Gitlab\HttpClient\Builder` to the `Gitlab\Client` constructor, you can customize the HTTP client. For example, to customize the user agent:
 
 ```php
-$client = new \Gitlab\Client('http://git.yourdomain.com/api/v3/');               // change here
-$client->authenticate('your_gitlab_token_here', \Gitlab\Client::AUTH_URL_TOKEN); // change here
-
-# Creating a new project
-$project = \Gitlab\Model\Project::create($client, 'My Project', array(
-  'description' => 'This is my project',
-  'issues_enabled' => false
-));
-
-$project->addHook('http://mydomain.com/hook/push/1');
-
-# Creating a new issue
-$project = new \Gitlab\Model\Project(1, $client);
-$issue = $project->createIssue('This does not work.', array(
-  'description' => 'This doesn\'t work properly. Please fix.',
-  'assignee_id' => 2
-));
-
-# Closing that issue
-$issue->close();
+$plugin = new Http\Client\Common\Plugin\HeaderSetPlugin([
+    'User-Agent' => 'Foobar',
+]);
+
+$builder = new Gitlab\HttpClient\Builder();
+$builder->addPlugin($plugin);
+
+$client = new Gitlab\Client($builder);
+```
+
+One can read more about HTTPlug plugins [here](https://docs.php-http.org/en/latest/plugins/introduction.html#how-it-works). Take a look around the [API methods](https://github.com/GitLabPHP/Client/tree/12.0/src/Api), and please feel free to report any bugs, noting our [code of conduct](.github/CODE_OF_CONDUCT.md).
+
+
+## Contributing
+
+We will gladly receive issue reports and review and accept pull requests, in accordance with our [code of conduct](.github/CODE_OF_CONDUCT.md) and [contribution guidelines](.github/CONTRIBUTING.md)!
+
+```
+$ make install
+$ make test
 ```
 
-You get the idea! Take a look around ([API methods](https://github.com/m4tthumphrey/php-gitlab-api/tree/master/lib/Gitlab/Api),
-[models](https://github.com/m4tthumphrey/php-gitlab-api/tree/master/lib/Gitlab/Model)) and please feel free to report any bugs.
 
-Framework Integrations
-----------------------
-- **Symfony** - https://github.com/Zeichen32/GitLabApiBundle
-- **Laravel** - https://github.com/vinkla/gitlab
+## Security
+
+If you discover a security vulnerability within this package, please send an email to Graham Campbell at hello@gjcampbell.co.uk. All security vulnerabilities will be promptly addressed. You may view our full security policy [here](https://github.com/GitLabPHP/Client/security/policy).
 
-If you have integrated GitLab into a popular PHP framework, let us know!
 
-Contributing
-------------
+## License
 
-There are many parts of Gitlab that I have not added to this as it was originally created for personal use, hence the
-lack of tests. Feel free to fork and add new functionality and tests, I'll gladly accept decent pull requests.
+GitLab PHP API Client is licensed under [The MIT License (MIT)](LICENSE).
diff --git a/composer.json b/composer.json
index 3c29600a4..487fb319b 100644
--- a/composer.json
+++ b/composer.json
@@ -1,35 +1,70 @@
-{
-	"name": "m4tthumphrey/php-gitlab-api",
-	"type": "library",
-	"description": "GitLab API client",
-	"homepage": "https://github.com/m4tthumphrey/php-gitlab-api",
-	"keywords": ["gitlab", "api"],
-	"license": "MIT",
-	"authors": [
-		{
-			"name": "Matt Humphrey",
-			"homepage": "http://m4tt.io"
-		},
-		{
-			"name": "KnpLabs Team",
-			"homepage": "http://knplabs.com"
-		},
-		{
-			"name": "Thibault Duplessis",
-			"email": "thibault.duplessis@gmail.com",
-			"homepage": "http://ornicar.github.com"
-		}
-	],
-	"require": {
-		"php":                ">=5.3.2",
-		"ext-curl":           "*",
-		"ext-xml":            "*",
-		"kriswallsmith/buzz": ">=0.7"
-	},
-	"require-dev": {
-		"phpunit/phpunit": "~4.5"
-	},
-	"autoload": {
-		"psr-0": { "Gitlab\\": "lib/" }
-	}
-}
+{
+    "name": "m4tthumphrey/php-gitlab-api",
+    "description": "GitLab API v4 client for PHP",
+    "keywords": ["gitlab", "api"],
+    "license": "MIT",
+    "authors": [
+        {
+            "name": "Fabien Bourigault",
+            "email": "bourigaultfabien@gmail.com",
+            "homepage": "https://github.com/fbourigault"
+        },
+        {
+            "name": "Graham Campbell",
+            "email": "hello@gjcampbell.co.uk",
+            "homepage": "https://github.com/GrahamCampbell"
+        },
+        {
+            "name": "Matt Humphrey",
+            "email": "matth@windsor-telecom.co.uk",
+            "homepage": "https://github.com/m4tthumphrey"
+        },
+        {
+            "name": "Miguel Piedrafita",
+            "email": "github@miguelpiedrafita.com",
+            "homepage": "https://github.com/m1guelpf"
+        }
+    ],
+    "require": {
+        "php": "^8.1",
+        "ext-json": "*",
+        "ext-xml": "*",
+        "php-http/cache-plugin": "^2.0.1",
+        "php-http/client-common": "^2.7.2",
+        "php-http/discovery": "^1.20.0",
+        "php-http/httplug": "^2.4.1",
+        "php-http/multipart-stream-builder": "^1.4.2",
+        "psr/cache": "^2.0 || ^3.0",
+        "psr/http-client-implementation": "^1.0",
+        "psr/http-factory-implementation": "^1.0",
+        "psr/http-message": "^1.1 || ^2.0",
+        "symfony/options-resolver": "^5.4 || ^6.0 || ^7.0"
+    },
+    "require-dev": {
+        "bamarni/composer-bin-plugin": "^1.8.2",
+        "guzzlehttp/guzzle": "^7.9.2"
+    },
+    "autoload": {
+        "psr-4": {
+            "Gitlab\\": "src/"
+        }
+    },
+    "autoload-dev": {
+        "psr-4": {
+            "Gitlab\\Tests\\": "tests/"
+        }
+    },
+    "config": {
+        "preferred-install": "dist",
+        "allow-plugins": {
+            "bamarni/composer-bin-plugin": true,
+            "php-http/discovery": true
+        }
+    },
+    "extra": {
+        "bamarni-bin": {
+            "bin-links": true,
+            "forward-command": false
+        }
+    }
+}
diff --git a/lib/Gitlab/Api/AbstractApi.php b/lib/Gitlab/Api/AbstractApi.php
deleted file mode 100644
index d6e3ab723..000000000
--- a/lib/Gitlab/Api/AbstractApi.php
+++ /dev/null
@@ -1,129 +0,0 @@
-<?php namespace Gitlab\Api;
-
-use Gitlab\Client;
-
-/**
- * Abstract class for Api classes
- *
- * @author Joseph Bielawski <stloyd@gmail.com>
- * @author Matt Humphrey <matt@m4tt.co>
- * @author Radu Topala <radu.topala@trisoft.ro>
- */
-abstract class AbstractApi implements ApiInterface
-{
-    /**
-     * Default entries per page
-     */
-    const PER_PAGE = 20;
-
-    /**
-     * The client
-     *
-     * @var Client
-     */
-    protected $client;
-
-    /**
-     * @param Client $client
-     */
-    public function __construct(Client $client)
-    {
-        $this->client = $client;
-    }
-
-    /**
-     * @return $this
-     * @codeCoverageIgnore
-     */
-    public function configure()
-    {
-        return $this;
-    }
-
-    /**
-     * @param string $path
-     * @param array $parameters
-     * @param array $requestHeaders
-     * @return mixed
-     */
-    protected function get($path, array $parameters = array(), $requestHeaders = array())
-    {
-        $response = $this->client->getHttpClient()->get($path, $parameters, $requestHeaders);
-
-        return $response->getContent();
-    }
-
-    /**
-     * @param string $path
-     * @param array $parameters
-     * @param array $requestHeaders
-     * @param array $files
-     * @return mixed
-     */
-    protected function post($path, array $parameters = array(), $requestHeaders = array(), array $files = array())
-    {
-        $response = $this->client->getHttpClient()->post($path, $parameters, $requestHeaders, $files);
-
-        return $response->getContent();
-    }
-
-    /**
-     * @param string $path
-     * @param array $parameters
-     * @param array $requestHeaders
-     * @return mixed
-     */
-    protected function patch($path, array $parameters = array(), $requestHeaders = array())
-    {
-        $response = $this->client->getHttpClient()->patch($path, $parameters, $requestHeaders);
-
-        return $response->getContent();
-    }
-
-    /**
-     * @param string $path
-     * @param array $parameters
-     * @param array $requestHeaders
-     * @return mixed
-     */
-    protected function put($path, array $parameters = array(), $requestHeaders = array())
-    {
-        $response = $this->client->getHttpClient()->put($path, $parameters, $requestHeaders);
-
-        return $response->getContent();
-    }
-
-    /**
-     * @param string $path
-     * @param array $parameters
-     * @param array $requestHeaders
-     * @return mixed
-     */
-    protected function delete($path, array $parameters = array(), $requestHeaders = array())
-    {
-        $response = $this->client->getHttpClient()->delete($path, $parameters, $requestHeaders);
-
-        return $response->getContent();
-    }
-
-    /**
-     * @param int $id
-     * @param string $path
-     * @return string
-     */
-    protected function getProjectPath($id, $path)
-    {
-        return 'projects/'.$this->encodePath($id).'/'.$path;
-    }
-
-    /**
-     * @param string $path
-     * @return string
-     */
-    protected function encodePath($path)
-    {
-        $path = rawurlencode($path);
-
-        return str_replace('.', '%2E', $path);
-    }
-}
diff --git a/lib/Gitlab/Api/ApiInterface.php b/lib/Gitlab/Api/ApiInterface.php
deleted file mode 100644
index 40c3a5659..000000000
--- a/lib/Gitlab/Api/ApiInterface.php
+++ /dev/null
@@ -1,11 +0,0 @@
-<?php namespace Gitlab\Api;
-
-use Gitlab\Client;
-
-/**
- * Api interface
- */
-interface ApiInterface
-{
-    public function __construct(Client $client);
-}
diff --git a/lib/Gitlab/Api/DeployKeys.php b/lib/Gitlab/Api/DeployKeys.php
deleted file mode 100644
index f7e2a39d5..000000000
--- a/lib/Gitlab/Api/DeployKeys.php
+++ /dev/null
@@ -1,24 +0,0 @@
-<?php namespace Gitlab\Api;
-
-class DeployKeys extends AbstractApi
-{
-    const ORDER_BY = 'id';
-    const SORT = 'asc';
-
-    /**
-     * @param int $page
-     * @param int $per_page
-     * @param string $order_by
-     * @param string $sort
-     * @return mixed
-     */
-    public function all($page = 1, $per_page = self::PER_PAGE, $order_by = self::ORDER_BY, $sort = self::SORT)
-    {
-        return $this->get('deploy_keys', array(
-            'page' => $page,
-            'per_page' => $per_page,
-            'order_by' => $order_by,
-            'sort' => $sort
-        ));
-    }
-}
diff --git a/lib/Gitlab/Api/Groups.php b/lib/Gitlab/Api/Groups.php
deleted file mode 100644
index 2696d4457..000000000
--- a/lib/Gitlab/Api/Groups.php
+++ /dev/null
@@ -1,136 +0,0 @@
-<?php namespace Gitlab\Api;
-
-class Groups extends AbstractApi
-{
-    /**
-     * @param int $page
-     * @param int $per_page
-     * @return mixed
-     */
-    public function all($page = 1, $per_page = self::PER_PAGE)
-    {
-        return $this->get('groups', array(
-            'page' => $page,
-            'per_page' => $per_page
-        ));
-    }
-
-    /**
-     * @param string $query
-     * @param int $page
-     * @param int $per_page
-     * @return mixed
-     */
-    public function search($query, $page = 1, $per_page = self::PER_PAGE)
-    {
-        return $this->get('groups?search='.$this->encodePath($query), array(
-            'page' => $page,
-            'per_page' => $per_page
-        ));
-    }
-
-    /**
-     * @param int $id
-     * @return mixed
-     */
-    public function show($id)
-    {
-        return $this->get('groups/'.$this->encodePath($id));
-    }
-
-    /**
-     * @param string $name
-     * @param string $path
-     * @param string $description
-     * @return mixed
-     */
-    public function create($name, $path, $description = null, $visibility_level = 0)
-    {
-        return $this->post('groups', array(
-            'name' => $name,
-            'path' => $path,
-            'description' => $description,
-            'visibility_level' => $visibility_level
-        ));
-    }
-
-    /**
-     * @param int $id
-     * @param array $params
-     * @return mixed
-     */
-    public function update($id, array $params)
-    {
-        return $this->put('groups/'.$this->encodePath($id), $params);
-    }
-
-    /**
-     * @param int $group_id
-     * @return mixed
-     */
-    public function remove($group_id)
-    {
-        return $this->delete('groups/'.$this->encodePath($group_id));
-    }
-
-    /**
-     * @param int $group_id
-     * @param int $project_id
-     * @return mixed
-     */
-    public function transfer($group_id, $project_id)
-    {
-        return $this->post('groups/'.$this->encodePath($group_id).'/projects/'.$this->encodePath($project_id));
-    }
-
-    /**
-     * @param int $id
-     * @param int $page
-     * @param int $per_page
-     * @return mixed
-     */
-    public function members($id, $page = 1, $per_page = self::PER_PAGE)
-    {
-        return $this->get('groups/'.$this->encodePath($id).'/members', array(
-            'page' => $page,
-            'per_page' => $per_page
-        ));
-    }
-
-    /**
-     * @param int $group_id
-     * @param int $user_id
-     * @param int $access_level
-     * @return mixed
-     */
-    public function addMember($group_id, $user_id, $access_level)
-    {
-        return $this->post('groups/'.$this->encodePath($group_id).'/members', array(
-            'user_id' => $user_id,
-            'access_level' => $access_level
-        ));
-    }
-
-    /**
-     * @param int $group_id
-     * @param int $user_id
-     * @param int $access_level
-     * @return mixed
-     */
-    public function saveMember($group_id, $user_id, $access_level)
-    {
-        return $this->put('groups/'.$this->encodePath($group_id).'/members/'.$this->encodePath($user_id), array(
-            'access_level' => $access_level
-        ));
-    }
-
-    /**
-     * @param int $group_id
-     * @param int $user_id
-     * @return mixed
-     */
-    public function removeMember($group_id, $user_id)
-    {
-        return $this->delete('groups/'.$this->encodePath($group_id).'/members/'.$this->encodePath($user_id));
-    }
-}
diff --git a/lib/Gitlab/Api/Issues.php b/lib/Gitlab/Api/Issues.php
deleted file mode 100644
index fbed8d9c5..000000000
--- a/lib/Gitlab/Api/Issues.php
+++ /dev/null
@@ -1,129 +0,0 @@
-<?php namespace Gitlab\Api;
-
-class Issues extends AbstractApi
-{
-    /**
-     * @param int $project_id
-     * @param int $page
-     * @param int $per_page
-     * @param array $params
-     * @return mixed
-     */
-    public function all($project_id = null, $page = 1, $per_page = self::PER_PAGE, array $params = array())
-    {
-        $path = $project_id === null ? 'issues' : $this->getProjectPath($project_id, 'issues');
-
-        $params = array_intersect_key($params, array('labels' => '', 'state' => '', 'sort' => '', 'order_by' => '', 'milestone' => ''));
-        $params = array_merge(array(
-            'page' => $page,
-            'per_page' => $per_page
-        ), $params);
-
-        return $this->get($path, $params);
-    }
-
-    /**
-     * @param int $project_id
-     * @param int $issue_id
-     * @return mixed
-     */
-    public function show($project_id, $issue_id)
-    {
-        return $this->get($this->getProjectPath($project_id, 'issues?iid='.$this->encodePath($issue_id)));
-    }
-
-    /**
-     * @param int $project_id
-     * @param array $params
-     * @return mixed
-     */
-    public function create($project_id, array $params)
-    {
-        return $this->post($this->getProjectPath($project_id, 'issues'), $params);
-    }
-
-    /**
-     * @param int $project_id
-     * @param int $issue_id
-     * @param array $params
-     * @return mixed
-     */
-    public function update($project_id, $issue_id, array $params)
-    {
-        return $this->put($this->getProjectPath($project_id, 'issues/'.$this->encodePath($issue_id)), $params);
-    }
-
-    /**
-     * @param int $project_id
-     * @param int $issue_id
-     * @return mixed
-     */
-    public function remove($project_id, $issue_id)
-    {
-        return $this->delete($this->getProjectPath($project_id, 'issues/'.$this->encodePath($issue_id)));
-    }
-
-    /**
-     * @param int $project_id
-     * @param int $issue_id
-     * @return mixed
-     */
-    public function showComments($project_id, $issue_id)
-    {
-        return $this->get($this->getProjectPath($project_id, 'issues/'.$this->encodePath($issue_id)).'/notes');
-    }
-
-    /**
-     * @param int $project_id
-     * @param int $issue_id
-     * @param int $note_id
-     * @return mixed
-     */
-    public function showComment($project_id, $issue_id, $note_id)
-    {
-        return $this->get($this->getProjectPath($project_id, 'issues/'.$this->encodePath($issue_id)).'/notes/'.$this->encodePath($note_id));
-    }
-
-    /**
-     * @param int $project_id
-     * @param int $issue_id
-     * @param string|array $body
-     * @return mixed
-     */
-    public function addComment($project_id, $issue_id, $body)
-    {
-        // backwards compatibility
-        if (is_array($body)) {
-            $params = $body;
-        } else {
-            $params = array('body' => $body);
-        }
-
-        return $this->post($this->getProjectPath($project_id, 'issues/'.$this->encodePath($issue_id).'/notes'), $params);
-    }
-
-    /**
-     * @param int $project_id
-     * @param int $issue_id
-     * @param int $note_id
-     * @param string $body
-     * @return mixed
-     */
-    public function updateComment($project_id, $issue_id, $note_id, $body)
-    {
-        return $this->put($this->getProjectPath($project_id, 'issues/'.$this->encodePath($issue_id).'/notes/'.$this->encodePath($note_id)), array(
-            'body' => $body
-        ));
-    }
-
-    /**
-     * @param int $project_id
-     * @param int $issue_id
-     * @param int $note_id
-     * @return mixed
-     */
-    public function removeComment($project_id, $issue_id, $note_id)
-    {
-        return $this->delete($this->getProjectPath($project_id, 'issues/'.$this->encodePath($issue_id).'/notes/'.$this->encodePath($note_id)));
-    }
-}
diff --git a/lib/Gitlab/Api/MergeRequests.php b/lib/Gitlab/Api/MergeRequests.php
deleted file mode 100644
index 2d6dc6184..000000000
--- a/lib/Gitlab/Api/MergeRequests.php
+++ /dev/null
@@ -1,221 +0,0 @@
-<?php namespace Gitlab\Api;
-
-class MergeRequests extends AbstractApi
-{
-    const STATE_ALL = 'all';
-    const STATE_MERGED = 'merged';
-    const STATE_OPENED = 'opened';
-    const STATE_CLOSED = 'closed';
-
-    const ORDER_BY = 'created_at';
-    const SORT = 'asc';
-
-    /**
-     * @param int $project_id
-     * @param string $state
-     * @param int $page
-     * @param int $per_page
-     * @param string $order_by
-     * @param string $sort
-     * @param string $object
-     * @return mixed
-     */
-    public function getList($project_id, $state = self::STATE_ALL, $page = 1, $per_page = self::PER_PAGE, $order_by = self::ORDER_BY, $sort = self::SORT, $object = 'merge_requests')
-    {
-        return $this->get($this->getProjectPath($project_id, $object), array(
-            'page' => $page,
-            'per_page' => $per_page,
-            'state' => $state,
-            'order_by' => $order_by,
-            'sort' => $sort
-        ));
-    }
-
-    /**
-     * @param int $project_id
-     * @param int $page
-     * @param int $per_page
-     * @param string $order_by
-     * @param string $sort
-     * @return mixed
-     */
-    public function all($project_id, $page = 1, $per_page = self::PER_PAGE, $order_by = self::ORDER_BY, $sort = self::SORT)
-    {
-        return $this->getList($project_id, self::STATE_ALL, $page, $per_page, $order_by, $sort);
-    }
-
-    /**
-     * @param int $project_id
-     * @param int $page
-     * @param int $per_page
-     * @param string $order_by
-     * @param string $sort
-     * @return mixed
-     */
-    public function merged($project_id, $page = 1, $per_page = self::PER_PAGE, $order_by = self::ORDER_BY, $sort = self::SORT)
-    {
-        return $this->getList($project_id, self::STATE_MERGED, $page, $per_page, $order_by, $sort);
-    }
-
-    /**
-     * @param int $project_id
-     * @param int $page
-     * @param int $per_page
-     * @param string $order_by
-     * @param string $sort
-     * @return mixed
-     */
-    public function opened($project_id, $page = 1, $per_page = self::PER_PAGE, $order_by = self::ORDER_BY, $sort = self::SORT)
-    {
-        return $this->getList($project_id, self::STATE_OPENED, $page, $per_page, $order_by, $sort);
-    }
-
-    /**
-     * @param int $project_id
-     * @param int $page
-     * @param int $per_page
-     * @param string $order_by
-     * @param string $sort
-     * @return mixed
-     */
-    public function closed($project_id, $page = 1, $per_page = self::PER_PAGE, $order_by = self::ORDER_BY, $sort = self::SORT)
-    {
-        return $this->getList($project_id, self::STATE_CLOSED, $page, $per_page, $order_by, $sort);
-    }
-
-    /**
-     * @param int $project_id
-     * @param int $mr_id
-     * @return mixed
-     */
-    public function show($project_id, $mr_id)
-    {
-        return $this->get($this->getProjectPath($project_id, 'merge_request/'.$this->encodePath($mr_id)));
-    }
-
-    /**
-     * @param int $project_id
-     * @param string $source
-     * @param string $target
-     * @param string $title
-     * @param int $assignee
-     * @param int $target_project_id
-     * @param string $description
-     * @return mixed
-     */
-    public function create($project_id, $source, $target, $title, $assignee = null, $target_project_id = null, $description = null)
-    {
-        return $this->post($this->getProjectPath($project_id, 'merge_requests'), array(
-            'source_branch' => $source,
-            'target_branch' => $target,
-            'title' => $title,
-            'assignee_id' => $assignee,
-            'target_project_id' => $target_project_id,
-            'description' => $description
-        ));
-    }
-
-    /**
-     * @param int $project_id
-     * @param int $mr_id
-     * @param array $params
-     * @return mixed
-     */
-    public function update($project_id, $mr_id, array $params)
-    {
-        return $this->put($this->getProjectPath($project_id, 'merge_request/'.$this->encodePath($mr_id)), $params);
-    }
-
-    /**
-     * @param int $project_id
-     * @param int $mr_id
-     * @param string $message
-     * @return mixed
-     */
-    public function merge($project_id, $mr_id, $message = null)
-    {
-        if (is_array($message)) {
-            $params = $message;
-        } else {
-            $params = array('merge_commit_message' => $message);
-        }
-
-        return $this->put($this->getProjectPath($project_id, 'merge_request/'.$this->encodePath($mr_id).'/merge'), $params);
-    }
-
-    /**
-     * @param int $project_id
-     * @param int $mr_id
-     * @return mixed
-     */
-    public function showNotes($project_id, $mr_id, $page = 1, $per_page = self::PER_PAGE, $order_by = self::ORDER_BY, $sort = 'desc')
-    {
-        return $this->getList($project_id, null, $page, $per_page, $order_by, $sort, 'merge_requests/'.$this->encodePath($mr_id).'/notes');
-    }
-
-    /**
-     * @param int $project_id
-     * @param int $mr_id
-     * @param string $note
-     * @return mixed
-     */
-    public function addNote($project_id, $mr_id, $note)
-    {
-        return $this->post($this->getProjectPath($project_id, 'merge_requests/'.$this->encodePath($mr_id).'/notes'), array(
-            'body' => $note
-        ));
-    }
-
-    /**
-     * @param int $project_id
-     * @param int $mr_id
-     * @return mixed
-     */
-    public function showComments($project_id, $mr_id)
-    {
-        return $this->get($this->getProjectPath($project_id, 'merge_request/'.$this->encodePath($mr_id).'/comments'));
-    }
-
-    /**
-     * @param int $project_id
-     * @param int $mr_id
-     * @param string $note
-     * @return mixed
-     */
-    public function addComment($project_id, $mr_id, $note)
-    {
-        return $this->post($this->getProjectPath($project_id, 'merge_request/'.$this->encodePath($mr_id).'/comments'), array(
-            'note' => $note
-        ));
-    }
-
-    /**
-     * @param int $project_id
-     * @param int $mr_id
-     * @return mixed
-     */
-    public function changes($project_id, $mr_id)
-    {
-        return $this->get($this->getProjectPath($project_id, 'merge_request/'.$this->encodePath($mr_id).'/changes'));
-    }
-
-    /**
-     * @param $project_id
-     * @param $mr_iid
-     * @return mixed
-     */
-    public function getByIid($project_id, $mr_iid)
-    {
-        return $this->get($this->getProjectPath($project_id, 'merge_requests'), array('iid' => $mr_iid));
-    }
-
-    /**
-     * @param int $project_id
-     * @param int $mr_id
-     * @return mixed
-     */
-    public function commits($project_id, $mr_id)
-    {
-        return $this->get($this->getProjectPath($project_id, 'merge_request/'.$this->encodePath($mr_id).'/commits'));
-    }
-}
diff --git a/lib/Gitlab/Api/Milestones.php b/lib/Gitlab/Api/Milestones.php
deleted file mode 100644
index 3b1528183..000000000
--- a/lib/Gitlab/Api/Milestones.php
+++ /dev/null
@@ -1,59 +0,0 @@
-<?php namespace Gitlab\Api;
-
-class Milestones extends AbstractApi
-{
-    /**
-     * @param int $project_id
-     * @param int $page
-     * @param int $per_page
-     * @return mixed
-     */
-    public function all($project_id, $page = 1, $per_page = self::PER_PAGE)
-    {
-        return $this->get($this->getProjectPath($project_id, 'milestones'), array(
-            'page' => $page,
-            'per_page' => $per_page
-        ));
-    }
-
-    /**
-     * @param int $project_id
-     * @param int $milestone_id
-     * @return mixed
-     */
-    public function show($project_id, $milestone_id)
-    {
-        return $this->get($this->getProjectPath($project_id, 'milestones/'.$this->encodePath($milestone_id)));
-    }
-
-    /**
-     * @param int $project_id
-     * @param array $params
-     * @return mixed
-     */
-    public function create($project_id, array $params)
-    {
-        return $this->post($this->getProjectPath($project_id, 'milestones'), $params);
-    }
-
-    /**
-     * @param int $project_id
-     * @param int $milestone_id
-     * @param array $params
-     * @return mixed
-     */
-    public function update($project_id, $milestone_id, array $params)
-    {
-        return $this->put($this->getProjectPath($project_id, 'milestones/'.$this->encodePath($milestone_id)), $params);
-    }
-
-    /**
-     * @param int $project_id
-     * @param int $milestone_id
-     * @return mixed
-     */
-    public function issues($project_id, $milestone_id)
-    {
-        return $this->get($this->getProjectPath($project_id, 'milestones/'.$this->encodePath($milestone_id).'/issues'));
-    }
-}
diff --git a/lib/Gitlab/Api/ProjectNamespaces.php b/lib/Gitlab/Api/ProjectNamespaces.php
deleted file mode 100644
index 480fa79c8..000000000
--- a/lib/Gitlab/Api/ProjectNamespaces.php
+++ /dev/null
@@ -1,32 +0,0 @@
-<?php namespace Gitlab\Api;
-
-class ProjectNamespaces extends AbstractApi
-{
-    /**
-     * @param int $page
-     * @param int $per_page
-     * @return mixed
-     */
-    public function all($page = 1, $per_page = self::PER_PAGE)
-    {
-        return $this->get('namespaces', array(
-            'page' => $page,
-            'per_page' => $per_page
-        ));
-    }
-
-    /**
-     * @param string $terms
-     * @param int $page
-     * @param int $per_page
-     * @return mixed
-     */
-    public function search($terms, $page = 1, $per_page = self::PER_PAGE)
-    {
-        return $this->get('namespaces', array(
-            'search' => $terms,
-            'page' => $page,
-            'per_page' => $per_page
-        ));
-    }
-}
diff --git a/lib/Gitlab/Api/Projects.php b/lib/Gitlab/Api/Projects.php
deleted file mode 100644
index 0e7fdc8a3..000000000
--- a/lib/Gitlab/Api/Projects.php
+++ /dev/null
@@ -1,564 +0,0 @@
-<?php namespace Gitlab\Api;
-
-class Projects extends AbstractApi
-{
-    const ORDER_BY = 'created_at';
-    const SORT = 'asc';
-
-    /**
-     * @param int $page
-     * @param int $per_page
-     * @param string $order_by
-     * @param string $sort
-     * @return mixed
-     */
-    public function all($page = 1, $per_page = self::PER_PAGE, $order_by = self::ORDER_BY, $sort = self::SORT)
-    {
-        return $this->get('projects/all', array(
-            'page' => $page,
-            'per_page' => $per_page,
-            'order_by' => $order_by,
-            'sort' => $sort
-        ));
-    }
-
-    /**
-     * @param int $page
-     * @param int $per_page
-     * @param string $order_by
-     * @param string $sort
-     * @return mixed
-     */
-    public function accessible($page = 1, $per_page = self::PER_PAGE, $order_by = self::ORDER_BY, $sort = self::SORT)
-    {
-        return $this->get('projects', array(
-            'page' => $page,
-            'per_page' => $per_page,
-            'order_by' => $order_by,
-            'sort' => $sort
-        ));
-    }
-
-    /**
-     * @param int $page
-     * @param int $per_page
-     * @param string $order_by
-     * @param string $sort
-     * @return mixed
-     */
-    public function owned($page = 1, $per_page = self::PER_PAGE, $order_by = self::ORDER_BY, $sort = self::SORT)
-    {
-        return $this->get('projects/owned', array(
-            'page' => $page,
-            'per_page' => $per_page,
-            'order_by' => $order_by,
-            'sort' => $sort
-        ));
-    }
-
-    /**
-     * @param string $query
-     * @param int $page
-     * @param int $per_page
-     * @param string $order_by
-     * @param string $sort
-     * @return mixed
-     */
-    public function search($query, $page = 1, $per_page = self::PER_PAGE, $order_by = self::ORDER_BY, $sort = self::SORT)
-    {
-        return $this->get('projects/search/'.$this->encodePath($query), array(
-            'page' => $page,
-            'per_page' => $per_page,
-            'order_by' => $order_by,
-            'sort' => $sort
-        ));
-    }
-
-    /**
-     * @param int $project_id
-     * @return mixed
-     */
-    public function show($project_id)
-    {
-        return $this->get('projects/'.$this->encodePath($project_id));
-    }
-
-    /**
-     * @param string $name
-     * @param array $params
-     * @return mixed
-     */
-    public function create($name, array $params = array())
-    {
-        $params['name'] = $name;
-
-        return $this->post('projects', $params);
-    }
-
-    /**
-     * @param int $user_id
-     * @param string $name
-     * @param array $params
-     * @return mixed
-     */
-    public function createForUser($user_id, $name, array $params = array())
-    {
-        $params['name'] = $name;
-
-        return $this->post('projects/user/'.$this->encodePath($user_id), $params);
-    }
-
-    /**
-     * @param int $project_id
-     * @param array $params
-     * @return mixed
-     */
-    public function update($project_id, array $params)
-    {
-        return $this->put('projects/'.$this->encodePath($project_id), $params);
-    }
-
-    /**
-     * @param int $project_id
-     * @return mixed
-     */
-    public function remove($project_id)
-    {
-        return $this->delete('projects/'.$this->encodePath($project_id));
-    }
-    
-    /**
-     * @param int $project_id
-     * @return mixed
-     */
-    public function archive($project_id){
-        return $this->post("projects/".$this->encodePath($project_id)."/archive");
-    }
-    
-    /**
-     * @param int $project_id
-     * @return mixed
-     */
-    public function unarchive($project_id){
-        return $this->post("projects/".$this->encodePath($project_id)."/unarchive");
-    }
-
-    /**
-     * @param int $project_id
-     * @param array $scope
-     * @return mixed
-     */
-    public function builds($project_id, $scope = null)
-    {
-        return $this->get($this->getProjectPath($project_id, 'builds'), array(
-            'scope' => $scope
-        ));
-    }
-
-    /**
-     * @param $project_id
-     * @param $build_id
-     * @return mixed
-     */
-    public function build($project_id, $build_id)
-    {
-        return $this->get($this->getProjectPath($project_id, 'builds/'.$this->encodePath($build_id)));
-    }
-
-    /**
-     * @param $project_id
-     * @param $build_id
-     * @return mixed
-     */
-    public function trace($project_id, $build_id)
-    {
-        return $this->get($this->getProjectPath($project_id, 'builds/'.$this->encodePath($build_id).'/trace'));
-    }
-
-    /**
-     * @param int $project_id
-     * @param string $username_query
-     * @return mixed
-     */
-    public function members($project_id, $username_query = null)
-    {
-        return $this->get($this->getProjectPath($project_id, 'members'), array(
-            'query' => $username_query
-        ));
-    }
-
-    /**
-     * @param int $project_id
-     * @param int $user_id
-     * @return mixed
-     */
-    public function member($project_id, $user_id)
-    {
-        return $this->get($this->getProjectPath($project_id, 'members/'.$this->encodePath($user_id)));
-    }
-
-    /**
-     * @param int $project_id
-     * @param int $user_id
-     * @param int $access_level
-     * @return mixed
-     */
-    public function addMember($project_id, $user_id, $access_level)
-    {
-        return $this->post($this->getProjectPath($project_id, 'members'), array(
-            'user_id' => $user_id,
-            'access_level' => $access_level
-        ));
-    }
-
-    /**
-     * @param int $project_id
-     * @param int $user_id
-     * @param int $access_level
-     * @return mixed
-     */
-    public function saveMember($project_id, $user_id, $access_level)
-    {
-        return $this->put($this->getProjectPath($project_id, 'members/'.urldecode($user_id)), array(
-            'access_level' => $access_level
-        ));
-    }
-
-    /**
-     * @param int $project_id
-     * @param int $user_id
-     * @return mixed
-     */
-    public function removeMember($project_id, $user_id)
-    {
-        return $this->delete($this->getProjectPath($project_id, 'members/'.urldecode($user_id)));
-    }
-
-    /**
-     * @param int $project_id
-     * @return mixed
-     */
-    public function deployKeys($project_id)
-    {
-        return $this->get($this->getProjectPath($project_id, 'deploy_keys'));
-    }
-
-    /**
-     * @param int $project_id
-     * @param int $page
-     * @param int $per_page
-     * @return mixed
-     */
-    public function hooks($project_id, $page = 1, $per_page = self::PER_PAGE)
-    {
-        return $this->get($this->getProjectPath($project_id, 'hooks'), array(
-            'page' => $page,
-            'per_page' => $per_page
-        ));
-    }
-
-    /**
-     * @param int $project_id
-     * @param int $hook_id
-     * @return mixed
-     */
-    public function hook($project_id, $hook_id)
-    {
-        return $this->get($this->getProjectPath($project_id, 'hooks/'.$this->encodePath($hook_id)));
-    }
-
-    /**
-     * @param int $project_id
-     * @param string $url
-     * @param array $params
-     * @return mixed
-     */
-    public function addHook($project_id, $url, array $params = array())
-    {
-        if (empty($params)) {
-            $params = array('push_events' => true);
-        }
-
-        $params['url'] = $url;
-
-        return $this->post($this->getProjectPath($project_id, 'hooks'), $params);
-    }
-
-    /**
-     * @param int $project_id
-     * @param int $hook_id
-     * @param array $params
-     * @return mixed
-     */
-    public function updateHook($project_id, $hook_id, array $params)
-    {
-        return $this->put($this->getProjectPath($project_id, 'hooks/'.$this->encodePath($hook_id)), $params);
-    }
-
-    /**
-     * @param int $project_id
-     * @param int $hook_id
-     * @return mixed
-     */
-    public function removeHook($project_id, $hook_id)
-    {
-        return $this->delete($this->getProjectPath($project_id, 'hooks/'.$this->encodePath($hook_id)));
-    }
-
-    /**
-     * @param int $project_id
-     * @return mixed
-     */
-    public function keys($project_id)
-    {
-        return $this->get($this->getProjectPath($project_id, 'keys'));
-    }
-
-    /**
-     * @param int $project_id
-     * @param int $key_id
-     * @return mixed
-     */
-    public function key($project_id, $key_id)
-    {
-        return $this->get($this->getProjectPath($project_id, 'keys/'.$this->encodePath($key_id)));
-    }
-
-    /**
-     * @param int $project_id
-     * @param string $title
-     * @param string $key
-     * @return mixed
-     */
-    public function addKey($project_id, $title, $key)
-    {
-        return $this->post($this->getProjectPath($project_id, 'keys'), array(
-            'title' => $title,
-            'key' => $key
-        ));
-    }
-
-    /**
-     * @param int $project_id
-     * @param int $key_id
-     * @return mixed
-     */
-    public function removeKey($project_id, $key_id)
-    {
-        return $this->delete($this->getProjectPath($project_id, 'keys/'.$this->encodePath($key_id)));
-    }
-
-    /**
-     * @param int $project_id
-     * @param int $key_id
-     * @return mixed
-     */
-    public function enableKey($project_id, $key_id)
-    {
-        return $this->post($this->getProjectPath($project_id, 'keys/'.$this->encodePath($key_id).'/enable'));
-    }
-
-    /**
-     * @param int $project_id
-     * @param int $key_id
-     * @return mixed
-     */
-    public function disableKey($project_id, $key_id)
-    {
-        return $this->delete($this->getProjectPath($project_id, 'keys/'.$this->encodePath($key_id).'/disable'));
-    }
-
-    /**
-     * @param int $project_id
-     * @param int $page
-     * @param int $per_page
-     * @return mixed
-     */
-    public function events($project_id, $page = 1, $per_page = self::PER_PAGE)
-    {
-        return $this->get($this->getProjectPath($project_id, 'events'), array(
-            'page' => $page,
-            'per_page' => $per_page
-        ));
-    }
-
-    /**
-     * @param int $project_id
-     * @return mixed
-     */
-    public function labels($project_id)
-    {
-        return $this->get($this->getProjectPath($project_id, 'labels'));
-    }
-
-    /**
-     * @param int $project_id
-     * @param array $params
-     * @return mixed
-     */
-    public function addLabel($project_id, array $params)
-    {
-        return $this->post($this->getProjectPath($project_id, 'labels'), $params);
-    }
-
-    /**
-     * @param int $project_id
-     * @param array $params
-     * @return mixed
-     */
-    public function updateLabel($project_id, array $params)
-    {
-        return $this->put($this->getProjectPath($project_id, 'labels'), $params);
-    }
-
-    /**
-     * @param int $project_id
-     * @param string $name
-     * @return mixed
-     */
-    public function removeLabel($project_id, $name)
-    {
-        return $this->delete($this->getProjectPath($project_id, 'labels'), array(
-            'name' => $name
-        ));
-    }
-
-    /**
-     * @param int $project_id
-     * @return mixed
-     */
-    public function fork($project_id)
-    {
-        return $this->post('projects/fork/'.$this->encodePath($project_id));
-    }
-
-    /**
-     * @param int $project_id
-     * @param int $forked_project_id
-     * @return mixed
-     */
-    public function createForkRelation($project_id, $forked_project_id)
-    {
-        return $this->post($this->getProjectPath($project_id, 'fork/'.$this->encodePath($forked_project_id)));
-    }
-
-    /**
-     * @param int $project_id
-     * @return mixed
-     */
-    public function removeForkRelation($project_id)
-    {
-        return $this->delete($this->getProjectPath($project_id, 'fork'));
-    }
-
-    /**
-     * @param int $project_id
-     * @param string $service_name
-     * @param array $params
-     * @return mixed
-     */
-    public function setService($project_id, $service_name, array $params = array())
-    {
-        return $this->put($this->getProjectPath($project_id, 'services/'.$this->encodePath($service_name)), $params);
-    }
-
-    /**
-     * @param int $project_id
-     * @param string $service_name
-     * @return mixed
-     */
-    public function removeService($project_id, $service_name)
-    {
-        return $this->delete($this->getProjectPath($project_id, 'services/'.$this->encodePath($service_name)));
-    }
-
-    /**
-     * @param int $project_id
-     * @return mixed
-     */
-    public function variables($project_id)
-    {
-        return $this->get($this->getProjectPath($project_id, 'variables'));
-    }
-
-    /**
-     * @param int $project_id
-     * @param string $key
-     * @return mixed
-     */
-    public function variable($project_id, $key)
-    {
-        return $this->get($this->getProjectPath($project_id, 'variables/'.$this->encodePath($key)));
-    }
-
-    /**
-     * @param int $project_id
-     * @param string $key
-     * @param string $value
-     * @return mixed
-     */
-    public function addVariable($project_id, $key, $value)
-    {
-        return $this->post($this->getProjectPath($project_id, 'variables'), array(
-            'key'   => $key,
-            'value' => $value
-        ));
-    }
-
-    /**
-     * @param int $project_id
-     * @param string $key
-     * @param string $value
-     * @return mixed
-     */
-    public function updateVariable($project_id, $key, $value)
-    {
-        return $this->put($this->getProjectPath($project_id, 'variables/'.$this->encodePath($key)), array(
-            'value' => $value,
-        ));
-    }
-
-    /**
-     * @param int $project_id
-     * @param string $key
-     * @return mixed
-     */
-    public function removeVariable($project_id, $key)
-    {
-        return $this->delete($this->getProjectPath($project_id, 'variables/'.$this->encodePath($key)));
-    }
-
-    /**
-     * @param int $project_id
-     * @param string $file
-     * @return mixed
-     */
-    public function uploadFile($project_id, $file)
-    {
-        return $this->post($this->getProjectPath($project_id, 'uploads'), array(), array(), array('file' => $file));
-    }
-
-    /**
-     * @param int $project_id
-     * @param int $page
-     * @param int $per_page
-     * @return mixed
-     */
-    public function deployments($project_id, $page = 1, $per_page = self::PER_PAGE)
-    {
-        return $this->get($this->getProjectPath($project_id, 'deployments'), array(
-            'page' => $page,
-            'per_page' => $per_page
-        ));
-    }
-
-    /**
-     * @param int $project_id
-     * @param int $deployment_id
-     * @return mixed
-     */
-    public function deployment($project_id, $deployment_id)
-    {
-        return $this->get($this->getProjectPath($project_id, 'deployments/'.$this->encodePath($deployment_id)));
-    }
-}
diff --git a/lib/Gitlab/Api/Repositories.php b/lib/Gitlab/Api/Repositories.php
deleted file mode 100644
index e8d307ea0..000000000
--- a/lib/Gitlab/Api/Repositories.php
+++ /dev/null
@@ -1,350 +0,0 @@
-<?php namespace Gitlab\Api;
-
-class Repositories extends AbstractApi
-{
-    /**
-     * @param int $project_id
-     * @return mixed
-     */
-    public function branches($project_id)
-    {
-        return $this->get($this->getProjectPath($project_id, 'repository/branches'));
-    }
-
-    /**
-     * @param int $project_id
-     * @param int $branch_id
-     * @return mixed
-     */
-    public function branch($project_id, $branch_id)
-    {
-        return $this->get($this->getProjectPath($project_id, 'repository/branches/'.$this->encodeBranch($branch_id)));
-    }
-
-    /**
-     * @param int $project_id
-     * @param string $branch_name
-     * @param string $ref
-     * @return mixed
-     */
-    public function createBranch($project_id, $branch_name, $ref)
-    {
-        return $this->post($this->getProjectPath($project_id, 'repository/branches'), array(
-            'branch_name' => $branch_name,
-            'ref' => $ref
-        ));
-    }
-
-    /**
-     * @param int $project_id
-     * @param string $branch_name
-     * @return mixed
-     */
-    public function deleteBranch($project_id, $branch_name)
-    {
-        return $this->delete($this->getProjectPath($project_id, 'repository/branches/'.$this->encodeBranch($branch_name)));
-    }
-
-    /**
-     * @param int $project_id
-     * @param string $branch_name
-     * @param bool $devPush
-     * @param bool $devMerge
-     * @return mixed
-     */
-    public function protectBranch($project_id, $branch_name, $devPush = false, $devMerge = false)
-    {
-        return $this->put($this->getProjectPath($project_id, 'repository/branches/'.$this->encodeBranch($branch_name).'/protect'), array(
-            'developers_can_push' => $devPush,
-            'developers_can_merge' => $devMerge
-        ));
-    }
-
-    /**
-     * @param int $project_id
-     * @param string $branch_name
-     * @return mixed
-     */
-    public function unprotectBranch($project_id, $branch_name)
-    {
-        return $this->put($this->getProjectPath($project_id, 'repository/branches/'.$this->encodeBranch($branch_name).'/unprotect'));
-    }
-
-    /**
-     * @param int $project_id
-     * @return mixed
-     */
-    public function tags($project_id)
-    {
-        return $this->get($this->getProjectPath($project_id, 'repository/tags'));
-    }
-
-    /**
-     * @param int $project_id
-     * @param string $name
-     * @param string $ref
-     * @param string $message
-     * @return mixed
-     */
-    public function createTag($project_id, $name, $ref, $message = null)
-    {
-        return $this->post($this->getProjectPath($project_id, 'repository/tags'), array(
-            'tag_name' => $name,
-            'ref' => $ref,
-            'message' => $message
-        ));
-    }
-
-    /**
-     * @param int    $project_id
-     * @param string $tag_name
-     * @param string $description
-     *
-     * @return mixed
-     */
-    public function createRelease( $project_id, $tag_name, $description ) {
-        return $this->post( $this->getProjectPath( $project_id, 'repository/tags/' . $this->encodeBranch( $tag_name ) . '/release' ), array(
-            'id'          => $project_id,
-            'tag_name'    => $tag_name,
-            'description' => $description
-        ) );
-    }
-
-    /**
-     * @param int    $project_id
-     * @param string $tag_name
-     * @param string $description
-     *
-     * @return mixed
-     */
-    public function updateRelease( $project_id, $tag_name, $description ) {
-        return $this->put( $this->getProjectPath( $project_id, 'repository/tags/' . $this->encodeBranch( $tag_name ) . '/release' ), array(
-            'id'          => $project_id,
-            'tag_name'    => $tag_name,
-            'description' => $description
-        ) );
-    }
-
-    /**
-     * @param int $project_id
-     * @param string $sha
-     * @param string $scope
-     * @param int $page
-     * @param int $per_page
-     *
-     * @return mixed
-     */
-    public function commitBuilds($project_id, $sha, $scope = null, $page = 0, $per_page = self::PER_PAGE)
-    {
-        return $this->get($this->getProjectPath($project_id, 'repository/commits/'.$this->encodePath($sha).'/builds'), array(
-            'page' => $page,
-            'per_page' => $per_page,
-            'scope' => $scope
-        ));
-    }
-
-    /**
-     * @param int $project_id
-     * @param int $page
-     * @param int $per_page
-     * @param null $ref_name
-     * @return mixed
-     */
-    public function commits($project_id, $page = 0, $per_page = self::PER_PAGE, $ref_name = null)
-    {
-        return $this->get($this->getProjectPath($project_id, 'repository/commits'), array(
-            'page' => $page,
-            'per_page' => $per_page,
-            'ref_name' => $ref_name
-        ));
-    }
-
-    /**
-     * @param int $project_id
-     * @param $sha
-     * @return mixed
-     */
-    public function commit($project_id, $sha)
-    {
-        return $this->get($this->getProjectPath($project_id, 'repository/commits/'.$this->encodePath($sha)));
-    }
-
-    /**
-     * @param int $project_id
-     * @param string $sha
-     * @param int $page
-     * @param int $per_page
-     * @return mixed
-     */
-    public function commitComments($project_id, $sha, $page = 0, $per_page = self::PER_PAGE)
-    {
-        return $this->get($this->getProjectPath($project_id, 'repository/commits/'.$this->encodePath($sha).'/comments'), array(
-            'page' => $page,
-            'per_page' => $per_page
-        ));
-    }
-
-    /**
-     * @param int $project_id
-     * @param string $sha
-     * @param string $note
-     * @param array $params
-     * @return mixed
-     */
-    public function createCommitComment($project_id, $sha, $note, array $params = array())
-    {
-        $params['note'] = $note;
-
-        return $this->post($this->getProjectPath($project_id, 'repository/commits/'.$this->encodePath($sha).'/comments'), $params);
-    }
-
-    /**
-     * @param int $project_id
-     * @param string $fromShaOrMaster
-     * @param string $toShaOrMaster
-     * @return mixed
-     */
-    public function compare($project_id, $fromShaOrMaster, $toShaOrMaster)
-    {
-        return $this->get($this->getProjectPath(
-            $project_id,
-            'repository/compare?from='.$this->encodeBranch($fromShaOrMaster).'&to='.$this->encodeBranch($toShaOrMaster)
-        ));
-    }
-
-    /**
-     * @param int $project_id
-     * @param string $sha
-     * @return string
-     */
-    public function diff($project_id, $sha)
-    {
-        return $this->get($this->getProjectPath($project_id, 'repository/commits/'.$this->encodePath($sha).'/diff'));
-    }
-
-    /**
-     * @param int $project_id
-     * @param array $params
-     * @return mixed
-     */
-    public function tree($project_id, array $params = array())
-    {
-        return $this->get($this->getProjectPath($project_id, 'repository/tree'), $params);
-    }
-
-    /**
-     * @param int $project_id
-     * @param string $sha
-     * @param string $filepath
-     * @return mixed
-     */
-    public function blob($project_id, $sha, $filepath)
-    {
-        return $this->get($this->getProjectPath($project_id, 'repository/commits/'.$this->encodePath($sha).'/blob'), array(
-            'filepath' => $filepath
-        ));
-    }
-
-    /**
-     * @param int $project_id
-     * @param string $file_path
-     * @param string $ref
-     * @return mixed
-     */
-    public function getFile($project_id, $file_path, $ref)
-    {
-        return $this->get($this->getProjectPath($project_id, 'repository/files'), array(
-            'file_path' => $file_path,
-            'ref' => $ref
-        ));
-    }
-
-    /**
-     * @param int $project_id
-     * @param string $file_path
-     * @param string $content
-     * @param string $branch_name
-     * @param string $commit_message
-     * @param string $encoding
-     * @return mixed
-     */
-    public function createFile($project_id, $file_path, $content, $branch_name, $commit_message, $encoding = null)
-    {
-        return $this->post($this->getProjectPath($project_id, 'repository/files'), array(
-            'file_path' => $file_path,
-            'branch_name' => $branch_name,
-            'content' => $content,
-            'commit_message' => $commit_message,
-            'encoding' => $encoding
-        ));
-    }
-
-    /**
-     * @param int $project_id
-     * @param string $file_path
-     * @param string $content
-     * @param string $branch_name
-     * @param string $commit_message
-     * @param string $encoding
-     * @return mixed
-     */
-    public function updateFile($project_id, $file_path, $content, $branch_name, $commit_message, $encoding = null)
-    {
-        return $this->put($this->getProjectPath($project_id, 'repository/files'), array(
-            'file_path' => $file_path,
-            'branch_name' => $branch_name,
-            'content' => $content,
-            'commit_message' => $commit_message,
-            'encoding' => $encoding
-        ));
-    }
-
-    /**
-     * @param int $project_id
-     * @param string $file_path
-     * @param string $branch_name
-     * @param string $commit_message
-     * @return mixed
-     */
-    public function deleteFile($project_id, $file_path, $branch_name, $commit_message)
-    {
-        return $this->delete($this->getProjectPath($project_id, 'repository/files'), array(
-            'file_path' => $file_path,
-            'branch_name' => $branch_name,
-            'commit_message' => $commit_message
-        ));
-    }
-
-    /**
-     * @param int $project_id
-     * @return mixed
-     */
-    public function contributors($project_id)
-    {
-        return $this->get($this->getProjectPath($project_id, 'repository/contributors'));
-    }
-
-    /**
-     * File content is base64 encoded and placed in the "content" index of the returning array.
-     * You can then save the content with the tar.gz extension
-     *
-     * @param int $project_id
-     * @param array $params
-     * @return mixed
-     */
-    public function archive($project_id, $params = array())
-    {
-        return $this->get($this->getProjectPath($project_id, 'repository/archive'), $params);
-    }
-
-    /**
-     * @param string $path
-     * @return string
-     */
-    protected function encodeBranch($path)
-    {
-        $path = $this->encodePath($path);
-
-        return str_replace('%2F', '/', $path);
-    }
-}
diff --git a/lib/Gitlab/Api/Snippets.php b/lib/Gitlab/Api/Snippets.php
deleted file mode 100644
index 00921128c..000000000
--- a/lib/Gitlab/Api/Snippets.php
+++ /dev/null
@@ -1,70 +0,0 @@
-<?php namespace Gitlab\Api;
-
-class Snippets extends AbstractApi
-{
-    /**
-     * @param int $project_id
-     * @return mixed
-     */
-    public function all($project_id)
-    {
-        return $this->get($this->getProjectPath($project_id, 'snippets'));
-    }
-
-    /**
-     * @param int $project_id
-     * @param int $snippet_id
-     * @return mixed
-     */
-    public function show($project_id, $snippet_id)
-    {
-        return $this->get($this->getProjectPath($project_id, 'snippets/'.$this->encodePath($snippet_id)));
-    }
-
-    /**
-     * @param int $project_id
-     * @param string $title
-     * @param string $filename
-     * @param string $code
-     * @return mixed
-     */
-    public function create($project_id, $title, $filename, $code)
-    {
-        return $this->post($this->getProjectPath($project_id, 'snippets'), array(
-            'title' => $title,
-            'file_name' => $filename,
-            'code' => $code
-        ));
-    }
-
-    /**
-     * @param int $project_id
-     * @param int $snippet_id
-     * @param array $params
-     * @return mixed
-     */
-    public function update($project_id, $snippet_id, array $params)
-    {
-        return $this->put($this->getProjectPath($project_id, 'snippets/'.$this->encodePath($snippet_id)), $params);
-    }
-
-    /**
-     * @param int $project_id
-     * @param int $snippet_id
-     * @return string
-     */
-    public function content($project_id, $snippet_id)
-    {
-        return $this->get($this->getProjectPath($project_id, 'snippets/'.$this->encodePath($snippet_id).'/raw'));
-    }
-
-    /**
-     * @param int $project_id
-     * @param int $snippet_id
-     * @return mixed
-     */
-    public function remove($project_id, $snippet_id)
-    {
-        return $this->delete($this->getProjectPath($project_id, 'snippets/'.$this->encodePath($snippet_id)));
-    }
-}
diff --git a/lib/Gitlab/Api/SystemHooks.php b/lib/Gitlab/Api/SystemHooks.php
deleted file mode 100644
index b21fd0908..000000000
--- a/lib/Gitlab/Api/SystemHooks.php
+++ /dev/null
@@ -1,41 +0,0 @@
-<?php namespace Gitlab\Api;
-
-class SystemHooks extends AbstractApi
-{
-    /**
-     * @return mixed
-     */
-    public function all()
-    {
-        return $this->get('hooks');
-    }
-
-    /**
-     * @param string $url
-     * @return mixed
-     */
-    public function create($url)
-    {
-        return $this->post('hooks', array(
-            'url' => $url
-        ));
-    }
-
-    /**
-     * @param int $id
-     * @return mixed
-     */
-    public function test($id)
-    {
-        return $this->get('hooks/'.$this->encodePath($id));
-    }
-
-    /**
-     * @param int $id
-     * @return mixed
-     */
-    public function remove($id)
-    {
-        return $this->delete('hooks/'.$this->encodePath($id));
-    }
-}
diff --git a/lib/Gitlab/Api/Users.php b/lib/Gitlab/Api/Users.php
deleted file mode 100644
index 9ed022d99..000000000
--- a/lib/Gitlab/Api/Users.php
+++ /dev/null
@@ -1,227 +0,0 @@
-<?php namespace Gitlab\Api;
-
-class Users extends AbstractApi
-{
-    /**
-     * @param null|true $active
-     * @param int $page
-     * @param int $per_page
-     * @return mixed
-     */
-    public function all($active = null, $page = 1, $per_page = self::PER_PAGE)
-    {
-        return $this->get('users', array(
-            'active' => $active,
-            'page' => $page,
-            'per_page' => $per_page
-        ));
-    }
-
-    /**
-     * @param string $query
-     * @param null|true $active
-     * @param int $page
-     * @param int $per_page
-     * @return mixed
-     */
-    public function search($query, $active = null, $page = 1, $per_page = self::PER_PAGE)
-    {
-        return $this->get('users', array(
-            'search' => $query,
-            'active' => $active,
-            'page' => $page,
-            'per_page' => $per_page
-        ));
-    }
-
-    /**
-     * @param int $id
-     * @return mixed
-     */
-    public function show($id)
-    {
-        return $this->get('users/'.$this->encodePath($id));
-    }
-
-    /**
-     * @param string $email
-     * @param string $password
-     * @param array $params
-     * @return mixed
-     */
-    public function create($email, $password, array $params = array())
-    {
-        $params['email']    = $email;
-        $params['password'] = $password;
-
-        return $this->post('users', $params);
-    }
-
-    /**
-     * @param int $id
-     * @param array $params
-     * @return mixed
-     */
-    public function update($id, array $params)
-    {
-        return $this->put('users/'.$this->encodePath($id), $params);
-    }
-
-    /**
-     * @param int $id
-     * @return mixed
-     */
-    public function remove($id)
-    {
-        return $this->delete('users/'.$this->encodePath($id));
-    }
-
-    /**
-     * @param  int $id
-     * @return mixed
-     */
-    public function block($id)
-    {
-        return $this->put('users/'.$this->encodePath($id).'/block');
-    }
-
-    /**
-     * @param  int $id
-     * @return mixed
-     */
-    public function unblock($id)
-    {
-        return $this->put('users/'.$this->encodePath($id).'/unblock');
-    }
-
-    /**
-     * @param string $emailOrUsername
-     * @param string $password
-     * @return mixed
-     */
-    public function session($emailOrUsername, $password)
-    {
-        return $this->post('session', array(
-            'login' => $emailOrUsername,
-            'email' => $emailOrUsername,
-            'password' => $password
-        ));
-    }
-
-    /**
-     * @param string $email
-     * @param string $password
-     * @return mixed
-     */
-    public function login($email, $password)
-    {
-        return $this->session($email, $password);
-    }
-
-    /**
-     * @return mixed
-     */
-    public function me()
-    {
-        return $this->get('user');
-    }
-
-    /**
-     * @return mixed
-     */
-    public function keys()
-    {
-        return $this->get('user/keys');
-    }
-
-    /**
-     * @param int $id
-     * @return mixed
-     */
-    public function key($id)
-    {
-        return $this->get('user/keys/'.$this->encodePath($id));
-    }
-
-    /**
-     * @param string $title
-     * @param string $key
-     * @return mixed
-     */
-    public function createKey($title, $key)
-    {
-        return $this->post('user/keys', array(
-            'title' => $title,
-            'key' => $key
-        ));
-    }
-
-    /**
-     * @param int $id
-     * @return mixed
-     */
-    public function removeKey($id)
-    {
-        return $this->delete('user/keys/'.$this->encodePath($id));
-    }
-
-    /**
-     * @param int $user_id
-     * @return mixed
-     */
-    public function userKeys($user_id)
-    {
-        return $this->get('users/'.$this->encodePath($user_id).'/keys');
-    }
-
-    /*
-     * @param int $user_id
-     * @param int $key_id
-     * @return mixed
-     */
-    public function userKey($user_id, $key_id)
-    {
-        return $this->get('users/'.$this->encodePath($user_id).'/keys/'.$this->encodePath($key_id));
-    }
-
-    /**
-     * @param int $user_id
-     * @param string $title
-     * @param string $key
-     * @return mixed
-     */
-    public function createKeyForUser($user_id, $title, $key)
-    {
-        return $this->post('users/'.$this->encodePath($user_id).'/keys', array(
-            'title' => $title,
-            'key' => $key
-        ));
-    }
-
-    /**
-     * @param int $user_id
-     * @param int $key_id
-     * @return mixed
-     */
-    public function removeUserKey($user_id, $key_id)
-    {
-        return $this->delete('users/'.$this->encodePath($user_id).'/keys/'.$this->encodePath($key_id));
-    }
-
-    /**
-     * @return mixed
-     */
-    public function emails()
-    {
-        return $this->get('user/emails');
-    }
-
-    /**
-     * @param $id
-     * @return mixed
-     */
-    public function email($id)
-    {
-        return $this->get('user/emails/'.$this->encodePath($id));
-    }
-}
diff --git a/lib/Gitlab/Client.php b/lib/Gitlab/Client.php
deleted file mode 100644
index 94674b96f..000000000
--- a/lib/Gitlab/Client.php
+++ /dev/null
@@ -1,284 +0,0 @@
-<?php namespace Gitlab;
-
-use Buzz\Client\Curl;
-use Buzz\Client\ClientInterface;
-
-use Gitlab\Api\AbstractApi;
-use Gitlab\Exception\InvalidArgumentException;
-use Gitlab\HttpClient\HttpClient;
-use Gitlab\HttpClient\HttpClientInterface;
-use Gitlab\HttpClient\Listener\AuthListener;
-use Gitlab\HttpClient\Listener\PaginationListener;
-
-/**
- * Simple API wrapper for Gitlab
- *
- * @author Matt Humphrey <matt@m4tt.co>
- *
- * @property-read \Gitlab\Api\Groups $groups
- * @property-read \Gitlab\Api\Issues $issues
- * @property-read \Gitlab\Api\MergeRequests $merge_requests
- * @property-read \Gitlab\Api\MergeRequests $mr
- * @property-read \Gitlab\Api\Milestones $milestones
- * @property-read \Gitlab\Api\Milestones $ms
- * @property-read \Gitlab\Api\ProjectNamespaces $namespaces
- * @property-read \Gitlab\Api\ProjectNamespaces $ns
- * @property-read \Gitlab\Api\Projects $projects
- * @property-read \Gitlab\Api\Repositories $repositories
- * @property-read \Gitlab\Api\Repositories $repo
- * @property-read \Gitlab\Api\Snippets $snippets
- * @property-read \Gitlab\Api\SystemHooks $hooks
- * @property-read \Gitlab\Api\SystemHooks $system_hooks
- * @property-read \Gitlab\Api\Users $users
- */
-class Client
-{
-    /**
-     * Constant for authentication method. Indicates the default, but deprecated
-     * login with username and token in URL.
-     */
-    const AUTH_URL_TOKEN = 'url_token';
-
-    /**
-     * Constant for authentication method. Indicates the new login method with
-     * with username and token via HTTP Authentication.
-     */
-    const AUTH_HTTP_TOKEN = 'http_token';
-
-    /**
-     * Constant for authentication method. Indicates the OAuth method with a key
-     * obtain using Gitlab's OAuth provider.
-     */
-    const AUTH_OAUTH_TOKEN = 'oauth_token';
-
-    /**
-     * @var array
-     */
-    private $options = array(
-        'user_agent'  => 'php-gitlab-api (http://github.com/m4tthumphrey/php-gitlab-api)',
-        'timeout'     => 60
-    );
-
-    private $baseUrl;
-
-    /**
-     * The Buzz instance used to communicate with Gitlab
-     *
-     * @var HttpClient
-     */
-    private $httpClient;
-
-    /**
-     * Instantiate a new Gitlab client
-     *
-     * @param string               $baseUrl
-     * @param null|ClientInterface $httpClient Buzz client
-     */
-    public function __construct($baseUrl, ClientInterface $httpClient = null)
-    {
-        $httpClient = $httpClient ?: new Curl();
-        $httpClient->setTimeout($this->options['timeout']);
-        $httpClient->setVerifyPeer(false);
-
-        $this->baseUrl     = $baseUrl;
-        $this->httpClient  = new HttpClient($this->baseUrl, $this->options, $httpClient);
-
-        /**
-         * a Pagination listener on Response
-         */
-        $this->httpClient->addListener(
-            new PaginationListener()
-        );
-    }
-
-    /**
-     * @param string $name
-     *
-     * @return AbstractApi|mixed
-     * @throws InvalidArgumentException
-     */
-    public function api($name)
-    {
-        switch ($name) {
-
-            case 'deploy_keys':
-                $api = new Api\DeployKeys($this);
-                break;
-
-            case 'groups':
-                $api = new Api\Groups($this);
-                break;
-
-            case 'issues':
-                $api = new Api\Issues($this);
-                break;
-
-            case 'mr':
-            case 'merge_requests':
-                $api = new Api\MergeRequests($this);
-                break;
-
-            case 'milestones':
-            case 'ms':
-                $api = new Api\Milestones($this);
-                break;
-
-            case 'namespaces':
-            case 'ns':
-                $api = new Api\ProjectNamespaces($this);
-                break;
-
-            case 'projects':
-                $api = new Api\Projects($this);
-                break;
-
-            case 'repo':
-            case 'repositories':
-                $api = new Api\Repositories($this);
-                break;
-
-            case 'snippets':
-                $api = new Api\Snippets($this);
-                break;
-
-            case 'hooks':
-            case 'system_hooks':
-                $api = new Api\SystemHooks($this);
-                break;
-
-            case 'users':
-                $api = new Api\Users($this);
-                break;
-
-            default:
-                throw new InvalidArgumentException('Invalid endpoint: "'.$name.'"');
-
-        }
-
-        return $api;
-    }
-
-    /**
-     * Authenticate a user for all next requests
-     *
-     * @param string $token Gitlab private token
-     * @param string $authMethod One of the AUTH_* class constants
-     * @param string $sudo
-     * @return $this
-     */
-    public function authenticate($token, $authMethod = self::AUTH_URL_TOKEN, $sudo = null)
-    {
-        $this->httpClient->addListener(
-            new AuthListener(
-                $authMethod,
-                $token,
-                $sudo
-            )
-        );
-
-        return $this;
-    }
-
-    /**
-     * @return HttpClient
-     */
-    public function getHttpClient()
-    {
-        return $this->httpClient;
-    }
-
-    /**
-     * @param HttpClientInterface $httpClient
-     * @return $this
-     */
-    public function setHttpClient(HttpClientInterface $httpClient)
-    {
-        $this->httpClient = $httpClient;
-
-        return $this;
-    }
-
-    /**
-     * @param string $url
-     * @return $this
-     */
-    public function setBaseUrl($url)
-    {
-        $this->baseUrl = $url;
-
-        return $this;
-    }
-
-    /**
-     * @return string
-     */
-    public function getBaseUrl()
-    {
-        return $this->baseUrl;
-    }
-
-    /**
-     * Clears used headers
-     *
-     * @return $this
-     */
-    public function clearHeaders()
-    {
-        $this->httpClient->clearHeaders();
-
-        return $this;
-    }
-
-    /**
-     * @param array $headers
-     * @return $this
-     */
-    public function setHeaders(array $headers)
-    {
-        $this->httpClient->setHeaders($headers);
-
-        return $this;
-    }
-
-    /**
-     * @param string $name
-     *
-     * @return mixed
-     *
-     * @throws InvalidArgumentException
-     */
-    public function getOption($name)
-    {
-        if (!array_key_exists($name, $this->options)) {
-            throw new InvalidArgumentException(sprintf('Undefined option called: "%s"', $name));
-        }
-
-        return $this->options[$name];
-    }
-
-    /**
-     * @param string $name
-     * @param mixed $value
-     * @throws InvalidArgumentException
-     * @return $this
-     */
-    public function setOption($name, $value)
-    {
-        if (!array_key_exists($name, $this->options)) {
-            throw new InvalidArgumentException(sprintf('Undefined option called: "%s"', $name));
-        }
-
-        $this->options[$name] = $value;
-
-        return $this;
-    }
-
-    /**
-     * @param string $api
-     * @return AbstractApi
-     */
-    public function __get($api)
-    {
-        return $this->api($api);
-    }
-}
diff --git a/lib/Gitlab/Exception/ErrorException.php b/lib/Gitlab/Exception/ErrorException.php
deleted file mode 100644
index 322a7082d..000000000
--- a/lib/Gitlab/Exception/ErrorException.php
+++ /dev/null
@@ -1,11 +0,0 @@
-<?php namespace Gitlab\Exception;
-
-/**
- * ErrorException
- *
- * @author Joseph Bielawski <stloyd@gmail.com>
- */
-class ErrorException extends \ErrorException
-{
-
-}
diff --git a/lib/Gitlab/Exception/InvalidArgumentException.php b/lib/Gitlab/Exception/InvalidArgumentException.php
deleted file mode 100644
index 892a6dc4b..000000000
--- a/lib/Gitlab/Exception/InvalidArgumentException.php
+++ /dev/null
@@ -1,11 +0,0 @@
-<?php namespace Gitlab\Exception;
-
-/**
- * InvalidArgumentException
- *
- * @author Joseph Bielawski <stloyd@gmail.com>
- */
-class InvalidArgumentException extends \InvalidArgumentException
-{
-
-}
diff --git a/lib/Gitlab/Exception/MissingArgumentException.php b/lib/Gitlab/Exception/MissingArgumentException.php
deleted file mode 100644
index f157b49d1..000000000
--- a/lib/Gitlab/Exception/MissingArgumentException.php
+++ /dev/null
@@ -1,18 +0,0 @@
-<?php namespace Gitlab\Exception;
-
-/**
- * MissingArgumentException
- *
- * @author Joseph Bielawski <stloyd@gmail.com>
- */
-class MissingArgumentException extends ErrorException
-{
-    public function __construct($required, $code = 0, $previous = null)
-    {
-        if (is_string($required)) {
-            $required = array($required);
-        }
-
-        parent::__construct(sprintf('One or more of required ("%s") parameters is missing!', implode('", "', $required)), $code, $previous);
-    }
-}
diff --git a/lib/Gitlab/Exception/RuntimeException.php b/lib/Gitlab/Exception/RuntimeException.php
deleted file mode 100644
index 9d938e2cc..000000000
--- a/lib/Gitlab/Exception/RuntimeException.php
+++ /dev/null
@@ -1,11 +0,0 @@
-<?php namespace Gitlab\Exception;
-
-/**
- * RuntimeException
- *
- * @author Joseph Bielawski <stloyd@gmail.com>
- */
-class RuntimeException extends \RuntimeException
-{
-
-}
diff --git a/lib/Gitlab/Exception/ValidationFailedException.php b/lib/Gitlab/Exception/ValidationFailedException.php
deleted file mode 100644
index 9ce6635fe..000000000
--- a/lib/Gitlab/Exception/ValidationFailedException.php
+++ /dev/null
@@ -1,11 +0,0 @@
-<?php namespace Gitlab\Exception;
-
-/**
- * ValidationFailedException
- *
- * @author Joseph Bielawski <stloyd@gmail.com>
- */
-class ValidationFailedException extends ErrorException
-{
-
-}
diff --git a/lib/Gitlab/HttpClient/HttpClient.php b/lib/Gitlab/HttpClient/HttpClient.php
deleted file mode 100644
index 7a84bfb43..000000000
--- a/lib/Gitlab/HttpClient/HttpClient.php
+++ /dev/null
@@ -1,231 +0,0 @@
-<?php namespace Gitlab\HttpClient;
-
-use Buzz\Client\ClientInterface;
-use Buzz\Listener\ListenerInterface;
-use Buzz\Message\Form\FormUpload;
-
-use Gitlab\Exception\ErrorException;
-use Gitlab\Exception\RuntimeException;
-use Gitlab\HttpClient\Listener\ErrorListener;
-use Gitlab\HttpClient\Message\Request;
-use Gitlab\HttpClient\Message\Response;
-use Gitlab\HttpClient\Message\FormRequest;
-
-/**
- * Performs requests on Gitlab API. API documentation should be self-explanatory.
- *
- * @author Joseph Bielawski <stloyd@gmail.com>
- * @author Matt Humphrey <matt@m4tt.co>
- */
-class HttpClient implements HttpClientInterface
-{
-    /**
-     * @var array
-     */
-    protected $options = array(
-        'user_agent'  => 'php-gitlab-api (http://github.com/m4tthumphrey/php-gitlab-api)',
-        'timeout'     => 10,
-    );
-
-    /**
-     * @var string
-     */
-    protected $baseUrl;
-
-    /**
-     * @var ListenerInterface[]
-     */
-    protected $listeners = array();
-    /**
-     * @var array
-     */
-    protected $headers = array();
-
-    /**
-     * @var Response
-     */
-    private $lastResponse;
-
-    /**
-     * @var Request
-     */
-    private $lastRequest;
-
-    /**
-     * @param string $baseUrl
-     * @param array $options
-     * @param ClientInterface $client
-     */
-    public function __construct($baseUrl, array $options, ClientInterface $client)
-    {
-        $this->baseUrl = $baseUrl;
-        $this->options = array_merge($this->options, $options);
-        $this->client  = $client;
-
-        $this->addListener(new ErrorListener($this->options));
-
-        $this->clearHeaders();
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function setOption($name, $value)
-    {
-        $this->options[$name] = $value;
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function setHeaders(array $headers)
-    {
-        $this->headers = array_merge($this->headers, $headers);
-    }
-
-    /**
-     * Clears used headers
-     */
-    public function clearHeaders()
-    {
-        $this->headers = array();
-    }
-
-    /**
-     * @param ListenerInterface $listener
-     */
-    public function addListener(ListenerInterface $listener)
-    {
-        $this->listeners[get_class($listener)] = $listener;
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function get($path, array $parameters = array(), array $headers = array())
-    {
-        if (0 < count($parameters)) {
-            $path .= (false === strpos($path, '?') ? '?' : '&').http_build_query($parameters, '', '&');
-        }
-
-        return $this->request($path, array(), 'GET', $headers);
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function post($path, array $parameters = array(), array $headers = array(), array $files = array())
-    {
-        return $this->request($path, $parameters, 'POST', $headers, $files);
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function patch($path, array $parameters = array(), array $headers = array())
-    {
-        return $this->request($path, $parameters, 'PATCH', $headers);
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function delete($path, array $parameters = array(), array $headers = array())
-    {
-        return $this->request($path, $parameters, 'DELETE', $headers);
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function put($path, array $parameters = array(), array $headers = array())
-    {
-        return $this->request($path, $parameters, 'PUT', $headers);
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function request($path, array $parameters = array(), $httpMethod = 'GET', array $headers = array(), array $files = array())
-    {
-        $path = trim($this->baseUrl.$path, '/');
-
-        $request = $this->createRequest($httpMethod, $path, $parameters, $headers, $files);
-
-        $hasListeners = 0 < count($this->listeners);
-        if ($hasListeners) {
-            foreach ($this->listeners as $listener) {
-                $listener->preSend($request);
-            }
-        }
-
-        $response = new Response();
-
-        try {
-            $this->client->send($request, $response);
-        } catch (\LogicException $e) {
-            throw new ErrorException($e->getMessage());
-        } catch (\RuntimeException $e) {
-            throw new RuntimeException($e->getMessage());
-        }
-
-        $this->lastRequest  = $request;
-        $this->lastResponse = $response;
-
-        if ($hasListeners) {
-            foreach ($this->listeners as $listener) {
-                $listener->postSend($request, $response);
-            }
-        }
-
-        return $response;
-    }
-
-    /**
-     * @return Request
-     */
-    public function getLastRequest()
-    {
-        return $this->lastRequest;
-    }
-
-    /**
-     * @return Response
-     */
-    public function getLastResponse()
-    {
-        return $this->lastResponse;
-    }
-
-    /**
-     * @param string $httpMethod
-     * @param string $url
-     * @param array $parameters
-     * @param array $headers
-     * @param array $files
-     *
-     * @return FormRequest|Request
-     */
-    private function createRequest($httpMethod, $url, array $parameters, array $headers, array $files)
-    {
-        if (empty($files)) {
-            $request = new Request($httpMethod);
-            $request->setContent(http_build_query($parameters));
-        } else {
-            $request = new FormRequest($httpMethod);
-            foreach ($parameters as $name => $value) {
-                $request->setField($name, $value);
-            }
-
-            foreach ($files as $name => $file) {
-                $upload = new FormUpload($file);
-                $request->setField($name, $upload);
-            }
-        }
-        $request->setHeaders($this->headers);
-        $request->fromUrl($url);
-        $request->addHeaders($headers);
-
-        return $request;
-    }
-}
diff --git a/lib/Gitlab/HttpClient/HttpClientInterface.php b/lib/Gitlab/HttpClient/HttpClientInterface.php
deleted file mode 100644
index 674987bec..000000000
--- a/lib/Gitlab/HttpClient/HttpClientInterface.php
+++ /dev/null
@@ -1,98 +0,0 @@
-<?php namespace Gitlab\HttpClient;
-
-use Gitlab\Exception\InvalidArgumentException;
-
-/**
- * Performs requests on Gitlab API. API documentation should be self-explanatory.
- *
- * @author Joseph Bielawski <stloyd@gmail.com>
- * @author Matt Humphrey <matt@m4tt.co>
- */
-interface HttpClientInterface
-{
-    /**
-     * Send a GET request
-     *
-     * @param string $path       Request path
-     * @param array  $parameters GET Parameters
-     * @param array  $headers    Reconfigure the request headers for this call only
-     *
-     * @return array Data
-     */
-    public function get($path, array $parameters = array(), array $headers = array());
-
-    /**
-     * Send a POST request
-     *
-     * @param string $path Request path
-     * @param array $parameters POST Parameters
-     * @param array $headers Reconfigure the request headers for this call only
-     * @param array $files Files paths of files to upload
-     *
-     * @return array Data
-     */
-    public function post($path, array $parameters = array(), array $headers = array(), array $files = array());
-
-    /**
-     * Send a PATCH request
-     *
-     * @param string $path       Request path
-     * @param array  $parameters PATCH Parameters
-     * @param array  $headers    Reconfigure the request headers for this call only
-     *
-     * @return array Data
-     */
-    public function patch($path, array $parameters = array(), array $headers = array());
-
-    /**
-     * Send a PUT request
-     *
-     * @param string $path       Request path
-     * @param array  $parameters PUT Parameters
-     * @param array  $headers    Reconfigure the request headers for this call only
-     *
-     * @return array Data
-     */
-    public function put($path, array $parameters = array(), array $headers = array());
-
-    /**
-     * Send a DELETE request
-     *
-     * @param string $path       Request path
-     * @param array  $parameters DELETE Parameters
-     * @param array  $headers    Reconfigure the request headers for this call only
-     *
-     * @return array Data
-     */
-    public function delete($path, array $parameters = array(), array $headers = array());
-
-    /**
-     * Send a request to the server, receive a response,
-     * decode the response and returns an associative array
-     *
-     * @param string $path       Request API path
-     * @param array  $parameters Parameters
-     * @param string $httpMethod HTTP method to use
-     * @param array  $headers    Request headers
-     *
-     * @return array Data
-     */
-    public function request($path, array $parameters = array(), $httpMethod = 'GET', array $headers = array());
-
-    /**
-     * Change an option value.
-     *
-     * @param string $name  The option name
-     * @param mixed  $value The value
-     *
-     * @throws InvalidArgumentException
-     */
-    public function setOption($name, $value);
-
-    /**
-     * Set HTTP headers
-     *
-     * @param array $headers
-     */
-    public function setHeaders(array $headers);
-}
diff --git a/lib/Gitlab/HttpClient/Listener/AuthListener.php b/lib/Gitlab/HttpClient/Listener/AuthListener.php
deleted file mode 100644
index 271641122..000000000
--- a/lib/Gitlab/HttpClient/Listener/AuthListener.php
+++ /dev/null
@@ -1,95 +0,0 @@
-<?php namespace Gitlab\HttpClient\Listener;
-
-use Gitlab\Client;
-use Gitlab\Exception\InvalidArgumentException;
-
-use Buzz\Listener\ListenerInterface;
-use Buzz\Message\MessageInterface;
-use Buzz\Message\RequestInterface;
-use Buzz\Util\Url;
-
-/**
- * @author Joseph Bielawski <stloyd@gmail.com>
- * @author Matt Humphrey <matt@m4tt.co>
- */
-class AuthListener implements ListenerInterface
-{
-    /**
-     * @var string
-     */
-    private $method;
-
-    /**
-     * @var string
-     */
-    private $token;
-
-    /**
-     * @var string|null
-     */
-    private $sudo;
-
-    /**
-     * @param string      $method
-     * @param string      $token
-     * @param string|null $sudo
-     */
-    public function __construct($method, $token, $sudo = null)
-    {
-        $this->method  = $method;
-        $this->token = $token;
-        $this->sudo = $sudo;
-    }
-
-    /**
-     * {@inheritDoc}
-     *
-     * @throws InvalidArgumentException
-     */
-    public function preSend(RequestInterface $request)
-    {
-        // Skip by default
-        if (null === $this->method) {
-            return;
-        }
-
-        switch ($this->method) {
-            case Client::AUTH_HTTP_TOKEN:
-                $request->addHeader('PRIVATE-TOKEN: '.$this->token);
-                if (!is_null($this->sudo)) {
-                    $request->addHeader('SUDO: '.$this->sudo);
-                }
-                break;
-
-            case Client::AUTH_URL_TOKEN:
-                $url  = $request->getUrl();
-
-                $query = array(
-                    'private_token' => $this->token
-                );
-
-                if (!is_null($this->sudo)) {
-                    $query['sudo'] = $this->sudo;
-                }
-
-                $url .= (false === strpos($url, '?') ? '?' : '&').utf8_encode(http_build_query($query, '', '&'));
-
-                $request->fromUrl(new Url($url));
-                break;
-
-            case Client::AUTH_OAUTH_TOKEN:
-                $request->addHeader('Authorization: Bearer '.$this->token);
-                if (!is_null($this->sudo)) {
-                    $request->addHeader('SUDO: '.$this->sudo);
-                }
-                break;
-        }
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function postSend(RequestInterface $request, MessageInterface $response)
-    {
-    }
-}
diff --git a/lib/Gitlab/HttpClient/Listener/ErrorListener.php b/lib/Gitlab/HttpClient/Listener/ErrorListener.php
deleted file mode 100644
index cb2acc68b..000000000
--- a/lib/Gitlab/HttpClient/Listener/ErrorListener.php
+++ /dev/null
@@ -1,97 +0,0 @@
-<?php namespace Gitlab\HttpClient\Listener;
-
-use Buzz\Listener\ListenerInterface;
-use Buzz\Message\MessageInterface;
-use Buzz\Message\RequestInterface;
-use Gitlab\Exception\ErrorException;
-use Gitlab\Exception\RuntimeException;
-
-/**
- * @author Joseph Bielawski <stloyd@gmail.com>
- * @author Matt Humphrey <git@m4tt.co>
- */
-class ErrorListener implements ListenerInterface
-{
-    /**
-     * @var array
-     */
-    private $options;
-
-    /**
-     * @param array $options
-     */
-    public function __construct(array $options)
-    {
-        $this->options = $options;
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function preSend(RequestInterface $request)
-    {
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function postSend(RequestInterface $request, MessageInterface $response)
-    {
-        /** @var $response \Gitlab\HttpClient\Message\Response */
-        if ($response->isClientError() || $response->isServerError()) {
-            $content = $response->getContent();
-            if (is_array($content) && isset($content['message'])) {
-                if (400 == $response->getStatusCode()) {
-                    $message = $this->parseMessage($content['message']);
-
-                    throw new ErrorException($message, 400);
-                }
-            }
-
-            $errorMessage = null;
-            if (isset($content['error'])) {
-                $errorMessage = $content['error'];
-                if (is_array($content['error'])) {
-                    $errorMessage = implode("\n", $content['error']);
-                }
-            } elseif (isset($content['message'])) {
-                $errorMessage = $this->parseMessage($content['message']);
-            } else {
-                $errorMessage = $content;
-            }
-
-            throw new RuntimeException($errorMessage, $response->getStatusCode());
-        }
-    }
-
-    /**
-     * @param mixed $message
-     * @return string
-     */
-    protected function parseMessage($message)
-    {
-        $string = $message;
-
-        if (is_array($message)) {
-            $format = '"%s" %s';
-            $errors = array();
-
-            foreach ($message as $field => $messages) {
-                if (is_array($messages)) {
-                    $messages = array_unique($messages);
-                    foreach ($messages as $error) {
-                        $errors[] = sprintf($format, $field, $error);
-                    }
-                } elseif (is_integer($field)) {
-                    $errors[] = $messages;
-                } else {
-                    $errors[] = sprintf($format, $field, $messages);
-                }
-            }
-
-            $string = implode(', ', $errors);
-        }
-
-        return $string;
-    }
-}
diff --git a/lib/Gitlab/HttpClient/Listener/PaginationListener.php b/lib/Gitlab/HttpClient/Listener/PaginationListener.php
deleted file mode 100644
index 80ba39ea9..000000000
--- a/lib/Gitlab/HttpClient/Listener/PaginationListener.php
+++ /dev/null
@@ -1,38 +0,0 @@
-<?php namespace Gitlab\HttpClient\Listener;
-
-use Buzz\Listener\ListenerInterface;
-use Buzz\Message\MessageInterface;
-use Buzz\Message\RequestInterface;
-
-class PaginationListener implements ListenerInterface
-{
-    /**
-     * {@inheritDoc}
-     */
-    public function preSend(RequestInterface $request)
-    {
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function postSend(RequestInterface $request, MessageInterface $response)
-    {
-        $header = $response->getHeader('Link');
-
-        if (empty($header)) {
-            return null;
-        }
-
-        $pagination = array();
-        foreach (explode(',', $header) as $link) {
-            preg_match('/<(.*)>; rel="(.*)"/i', trim($link, ','), $match);
-
-            if (3 === count($match)) {
-                $pagination[$match[2]] = $match[1];
-            }
-        }
-
-        $response->setPagination($pagination);
-    }
-}
diff --git a/lib/Gitlab/HttpClient/Message/FormRequest.php b/lib/Gitlab/HttpClient/Message/FormRequest.php
deleted file mode 100644
index ecd05e17d..000000000
--- a/lib/Gitlab/HttpClient/Message/FormRequest.php
+++ /dev/null
@@ -1,8 +0,0 @@
-<?php namespace Gitlab\HttpClient\Message;
-
-use Buzz\Message\Form\FormRequest as BaseFormRequest;
-
-class FormRequest extends BaseFormRequest
-{
-
-}
diff --git a/lib/Gitlab/HttpClient/Message/Request.php b/lib/Gitlab/HttpClient/Message/Request.php
deleted file mode 100644
index 41a950f35..000000000
--- a/lib/Gitlab/HttpClient/Message/Request.php
+++ /dev/null
@@ -1,8 +0,0 @@
-<?php namespace Gitlab\HttpClient\Message;
-
-use Buzz\Message\Request as BaseRequest;
-
-class Request extends BaseRequest
-{
-
-}
diff --git a/lib/Gitlab/HttpClient/Message/Response.php b/lib/Gitlab/HttpClient/Message/Response.php
deleted file mode 100644
index e5746e0b8..000000000
--- a/lib/Gitlab/HttpClient/Message/Response.php
+++ /dev/null
@@ -1,48 +0,0 @@
-<?php namespace Gitlab\HttpClient\Message;
-
-use Buzz\Message\Response as BaseResponse;
-
-class Response extends BaseResponse
-{
-    /**
-     * @var array pagination
-     * See PaginationListener
-     */
-    protected $pagination = array();
-
-    /**
-     * @return mixed
-     */
-    public function getPagination()
-    {
-        return $this->pagination;
-    }
-
-    /**
-     * @param array $pagination
-     */
-    public function setPagination(array $pagination)
-    {
-        $this->pagination = $pagination;
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function getContent()
-    {
-        $response = parent::getContent();
-
-        if ($this->getHeader('Content-Type') === 'application/json') {
-            $content  = json_decode($response, true);
-    
-            if (JSON_ERROR_NONE !== json_last_error()) {
-                return $response;
-            }
-    
-            return $content;
-        }
-
-        return $response;
-    }
-}
diff --git a/lib/Gitlab/Model/AbstractModel.php b/lib/Gitlab/Model/AbstractModel.php
deleted file mode 100644
index 7c11d4b21..000000000
--- a/lib/Gitlab/Model/AbstractModel.php
+++ /dev/null
@@ -1,129 +0,0 @@
-<?php namespace Gitlab\Model;
-
-use Gitlab\Client;
-use Gitlab\Exception\RuntimeException;
-use Gitlab\Api\AbstractApi;
-
-abstract class AbstractModel
-{
-    /**
-     * @var array
-     */
-    protected static $properties;
-
-    /**
-     * @var array
-     */
-    protected $data = array();
-
-    /**
-     * @var Client
-     */
-    protected $client;
-
-    /**
-     * @return Client
-     */
-    public function getClient()
-    {
-        return $this->client;
-    }
-
-    /**
-     * @param Client $client
-     * @return $this
-     */
-    public function setClient(Client $client = null)
-    {
-        if (null !== $client) {
-            $this->client = $client;
-        }
-
-        return $this;
-    }
-
-    /**
-     * @param string $api
-     * @return AbstractApi|mixed
-     */
-    public function api($api)
-    {
-        return $this->getClient()->api($api);
-    }
-
-    /**
-     * @param array $data
-     * @return $this
-     */
-    protected function hydrate(array $data = array())
-    {
-        if (!empty($data)) {
-            foreach ($data as $field => $value) {
-                $this->setData($field, $value);
-            }
-        }
-
-        return $this;
-    }
-
-    /**
-     * @param string $field
-     * @param mixed $value
-     * @return $this
-     */
-    protected function setData($field, $value)
-    {
-        if (in_array($field, static::$properties)) {
-            $this->data[$field] = $value;
-        }
-
-        return $this;
-    }
-
-    /**
-     * @return array
-     */
-    public function getData()
-    {
-        return $this->data;
-    }
-
-    /**
-     * @param string $property
-     * @param mixed $value
-     * @throws RuntimeException
-     */
-    public function __set($property, $value)
-    {
-        throw new RuntimeException('Model properties are immutable');
-    }
-
-    /**
-     * @param string $property
-     * @return mixed
-     */
-    public function __get($property)
-    {
-        if (!in_array($property, static::$properties)) {
-            throw new RuntimeException(sprintf(
-                'Property "%s" does not exist for %s object',
-                $property, get_called_class()
-            ));
-        }
-
-        if (isset($this->data[$property])) {
-            return $this->data[$property];
-        }
-
-        return null;
-    }
-
-    /**
-     * @param string $property
-     * @return bool
-     */
-    public function __isset($property)
-    {
-        return isset($this->data[$property]);
-    }
-}
diff --git a/lib/Gitlab/Model/Branch.php b/lib/Gitlab/Model/Branch.php
deleted file mode 100644
index 23afebcfe..000000000
--- a/lib/Gitlab/Model/Branch.php
+++ /dev/null
@@ -1,144 +0,0 @@
-<?php namespace Gitlab\Model;
-
-use Gitlab\Client;
-use Gitlab\Api\AbstractApi as Api;
-
-/**
- * Class Branch
- *
- * @property-read string $name
- * @property-read bool $protected
- * @property-read Commit $commit
- * @property-read Project $project
- */
-class Branch extends AbstractModel
-{
-    /**
-     * @var array
-     */
-    protected static $properties = array(
-        'name',
-        'commit',
-        'project',
-        'protected'
-    );
-
-    /**
-     * @param Client  $client
-     * @param Project $project
-     * @param array   $data
-     * @return Branch
-     */
-    public static function fromArray(Client $client, Project $project, array $data)
-    {
-        $branch = new static($project, $data['name'], $client);
-
-        if (isset($data['commit'])) {
-            $data['commit'] = Commit::fromArray($client, $project, $data['commit']);
-        }
-
-        return $branch->hydrate($data);
-    }
-
-    /**
-     * @param Project $project
-     * @param string $name
-     * @param Client $client
-     */
-    public function __construct(Project $project, $name = null, Client $client = null)
-    {
-        $this->setClient($client);
-        $this->setData('project', $project);
-        $this->setData('name', $name);
-    }
-
-    /**
-     * @return Branch
-     */
-    public function show()
-    {
-        $data = $this->api('repositories')->branch($this->project->id, $this->name);
-
-        return static::fromArray($this->getClient(), $this->project, $data);
-    }
-
-    /**
-     * @param bool $devPush
-     * @param bool $devMerge
-     * @return Branch
-     */
-    public function protect($devPush = false, $devMerge = false)
-    {
-        $data = $this->api('repositories')->protectBranch($this->project->id, $this->name, $devPush, $devMerge);
-
-        return static::fromArray($this->getClient(), $this->project, $data);
-    }
-
-    /**
-     * @return Branch
-     */
-    public function unprotect()
-    {
-        $data = $this->api('repositories')->unprotectBranch($this->project->id, $this->name);
-
-        return static::fromArray($this->getClient(), $this->project, $data);
-    }
-
-    /**
-     * @return bool
-     */
-    public function delete()
-    {
-        $this->api('repositories')->deleteBranch($this->project->id, $this->name);
-
-        return true;
-    }
-
-    /**
-     * @param int $page
-     * @param int $per_page
-     * @return Commit[]
-     */
-    public function commits($page = 1, $per_page = Api::PER_PAGE)
-    {
-        return $this->project->commits($page, $per_page, $this->name);
-    }
-
-    /**
-     * @param string $file_path
-     * @param string $content
-     * @param string $commit_message
-     * @return File
-     */
-    public function createFile($file_path, $content, $commit_message)
-    {
-        $data = $this->api('repositories')->createFile($this->project->id, $file_path, $content, $this->name, $commit_message);
-
-        return File::fromArray($this->getClient(), $this->project, $data);
-    }
-
-    /**
-     * @param string $file_path
-     * @param string $content
-     * @param string $commit_message
-     * @return File
-     */
-    public function updateFile($file_path, $content, $commit_message)
-    {
-        $data = $this->api('repositories')->updateFile($this->project->id, $file_path, $content, $this->name, $commit_message);
-
-        return File::fromArray($this->getClient(), $this->project, $data);
-    }
-
-    /**
-     * @param string $file_path
-     * @param string $commit_message
-     * @return bool
-     */
-    public function deleteFile($file_path, $commit_message)
-    {
-        $this->api('repositories')->deleteFile($this->project->id, $file_path, $this->name, $commit_message);
-
-        return true;
-    }
-}
diff --git a/lib/Gitlab/Model/Commit.php b/lib/Gitlab/Model/Commit.php
deleted file mode 100644
index 9b0073255..000000000
--- a/lib/Gitlab/Model/Commit.php
+++ /dev/null
@@ -1,86 +0,0 @@
-<?php namespace Gitlab\Model;
-
-use Gitlab\Client;
-
-/**
- * Class Commit
- *
- * @property-read string $id
- * @property-read string $short_id
- * @property-read string $title
- * @property-read string $message
- * @property-read string $author_name
- * @property-read string $author_email
- * @property-read string $authored_date
- * @property-read string $committed_date
- * @property-read string $created_at
- * @property-read Commit[] $parents
- * @property-read Node[] $tree
- * @property-read User $committer
- * @property-read User $author
- * @property-read Project $project
- */
-class Commit extends AbstractModel
-{
-    /**
-     * @var array
-     */
-    protected static $properties = array(
-        'id',
-        'short_id',
-        'parents',
-        'tree',
-        'title',
-        'message',
-        'author',
-        'author_name',
-        'author_email',
-        'committer',
-        'authored_date',
-        'committed_date',
-        'created_at',
-        'project'
-    );
-
-    /**
-     * @param Client  $client
-     * @param Project $project
-     * @param array   $data
-     * @return Commit
-     */
-    public static function fromArray(Client $client, Project $project, array $data)
-    {
-        $commit = new static($project, $data['id'], $client);
-
-        if (isset($data['parents'])) {
-            $parents = array();
-            foreach ($data['parents'] as $parent) {
-                $parents[] = static::fromArray($client, $project, $parent);
-            }
-
-            $data['parents'] = $parents;
-        }
-
-        if (isset($data['author'])) {
-            $data['author'] = User::fromArray($client, $data['author']);
-        }
-
-        if (isset($data['committer'])) {
-            $data['committer'] = User::fromArray($client, $data['committer']);
-        }
-
-        return $commit->hydrate($data);
-    }
-
-    /**
-     * @param Project $project
-     * @param int $id
-     * @param Client  $client
-     */
-    public function __construct(Project $project, $id = null, Client $client = null)
-    {
-        $this->setClient($client);
-        $this->setData('project', $project);
-        $this->setData('id', $id);
-    }
-}
diff --git a/lib/Gitlab/Model/CommitNote.php b/lib/Gitlab/Model/CommitNote.php
deleted file mode 100644
index 4b455c7f1..000000000
--- a/lib/Gitlab/Model/CommitNote.php
+++ /dev/null
@@ -1,50 +0,0 @@
-<?php namespace Gitlab\Model;
-
-use Gitlab\Client;
-
-/**
- * Class CommitNote
- *
- * @property-read string $note
- * @property-read string $path
- * @property-read string $line
- * @property-read string $line_type
- * @property-read User $author
- */
-class CommitNote extends AbstractModel
-{
-    /**
-     * @var array
-     */
-    protected static $properties = array(
-        'note',
-        'path',
-        'line',
-        'line_type',
-        'author'
-    );
-
-    /**
-     * @param Client $client
-     * @param array  $data
-     * @return CommitNote
-     */
-    public static function fromArray(Client $client, array $data)
-    {
-        $comment = new static($client);
-
-        if (isset($data['author'])) {
-            $data['author'] = User::fromArray($client, $data['author']);
-        }
-
-        return $comment->hydrate($data);
-    }
-
-    /**
-     * @param Client $client
-     */
-    public function __construct(Client $client = null)
-    {
-        $this->setClient($client);
-    }
-}
diff --git a/lib/Gitlab/Model/Comparison.php b/lib/Gitlab/Model/Comparison.php
deleted file mode 100644
index 27901829d..000000000
--- a/lib/Gitlab/Model/Comparison.php
+++ /dev/null
@@ -1,73 +0,0 @@
-<?php namespace Gitlab\Model;
-
-use Gitlab\Client;
-
-/**
- * Class Comparison
- *
- * @property-read bool $compare_timeout
- * @property-read bool $compare_same_ref
- * @property-read Commit $commit
- * @property-read Commit[] $commits
- * @property-read Diff[] $diffs
- * @property-read Project $project
- */
-class Comparison extends AbstractModel
-{
-    /**
-     * @var array
-     */
-    protected static $properties = array(
-        'commit',
-        'commits',
-        'diffs',
-        'compare_timeout',
-        'compare_same_ref',
-        'project'
-    );
-
-    /**
-     * @param Client $client
-     * @param Project $project
-     * @param array $data
-     * @return Comparison
-     */
-    public static function fromArray(Client $client, Project $project, array $data)
-    {
-        $file = new static($project, $client);
-
-        if (isset($data['commit'])) {
-            $data['commit'] = Commit::fromArray($client, $project, $data['commit']);
-        }
-
-        if (isset($data['commits'])) {
-            $commits = array();
-            foreach ($data['commits'] as $commit) {
-                $commits[] = Commit::fromArray($client, $project, $commit);
-            }
-
-            $data['commits'] = $commits;
-        }
-
-        if (isset($data['diffs'])) {
-            $diffs = array();
-            foreach ($data['diffs'] as $diff) {
-                $diffs[] = Diff::fromArray($client, $project, $diff);
-            }
-
-            $data['diffs'] = $diffs;
-        }
-
-        return $file->hydrate($data);
-    }
-
-    /**
-     * @param Project $project
-     * @param Client $client
-     */
-    public function __construct(Project $project, Client $client = null)
-    {
-        $this->setClient($client);
-        $this->setData('project', $project);
-    }
-}
diff --git a/lib/Gitlab/Model/Contributor.php b/lib/Gitlab/Model/Contributor.php
deleted file mode 100644
index 658da48bc..000000000
--- a/lib/Gitlab/Model/Contributor.php
+++ /dev/null
@@ -1,51 +0,0 @@
-<?php namespace Gitlab\Model;
-
-use Gitlab\Client;
-
-/**
- * Class Contributor
- *
- * @property-read string $name
- * @property-read string $email
- * @property-read int $commits
- * @property-read int $additions
- * @property-read int $deletions
- * @property-read Project $project
- */
-class Contributor extends AbstractModel
-{
-    /**
-     * @var array
-     */
-    protected static $properties = array(
-        'name',
-        'email',
-        'commits',
-        'additions',
-        'deletions',
-        'project'
-    );
-
-    /**
-     * @param Client $client
-     * @param Project $project
-     * @param array $data
-     * @return Contributor
-     */
-    public static function fromArray(Client $client, Project $project, array $data)
-    {
-        $contributor = new static($project, $client);
-
-        return $contributor->hydrate($data);
-    }
-
-    /**
-     * @param Project $project
-     * @param Client $client
-     */
-    public function __construct(Project $project, Client $client = null)
-    {
-        $this->setClient($client);
-        $this->setData('project', $project);
-    }
-}
diff --git a/lib/Gitlab/Model/Diff.php b/lib/Gitlab/Model/Diff.php
deleted file mode 100644
index 5fd95d5ac..000000000
--- a/lib/Gitlab/Model/Diff.php
+++ /dev/null
@@ -1,65 +0,0 @@
-<?php namespace Gitlab\Model;
-
-use Gitlab\Client;
-
-/**
- * Class Diff
- *
- * @property-read string $old_path
- * @property-read string $new_path
- * @property-read string $a_mode
- * @property-read string $b_mode
- * @property-read string $diff
- * @property-read bool $new_file
- * @property-read bool $renamed_file
- * @property-read bool $deleted_file
- * @property-read Project $project
- */
-class Diff extends AbstractModel
-{
-    /**
-     * @var array
-     */
-    protected static $properties = array(
-        'old_path',
-        'new_path',
-        'a_mode',
-        'b_mode',
-        'diff',
-        'new_file',
-        'renamed_file',
-        'deleted_file',
-        'project'
-    );
-
-    /**
-     * @param Client $client
-     * @param Project $project
-     * @param array $data
-     * @return Diff
-     */
-    public static function fromArray(Client $client, Project $project, array $data)
-    {
-        $diff = new static($project, $client);
-
-        return $diff->hydrate($data);
-    }
-
-    /**
-     * @param Project $project
-     * @param Client $client
-     */
-    public function __construct(Project $project, Client $client = null)
-    {
-        $this->setClient($client);
-        $this->setData('project', $project);
-    }
-
-    /**
-     * @return string
-     */
-    public function __toString()
-    {
-        return $this->diff;
-    }
-}
diff --git a/lib/Gitlab/Model/Event.php b/lib/Gitlab/Model/Event.php
deleted file mode 100644
index 83565e174..000000000
--- a/lib/Gitlab/Model/Event.php
+++ /dev/null
@@ -1,65 +0,0 @@
-<?php namespace Gitlab\Model;
-
-use Gitlab\Client;
-
-/**
- * Class Event
- *
- * @property-read string $title
- * @property-read int $id
- * @property-read string $action_name
- * @property-read string $data
- * @property-read int $target_id
- * @property-read string $target_type
- * @property-read string $target_title
- * @property-read int $author_id
- * @property-read string $author_username
- * @property-read User $author
- * @property-read Project $project
- */
-class Event extends AbstractModel
-{
-    /**
-     * @var array
-     */
-    protected static $properties = array(
-        'title',
-        'project_id',
-        'action_name',
-        'target_id',
-        'target_type',
-        'author_id',
-        'author_username',
-        'data',
-        'target_title',
-        'author',
-        'project'
-    );
-
-    /**
-     * @param Client  $client
-     * @param Project $project
-     * @param array   $data
-     * @return Event
-     */
-    public static function fromArray(Client $client, Project $project, array $data)
-    {
-        $event = new static($project, $client);
-
-        if (isset($data['author_id'])) {
-            $data['author'] = new User($data['author_id'], $client);
-        }
-
-        return $event->hydrate($data);
-    }
-
-    /**
-     * @param Project $project
-     * @param Client  $client
-     */
-    public function __construct(Project $project, Client $client = null)
-    {
-        $this->setClient($client);
-        $this->setData('project', $project);
-    }
-}
diff --git a/lib/Gitlab/Model/File.php b/lib/Gitlab/Model/File.php
deleted file mode 100644
index b2edac127..000000000
--- a/lib/Gitlab/Model/File.php
+++ /dev/null
@@ -1,47 +0,0 @@
-<?php namespace Gitlab\Model;
-
-use Gitlab\Client;
-
-/**
- * Class File
- *
- * @property-read string $file_path
- * @property-read string $branch_name
- * @property-read Project $project
- */
-class File extends AbstractModel
-{
-    /**
-     * @var array
-     */
-    protected static $properties = array(
-        'project',
-        'file_path',
-        'branch_name'
-    );
-
-    /**
-     * @param Client $client
-     * @param Project $project
-     * @param array $data
-     * @return File
-     */
-    public static function fromArray(Client $client, Project $project, array $data)
-    {
-        $file = new static($project, $data['file_path'], $client);
-
-        return $file->hydrate($data);
-    }
-
-    /**
-     * @param Project $project
-     * @param string $file_path
-     * @param Client $client
-     */
-    public function __construct(Project $project, $file_path = null, Client $client = null)
-    {
-        $this->setClient($client);
-        $this->setData('project', $project);
-        $this->setData('file_path', $file_path);
-    }
-}
diff --git a/lib/Gitlab/Model/Group.php b/lib/Gitlab/Model/Group.php
deleted file mode 100644
index 614d50ffb..000000000
--- a/lib/Gitlab/Model/Group.php
+++ /dev/null
@@ -1,128 +0,0 @@
-<?php namespace Gitlab\Model;
-
-use Gitlab\Client;
-
-/**
- * Class Group
- *
- * @property-read int $id
- * @property-read string $name
- * @property-read string $path
- * @property-read string $description
- * @property-read Project[] $projects
- */
-class Group extends AbstractModel
-{
-    /**
-     * @var array
-     */
-    protected static $properties = array(
-        'id',
-        'name',
-        'path',
-        'description',
-        'projects'
-    );
-
-    /**
-     * @param Client $client
-     * @param array  $data
-     * @return Group
-     */
-    public static function fromArray(Client $client, array $data)
-    {
-        $group = new static($data['id'], $client);
-
-        if (isset($data['projects'])) {
-            $projects = array();
-            foreach ($data['projects'] as $project) {
-                $projects[] = Project::fromArray($client, $project);
-            }
-            $data['projects'] = $projects;
-        }
-
-        return $group->hydrate($data);
-    }
-
-    /**
-     * @param Client $client
-     * @param string $name
-     * @param string $path
-     * @return Group
-     */
-    public static function create(Client $client, $name, $path)
-    {
-        $data = $client->api('groups')->create($name, $path);
-
-        return static::fromArray($client, $data);
-    }
-
-    /**
-     * @param int $id
-     * @param Client $client
-     */
-    public function __construct($id, Client $client = null)
-    {
-        $this->setClient($client);
-        $this->setData('id', $id);
-    }
-
-    /**
-     * @return Group
-     */
-    public function show()
-    {
-        $data = $this->api('groups')->show($this->id);
-
-        return Group::fromArray($this->getClient(), $data);
-    }
-
-    /**
-     * @param int $project_id
-     * @return Group
-     */
-    public function transfer($project_id)
-    {
-        $data = $this->api('groups')->transfer($this->id, $project_id);
-
-        return Group::fromArray($this->getClient(), $data);
-    }
-
-    /**
-     * @return User[]
-     */
-    public function members()
-    {
-        $data = $this->api('groups')->members($this->id);
-
-        $members = array();
-        foreach ($data as $member) {
-            $members[] = User::fromArray($this->getClient(), $member);
-        }
-
-        return $members;
-    }
-
-    /**
-     * @param int $user_id
-     * @param int $access_level
-     * @return User
-     */
-    public function addMember($user_id, $access_level)
-    {
-        $data = $this->api('groups')->addMember($this->id, $user_id, $access_level);
-
-        return User::fromArray($this->getClient(), $data);
-    }
-
-    /**
-     * @param int $user_id
-     * @return bool
-     */
-    public function removeMember($user_id)
-    {
-        $this->api('groups')->removeMember($this->id, $user_id);
-
-        return true;
-    }
-}
diff --git a/lib/Gitlab/Model/Hook.php b/lib/Gitlab/Model/Hook.php
deleted file mode 100644
index 15bda3992..000000000
--- a/lib/Gitlab/Model/Hook.php
+++ /dev/null
@@ -1,76 +0,0 @@
-<?php namespace Gitlab\Model;
-
-use Gitlab\Client;
-
-/**
- * Class Hook
- *
- * @property-read int $id
- * @property-read string $url
- * @property-read string $created_at
- */
-class Hook extends AbstractModel
-{
-    /**
-     * @var array
-     */
-    protected static $properties = array(
-        'id',
-        'url',
-        'created_at'
-    );
-
-    /**
-     * @param Client $client
-     * @param array  $data
-     * @return Hook
-     */
-    public static function fromArray(Client $client, array $data)
-    {
-        $hook = new static($data['id'], $client);
-
-        return $hook->hydrate($data);
-    }
-
-    /**
-     * @param Client $client
-     * @param string $url
-     * @return Hook
-     */
-    public static function create(Client $client, $url)
-    {
-        $data = $client->api('system_hooks')->create($url);
-
-        return static::fromArray($client, $data);
-    }
-
-    /**
-     * @param int $id
-     * @param Client $client
-     */
-    public function __construct($id, Client $client = null)
-    {
-        $this->setClient($client);
-        $this->setData('id', $id);
-    }
-
-    /**
-     * @return bool
-     */
-    public function test()
-    {
-        $this->api('system_hooks')->test($this->id);
-
-        return true;
-    }
-
-    /**
-     * @return bool
-     */
-    public function delete()
-    {
-        $this->api('system_hooks')->remove($this->id);
-
-        return true;
-    }
-}
diff --git a/lib/Gitlab/Model/Issue.php b/lib/Gitlab/Model/Issue.php
deleted file mode 100644
index 86c7e2add..000000000
--- a/lib/Gitlab/Model/Issue.php
+++ /dev/null
@@ -1,171 +0,0 @@
-<?php namespace Gitlab\Model;
-
-use Gitlab\Client;
-
-/**
- * Class Issue
- *
- * @property-read int $id
- * @property-read int $iid
- * @property-read int $project_id,
- * @property-read string $title
- * @property-read string $description
- * @property-read array $labels
- * @property-read bool $closed
- * @property-read string $updated_at
- * @property-read string $created_at
- * @property-read string $state
- * @property-read User $assignee
- * @property-read User $author
- * @property-read Milestone $milestone
- * @property-read Project $project
- */
-class Issue extends AbstractModel implements Noteable
-{
-    /**
-     * @var array
-     */
-    protected static $properties = array(
-        'id',
-        'iid',
-        'project_id',
-        'title',
-        'description',
-        'labels',
-        'milestone',
-        'assignee',
-        'author',
-        'closed',
-        'updated_at',
-        'created_at',
-        'project',
-        'state'
-    );
-
-    /**
-     * @param Client  $client
-     * @param Project $project
-     * @param array   $data
-     * @return Issue
-     */
-    public static function fromArray(Client $client, Project $project, array $data)
-    {
-        $issue = new static($project, $data['id'], $client);
-
-        if (isset($data['author'])) {
-            $data['author'] = User::fromArray($client, $data['author']);
-        }
-
-        if (isset($data['assignee'])) {
-            $data['assignee'] = User::fromArray($client, $data['assignee']);
-        }
-
-        return $issue->hydrate($data);
-    }
-
-    /**
-     * @param Project $project
-     * @param int $id
-     * @param Client $client
-     */
-    public function __construct(Project $project, $id = null, Client $client = null)
-    {
-        $this->setClient($client);
-        $this->setData('project', $project);
-        $this->setData('id', $id);
-    }
-
-    /**
-     * @return Issue
-     */
-    public function show()
-    {
-        $data = $this->api('issues')->show($this->project->id, $this->id);
-
-        return static::fromArray($this->getClient(), $this->project, $data);
-    }
-
-    /**
-     * @param array $params
-     * @return Issue
-     */
-    public function update(array $params)
-    {
-        $data = $this->api('issues')->update($this->project->id, $this->id, $params);
-
-        return static::fromArray($this->getClient(), $this->project, $data);
-    }
-
-    /**
-     * @param string $comment
-     * @return Issue
-     */
-    public function close($comment = null)
-    {
-        if ($comment) {
-            $this->addComment($comment);
-        }
-
-        return $this->update(array(
-            'state_event' => 'close'
-        ));
-    }
-
-    /**
-     * @return Issue
-     */
-    public function open()
-    {
-        return $this->update(array(
-            'state_event' => 'reopen'
-        ));
-    }
-
-    /**
-     * @return Issue
-     */
-    public function reopen()
-    {
-        return $this->open();
-    }
-
-    /**
-     * @param string $comment
-     * @return Note
-     */
-    public function addComment($comment)
-    {
-        $data = $this->api('issues')->addComment($this->project->id, $this->id, array(
-            'body' => $comment
-        ));
-
-        return Note::fromArray($this->getClient(), $this, $data);
-    }
-
-    /**
-     * @return Note[]
-     */
-    public function showComments()
-    {
-        $notes = array();
-        $data = $this->api('issues')->showComments($this->project->id, $this->id);
-
-        foreach ($data as $note) {
-            $notes[] = Note::fromArray($this->getClient(), $this, $note);
-        }
-
-        return $notes;
-    }
-
-    /**
-     * @return bool
-     */
-    public function isClosed()
-    {
-        if ($this->state == 'closed') {
-            return true;
-        }
-
-        return false;
-    }
-}
diff --git a/lib/Gitlab/Model/Key.php b/lib/Gitlab/Model/Key.php
deleted file mode 100644
index 5e6aebfa5..000000000
--- a/lib/Gitlab/Model/Key.php
+++ /dev/null
@@ -1,44 +0,0 @@
-<?php namespace Gitlab\Model;
-
-use Gitlab\Client;
-
-/**
- * Class Key
- *
- * @property-read int $id
- * @property-read string $title
- * @property-read string $key
- * @property-read string $created_at
- */
-class Key extends AbstractModel
-{
-    /**
-     * @var array
-     */
-    protected static $properties = array(
-        'id',
-        'title',
-        'key',
-        'created_at'
-    );
-
-    /**
-     * @param Client $client
-     * @param array $data
-     * @return Key
-     */
-    public static function fromArray(Client $client, array $data)
-    {
-        $key = new static($client);
-
-        return $key->hydrate($data);
-    }
-
-    /**
-     * @param Client $client
-     */
-    public function __construct(Client $client = null)
-    {
-        $this->setClient($client);
-    }
-}
diff --git a/lib/Gitlab/Model/Label.php b/lib/Gitlab/Model/Label.php
deleted file mode 100644
index 5c8a20919..000000000
--- a/lib/Gitlab/Model/Label.php
+++ /dev/null
@@ -1,43 +0,0 @@
-<?php namespace Gitlab\Model;
-
-use Gitlab\Client;
-
-/**
- * Class Label
- *
- * @property-read string $name
- * @property-read string $color
- */
-class Label extends AbstractModel
-{
-    /**
-     * @var array
-     */
-    protected static $properties = array(
-        'name',
-        'color'
-    );
-
-    /**
-     * @param Client $client
-     * @param Project $project
-     * @param array  $data
-     * @return Label
-     */
-    public static function fromArray(Client $client, Project $project, array $data)
-    {
-        $label = new static($project, $client);
-
-        return $label->hydrate($data);
-    }
-
-    /**
-     * @param Project $project
-     * @param Client $client
-     */
-    public function __construct(Project $project, Client $client = null)
-    {
-        $this->setClient($client);
-        $this->setData('project', $project);
-    }
-}
diff --git a/lib/Gitlab/Model/MergeRequest.php b/lib/Gitlab/Model/MergeRequest.php
deleted file mode 100644
index 686b47a7d..000000000
--- a/lib/Gitlab/Model/MergeRequest.php
+++ /dev/null
@@ -1,227 +0,0 @@
-<?php namespace Gitlab\Model;
-
-use Gitlab\Client;
-
-/**
- * Class MergeRequest
- *
- * @property-read int $id
- * @property-read int $iid
- * @property-read string $target_branch
- * @property-read string $source_branch
- * @property-read int $project_id
- * @property-read string $title
- * @property-read string $description
- * @property-read bool $closed
- * @property-read bool $merged
- * @property-read string $state
- * @property-read int $source_project_id
- * @property-read int $target_project_id
- * @property-read int $upvotes
- * @property-read int $downvotes
- * @property-read array $labels
- * @property-read User $author
- * @property-read User $assignee
- * @property-read Project $project
- * @property-read Milestone $milestone
- * @property-read File[] $files
- */
-class MergeRequest extends AbstractModel implements Noteable
-{
-    /**
-     * @var array
-     */
-    protected static $properties = array(
-        'id',
-        'iid',
-        'target_branch',
-        'source_branch',
-        'project_id',
-        'title',
-        'description',
-        'closed',
-        'merged',
-        'author',
-        'assignee',
-        'project',
-        'state',
-        'source_project_id',
-        'target_project_id',
-        'upvotes',
-        'downvotes',
-        'labels',
-        'milestone',
-        'files'
-    );
-
-    /**
-     * @param Client  $client
-     * @param Project $project
-     * @param array   $data
-     * @return MergeRequest
-     */
-    public static function fromArray(Client $client, Project $project, array $data)
-    {
-        $mr = new static($project, $data['id'], $client);
-
-        if (isset($data['author'])) {
-            $data['author'] = User::fromArray($client, $data['author']);
-        }
-
-        if (isset($data['assignee'])) {
-            $data['assignee'] = User::fromArray($client, $data['assignee']);
-        }
-
-        if (isset($data['milestone'])) {
-            $data['milestone'] = Milestone::fromArray($client, $project, $data['milestone']);
-        }
-
-        if (isset($data['files'])) {
-            $files = array();
-            foreach ($data['files'] as $file) {
-                $files[] = File::fromArray($client, $project, $file);
-            }
-
-            $data['files'] = $files;
-        }
-
-        return $mr->hydrate($data);
-    }
-
-    /**
-     * @param Project $project
-     * @param int $id
-     * @param Client $client
-     */
-    public function __construct(Project $project, $id = null, Client $client = null)
-    {
-        $this->setClient($client);
-        $this->setData('project', $project);
-        $this->setData('id', $id);
-    }
-
-    /**
-     * @return MergeRequest
-     */
-    public function show()
-    {
-        $data = $this->api('mr')->show($this->project->id, $this->id);
-
-        return static::fromArray($this->getClient(), $this->project, $data);
-    }
-
-    /**
-     * @param array $params
-     * @return MergeRequest
-     */
-    public function update(array $params)
-    {
-        $data = $this->api('mr')->update($this->project->id, $this->id, $params);
-
-        return static::fromArray($this->getClient(), $this->project, $data);
-    }
-
-    /**
-     * @param string $comment
-     * @return MergeRequest
-     */
-    public function close($comment = null)
-    {
-        if ($comment) {
-            $this->addComment($comment);
-        }
-
-        return $this->update(array(
-            'state_event' => 'close'
-        ));
-    }
-
-    /**
-     * @return MergeRequest
-     */
-    public function reopen()
-    {
-        return $this->update(array(
-            'state_event' => 'reopen'
-        ));
-    }
-
-    /**
-     * @return MergeRequest
-     */
-    public function open()
-    {
-        return $this->reopen();
-    }
-
-    /**
-     * @param string $message
-     * @return MergeRequest
-     */
-    public function merge($message = null)
-    {
-        $data = $this->api('mr')->merge($this->project->id, $this->id, array(
-            'merge_commit_message' => $message
-        ));
-
-        return static::fromArray($this->getClient(), $this->project, $data);
-    }
-
-    /**
-     * @return MergeRequest
-     */
-    public function merged()
-    {
-        return $this->update(array(
-            'state_event' => 'merge'
-        ));
-    }
-
-    /**
-     * @param string $comment
-     * @return Note
-     */
-    public function addComment($comment)
-    {
-        $data = $this->api('mr')->addComment($this->project->id, $this->id, $comment);
-
-        return Note::fromArray($this->getClient(), $this, $data);
-    }
-
-    /**
-     * @return Note[]
-     */
-    public function showComments()
-    {
-        $notes = array();
-        $data = $this->api('mr')->showComments($this->project->id, $this->id);
-
-        foreach ($data as $note) {
-            $notes[] = Note::fromArray($this->getClient(), $this, $note);
-        }
-
-        return $notes;
-    }
-
-    /**
-     * @return bool
-     */
-    public function isClosed()
-    {
-        if (in_array($this->state, array('closed', 'merged'))) {
-            return true;
-        }
-
-        return false;
-    }
-
-    /**
-     * @return MergeRequest
-     */
-    public function changes()
-    {
-        $data = $this->api('mr')->changes($this->project->id, $this->id);
-
-        return static::fromArray($this->getClient(), $this->project, $data);
-    }
-}
diff --git a/lib/Gitlab/Model/Milestone.php b/lib/Gitlab/Model/Milestone.php
deleted file mode 100644
index c59afca38..000000000
--- a/lib/Gitlab/Model/Milestone.php
+++ /dev/null
@@ -1,115 +0,0 @@
-<?php namespace Gitlab\Model;
-
-use Gitlab\Client;
-
-/**
- * Class Milestone
- *
- * @property-read int $id
- * @property-read int $iid
- * @property-read int $project_id
- * @property-read string $title
- * @property-read string $description
- * @property-read string $due_date
- * @property-read string $state
- * @property-read bool $closed
- * @property-read string $updated_at
- * @property-read string $created_at
- * @property-read Project $project
- */
-class Milestone extends AbstractModel
-{
-    /**
-     * @var array
-     */
-    protected static $properties = array(
-        'id',
-        'iid',
-        'project',
-        'project_id',
-        'title',
-        'description',
-        'due_date',
-        'state',
-        'closed',
-        'updated_at',
-        'created_at'
-    );
-
-    /**
-     * @param Client  $client
-     * @param Project $project
-     * @param array   $data
-     * @return Milestone
-     */
-    public static function fromArray(Client $client, Project $project, array $data)
-    {
-        $milestone = new static($project, $data['id'], $client);
-
-        return $milestone->hydrate($data);
-    }
-
-    /**
-     * @param Project $project
-     * @param int $id
-     * @param Client  $client
-     */
-    public function __construct(Project $project, $id, Client $client = null)
-    {
-        $this->setClient($client);
-        $this->setData('id', $id);
-        $this->setData('project', $project);
-    }
-
-    /**
-     * @return Milestone
-     */
-    public function show()
-    {
-        $data = $this->api('milestones')->show($this->project->id, $this->id);
-
-        return static::fromArray($this->getClient(), $this->project, $data);
-    }
-
-    /**
-     * @param array $params
-     * @return Milestone
-     */
-    public function update(array $params)
-    {
-        $data = $this->api('milestones')->update($this->project->id, $this->id, $params);
-
-        return static::fromArray($this->getClient(), $this->project, $data);
-    }
-
-    /**
-     * @return Milestone
-     */
-    public function complete()
-    {
-        return $this->update(array('closed' => true));
-    }
-
-    /**
-     * @return Milestone
-     */
-    public function incomplete()
-    {
-        return $this->update(array('closed' => false));
-    }
-
-    /**
-     * @return Issue[]
-     */
-    public function issues()
-    {
-        $data = $this->api('milestones')->issues($this->project->id, $this->id);
-
-        $issues = array();
-        foreach ($data as $issue) {
-            $issues[] = Issue::fromArray($this->getClient(), $this->project, $issue);
-        }
-
-        return $issues;
-    }
-}
diff --git a/lib/Gitlab/Model/Node.php b/lib/Gitlab/Model/Node.php
deleted file mode 100644
index 8add42a07..000000000
--- a/lib/Gitlab/Model/Node.php
+++ /dev/null
@@ -1,51 +0,0 @@
-<?php namespace Gitlab\Model;
-
-use Gitlab\Client;
-
-/**
- * Class Node
- *
- * @property-read string $name
- * @property-read string $type
- * @property-read string $mode
- * @property-read int $id
- * @property-read Project $project
- */
-class Node extends AbstractModel
-{
-    /**
-     * @var array
-     */
-    protected static $properties = array(
-        'name',
-        'type',
-        'mode',
-        'id',
-        'project'
-    );
-
-    /**
-     * @param Client  $client
-     * @param Project $project
-     * @param array   $data
-     * @return Node
-     */
-    public static function fromArray(Client $client, Project $project, array $data)
-    {
-        $node = new static($project, $data['id'], $client);
-
-        return $node->hydrate($data);
-    }
-
-    /**
-     * @param Project $project
-     * @param int $id
-     * @param Client $client
-     */
-    public function __construct(Project $project, $id = null, Client $client)
-    {
-        $this->setClient($client);
-        $this->setData('project', $project);
-        $this->setData('id', $id);
-    }
-}
diff --git a/lib/Gitlab/Model/Note.php b/lib/Gitlab/Model/Note.php
deleted file mode 100644
index c7767792d..000000000
--- a/lib/Gitlab/Model/Note.php
+++ /dev/null
@@ -1,61 +0,0 @@
-<?php namespace Gitlab\Model;
-
-use Gitlab\Client;
-
-/**
- * Class Note
- *
- * @property-read User $author
- * @property-read string $body
- * @property-read string $created_at
- * @property-read string $updated_at
- * @property-read string $parent_type
- * @property-read Issue|MergeRequest $parent
- * @property-read string $attachment
- * @property-read bool $system
- */
-class Note extends AbstractModel
-{
-    /**
-     * @var array
-     */
-    protected static $properties = array(
-        'id',
-        'author',
-        'body',
-        'created_at',
-        'updated_at',
-        'parent_type',
-        'parent',
-        'attachment',
-        'system'
-    );
-
-    /**
-     * @param Client $client
-     * @param Noteable $type
-     * @param array $data
-     * @return mixed
-     */
-    public static function fromArray(Client $client, Noteable $type, array $data)
-    {
-        $comment = new static($type, $client);
-
-        if (isset($data['author'])) {
-            $data['author'] = User::fromArray($client, $data['author']);
-        }
-
-        return $comment->hydrate($data);
-    }
-
-    /**
-     * @param Noteable $type
-     * @param Client $client
-     */
-    public function __construct(Noteable $type, Client $client = null)
-    {
-        $this->setClient($client);
-        $this->setData('parent_type', get_class($type));
-        $this->setData('parent', $type);
-    }
-}
diff --git a/lib/Gitlab/Model/Noteable.php b/lib/Gitlab/Model/Noteable.php
deleted file mode 100644
index 11e2cb650..000000000
--- a/lib/Gitlab/Model/Noteable.php
+++ /dev/null
@@ -1,36 +0,0 @@
-<?php namespace Gitlab\Model;
-
-interface Noteable
-{
-    /**
-     * @param string $comment
-     * @return Note
-     */
-    public function addComment($comment);
-
-    /**
-     * @return Note[]
-     */
-    public function showComments();
-
-    /**
-     * @param string $comment
-     * @return static
-     */
-    public function close($comment = null);
-
-    /**
-     * @return static
-     */
-    public function open();
-
-    /**
-     * @return static
-     */
-    public function reopen();
-
-    /**
-     * @return bool
-     */
-    public function isClosed();
-}
diff --git a/lib/Gitlab/Model/Project.php b/lib/Gitlab/Model/Project.php
deleted file mode 100644
index 1b52d8f54..000000000
--- a/lib/Gitlab/Model/Project.php
+++ /dev/null
@@ -1,1089 +0,0 @@
-<?php namespace Gitlab\Model;
-
-use Gitlab\Api\MergeRequests;
-use Gitlab\Client;
-use Gitlab\Api\AbstractApi as Api;
-
-/**
- * Class Project
- *
- * @property-read int $id
- * @property-read string $code
- * @property-read string $name
- * @property-read string $name_with_namespace
- * @property-read string $description
- * @property-read string $path
- * @property-read string $path_with_namespace
- * @property-read string $ssh_url_to_repo
- * @property-read string $http_url_to_repo
- * @property-read string $web_url
- * @property-read string $default_branch
- * @property-read bool $private
- * @property-read bool $public
- * @property-read bool $issues_enabled
- * @property-read bool $merge_requests_enabled
- * @property-read bool $wall_enabled
- * @property-read bool $wiki_enabled
- * @property-read bool $snippets_enabled
- * @property-read string $created_at
- * @property-read int $greatest_access_level
- * @property-read string $last_activity_at
- * @property-read string $tag_list
- * @property-read User $owner
- * @property-read ProjectNamespace $namespace
- */
-class Project extends AbstractModel
-{
-    /**
-     * @var array
-     */
-    protected static $properties = array(
-        'id',
-        'code',
-        'name',
-        'name_with_namespace',
-        'namespace',
-        'description',
-        'path',
-        'path_with_namespace',
-        'ssh_url_to_repo',
-        'http_url_to_repo',
-        'web_url',
-        'default_branch',
-        'owner',
-        'private',
-        'public',
-        'issues_enabled',
-        'merge_requests_enabled',
-        'wall_enabled',
-        'wiki_enabled',
-        'created_at',
-        'greatest_access_level',
-        'last_activity_at',
-        'snippets_enabled',
-        'tag_list'
-    );
-
-    /**
-     * @param Client $client
-     * @param array $data
-     * @return Project
-     */
-    public static function fromArray(Client $client, array $data)
-    {
-        $project = new static($data['id']);
-        $project->setClient($client);
-
-        if (isset($data['owner'])) {
-            $data['owner'] = User::fromArray($client, $data['owner']);
-        }
-
-        if (isset($data['namespace']) && is_array($data['namespace'])) {
-            $data['namespace'] = ProjectNamespace::fromArray($client, $data['namespace']);
-        }
-
-        return $project->hydrate($data);
-    }
-
-    /**
-     * @param Client $client
-     * @param string $name
-     * @param array $params
-     * @return Project
-     */
-    public static function create(Client $client, $name, array $params = array())
-    {
-        $data = $client->api('projects')->create($name, $params);
-
-        return static::fromArray($client, $data);
-    }
-
-    /**
-     * @param int $user_id
-     * @param Client $client
-     * @param string $name
-     * @param array $params
-     * @return Project
-     */
-    public static function createForUser($user_id, Client $client, $name, array $params = array())
-    {
-        $data = $client->api('projects')->createForUser($user_id, $name, $params);
-
-        return static::fromArray($client, $data);
-    }
-    /**
-     * @param int $id
-     * @param Client $client
-     */
-    public function __construct($id = null, Client $client = null)
-    {
-        $this->setClient($client);
-        $this->setData('id', $id);
-    }
-
-    /**
-     * @return Project
-     */
-    public function show()
-    {
-        $data = $this->api('projects')->show($this->id);
-
-        return static::fromArray($this->getClient(), $data);
-    }
-
-    /**
-     * @param array $params
-     * @return Project
-     */
-    public function update(array $params)
-    {
-        $data = $this->api('projects')->update($this->id, $params);
-
-        return static::fromArray($this->getClient(), $data);
-    }
-    
-    /**
-     * @return Project
-     */
-    public function archive()
-    {
-        $data = $this->api("projects")->archive($this->id);
-        
-        return static::fromArray($this->getClient(), $data);
-    }
-    
-    /**
-     * @return Project
-     */
-    public function unarchive()
-    {
-        $data = $this->api("projects")->unarchive($this->id);
-        
-        return static::fromArray($this->getClient(), $data);
-    }
-
-    /**
-     * @return bool
-     */
-    public function remove()
-    {
-        $this->api('projects')->remove($this->id);
-
-        return true;
-    }
-
-    /**
-     * @param string $username_query
-     * @return User[]
-     */
-    public function members($username_query = null)
-    {
-        $data = $this->api('projects')->members($this->id, $username_query);
-
-        $members = array();
-        foreach ($data as $member) {
-            $members[] = User::fromArray($this->getClient(), $member);
-        }
-
-        return $members;
-    }
-
-    /**
-     * @param int $user_id
-     * @return User
-     */
-    public function member($user_id)
-    {
-        $data = $this->api('projects')->member($this->id, $user_id);
-
-        return User::fromArray($this->getClient(), $data);
-    }
-
-    /**
-     * @param int $user_id
-     * @param int $access_level
-     * @return User
-     */
-    public function addMember($user_id, $access_level)
-    {
-        $data = $this->api('projects')->addMember($this->id, $user_id, $access_level);
-
-        return User::fromArray($this->getClient(), $data);
-    }
-
-    /**
-     * @param int $user_id
-     * @param int $access_level
-     * @return User
-     */
-    public function saveMember($user_id, $access_level)
-    {
-        $data = $this->api('projects')->saveMember($this->id, $user_id, $access_level);
-
-        return User::fromArray($this->getClient(), $data);
-    }
-
-    /**
-     * @param int $user_id
-     * @return bool
-     */
-    public function removeMember($user_id)
-    {
-        $this->api('projects')->removeMember($this->id, $user_id);
-
-        return true;
-    }
-
-    /**
-     * @param int $page
-     * @param int $per_page
-     * @return ProjectHook[]
-     */
-    public function hooks($page = 1, $per_page = Api::PER_PAGE)
-    {
-        $data = $this->api('projects')->hooks($this->id, $page, $per_page);
-
-        $hooks = array();
-        foreach ($data as $hook) {
-            $hooks[] = ProjectHook::fromArray($this->getClient(), $this, $hook);
-        }
-
-        return $hooks;
-    }
-
-    /**
-     * @param int $id
-     * @return ProjectHook
-     */
-    public function hook($id)
-    {
-        $hook = new ProjectHook($this, $id, $this->getClient());
-
-        return $hook->show();
-    }
-
-    /**
-     * @param string $url
-     * @param array $events
-     * @return ProjectHook
-     */
-    public function addHook($url, array $events = array())
-    {
-        $data = $this->api('projects')->addHook($this->id, $url, $events);
-
-        return ProjectHook::fromArray($this->getClient(), $this, $data);
-    }
-
-    /**
-     * @param int $hook_id
-     * @param array $params
-     * @return mixed
-     */
-    public function updateHook($hook_id, array $params)
-    {
-        $hook = new ProjectHook($this, $hook_id, $this->getClient());
-
-        return $hook->update($params);
-    }
-
-    /**
-     * @param int $hook_id
-     * @return bool
-     */
-    public function removeHook($hook_id)
-    {
-        $hook = new ProjectHook($this, $hook_id, $this->getClient());
-
-        return $hook->delete();
-    }
-
-    /**
-     * @return Key[]
-     */
-    public function keys()
-    {
-        $data = $this->api('projects')->keys($this->id);
-
-        $keys = array();
-        foreach ($data as $key) {
-            $keys[] = Key::fromArray($this->getClient(), $key);
-        }
-
-        return $keys;
-    }
-
-    /**
-     * @param int $key_id
-     * @return Key
-     */
-    public function key($key_id)
-    {
-        $data = $this->api('projects')->key($this->id, $key_id);
-
-        return Key::fromArray($this->getClient(), $data);
-    }
-
-    /**
-     * @param string $title
-     * @param string $key
-     * @return Key
-     */
-    public function addKey($title, $key)
-    {
-        $data = $this->api('projects')->addKey($this->id, $title, $key);
-
-        return Key::fromArray($this->getClient(), $data);
-    }
-
-    /**
-     * @param string $key_id
-     * @return bool
-     */
-    public function removeKey($key_id)
-    {
-        $this->api('projects')->removeKey($this->id, $key_id);
-
-        return true;
-    }
-
-    /**
-     * @param string $key_id
-     * @return bool
-     */
-    public function enableKey($key_id)
-    {
-        $this->api('projects')->enableKey($this->id, $key_id);
-
-        return true;
-    }
-
-    /**
-     * @param string $key_id
-     * @return bool
-     */
-    public function disableKey($key_id)
-    {
-        $this->api('projects')->disableKey($this->id, $key_id);
-
-        return true;
-    }
-
-    /**
-     * @param string $name
-     * @param string $ref
-     * @return Branch
-     */
-    public function createBranch($name, $ref)
-    {
-        $data = $this->api('repositories')->createBranch($this->id, $name, $ref);
-
-        return Branch::fromArray($this->getClient(), $this, $data);
-    }
-
-    /**
-     * @param string $name
-     * @return bool
-     */
-    public function deleteBranch($name)
-    {
-        $this->api('repositories')->deleteBranch($this->id, $name);
-
-        return true;
-    }
-
-    /**
-     * @return Branch[]
-     */
-    public function branches()
-    {
-        $data = $this->api('repo')->branches($this->id);
-
-        $branches = array();
-        foreach ($data as $branch) {
-            $branches[] = Branch::fromArray($this->getClient(), $this, $branch);
-        }
-
-        return $branches;
-    }
-
-    /**
-     * @param string $branch_name
-     * @return Branch
-     */
-    public function branch($branch_name)
-    {
-        $branch = new Branch($this, $branch_name);
-        $branch->setClient($this->getClient());
-
-        return $branch->show();
-    }
-
-    /**
-     * @param string $branch_name
-     * @param bool $devPush
-     * @param bool $devMerge
-     * @return Branch
-     */
-    public function protectBranch($branch_name, $devPush = false, $devMerge = false)
-    {
-        $branch = new Branch($this, $branch_name);
-        $branch->setClient($this->getClient());
-
-        return $branch->protect($devPush, $devMerge);
-    }
-
-    /**
-     * @param string $branch_name
-     * @return Branch
-     */
-    public function unprotectBranch($branch_name)
-    {
-        $branch = new Branch($this, $branch_name);
-        $branch->setClient($this->getClient());
-
-        return $branch->unprotect();
-    }
-
-    /**
-     * @return Tag[]
-     */
-    public function tags()
-    {
-        $data = $this->api('repo')->tags($this->id);
-
-        $tags = array();
-        foreach ($data as $tag) {
-            $tags[] = Tag::fromArray($this->getClient(), $this, $tag);
-        }
-
-        return $tags;
-    }
-
-    /**
-     * @param int $page
-     * @param int $per_page
-     * @param string $ref_name
-     * @return Commit[]
-     */
-    public function commits($page = 0, $per_page = Api::PER_PAGE, $ref_name = null)
-    {
-        $data = $this->api('repo')->commits($this->id, $page, $per_page, $ref_name);
-
-        $commits = array();
-        foreach ($data as $commit) {
-            $commits[] = Commit::fromArray($this->getClient(), $this, $commit);
-        }
-
-        return $commits;
-    }
-
-    /**
-     * @param string $sha
-     * @return Commit
-     */
-    public function commit($sha)
-    {
-        $data = $this->api('repo')->commit($this->id, $sha);
-
-        return Commit::fromArray($this->getClient(), $this, $data);
-    }
-
-    /**
-     * @param string $ref
-     * @param int $page
-     * @param int $per_page
-     * @return Commit[]
-     */
-    public function commitComments($ref, $page = 0, $per_page = Api::PER_PAGE)
-    {
-        $data = $this->api('repo')->commitComments($this->id, $ref, $page, $per_page);
-
-        $comments = array();
-        foreach ($data as $comment) {
-            $comments[] = CommitNote::fromArray($this->getClient(), $comment);
-        }
-
-        return $comments;
-    }
-
-    /**
-     * @param string $ref
-     * @param string $note
-     * @param array $params
-     * @return CommitNote
-     */
-    public function createCommitComment($ref, $note, array $params = array())
-    {
-        $data = $this->api('repo')->createCommitComment($this->id, $ref, $note, $params);
-
-        return CommitNote::fromArray($this->getClient(), $data);
-    }
-
-    /**
-     * @param string $sha
-     * @return string
-     */
-    public function diff($sha)
-    {
-        return $this->api('repo')->diff($this->id, $sha);
-    }
-
-    /**
-     * @param string $from
-     * @param string $to
-     * @return Comparison
-     */
-    public function compare($from, $to)
-    {
-        $data = $this->api('repo')->compare($this->id, $from, $to);
-
-        return Comparison::fromArray($this->getClient(), $this, $data);
-    }
-
-    /**
-     * @param array $params
-     * @return Node[]
-     */
-    public function tree(array $params = array())
-    {
-        $data = $this->api('repo')->tree($this->id, $params);
-
-        $tree = array();
-        foreach ($data as $node) {
-            $tree[] = Node::fromArray($this->getClient(), $this, $node);
-        }
-
-        return $tree;
-    }
-
-    /**
-     * @param string $sha
-     * @param string $filepath
-     * @return string
-     */
-    public function blob($sha, $filepath)
-    {
-        return $this->api('repo')->blob($this->id, $sha, $filepath);
-    }
-
-    /**
-     * @param $sha
-     * @param $filepath
-     *
-     * @return array
-     */
-    public function getFile($sha, $filepath)
-    {
-        return $this->api('repo')->getFile($this->id, $filepath, $sha);
-    }
-
-    /**
-     * @param string $file_path
-     * @param string $content
-     * @param string $branch_name
-     * @param string $commit_message
-     * @return File
-     */
-    public function createFile($file_path, $content, $branch_name, $commit_message)
-    {
-        $data = $this->api('repo')->createFile($this->id, $file_path, $content, $branch_name, $commit_message);
-
-        return File::fromArray($this->getClient(), $this, $data);
-    }
-
-    /**
-     * @param string $file_path
-     * @param string $content
-     * @param string $branch_name
-     * @param string $commit_message
-     * @return File
-     */
-    public function updateFile($file_path, $content, $branch_name, $commit_message)
-    {
-        $data = $this->api('repo')->updateFile($this->id, $file_path, $content, $branch_name, $commit_message);
-
-        return File::fromArray($this->getClient(), $this, $data);
-    }
-
-    /**
-     * @param string $file_path
-     * @param string $branch_name
-     * @param string $commit_message
-     * @return bool
-     */
-    public function deleteFile($file_path, $branch_name, $commit_message)
-    {
-        $this->api('repo')->deleteFile($this->id, $file_path, $branch_name, $commit_message);
-
-        return true;
-    }
-
-    /**
-     * @param int $page
-     * @param int $per_page
-     * @return Event[]
-     */
-    public function events($page = 1, $per_page = Api::PER_PAGE)
-    {
-        $data = $this->api('projects')->events($this->id, $page, $per_page);
-
-        $events = array();
-        foreach ($data as $event) {
-            $events[] = Event::fromArray($this->getClient(), $this, $event);
-        }
-
-        return $events;
-    }
-
-    /**
-     * @param int    $page
-     * @param int    $per_page
-     * @param string $state
-     * @return MergeRequest[]
-     */
-    public function mergeRequests($page = 1, $per_page = Api::PER_PAGE, $state = MergeRequests::STATE_ALL)
-    {
-        $data = $this->api('mr')->$state($this->id, $page, $per_page);
-
-        $mrs = array();
-        foreach ($data as $mr) {
-            $mrs[] = MergeRequest::fromArray($this->getClient(), $this, $mr);
-        }
-
-        return $mrs;
-    }
-
-    /**
-     * @param int $id
-     * @return MergeRequest
-     */
-    public function mergeRequest($id)
-    {
-        $mr = new MergeRequest($this, $id, $this->getClient());
-
-        return $mr->show();
-    }
-
-    /**
-     * @param string $source
-     * @param string $target
-     * @param string $title
-     * @param int $assignee
-     * @param string $description
-     * @return MergeRequest
-     */
-    public function createMergeRequest($source, $target, $title, $assignee = null, $description = null)
-    {
-        $data = $this->api('mr')->create($this->id, $source, $target, $title, $assignee, null, $description);
-
-        return MergeRequest::fromArray($this->getClient(), $this, $data);
-    }
-
-    /**
-     * @param int $id
-     * @param array $params
-     * @return MergeRequest
-     */
-    public function updateMergeRequest($id, array $params)
-    {
-        $mr = new MergeRequest($this, $id, $this->getClient());
-
-        return $mr->update($params);
-    }
-
-    /**
-     * @param int $id
-     * @return MergeRequest
-     */
-    public function closeMergeRequest($id)
-    {
-        $mr = new MergeRequest($this, $id, $this->getClient());
-
-        return $mr->close();
-    }
-
-    /**
-     * @param int $id
-     * @return MergeRequest
-     */
-    public function openMergeRequest($id)
-    {
-        $mr = new MergeRequest($this, $id, $this->getClient());
-
-        return $mr->reopen();
-    }
-
-    /**
-     * @param int $id
-     * @return MergeRequest
-     */
-    public function mergeMergeRequest($id)
-    {
-        $mr = new MergeRequest($this, $id, $this->getClient());
-
-        return $mr->merge();
-    }
-
-    /**
-     * @param int $page
-     * @param int $per_page
-     * @return Issue[]
-     */
-    public function issues($page = 1, $per_page = Api::PER_PAGE)
-    {
-        $data = $this->api('issues')->all($this->id, $page, $per_page);
-
-        $issues = array();
-        foreach ($data as $issue) {
-            $issues[] = Issue::fromArray($this->getClient(), $this, $issue);
-        }
-
-        return $issues;
-    }
-
-    /**
-     * @param string $title
-     * @param array $params
-     * @return Issue
-     */
-    public function createIssue($title, array $params = array())
-    {
-        $params['title'] = $title;
-        $data = $this->api('issues')->create($this->id, $params);
-
-        return Issue::fromArray($this->getClient(), $this, $data);
-    }
-
-    /**
-     * @param int $id
-     * @return Issue
-     */
-    public function issue($id)
-    {
-        $issue = new Issue($this, $id, $this->getClient());
-
-        return $issue->show();
-    }
-
-    /**
-     * @param int $id
-     * @param array $params
-     * @return Issue
-     */
-    public function updateIssue($id, array $params)
-    {
-        $issue = new Issue($this, $id, $this->getClient());
-
-        return $issue->update($params);
-    }
-
-    /**
-     * @param int $id
-     * @param string $comment
-     * @return Issue
-     */
-    public function closeIssue($id, $comment = null)
-    {
-        $issue = new Issue($this, $id, $this->getClient());
-
-        return $issue->close($comment);
-    }
-
-    /**
-     * @param int $id
-     * @return Issue
-     */
-    public function openIssue($id)
-    {
-        $issue = new Issue($this, $id, $this->getClient());
-
-        return $issue->open();
-    }
-
-    /**
-     * @param int $page
-     * @param int $per_page
-     * @return Milestone[]
-     */
-    public function milestones($page = 1, $per_page = Api::PER_PAGE)
-    {
-        $data = $this->api('milestones')->all($this->id, $page, $per_page);
-
-        $milestones = array();
-        foreach ($data as $milestone) {
-            $milestones[] = Milestone::fromArray($this->getClient(), $this, $milestone);
-        }
-
-        return $milestones;
-    }
-
-    /**
-     * @param string $title
-     * @param array $params
-     * @return Milestone
-     */
-    public function createMilestone($title, array $params = array())
-    {
-        $params['title'] = $title;
-        $data = $this->api('milestones')->create($this->id, $params);
-
-        return Milestone::fromArray($this->getClient(), $this, $data);
-    }
-
-    /**
-     * @param int $id
-     * @return Milestone
-     */
-    public function milestone($id)
-    {
-        $milestone = new Milestone($this, $id, $this->getClient());
-
-        return $milestone->show();
-    }
-
-    /**
-     * @param int $id
-     * @param array $params
-     * @return Milestone
-     */
-    public function updateMilestone($id, array $params)
-    {
-        $milestone = new Milestone($this, $id, $this->getClient());
-
-        return $milestone->update($params);
-    }
-
-    /**
-     * @param int $id
-     * @return Issue[]
-     */
-    public function milestoneIssues($id)
-    {
-        $milestone = new Milestone($this, $id, $this->getClient());
-
-        return $milestone->issues();
-    }
-
-    /**
-     * @return Snippet[]
-     */
-    public function snippets()
-    {
-        $data = $this->api('snippets')->all($this->id);
-
-        $snippets = array();
-        foreach ($data as $snippet) {
-            $snippets[] = Snippet::fromArray($this->getClient(), $this, $snippet);
-        }
-
-        return $snippets;
-    }
-
-    /**
-     * @param string $title
-     * @param string $filename
-     * @param string $code
-     * @param string $lifetime
-     * @return Snippet
-     */
-    public function createSnippet($title, $filename, $code, $lifetime = null)
-    {
-        $data = $this->api('snippets')->create($this->id, $title, $filename, $code, $lifetime);
-
-        return Snippet::fromArray($this->getClient(), $this, $data);
-    }
-
-    /**
-     * @param int $id
-     * @return Snippet
-     */
-    public function snippet($id)
-    {
-        $snippet = new Snippet($this, $id, $this->getClient());
-
-        return $snippet->show();
-    }
-
-    /**
-     * @param int $id
-     * @return Snippet
-     */
-    public function snippetContent($id)
-    {
-        $snippet = new Snippet($this, $id, $this->getClient());
-
-        return $snippet->content();
-    }
-
-    /**
-     * @param int $id
-     * @param array $params
-     * @return Snippet
-     */
-    public function updateSnippet($id, array $params)
-    {
-        $snippet = new Snippet($this, $id, $this->getClient());
-
-        return $snippet->update($params);
-    }
-
-    /**
-     * @param int $id
-     * @return bool
-     */
-    public function removeSnippet($id)
-    {
-        $snippet = new Snippet($this, $id, $this->getClient());
-
-        return $snippet->remove();
-    }
-
-    /**
-     * @param int $group_id
-     * @return Group
-     */
-    public function transfer($group_id)
-    {
-        $group = new Group($group_id, $this->getClient());
-
-        return $group->transfer($this->id);
-    }
-
-    /**
-     * @param int $id
-     * @return Project
-     */
-    public function forkTo($id)
-    {
-        $data = $this->api('projects')->createForkRelation($id, $this->id);
-
-        return Project::fromArray($this->getClient(), $data);
-    }
-
-    /**
-     * @param int $id
-     * @return Project
-     */
-    public function forkFrom($id)
-    {
-        return $this->createForkRelation($id);
-    }
-
-    /**
-     * @param int $id
-     * @return Project
-     */
-    public function createForkRelation($id)
-    {
-        $data = $this->api('projects')->createForkRelation($this->id, $id);
-
-        return Project::fromArray($this->getClient(), $data);
-    }
-
-    /**
-     * @return bool
-     */
-    public function removeForkRelation()
-    {
-        $this->api('projects')->removeForkRelation($this->id);
-
-        return true;
-    }
-
-    /**
-     * @param string $service_name
-     * @param array $params
-     * @return bool
-     */
-    public function setService($service_name, array $params = array())
-    {
-        $this->api('projects')->setService($this->id, $service_name, $params);
-
-        return true;
-    }
-
-    /**
-     * @param string $service_name
-     * @return bool
-     */
-    public function removeService($service_name)
-    {
-        $this->api('projects')->removeService($this->id, $service_name);
-
-        return true;
-    }
-
-    /**
-     * @return Label[]
-     */
-    public function labels()
-    {
-        $data = $this->api('projects')->labels($this->id);
-
-        $labels = array();
-        foreach ($data as $label) {
-            $labels[] = Label::fromArray($this->getClient(), $this, $label);
-        }
-
-        return $labels;
-    }
-
-    /**
-     * @param string $name
-     * @param string $color
-     * @return Label
-     */
-    public function addLabel($name, $color)
-    {
-        $data = $this->api('projects')->addLabel($this->id, array(
-            'name' => $name,
-            'color' => $color
-        ));
-
-        return Label::fromArray($this->getClient(), $this, $data);
-    }
-
-    /**
-     * @param string $name
-     * @param array $params
-     * @return Label
-     */
-    public function updateLabel($name, array $params)
-    {
-        if (isset($params['name'])) {
-            $params['new_name'] = $params['name'];
-        }
-
-        $params['name'] = $name;
-
-        $data = $this->api('projects')->updateLabel($this->id, $params);
-
-        return Label::fromArray($this->getClient(), $this, $data);
-    }
-
-    /**
-     * @param string $name
-     * @return bool
-     */
-    public function removeLabel($name)
-    {
-        $this->api('projects')->removeLabel($this->id, $name);
-
-        return true;
-    }
-
-    /**
-     * @return array
-     */
-    public function contributors()
-    {
-        $data = $this->api('repo')->contributors($this->id);
-
-        $contributors = array();
-        foreach ($data as $contributor) {
-            $contributors[] = Contributor::fromArray($this->getClient(), $this, $contributor);
-        }
-
-        return $contributors;
-    }
-}
diff --git a/lib/Gitlab/Model/ProjectHook.php b/lib/Gitlab/Model/ProjectHook.php
deleted file mode 100644
index 3da0a7c02..000000000
--- a/lib/Gitlab/Model/ProjectHook.php
+++ /dev/null
@@ -1,100 +0,0 @@
-<?php namespace Gitlab\Model;
-
-use Gitlab\Client;
-
-/**
- * Class ProjectHook
- *
- * @property-read int $id
- * @property-read string $url
- * @property-read int $project_id
- * @property-read bool $push_events
- * @property-read bool $issues_events
- * @property-read bool $merge_requests_events
- * @property-read bool $build_events
- * @property-read bool $tag_push_events
- * @property-read string $created_at
- * @property-read Project $project
- */
-class ProjectHook extends AbstractModel
-{
-    /**
-     * @var array
-     */
-    protected static $properties = array(
-        'id',
-        'project',
-        'url',
-        'project_id',
-        'push_events',
-        'issues_events',
-        'merge_requests_events',
-        'build_events',
-        'tag_push_events',
-        'created_at'
-    );
-
-    /**
-     * @param Client  $client
-     * @param Project $project
-     * @param array   $data
-     * @return ProjectHook
-     */
-    public static function fromArray(Client $client, Project $project, array $data)
-    {
-        $hook = new static($project, $data['id'], $client);
-
-        return $hook->hydrate($data);
-    }
-
-    /**
-     * @param Project $project
-     * @param int $id
-     * @param Client $client
-     */
-    public function __construct(Project $project, $id, Client $client = null)
-    {
-        $this->setClient($client);
-        $this->setData('project', $project);
-        $this->setData('id', $id);
-    }
-
-    /**
-     * @return ProjectHook
-     */
-    public function show()
-    {
-        $data = $this->api('projects')->hook($this->project->id, $this->id);
-
-        return static::fromArray($this->getClient(), $this->project, $data);
-    }
-
-    /**
-     * @return bool
-     */
-    public function delete()
-    {
-        $this->api('projects')->removeHook($this->project->id, $this->id);
-
-        return true;
-    }
-
-    /**
-     * @return bool
-     */
-    public function remove()
-    {
-        return $this->delete();
-    }
-
-    /**
-     * @param array $params
-     * @return ProjectHook
-     */
-    public function update(array $params)
-    {
-        $data = $this->api('projects')->updateHook($this->project->id, $this->id, $params);
-
-        return static::fromArray($this->getClient(), $this->project, $data);
-    }
-}
diff --git a/lib/Gitlab/Model/ProjectNamespace.php b/lib/Gitlab/Model/ProjectNamespace.php
deleted file mode 100644
index 9e3aee4ed..000000000
--- a/lib/Gitlab/Model/ProjectNamespace.php
+++ /dev/null
@@ -1,55 +0,0 @@
-<?php namespace Gitlab\Model;
-
-use Gitlab\Client;
-
-/**
- * Class ProjectNamespace
- *
- * @property-read int $id
- * @property-read string $name
- * @property-read string $path
- * @property-read string $kind
- * @property-read int $owner_id
- * @property-read string $created_at
- * @property-read string $updated_at
- * @property-read string $description
- */
-class ProjectNamespace extends AbstractModel
-{
-    /**
-     * @var array
-     */
-    protected static $properties = array(
-        'id',
-        'name',
-        'path',
-        'kind',
-        'owner_id',
-        'created_at',
-        'updated_at',
-        'description'
-    );
-
-    /**
-     * @param Client $client
-     * @param array  $data
-     * @return ProjectNamespace
-     */
-    public static function fromArray(Client $client, array $data)
-    {
-        $project = new static($data['id']);
-        $project->setClient($client);
-
-        return $project->hydrate($data);
-    }
-
-    /**
-     * @param int $id
-     * @param Client $client
-     */
-    public function __construct($id = null, Client $client = null)
-    {
-        $this->setClient($client);
-        $this->setData('id', $id);
-    }
-}
diff --git a/lib/Gitlab/Model/Session.php b/lib/Gitlab/Model/Session.php
deleted file mode 100644
index f9b8177be..000000000
--- a/lib/Gitlab/Model/Session.php
+++ /dev/null
@@ -1,70 +0,0 @@
-<?php namespace Gitlab\Model;
-
-use Gitlab\Client;
-
-/**
- * Class Session
- *
- * @property-read int $id
- * @property-read string $email
- * @property-read string $name
- * @property-read string $private_token
- * @property-read string $created_at
- * @property-read bool $blocked
- */
-class Session extends AbstractModel
-{
-    /**
-     * @var array
-     */
-    protected static $properties = array(
-        'id',
-        'email',
-        'name',
-        'private_token',
-        'created_at',
-        'blocked'
-    );
-
-    /**
-     * @param Client $client
-     * @param array  $data
-     * @return Session
-     */
-    public static function fromArray(Client $client, array $data)
-    {
-        $session = new static($client);
-
-        return $session->hydrate($data);
-    }
-
-    /**
-     * @param Client $client
-     */
-    public function __construct(Client $client = null)
-    {
-        $this->setClient($client);
-    }
-
-    /**
-     * @return User
-     */
-    public function me()
-    {
-        $data = $this->api('users')->show();
-
-        return User::fromArray($this->getClient(), $data);
-    }
-
-    /**
-     * @param string $email
-     * @param string $password
-     * @return $this
-     */
-    public function login($email, $password)
-    {
-        $data = $this->api('users')->session($email, $password);
-
-        return $this->hydrate($data);
-    }
-}
diff --git a/lib/Gitlab/Model/Snippet.php b/lib/Gitlab/Model/Snippet.php
deleted file mode 100644
index 5d20e0dbf..000000000
--- a/lib/Gitlab/Model/Snippet.php
+++ /dev/null
@@ -1,100 +0,0 @@
-<?php namespace Gitlab\Model;
-
-use Gitlab\Client;
-
-/**
- * Class Snippet
- *
- * @property-read int $id
- * @property-read string $title
- * @property-read string $file_name
- * @property-read string $expires_at
- * @property-read string $updated_at
- * @property-read string $created_at
- * @property-read Project $project
- * @property-read User $author
- */
-class Snippet extends AbstractModel
-{
-    /**
-     * @var array
-     */
-    protected static $properties = array(
-        'id',
-        'title',
-        'file_name',
-        'author',
-        'expires_at',
-        'updated_at',
-        'created_at',
-        'project'
-    );
-
-    /**
-     * @param Client  $client
-     * @param Project $project
-     * @param array   $data
-     * @return Snippet
-     */
-    public static function fromArray(Client $client, Project $project, array $data)
-    {
-        $snippet = new static($project, $data['id'], $client);
-
-        if (isset($data['author'])) {
-            $data['author'] = User::fromArray($client, $data['author']);
-        }
-
-        return $snippet->hydrate($data);
-    }
-
-    /**
-     * @param Project $project
-     * @param int $id
-     * @param Client $client
-     */
-    public function __construct(Project $project, $id = null, Client $client = null)
-    {
-        $this->setClient($client);
-        $this->setData('project', $project);
-        $this->setData('id', $id);
-    }
-
-    /**
-     * @return Snippet
-     */
-    public function show()
-    {
-        $data = $this->api('snippets')->show($this->project->id, $this->id);
-
-        return static::fromArray($this->getClient(), $this->project, $data);
-    }
-
-    /**
-     * @param array $params
-     * @return Snippet
-     */
-    public function update(array $params)
-    {
-        $data = $this->api('snippets')->update($this->project->id, $this->id, $params);
-
-        return static::fromArray($this->getClient(), $this->project, $data);
-    }
-
-    /**
-     * @return string
-     */
-    public function content()
-    {
-        return $this->api('snippets')->content($this->project->id, $this->id);
-    }
-
-    /**
-     * @return bool
-     */
-    public function remove()
-    {
-        $this->api('snippets')->remove($this->project->id, $this->id);
-
-        return true;
-    }
-}
diff --git a/lib/Gitlab/Model/Tag.php b/lib/Gitlab/Model/Tag.php
deleted file mode 100644
index 6028b3da5..000000000
--- a/lib/Gitlab/Model/Tag.php
+++ /dev/null
@@ -1,53 +0,0 @@
-<?php namespace Gitlab\Model;
-
-use Gitlab\Client;
-
-/**
- * Class Tag
- *
- * @property-read string $name
- * @property-read bool $protected
- * @property-read Commit $commit
- * @property-read Project $project
- */
-class Tag extends AbstractModel
-{
-    /**
-     * @var array
-     */
-    protected static $properties = array(
-        'name',
-        'commit',
-        'project',
-        'protected'
-    );
-
-    /**
-     * @param Client  $client
-     * @param Project $project
-     * @param array   $data
-     * @return Tag
-     */
-    public static function fromArray(Client $client, Project $project, array $data)
-    {
-        $branch = new static($project, $data['name'], $client);
-
-        if (isset($data['commit'])) {
-            $data['commit'] = Commit::fromArray($client, $project, $data['commit']);
-        }
-
-        return $branch->hydrate($data);
-    }
-
-    /**
-     * @param Project $project
-     * @param string $name
-     * @param Client $client
-     */
-    public function __construct(Project $project, $name = null, Client $client = null)
-    {
-        $this->setClient($client);
-        $this->setData('project', $project);
-        $this->setData('name', $name);
-    }
-}
diff --git a/lib/Gitlab/Model/User.php b/lib/Gitlab/Model/User.php
deleted file mode 100644
index 5ffffc16e..000000000
--- a/lib/Gitlab/Model/User.php
+++ /dev/null
@@ -1,227 +0,0 @@
-<?php namespace Gitlab\Model;
-
-use Gitlab\Client;
-
-/**
- * Class User
- *
- * @property-read int $id
- * @property-read string $email
- * @property-read string $password
- * @property-read string $username
- * @property-read string $name
- * @property-read string $bio
- * @property-read string $skype
- * @property-read string $linkedin
- * @property-read string $twitter
- * @property-read bool $dark_scheme
- * @property-read int $theme_id
- * @property-read int $color_scheme_id
- * @property-read bool $blocked
- * @property-read int $access_level
- * @property-read string $created_at
- * @property-read string $extern_uid
- * @property-read string $provider
- * @property-read string $state
- * @property-read bool $is_admin
- * @property-read bool $can_create_group
- * @property-read bool $can_create_project
- * @property-read string $avatar_url
- * @property-read string $current_sign_in_at
- * @property-read bool $two_factor_enabled
- */
-class User extends AbstractModel
-{
-    /**
-     * @var array
-     */
-    protected static $properties = array(
-        'id',
-        'email',
-        'password',
-        'username',
-        'name',
-        'bio',
-        'skype',
-        'linkedin',
-        'twitter',
-        'dark_scheme',
-        'theme_id',
-        'color_scheme_id',
-        'blocked',
-        'projects_limit',
-        'access_level',
-        'created_at',
-        'extern_uid',
-        'provider',
-        'state',
-        'is_admin',
-        'can_create_group',
-        'can_create_project',
-        'avatar_url',
-        'current_sign_in_at',
-        'two_factor_enabled'
-    );
-
-    /**
-     * @param Client $client
-     * @param array  $data
-     * @return User
-     */
-    public static function fromArray(Client $client, array $data)
-    {
-        $id = isset($data['id']) ? $data['id'] : 0;
-
-        $user = new static($id, $client);
-
-        return $user->hydrate($data);
-    }
-
-    /**
-     * @param Client $client
-     * @param string $email
-     * @param string $password
-     * @param array  $params
-     * @return User
-     */
-    public static function create(Client $client, $email, $password, array $params = array())
-    {
-        $data = $client->api('users')->create($email, $password, $params);
-
-        return static::fromArray($client, $data);
-    }
-
-    /**
-     * @param int $id
-     * @param Client $client
-     */
-    public function __construct($id = null, Client $client = null)
-    {
-        $this->setClient($client);
-        $this->setData('id', $id);
-    }
-
-    /**
-     * @return User
-     */
-    public function show()
-    {
-        $data = $this->api('users')->show($this->id);
-
-        return static::fromArray($this->getClient(), $data);
-    }
-
-    /**
-     * @param array $params
-     * @return User
-     */
-    public function update(array $params)
-    {
-        $data = $this->api('users')->update($this->id, $params);
-
-        return static::fromArray($this->getClient(), $data);
-    }
-
-    /**
-     * @return bool
-     */
-    public function remove()
-    {
-        $this->api('users')->remove($this->id);
-
-        return true;
-    }
-
-    /**
-     * @return bool
-     */
-    public function block()
-    {
-        $this->api('users')->block($this->id);
-
-        return true;
-    }
-
-    /**
-     * @return bool
-     */
-    public function unblock()
-    {
-        $this->api('users')->unblock($this->id);
-
-        return true;
-    }
-
-    /**
-     * @return Key[]
-     */
-    public function keys()
-    {
-        $data = $this->api('users')->keys();
-
-        $keys = array();
-        foreach ($data as $key) {
-            $keys[] = Key::fromArray($this->getClient(), $key);
-        }
-
-        return $keys;
-    }
-
-    /**
-     * @param string $title
-     * @param string $key
-     * @return Key
-     */
-    public function createKey($title, $key)
-    {
-        $data = $this->api('users')->createKey($title, $key);
-
-        return Key::fromArray($this->getClient(), $data);
-    }
-
-    /**
-     * @param string $title
-     * @param string $key
-     * @return Key
-     */
-    public function createKeyForUser($user_id, $title, $key)
-    {
-        $data = $this->api('users')->createKeyForUser($user_id, $title, $key);
-
-        return Key::fromArray($this->getClient(), $data);
-    }
-
-    /**
-     * @param int $id
-     * @return bool
-     */
-    public function removeKey($id)
-    {
-        $this->api('users')->removeKey($id);
-
-        return true;
-    }
-
-    /**
-     * @param int $group_id
-     * @param int $access_level
-     * @return User
-     */
-    public function addToGroup($group_id, $access_level)
-    {
-        $group = new Group($group_id, $this->getClient());
-
-        return $group->addMember($this->id, $access_level);
-    }
-
-    /**
-     * @param int $group_id
-     * @return bool
-     */
-    public function removeFromGroup($group_id)
-    {
-        $group = new Group($group_id, $this->getClient());
-
-        return $group->removeMember($this->id);
-    }
-}
diff --git a/lib/Gitlab/ResultPager.php b/lib/Gitlab/ResultPager.php
deleted file mode 100644
index 3cfb44d13..000000000
--- a/lib/Gitlab/ResultPager.php
+++ /dev/null
@@ -1,121 +0,0 @@
-<?php namespace Gitlab;
-
-use Gitlab\Api\ApiInterface;
-
-/**
- * Pager class for supporting pagination in Gitlab classes
- */
-class ResultPager implements ResultPagerInterface
-{
-    /**
-     * @var \Gitlab\Client client
-     */
-    protected $client;
-
-    /**
-     * The Gitlab client to use for pagination. This must be the same
-     * instance that you got the Api instance from, i.e.:
-     *
-     * $client = new \Gitlab\Client();
-     * $api = $client->api('someApi');
-     * $pager = new \Gitlab\ResultPager($client);
-     *
-     * @param \Gitlab\Client $client
-     *
-     */
-    public function __construct(Client $client)
-    {
-        $this->client = $client;
-    }
-
-    /**
-     * {@inheritdoc}
-     */
-    public function fetch(ApiInterface $api, $method, array $parameters = array())
-    {
-        $result = call_user_func_array(array($api, $method), $parameters);
-
-        return $result;
-    }
-
-    /**
-     * {@inheritdoc}
-     */
-    public function fetchAll(ApiInterface $api, $method, array $parameters = array())
-    {
-        $result = call_user_func_array(array($api, $method), $parameters);
-
-        while ($this->hasNext()) {
-            $result = array_merge($result, $this->fetchNext());
-        }
-
-        return $result;
-    }
-
-    /**
-     * {@inheritdoc}
-     */
-    public function hasNext()
-    {
-        return $this->has('next');
-    }
-
-    /**
-     * {@inheritdoc}
-     */
-    public function fetchNext()
-    {
-        return $this->get('next');
-    }
-
-    /**
-     * {@inheritdoc}
-     */
-    public function hasPrevious()
-    {
-        return $this->has('prev');
-    }
-
-    /**
-     * {@inheritdoc}
-     */
-    public function fetchPrevious()
-    {
-        return $this->get('prev');
-    }
-
-    /**
-     * {@inheritdoc}
-     */
-    public function fetchFirst()
-    {
-        return $this->get('first');
-    }
-
-    /**
-     * {@inheritdoc}
-     */
-    public function fetchLast()
-    {
-        return $this->get('last');
-    }
-
-    /**
-     * {@inheritdoc}
-     */
-    protected function has($key)
-    {
-        return !empty($this->client->getHttpClient()->getLastResponse()->getPagination()) && isset($this->client->getHttpClient()->getLastResponse()->getPagination()[$key]);
-    }
-
-    /**
-     * {@inheritdoc}
-     */
-    protected function get($key)
-    {
-        if ($this->has($key)) {
-            $result = $this->client->getHttpClient()->get(strtr($this->client->getHttpClient()->getLastResponse()->getPagination()[$key], array($this->client->getBaseUrl() => '')))->getContent();
-            return $result;
-        }
-    }
-}
diff --git a/lib/Gitlab/ResultPagerInterface.php b/lib/Gitlab/ResultPagerInterface.php
deleted file mode 100644
index 0078580c1..000000000
--- a/lib/Gitlab/ResultPagerInterface.php
+++ /dev/null
@@ -1,68 +0,0 @@
-<?php namespace Gitlab;
-
-use Gitlab\Api\ApiInterface;
-
-/**
- * Pager interface
- */
-interface ResultPagerInterface
-{
-    /**
-     * Fetch a single result (page) from an api call
-     *
-     * @param ApiInterface $api        the Api instance
-     * @param string       $method     the method name to call on the Api instance
-     * @param array        $parameters the method parameters in an array
-     *
-     * @return array returns the result of the Api::$method() call
-     */
-    public function fetch(ApiInterface $api, $method, array $parameters = array());
-
-    /**
-     * Fetch all results (pages) from an api call
-     * Use with care - there is no maximum
-     *
-     * @param ApiInterface $api        the Api instance
-     * @param string       $method     the method name to call on the Api instance
-     * @param array        $parameters the method parameters in an array
-     *
-     * @return array returns a merge of the results of the Api::$method() call
-     */
-    public function fetchAll(ApiInterface $api, $method, array $parameters = array());
-
-    /**
-     * Check to determine the availability of a next page
-     * @return bool
-     */
-    public function hasNext();
-
-    /**
-     * Check to determine the availability of a previous page
-     * @return bool
-     */
-    public function hasPrevious();
-
-    /**
-     * Fetch the next page
-     * @return array
-     */
-    public function fetchNext();
-
-    /**
-     * Fetch the previous page
-     * @return array
-     */
-    public function fetchPrevious();
-
-    /**
-     * Fetch the first page
-     * @return array
-     */
-    public function fetchFirst();
-
-    /**
-     * Fetch the last page
-     * @return array
-     */
-    public function fetchLast();
-}
diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon
new file mode 100644
index 000000000..3ee843102
--- /dev/null
+++ b/phpstan-baseline.neon
@@ -0,0 +1,104 @@
+parameters:
+	ignoreErrors:
+		-
+			message: '#^Parameter \#1 \$callback of function set_error_handler expects \(callable\(int, string, string, int\)\: bool\)\|null, Closure\(\)\: void given\.$#'
+			identifier: argument.type
+			count: 1
+			path: src/Api/AbstractApi.php
+
+		-
+			message: '#^Parameter \#2 \$resource of method Http\\Message\\MultipartStream\\MultipartStreamBuilder\:\:addResource\(\) expects Psr\\Http\\Message\\StreamInterface\|resource\|string, mixed given\.$#'
+			identifier: argument.type
+			count: 1
+			path: src/Api/AbstractApi.php
+
+		-
+			message: '#^Parameter \#4 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#'
+			identifier: argument.type
+			count: 1
+			path: src/Api/AbstractApi.php
+
+		-
+			message: '#^Property Gitlab\\Api\\AbstractApi\:\:\$perPage \(int\|null\) is never assigned int so it can be removed from the property type\.$#'
+			identifier: property.unusedType
+			count: 1
+			path: src/Api/AbstractApi.php
+
+		-
+			message: '#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\.$#'
+			identifier: foreach.nonIterable
+			count: 1
+			path: src/Api/Groups.php
+
+		-
+			message: '#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\.$#'
+			identifier: foreach.nonIterable
+			count: 2
+			path: src/Api/Projects.php
+
+		-
+			message: '#^Parameter \#1 \$array of function array_values expects array\<T\>, mixed given\.$#'
+			identifier: argument.type
+			count: 1
+			path: src/Api/Projects.php
+
+		-
+			message: '#^Parameter \#1 \$options of method Symfony\\Component\\OptionsResolver\\OptionsResolver\:\:resolve\(\) expects array, mixed given\.$#'
+			identifier: argument.type
+			count: 1
+			path: src/Api/Repositories.php
+
+		-
+			message: '#^Parameter \#1 \$uri of static method Gitlab\\Api\\AbstractApi\:\:encodePath\(\) expects int\|string, mixed given\.$#'
+			identifier: argument.type
+			count: 3
+			path: src/Api/RepositoryFiles.php
+
+		-
+			message: '#^Parameter \#1 \$message of static method Gitlab\\HttpClient\\Message\\ResponseMediator\:\:getMessageAsString\(\) expects array, mixed given\.$#'
+			identifier: argument.type
+			count: 1
+			path: src/HttpClient/Message/ResponseMediator.php
+
+		-
+			message: '#^Parameter \#3 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#'
+			identifier: argument.type
+			count: 2
+			path: src/HttpClient/Message/ResponseMediator.php
+
+		-
+			message: '#^PHPDoc tag @return contains generic type Http\\Promise\\Promise\<Psr\\Http\\Message\\ResponseInterface\> but interface Http\\Promise\\Promise is not generic\.$#'
+			identifier: generics.notGeneric
+			count: 1
+			path: src/HttpClient/Plugin/Authentication.php
+
+		-
+			message: '#^PHPDoc tag @return contains generic type Http\\Promise\\Promise\<Psr\\Http\\Message\\ResponseInterface\> but interface Http\\Promise\\Promise is not generic\.$#'
+			identifier: generics.notGeneric
+			count: 1
+			path: src/HttpClient/Plugin/ExceptionThrower.php
+
+		-
+			message: '#^PHPDoc tag @var with type string is not subtype of native type non\-empty\-string\|false\.$#'
+			identifier: varTag.nativeType
+			count: 1
+			path: src/HttpClient/Util/JsonArray.php
+
+		-
+			message: '#^Cannot cast mixed to string\.$#'
+			identifier: cast.string
+			count: 1
+			path: src/HttpClient/Util/QueryStringBuilder.php
+
+		-
+			message: '#^PHPDoc tag @var with type Closure\(Gitlab\\Api\\AbstractApi\)\: Gitlab\\Api\\AbstractApi is not subtype of type Closure\(Gitlab\\Api\\AbstractApi\)\: Gitlab\\Api\\AbstractApi\.$#'
+			identifier: varTag.type
+			count: 1
+			path: src/ResultPager.php
+
+		-
+			message: '#^Variable method call on Gitlab\\Api\\AbstractApi\.$#'
+			identifier: method.dynamicName
+			count: 1
+			path: src/ResultPager.php
+
diff --git a/phpstan.neon.dist b/phpstan.neon.dist
new file mode 100644
index 000000000..40f6a875d
--- /dev/null
+++ b/phpstan.neon.dist
@@ -0,0 +1,16 @@
+includes:
+    - phpstan-baseline.neon
+    - vendor-bin/phpstan/vendor/phpstan/phpstan-deprecation-rules/rules.neon
+    - vendor-bin/phpstan/vendor/phpstan/phpstan-strict-rules/rules.neon
+
+parameters:
+    level: max
+    paths:
+        - src
+    ignoreErrors:
+        - '#Only booleans are allowed in an if condition#'
+        - '#PHPDoc tag \@var above a method has no effect.#'
+        - '#return type has no value type specified in iterable type array#'
+        - '#no value type specified in iterable type array#'
+        - '#expects array\<string, mixed\>, array given#'
+        - '#expects array\<string, string\>, array given#'
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index e01386f15..b689bdf47 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -1,25 +1,13 @@
 <?xml version="1.0" encoding="UTF-8"?>
-
-<phpunit backupGlobals="false"
-				 backupStaticAttributes="false"
-				 colors="true"
-				 convertErrorsToExceptions="true"
-				 convertNoticesToExceptions="true"
-				 convertWarningsToExceptions="true"
-				 processIsolation="false"
-				 stopOnFailure="false"
-				 syntaxCheck="false"
-				 bootstrap="test/bootstrap.php"
-	>
-	<testsuites>
-		<testsuite name="php-gitlab-api Test Suite">
-			<directory>./test/Gitlab/</directory>
-		</testsuite>
-	</testsuites>
-
-	<filter>
-		<whitelist>
-			<directory suffix=".php">./lib/Gitlab/</directory>
-		</whitelist>
-	</filter>
-</phpunit>
\ No newline at end of file
+<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" backupGlobals="false" beStrictAboutTestsThatDoNotTestAnything="true" beStrictAboutOutputDuringTests="true" bootstrap="vendor/autoload.php" colors="true" failOnRisky="true" failOnWarning="true" processIsolation="false" stopOnError="false" stopOnFailure="false" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd">
+  <testsuites>
+    <testsuite name="Test Suite">
+      <directory suffix="Test.php">./tests</directory>
+    </testsuite>
+  </testsuites>
+  <source>
+    <include>
+      <directory suffix=".php">./src</directory>
+    </include>
+  </source>
+</phpunit>
diff --git a/psalm-baseline.xml b/psalm-baseline.xml
new file mode 100644
index 000000000..3c19e346b
--- /dev/null
+++ b/psalm-baseline.xml
@@ -0,0 +1,35 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<files psalm-version="5.26.1@d747f6500b38ac4f7dfc5edbcae6e4b637d7add0">
+  <file src="src/Api/AbstractApi.php">
+    <InvalidArgument>
+      <code><![CDATA[function () use ($filename, $mode, &$ex): void {
+            $ex = new RuntimeException(\sprintf(
+                'Unable to open %s using mode %s: %s',
+                $filename,
+                $mode,
+                \func_get_args()[1]
+            ));
+        }]]></code>
+    </InvalidArgument>
+  </file>
+  <file src="src/Api/Projects.php">
+    <TypeDoesNotContainType>
+      <code><![CDATA['is_int']]></code>
+    </TypeDoesNotContainType>
+  </file>
+  <file src="src/HttpClient/Plugin/Authentication.php">
+    <TooManyTemplateParams>
+      <code><![CDATA[Promise<ResponseInterface>]]></code>
+    </TooManyTemplateParams>
+  </file>
+  <file src="src/HttpClient/Plugin/ExceptionThrower.php">
+    <TooManyTemplateParams>
+      <code><![CDATA[Promise<ResponseInterface>]]></code>
+    </TooManyTemplateParams>
+  </file>
+  <file src="src/ResultPager.php">
+    <InaccessibleProperty>
+      <code><![CDATA[$clone->perPage]]></code>
+    </InaccessibleProperty>
+  </file>
+</files>
diff --git a/psalm.xml b/psalm.xml
new file mode 100644
index 000000000..3dd74b68c
--- /dev/null
+++ b/psalm.xml
@@ -0,0 +1,18 @@
+<?xml version="1.0"?>
+<psalm
+    errorLevel="3"
+    resolveFromConfigFile="true"
+    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+    xmlns="https://getpsalm.org/schema/config"
+    xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd"
+    errorBaseline="psalm-baseline.xml"
+    findUnusedBaselineEntry="true"
+    findUnusedCode="false"
+>
+    <issueHandlers>
+        <PossiblyInvalidDocblockTag errorLevel="info" />
+    </issueHandlers>
+    <projectFiles>
+        <directory name="src" />
+    </projectFiles>
+</psalm>
diff --git a/src/Api/AbstractApi.php b/src/Api/AbstractApi.php
new file mode 100644
index 000000000..b0219ac42
--- /dev/null
+++ b/src/Api/AbstractApi.php
@@ -0,0 +1,370 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Api;
+
+use Gitlab\Client;
+use Gitlab\Exception\RuntimeException;
+use Gitlab\HttpClient\Message\ResponseMediator;
+use Gitlab\HttpClient\Util\JsonArray;
+use Gitlab\HttpClient\Util\QueryStringBuilder;
+use Http\Message\MultipartStream\MultipartStreamBuilder;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\StreamInterface;
+use Symfony\Component\OptionsResolver\OptionsResolver;
+
+/**
+ * @author Joseph Bielawski <stloyd@gmail.com>
+ * @author Matt Humphrey <matt@m4tt.co>
+ * @author Radu Topala <radu.topala@trisoft.ro>
+ */
+abstract class AbstractApi
+{
+    /**
+     * The URI prefix.
+     *
+     * @var string
+     */
+    private const URI_PREFIX = '/api/v4/';
+
+    /**
+     * The access levels for groups and projects
+     * as defined in the Gitlab::Access module.
+     *
+     * @see https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/access.rb
+     *
+     * @var array
+     */
+    protected const ACCESS_LEVELS = [0, 10, 20, 30, 40, 50];
+
+    private readonly Client $client;
+
+    private ?int $perPage;
+
+    public function __construct(Client $client)
+    {
+        $this->client = $client;
+        $this->perPage = null;
+    }
+
+    /**
+     * Send a GET request with query params and return the raw response.
+     *
+     * @param array<string,string> $headers
+     *
+     * @throws \Http\Client\Exception
+     */
+    protected function getAsResponse(string $uri, array $params = [], array $headers = []): ResponseInterface
+    {
+        if (null !== $this->perPage && !isset($params['per_page'])) {
+            $params['per_page'] = $this->perPage;
+        }
+
+        return $this->client->getHttpClient()->get(self::prepareUri($uri, $params), $headers);
+    }
+
+    /**
+     * @param array<string,mixed>  $params
+     * @param array<string,string> $headers
+     */
+    protected function get(string $uri, array $params = [], array $headers = []): mixed
+    {
+        $response = $this->getAsResponse($uri, $params, $headers);
+
+        return ResponseMediator::getContent($response);
+    }
+
+    /**
+     * @param array<string,mixed>  $params
+     * @param array<string,string> $headers
+     * @param array<string,string> $files
+     * @param array<string,mixed>  $uriParams
+     */
+    protected function post(string $uri, array $params = [], array $headers = [], array $files = [], array $uriParams = []): mixed
+    {
+        if (0 < \count($files)) {
+            $builder = $this->createMultipartStreamBuilder($params, $files);
+            $body = self::prepareMultipartBody($builder);
+            $headers = self::addMultipartContentType($headers, $builder);
+        } else {
+            $body = self::prepareJsonBody($params);
+
+            if (null !== $body) {
+                $headers = self::addJsonContentType($headers);
+            }
+        }
+
+        $response = $this->client->getHttpClient()->post(self::prepareUri($uri, $uriParams), $headers, $body);
+
+        return ResponseMediator::getContent($response);
+    }
+
+    /**
+     * @param array<string,mixed>  $params
+     * @param array<string,string> $headers
+     * @param array<string,string> $files
+     */
+    protected function put(string $uri, array $params = [], array $headers = [], array $files = []): mixed
+    {
+        if (0 < \count($files)) {
+            $builder = $this->createMultipartStreamBuilder($params, $files);
+            $body = self::prepareMultipartBody($builder);
+            $headers = self::addMultipartContentType($headers, $builder);
+        } else {
+            $body = self::prepareJsonBody($params);
+
+            if (null !== $body) {
+                $headers = self::addJsonContentType($headers);
+            }
+        }
+
+        $response = $this->client->getHttpClient()->put(self::prepareUri($uri), $headers, $body ?? '');
+
+        return ResponseMediator::getContent($response);
+    }
+
+    /**
+     * @param array<string,mixed>  $params
+     * @param array<string,string> $headers
+     * @param array<string,string> $files
+     */
+    protected function patch(string $uri, array $params = [], array $headers = [], array $files = []): mixed
+    {
+        if (0 < \count($files)) {
+            $builder = $this->createMultipartStreamBuilder($params, $files);
+            $body = self::prepareMultipartBody($builder);
+            $headers = self::addMultipartContentType($headers, $builder);
+        } else {
+            $body = self::prepareJsonBody($params);
+
+            if (null !== $body) {
+                $headers = self::addJsonContentType($headers);
+            }
+        }
+
+        $response = $this->client->getHttpClient()->patch(self::prepareUri($uri), $headers, $body ?? '');
+
+        return ResponseMediator::getContent($response);
+    }
+
+    /**
+     * @param array<string,string> $headers
+     * @param array<string,mixed>  $uriParams
+     */
+    protected function putFile(string $uri, string $file, array $headers = [], array $uriParams = []): mixed
+    {
+        $resource = self::tryFopen($file, 'r');
+        $body = $this->client->getStreamFactory()->createStreamFromResource($resource);
+
+        if ($body->isReadable()) {
+            $headers = \array_merge([ResponseMediator::CONTENT_TYPE_HEADER => self::guessFileContentType($file)], $headers);
+        }
+
+        $response = $this->client->getHttpClient()->put(self::prepareUri($uri, $uriParams), $headers, $body);
+
+        return ResponseMediator::getContent($response);
+    }
+
+    /**
+     * @param array<string,mixed>  $params
+     * @param array<string,string> $headers
+     */
+    protected function delete(string $uri, array $params = [], array $headers = []): mixed
+    {
+        $body = self::prepareJsonBody($params);
+
+        if (null !== $body) {
+            $headers = self::addJsonContentType($headers);
+        }
+
+        $response = $this->client->getHttpClient()->delete(self::prepareUri($uri), $headers, $body ?? '');
+
+        return ResponseMediator::getContent($response);
+    }
+
+    protected static function encodePath(int|string $uri): string
+    {
+        return \rawurlencode((string) $uri);
+    }
+
+    protected function getProjectPath(int|string $id, string $uri): string
+    {
+        return 'projects/'.self::encodePath($id).'/'.$uri;
+    }
+
+    /**
+     * Create a new OptionsResolver with page and per_page options.
+     */
+    protected function createOptionsResolver(): OptionsResolver
+    {
+        $resolver = new OptionsResolver();
+        $resolver->setDefined('page')
+            ->setAllowedTypes('page', 'int')
+            ->setAllowedValues('page', function ($value): bool {
+                return $value > 0;
+            })
+        ;
+        $resolver->setDefined('per_page')
+            ->setAllowedTypes('per_page', 'int')
+            ->setAllowedValues('per_page', function ($value): bool {
+                return $value > 0 && $value <= 100;
+            })
+        ;
+
+        return $resolver;
+    }
+
+    /**
+     * Prepare the request URI.
+     */
+    private static function prepareUri(string $uri, array $query = []): string
+    {
+        $query = \array_filter($query, function ($value): bool {
+            return null !== $value;
+        });
+
+        return \sprintf('%s%s%s', self::URI_PREFIX, $uri, QueryStringBuilder::build($query));
+    }
+
+    /**
+     * Prepare the request URI.
+     *
+     * @param array<string,mixed>  $params
+     * @param array<string,string> $files
+     */
+    private function createMultipartStreamBuilder(array $params = [], array $files = []): MultipartStreamBuilder
+    {
+        $builder = new MultipartStreamBuilder($this->client->getStreamFactory());
+
+        foreach ($params as $name => $value) {
+            $builder->addResource($name, $value);
+        }
+
+        foreach ($files as $name => $file) {
+            $builder->addResource($name, self::tryFopen($file, 'r'), [
+                'headers' => [
+                    ResponseMediator::CONTENT_TYPE_HEADER => self::guessFileContentType($file),
+                ],
+                'filename' => \basename($file),
+            ]);
+        }
+
+        return $builder;
+    }
+
+    /**
+     * Prepare the request multipart body.
+     */
+    private static function prepareMultipartBody(MultipartStreamBuilder $builder): StreamInterface
+    {
+        return $builder->build();
+    }
+
+    /**
+     * Add the multipart content type to the headers if one is not already present.
+     *
+     * @param array<string,string>   $headers
+     *
+     * @return array<string,string>
+     */
+    private static function addMultipartContentType(array $headers, MultipartStreamBuilder $builder): array
+    {
+        $contentType = \sprintf('%s; boundary=%s', ResponseMediator::MULTIPART_CONTENT_TYPE, $builder->getBoundary());
+
+        return \array_merge([ResponseMediator::CONTENT_TYPE_HEADER => $contentType], $headers);
+    }
+
+    /**
+     * Prepare the request JSON body.
+     *
+     * @param array<string,mixed> $params
+     */
+    private static function prepareJsonBody(array $params): ?string
+    {
+        $params = \array_filter($params, function ($value): bool {
+            return null !== $value;
+        });
+
+        if (0 === \count($params)) {
+            return null;
+        }
+
+        return JsonArray::encode($params);
+    }
+
+    /**
+     * Add the JSON content type to the headers if one is not already present.
+     *
+     * @param array<string,string> $headers
+     *
+     * @return array<string,string>
+     */
+    private static function addJsonContentType(array $headers): array
+    {
+        return \array_merge([ResponseMediator::CONTENT_TYPE_HEADER => ResponseMediator::JSON_CONTENT_TYPE], $headers);
+    }
+
+    /**
+     * Safely opens a PHP stream resource using a filename.
+     *
+     * When fopen fails, PHP normally raises a warning. This function adds an
+     * error handler that checks for errors and throws an exception instead.
+     *
+     * @param string $filename File to open
+     * @param string $mode     Mode used to open the file
+     *
+     * @throws RuntimeException if the file cannot be opened
+     *
+     * @return resource
+     *
+     * @see https://github.com/guzzle/psr7/blob/1.6.1/src/functions.php#L287-L320
+     */
+    private static function tryFopen(string $filename, string $mode)
+    {
+        $ex = null;
+        \set_error_handler(function () use ($filename, $mode, &$ex): void {
+            $ex = new RuntimeException(\sprintf(
+                'Unable to open %s using mode %s: %s',
+                $filename,
+                $mode,
+                \func_get_args()[1]
+            ));
+        });
+
+        $handle = \fopen($filename, $mode);
+        \restore_error_handler();
+
+        if (null !== $ex) {
+            throw $ex;
+        }
+
+        /** @var resource */
+        return $handle;
+    }
+
+    /**
+     * Guess the content type of the file if possible.
+     */
+    private static function guessFileContentType(string $file): string
+    {
+        if (!\class_exists(\finfo::class, false)) {
+            return ResponseMediator::STREAM_CONTENT_TYPE;
+        }
+
+        $finfo = new \finfo(\FILEINFO_MIME_TYPE);
+        $type = $finfo->file($file);
+
+        return false !== $type ? $type : ResponseMediator::STREAM_CONTENT_TYPE;
+    }
+}
diff --git a/src/Api/DeployKeys.php b/src/Api/DeployKeys.php
new file mode 100644
index 000000000..9259d25c3
--- /dev/null
+++ b/src/Api/DeployKeys.php
@@ -0,0 +1,25 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Api;
+
+class DeployKeys extends AbstractApi
+{
+    public function all(array $parameters = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+
+        return $this->get('deploy_keys', $resolver->resolve($parameters));
+    }
+}
diff --git a/src/Api/Deployments.php b/src/Api/Deployments.php
new file mode 100644
index 000000000..3770bf9df
--- /dev/null
+++ b/src/Api/Deployments.php
@@ -0,0 +1,53 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Api;
+
+class Deployments extends AbstractApi
+{
+    /**
+     * @param array      $parameters {
+     *
+     *     @var string $order_by                    Return deployments ordered by id, iid, created_at, updated_at,
+     *                                              or ref fields (default is id)
+     *     @var string $sort                        Return deployments sorted in asc or desc order (default is desc)
+     *     @var string $status                      Return deployments filtered by status of deployment allowed
+     *                                              values of status are 'created', 'running', 'success', 'failed',
+     *                                              'canceled', 'blocked'
+     *     @var string $environment                 Return deployments filtered to a particular environment
+     * }
+     */
+    public function all(int|string $project_id, array $parameters = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+        $resolver->setDefined('order_by')
+            ->setAllowedTypes('order_by', 'string')
+            ->setAllowedValues('order_by', ['id', 'iid', 'created_at', 'updated_at', 'ref']);
+        $resolver->setDefined('sort')
+            ->setAllowedTypes('sort', 'string')
+            ->setAllowedValues('sort', ['desc', 'asc']);
+        $resolver->setDefined('status')
+            ->setAllowedTypes('status', 'string')
+            ->setAllowedValues('status', ['created', 'running', 'success', 'failed', 'canceled', 'blocked']);
+        $resolver->setDefined('environment')
+            ->setAllowedTypes('environment', 'string');
+
+        return $this->get($this->getProjectPath($project_id, 'deployments'), $resolver->resolve($parameters));
+    }
+
+    public function show(int|string $project_id, int $deployment_id): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'deployments/'.$deployment_id));
+    }
+}
diff --git a/src/Api/Environments.php b/src/Api/Environments.php
new file mode 100644
index 000000000..b83693a9a
--- /dev/null
+++ b/src/Api/Environments.php
@@ -0,0 +1,71 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Api;
+
+use Symfony\Component\OptionsResolver\OptionsResolver;
+
+class Environments extends AbstractApi
+{
+    public function all(int|string $project_id, array $parameters = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+        $resolver->setDefined('name')
+            ->setAllowedTypes('name', 'string');
+        $resolver->setDefined('search')
+            ->setAllowedTypes('search', 'string');
+        $resolver->setDefined('states')
+            ->setAllowedTypes('states', 'string')
+            ->setAllowedValues('states', ['available', 'stopped']);
+
+        return $this->get($this->getProjectPath($project_id, 'environments'), $resolver->resolve($parameters));
+    }
+
+    /**
+     * @param array      $parameters {
+     *
+     *     @var string $name         The name of the environment
+     *     @var string $external_url Place to link to for this environment
+     *     @var string $tier         The tier of the new environment. Allowed values are production, staging, testing, development, and other.
+     * }
+     */
+    public function create(int|string $project_id, array $parameters = []): mixed
+    {
+        $resolver = new OptionsResolver();
+        $resolver->setDefined('name')
+            ->setRequired('name')
+            ->setAllowedTypes('name', 'string');
+        $resolver->setDefined('external_url')
+            ->setAllowedTypes('external_url', 'string');
+        $resolver->setDefined('tier')
+            ->setAllowedValues('tier', ['production', 'staging', 'testing', 'development', 'other']);
+
+        return $this->post($this->getProjectPath($project_id, 'environments'), $resolver->resolve($parameters));
+    }
+
+    public function remove(int|string $project_id, int $environment_id): mixed
+    {
+        return $this->delete($this->getProjectPath($project_id, 'environments/'.$environment_id));
+    }
+
+    public function stop(int|string $project_id, int $environment_id): mixed
+    {
+        return $this->post($this->getProjectPath($project_id, 'environments/'.self::encodePath($environment_id).'/stop'));
+    }
+
+    public function show(int|string $project_id, int $environment_id): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'environments/'.self::encodePath($environment_id)));
+    }
+}
diff --git a/src/Api/Events.php b/src/Api/Events.php
new file mode 100644
index 000000000..74f3d1cd1
--- /dev/null
+++ b/src/Api/Events.php
@@ -0,0 +1,54 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Niclas Hoyer <info@niclashoyer.de>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Api;
+
+use Symfony\Component\OptionsResolver\Options;
+
+class Events extends AbstractApi
+{
+    /**
+     * @param array $parameters {
+     *
+     *     @var string             $action         include only events of a particular action type
+     *     @var string             $target_type    include only events of a particular target type
+     *     @var \DateTimeInterface $before         include only events created before a particular date
+     *     @var \DateTimeInterface $after          include only events created after a particular date
+     *     @var string             $scope          include all events across a user’s projects
+     *     @var string             $sort           sort events in asc or desc order by created_at
+     *
+     * }
+     */
+    public function all(array $parameters = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+        $datetimeNormalizer = function (Options $resolver, \DateTimeInterface $value): string {
+            return $value->format('Y-m-d');
+        };
+
+        $resolver->setDefined('action');
+        $resolver->setDefined('target_type');
+        $resolver->setDefined('before')
+            ->setAllowedTypes('before', \DateTimeInterface::class)
+            ->setNormalizer('before', $datetimeNormalizer)
+        ;
+        $resolver->setDefined('after')
+            ->setAllowedTypes('after', \DateTimeInterface::class)
+            ->setNormalizer('after', $datetimeNormalizer)
+        ;
+        $resolver->setDefined('scope');
+        $resolver->setDefined('sort');
+
+        return $this->get('events', $resolver->resolve($parameters));
+    }
+}
diff --git a/src/Api/Groups.php b/src/Api/Groups.php
new file mode 100644
index 000000000..7104d3450
--- /dev/null
+++ b/src/Api/Groups.php
@@ -0,0 +1,857 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Api;
+
+use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException;
+use Symfony\Component\OptionsResolver\Exception\UndefinedOptionsException;
+use Symfony\Component\OptionsResolver\Options;
+use Symfony\Component\OptionsResolver\OptionsResolver;
+
+class Groups extends AbstractApi
+{
+    /**
+     * @var string
+     */
+    public const STATE_ALL = 'all';
+
+    /**
+     * @var string
+     */
+    public const STATE_MERGED = 'merged';
+
+    /**
+     * @var string
+     */
+    public const STATE_OPENED = 'opened';
+
+    /**
+     * @var string
+     */
+    public const STATE_CLOSED = 'closed';
+
+    /**
+     * @var string
+     */
+    public const STATE_LOCKED = 'locked';
+
+    /**
+     * @param array $parameters {
+     *
+     *     @var int[]  $skip_groups      skip the group IDs passes
+     *     @var bool   $all_available    show all the groups you have access to
+     *     @var string $search           return list of authorized groups matching the search criteria
+     *     @var string $order_by         Order groups by name or path (default is name)
+     *     @var string $sort             Order groups in asc or desc order (default is asc)
+     *     @var bool   $statistics       include group statistics (admins only)
+     *     @var bool   $owned            limit by groups owned by the current user
+     *     @var int    $min_access_level limit by groups in which the current user has at least this access level
+     *     @var bool   $top_level_only   limit to top level groups, excluding all subgroups
+     * }
+     */
+    public function all(array $parameters = []): mixed
+    {
+        $resolver = $this->getGroupSearchResolver();
+
+        return $this->get('groups', $resolver->resolve($parameters));
+    }
+
+    public function show(int|string $id): mixed
+    {
+        return $this->get('groups/'.self::encodePath($id));
+    }
+
+    public function create(string $name, string $path, ?string $description = null, string $visibility = 'private', ?bool $lfs_enabled = null, ?bool $request_access_enabled = null, ?int $parent_id = null, ?int $shared_runners_minutes_limit = null): mixed
+    {
+        $params = [
+            'name' => $name,
+            'path' => $path,
+            'description' => $description,
+            'visibility' => $visibility,
+            'lfs_enabled' => $lfs_enabled,
+            'request_access_enabled' => $request_access_enabled,
+            'parent_id' => $parent_id,
+            'shared_runners_minutes_limit' => $shared_runners_minutes_limit,
+        ];
+
+        return $this->post('groups', \array_filter($params, function ($value) {
+            return null !== $value && (!\is_string($value) || '' !== $value);
+        }));
+    }
+
+    public function update(int|string $id, array $params): mixed
+    {
+        return $this->put('groups/'.self::encodePath($id), $params);
+    }
+
+    public function remove(int|string $group_id): mixed
+    {
+        return $this->delete('groups/'.self::encodePath($group_id));
+    }
+
+    public function transfer(int|string $group_id, int|string $project_id): mixed
+    {
+        return $this->post('groups/'.self::encodePath($group_id).'/projects/'.self::encodePath($project_id));
+    }
+
+    public function allMembers(int|string $group_id, array $parameters = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+        $resolver->setDefined('query');
+        $resolver->setDefined('user_ids')
+            ->setAllowedTypes('user_ids', 'array')
+            ->setAllowedValues('user_ids', function (array $value) {
+                return \count($value) === \count(\array_filter($value, 'is_int'));
+            })
+        ;
+
+        return $this->get('groups/'.self::encodePath($group_id).'/members/all', $resolver->resolve($parameters));
+    }
+
+    /**
+     * @param array      $parameters {
+     *
+     *     @var string $query A query string to search for members.
+     * }
+     */
+    public function members(int|string $group_id, array $parameters = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+        $resolver->setDefined('query');
+        $resolver->setDefined('user_ids')
+            ->setAllowedTypes('user_ids', 'array')
+            ->setAllowedValues('user_ids', function (array $value) {
+                return \count($value) === \count(\array_filter($value, 'is_int'));
+            })
+        ;
+
+        return $this->get('groups/'.self::encodePath($group_id).'/members', $resolver->resolve($parameters));
+    }
+
+    public function member(int|string $group_id, int $user_id): mixed
+    {
+        return $this->get('groups/'.self::encodePath($group_id).'/members/'.self::encodePath($user_id));
+    }
+
+    public function allMember(int|string $group_id, int $user_id): mixed
+    {
+        return $this->get('groups/'.self::encodePath($group_id).'/members/all/'.self::encodePath($user_id));
+    }
+
+    public function addMember(int|string $group_id, int $user_id, int $access_level, array $parameters = []): mixed
+    {
+        $dateNormalizer = function (OptionsResolver $optionsResolver, \DateTimeInterface $date): string {
+            return $date->format('Y-m-d');
+        };
+
+        $resolver = $this->createOptionsResolver()
+            ->setDefined('expires_at')
+            ->setAllowedTypes('expires_at', \DateTimeInterface::class)
+            ->setNormalizer('expires_at', $dateNormalizer)
+        ;
+
+        $parameters = \array_merge([
+            'user_id' => $user_id,
+            'access_level' => $access_level,
+        ], $resolver->resolve($parameters));
+
+        return $this->post('groups/'.self::encodePath($group_id).'/members', $parameters);
+    }
+
+    public function saveMember(int|string $group_id, int $user_id, int $access_level): mixed
+    {
+        return $this->put('groups/'.self::encodePath($group_id).'/members/'.self::encodePath($user_id), [
+            'access_level' => $access_level,
+        ]);
+    }
+
+    /**
+     * @param array      $parameters {
+     *
+     *     @var int    $group_access the access level to grant the group
+     *     @var string $expires_at   share expiration date in ISO 8601 format: 2016-09-26
+     * }
+     */
+    public function addShare(int|string $group_id, array $parameters = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+
+        $datetimeNormalizer = function (OptionsResolver $optionsResolver, \DateTimeInterface $value) {
+            return $value->format('Y-m-d');
+        };
+
+        $resolver->setRequired('group_id')
+            ->setAllowedTypes('group_id', 'int');
+
+        $resolver->setRequired('group_access')
+            ->setAllowedTypes('group_access', 'int')
+            ->setAllowedValues('group_access', [0, 10, 20, 30, 40, 50]);
+
+        $resolver->setDefined('expires_at')
+            ->setAllowedTypes('expires_at', \DateTimeInterface::class)
+            ->setNormalizer('expires_at', $datetimeNormalizer)
+        ;
+
+        return $this->post('groups/'.self::encodePath($group_id).'/share', $resolver->resolve($parameters));
+    }
+
+    public function removeMember(int|string $group_id, int $user_id): mixed
+    {
+        return $this->delete('groups/'.self::encodePath($group_id).'/members/'.self::encodePath($user_id));
+    }
+
+    /**
+     * @param array      $parameters {
+     *
+     *     @var bool   $archived                    limit by archived status
+     *     @var string $visibility                  limit by visibility public, internal, or private
+     *     @var string $order_by                    Return projects ordered by id, name, path, created_at, updated_at, or last_activity_at fields.
+     *                                              Default is created_at.
+     *     @var string $sort                        Return projects sorted in asc or desc order (default is desc)
+     *     @var string $search                      return list of authorized projects matching the search criteria
+     *     @var bool   $simple                      return only the ID, URL, name, and path of each project
+     *     @var bool   $owned                       limit by projects owned by the current user
+     *     @var bool   $starred                     limit by projects starred by the current user
+     *     @var bool   $with_issues_enabled         Limit by projects with issues feature enabled (default is false)
+     *     @var bool   $with_merge_requests_enabled Limit by projects with merge requests feature enabled (default is false)
+     *     @var bool   $with_shared                 Include projects shared to this group (default is true)
+     *     @var bool   $include_subgroups           Include projects in subgroups of this group (default is false)
+     *     @var bool   $with_custom_attributes      Include custom attributes in response (admins only).
+     * }
+     */
+    public function projects(int|string $id, array $parameters = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+        $booleanNormalizer = function (Options $resolver, $value): string {
+            return $value ? 'true' : 'false';
+        };
+
+        $resolver->setDefined('archived')
+            ->setAllowedTypes('archived', 'bool')
+            ->setNormalizer('archived', $booleanNormalizer)
+        ;
+        $resolver->setDefined('visibility')
+            ->setAllowedValues('visibility', ['public', 'internal', 'private'])
+        ;
+        $resolver->setDefined('order_by')
+            ->setAllowedValues('order_by', ['id', 'name', 'path', 'created_at', 'updated_at', 'last_activity_at'])
+        ;
+        $resolver->setDefined('sort')
+            ->setAllowedValues('sort', ['asc', 'desc'])
+        ;
+        $resolver->setDefined('search');
+        $resolver->setDefined('simple')
+            ->setAllowedTypes('simple', 'bool')
+            ->setNormalizer('simple', $booleanNormalizer)
+        ;
+        $resolver->setDefined('owned')
+            ->setAllowedTypes('owned', 'bool')
+            ->setNormalizer('owned', $booleanNormalizer)
+        ;
+        $resolver->setDefined('starred')
+            ->setAllowedTypes('starred', 'bool')
+            ->setNormalizer('starred', $booleanNormalizer)
+        ;
+        $resolver->setDefined('with_issues_enabled')
+            ->setAllowedTypes('with_issues_enabled', 'bool')
+            ->setNormalizer('with_issues_enabled', $booleanNormalizer)
+        ;
+        $resolver->setDefined('with_merge_requests_enabled')
+            ->setAllowedTypes('with_merge_requests_enabled', 'bool')
+            ->setNormalizer('with_merge_requests_enabled', $booleanNormalizer)
+        ;
+        $resolver->setDefined('with_shared')
+            ->setAllowedTypes('with_shared', 'bool')
+            ->setNormalizer('with_shared', $booleanNormalizer)
+        ;
+        $resolver->setDefined('include_subgroups')
+            ->setAllowedTypes('include_subgroups', 'bool')
+            ->setNormalizer('include_subgroups', $booleanNormalizer)
+        ;
+        $resolver->setDefined('with_custom_attributes')
+            ->setAllowedTypes('with_custom_attributes', 'bool')
+            ->setNormalizer('with_custom_attributes', $booleanNormalizer)
+        ;
+
+        return $this->get('groups/'.self::encodePath($id).'/projects', $resolver->resolve($parameters));
+    }
+
+    /**
+     * @param array      $parameters {
+     *
+     *     @var int[]  $skip_groups   skip the group IDs passes
+     *     @var bool   $all_available show all the groups you have access to
+     *     @var string $search        return list of authorized groups matching the search criteria
+     *     @var string $order_by      Order groups by name or path (default is name)
+     *     @var string $sort          Order groups in asc or desc order (default is asc)
+     *     @var bool   $statistics    include group statistics (admins only)
+     *     @var bool   $owned         Limit by groups owned by the current user.
+     * }
+     */
+    public function subgroups(int|string $group_id, array $parameters = []): mixed
+    {
+        $resolver = $this->getSubgroupSearchResolver();
+
+        return $this->get('groups/'.self::encodePath($group_id).'/subgroups', $resolver->resolve($parameters));
+    }
+
+    /**
+     * @param array      $parameters {
+     *
+     *     @var string   $assignee_id              Return issues assigned to the given user id. Mutually exclusive with assignee_username.
+     *                                             None returns unassigned issues. Any returns issues with an assignee.
+     *     @var string   $assignee_username        Return issues assigned to the given username. Similar to assignee_id and mutually exclusive with assignee_id.
+     *                                             In GitLab CE, the assignee_username array should only contain a single value. Otherwise, an invalid parameter error is returned.
+     *     @var int      $author_id                Return issues created by the given user id. Mutually exclusive with author_username.
+     *                                             Combine with scope=all or scope=assigned_to_me.
+     *     @var string   $author_username          Return issues created by the given username. Similar to author_id and mutually exclusive with author_id.
+     *     @var bool     $confidential             Filter confidential or public issues
+     *     @var \DateTimeInterface $created_after  Return issues created after the given time (inclusive)
+     *     @var \DateTimeInterface $created_before Return issues created before the given time (inclusive)
+     *     @var int      $iteration_id             Return issues assigned to the given iteration ID. None returns issues that do not belong to an iteration. Any returns issues that belong to an iteration. Mutually exclusive with iteration_title.
+     *     @var string   $iteration_title          Return issues assigned to the iteration with the given title. Similar to iteration_id and mutually exclusive with iteration_id.
+     *     @var string   $labels                   Comma-separated list of label names, issues must have all labels to be returned. None lists all issues with no labels. Any lists all issues with at least one label. No+Label (Deprecated) lists all issues with no labels. Predefined names are case-insensitive.
+     *     @var string   $milestone                The milestone title. None lists all issues with no milestone. Any lists all issues that have an assigned milestone.
+     *     @var string   $my_reaction_emoji        Return issues reacted by the authenticated user by the given emoji. None returns issues not given a reaction. Any returns issues given at least one reaction.
+     *     @var bool     $non_archived             Return issues from non archived projects. Default is true.
+     *     @var string   $not                      Return issues that do not match the parameters supplied. Accepts: labels, milestone, author_id, author_username, assignee_id, assignee_username, my_reaction_emoji, search, in
+     *     @var string   $order_by                 Return issues ordered by created_at, updated_at, priority, due_date, relative_position, label_priority, milestone_due, popularity, weight fields. Default is created_at
+     *     @var string   $scope                    Return issues for the given scope: created_by_me, assigned_to_me or all. Defaults to all.
+     *     @var string   $search                   Search group issues against their title and description
+     *     @var string   $sort                     Return issues sorted in asc or desc order. Default is desc
+     *     @var string   $state                    Return all issues or just those that are opened or closed
+     *     @var \DateTimeInterface $updated_after  Return issues updated on or after the given time. Expected in ISO 8601 format (2019-03-15T08:00:00Z)
+     *     @var \DateTimeInterface $updated_before Return issues updated on or before the given time. Expected in ISO 8601 format (2019-03-15T08:00:00Z)
+     *     @var int      $weight                   Return issues with the specified weight. None returns issues with no weight assigned. Any returns issues with a weight assigned.
+     *     @var bool     $with_labels_details      If true, the response returns more details for each label in labels field: :name, :color, :description, :description_html, :text_color. Default is false.
+     * }
+     */
+    public function issues(int|string $group_id, array $parameters = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+        $booleanNormalizer = function (Options $resolver, $value): string {
+            return $value ? 'true' : 'false';
+        };
+        $datetimeNormalizer = function (Options $resolver, \DateTimeInterface $value): string {
+            return $value->format('c');
+        };
+
+        $resolver->setDefined('assignee_id');
+        $resolver->setDefined('assignee_username')
+            ->setAllowedTypes('assignee_username', 'string');
+
+        $resolver->setDefined('author_id');
+        $resolver->setDefined('author_username')
+            ->setAllowedTypes('author_username', 'string');
+
+        $resolver->setDefined('confidential')
+            ->setAllowedTypes('confidential', 'bool')
+            ->setNormalizer('confidential', $booleanNormalizer);
+
+        $resolver->setDefined('created_after')
+            ->setAllowedTypes('created_after', \DateTimeInterface::class)
+            ->setNormalizer('created_after', $datetimeNormalizer);
+        $resolver->setDefined('created_before')
+            ->setAllowedTypes('created_before', \DateTimeInterface::class)
+            ->setNormalizer('created_before', $datetimeNormalizer);
+
+        $resolver->setDefined('updated_after')
+            ->setAllowedTypes('updated_after', \DateTimeInterface::class)
+            ->setNormalizer('updated_after', $datetimeNormalizer);
+        $resolver->setDefined('updated_before')
+            ->setAllowedTypes('updated_before', \DateTimeInterface::class)
+            ->setNormalizer('updated_before', $datetimeNormalizer);
+
+        $resolver->setDefined('iteration_id');
+        $resolver->setDefined('iteration_title')
+            ->setAllowedTypes('iteration_title', 'string');
+
+        $resolver->setDefined('labels')
+            ->setAllowedTypes('labels', 'string');
+
+        $resolver->setDefined('milestone')
+            ->setAllowedTypes('milestone', 'string');
+
+        $resolver->setDefined('my_reaction_emoji')
+            ->setAllowedTypes('my_reaction_emoji', 'string');
+
+        $resolver->setDefined('non_archived')
+            ->setAllowedTypes('non_archived', 'bool')
+            ->setNormalizer('non_archived', $booleanNormalizer);
+
+        $resolver->setDefined('not')
+            ->setAllowedTypes('not', 'string');
+
+        $resolver->setDefined('order_by')
+            ->setAllowedValues('order_by', ['created_at', 'updated_at']);
+        $resolver->setDefined('sort')
+            ->setAllowedValues('sort', ['asc', 'desc']);
+
+        $resolver->setDefined('scope')
+            ->setAllowedTypes('scope', 'string');
+
+        $resolver->setDefined('search')
+            ->setAllowedTypes('search', 'string');
+
+        $resolver->setDefined('state')
+            ->setAllowedValues('state', [self::STATE_ALL, self::STATE_OPENED, self::STATE_CLOSED]);
+
+        $resolver->setDefined('weight');
+
+        $resolver->setDefined('with_labels_details')
+            ->setAllowedTypes('with_labels_details', 'bool')
+            ->setNormalizer('with_labels_details', $booleanNormalizer);
+
+        return $this->get('groups/'.self::encodePath($group_id).'/issues', $resolver->resolve($parameters));
+    }
+
+    /**
+     * @param array      $parameters {
+     *
+     *     @var bool     $with_counts               Whether or not to include issue and merge request counts. Defaults to false.
+     *     @var bool     $include_ancestor_groups   Include ancestor groups. Defaults to true.
+     *     @var bool     $include_descendant_groups Include descendant groups. Defaults to false.
+     *     @var bool     $only_group_labels         Toggle to include only group labels or also project labels. Defaults to true.
+     *     @var string   $search                    Keyword to filter labels by.
+     * }
+     */
+    public function labels(int|string $group_id, array $parameters = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+
+        $resolver->setDefined('with_counts')
+            ->setAllowedTypes('with_counts', 'bool');
+
+        $resolver->setDefined('include_ancestor_groups')
+            ->setAllowedTypes('include_ancestor_groups', 'bool');
+
+        $resolver->setDefined('include_descendant_groups')
+            ->setAllowedTypes('include_descendant_groups', 'bool');
+
+        $resolver->setDefined('only_group_labels')
+            ->setAllowedTypes('only_group_labels', 'bool');
+
+        $resolver->setDefined('search')
+            ->setAllowedTypes('search', 'string');
+
+        return $this->get('groups/'.self::encodePath($group_id).'/labels', $resolver->resolve($parameters));
+    }
+
+    public function addLabel(int|string $group_id, array $params): mixed
+    {
+        return $this->post('groups/'.self::encodePath($group_id).'/labels', $params);
+    }
+
+    public function updateLabel(int|string $group_id, int $label_id, array $params): mixed
+    {
+        return $this->put('groups/'.self::encodePath($group_id).'/labels/'.self::encodePath($label_id), $params);
+    }
+
+    public function removeLabel(int|string $group_id, int $label_id): mixed
+    {
+        return $this->delete('groups/'.self::encodePath($group_id).'/labels/'.self::encodePath($label_id));
+    }
+
+    public function variables(int|string $group_id, array $parameters = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+
+        return $this->get('groups/'.self::encodePath($group_id).'/variables', $resolver->resolve($parameters));
+    }
+
+    public function variable(int|string $group_id, string $key): mixed
+    {
+        return $this->get('groups/'.self::encodePath($group_id).'/variables/'.self::encodePath($key));
+    }
+
+    /**
+     * @param array      $parameters {
+     *
+     *      @var string $masked         true or false
+     *      @var string $variable_type  env_var (default) or file
+     * }
+     */
+    public function addVariable(int|string $group_id, string $key, string $value, ?bool $protected = null, array $parameters = []): mixed
+    {
+        $payload = [
+            'key' => $key,
+            'value' => $value,
+        ];
+
+        if ($protected) {
+            $payload['protected'] = $protected;
+        }
+
+        if (isset($parameters['masked'])) {
+            $payload['masked'] = $parameters['masked'];
+        }
+
+        if (isset($parameters['variable_type'])) {
+            $payload['variable_type'] = $parameters['variable_type'];
+        }
+
+        return $this->post('groups/'.self::encodePath($group_id).'/variables', $payload);
+    }
+
+    public function updateVariable(int|string $group_id, string $key, string $value, ?bool $protected = null): mixed
+    {
+        $payload = [
+            'value' => $value,
+        ];
+
+        if ($protected) {
+            $payload['protected'] = $protected;
+        }
+
+        return $this->put('groups/'.self::encodePath($group_id).'/variables/'.self::encodePath($key), $payload);
+    }
+
+    public function removeVariable(int|string $group_id, string $key): mixed
+    {
+        return $this->delete('groups/'.self::encodePath($group_id).'/variables/'.self::encodePath($key));
+    }
+
+    /**
+     * @param array      $parameters {
+     *
+     *     @var int[]              $iids           return the request having the given iid
+     *     @var string             $state          return all merge requests or just those that are opened, closed, or
+     *                                             merged
+     *     @var string             $scope          Return merge requests for the given scope: created-by-me,
+     *                                             assigned-to-me or all (default is created-by-me)
+     *     @var string             $order_by       return requests ordered by created_at or updated_at fields (default is created_at)
+     *     @var string             $sort           return requests sorted in asc or desc order (default is desc)
+     *     @var string             $milestone      return merge requests for a specific milestone
+     *     @var string             $view           if simple, returns the iid, URL, title, description, and basic state of merge request
+     *     @var string             $labels         return merge requests matching a comma separated list of labels
+     *     @var \DateTimeInterface $created_after  return merge requests created after the given time (inclusive)
+     *     @var \DateTimeInterface $created_before return merge requests created before the given time (inclusive)
+     * }
+     */
+    public function mergeRequests(int|string $group_id, array $parameters = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+        $datetimeNormalizer = function (Options $resolver, \DateTimeInterface $value): string {
+            return $value->format('c');
+        };
+        $resolver->setDefined('state')
+            ->setAllowedValues('state', [self::STATE_ALL, self::STATE_MERGED, self::STATE_OPENED, self::STATE_CLOSED])
+        ;
+        $resolver->setDefined('order_by')
+            ->setAllowedValues('order_by', ['created_at', 'updated_at'])
+        ;
+        $resolver->setDefined('sort')
+            ->setAllowedValues('sort', ['asc', 'desc'])
+        ;
+        $resolver->setDefined('milestone');
+        $resolver->setDefined('view')
+            ->setAllowedValues('view', ['simple'])
+        ;
+        $resolver->setDefined('labels');
+        $resolver->setDefined('with_labels_details')
+            ->setAllowedTypes('with_labels_details', 'bool')
+        ;
+
+        $resolver->setDefined('created_after')
+            ->setAllowedTypes('created_after', \DateTimeInterface::class)
+            ->setNormalizer('created_after', $datetimeNormalizer)
+        ;
+        $resolver->setDefined('created_before')
+            ->setAllowedTypes('created_before', \DateTimeInterface::class)
+            ->setNormalizer('created_before', $datetimeNormalizer)
+        ;
+
+        $resolver->setDefined('updated_after')
+            ->setAllowedTypes('updated_after', \DateTimeInterface::class)
+            ->setNormalizer('updated_after', $datetimeNormalizer)
+        ;
+        $resolver->setDefined('updated_before')
+            ->setAllowedTypes('updated_before', \DateTimeInterface::class)
+            ->setNormalizer('updated_before', $datetimeNormalizer)
+        ;
+
+        $resolver->setDefined('scope')
+            ->setAllowedValues('scope', ['created_by_me', 'assigned_to_me', 'all'])
+        ;
+        $resolver->setDefined('author_id')
+            ->setAllowedTypes('author_id', 'integer');
+        $resolver->setDefined('author_username');
+
+        $resolver->setDefined('assignee_id')
+            ->setAllowedTypes('assignee_id', 'integer');
+
+        $resolver->setDefined('approver_ids')
+            ->setAllowedTypes('approver_ids', 'array')
+            ->setAllowedValues('approver_ids', function (array $value) {
+                return \count($value) === \count(\array_filter($value, 'is_int'));
+            })
+        ;
+        $resolver->setDefined('non_archived')
+            ->setAllowedTypes('non_archived', 'bool')
+        ;
+        $resolver->setDefined('reviewer_id')
+            ->setAllowedTypes('reviewer_id', 'integer');
+        $resolver->setDefined('reviewer_username');
+        $resolver->setDefined('my_reaction_emoji');
+
+        $resolver->setDefined('search');
+        $resolver->setDefined('source_branch');
+        $resolver->setDefined('target_branch');
+        $resolver->setDefined('with_merge_status_recheck')
+            ->setAllowedTypes('with_merge_status_recheck', 'bool')
+        ;
+        $resolver->setDefined('approved_by_ids')
+            ->setAllowedTypes('approved_by_ids', 'array')
+            ->setAllowedValues('approved_by_ids', function (array $value) {
+                return \count($value) === \count(\array_filter($value, 'is_int'));
+            })
+        ;
+
+        return $this->get('groups/'.self::encodePath($group_id).'/merge_requests', $resolver->resolve($parameters));
+    }
+
+    /**
+     * @param array      $parameters {
+     *
+     *     @var string $state               Return opened, upcoming, current (previously started), closed, or all iterations.
+     *                                      Filtering by started state is deprecated starting with 14.1, please use current instead.
+     *     @var string $search              return only iterations with a title matching the provided string
+     *     @var bool   $include_ancestors   Include iterations from parent group and its ancestors. Defaults to true.
+     * }
+     */
+    public function iterations(int|string $group_id, array $parameters = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+        $booleanNormalizer = function (Options $resolver, $value): string {
+            return $value ? 'true' : 'false';
+        };
+
+        $resolver->setDefined('state')
+            ->setAllowedValues('state', ['opened', 'upcoming', 'current', 'current (previously started)', 'closed', 'all'])
+        ;
+        $resolver->setDefined('include_ancestors')
+            ->setAllowedTypes('include_ancestors', 'bool')
+            ->setNormalizer('include_ancestors', $booleanNormalizer)
+            ->setDefault('include_ancestors', true)
+        ;
+
+        return $this->get('groups/'.self::encodePath($group_id).'/iterations', $resolver->resolve($parameters));
+    }
+
+    /**
+     * @param array      $parameters {
+     *
+     *     @var bool   $exclude_subgroups   if the parameter is included as true, packages from projects from subgroups
+     *                                      are not listed. default is false.
+     *     @var string $order_by            the field to use as order. one of created_at (default), name, version, type,
+     *                                      or project_path.
+     *     @var string $sort                the direction of the order, either asc (default) for ascending order
+     *                                      or desc for descending order
+     *     @var string $package_type        filter the returned packages by type. one of conan, maven, npm, pypi,
+     *                                      composer, nuget, or golang.
+     *     @var string $package_name        filter the project packages with a fuzzy search by name
+     *     @var bool   $include_versionless when set to true, versionless packages are included in the response
+     *     @var string $status              filter the returned packages by status. one of default (default),
+     *                                      hidden, or processing.
+     * }
+     */
+    public function packages(int|string $group_id, array $parameters = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+        $booleanNormalizer = function (Options $resolver, $value): string {
+            return $value ? 'true' : 'false';
+        };
+
+        $resolver->setDefined('exclude_subgroups')
+            ->setAllowedTypes('exclude_subgroups', 'bool')
+            ->setNormalizer('exclude_subgroups', $booleanNormalizer)
+        ;
+        $resolver->setDefined('order_by')
+            ->setAllowedValues('order_by', ['created_at', 'name', 'version', 'type'])
+        ;
+        $resolver->setDefined('sort')
+            ->setAllowedValues('sort', ['asc', 'desc'])
+        ;
+        $resolver->setDefined('package_type')
+            ->setAllowedValues('package_type', ['conan', 'maven', 'npm', 'pypi', 'composer', 'nuget', 'golang'])
+        ;
+        $resolver->setDefined('package_name');
+        $resolver->setDefined('include_versionless')
+            ->setAllowedTypes('include_versionless', 'bool')
+            ->setNormalizer('include_versionless', $booleanNormalizer)
+        ;
+        $resolver->setDefined('status')
+            ->setAllowedValues('status', ['default', 'hidden', 'processing'])
+        ;
+
+        return $this->get('groups/'.self::encodePath($group_id).'/packages', $resolver->resolve($parameters));
+    }
+
+    private function getGroupSearchResolver(): OptionsResolver
+    {
+        $resolver = $this->getSubgroupSearchResolver();
+        $booleanNormalizer = function (Options $resolver, $value): string {
+            return $value ? 'true' : 'false';
+        };
+
+        $resolver->setDefined('top_level_only')
+            ->setAllowedTypes('top_level_only', 'bool')
+            ->setNormalizer('top_level_only', $booleanNormalizer)
+        ;
+
+        return $resolver;
+    }
+
+    private function getSubgroupSearchResolver(): OptionsResolver
+    {
+        $resolver = $this->createOptionsResolver();
+        $booleanNormalizer = function (Options $resolver, $value): string {
+            return $value ? 'true' : 'false';
+        };
+
+        $resolver->setDefined('skip_groups')
+            ->setAllowedTypes('skip_groups', 'array')
+            ->setAllowedValues('skip_groups', function (array $value) {
+                return \count($value) === \count(\array_filter($value, 'is_int'));
+            })
+        ;
+        $resolver->setDefined('all_available')
+            ->setAllowedTypes('all_available', 'bool')
+            ->setNormalizer('all_available', $booleanNormalizer)
+        ;
+        $resolver->setDefined('search');
+        $resolver->setDefined('order_by')
+            ->setAllowedValues('order_by', ['name', 'path'])
+        ;
+        $resolver->setDefined('sort')
+            ->setAllowedValues('sort', ['asc', 'desc'])
+        ;
+        $resolver->setDefined('statistics')
+            ->setAllowedTypes('statistics', 'bool')
+            ->setNormalizer('statistics', $booleanNormalizer)
+        ;
+        $resolver->setDefined('owned')
+            ->setAllowedTypes('owned', 'bool')
+            ->setNormalizer('owned', $booleanNormalizer)
+        ;
+        $resolver->setDefined('min_access_level')
+            ->setAllowedValues('min_access_level', [null, 10, 20, 30, 40, 50])
+        ;
+
+        return $resolver;
+    }
+
+    public function deployTokens(int|string $group_id, ?bool $active = null): mixed
+    {
+        return $this->get('groups/'.self::encodePath($group_id).'/deploy_tokens', (null !== $active) ? ['active' => $active] : []);
+    }
+
+    /**
+     * @param array      $parameters {
+     *
+     *     @var string $name                    the name of the deploy token
+     *     @var \DateTimeInterface $expires_at  expiration date for the deploy token, does not expire if no value is provided
+     *     @var string $username                the username for the deploy token
+     *     @var array  $scopes                  the scopes, one or many of: read_repository, read_registry, write_registry, read_package_registry, write_package_registry
+     * }
+     */
+    public function createDeployToken(int|string $group_id, array $parameters = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+        $datetimeNormalizer = function (Options $resolver, \DateTimeInterface $value): string {
+            return $value->format('c');
+        };
+
+        $resolver->define('name')
+            ->required()
+        ;
+
+        $resolver->define('scopes')
+            ->required()
+            ->allowedTypes('array')
+            ->allowedValues(function ($scopes) {
+                $allowed = ['read_repository', 'read_registry', 'write_registry', 'read_package_registry', 'write_package_registry'];
+                foreach ($scopes as $scope) {
+                    if (!\in_array($scope, $allowed, true)) {
+                        return false;
+                    }
+                }
+
+                return true;
+            })
+        ;
+        $resolver->setDefined('username')
+            ->setAllowedTypes('username', 'string')
+        ;
+
+        $resolver->setDefined('expires_at')
+            ->setAllowedTypes('expires_at', \DateTimeInterface::class)
+            ->setNormalizer('expires_at', $datetimeNormalizer)
+        ;
+
+        return $this->post('groups/'.self::encodePath($group_id).'/deploy_tokens', $resolver->resolve($parameters));
+    }
+
+    public function deleteDeployToken(int|string $group_id, int $token_id): mixed
+    {
+        return $this->delete('groups/'.self::encodePath($group_id).'/deploy_tokens/'.self::encodePath($token_id));
+    }
+
+    /**
+     * @param array $parameters {
+     *
+     *     @var string $scope        The scope to search in
+     *     @var string $search       The search query
+     *     @var string $state        Filter by state. Issues and merge requests are supported; it is ignored for other scopes.
+     *     @var bool   $confidential Filter by confidentiality. Issues scope is supported; it is ignored for other scopes.
+     *     @var string $order_by     Allowed values are created_at only. If this is not set, the results are either sorted by created_at in descending order for basic search, or by the most relevant documents when using advanced search.
+     *     @var string $sort         Return projects sorted in asc or desc order (default is desc)
+     * }
+     *
+     * @throws UndefinedOptionsException If an option name is undefined
+     * @throws InvalidOptionsException   If an option doesn't fulfill the specified validation rules
+     */
+    public function search(int|string $id, array $parameters = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+        $booleanNormalizer = function (Options $resolver, $value): string {
+            return $value ? 'true' : 'false';
+        };
+        $resolver->setDefined('confidential')
+            ->setAllowedTypes('confidential', 'bool')
+            ->setNormalizer('confidential', $booleanNormalizer);
+        $scope = [
+            'issues',
+            'merge_requests',
+            'milestones',
+            'projects',
+            'users',
+            'blobs',
+            'commits',
+            'notes',
+            'wiki_blobs',
+        ];
+        $resolver->setRequired('scope')
+            ->setAllowedValues('scope', $scope);
+        $resolver->setRequired('search');
+        $resolver->setDefined('order_by')
+            ->setAllowedValues('order_by', ['created_at']);
+        $resolver->setDefined('sort')
+            ->setAllowedValues('sort', ['asc', 'desc']);
+        $resolver->setDefined('state')
+            ->setAllowedValues('state', ['opened', 'closed']);
+
+        return $this->get('groups/'.self::encodePath($id).'/search', $resolver->resolve($parameters));
+    }
+}
diff --git a/src/Api/GroupsBoards.php b/src/Api/GroupsBoards.php
new file mode 100644
index 000000000..a3fa67421
--- /dev/null
+++ b/src/Api/GroupsBoards.php
@@ -0,0 +1,80 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Api;
+
+class GroupsBoards extends AbstractApi
+{
+    public function all(int|string|null $group_id = null, array $parameters = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+
+        $path = null === $group_id ? 'boards' : 'groups/'.self::encodePath($group_id).'/boards';
+
+        return $this->get($path, $resolver->resolve($parameters));
+    }
+
+    public function show(int|string $group_id, int $board_id): mixed
+    {
+        return $this->get('groups/'.self::encodePath($group_id).'/boards/'.self::encodePath($board_id));
+    }
+
+    public function create(int|string $group_id, array $params): mixed
+    {
+        return $this->post('groups/'.self::encodePath($group_id).'/boards', $params);
+    }
+
+    public function update(int|string $group_id, int $board_id, array $params): mixed
+    {
+        return $this->put('groups/'.self::encodePath($group_id).'/boards/'.self::encodePath($board_id), $params);
+    }
+
+    public function remove(int|string $group_id, int $board_id): mixed
+    {
+        return $this->delete('groups/'.self::encodePath($group_id).'/boards/'.self::encodePath($board_id));
+    }
+
+    public function allLists(int|string $group_id, int $board_id): mixed
+    {
+        return $this->get('groups/'.self::encodePath($group_id).'/boards/'.self::encodePath($board_id).'/lists');
+    }
+
+    public function showList(int|string $group_id, int $board_id, int $list_id): mixed
+    {
+        return $this->get('groups/'.self::encodePath($group_id).'/boards/'.self::encodePath($board_id).'/lists/'.self::encodePath($list_id));
+    }
+
+    public function createList(int|string $group_id, int $board_id, int $label_id): mixed
+    {
+        $params = [
+            'label_id' => $label_id,
+        ];
+
+        return $this->post('groups/'.self::encodePath($group_id).'/boards/'.self::encodePath($board_id).'/lists', $params);
+    }
+
+    public function updateList(int|string $group_id, int $board_id, int $list_id, int $position): mixed
+    {
+        $params = [
+            'position' => $position,
+        ];
+
+        return $this->put('groups/'.self::encodePath($group_id).'/boards/'.self::encodePath($board_id).'/lists/'.self::encodePath($list_id), $params);
+    }
+
+    public function deleteList(int|string $group_id, int $board_id, int $list_id): mixed
+    {
+        return $this->delete('groups/'.self::encodePath($group_id).'/boards/'.self::encodePath($board_id).'/lists/'.self::encodePath($list_id));
+    }
+}
diff --git a/src/Api/GroupsEpics.php b/src/Api/GroupsEpics.php
new file mode 100644
index 000000000..603aef6e0
--- /dev/null
+++ b/src/Api/GroupsEpics.php
@@ -0,0 +1,83 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Api;
+
+class GroupsEpics extends AbstractApi
+{
+    /**
+     * @var string
+     */
+    public const STATE_ALL = 'all';
+
+    /**
+     * @var string
+     */
+    public const STATE_OPENED = 'opened';
+
+    /**
+     * @var string
+     */
+    public const STATE_CLOSED = 'closed';
+
+    /**
+     * @param array      $parameters {
+     *
+     *     @var int[]  $iids   return only the epics having the given iids
+     *     @var string $state  return only active or closed epics
+     *     @var string $search Return only epics with a title or description matching the provided string.
+     * }
+     */
+    public function all(int|string $group_id, array $parameters = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+        $resolver->setDefined('iids')
+            ->setAllowedTypes('iids', 'array')
+            ->setAllowedValues('iids', function (array $value) {
+                return \count($value) === \count(\array_filter($value, 'is_int'));
+            })
+        ;
+        $resolver->setDefined('state')
+            ->setAllowedValues('state', [self::STATE_ALL, self::STATE_OPENED, self::STATE_CLOSED])
+        ;
+        $resolver->setDefined('search');
+
+        return $this->get('groups/'.self::encodePath($group_id).'/epics', $resolver->resolve($parameters));
+    }
+
+    public function show(int|string $group_id, int $epic_id): mixed
+    {
+        return $this->get('groups/'.self::encodePath($group_id).'/epics/'.self::encodePath($epic_id));
+    }
+
+    public function create(int|string $group_id, array $params): mixed
+    {
+        return $this->post('groups/'.self::encodePath($group_id).'/epics', $params);
+    }
+
+    public function update(int|string $group_id, int $epic_id, array $params): mixed
+    {
+        return $this->put('groups/'.self::encodePath($group_id).'/epics/'.self::encodePath($epic_id), $params);
+    }
+
+    public function remove(int|string $group_id, int $epic_id): mixed
+    {
+        return $this->delete('groups/'.self::encodePath($group_id).'/epics/'.self::encodePath($epic_id));
+    }
+
+    public function issues(int|string $group_id, int $epic_iid): mixed
+    {
+        return $this->get('groups/'.self::encodePath($group_id).'/epics/'.self::encodePath($epic_iid).'/issues');
+    }
+}
diff --git a/src/Api/GroupsMilestones.php b/src/Api/GroupsMilestones.php
new file mode 100644
index 000000000..50fac6674
--- /dev/null
+++ b/src/Api/GroupsMilestones.php
@@ -0,0 +1,99 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Api;
+
+use Symfony\Component\OptionsResolver\Options;
+
+class GroupsMilestones extends AbstractApi
+{
+    /**
+     * @var string
+     */
+    public const STATE_ACTIVE = 'active';
+
+    /**
+     * @var string
+     */
+    public const STATE_CLOSED = 'closed';
+
+    /**
+     * @param array      $parameters {
+     *
+     *     @var int[]  $iids   return only the milestones having the given iids
+     *     @var string $state  return only active or closed milestones
+     *     @var string $search Return only milestones with a title or description matching the provided string
+     *     @var \DateTimeInterface $updated_after Return only milestones updated on or after the given datetime. Expected in ISO 8601 format (2019-03-15T08:00:00Z)
+     *     @var \DateTimeInterface $updated_before Return only milestones updated on or before the given datetime. Expected in ISO 8601 format (2019-03-15T08:00:00Z)
+     * }
+     */
+    public function all(int|string $group_id, array $parameters = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+        $datetimeNormalizer = function (Options $resolver, \DateTimeInterface $value): string {
+            $utc = (new \DateTimeImmutable($value->format(\DateTimeImmutable::RFC3339_EXTENDED)))->setTimezone(new \DateTimeZone('UTC'));
+
+            return $utc->format('Y-m-d\TH:i:s.v\Z');
+        };
+        $resolver->setDefined('iids')
+            ->setAllowedTypes('iids', 'array')
+            ->setAllowedValues('iids', function (array $value) {
+                return \count($value) === \count(\array_filter($value, 'is_int'));
+            })
+        ;
+        $resolver->setDefined('state')
+            ->setAllowedValues('state', [self::STATE_ACTIVE, self::STATE_CLOSED])
+        ;
+        $resolver->setDefined('search');
+
+        $resolver->setDefined('updated_after')
+            ->setAllowedTypes('updated_after', \DateTimeInterface::class)
+            ->setNormalizer('updated_after', $datetimeNormalizer);
+        $resolver->setDefined('updated_before')
+            ->setAllowedTypes('updated_before', \DateTimeInterface::class)
+            ->setNormalizer('updated_before', $datetimeNormalizer);
+
+        return $this->get('groups/'.self::encodePath($group_id).'/milestones', $resolver->resolve($parameters));
+    }
+
+    public function show(int|string $group_id, int $milestone_id): mixed
+    {
+        return $this->get('groups/'.self::encodePath($group_id).'/milestones/'.self::encodePath($milestone_id));
+    }
+
+    public function create(int|string $group_id, array $params): mixed
+    {
+        return $this->post('groups/'.self::encodePath($group_id).'/milestones', $params);
+    }
+
+    public function update(int|string $group_id, int $milestone_id, array $params): mixed
+    {
+        return $this->put('groups/'.self::encodePath($group_id).'/milestones/'.self::encodePath($milestone_id), $params);
+    }
+
+    public function remove(int|string $group_id, int $milestone_id): mixed
+    {
+        return $this->delete('groups/'.self::encodePath($group_id).'/milestones/'.self::encodePath($milestone_id));
+    }
+
+    public function issues(int|string $group_id, int $milestone_id): mixed
+    {
+        return $this->get('groups/'.self::encodePath($group_id).'/milestones/'.self::encodePath($milestone_id).'/issues');
+    }
+
+    public function mergeRequests(int|string $group_id, int $milestone_id): mixed
+    {
+        return $this->get('groups/'.self::encodePath($group_id).'/milestones/'.self::encodePath($milestone_id).'/merge_requests');
+    }
+}
diff --git a/src/Api/IssueBoards.php b/src/Api/IssueBoards.php
new file mode 100644
index 000000000..369de96f7
--- /dev/null
+++ b/src/Api/IssueBoards.php
@@ -0,0 +1,80 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Api;
+
+class IssueBoards extends AbstractApi
+{
+    public function all(int|string|null $project_id = null, array $parameters = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+
+        $path = null === $project_id ? 'boards' : $this->getProjectPath($project_id, 'boards');
+
+        return $this->get($path, $resolver->resolve($parameters));
+    }
+
+    public function show(int|string $project_id, int $board_id): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'boards/'.self::encodePath($board_id)));
+    }
+
+    public function create(int|string $project_id, array $params): mixed
+    {
+        return $this->post($this->getProjectPath($project_id, 'boards'), $params);
+    }
+
+    public function update(int|string $project_id, int $board_id, array $params): mixed
+    {
+        return $this->put($this->getProjectPath($project_id, 'boards/'.self::encodePath($board_id)), $params);
+    }
+
+    public function remove(int|string $project_id, int $board_id): mixed
+    {
+        return $this->delete($this->getProjectPath($project_id, 'boards/'.self::encodePath($board_id)));
+    }
+
+    public function allLists(int|string $project_id, int $board_id): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'boards/'.self::encodePath($board_id).'/lists'));
+    }
+
+    public function showList(int|string $project_id, int $board_id, int $list_id): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'boards/'.self::encodePath($board_id).'/lists/'.self::encodePath($list_id)));
+    }
+
+    public function createList(int|string $project_id, int $board_id, int $label_id): mixed
+    {
+        $params = [
+            'label_id' => $label_id,
+        ];
+
+        return $this->post($this->getProjectPath($project_id, 'boards/'.self::encodePath($board_id).'/lists'), $params);
+    }
+
+    public function updateList(int|string $project_id, int $board_id, int $list_id, int $position): mixed
+    {
+        $params = [
+            'position' => $position,
+        ];
+
+        return $this->put($this->getProjectPath($project_id, 'boards/'.self::encodePath($board_id).'/lists/'.self::encodePath($list_id)), $params);
+    }
+
+    public function deleteList(int|string $project_id, int $board_id, int $list_id): mixed
+    {
+        return $this->delete($this->getProjectPath($project_id, 'boards/'.self::encodePath($board_id).'/lists/'.self::encodePath($list_id)));
+    }
+}
diff --git a/src/Api/IssueLinks.php b/src/Api/IssueLinks.php
new file mode 100644
index 000000000..d5c8399f7
--- /dev/null
+++ b/src/Api/IssueLinks.php
@@ -0,0 +1,48 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Api;
+
+class IssueLinks extends AbstractApi
+{
+    public function all(int|string $project_id, int $issue_iid): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'issues/'.self::encodePath($issue_iid)).'/links');
+    }
+
+    /**
+     * @param array      $parameters        {
+     *
+     *     @var string $link_type
+     * }
+     */
+    public function create(int|string $project_id, int $issue_iid, int|string $target_project_id, int $target_issue_iid, array $parameters = []): mixed
+    {
+        $parameters['target_project_id'] = $target_project_id;
+        $parameters['target_issue_iid'] = $target_issue_iid;
+
+        return $this->post($this->getProjectPath($project_id, 'issues/'.self::encodePath($issue_iid).'/links'), $parameters);
+    }
+
+    /**
+     * @param array      $parameters    {
+     *
+     *     @var string $link_type
+     * }
+     */
+    public function remove(int|string $project_id, int $issue_iid, int|string $issue_link_id, array $parameters = []): mixed
+    {
+        return $this->delete($this->getProjectPath($project_id, 'issues/'.self::encodePath($issue_iid)).'/links/'.self::encodePath($issue_link_id), $parameters);
+    }
+}
diff --git a/src/Api/Issues.php b/src/Api/Issues.php
new file mode 100644
index 000000000..10f74f261
--- /dev/null
+++ b/src/Api/Issues.php
@@ -0,0 +1,306 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Api;
+
+use Symfony\Component\OptionsResolver\Options;
+use Symfony\Component\OptionsResolver\OptionsResolver;
+
+class Issues extends AbstractApi
+{
+    /**
+     * @var string
+     */
+    public const STATE_OPENED = 'opened';
+
+    /**
+     * @var string
+     */
+    public const STATE_CLOSED = 'closed';
+
+    /**
+     * @param array           $parameters {
+     *
+     *     @var string $state                return all issues or just those that are opened or closed
+     *     @var string $labels               comma-separated list of label names, issues must have all labels to be returned
+     *     @var bool   $with_labels_details  if true, response will return more details for each label
+     *     @var string $milestone            the milestone title
+     *     @var string $scope                return issues for the given scope: created-by-me, assigned-to-me or all (default is created-by-me)
+     *     @var int[]  $iids                 return only the issues having the given iid
+     *     @var string $order_by             return requests ordered by created_at or updated_at fields (default is created_at)
+     *     @var string $sort                 return requests sorted in asc or desc order (default is desc)
+     *     @var bool   $confidential         filter confidential or public issues
+     *     @var string $search               search issues against their title and description
+     *     @var int    $author_id            filter issues assigned to the specified author user id
+     *     @var int    $assignee_id          filter issues assigned to the specified assignee user id
+     *     @var int    $iteration_id         filter issues assigned to the specified iteration id
+     *     @var string $iteration_title      filter issues assigned to the specified iteration title
+     * }
+     */
+    public function all(int|string|null $project_id = null, array $parameters = []): mixed
+    {
+        $path = null === $project_id ? 'issues' : $this->getProjectPath($project_id, 'issues');
+
+        return $this->get($path, $this->createOptionsResolver()->resolve($parameters));
+    }
+
+    public function group(int|string $group_id, array $parameters = []): mixed
+    {
+        return $this->get(
+            'groups/'.self::encodePath($group_id).'/issues',
+            $this->createOptionsResolver()->resolve($parameters)
+        );
+    }
+
+    public function show(int|string $project_id, int $issue_iid): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'issues/'.self::encodePath($issue_iid)));
+    }
+
+    public function create(int|string $project_id, array $params): mixed
+    {
+        return $this->post($this->getProjectPath($project_id, 'issues'), $params);
+    }
+
+    public function update(int|string $project_id, int $issue_iid, array $params): mixed
+    {
+        return $this->put($this->getProjectPath($project_id, 'issues/'.self::encodePath($issue_iid)), $params);
+    }
+
+    public function reorder(int|string $project_id, int $issue_iid, array $params): mixed
+    {
+        return $this->put($this->getProjectPath($project_id, 'issues/'.self::encodePath($issue_iid)).'/reorder', $params);
+    }
+
+    public function move(int|string $project_id, int $issue_iid, int|string $to_project_id): mixed
+    {
+        return $this->post($this->getProjectPath($project_id, 'issues/'.self::encodePath($issue_iid)).'/move', [
+            'to_project_id' => $to_project_id,
+        ]);
+    }
+
+    public function remove(int|string $project_id, int $issue_iid): mixed
+    {
+        return $this->delete($this->getProjectPath($project_id, 'issues/'.self::encodePath($issue_iid)));
+    }
+
+    public function showNotes(int|string $project_id, int $issue_iid): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'issues/'.self::encodePath($issue_iid).'/notes'));
+    }
+
+    public function showNote(int|string $project_id, int $issue_iid, int $note_id): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'issues/'.self::encodePath($issue_iid).'/notes/'.self::encodePath($note_id)));
+    }
+
+    public function addNote(int|string $project_id, int $issue_iid, string $body, array $params = []): mixed
+    {
+        $params['body'] = $body;
+
+        return $this->post($this->getProjectPath($project_id, 'issues/'.self::encodePath($issue_iid).'/notes'), $params);
+    }
+
+    public function updateNote(int|string $project_id, int $issue_iid, int $note_id, string $body, array $params = []): mixed
+    {
+        $params['body'] = $body;
+
+        return $this->put($this->getProjectPath($project_id, 'issues/'.self::encodePath($issue_iid).'/notes/'.self::encodePath($note_id)), $params);
+    }
+
+    public function removeNote(int|string $project_id, int $issue_iid, int $note_id): mixed
+    {
+        return $this->delete($this->getProjectPath($project_id, 'issues/'.self::encodePath($issue_iid).'/notes/'.self::encodePath($note_id)));
+    }
+
+    public function showDiscussions(int|string $project_id, int $issue_iid): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'issues/'.self::encodePath($issue_iid)).'/discussions');
+    }
+
+    public function showDiscussion(int|string $project_id, int $issue_iid, string $discussion_id): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'issues/'.self::encodePath($issue_iid)).'/discussions/'.self::encodePath($discussion_id));
+    }
+
+    public function addDiscussion(int|string $project_id, int $issue_iid, string $body): mixed
+    {
+        return $this->post($this->getProjectPath($project_id, 'issues/'.self::encodePath($issue_iid).'/discussions'), ['body' => $body]);
+    }
+
+    public function addDiscussionNote(int|string $project_id, int $issue_iid, string $discussion_id, string $body): mixed
+    {
+        return $this->post($this->getProjectPath($project_id, 'issues/'.self::encodePath($issue_iid).'/discussions/'.self::encodePath($discussion_id).'/notes'), ['body' => $body]);
+    }
+
+    public function updateDiscussionNote(int|string $project_id, int $issue_iid, string $discussion_id, int $note_id, string $body): mixed
+    {
+        return $this->put($this->getProjectPath($project_id, 'issues/'.self::encodePath($issue_iid).'/discussions/'.self::encodePath($discussion_id).'/notes/'.self::encodePath($note_id)), [
+            'body' => $body,
+        ]);
+    }
+
+    public function removeDiscussionNote(int|string $project_id, int $issue_iid, string $discussion_id, int $note_id): mixed
+    {
+        return $this->delete($this->getProjectPath($project_id, 'issues/'.self::encodePath($issue_iid).'/discussions/'.self::encodePath($discussion_id).'/notes/'.self::encodePath($note_id)));
+    }
+
+    public function setTimeEstimate(int|string $project_id, int $issue_iid, string $duration): mixed
+    {
+        return $this->post($this->getProjectPath($project_id, 'issues/'.self::encodePath($issue_iid).'/time_estimate'), ['duration' => $duration]);
+    }
+
+    public function resetTimeEstimate(int|string $project_id, int $issue_iid): mixed
+    {
+        return $this->post($this->getProjectPath($project_id, 'issues/'.self::encodePath($issue_iid).'/reset_time_estimate'));
+    }
+
+    public function addSpentTime(int|string $project_id, int $issue_iid, string $duration): mixed
+    {
+        return $this->post($this->getProjectPath($project_id, 'issues/'.self::encodePath($issue_iid).'/add_spent_time'), ['duration' => $duration]);
+    }
+
+    public function resetSpentTime(int|string $project_id, int $issue_iid): mixed
+    {
+        return $this->post($this->getProjectPath($project_id, 'issues/'.self::encodePath($issue_iid).'/reset_spent_time'));
+    }
+
+    public function getTimeStats(int|string $project_id, int $issue_iid): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'issues/'.self::encodePath($issue_iid).'/time_stats'));
+    }
+
+    /**
+     * Subscribes the authenticated user to an issue to receive notifications.
+     * If the user is already subscribed to the issue, the status code 304 is returned.
+     *
+     * @see https://docs.gitlab.com/ee/api/issues.html#subscribe-to-an-issue
+     *
+     * @param int|string $project_id The ID or URL-encoded path of the project owned by the authenticated user
+     * @param int        $issue_iid  The internal ID of a project’s issue
+     */
+    public function subscribe(int|string $project_id, int $issue_iid): mixed
+    {
+        return $this->post($this->getProjectPath($project_id, 'issues/'.self::encodePath($issue_iid).'/subscribe'));
+    }
+
+    /**
+     * Unsubscribes the authenticated user from the issue to not receive notifications from it.
+     * If the user is not subscribed to the issue, the status code 304 is returned.
+     *
+     * @see https://docs.gitlab.com/ee/api/issues.html#unsubscribe-from-an-issue
+     *
+     * @param int|string $project_id The ID or URL-encoded path of the project owned by the authenticated user
+     * @param int        $issue_iid  The internal ID of a project’s issue
+     */
+    public function unsubscribe(int|string $project_id, int $issue_iid): mixed
+    {
+        return $this->post($this->getProjectPath($project_id, 'issues/'.self::encodePath($issue_iid).'/unsubscribe'));
+    }
+
+    public function awardEmoji(int|string $project_id, int $issue_iid): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'issues/'.self::encodePath($issue_iid).'/award_emoji'));
+    }
+
+    public function removeAwardEmoji(int|string $project_id, int $issue_iid, int $award_id): mixed
+    {
+        return $this->delete($this->getProjectPath($project_id, 'issues/'.self::encodePath($issue_iid).'/award_emoji/'.self::encodePath($award_id)));
+    }
+
+    public function closedByMergeRequests(int|string $project_id, int $issue_iid): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'issues/'.self::encodePath($issue_iid)).'/closed_by');
+    }
+
+    public function relatedMergeRequests(int|string $project_id, int $issue_iid): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'issues/'.self::encodePath($issue_iid).'/related_merge_requests'));
+    }
+
+    public function showParticipants(int|string $project_id, int $issue_iid): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'issues/'.self::encodePath($issue_iid)).'/participants');
+    }
+
+    public function showResourceLabelEvents(int|string $project_id, int $issue_iid): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'issues/'.self::encodePath($issue_iid)).'/resource_label_events');
+    }
+
+    public function showResourceLabelEvent(int|string $project_id, int $issue_iid, int $resource_label_event_id): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'issues/'.self::encodePath($issue_iid)).'/resource_label_events/'.self::encodePath($resource_label_event_id));
+    }
+
+    protected function createOptionsResolver(): OptionsResolver
+    {
+        $resolver = parent::createOptionsResolver();
+        $booleanNormalizer = function (Options $resolver, $value): string {
+            return $value ? 'true' : 'false';
+        };
+
+        $resolver->setDefined('state')
+            ->setAllowedValues('state', [self::STATE_OPENED, self::STATE_CLOSED])
+        ;
+        $resolver->setDefined('labels');
+        $resolver->setDefined('milestone');
+        $resolver->setDefined('milestone_id')
+            ->setAllowedTypes('milestone_id', 'integer');
+        $resolver->setDefined('with_labels_details')
+            ->setAllowedTypes('with_labels_details', 'bool')
+            ->setNormalizer('with_labels_details', $booleanNormalizer)
+        ;
+        $resolver->setDefined('iids')
+            ->setAllowedTypes('iids', 'array')
+            ->setAllowedValues('iids', function (array $value) {
+                return \count($value) === \count(\array_filter($value, 'is_int'));
+            })
+        ;
+        $resolver->setDefined('scope')
+            ->setAllowedValues('scope', ['created-by-me', 'assigned-to-me', 'all'])
+        ;
+        $resolver->setDefined('order_by')
+            ->setAllowedValues('order_by', ['created_at', 'updated_at', 'priority', 'due_date', 'relative_position', 'label_priority', 'milestone_due', 'popularity', 'weight'])
+        ;
+        $resolver->setDefined('sort')
+            ->setAllowedValues('sort', ['asc', 'desc'])
+        ;
+        $resolver->setDefined('confidential')
+            ->setAllowedValues('confidential', [false, true])
+        ;
+        $resolver->setDefined('search');
+        $resolver->setDefined('created_after');
+        $resolver->setDefined('created_before');
+        $resolver->setDefined('updated_after');
+        $resolver->setDefined('updated_before');
+        $resolver->setDefined('author_id')
+            ->setAllowedTypes('author_id', 'integer')
+        ;
+        $resolver->setDefined('assignee_id')
+            ->setAllowedTypes('assignee_id', 'integer')
+        ;
+        $resolver->setDefined('iteration_id')
+            ->setAllowedTypes('iteration_id', 'integer')
+        ;
+        $resolver->setDefined('iteration_title')
+            ->setAllowedTypes('iteration_title', 'string')
+        ;
+        $resolver->setDefined('weight')
+            ->setAllowedTypes('weight', 'integer')
+        ;
+
+        return $resolver;
+    }
+}
diff --git a/src/Api/IssuesStatistics.php b/src/Api/IssuesStatistics.php
new file mode 100644
index 000000000..33e459ef8
--- /dev/null
+++ b/src/Api/IssuesStatistics.php
@@ -0,0 +1,98 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Api;
+
+use Symfony\Component\OptionsResolver\Options;
+use Symfony\Component\OptionsResolver\OptionsResolver;
+
+class IssuesStatistics extends AbstractApi
+{
+    public function all(array $parameters): mixed
+    {
+        return $this->get('issues_statistics', $this->createOptionsResolver()->resolve($parameters));
+    }
+
+    public function project(int|string $project_id, array $parameters): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'issues_statistics'), $this->createOptionsResolver()->resolve($parameters));
+    }
+
+    public function group(int|string $group_id, array $parameters): mixed
+    {
+        return $this->get('groups/'.self::encodePath($group_id).'/issues_statistics', $this->createOptionsResolver()->resolve($parameters));
+    }
+
+    protected function createOptionsResolver(): OptionsResolver
+    {
+        $resolver = new OptionsResolver();
+
+        $resolver->setDefined('milestone')
+            ->setAllowedTypes('milestone', 'string');
+
+        $resolver->setDefined('labels')
+            ->setAllowedTypes('labels', 'string');
+
+        $resolver->setDefined('scope')
+            ->setAllowedValues('scope', ['created-by-me', 'assigned-to-me', 'all']);
+
+        $resolver->setDefined('author_id')
+            ->setAllowedTypes('author_id', 'integer');
+
+        $resolver->setDefined('author_username')
+            ->setAllowedTypes('author_username', 'string');
+
+        $resolver->setDefined('assignee_id')
+            ->setAllowedTypes('assignee_id', 'integer');
+
+        $resolver->setDefined('assignee_username')
+            ->setAllowedTypes('assignee_username', 'string');
+
+        $resolver->setDefined('my_reaction_emoji')
+            ->setAllowedTypes('my_reaction_emoji', 'string');
+
+        $resolver->setDefined('search')
+            ->setAllowedTypes('search', 'string');
+
+        $datetimeNormalizer = function (Options $resolver, \DateTimeInterface $value): string {
+            return $value->format('c');
+        };
+
+        $resolver->setDefined('created_after')
+            ->setAllowedTypes('created_after', \DateTimeInterface::class)
+            ->setNormalizer('created_after', $datetimeNormalizer);
+
+        $resolver->setDefined('created_before')
+            ->setAllowedTypes('created_before', \DateTimeInterface::class)
+            ->setNormalizer('created_before', $datetimeNormalizer);
+
+        $resolver->setDefined('updated_after')
+            ->setAllowedTypes('updated_after', \DateTimeInterface::class)
+            ->setNormalizer('updated_after', $datetimeNormalizer);
+
+        $resolver->setDefined('updated_before')
+            ->setAllowedTypes('updated_before', \DateTimeInterface::class)
+            ->setNormalizer('updated_before', $datetimeNormalizer);
+
+        $booleanNormalizer = function (Options $resolver, $value): string {
+            return $value ? 'true' : 'false';
+        };
+
+        $resolver->setDefined('confidential')
+            ->setAllowedTypes('confidential', 'bool')
+            ->setNormalizer('confidential', $booleanNormalizer);
+
+        return $resolver;
+    }
+}
diff --git a/src/Api/Jobs.php b/src/Api/Jobs.php
new file mode 100644
index 000000000..1850e535e
--- /dev/null
+++ b/src/Api/Jobs.php
@@ -0,0 +1,200 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Api;
+
+use Psr\Http\Message\StreamInterface;
+use Symfony\Component\OptionsResolver\OptionsResolver;
+
+class Jobs extends AbstractApi
+{
+    /**
+     * @var string
+     */
+    public const SCOPE_CREATED = 'created';
+
+    /**
+     * @var string
+     */
+    public const SCOPE_PENDING = 'pending';
+
+    /**
+     * @var string
+     */
+    public const SCOPE_RUNNING = 'running';
+
+    /**
+     * @var string
+     */
+    public const SCOPE_FAILED = 'failed';
+
+    /**
+     * @var string
+     */
+    public const SCOPE_SUCCESS = 'success';
+
+    /**
+     * @var string
+     */
+    public const SCOPE_CANCELED = 'canceled';
+
+    /**
+     * @var string
+     */
+    public const SCOPE_SKIPPED = 'skipped';
+
+    /**
+     * @var string
+     */
+    public const SCOPE_MANUAL = 'manual';
+
+    /**
+     * @param array      $parameters {
+     *
+     *     @var string|string[] $scope The scope of jobs to show, one or array of: created, pending, running, failed,
+     *                                 success, canceled, skipped, manual; showing all jobs if none provided.
+     * }
+     */
+    public function all(int|string $project_id, array $parameters = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+
+        return $this->get('projects/'.self::encodePath($project_id).'/jobs', $resolver->resolve($parameters));
+    }
+
+    /**
+     * @param array      $parameters  {
+     *
+     *     @var string|string[] $scope The scope of jobs to show, one or array of: created, pending, running, failed,
+     *                                 success, canceled, skipped, manual; showing all jobs if none provided.
+     * }
+     */
+    public function pipelineJobs(int|string $project_id, int $pipeline_id, array $parameters = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+
+        return $this->get(
+            $this->getProjectPath($project_id, 'pipelines/').self::encodePath($pipeline_id).'/jobs',
+            $resolver->resolve($parameters)
+        );
+    }
+
+    /**
+     * @param array      $parameters  {
+     *
+     *     @var string|string[] $scope            The scope of bridge jobs to show, one or array of: created, pending, running, failed,
+     *                                            success, canceled, skipped, manual; showing all jobs if none provided
+     *     @var bool            $include_retried  Include retried jobs in the response. Defaults to false. Introduced in GitLab 13.9.
+     * }
+     */
+    public function pipelineBridges(int|string $project_id, int $pipeline_id, array $parameters = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+
+        return $this->get(
+            $this->getProjectPath($project_id, 'pipelines/').self::encodePath($pipeline_id).'/bridges',
+            $resolver->resolve($parameters)
+        );
+    }
+
+    public function show(int|string $project_id, int $job_id): mixed
+    {
+        return $this->get('projects/'.self::encodePath($project_id).'/jobs/'.self::encodePath($job_id));
+    }
+
+    public function artifacts(int|string $project_id, int $job_id): StreamInterface
+    {
+        return $this->getAsResponse('projects/'.self::encodePath($project_id).'/jobs/'.self::encodePath($job_id).'/artifacts')->getBody();
+    }
+
+    public function artifactsByRefName(int|string $project_id, string $ref_name, string $job_name): StreamInterface
+    {
+        return $this->getAsResponse('projects/'.self::encodePath($project_id).'/jobs/artifacts/'.self::encodePath($ref_name).'/download', [
+            'job' => $job_name,
+        ])->getBody();
+    }
+
+    public function artifactByRefName(int|string $project_id, string $ref_name, string $job_name, string $artifact_path): StreamInterface
+    {
+        return $this->getAsResponse('projects/'.self::encodePath($project_id).'/jobs/artifacts/'.self::encodePath($ref_name).'/raw/'.self::encodePath($artifact_path), [
+            'job' => $job_name,
+        ])->getBody();
+    }
+
+    public function artifactByJobId(int|string $project_id, int $job_id, string $artifact_path): StreamInterface
+    {
+        return $this->getAsResponse('projects/'.self::encodePath($project_id).'/jobs/'.self::encodePath($job_id).'/artifacts/'.self::encodePath($artifact_path))->getBody();
+    }
+
+    public function trace(int|string $project_id, int $job_id): mixed
+    {
+        return $this->get('projects/'.self::encodePath($project_id).'/jobs/'.self::encodePath($job_id).'/trace');
+    }
+
+    public function cancel(int|string $project_id, int $job_id): mixed
+    {
+        return $this->post('projects/'.self::encodePath($project_id).'/jobs/'.self::encodePath($job_id).'/cancel');
+    }
+
+    public function retry(int|string $project_id, int $job_id): mixed
+    {
+        return $this->post('projects/'.self::encodePath($project_id).'/jobs/'.self::encodePath($job_id).'/retry');
+    }
+
+    public function erase(int|string $project_id, int $job_id): mixed
+    {
+        return $this->post('projects/'.self::encodePath($project_id).'/jobs/'.self::encodePath($job_id).'/erase');
+    }
+
+    public function keepArtifacts(int|string $project_id, int $job_id): mixed
+    {
+        return $this->post('projects/'.self::encodePath($project_id).'/jobs/'.self::encodePath($job_id).'/artifacts/keep');
+    }
+
+    public function play(int|string $project_id, int $job_id): mixed
+    {
+        return $this->post('projects/'.self::encodePath($project_id).'/jobs/'.self::encodePath($job_id).'/play');
+    }
+
+    protected function createOptionsResolver(): OptionsResolver
+    {
+        $allowedScopeValues = [
+            self::SCOPE_CANCELED,
+            self::SCOPE_CREATED,
+            self::SCOPE_FAILED,
+            self::SCOPE_MANUAL,
+            self::SCOPE_PENDING,
+            self::SCOPE_RUNNING,
+            self::SCOPE_SKIPPED,
+            self::SCOPE_SUCCESS,
+        ];
+
+        $resolver = parent::createOptionsResolver();
+        $resolver->setDefined('scope')
+            ->setAllowedTypes('scope', ['string', 'array'])
+            ->setAllowedValues('scope', $allowedScopeValues)
+            ->addAllowedValues('scope', function ($value) use ($allowedScopeValues) {
+                return \is_array($value) && 0 === \count(\array_diff($value, $allowedScopeValues));
+            })
+            ->setNormalizer('scope', function (OptionsResolver $resolver, $value) {
+                return (array) $value;
+            })
+        ;
+
+        $resolver->setDefined('include_retried')
+            ->setAllowedTypes('include_retried', ['bool']);
+
+        return $resolver;
+    }
+}
diff --git a/src/Api/Keys.php b/src/Api/Keys.php
new file mode 100644
index 000000000..05f4d4d5b
--- /dev/null
+++ b/src/Api/Keys.php
@@ -0,0 +1,23 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Api;
+
+class Keys extends AbstractApi
+{
+    public function show(int $id): mixed
+    {
+        return $this->get('keys/'.self::encodePath($id));
+    }
+}
diff --git a/src/Api/MergeRequests.php b/src/Api/MergeRequests.php
new file mode 100644
index 000000000..83d5b7d07
--- /dev/null
+++ b/src/Api/MergeRequests.php
@@ -0,0 +1,371 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Api;
+
+use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException;
+use Symfony\Component\OptionsResolver\Exception\UndefinedOptionsException;
+use Symfony\Component\OptionsResolver\Options;
+
+class MergeRequests extends AbstractApi
+{
+    /**
+     * @var string
+     */
+    public const STATE_ALL = 'all';
+
+    /**
+     * @var string
+     */
+    public const STATE_MERGED = 'merged';
+
+    /**
+     * @var string
+     */
+    public const STATE_OPENED = 'opened';
+
+    /**
+     * @var string
+     */
+    public const STATE_CLOSED = 'closed';
+
+    /**
+     * @var string
+     */
+    public const STATE_LOCKED = 'locked';
+
+    /**
+     * @param array           $parameters {
+     *
+     *     @var int[]              $iids           return the request having the given iid
+     *     @var string             $state          return all merge requests or just those that are opened, closed, or
+     *                                             merged
+     *     @var string             $scope          Return merge requests for the given scope: created-by-me,
+     *                                             assigned-to-me or all (default is created-by-me)
+     *     @var string             $order_by       return requests ordered by created_at or updated_at fields (default is created_at)
+     *     @var string             $sort           return requests sorted in asc or desc order (default is desc)
+     *     @var string             $milestone      return merge requests for a specific milestone
+     *     @var string             $view           if simple, returns the iid, URL, title, description, and basic state of merge request
+     *     @var string             $labels         return merge requests matching a comma separated list of labels
+     *     @var \DateTimeInterface $created_after  return merge requests created after the given time (inclusive)
+     *     @var \DateTimeInterface $created_before return merge requests created before the given time (inclusive)
+     *     @var int                $reviewer_id    return merge requests which have the user as a reviewer with the given user id
+     *     @var bool               $wip            return only draft merge requests (true) or only non-draft merge requests (false)
+     * }
+     *
+     * @throws UndefinedOptionsException if an option name is undefined
+     * @throws InvalidOptionsException   if an option doesn't fulfill the specified validation rules
+     */
+    public function all(int|string|null $project_id = null, array $parameters = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+        $datetimeNormalizer = function (Options $resolver, \DateTimeInterface $value): string {
+            $utc = (new \DateTimeImmutable($value->format(\DateTimeImmutable::RFC3339_EXTENDED)))->setTimezone(new \DateTimeZone('UTC'));
+
+            return $utc->format('Y-m-d\TH:i:s.v\Z');
+        };
+        $resolver->setDefined('iids')
+            ->setAllowedTypes('iids', 'array')
+            ->setAllowedValues('iids', function (array $value) {
+                return \count($value) === \count(\array_filter($value, 'is_int'));
+            })
+        ;
+        $resolver->setDefined('state')
+            ->setAllowedValues('state', [self::STATE_ALL, self::STATE_MERGED, self::STATE_OPENED, self::STATE_CLOSED, self::STATE_LOCKED])
+        ;
+        $resolver->setDefined('scope')
+            ->setAllowedValues('scope', ['created-by-me', 'assigned-to-me', 'all'])
+        ;
+        $resolver->setDefined('order_by')
+            ->setAllowedValues('order_by', ['created_at', 'updated_at'])
+        ;
+        $resolver->setDefined('sort')
+            ->setAllowedValues('sort', ['asc', 'desc'])
+        ;
+        $resolver->setDefined('milestone');
+        $resolver->setDefined('view')
+            ->setAllowedValues('view', ['simple'])
+        ;
+        $resolver->setDefined('labels');
+        $resolver->setDefined('created_after')
+            ->setAllowedTypes('created_after', \DateTimeInterface::class)
+            ->setNormalizer('created_after', $datetimeNormalizer)
+        ;
+        $resolver->setDefined('created_before')
+            ->setAllowedTypes('created_before', \DateTimeInterface::class)
+            ->setNormalizer('created_before', $datetimeNormalizer)
+        ;
+
+        $resolver->setDefined('updated_after')
+            ->setAllowedTypes('updated_after', \DateTimeInterface::class)
+            ->setNormalizer('updated_after', $datetimeNormalizer)
+        ;
+        $resolver->setDefined('updated_before')
+            ->setAllowedTypes('updated_before', \DateTimeInterface::class)
+            ->setNormalizer('updated_before', $datetimeNormalizer)
+        ;
+
+        $resolver->setDefined('scope')
+            ->setAllowedValues('scope', ['created_by_me', 'assigned_to_me', 'all'])
+        ;
+        $resolver->setDefined('author_id')
+            ->setAllowedTypes('author_id', 'integer');
+
+        $resolver->setDefined('assignee_id')
+            ->setAllowedTypes('assignee_id', 'integer');
+
+        $resolver->setDefined('search');
+        $resolver->setDefined('source_branch');
+        $resolver->setDefined('target_branch');
+        $resolver->setDefined('with_merge_status_recheck')
+            ->setAllowedTypes('with_merge_status_recheck', 'bool')
+        ;
+        $resolver->setDefined('approved_by_ids')
+            ->setAllowedTypes('approved_by_ids', 'array')
+            ->setAllowedValues('approved_by_ids', function (array $value) {
+                return \count($value) === \count(\array_filter($value, 'is_int'));
+            })
+        ;
+        $resolver->setDefined('reviewer_id')
+            ->setAllowedTypes('reviewer_id', 'integer');
+        $resolver->setDefined('wip')
+            ->setAllowedTypes('wip', 'boolean')
+            ->addNormalizer('wip', static function ($resolver, $wip) {
+                return $wip ? 'yes' : 'no';
+            });
+
+        $path = null === $project_id ? 'merge_requests' : $this->getProjectPath($project_id, 'merge_requests');
+
+        return $this->get($path, $resolver->resolve($parameters));
+    }
+
+    /**
+     * @param array      $parameters {
+     *
+     *     @var bool               $include_diverged_commits_count      Return the commits behind the target branch
+     *     @var bool               $include_rebase_in_progress          Return whether a rebase operation is in progress
+     * }
+     */
+    public function show(int|string $project_id, int $mr_iid, array $parameters = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+        $resolver->setDefined('include_diverged_commits_count')
+            ->setAllowedTypes('include_diverged_commits_count', 'bool')
+        ;
+        $resolver->setDefined('include_rebase_in_progress')
+            ->setAllowedTypes('include_rebase_in_progress', 'bool')
+        ;
+
+        return $this->get($this->getProjectPath($project_id, 'merge_requests/'.self::encodePath($mr_iid)), $resolver->resolve($parameters));
+    }
+
+    /**
+     * @param array<string,mixed> $parameters {
+     *
+     *     @var int        $assignee_id       the assignee id
+     *     @var int|string $target_project_id the target project id
+     *     @var string     $description       the description
+     * }
+     */
+    public function create(int|string $project_id, string $source, string $target, string $title, array $parameters = []): mixed
+    {
+        $baseParams = [
+            'source_branch' => $source,
+            'target_branch' => $target,
+            'title' => $title,
+        ];
+
+        return $this->post(
+            $this->getProjectPath($project_id, 'merge_requests'),
+            \array_merge($baseParams, $parameters)
+        );
+    }
+
+    public function update(int|string $project_id, int $mr_iid, array $parameters): mixed
+    {
+        return $this->put($this->getProjectPath($project_id, 'merge_requests/'.self::encodePath($mr_iid)), $parameters);
+    }
+
+    public function merge(int|string $project_id, int $mr_iid, array $parameters = []): mixed
+    {
+        return $this->put($this->getProjectPath($project_id, 'merge_requests/'.self::encodePath($mr_iid).'/merge'), $parameters);
+    }
+
+    public function showNotes(int|string $project_id, int $mr_iid): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'merge_requests/'.self::encodePath($mr_iid).'/notes'));
+    }
+
+    public function showNote(int|string $project_id, int $mr_iid, int $note_id): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'merge_requests/'.self::encodePath($mr_iid).'/notes/'.self::encodePath($note_id)));
+    }
+
+    public function addNote(int|string $project_id, int $mr_iid, string $body, array $params = []): mixed
+    {
+        $params['body'] = $body;
+
+        return $this->post($this->getProjectPath($project_id, 'merge_requests/'.self::encodePath($mr_iid).'/notes'), $params);
+    }
+
+    public function updateNote(int|string $project_id, int $mr_iid, int $note_id, string $body): mixed
+    {
+        return $this->put($this->getProjectPath($project_id, 'merge_requests/'.self::encodePath($mr_iid).'/notes/'.self::encodePath($note_id)), [
+            'body' => $body,
+        ]);
+    }
+
+    public function removeNote(int|string $project_id, int $mr_iid, int $note_id): mixed
+    {
+        return $this->delete($this->getProjectPath($project_id, 'merge_requests/'.self::encodePath($mr_iid).'/notes/'.self::encodePath($note_id)));
+    }
+
+    public function showDiscussions(int|string $project_id, int $mr_iid): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'merge_requests/'.self::encodePath($mr_iid)).'/discussions');
+    }
+
+    public function showDiscussion(int|string $project_id, int $mr_iid, string $discussion_id): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'merge_requests/'.self::encodePath($mr_iid)).'/discussions/'.self::encodePath($discussion_id));
+    }
+
+    public function addDiscussion(int|string $project_id, int $mr_iid, array $params): mixed
+    {
+        return $this->post($this->getProjectPath($project_id, 'merge_requests/'.self::encodePath($mr_iid).'/discussions'), $params);
+    }
+
+    public function resolveDiscussion(int|string $project_id, int $mr_iid, string $discussion_id, bool $resolved = true): mixed
+    {
+        return $this->put($this->getProjectPath($project_id, 'merge_requests/'.self::encodePath($mr_iid).'/discussions/'.self::encodePath($discussion_id)), [
+            'resolved' => $resolved,
+        ]);
+    }
+
+    public function addDiscussionNote(int|string $project_id, int $mr_iid, string $discussion_id, string $body): mixed
+    {
+        return $this->post($this->getProjectPath($project_id, 'merge_requests/'.self::encodePath($mr_iid).'/discussions/'.self::encodePath($discussion_id).'/notes'), ['body' => $body]);
+    }
+
+    public function updateDiscussionNote(int|string $project_id, int $mr_iid, string $discussion_id, int $note_id, array $params): mixed
+    {
+        return $this->put($this->getProjectPath($project_id, 'merge_requests/'.self::encodePath($mr_iid).'/discussions/'.self::encodePath($discussion_id).'/notes/'.self::encodePath($note_id)), $params);
+    }
+
+    public function removeDiscussionNote(int|string $project_id, int $mr_iid, string $discussion_id, int $note_id): mixed
+    {
+        return $this->delete($this->getProjectPath($project_id, 'merge_requests/'.self::encodePath($mr_iid).'/discussions/'.self::encodePath($discussion_id).'/notes/'.self::encodePath($note_id)));
+    }
+
+    public function showParticipants(int|string $project_id, int $mr_iid): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'merge_requests/'.self::encodePath($mr_iid)).'/participants');
+    }
+
+    public function changes(int|string $project_id, int $mr_iid): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'merge_requests/'.self::encodePath($mr_iid).'/changes'));
+    }
+
+    public function commits(int|string $project_id, int $mr_iid): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'merge_requests/'.self::encodePath($mr_iid).'/commits'));
+    }
+
+    public function closesIssues(int|string $project_id, int $mr_iid): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'merge_requests/'.self::encodePath($mr_iid).'/closes_issues'));
+    }
+
+    public function approvals(int|string $project_id, int $mr_iid): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'merge_requests/'.self::encodePath($mr_iid).'/approvals'));
+    }
+
+    public function approve(int|string $project_id, int $mr_iid): mixed
+    {
+        return $this->post($this->getProjectPath($project_id, 'merge_requests/'.self::encodePath($mr_iid).'/approve'));
+    }
+
+    public function unapprove(int|string $project_id, int $mr_iid): mixed
+    {
+        return $this->post($this->getProjectPath($project_id, 'merge_requests/'.self::encodePath($mr_iid).'/unapprove'));
+    }
+
+    public function awardEmoji(int|string $project_id, int $mr_iid): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'merge_requests/'.self::encodePath($mr_iid).'/award_emoji'));
+    }
+
+    public function removeAwardEmoji(int|string $project_id, int $mr_iid, int $award_id): mixed
+    {
+        return $this->delete($this->getProjectPath($project_id, 'merge_requests/'.self::encodePath($mr_iid).'/award_emoji/'.self::encodePath($award_id)));
+    }
+
+    public function rebase(int|string $project_id, int $mr_iid, array $params = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+        $resolver->setDefined('skip_ci')
+            ->setAllowedTypes('skip_ci', 'bool');
+
+        return $this->put($this->getProjectPath($project_id, 'merge_requests/'.self::encodePath($mr_iid)).'/rebase', $resolver->resolve($params));
+    }
+
+    public function approvalState(int|string $project_id, int $mr_iid): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'merge_requests/'.self::encodePath($mr_iid).'/approval_state'));
+    }
+
+    public function levelRules(int|string $project_id, int $mr_iid): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'merge_requests/'.self::encodePath($mr_iid).'/approval_rules'));
+    }
+
+    /**
+     * @param array<string,mixed> $parameters
+     */
+    public function createLevelRule(int|string $project_id, int $mr_iid, string $name, int $approvals_required, array $parameters = []): mixed
+    {
+        $baseParam = [
+            'name' => $name,
+            'approvals_required' => $approvals_required,
+        ];
+
+        return $this->post(
+            $this->getProjectPath($project_id, 'merge_requests/'.self::encodePath($mr_iid).'/approval_rules'),
+            \array_merge($baseParam, $parameters)
+        );
+    }
+
+    /**
+     * @param array<string,mixed> $parameters
+     */
+    public function updateLevelRule(int|string $project_id, int $mr_iid, int $approval_rule_id, string $name, int $approvals_required, array $parameters = []): mixed
+    {
+        $baseParam = [
+            'name' => $name,
+            'approvals_required' => $approvals_required,
+        ];
+
+        return $this->put(
+            $this->getProjectPath($project_id, 'merge_requests/'.self::encodePath($mr_iid).'/approval_rules/'.self::encodePath($approval_rule_id)),
+            \array_merge($baseParam, $parameters)
+        );
+    }
+
+    public function deleteLevelRule(int|string $project_id, int $mr_iid, int $approval_rule_id): mixed
+    {
+        return $this->delete($this->getProjectPath($project_id, 'merge_requests/'.self::encodePath($mr_iid).'/approval_rules/'.self::encodePath($approval_rule_id)));
+    }
+}
diff --git a/src/Api/Milestones.php b/src/Api/Milestones.php
new file mode 100644
index 000000000..ee0761505
--- /dev/null
+++ b/src/Api/Milestones.php
@@ -0,0 +1,83 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Api;
+
+class Milestones extends AbstractApi
+{
+    /**
+     * @var string
+     */
+    public const STATE_ACTIVE = 'active';
+
+    /**
+     * @var string
+     */
+    public const STATE_CLOSED = 'closed';
+
+    /**
+     * @param array      $parameters {
+     *
+     *     @var int[]  $iids   return only the milestones having the given iids
+     *     @var string $state  return only active or closed milestones
+     *     @var string $search Return only milestones with a title or description matching the provided string.
+     * }
+     */
+    public function all(int|string $project_id, array $parameters = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+        $resolver->setDefined('iids')
+            ->setAllowedTypes('iids', 'array')
+            ->setAllowedValues('iids', function (array $value) {
+                return \count($value) === \count(\array_filter($value, 'is_int'));
+            })
+        ;
+        $resolver->setDefined('state')
+            ->setAllowedValues('state', [self::STATE_ACTIVE, self::STATE_CLOSED])
+        ;
+        $resolver->setDefined('search');
+
+        return $this->get($this->getProjectPath($project_id, 'milestones'), $resolver->resolve($parameters));
+    }
+
+    public function show(int|string $project_id, int $milestone_id): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'milestones/'.self::encodePath($milestone_id)));
+    }
+
+    public function create(int|string $project_id, array $params): mixed
+    {
+        return $this->post($this->getProjectPath($project_id, 'milestones'), $params);
+    }
+
+    public function update(int|string $project_id, int $milestone_id, array $params): mixed
+    {
+        return $this->put($this->getProjectPath($project_id, 'milestones/'.self::encodePath($milestone_id)), $params);
+    }
+
+    public function remove(int|string $project_id, int $milestone_id): mixed
+    {
+        return $this->delete($this->getProjectPath($project_id, 'milestones/'.self::encodePath($milestone_id)));
+    }
+
+    public function issues(int|string $project_id, int $milestone_id): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'milestones/'.self::encodePath($milestone_id).'/issues'));
+    }
+
+    public function mergeRequests(int|string $project_id, int $milestone_id): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'milestones/'.self::encodePath($milestone_id).'/merge_requests'));
+    }
+}
diff --git a/src/Api/Packages.php b/src/Api/Packages.php
new file mode 100644
index 000000000..4698905f0
--- /dev/null
+++ b/src/Api/Packages.php
@@ -0,0 +1,102 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Api;
+
+use Symfony\Component\OptionsResolver\Options;
+
+class Packages extends AbstractApi
+{
+    /**
+     * @param array      $parameters {
+     *
+     *     @var string $order_by            the field to use as order. one of created_at (default), name,
+     *                                      version, or type
+     *     @var string $sort                the direction of the order, either asc (default) for ascending order or
+     *                                      desc for descending order
+     *     @var string $package_type        filter the returned packages by type. one of conan, maven, npm, pypi,
+     *                                      composer, nuget, or golang.
+     *     @var string $package_name        filter the project packages with a fuzzy search by name
+     *     @var bool   $include_versionless when set to true, versionless packages are included in the response
+     *     @var string $status              filter the returned packages by status. one of default (default),
+     *                                      hidden, or processing.
+     * }
+     */
+    public function all(int|string $project_id, array $parameters = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+
+        $resolver->setDefined('order_by')
+            ->setAllowedValues('order_by', ['created_at', 'name', 'version', 'type'])
+        ;
+        $resolver->setDefined('sort')
+            ->setAllowedValues('sort', ['asc', 'desc'])
+        ;
+        $resolver->setDefined('package_type')
+            ->setAllowedValues('package_type', ['conan', 'maven', 'npm', 'pypi', 'composer', 'nuget', 'golang'])
+        ;
+        $resolver->setDefined('package_name');
+        $resolver->setDefined('include_versionless')
+            ->setAllowedTypes('include_versionless', 'bool')
+            ->setNormalizer('include_versionless', function (Options $resolver, $value): string {
+                return $value ? 'true' : 'false';
+            })
+        ;
+        $resolver->setDefined('status')
+            ->setAllowedValues('status', ['default', 'hidden', 'processing'])
+        ;
+
+        return $this->get($this->getProjectPath($project_id, 'packages'), $resolver->resolve($parameters));
+    }
+
+    public function show(int|string $project_id, int $package_id): mixed
+    {
+        return $this->get($this->getPackagePath($project_id, $package_id));
+    }
+
+    public function allFiles(int|string $project_id, int $package_id): mixed
+    {
+        return $this->get($this->getPackagePath($project_id, $package_id).'/package_files');
+    }
+
+    public function remove(int|string $project_id, int $package_id): mixed
+    {
+        return $this->delete($this->getPackagePath($project_id, $package_id));
+    }
+
+    public function removeFile(int|string $project_id, int $package_id, int $package_file_id): mixed
+    {
+        return $this->delete(
+            $this->getPackagePath($project_id, $package_id).'/package_files/'.self::encodePath($package_file_id)
+        );
+    }
+
+    public function addGenericFile(int|string $project_id, string $package_name, string $package_version, string $file, string $status = 'default'): mixed
+    {
+        return $this->putFile(
+            $this->getProjectPath(
+                $project_id,
+                'packages/generic/'.self::encodePath($package_name).'/'.self::encodePath($package_version).'/'.self::encodePath(\basename($file))
+            ),
+            $file,
+            [],
+            ['status' => $status]
+        );
+    }
+
+    private function getPackagePath(int|string $project_id, int $package_id): string
+    {
+        return $this->getProjectPath($project_id, 'packages/'.self::encodePath($package_id));
+    }
+}
diff --git a/src/Api/ProjectNamespaces.php b/src/Api/ProjectNamespaces.php
new file mode 100644
index 000000000..d35228ba1
--- /dev/null
+++ b/src/Api/ProjectNamespaces.php
@@ -0,0 +1,37 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Api;
+
+class ProjectNamespaces extends AbstractApi
+{
+    /**
+     * @param array $parameters {
+     *
+     *     @var string $search Returns a list of namespaces the user is authorized to see based on the search criteria.
+     * }
+     */
+    public function all(array $parameters = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+        $resolver->setDefined('search');
+
+        return $this->get('namespaces', $resolver->resolve($parameters));
+    }
+
+    public function show(int|string $namespace_id): mixed
+    {
+        return $this->get('namespaces/'.self::encodePath($namespace_id));
+    }
+}
diff --git a/src/Api/Projects.php b/src/Api/Projects.php
new file mode 100644
index 000000000..32aa168a7
--- /dev/null
+++ b/src/Api/Projects.php
@@ -0,0 +1,1283 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Api;
+
+use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException;
+use Symfony\Component\OptionsResolver\Exception\UndefinedOptionsException;
+use Symfony\Component\OptionsResolver\Options;
+use Symfony\Component\OptionsResolver\OptionsResolver;
+
+class Projects extends AbstractApi
+{
+    /**
+     * @param array $parameters {
+     *
+     *     @var bool               $archived                    limit by archived status
+     *     @var string             $visibility                  limit by visibility public, internal, or private
+     *     @var string             $order_by                    Return projects ordered by id, name, path, created_at, updated_at,
+     *                                                          last_activity_at, repository_size, storage_size, packages_size or
+     *                                                          wiki_size fields (default is created_at)
+     *     @var string             $sort                        Return projects sorted in asc or desc order (default is desc)
+     *     @var string             $search                      return list of projects matching the search criteria
+     *     @var bool               $search_namespaces           Include ancestor namespaces when matching search criteria
+     *     @var bool               $simple                      return only the ID, URL, name, and path of each project
+     *     @var bool               $owned                       limit by projects owned by the current user
+     *     @var bool               $membership                  limit by projects that the current user is a member of
+     *     @var bool               $starred                     limit by projects starred by the current user
+     *     @var bool               $statistics                  include project statistics
+     *     @var bool               $with_issues_enabled         limit by enabled issues feature
+     *     @var bool               $with_merge_requests_enabled limit by enabled merge requests feature
+     *     @var int                $min_access_level            Limit by current user minimal access level
+     *     @var int                $id_after                    Limit by project id's greater than the specified id
+     *     @var int                $id_before                   Limit by project id's less than the specified id
+     *     @var \DateTimeInterface $last_activity_after         Limit by last_activity after specified time
+     *     @var \DateTimeInterface $last_activity_before        Limit by last_activity before specified time
+     *     @var bool               $repository_checksum_failed  Limit by failed repository checksum calculation
+     *     @var string             $repository_storage          Limit by repository storage type
+     *     @var bool               $wiki_checksum_failed        Limit by failed wiki checksum calculation
+     *     @var bool               $with_custom_attributes      Include custom attributes in response
+     *     @var string             $with_programming_language   Limit by programming language
+     *     @var string             $topic                       Limit by topic
+     * }
+     *
+     * @throws UndefinedOptionsException If an option name is undefined
+     * @throws InvalidOptionsException   If an option doesn't fulfill the specified validation rules
+     */
+    public function all(array $parameters = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+        $booleanNormalizer = function (Options $resolver, $value): string {
+            return $value ? 'true' : 'false';
+        };
+        $datetimeNormalizer = function (Options $resolver, \DateTimeInterface $value): string {
+            return $value->format('c');
+        };
+        $resolver->setDefined('archived')
+            ->setAllowedTypes('archived', 'bool')
+            ->setNormalizer('archived', $booleanNormalizer)
+        ;
+        $resolver->setDefined('visibility')
+            ->setAllowedValues('visibility', ['public', 'internal', 'private'])
+        ;
+        $orderBy = [
+            'id', 'name', 'path', 'created_at', 'updated_at', 'last_activity_at',
+            'repository_size', 'storage_size', 'packages_size', 'wiki_size',
+        ];
+        $resolver->setDefined('order_by')
+            ->setAllowedValues('order_by', $orderBy)
+        ;
+        $resolver->setDefined('sort')
+            ->setAllowedValues('sort', ['asc', 'desc'])
+        ;
+        $resolver->setDefined('search');
+        $resolver->setDefined('search_namespaces')
+            ->setAllowedTypes('search_namespaces', 'bool')
+            ->setNormalizer('search_namespaces', $booleanNormalizer)
+        ;
+        $resolver->setDefined('simple')
+            ->setAllowedTypes('simple', 'bool')
+            ->setNormalizer('simple', $booleanNormalizer)
+        ;
+        $resolver->setDefined('owned')
+            ->setAllowedTypes('owned', 'bool')
+            ->setNormalizer('owned', $booleanNormalizer)
+        ;
+        $resolver->setDefined('membership')
+            ->setAllowedTypes('membership', 'bool')
+            ->setNormalizer('membership', $booleanNormalizer)
+        ;
+        $resolver->setDefined('starred')
+            ->setAllowedTypes('starred', 'bool')
+            ->setNormalizer('starred', $booleanNormalizer)
+        ;
+        $resolver->setDefined('statistics')
+            ->setAllowedTypes('statistics', 'bool')
+            ->setNormalizer('statistics', $booleanNormalizer)
+        ;
+        $resolver->setDefined('with_issues_enabled')
+            ->setAllowedTypes('with_issues_enabled', 'bool')
+            ->setNormalizer('with_issues_enabled', $booleanNormalizer)
+        ;
+        $resolver->setDefined('with_merge_requests_enabled')
+            ->setAllowedTypes('with_merge_requests_enabled', 'bool')
+            ->setNormalizer('with_merge_requests_enabled', $booleanNormalizer)
+        ;
+        $resolver->setDefined('min_access_level')
+            ->setAllowedValues('min_access_level', [null, 10, 20, 30, 40, 50])
+        ;
+        $resolver->setDefined('id_after')
+            ->setAllowedTypes('id_after', 'integer')
+        ;
+        $resolver->setDefined('id_before')
+            ->setAllowedTypes('id_before', 'integer')
+        ;
+        $resolver->setDefined('last_activity_after')
+            ->setAllowedTypes('last_activity_after', \DateTimeInterface::class)
+            ->setNormalizer('last_activity_after', $datetimeNormalizer)
+        ;
+        $resolver->setDefined('last_activity_before')
+            ->setAllowedTypes('last_activity_before', \DateTimeInterface::class)
+            ->setNormalizer('last_activity_before', $datetimeNormalizer)
+        ;
+        $resolver->setDefined('repository_checksum_failed')
+            ->setAllowedTypes('repository_checksum_failed', 'bool')
+            ->setNormalizer('repository_checksum_failed', $booleanNormalizer)
+        ;
+        $resolver->setDefined('repository_storage');
+        $resolver->setDefined('wiki_checksum_failed')
+            ->setAllowedTypes('wiki_checksum_failed', 'bool')
+            ->setNormalizer('wiki_checksum_failed', $booleanNormalizer)
+        ;
+        $resolver->setDefined('with_custom_attributes')
+            ->setAllowedTypes('with_custom_attributes', 'bool')
+            ->setNormalizer('with_custom_attributes', $booleanNormalizer)
+        ;
+        $resolver->setDefined('with_programming_language');
+        $resolver->setDefined('topic');
+
+        return $this->get('projects', $resolver->resolve($parameters));
+    }
+
+    /**
+     * @param array      $parameters {
+     *
+     *     @var bool   $statistics                    include project statistics
+     *     @var bool   $with_custom_attributes        Include project custom attributes.
+     * }
+     */
+    public function show(int|string $project_id, array $parameters = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+        $booleanNormalizer = function (Options $resolver, $value): bool {
+            return (bool) $value;
+        };
+        $resolver->setDefined('statistics')
+            ->setAllowedTypes('statistics', 'bool')
+            ->setNormalizer('statistics', $booleanNormalizer)
+        ;
+        $resolver->setDefined('with_custom_attributes')
+            ->setAllowedTypes('with_custom_attributes', 'bool')
+            ->setNormalizer('with_custom_attributes', $booleanNormalizer)
+        ;
+
+        return $this->get('projects/'.self::encodePath($project_id), $resolver->resolve($parameters));
+    }
+
+    public function create(string $name, array $parameters = []): mixed
+    {
+        $parameters['name'] = $name;
+
+        return $this->post('projects', $parameters);
+    }
+
+    public function createForUser(int $user_id, string $name, array $parameters = []): mixed
+    {
+        $parameters['name'] = $name;
+
+        return $this->post('projects/user/'.self::encodePath($user_id), $parameters);
+    }
+
+    public function update(int|string $project_id, array $parameters): mixed
+    {
+        return $this->put('projects/'.self::encodePath($project_id), $parameters);
+    }
+
+    public function remove(int|string $project_id): mixed
+    {
+        return $this->delete('projects/'.self::encodePath($project_id));
+    }
+
+    public function archive(int|string $project_id): mixed
+    {
+        return $this->post('projects/'.self::encodePath($project_id).'/archive');
+    }
+
+    public function unarchive(int|string $project_id): mixed
+    {
+        return $this->post('projects/'.self::encodePath($project_id).'/unarchive');
+    }
+
+    public function triggers(int|string $project_id): mixed
+    {
+        return $this->get('projects/'.self::encodePath($project_id).'/triggers');
+    }
+
+    public function trigger(int|string $project_id, int $trigger_id): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'triggers/'.self::encodePath($trigger_id)));
+    }
+
+    public function createTrigger(int|string $project_id, string $description): mixed
+    {
+        return $this->post($this->getProjectPath($project_id, 'triggers'), [
+            'description' => $description,
+        ]);
+    }
+
+    public function removeTrigger(int|string $project_id, int $trigger_id): mixed
+    {
+        return $this->delete($this->getProjectPath($project_id, 'triggers/'.self::encodePath($trigger_id)));
+    }
+
+    public function triggerPipeline(int|string $project_id, string $ref, string $token, array $variables = []): mixed
+    {
+        return $this->post($this->getProjectPath($project_id, 'trigger/pipeline'), [
+            'ref' => $ref,
+            'token' => $token,
+            'variables' => $variables,
+        ]);
+    }
+
+    public function disableRunner(int $project_id, int $runner_id): mixed
+    {
+        return $this->delete('projects/'.self::encodePath($project_id).'/runners/'.self::encodePath($runner_id));
+    }
+
+    public function enableRunner(int $project_id, int $runner_id): mixed
+    {
+        $parameters = [
+            'runner_id' => $runner_id,
+        ];
+
+        return $this->post('projects/'.self::encodePath($project_id).'/runners', $parameters);
+    }
+
+    /**
+     * @param array      $parameters {
+     *
+     *     @var string $scope       the scope of pipelines, one of: running, pending, finished, branches, tags
+     *     @var string $status      the status of pipelines, one of: running, pending, success, failed, canceled, skipped
+     *     @var string $ref         the ref of pipelines
+     *     @var string $sha         the sha of pipelines
+     *     @var bool   $yaml_errors returns pipelines with invalid configurations
+     *     @var string $name        the name of the user who triggered pipelines
+     *     @var string $username    the username of the user who triggered pipelines
+     *     @var string $order_by    order pipelines by id, status, ref, updated_at, or user_id (default: id)
+     *     @var string $order       sort pipelines in asc or desc order (default: desc)
+     *     @var string $source      the source of the pipeline
+     * }
+     */
+    public function pipelines(int|string $project_id, array $parameters = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+        $booleanNormalizer = function (Options $resolver, $value): string {
+            return $value ? 'true' : 'false';
+        };
+        $datetimeNormalizer = function (Options $resolver, \DateTimeInterface $value): string {
+            return $value->format('Y-m-d');
+        };
+
+        $resolver->setDefined('scope')
+            ->setAllowedValues('scope', ['running', 'pending', 'finished', 'branches', 'tags'])
+        ;
+        $resolver->setDefined('status')
+            ->setAllowedValues('status', ['running', 'pending', 'success', 'failed', 'canceled', 'skipped'])
+        ;
+        $resolver->setDefined('ref');
+        $resolver->setDefined('sha');
+        $resolver->setDefined('yaml_errors')
+            ->setAllowedTypes('yaml_errors', 'bool')
+            ->setNormalizer('yaml_errors', $booleanNormalizer)
+        ;
+        $resolver->setDefined('name');
+        $resolver->setDefined('username');
+        $resolver->setDefined('updated_after')
+                 ->setAllowedTypes('updated_after', \DateTimeInterface::class)
+                 ->setNormalizer('updated_after', $datetimeNormalizer)
+        ;
+        $resolver->setDefined('updated_before')
+                 ->setAllowedTypes('updated_before', \DateTimeInterface::class)
+                 ->setNormalizer('updated_before', $datetimeNormalizer)
+        ;
+        $resolver->setDefined('order_by')
+            ->setAllowedValues('order_by', ['id', 'status', 'ref', 'updated_at', 'user_id'])
+        ;
+        $resolver->setDefined('sort')
+            ->setAllowedValues('sort', ['asc', 'desc'])
+        ;
+        $resolver->setDefined('source')
+            ->setAllowedValues('source', ['push', 'web', 'trigger', 'schedule', 'api', 'external', 'pipeline', 'chat', 'webide', 'merge_request_event', 'external_pull_request_event', 'parent_pipeline', 'ondemand_dast_scan', 'ondemand_dast_validation'])
+        ;
+
+        return $this->get($this->getProjectPath($project_id, 'pipelines'), $resolver->resolve($parameters));
+    }
+
+    public function pipeline(int|string $project_id, int $pipeline_id): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'pipelines/'.self::encodePath($pipeline_id)));
+    }
+
+    public function pipelineJobs(int|string $project_id, int $pipeline_id): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'pipelines/'.self::encodePath($pipeline_id).'/jobs'));
+    }
+
+    public function pipelineVariables(int|string $project_id, int $pipeline_id): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'pipelines/'.self::encodePath($pipeline_id).'/variables'));
+    }
+
+    public function pipelineTestReport(int|string $project_id, int $pipeline_id): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'pipelines/'.self::encodePath($pipeline_id).'/test_report'));
+    }
+
+    public function pipelineTestReportSummary(int|string $project_id, int $pipeline_id): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'pipelines/'.self::encodePath($pipeline_id).'/test_report_summary'));
+    }
+
+    /**
+     * @param array|null $variables  {
+     *
+     *     @var string $key            The name of the variable
+     *     @var mixed $value           The value of the variable
+     *     @var string $variable_type  env_var (default) or file
+     * }
+     */
+    public function createPipeline(int|string $project_id, string $commit_ref, ?array $variables = null): mixed
+    {
+        $parameters = [];
+
+        if (null !== $variables) {
+            $parameters['variables'] = $variables;
+        }
+
+        return $this->post($this->getProjectPath($project_id, 'pipeline'), $parameters, [], [], [
+            'ref' => $commit_ref,
+        ]);
+    }
+
+    public function retryPipeline(int|string $project_id, int $pipeline_id): mixed
+    {
+        return $this->post($this->getProjectPath($project_id, 'pipelines/'.self::encodePath($pipeline_id)).'/retry');
+    }
+
+    public function cancelPipeline(int|string $project_id, int $pipeline_id): mixed
+    {
+        return $this->post($this->getProjectPath($project_id, 'pipelines/'.self::encodePath($pipeline_id)).'/cancel');
+    }
+
+    public function deletePipeline(int|string $project_id, int $pipeline_id): mixed
+    {
+        return $this->delete($this->getProjectPath($project_id, 'pipelines/'.self::encodePath($pipeline_id)));
+    }
+
+    public function allMembers(int|string $project_id, array $parameters = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+        $resolver->setDefined('query');
+        $resolver->setDefined('user_ids')
+            ->setAllowedTypes('user_ids', 'array')
+            ->setAllowedValues('user_ids', function (array $value) {
+                return \count($value) === \count(\array_filter($value, 'is_int'));
+            })
+        ;
+
+        return $this->get('projects/'.self::encodePath($project_id).'/members/all', $resolver->resolve($parameters));
+    }
+
+    /**
+     * @param array      $parameters {
+     *
+     *     @var string $query           The query you want to search members for.
+     * }
+     */
+    public function members(int|string $project_id, array $parameters = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+
+        $resolver->setDefined('query')
+            ->setAllowedTypes('query', 'string')
+        ;
+        $resolver->setDefined('user_ids')
+            ->setAllowedTypes('user_ids', 'array')
+            ->setAllowedValues('user_ids', function (array $value) {
+                return \count($value) === \count(\array_filter($value, 'is_int'));
+            })
+        ;
+
+        return $this->get($this->getProjectPath($project_id, 'members'), $resolver->resolve($parameters));
+    }
+
+    public function member(int|string $project_id, int $user_id): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'members/'.self::encodePath($user_id)));
+    }
+
+    public function allMember(int|string $project_id, int $user_id): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'members/all/'.self::encodePath($user_id)));
+    }
+
+    public function addMember(int|string $project_id, int $user_id, int $access_level, ?string $expires_at = null): mixed
+    {
+        $params = [
+            'user_id' => $user_id,
+            'access_level' => $access_level,
+        ];
+        if (null !== $expires_at) {
+            $params['expires_at'] = $expires_at;
+        }
+
+        return $this->post($this->getProjectPath($project_id, 'members'), $params);
+    }
+
+    public function saveMember(int|string $project_id, int $user_id, int $access_level, ?string $expires_at = null): mixed
+    {
+        $params = [
+            'access_level' => $access_level,
+        ];
+        if (null !== $expires_at) {
+            $params['expires_at'] = $expires_at;
+        }
+
+        return $this->put($this->getProjectPath($project_id, 'members/'.self::encodePath($user_id)), $params);
+    }
+
+    public function removeMember(int|string $project_id, int $user_id): mixed
+    {
+        return $this->delete($this->getProjectPath($project_id, 'members/'.self::encodePath($user_id)));
+    }
+
+    public function hooks(int|string $project_id, array $parameters = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+
+        return $this->get($this->getProjectPath($project_id, 'hooks'), $resolver->resolve($parameters));
+    }
+
+    public function hook(int|string $project_id, int $hook_id): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'hooks/'.self::encodePath($hook_id)));
+    }
+
+    /**
+     * Get project users.
+     *
+     * See https://docs.gitlab.com/ee/api/projects.html#get-project-users for more info.
+     */
+    public function users(int|string $project_id, array $parameters = []): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'users'), $parameters);
+    }
+
+    /**
+     * Get project issues.
+     *
+     * See https://docs.gitlab.com/ee/api/issues.html#list-project-issues for more info.
+     */
+    public function issues(int|string $project_id, array $parameters = []): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'issues'), $parameters);
+    }
+
+    /**
+     * Get projects board list.
+     *
+     * See https://docs.gitlab.com/ee/api/boards.html for more info.
+     */
+    public function boards(int|string $project_id): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'boards'));
+    }
+
+    /**
+     * @param array      $parameters {
+     *
+     *     @var string $state               Return opened, upcoming, current (previously started), closed, or all iterations.
+     *                                      Filtering by started state is deprecated starting with 14.1, please use current instead.
+     *     @var string $search              return only iterations with a title matching the provided string
+     *     @var bool   $include_ancestors   Include iterations from parent group and its ancestors. Defaults to true.
+     * }
+     */
+    public function iterations(int|string $project_id, array $parameters = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+        $booleanNormalizer = function (Options $resolver, $value): string {
+            return $value ? 'true' : 'false';
+        };
+
+        $resolver->setDefined('state')
+            ->setAllowedValues('state', ['opened', 'upcoming', 'current', 'current (previously started)', 'closed', 'all'])
+        ;
+        $resolver->setDefined('include_ancestors')
+            ->setAllowedTypes('include_ancestors', 'bool')
+            ->setNormalizer('include_ancestors', $booleanNormalizer)
+            ->setDefault('include_ancestors', true)
+        ;
+
+        return $this->get('projects/'.self::encodePath($project_id).'/iterations', $resolver->resolve($parameters));
+    }
+
+    /**
+     * Gets a list of all discussion items for a single commit.
+     *
+     * Example:
+     * - https://gitlab.com/gitlab-org/gitlab/-/commit/695c29abcf7dc2eabde8d59869abcea0923ce8fa#note_334686748
+     * - https://gitlab.com/api/v4/projects/gitlab-org%2Fgitlab/repository/commits/695c29abcf7dc2eabde8d59869abcea0923ce8fa/discussions
+     *
+     * @see https://docs.gitlab.com/ee/api/discussions.html#list-project-commit-discussion-items
+     */
+    public function getRepositoryCommitDiscussions(int|string $project_id, string $commit_id): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'repository/commits/'.self::encodePath($commit_id)).'/discussions');
+    }
+
+    public function addHook(int|string $project_id, string $url, array $parameters = []): mixed
+    {
+        if (0 === \count($parameters)) {
+            $parameters = ['push_events' => true];
+        }
+
+        $parameters['url'] = $url;
+
+        return $this->post($this->getProjectPath($project_id, 'hooks'), $parameters);
+    }
+
+    public function updateHook(int|string $project_id, int $hook_id, array $parameters): mixed
+    {
+        return $this->put($this->getProjectPath($project_id, 'hooks/'.self::encodePath($hook_id)), $parameters);
+    }
+
+    public function removeHook(int|string $project_id, int $hook_id): mixed
+    {
+        return $this->delete($this->getProjectPath($project_id, 'hooks/'.self::encodePath($hook_id)));
+    }
+
+    public function transfer(int|string $project_id, mixed $namespace): mixed
+    {
+        return $this->put($this->getProjectPath($project_id, 'transfer'), ['namespace' => $namespace]);
+    }
+
+    public function deployKeys(int|string $project_id): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'deploy_keys'));
+    }
+
+    public function deployKey(int|string $project_id, int $key_id): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'deploy_keys/'.self::encodePath($key_id)));
+    }
+
+    public function addDeployKey(int|string $project_id, string $title, string $key, bool $canPush = false): mixed
+    {
+        return $this->post($this->getProjectPath($project_id, 'deploy_keys'), [
+            'title' => $title,
+            'key' => $key,
+            'can_push' => $canPush,
+        ]);
+    }
+
+    public function deleteDeployKey(int|string $project_id, int $key_id): mixed
+    {
+        return $this->delete($this->getProjectPath($project_id, 'deploy_keys/'.self::encodePath($key_id)));
+    }
+
+    public function enableDeployKey(int|string $project_id, int $key_id): mixed
+    {
+        return $this->post($this->getProjectPath($project_id, 'deploy_keys/'.self::encodePath($key_id).'/enable'));
+    }
+
+    public function deployTokens(int|string $project_id, ?bool $active = null): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'deploy_tokens'), (null !== $active) ? ['active' => $active] : []);
+    }
+
+    /**
+     * @param array      $parameters {
+     *
+     *     @var string $name                    the name of the deploy token
+     *     @var \DateTimeInterface $expires_at  expiration date for the deploy token, does not expire if no value is provided
+     *     @var string $username                the username for the deploy token
+     *     @var array  $scopes                  the scopes, one or many of: read_repository, read_registry, write_registry, read_package_registry, write_package_registry
+     * }
+     */
+    public function createDeployToken(int|string $project_id, array $parameters = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+        $datetimeNormalizer = function (Options $resolver, \DateTimeInterface $value): string {
+            return $value->format('c');
+        };
+
+        $resolver->define('name')
+            ->required()
+        ;
+
+        $resolver->define('scopes')
+            ->required()
+            ->allowedTypes('array')
+            ->allowedValues(function ($scopes) {
+                $allowed = ['read_repository', 'read_registry', 'write_registry', 'read_package_registry', 'write_package_registry'];
+                foreach ($scopes as $scope) {
+                    if (!\in_array($scope, $allowed, true)) {
+                        return false;
+                    }
+                }
+
+                return true;
+            })
+        ;
+        $resolver->setDefined('username')
+            ->setAllowedTypes('username', 'string')
+        ;
+
+        $resolver->setDefined('expires_at')
+            ->setAllowedTypes('expires_at', \DateTimeInterface::class)
+            ->setNormalizer('expires_at', $datetimeNormalizer)
+        ;
+
+        return $this->post($this->getProjectPath($project_id, 'deploy_tokens'), $resolver->resolve($parameters));
+    }
+
+    public function deleteDeployToken(int|string $project_id, int $token_id): mixed
+    {
+        return $this->delete($this->getProjectPath($project_id, 'deploy_tokens/'.self::encodePath($token_id)));
+    }
+
+    /**
+     * @param array      $parameters {
+     *
+     *     @var string             $action      include only events of a particular action type
+     *     @var string             $target_type include only events of a particular target type
+     *     @var \DateTimeInterface $before      include only events created before a particular date
+     *     @var \DateTimeInterface $after       include only events created after a particular date
+     *     @var string             $sort        Sort events in asc or desc order by created_at (default is desc)
+     * }
+     */
+    public function events(int|string $project_id, array $parameters = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+        $datetimeNormalizer = function (Options $resolver, \DateTimeInterface $value): string {
+            return $value->format('Y-m-d');
+        };
+
+        $resolver->setDefined('action')
+            ->setAllowedValues('action', ['created', 'updated', 'closed', 'reopened', 'pushed', 'commented', 'merged', 'joined', 'left', 'destroyed', 'expired', 'approved'])
+        ;
+        $resolver->setDefined('target_type')
+            ->setAllowedValues('target_type', ['issue', 'milestone', 'merge_request', 'note', 'project', 'snippet', 'user'])
+        ;
+        $resolver->setDefined('before')
+            ->setAllowedTypes('before', \DateTimeInterface::class)
+            ->setNormalizer('before', $datetimeNormalizer);
+        $resolver->setDefined('after')
+            ->setAllowedTypes('after', \DateTimeInterface::class)
+            ->setNormalizer('after', $datetimeNormalizer)
+        ;
+        $resolver->setDefined('sort')
+            ->setAllowedValues('sort', ['asc', 'desc'])
+        ;
+
+        return $this->get($this->getProjectPath($project_id, 'events'), $resolver->resolve($parameters));
+    }
+
+    /**
+     * @param array      $parameters {
+     *
+     *     @var bool     $with_counts               Whether or not to include issue and merge request counts. Defaults to false.
+     *     @var bool     $include_ancestor_groups   Include ancestor groups. Defaults to true.
+     *     @var string   $search                    Keyword to filter labels by.
+     * }
+     */
+    public function labels(int|string $project_id, array $parameters = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+
+        $resolver->setDefined('with_counts')
+            ->setAllowedTypes('with_counts', 'bool');
+
+        $resolver->setDefined('include_ancestor_groups')
+            ->setAllowedTypes('include_ancestor_groups', 'bool');
+
+        $resolver->setDefined('search')
+            ->setAllowedTypes('search', 'string');
+
+        return $this->get($this->getProjectPath($project_id, 'labels'), $resolver->resolve($parameters));
+    }
+
+    public function addLabel(int|string $project_id, array $parameters): mixed
+    {
+        return $this->post($this->getProjectPath($project_id, 'labels'), $parameters);
+    }
+
+    public function updateLabel(int|string $project_id, int $label_id, array $parameters): mixed
+    {
+        return $this->put($this->getProjectPath($project_id, 'labels/'.self::encodePath($label_id)), $parameters);
+    }
+
+    public function removeLabel(int|string $project_id, int $label_id): mixed
+    {
+        return $this->delete($this->getProjectPath($project_id, 'labels/'.self::encodePath($label_id)));
+    }
+
+    /**
+     * Get languages used in a project with percentage value.
+     */
+    public function languages(int|string $project_id): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'languages'));
+    }
+
+    /**
+     * @param array      $parameters {
+     *
+     *     @var bool               $archived                    Limit by archived status
+     *     @var string             $visibility                  Limit by visibility public, internal, or private
+     *     @var string             $order_by                    Return projects ordered by id, name, path, created_at, updated_at,
+     *                                                          last_activity_at, repository_size, storage_size, packages_size or
+     *                                                          wiki_size fields (default is created_at)
+     *     @var string             $sort                        Return projects sorted in asc or desc order (default is desc)
+     *     @var string             $search                      Return list of projects matching the search criteria
+     *     @var bool               $simple                      Return only the ID, URL, name, and path of each project
+     *     @var bool               $owned                       Limit by projects owned by the current user
+     *     @var bool               $membership                  Limit by projects that the current user is a member of
+     *     @var bool               $starred                     Limit by projects starred by the current user
+     *     @var bool               $statistics                  Include project statistics
+     *     @var bool               $with_issues_enabled         Limit by enabled issues feature
+     *     @var bool               $with_merge_requests_enabled Limit by enabled merge requests feature
+     *     @var int                $min_access_level            Limit by current user minimal access level
+     *     @var \DateTimeInterface $updated_before              limit results to projects last updated before the specified time
+     *     @var \DateTimeInterface $updated_after               limit results to projects last updated after the specified time
+     *     @var bool               $with_custom_attributes      Include custom attributes in response
+     * }
+     */
+    public function forks(int|string $project_id, array $parameters = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+        $booleanNormalizer = function (Options $resolver, $value): string {
+            return $value ? 'true' : 'false';
+        };
+        $datetimeNormalizer = function (Options $resolver, \DateTimeInterface $value): string {
+            return $value->format('c');
+        };
+        $resolver->setDefined('archived')
+            ->setAllowedTypes('archived', 'bool')
+            ->setNormalizer('archived', $booleanNormalizer)
+        ;
+        $resolver->setDefined('visibility')
+            ->setAllowedValues('visibility', ['public', 'internal', 'private'])
+        ;
+        $orderBy = [
+            'id', 'name', 'path', 'created_at', 'updated_at', 'last_activity_at',
+            'repository_size', 'storage_size', 'packages_size', 'wiki_size',
+        ];
+        $resolver->setDefined('order_by')
+            ->setAllowedValues('order_by', $orderBy)
+        ;
+        $resolver->setDefined('sort')
+            ->setAllowedValues('sort', ['asc', 'desc'])
+        ;
+        $resolver->setDefined('search');
+        $resolver->setDefined('simple')
+            ->setAllowedTypes('simple', 'bool')
+            ->setNormalizer('simple', $booleanNormalizer)
+        ;
+        $resolver->setDefined('owned')
+            ->setAllowedTypes('owned', 'bool')
+            ->setNormalizer('owned', $booleanNormalizer)
+        ;
+        $resolver->setDefined('membership')
+            ->setAllowedTypes('membership', 'bool')
+            ->setNormalizer('membership', $booleanNormalizer)
+        ;
+        $resolver->setDefined('starred')
+            ->setAllowedTypes('starred', 'bool')
+            ->setNormalizer('starred', $booleanNormalizer)
+        ;
+        $resolver->setDefined('statistics')
+            ->setAllowedTypes('statistics', 'bool')
+            ->setNormalizer('statistics', $booleanNormalizer)
+        ;
+        $resolver->setDefined('with_issues_enabled')
+            ->setAllowedTypes('with_issues_enabled', 'bool')
+            ->setNormalizer('with_issues_enabled', $booleanNormalizer)
+        ;
+        $resolver->setDefined('with_merge_requests_enabled')
+            ->setAllowedTypes('with_merge_requests_enabled', 'bool')
+            ->setNormalizer('with_merge_requests_enabled', $booleanNormalizer)
+        ;
+        $resolver->setDefined('min_access_level')
+            ->setAllowedValues('min_access_level', [null, 10, 20, 30, 40, 50])
+        ;
+        $resolver->setDefined('updated_before')
+            ->setAllowedTypes('updated_before', \DateTimeInterface::class)
+            ->setNormalizer('updated_before', $datetimeNormalizer)
+        ;
+        $resolver->setDefined('updated_after')
+            ->setAllowedTypes('updated_after', \DateTimeInterface::class)
+            ->setNormalizer('updated_after', $datetimeNormalizer)
+        ;
+        $resolver->setDefined('with_custom_attributes')
+            ->setAllowedTypes('with_custom_attributes', 'bool')
+            ->setNormalizer('with_custom_attributes', $booleanNormalizer)
+        ;
+
+        return $this->get($this->getProjectPath($project_id, 'forks'), $resolver->resolve($parameters));
+    }
+
+    /**
+     * @param array      $parameters {
+     *
+     *     @var string $namespace      The ID or path of the namespace that the project will be forked to
+     *     @var string $path           The path of the forked project (optional)
+     *     @var string $name           The name of the forked project (optional)
+     * }
+     */
+    public function fork(int|string $project_id, array $parameters = []): mixed
+    {
+        $resolver = new OptionsResolver();
+        $resolver->setDefined(['namespace', 'path', 'name']);
+
+        $resolved = $resolver->resolve($parameters);
+
+        return $this->post($this->getProjectPath($project_id, 'fork'), $resolved);
+    }
+
+    public function createForkRelation(int|string $project_id, int|string $forked_project_id): mixed
+    {
+        return $this->post($this->getProjectPath($project_id, 'fork/'.self::encodePath($forked_project_id)));
+    }
+
+    public function removeForkRelation(int|string $project_id): mixed
+    {
+        return $this->delete($this->getProjectPath($project_id, 'fork'));
+    }
+
+    public function setService(int|string $project_id, string $service_name, array $parameters = []): mixed
+    {
+        return $this->put($this->getProjectPath($project_id, 'services/'.self::encodePath($service_name)), $parameters);
+    }
+
+    public function removeService(int|string $project_id, string $service_name): mixed
+    {
+        return $this->delete($this->getProjectPath($project_id, 'services/'.self::encodePath($service_name)));
+    }
+
+    public function variables(int|string $project_id, array $parameters = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+
+        return $this->get($this->getProjectPath($project_id, 'variables'), $resolver->resolve($parameters));
+    }
+
+    public function variable(int|string $project_id, string $key, array $parameters = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+        $resolver->setDefined('filter')
+            ->setAllowedTypes('filter', 'array');
+
+        return $this->get($this->getProjectPath($project_id, 'variables/'.self::encodePath($key)), $resolver->resolve($parameters));
+    }
+
+    /**
+     * @param array<string,mixed> $parameters        {
+     *
+     *      @var string $variable_type  env_var (default) or file
+     * }
+     */
+    public function addVariable(int|string $project_id, string $key, string $value, ?bool $protected = null, ?string $environment_scope = null, array $parameters = []): mixed
+    {
+        $payload = [
+            'key' => $key,
+            'value' => $value,
+        ];
+
+        if ($protected) {
+            $payload['protected'] = $protected;
+        }
+
+        if ($environment_scope) {
+            $payload['environment_scope'] = $environment_scope;
+        }
+
+        $payload = \array_merge($parameters, $payload);
+
+        return $this->post($this->getProjectPath($project_id, 'variables'), $payload);
+    }
+
+    /**
+     * @param array<string,mixed> $parameters        {
+     *
+     *      @var string $variable_type  env_var (default) or file
+     *}
+     */
+    public function updateVariable(int|string $project_id, string $key, string $value, ?bool $protected = null, ?string $environment_scope = null, array $parameters = []): mixed
+    {
+        $payload = [
+            'value' => $value,
+        ];
+
+        if ($protected) {
+            $payload['protected'] = $protected;
+        }
+
+        if ($environment_scope) {
+            $payload['environment_scope'] = $environment_scope;
+        }
+
+        $payload = \array_merge($parameters, $payload);
+
+        return $this->put($this->getProjectPath($project_id, 'variables/'.self::encodePath($key)), $payload);
+    }
+
+    /**
+     * @param array<string, mixed> $parameters    {
+     *
+     *    @var array $filter    {
+     *        @var string $environment_scope    Use filter[environment_scope] to select the variable with the matching environment_scope attribute.
+     *    }
+     * }
+     */
+    public function removeVariable(int|string $project_id, string $key, array $parameters = []): mixed
+    {
+        $resolver = new OptionsResolver();
+        $resolver->setDefined('filter')
+            ->setAllowedTypes('filter', 'array');
+
+        return $this->delete($this->getProjectPath($project_id, 'variables/'.self::encodePath($key)), $resolver->resolve($parameters));
+    }
+
+    public function uploadFile(int|string $project_id, string $file): mixed
+    {
+        return $this->post($this->getProjectPath($project_id, 'uploads'), [], [], ['file' => $file]);
+    }
+
+    public function uploadAvatar(int|string $project_id, string $file): mixed
+    {
+        return $this->put('projects/'.self::encodePath($project_id), [], [], ['avatar' => $file]);
+    }
+
+    /**
+     * @see https://docs.gitlab.com/ee/api/deployments.html#list-project-deployments
+     */
+    public function deployments(int|string $project_id, array $parameters = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+
+        $datetimeNormalizer = function (Options $resolver, \DateTimeInterface $value): string {
+            return $value->format('c');
+        };
+
+        $resolver->setDefined('order_by')
+            ->setAllowedTypes('order_by', 'string')
+            ->setAllowedValues('order_by', ['id', 'iid', 'created_at', 'updated_at', 'finished_at', 'ref'])
+        ;
+
+        $resolver->setDefined('sort')
+            ->setAllowedTypes('sort', 'string')
+            ->setAllowedValues('sort', ['asc', 'desc'])
+        ;
+
+        $resolver->setDefined('updated_after')
+            ->setAllowedTypes('updated_after', \DateTimeInterface::class)
+            ->setNormalizer('updated_after', $datetimeNormalizer)
+        ;
+
+        $resolver->setDefined('updated_before')
+            ->setAllowedTypes('updated_before', \DateTimeInterface::class)
+            ->setNormalizer('updated_before', $datetimeNormalizer)
+        ;
+
+        $resolver->setDefined('finished_after')
+            ->setAllowedTypes('finished_after', \DateTimeInterface::class)
+            ->setNormalizer('finished_after', $datetimeNormalizer)
+        ;
+
+        $resolver->setDefined('finished_before')
+            ->setAllowedTypes('finished_before', \DateTimeInterface::class)
+            ->setNormalizer('finished_before', $datetimeNormalizer)
+        ;
+
+        $resolver->setDefined('environment')
+            ->setAllowedTypes('environment', 'string')
+        ;
+
+        $resolver->setDefined('status')
+            ->setAllowedTypes('status', 'string')
+            ->setAllowedValues('status', ['created', 'running', 'success', 'failed', 'canceled', 'blocked'])
+        ;
+
+        return $this->get($this->getProjectPath($project_id, 'deployments'), $resolver->resolve($parameters));
+    }
+
+    public function deployment(int|string $project_id, int $deployment_id): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'deployments/'.self::encodePath($deployment_id)));
+    }
+
+    public function addShare(int|string $project_id, array $parameters = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+
+        $datetimeNormalizer = function (OptionsResolver $optionsResolver, \DateTimeInterface $value): string {
+            return $value->format('Y-m-d');
+        };
+
+        $resolver->setRequired('group_id')
+            ->setAllowedTypes('group_id', 'int');
+
+        $resolver->setRequired('group_access')
+            ->setAllowedTypes('group_access', 'int')
+            ->setAllowedValues('group_access', self::ACCESS_LEVELS);
+
+        $resolver->setDefined('expires_at')
+            ->setAllowedTypes('expires_at', \DateTimeInterface::class)
+            ->setNormalizer('expires_at', $datetimeNormalizer)
+        ;
+
+        return $this->post($this->getProjectPath($project_id, 'share'), $resolver->resolve($parameters));
+    }
+
+    public function removeShare(int|string $project_id, int|string $group_id): mixed
+    {
+        return $this->delete($this->getProjectPath($project_id, 'share/'.$group_id));
+    }
+
+    public function badges(int|string $project_id): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'badges'));
+    }
+
+    public function badge(int|string $project_id, int $badge_id): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'badges/'.self::encodePath($badge_id)));
+    }
+
+    public function addBadge(int|string $project_id, array $parameters = []): mixed
+    {
+        return $this->post($this->getProjectPath($project_id, 'badges'), $parameters);
+    }
+
+    public function removeBadge(int|string $project_id, int $badge_id): mixed
+    {
+        return $this->delete($this->getProjectPath($project_id, 'badges/'.self::encodePath($badge_id)));
+    }
+
+    public function updateBadge(int|string $project_id, int $badge_id, array $parameters = []): mixed
+    {
+        return $this->put($this->getProjectPath($project_id, 'badges/'.self::encodePath($badge_id)), $parameters);
+    }
+
+    public function protectedBranches(int|string $project_id, array $parameters = []): mixed
+    {
+        return $this->get('projects/'.self::encodePath($project_id).'/protected_branches');
+    }
+
+    public function addProtectedBranch(int|string $project_id, array $parameters = []): mixed
+    {
+        return $this->post($this->getProjectPath($project_id, 'protected_branches'), $parameters);
+    }
+
+    public function deleteProtectedBranch(int|string $project_id, string $branch_name): mixed
+    {
+        return $this->delete($this->getProjectPath($project_id, 'protected_branches/'.self::encodePath($branch_name)));
+    }
+
+    public function updateProtectedBranch(int|string $project_id, string $branch_name, array $parameters = []): mixed
+    {
+        return $this->patch($this->getProjectPath($project_id, 'protected_branches/'.self::encodePath($branch_name)), $parameters);
+    }
+
+    public function approvalsConfiguration(int|string $project_id): mixed
+    {
+        return $this->get('projects/'.self::encodePath($project_id).'/approvals');
+    }
+
+    public function updateApprovalsConfiguration(int|string $project_id, array $parameters = []): mixed
+    {
+        return $this->post('projects/'.self::encodePath($project_id).'/approvals', $parameters);
+    }
+
+    public function approvalsRules(int|string $project_id): mixed
+    {
+        return $this->get('projects/'.self::encodePath($project_id).'/approval_rules');
+    }
+
+    public function createApprovalsRule(int|string $project_id, array $parameters = []): mixed
+    {
+        return $this->post('projects/'.self::encodePath($project_id).'/approval_rules/', $parameters);
+    }
+
+    public function updateApprovalsRule(int|string $project_id, int $approval_rule_id, array $parameters = []): mixed
+    {
+        return $this->put('projects/'.self::encodePath($project_id).'/approval_rules/'.self::encodePath($approval_rule_id), $parameters);
+    }
+
+    public function deleteApprovalsRule(int|string $project_id, int $approval_rule_id): mixed
+    {
+        return $this->delete('projects/'.self::encodePath($project_id).'/approval_rules/'.self::encodePath($approval_rule_id));
+    }
+
+    public function deleteAllMergedBranches(int|string $project_id): mixed
+    {
+        return $this->delete($this->getProjectPath($project_id, 'repository/merged_branches'));
+    }
+
+    public function projectAccessTokens(int|string $project_id): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'access_tokens'));
+    }
+
+    public function projectAccessToken(int|string $project_id, int|string $token_id): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'access_tokens/'.self::encodePath($token_id)));
+    }
+
+    /**
+     * @param array      $parameters {
+     *
+     *     @var string $name                    the name of the project access token
+     *     @var array  $scopes                  the scopes, one or many of: api, read_api, read_registry, write_registry, read_repository, write_repository
+     *     @var int    $access_level            the access level: 10 (Guest), 20 (Reporter), 30 (Developer), 40 (Maintainer), 50 (Owner)
+     *     @var \DateTimeInterface $expires_at  the token expires at midnight UTC on that date
+     * }
+     */
+    public function createProjectAccessToken(int|string $project_id, array $parameters = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+        $datetimeNormalizer = function (Options $resolver, \DateTimeInterface $value): string {
+            return $value->format('Y-m-d');
+        };
+
+        $resolver->define('name')
+            ->required()
+        ;
+
+        $resolver->define('scopes')
+            ->required()
+            ->allowedTypes('array')
+            ->allowedValues(function ($scopes) {
+                $allowed = ['api', 'read_api', 'read_registry', 'write_registry', 'read_repository', 'write_repository'];
+                foreach ($scopes as $scope) {
+                    if (!\in_array($scope, $allowed, true)) {
+                        return false;
+                    }
+                }
+
+                return true;
+            })
+        ;
+
+        $resolver->setDefined('access_level')
+            ->setAllowedTypes('access_level', 'int')
+            ->setAllowedValues('access_level', [10, 20, 30, 40, 50])
+        ;
+
+        $resolver->setDefined('expires_at')
+            ->setAllowedTypes('expires_at', \DateTimeInterface::class)
+            ->setNormalizer('expires_at', $datetimeNormalizer)
+        ;
+
+        return $this->post($this->getProjectPath($project_id, 'access_tokens'), $resolver->resolve($parameters));
+    }
+
+    public function deleteProjectAccessToken(int|string $project_id, int|string $token_id): mixed
+    {
+        return $this->delete($this->getProjectPath($project_id, 'access_tokens/'.$token_id));
+    }
+
+    public function protectedTags(int|string $project_id): mixed
+    {
+        return $this->get('projects/'.self::encodePath($project_id).'/protected_tags');
+    }
+
+    public function protectedTag(int|string $project_id, string $tag_name): mixed
+    {
+        return $this->get('projects/'.self::encodePath($project_id).'/protected_tags/'.self::encodePath($tag_name));
+    }
+
+    public function addProtectedTag(int|string $project_id, array $parameters = []): mixed
+    {
+        $resolver = new OptionsResolver();
+        $resolver->setDefined('name')
+            ->setAllowedTypes('name', 'string')
+            ->setRequired('name')
+        ;
+        $resolver->setDefined('create_access_level')
+            ->setAllowedTypes('create_access_level', 'int')
+            ->setAllowedValues('create_access_level', [0, 30, 40])
+        ;
+        $resolver->setDefined('allowed_to_create')
+            ->setAllowedTypes('allowed_to_create', 'array')
+            ->setAllowedValues('allowed_to_create', function (array $value) {
+                $keys = \array_keys((array) \call_user_func_array('array_merge', $value));
+                $diff = \array_diff($keys, ['user_id', 'group_id', 'access_level']);
+                $values = \array_map(function ($item) {
+                    return \array_values($item)[0] ?? '';
+                }, $value);
+                $integer = \count($values) === \count(\array_filter($values, 'is_int'));
+
+                return \count($value) > 0 && 0 === \count($diff) && $integer;
+            })
+        ;
+
+        return $this->post($this->getProjectPath($project_id, 'protected_tags'), $resolver->resolve($parameters));
+    }
+
+    public function deleteProtectedTag(int|string $project_id, string $tag_name): mixed
+    {
+        return $this->delete($this->getProjectPath($project_id, 'protected_tags/'.self::encodePath($tag_name)));
+    }
+
+    /**
+     * @param array $parameters {
+     *
+     *     @var string $scope        The scope to search in
+     *     @var string $search       The search query
+     *     @var string $state        Filter by state. Issues and merge requests are supported; it is ignored for other scopes.
+     *     @var string $ref          The name of a repository branch or tag to search on. The project’s default branch is used by default. Applicable only for scopes blobs, commits, and wiki_blobs.
+     *     @var bool   $confidential Filter by confidentiality. Issues scope is supported; it is ignored for other scopes.
+     *     @var string $order_by     Allowed values are created_at only. If this is not set, the results are either sorted by created_at in descending order for basic search, or by the most relevant documents when using advanced search.
+     *     @var string $sort         Return projects sorted in asc or desc order (default is desc)
+     * }
+     *
+     * @throws UndefinedOptionsException If an option name is undefined
+     * @throws InvalidOptionsException   If an option doesn't fulfill the specified validation rules
+     */
+    public function search(int|string $id, array $parameters = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+        $booleanNormalizer = function (Options $resolver, $value): string {
+            return $value ? 'true' : 'false';
+        };
+        $resolver->setDefined('confidential')
+            ->setAllowedTypes('confidential', 'bool')
+            ->setNormalizer('confidential', $booleanNormalizer);
+        $scope = [
+            'blobs',
+            'commits',
+            'issues',
+            'merge_requests',
+            'milestones',
+            'notes',
+            'users',
+            'wiki_blobs',
+        ];
+        $resolver->setRequired('scope')
+            ->setAllowedValues('scope', $scope);
+        $resolver->setRequired('search');
+        $resolver->setDefined('ref')
+            ->setAllowedTypes('ref', 'string');
+        $resolver->setDefined('order_by')
+            ->setAllowedValues('order_by', ['created_at']);
+        $resolver->setDefined('sort')
+            ->setAllowedValues('sort', ['asc', 'desc']);
+        $resolver->setDefined('state')
+            ->setAllowedValues('state', ['opened', 'closed']);
+
+        return $this->get('projects/'.self::encodePath($id).'/search', $resolver->resolve($parameters));
+    }
+}
diff --git a/src/Api/Repositories.php b/src/Api/Repositories.php
new file mode 100644
index 000000000..eded16b93
--- /dev/null
+++ b/src/Api/Repositories.php
@@ -0,0 +1,360 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Api;
+
+use Symfony\Component\OptionsResolver\Options;
+use Symfony\Component\OptionsResolver\OptionsResolver;
+
+class Repositories extends AbstractApi
+{
+    /**
+     * @var string
+     */
+    public const TYPE_BRANCH = 'branch';
+
+    /**
+     * @var string
+     */
+    public const TYPE_TAG = 'tag';
+
+    /**
+     * @param array      $parameters {
+     *
+     *     @var string $search
+     * }
+     */
+    public function branches(int|string $project_id, array $parameters = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+        $resolver->setDefined('search')
+            ->setAllowedTypes('search', 'string');
+
+        return $this->get($this->getProjectPath($project_id, 'repository/branches'), $resolver->resolve($parameters));
+    }
+
+    public function branch(int|string $project_id, string $branch): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'repository/branches/'.self::encodePath($branch)));
+    }
+
+    public function createBranch(int|string $project_id, string $branch, string $ref): mixed
+    {
+        return $this->post($this->getProjectPath($project_id, 'repository/branches'), [
+            'branch' => $branch,
+            'ref' => $ref,
+        ]);
+    }
+
+    public function deleteBranch(int|string $project_id, string $branch): mixed
+    {
+        return $this->delete($this->getProjectPath($project_id, 'repository/branches/'.self::encodePath($branch)));
+    }
+
+    public function protectBranch(int|string $project_id, string $branch, bool $devPush = false, bool $devMerge = false): mixed
+    {
+        return $this->put($this->getProjectPath($project_id, 'repository/branches/'.self::encodePath($branch).'/protect'), [
+            'developers_can_push' => $devPush,
+            'developers_can_merge' => $devMerge,
+        ]);
+    }
+
+    public function unprotectBranch(int|string $project_id, string $branch): mixed
+    {
+        return $this->put($this->getProjectPath($project_id, 'repository/branches/'.self::encodePath($branch).'/unprotect'));
+    }
+
+    public function tags(int|string $project_id, array $parameters = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+        $resolver->setDefined('search')
+            ->setAllowedTypes('search', 'string');
+
+        return $this->get($this->getProjectPath($project_id, 'repository/tags'), $resolver->resolve($parameters));
+    }
+
+    public function createTag(int|string $project_id, string $name, string $ref, ?string $message = null): mixed
+    {
+        return $this->post($this->getProjectPath($project_id, 'repository/tags'), [
+            'tag_name' => $name,
+            'ref' => $ref,
+            'message' => $message,
+        ]);
+    }
+
+    public function createRelease(int|string $project_id, string $tag_name, string $description, ?string $name = null): mixed
+    {
+        return $this->post($this->getProjectPath($project_id, 'releases'), \array_filter([
+            'id' => $project_id,
+            'tag_name' => $tag_name,
+            'description' => $description,
+            'name' => $name,
+        ], fn ($v) => null !== $v));
+    }
+
+    public function updateRelease(int|string $project_id, string $tag_name, string $description, ?string $name = null): mixed
+    {
+        return $this->put($this->getProjectPath($project_id, 'releases/'.self::encodePath($tag_name)), \array_filter([
+            'id' => $project_id,
+            'tag_name' => $tag_name,
+            'description' => $description,
+            'name' => $name,
+        ], fn ($v) => null !== $v));
+    }
+
+    public function releases(int|string $project_id): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+
+        return $this->get($this->getProjectPath($project_id, 'releases'));
+    }
+
+    /**
+     * @see https://docs.gitlab.com/ee/api/commits.html#list-repository-commits
+     *
+     * @param array      $parameters {
+     *
+     *     @var string             $ref_name the name of a repository branch or tag or if not given the default branch
+     *     @var \DateTimeInterface $since    only commits after or on this date will be returned
+     *     @var \DateTimeInterface $until    Only commits before or on this date will be returned.
+     * }
+     */
+    public function commits(int|string $project_id, array $parameters = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+        $datetimeNormalizer = function (Options $options, \DateTimeInterface $value): string {
+            return $value->format('c');
+        };
+        $booleanNormalizer = function (Options $resolver, $value): string {
+            return $value ? 'true' : 'false';
+        };
+
+        $resolver->setDefined('path');
+        $resolver->setDefined('ref_name');
+        $resolver->setDefined('author');
+        $resolver->setDefined('since')
+            ->setAllowedTypes('since', \DateTimeInterface::class)
+            ->setNormalizer('since', $datetimeNormalizer)
+        ;
+        $resolver->setDefined('until')
+            ->setAllowedTypes('until', \DateTimeInterface::class)
+            ->setNormalizer('until', $datetimeNormalizer)
+        ;
+        $resolver->setDefined('all')
+            ->setAllowedTypes('all', 'bool')
+            ->setNormalizer('all', $booleanNormalizer)
+        ;
+        $resolver->setDefined('with_stats')
+            ->setAllowedTypes('with_stats', 'bool')
+            ->setNormalizer('with_stats', $booleanNormalizer)
+        ;
+        $resolver->setDefined('first_parent')
+            ->setAllowedTypes('first_parent', 'bool')
+            ->setNormalizer('first_parent', $booleanNormalizer)
+        ;
+        $resolver->setDefined('order')
+            ->setAllowedValues('order', ['default', 'topo'])
+        ;
+
+        return $this->get($this->getProjectPath($project_id, 'repository/commits'), $resolver->resolve($parameters));
+    }
+
+    public function commit(int|string $project_id, string $sha): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'repository/commits/'.self::encodePath($sha)));
+    }
+
+    public function commitRefs(int|string $project_id, string $sha, array $parameters = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+
+        return $this->get(
+            $this->getProjectPath($project_id, 'repository/commits/'.self::encodePath($sha).'/refs'),
+            $resolver->resolve($parameters)
+        );
+    }
+
+    /**
+     * @param array      $parameters {
+     *
+     *     @var string $branch         Name of the branch to commit into. To create a new branch, also provide start_branch.
+     *     @var string $commit_message commit message
+     *     @var string $start_branch   name of the branch to start the new commit from
+     *     @var array $actions {
+     *         @var string $action        he action to perform, create, delete, move, update
+     *         @var string $file_path     full path to the file
+     *         @var string $previous_path original full path to the file being moved
+     *         @var string $content       File content, required for all except delete. Optional for move.
+     *         @var string $encoding      text or base64. text is default.
+     *     }
+     *     @var string $author_email   specify the commit author's email address
+     *     @var string $author_name    Specify the commit author's name.
+     * }
+     */
+    public function createCommit(int|string $project_id, array $parameters = []): mixed
+    {
+        $resolver = new OptionsResolver();
+        $resolver->setDefined('branch')
+            ->setRequired('branch')
+        ;
+        $resolver->setDefined('commit_message')
+            ->setRequired('commit_message')
+        ;
+        $resolver->setDefined('start_branch');
+        $resolver->setDefined('actions')
+            ->setRequired('actions')
+            ->setAllowedTypes('actions', 'array')
+            ->setAllowedValues('actions', function (array $actions) {
+                return 0 < \count($actions);
+            })
+            ->setNormalizer('actions', function (Options $resolver, array $actions) {
+                $actionsOptionsResolver = new OptionsResolver();
+                $actionsOptionsResolver->setDefined('action')
+                    ->setRequired('action')
+                    ->setAllowedValues('action', ['create', 'delete', 'move', 'update', 'chmod'])
+                ;
+                $actionsOptionsResolver->setDefined('file_path')
+                    ->setRequired('file_path')
+                ;
+                $actionsOptionsResolver->setDefined('previous_path');
+                $actionsOptionsResolver->setDefined('content');
+                $actionsOptionsResolver->setDefined('encoding')
+                    ->setAllowedValues('encoding', ['text', 'base64'])
+                ;
+                $actionsOptionsResolver->setDefined('execute_filemode')
+                    ->setAllowedValues('execute_filemode', [true, false])
+                ;
+
+                return \array_map(function ($action) use ($actionsOptionsResolver) {
+                    return $actionsOptionsResolver->resolve($action);
+                }, $actions);
+            })
+        ;
+        $resolver->setDefined('author_email');
+        $resolver->setDefined('author_name');
+
+        return $this->post($this->getProjectPath($project_id, 'repository/commits'), $resolver->resolve($parameters));
+    }
+
+    public function revertCommit(int|string $project_id, string $branch, string $sha): mixed
+    {
+        return $this->post($this->getProjectPath($project_id, 'repository/commits/'.self::encodePath($sha).'/revert'), [
+            'branch' => $branch,
+        ]);
+    }
+
+    public function commitComments(int|string $project_id, string $sha, array $parameters = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+
+        return $this->get(
+            $this->getProjectPath($project_id, 'repository/commits/'.self::encodePath($sha).'/comments'),
+            $resolver->resolve($parameters)
+        );
+    }
+
+    public function createCommitComment(int|string $project_id, string $sha, string $note, array $params = []): mixed
+    {
+        $params['note'] = $note;
+
+        return $this->post($this->getProjectPath($project_id, 'repository/commits/'.self::encodePath($sha).'/comments'), $params);
+    }
+
+    public function getCommitBuildStatus(int|string $project_id, string $sha, array $params = []): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'repository/commits/'.self::encodePath($sha).'/statuses'), $params);
+    }
+
+    public function postCommitBuildStatus(int|string $project_id, string $sha, string $state, array $params = []): mixed
+    {
+        $params['state'] = $state;
+
+        return $this->post($this->getProjectPath($project_id, 'statuses/'.self::encodePath($sha)), $params);
+    }
+
+    public function compare(int|string $project_id, string $fromShaOrMaster, string $toShaOrMaster, bool $straight = false, ?string $fromProjectId = null): mixed
+    {
+        $params = [
+            'from' => $fromShaOrMaster,
+            'to' => $toShaOrMaster,
+            'straight' => $straight ? 'true' : 'false',
+        ];
+
+        if (null !== $fromProjectId) {
+            $params['from_project_id'] = self::encodePath($fromProjectId);
+        }
+
+        return $this->get($this->getProjectPath($project_id, 'repository/compare'), $params);
+    }
+
+    public function diff(int|string $project_id, string $sha): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'repository/commits/'.self::encodePath($sha).'/diff'));
+    }
+
+    public function tree(int|string $project_id, array $params = []): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'repository/tree'), $params);
+    }
+
+    public function contributors(int|string $project_id): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'repository/contributors'));
+    }
+
+    /**
+     * @param string     $format     Options: "tar.gz", "zip", "tar.bz2" and "tar"
+     */
+    public function archive(int|string $project_id, array $params = [], string $format = 'tar.gz'): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'repository/archive.'.$format), $params);
+    }
+
+    public function mergeBase(int|string $project_id, array $refs): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'repository/merge_base'), ['refs' => $refs]);
+    }
+
+    public function cherryPick(int|string $project_id, string $sha, array $params = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+        $booleanNormalizer = function (Options $resolver, $value): string {
+            return $value ? 'true' : 'false';
+        };
+
+        $resolver->setDefined('branch')
+            ->setRequired('branch');
+
+        $resolver->setDefined('dry_run')
+            ->setAllowedTypes('dry_run', 'bool')
+            ->setNormalizer('dry_run', $booleanNormalizer);
+
+        return $this->post($this->getProjectPath($project_id, 'repository/commits/'.self::encodePath($sha).'/cherry_pick'), $params);
+    }
+
+    protected function createOptionsResolver(): OptionsResolver
+    {
+        $allowedTypeValues = [
+            self::TYPE_BRANCH,
+            self::TYPE_TAG,
+        ];
+
+        $resolver = parent::createOptionsResolver();
+        $resolver->setDefined('type')
+            ->setAllowedTypes('type', 'string')
+            ->setAllowedValues('type', $allowedTypeValues);
+
+        return $resolver;
+    }
+}
diff --git a/src/Api/RepositoryFiles.php b/src/Api/RepositoryFiles.php
new file mode 100644
index 000000000..80f303ad8
--- /dev/null
+++ b/src/Api/RepositoryFiles.php
@@ -0,0 +1,126 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Api;
+
+use Symfony\Component\OptionsResolver\OptionsResolver;
+
+class RepositoryFiles extends AbstractApi
+{
+    public function getFile(int|string $project_id, string $file_path, string $ref): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'repository/files/'.self::encodePath($file_path)), [
+            'ref' => $ref,
+        ]);
+    }
+
+    public function getRawFile(int|string $project_id, string $file_path, string $ref): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'repository/files/'.self::encodePath($file_path).'/raw'), [
+            'ref' => $ref,
+        ]);
+    }
+
+    /**
+     * @param array      $parameters {
+     *
+     *     @var string $file_path      Url encoded full path to new file. Ex. lib%2Fclass%2Erb.
+     *     @var string $branch         name of the branch
+     *     @var string $start_branch   name of the branch to start the new commit from
+     *     @var string $encoding       change encoding to 'base64' (default is text)
+     *     @var string $author_email   specify the commit author's email address
+     *     @var string $author_name    specify the commit author's name
+     *     @var string $content        file content
+     *     @var string $commit_message Commit message.
+     * }
+     */
+    public function createFile(int|string $project_id, array $parameters = []): mixed
+    {
+        $resolver = new OptionsResolver();
+        $resolver->setRequired('file_path');
+        $resolver->setRequired('branch');
+        $resolver->setDefined('start_branch');
+        $resolver->setDefined('encoding')
+            ->setAllowedValues('encoding', ['text', 'base64'])
+        ;
+        $resolver->setDefined('author_email');
+        $resolver->setDefined('author_name');
+        $resolver->setRequired('content');
+        $resolver->setRequired('commit_message');
+
+        $resolved = $resolver->resolve($parameters);
+
+        return $this->post($this->getProjectPath($project_id, 'repository/files/'.self::encodePath($resolved['file_path'])), $resolved);
+    }
+
+    /**
+     * @param array      $parameters {
+     *
+     *     @var string $file_path      Url encoded full path to new file. Ex. lib%2Fclass%2Erb.
+     *     @var string $branch         name of the branch
+     *     @var string $start_branch   name of the branch to start the new commit from
+     *     @var string $encoding       change encoding to 'base64' (default is text)
+     *     @var string $author_email   specify the commit author's email address
+     *     @var string $author_name    specify the commit author's name
+     *     @var string $content        file content
+     *     @var string $commit_message commit message
+     *     @var string $last_commit_id last known file commit id
+     * }
+     */
+    public function updateFile(int|string $project_id, array $parameters = []): mixed
+    {
+        $resolver = new OptionsResolver();
+        $resolver->setRequired('file_path');
+        $resolver->setRequired('branch');
+        $resolver->setDefined('start_branch');
+        $resolver->setDefined('encoding')
+            ->setAllowedValues('encoding', ['text', 'base64'])
+        ;
+        $resolver->setDefined('author_email');
+        $resolver->setDefined('author_name');
+        $resolver->setRequired('content');
+        $resolver->setRequired('commit_message');
+        $resolver->setDefined('last_commit_id');
+
+        $resolved = $resolver->resolve($parameters);
+
+        return $this->put($this->getProjectPath($project_id, 'repository/files/'.self::encodePath($resolved['file_path'])), $resolved);
+    }
+
+    /**
+     * @param array      $parameters {
+     *
+     *     @var string $file_path      Url encoded full path to new file. Ex. lib%2Fclass%2Erb.
+     *     @var string $branch         name of the branch
+     *     @var string $start_branch   name of the branch to start the new commit from
+     *     @var string $author_email   specify the commit author's email address
+     *     @var string $author_name    specify the commit author's name
+     *     @var string $commit_message Commit message.
+     * }
+     */
+    public function deleteFile(int|string $project_id, array $parameters = []): mixed
+    {
+        $resolver = new OptionsResolver();
+        $resolver->setRequired('file_path');
+        $resolver->setRequired('branch');
+        $resolver->setDefined('start_branch');
+        $resolver->setDefined('author_email');
+        $resolver->setDefined('author_name');
+        $resolver->setRequired('commit_message');
+
+        $resolved = $resolver->resolve($parameters);
+
+        return $this->delete($this->getProjectPath($project_id, 'repository/files/'.self::encodePath($resolved['file_path'])), $resolved);
+    }
+}
diff --git a/src/Api/ResourceIterationEvents.php b/src/Api/ResourceIterationEvents.php
new file mode 100644
index 000000000..f18edb752
--- /dev/null
+++ b/src/Api/ResourceIterationEvents.php
@@ -0,0 +1,33 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Api;
+
+class ResourceIterationEvents extends AbstractApi
+{
+    public function all(int|string $project_id, int $issue_iid): mixed
+    {
+        $path = 'issues/'.self::encodePath($issue_iid).'/resource_iteration_events';
+
+        return $this->get($this->getProjectPath($project_id, $path));
+    }
+
+    public function show(int|string $project_id, int $issue_iid, int $resource_iteration_event_id): mixed
+    {
+        $path = 'issues/'.self::encodePath($issue_iid).'/resource_iteration_events/';
+        $path .= self::encodePath($resource_iteration_event_id);
+
+        return $this->get($this->getProjectPath($project_id, $path));
+    }
+}
diff --git a/src/Api/ResourceLabelEvents.php b/src/Api/ResourceLabelEvents.php
new file mode 100644
index 000000000..9ccf65679
--- /dev/null
+++ b/src/Api/ResourceLabelEvents.php
@@ -0,0 +1,33 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Api;
+
+class ResourceLabelEvents extends AbstractApi
+{
+    public function all(int|string $project_id, int $issue_iid): mixed
+    {
+        $path = 'issues/'.self::encodePath($issue_iid).'/resource_label_events';
+
+        return $this->get($this->getProjectPath($project_id, $path));
+    }
+
+    public function show(int|string $project_id, int $issue_iid, int $resource_label_event_id): mixed
+    {
+        $path = 'issues/'.self::encodePath($issue_iid).'/resource_label_events/';
+        $path .= self::encodePath($resource_label_event_id);
+
+        return $this->get($this->getProjectPath($project_id, $path));
+    }
+}
diff --git a/src/Api/ResourceMilestoneEvents.php b/src/Api/ResourceMilestoneEvents.php
new file mode 100644
index 000000000..be4adb473
--- /dev/null
+++ b/src/Api/ResourceMilestoneEvents.php
@@ -0,0 +1,33 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Api;
+
+class ResourceMilestoneEvents extends AbstractApi
+{
+    public function all(int|string $project_id, int $issue_iid): mixed
+    {
+        $path = 'issues/'.self::encodePath($issue_iid).'/resource_milestone_events';
+
+        return $this->get($this->getProjectPath($project_id, $path));
+    }
+
+    public function show(int|string $project_id, int $issue_iid, int $resource_milestone_event_id): mixed
+    {
+        $path = 'issues/'.self::encodePath($issue_iid).'/resource_milestone_events/';
+        $path .= self::encodePath($resource_milestone_event_id);
+
+        return $this->get($this->getProjectPath($project_id, $path));
+    }
+}
diff --git a/src/Api/ResourceStateEvents.php b/src/Api/ResourceStateEvents.php
new file mode 100644
index 000000000..be03a6d5b
--- /dev/null
+++ b/src/Api/ResourceStateEvents.php
@@ -0,0 +1,33 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Api;
+
+class ResourceStateEvents extends AbstractApi
+{
+    public function all(int|string $project_id, int $issue_iid): mixed
+    {
+        $path = 'issues/'.self::encodePath($issue_iid).'/resource_state_events';
+
+        return $this->get($this->getProjectPath($project_id, $path));
+    }
+
+    public function show(int|string $project_id, int $issue_iid, int $resource_label_event_id): mixed
+    {
+        $path = 'issues/'.self::encodePath($issue_iid).'/resource_state_events/';
+        $path .= self::encodePath($resource_label_event_id);
+
+        return $this->get($this->getProjectPath($project_id, $path));
+    }
+}
diff --git a/src/Api/ResourceWeightEvents.php b/src/Api/ResourceWeightEvents.php
new file mode 100644
index 000000000..53a7b162f
--- /dev/null
+++ b/src/Api/ResourceWeightEvents.php
@@ -0,0 +1,33 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Api;
+
+class ResourceWeightEvents extends AbstractApi
+{
+    public function all(int|string $project_id, int $issue_iid): mixed
+    {
+        $path = 'issues/'.self::encodePath($issue_iid).'/resource_weight_events';
+
+        return $this->get($this->getProjectPath($project_id, $path));
+    }
+
+    public function show(int|string $project_id, int $issue_iid, int $resource_label_event_id): mixed
+    {
+        $path = 'issues/'.self::encodePath($issue_iid).'/resource_weight_events/';
+        $path .= self::encodePath($resource_label_event_id);
+
+        return $this->get($this->getProjectPath($project_id, $path));
+    }
+}
diff --git a/src/Api/Schedules.php b/src/Api/Schedules.php
new file mode 100644
index 000000000..44b61964a
--- /dev/null
+++ b/src/Api/Schedules.php
@@ -0,0 +1,74 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Api;
+
+class Schedules extends AbstractApi
+{
+    public function create(int|string $project_id, array $params): mixed
+    {
+        return $this->post($this->getProjectPath($project_id, 'pipeline_schedules'), $params);
+    }
+
+    public function show(int|string $project_id, int $schedule_id): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'pipeline_schedules/'.self::encodePath($schedule_id)));
+    }
+
+    public function showAll(int|string $project_id): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'pipeline_schedules'));
+    }
+
+    public function update(int|string $project_id, int $schedule_id, array $params): mixed
+    {
+        return $this->put($this->getProjectPath($project_id, 'pipeline_schedules/'.self::encodePath($schedule_id)), $params);
+    }
+
+    public function remove(int|string $project_id, int $schedule_id): mixed
+    {
+        return $this->delete($this->getProjectPath($project_id, 'pipeline_schedules/'.self::encodePath($schedule_id)));
+    }
+
+    public function addVariable(int|string $project_id, int $schedule_id, array $params): mixed
+    {
+        $path = 'pipeline_schedules/'.self::encodePath($schedule_id).'/variables';
+
+        return $this->post($this->getProjectPath($project_id, $path), $params);
+    }
+
+    public function updateVariable(int|string $project_id, int $schedule_id, string $variable_key, array $params): mixed
+    {
+        $path = 'pipeline_schedules/'.self::encodePath($schedule_id).'/variables/'.self::encodePath($variable_key);
+
+        return $this->put($this->getProjectPath($project_id, $path), $params);
+    }
+
+    public function removeVariable(int|string $project_id, int $schedule_id, string $variable_key): mixed
+    {
+        $path = 'pipeline_schedules/'.self::encodePath($schedule_id).'/variables/'.self::encodePath($variable_key);
+
+        return $this->delete($this->getProjectPath($project_id, $path));
+    }
+
+    public function takeOwnership(int|string $project_id, int $schedule_id): mixed
+    {
+        return $this->post($this->getProjectPath($project_id, 'pipeline_schedules/'.self::encodePath($schedule_id)).'/take_ownership');
+    }
+
+    public function play(int|string $project_id, int $schedule_id): mixed
+    {
+        return $this->post($this->getProjectPath($project_id, 'pipeline_schedules/'.self::encodePath($schedule_id)).'/play');
+    }
+}
diff --git a/src/Api/Search.php b/src/Api/Search.php
new file mode 100644
index 000000000..a7685bd65
--- /dev/null
+++ b/src/Api/Search.php
@@ -0,0 +1,70 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <graham@alt-three.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Api;
+
+use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException;
+use Symfony\Component\OptionsResolver\Exception\UndefinedOptionsException;
+use Symfony\Component\OptionsResolver\Options;
+
+class Search extends AbstractApi
+{
+    /**
+     * @param array $parameters {
+     *
+     *     @var string $scope        The scope to search in
+     *     @var string $search       The search query
+     *     @var string $state        Filter by state. Issues and merge requests are supported; it is ignored for other scopes.
+     *     @var bool   $confidential Filter by confidentiality. Issues scope is supported; it is ignored for other scopes.
+     *     @var string $order_by     Allowed values are created_at only. If this is not set, the results are either sorted by created_at in descending order for basic search, or by the most relevant documents when using advanced search.
+     *     @var string $sort         Return projects sorted in asc or desc order (default is desc)
+     * }
+     *
+     * @throws UndefinedOptionsException If an option name is undefined
+     * @throws InvalidOptionsException   If an option doesn't fulfill the specified validation rules
+     */
+    public function all(array $parameters = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+        $booleanNormalizer = function (Options $resolver, $value): string {
+            return $value ? 'true' : 'false';
+        };
+        $resolver->setDefined('confidential')
+            ->setAllowedTypes('confidential', 'bool')
+            ->setNormalizer('confidential', $booleanNormalizer);
+        $scope = [
+            'projects',
+            'issues',
+            'merge_requests',
+            'milestones',
+            'snippet_titles',
+            'wiki_blobs',
+            'commits',
+            'blobs',
+            'notes',
+            'users',
+        ];
+        $resolver->setRequired('scope')
+            ->setAllowedValues('scope', $scope);
+        $resolver->setRequired('search');
+        $resolver->setDefined('order_by')
+            ->setAllowedValues('order_by', ['created_at']);
+        $resolver->setDefined('sort')
+            ->setAllowedValues('sort', ['asc', 'desc']);
+        $resolver->setDefined('state')
+            ->setAllowedValues('state', ['opened', 'closed']);
+
+        return $this->get('search', $resolver->resolve($parameters));
+    }
+}
diff --git a/src/Api/Snippets.php b/src/Api/Snippets.php
new file mode 100644
index 000000000..d62e174eb
--- /dev/null
+++ b/src/Api/Snippets.php
@@ -0,0 +1,92 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Api;
+
+class Snippets extends AbstractApi
+{
+    public function all(int|string $project_id): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'snippets'));
+    }
+
+    public function show(int|string $project_id, int $snippet_id): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'snippets/'.self::encodePath($snippet_id)));
+    }
+
+    public function create(int|string $project_id, string $title, string $filename, string $code, string $visibility): mixed
+    {
+        return $this->post($this->getProjectPath($project_id, 'snippets'), [
+            'title' => $title,
+            'file_name' => $filename,
+            'code' => $code,
+            'visibility' => $visibility,
+        ]);
+    }
+
+    public function update(int|string $project_id, int $snippet_id, array $params): mixed
+    {
+        return $this->put($this->getProjectPath($project_id, 'snippets/'.self::encodePath($snippet_id)), $params);
+    }
+
+    public function content(int|string $project_id, int $snippet_id): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'snippets/'.self::encodePath($snippet_id).'/raw'));
+    }
+
+    public function remove(int|string $project_id, int $snippet_id): mixed
+    {
+        return $this->delete($this->getProjectPath($project_id, 'snippets/'.self::encodePath($snippet_id)));
+    }
+
+    public function showNotes(int|string $project_id, int $snippet_id): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'snippets/'.self::encodePath($snippet_id).'/notes'));
+    }
+
+    public function showNote(int|string $project_id, int $snippet_id, int $note_id): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'snippets/'.self::encodePath($snippet_id).'/notes/'.self::encodePath($note_id)));
+    }
+
+    public function addNote(int|string $project_id, int $snippet_id, string $body, array $params = []): mixed
+    {
+        $params['body'] = $body;
+
+        return $this->post($this->getProjectPath($project_id, 'snippets/'.self::encodePath($snippet_id).'/notes'), $params);
+    }
+
+    public function updateNote(int|string $project_id, int $snippet_id, int $note_id, string $body): mixed
+    {
+        return $this->put($this->getProjectPath($project_id, 'snippets/'.self::encodePath($snippet_id).'/notes/'.self::encodePath($note_id)), [
+            'body' => $body,
+        ]);
+    }
+
+    public function removeNote(int|string $project_id, int $snippet_id, int $note_id): mixed
+    {
+        return $this->delete($this->getProjectPath($project_id, 'snippets/'.self::encodePath($snippet_id).'/notes/'.self::encodePath($note_id)));
+    }
+
+    public function awardEmoji(int|string $project_id, int $snippet_id): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'snippets/'.self::encodePath($snippet_id).'/award_emoji'));
+    }
+
+    public function removeAwardEmoji(int|string $project_id, int $snippet_id, int $award_id): mixed
+    {
+        return $this->delete($this->getProjectPath($project_id, 'snippets/'.self::encodePath($snippet_id).'/award_emoji/'.self::encodePath($award_id)));
+    }
+}
diff --git a/src/Api/SystemHooks.php b/src/Api/SystemHooks.php
new file mode 100644
index 000000000..f068429db
--- /dev/null
+++ b/src/Api/SystemHooks.php
@@ -0,0 +1,94 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Api;
+
+use Symfony\Component\OptionsResolver\Options;
+use Symfony\Component\OptionsResolver\OptionsResolver;
+
+class SystemHooks extends AbstractApi
+{
+    public function all(): mixed
+    {
+        return $this->get('hooks');
+    }
+
+    /**
+     * @param array<string,string|bool> $parameters {
+     *
+     *     @var string  $token                      secret token to validate received payloads
+     *     @var bool    $push_events                when true, the hook fires on push events
+     *     @var bool    $tag_push_events            when true, the hook fires on new tags being pushed
+     *     @var bool    $merge_requests_events      trigger hook on merge requests events
+     *     @var bool    $repository_update_events   trigger hook on repository update events
+     *     @var bool    $enable_ssl_verification    do SSL verification when triggering the hook
+     * }
+     */
+    public function create(string $url, array $parameters = []): mixed
+    {
+        $parameters = $this->createOptionsResolver()->resolve($parameters);
+
+        $parameters['url'] = $url;
+
+        return $this->post('hooks', $parameters);
+    }
+
+    public function test(int $id): mixed
+    {
+        return $this->get('hooks/'.self::encodePath($id));
+    }
+
+    public function remove(int $id): mixed
+    {
+        return $this->delete('hooks/'.self::encodePath($id));
+    }
+
+    protected function createOptionsResolver(): OptionsResolver
+    {
+        $resolver = new OptionsResolver();
+
+        $resolver->setDefined('token');
+
+        $booleanNormalizer = function (Options $resolver, $value): string {
+            return $value ? 'true' : 'false';
+        };
+
+        $resolver->setDefined('push_events')
+                 ->setAllowedTypes('push_events', 'bool')
+                 ->setNormalizer('push_events', $booleanNormalizer)
+        ;
+
+        $resolver->setDefined('tag_push_events')
+                 ->setAllowedTypes('tag_push_events', 'bool')
+                 ->setNormalizer('tag_push_events', $booleanNormalizer)
+        ;
+
+        $resolver->setDefined('merge_requests_events')
+                 ->setAllowedTypes('merge_requests_events', 'bool')
+                 ->setNormalizer('merge_requests_events', $booleanNormalizer)
+        ;
+
+        $resolver->setDefined('repository_update_events')
+                 ->setAllowedTypes('repository_update_events', 'bool')
+                 ->setNormalizer('repository_update_events', $booleanNormalizer)
+        ;
+
+        $resolver->setDefined('enable_ssl_verification')
+                 ->setAllowedTypes('enable_ssl_verification', 'bool')
+                 ->setNormalizer('enable_ssl_verification', $booleanNormalizer)
+        ;
+
+        return $resolver;
+    }
+}
diff --git a/src/Api/Tags.php b/src/Api/Tags.php
new file mode 100644
index 000000000..8085d7638
--- /dev/null
+++ b/src/Api/Tags.php
@@ -0,0 +1,64 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Api;
+
+class Tags extends AbstractApi
+{
+    /**
+     * @param array      $parameters {
+     *
+     *     @var string $order_by Return tags ordered by `name`, `updated` or `version` fields. Default is `updated`.
+     *     @var string $sort     Return tags sorted in asc or desc order. Default is desc.
+     *     @var string $search   Return list of tags matching the search criteria. You can use `^term` and `term$` to
+     *                           find tags that begin and end with term respectively.
+     * }
+     */
+    public function all(int|string $project_id, array $parameters = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+        $resolver->setDefined('order_by')
+            ->setAllowedValues('order_by', ['name', 'updated', 'version']);
+        $resolver->setDefined('sort')
+            ->setAllowedValues('sort', ['asc', 'desc']);
+        $resolver->setDefined('search');
+
+        return $this->get($this->getProjectPath($project_id, 'repository/tags'), $resolver->resolve($parameters));
+    }
+
+    public function show(int|string $project_id, string $tag_name): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'repository/tags/'.self::encodePath($tag_name)));
+    }
+
+    public function create(int|string $project_id, array $params = []): mixed
+    {
+        return $this->post($this->getProjectPath($project_id, 'repository/tags'), $params);
+    }
+
+    public function remove(int|string $project_id, string $tag_name): mixed
+    {
+        return $this->delete($this->getProjectPath($project_id, 'repository/tags/'.self::encodePath($tag_name)));
+    }
+
+    public function createRelease(int|string $project_id, string $tag_name, array $params = []): mixed
+    {
+        return $this->post($this->getProjectPath($project_id, 'repository/tags/'.self::encodePath($tag_name).'/release'), $params);
+    }
+
+    public function updateRelease(int|string $project_id, string $tag_name, array $params = []): mixed
+    {
+        return $this->put($this->getProjectPath($project_id, 'repository/tags/'.self::encodePath($tag_name).'/release'), $params);
+    }
+}
diff --git a/src/Api/Users.php b/src/Api/Users.php
new file mode 100644
index 000000000..01fd1ff3e
--- /dev/null
+++ b/src/Api/Users.php
@@ -0,0 +1,446 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Api;
+
+use Symfony\Component\OptionsResolver\Options;
+
+class Users extends AbstractApi
+{
+    /**
+     * @param array $parameters {
+     *
+     *     @var string             $search         search for user by email or username
+     *     @var string             $username       lookup for user by username
+     *     @var bool               $external       search for external users only
+     *     @var string             $extern_uid     lookup for users by external uid
+     *     @var string             $provider       lookup for users by provider
+     *     @var \DateTimeInterface $created_before return users created before the given time (inclusive)
+     *     @var \DateTimeInterface $created_after  return users created after the given time (inclusive)
+     *     @var bool               $active         Return only active users. It does not support filtering inactive users.
+     *     @var bool               $blocked        Return only blocked users. It does not support filtering non-blocked users.
+     * }
+     */
+    public function all(array $parameters = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+        $datetimeNormalizer = function (Options $resolver, \DateTimeInterface $value): string {
+            return $value->format('c');
+        };
+
+        $resolver->setDefined('search');
+        $resolver->setDefined('username');
+        $resolver->setDefined('external')
+            ->setAllowedTypes('external', 'bool')
+        ;
+        $resolver->setDefined('extern_uid');
+        $resolver->setDefined('provider');
+        $resolver->setDefined('created_before')
+            ->setAllowedTypes('created_before', \DateTimeInterface::class)
+            ->setNormalizer('created_before', $datetimeNormalizer)
+        ;
+        $resolver->setDefined('created_after')
+            ->setAllowedTypes('created_after', \DateTimeInterface::class)
+            ->setNormalizer('created_after', $datetimeNormalizer)
+        ;
+        $resolver->setDefined('active')
+            ->setAllowedTypes('active', 'bool')
+            ->setAllowedValues('active', true)
+        ;
+        $resolver->setDefined('blocked')
+            ->setAllowedTypes('blocked', 'bool')
+            ->setAllowedValues('blocked', true)
+        ;
+
+        return $this->get('users', $resolver->resolve($parameters));
+    }
+
+    public function show(int $id): mixed
+    {
+        return $this->get('users/'.self::encodePath($id));
+    }
+
+    /**
+     * @param array $parameters {
+     *
+     *     @var string $type Filter memberships by type. Can be either Project or Namespace
+     * }
+     */
+    public function usersMemberships(int $id, array $parameters = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+        $resolver->setDefined('type')
+                 ->setAllowedValues('type', ['Project', 'Namespace'])
+        ;
+
+        return $this->get('users/'.self::encodePath($id).'/memberships', $resolver->resolve($parameters));
+    }
+
+    /**
+     * @param array $parameters {
+     *
+     *     @var bool   $archived                    limit by archived status
+     *     @var string $visibility                  limit by visibility public, internal, or private
+     *     @var string $order_by                    Return projects ordered by id, name, path, created_at, updated_at,
+     *                                              or last_activity_at fields (default is created_at)
+     *     @var string $sort                        Return projects sorted in asc or desc order (default is desc)
+     *     @var string $search                      return list of projects matching the search criteria
+     *     @var bool   $simple                      return only the ID, URL, name, and path of each project
+     *     @var bool   $owned                       limit by projects owned by the current user
+     *     @var bool   $membership                  limit by projects that the current user is a member of
+     *     @var bool   $starred                     limit by projects starred by the current user
+     *     @var bool   $statistics                  include project statistics
+     *     @var bool   $with_issues_enabled         limit by enabled issues feature
+     *     @var bool   $with_merge_requests_enabled limit by enabled merge requests feature
+     *     @var int    $min_access_level            Limit by current user minimal access level
+     * }
+     */
+    public function usersProjects(int $id, array $parameters = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+        $booleanNormalizer = function (Options $resolver, $value): string {
+            return $value ? 'true' : 'false';
+        };
+        $resolver->setDefined('archived')
+            ->setAllowedTypes('archived', 'bool')
+            ->setNormalizer('archived', $booleanNormalizer)
+        ;
+        $resolver->setDefined('visibility')
+            ->setAllowedValues('visibility', ['public', 'internal', 'private'])
+        ;
+        $resolver->setDefined('order_by')
+            ->setAllowedValues('order_by', ['id', 'name', 'path', 'created_at', 'updated_at', 'last_activity_at'])
+        ;
+        $resolver->setDefined('sort')
+            ->setAllowedValues('sort', ['asc', 'desc'])
+        ;
+        $resolver->setDefined('search');
+        $resolver->setDefined('simple')
+            ->setAllowedTypes('simple', 'bool')
+            ->setNormalizer('simple', $booleanNormalizer)
+        ;
+        $resolver->setDefined('owned')
+            ->setAllowedTypes('owned', 'bool')
+            ->setNormalizer('owned', $booleanNormalizer)
+        ;
+        $resolver->setDefined('membership')
+            ->setAllowedTypes('membership', 'bool')
+            ->setNormalizer('membership', $booleanNormalizer)
+        ;
+        $resolver->setDefined('starred')
+            ->setAllowedTypes('starred', 'bool')
+            ->setNormalizer('starred', $booleanNormalizer)
+        ;
+        $resolver->setDefined('statistics')
+            ->setAllowedTypes('statistics', 'bool')
+            ->setNormalizer('statistics', $booleanNormalizer)
+        ;
+        $resolver->setDefined('with_issues_enabled')
+            ->setAllowedTypes('with_issues_enabled', 'bool')
+            ->setNormalizer('with_issues_enabled', $booleanNormalizer)
+        ;
+        $resolver->setDefined('with_merge_requests_enabled')
+            ->setAllowedTypes('with_merge_requests_enabled', 'bool')
+            ->setNormalizer('with_merge_requests_enabled', $booleanNormalizer)
+        ;
+        $resolver->setDefined('min_access_level')
+            ->setAllowedValues('min_access_level', [null, 10, 20, 30, 40, 50])
+        ;
+
+        return $this->get('users/'.self::encodePath($id).'/projects', $resolver->resolve($parameters));
+    }
+
+    /**
+     * @param array $parameters {
+     *
+     *     @var bool   $archived                    limit by archived status
+     *     @var string $visibility                  limit by visibility public, internal, or private
+     *     @var string $order_by                    Return projects ordered by id, name, path, created_at, updated_at,
+     *                                              or last_activity_at fields (default is created_at)
+     *     @var string $sort                        Return projects sorted in asc or desc order (default is desc)
+     *     @var string $search                      return list of projects matching the search criteria
+     *     @var bool   $simple                      return only the ID, URL, name, and path of each project
+     *     @var bool   $owned                       limit by projects owned by the current user
+     *     @var bool   $membership                  limit by projects that the current user is a member of
+     *     @var bool   $starred                     limit by projects starred by the current user
+     *     @var bool   $statistics                  include project statistics
+     *     @var bool   $with_issues_enabled         limit by enabled issues feature
+     *     @var bool   $with_merge_requests_enabled limit by enabled merge requests feature
+     *     @var int    $min_access_level            Limit by current user minimal access level
+     *     @var bool   $with_custom_attributes      Include custom attributes in response (administrator only)
+     * }
+     */
+    public function usersStarredProjects(int $id, array $parameters = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+        $booleanNormalizer = function (Options $resolver, $value): string {
+            return $value ? 'true' : 'false';
+        };
+        $resolver->setDefined('archived')
+            ->setAllowedTypes('archived', 'bool')
+            ->setNormalizer('archived', $booleanNormalizer)
+        ;
+        $resolver->setDefined('visibility')
+            ->setAllowedValues('visibility', ['public', 'internal', 'private'])
+        ;
+        $resolver->setDefined('order_by')
+            ->setAllowedValues('order_by', ['id', 'name', 'path', 'created_at', 'updated_at', 'last_activity_at'])
+        ;
+        $resolver->setDefined('sort')
+            ->setAllowedValues('sort', ['asc', 'desc'])
+        ;
+        $resolver->setDefined('search');
+        $resolver->setDefined('simple')
+            ->setAllowedTypes('simple', 'bool')
+            ->setNormalizer('simple', $booleanNormalizer)
+        ;
+        $resolver->setDefined('owned')
+            ->setAllowedTypes('owned', 'bool')
+            ->setNormalizer('owned', $booleanNormalizer)
+        ;
+        $resolver->setDefined('membership')
+            ->setAllowedTypes('membership', 'bool')
+            ->setNormalizer('membership', $booleanNormalizer)
+        ;
+        $resolver->setDefined('starred')
+            ->setAllowedTypes('starred', 'bool')
+            ->setNormalizer('starred', $booleanNormalizer)
+        ;
+        $resolver->setDefined('statistics')
+            ->setAllowedTypes('statistics', 'bool')
+            ->setNormalizer('statistics', $booleanNormalizer)
+        ;
+        $resolver->setDefined('with_issues_enabled')
+            ->setAllowedTypes('with_issues_enabled', 'bool')
+            ->setNormalizer('with_issues_enabled', $booleanNormalizer)
+        ;
+        $resolver->setDefined('with_merge_requests_enabled')
+            ->setAllowedTypes('with_merge_requests_enabled', 'bool')
+            ->setNormalizer('with_merge_requests_enabled', $booleanNormalizer)
+        ;
+        $resolver->setDefined('min_access_level')
+            ->setAllowedValues('min_access_level', [null, 10, 20, 30, 40, 50])
+        ;
+        $resolver->setDefined('with_custom_attributes')
+            ->setAllowedTypes('with_custom_attributes', 'bool')
+            ->setNormalizer('with_custom_attributes', $booleanNormalizer)
+        ;
+
+        return $this->get('users/'.self::encodePath($id).'/starred_projects', $resolver->resolve($parameters));
+    }
+
+    public function user(): mixed
+    {
+        return $this->get('user');
+    }
+
+    public function create(string $email, string $password, array $params = []): mixed
+    {
+        $params['email'] = $email;
+        $params['password'] = $password;
+
+        return $this->post('users', $params);
+    }
+
+    public function update(int $id, array $params, array $files = []): mixed
+    {
+        return $this->put('users/'.self::encodePath($id), $params, [], $files);
+    }
+
+    /**
+     * @param array $params {
+     *
+     *     @var bool   $hard_delete     If true, contributions that would usually be moved to the ghost user are
+     *                                  deleted instead, as well as groups owned solely by this user.
+     * }
+     */
+    public function remove(int $id, array $params = []): mixed
+    {
+        return $this->delete('users/'.self::encodePath($id), $params);
+    }
+
+    public function block(int $id): mixed
+    {
+        return $this->post('users/'.self::encodePath($id).'/block');
+    }
+
+    public function unblock(int $id): mixed
+    {
+        return $this->post('users/'.self::encodePath($id).'/unblock');
+    }
+
+    public function activate(int $id): mixed
+    {
+        return $this->post('users/'.self::encodePath($id).'/activate');
+    }
+
+    public function deactivate(int $id): mixed
+    {
+        return $this->post('users/'.self::encodePath($id).'/deactivate');
+    }
+
+    public function me(): mixed
+    {
+        return $this->get('user');
+    }
+
+    public function keys(): mixed
+    {
+        return $this->get('user/keys');
+    }
+
+    public function key(int $id): mixed
+    {
+        return $this->get('user/keys/'.self::encodePath($id));
+    }
+
+    public function createKey(string $title, string $key): mixed
+    {
+        return $this->post('user/keys', [
+            'title' => $title,
+            'key' => $key,
+        ]);
+    }
+
+    public function removeKey(int $id): mixed
+    {
+        return $this->delete('user/keys/'.self::encodePath($id));
+    }
+
+    public function userKeys(int $user_id): mixed
+    {
+        return $this->get('users/'.self::encodePath($user_id).'/keys');
+    }
+
+    public function userKey(int $user_id, int $key_id): mixed
+    {
+        return $this->get('users/'.self::encodePath($user_id).'/keys/'.self::encodePath($key_id));
+    }
+
+    public function createKeyForUser(int $user_id, string $title, string $key): mixed
+    {
+        return $this->post('users/'.self::encodePath($user_id).'/keys', [
+            'title' => $title,
+            'key' => $key,
+        ]);
+    }
+
+    public function removeUserKey(int $user_id, int $key_id): mixed
+    {
+        return $this->delete('users/'.self::encodePath($user_id).'/keys/'.self::encodePath($key_id));
+    }
+
+    public function emails(): mixed
+    {
+        return $this->get('user/emails');
+    }
+
+    public function email(int $id): mixed
+    {
+        return $this->get('user/emails/'.self::encodePath($id));
+    }
+
+    public function userEmails(int $user_id): mixed
+    {
+        return $this->get('users/'.self::encodePath($user_id).'/emails');
+    }
+
+    public function createEmailForUser(int $user_id, string $email, bool $skip_confirmation = false): mixed
+    {
+        return $this->post('users/'.self::encodePath($user_id).'/emails', [
+            'email' => $email,
+            'skip_confirmation' => $skip_confirmation,
+        ]);
+    }
+
+    public function removeUserEmail(int $user_id, int $email_id): mixed
+    {
+        return $this->delete('users/'.self::encodePath($user_id).'/emails/'.self::encodePath($email_id));
+    }
+
+    public function userImpersonationTokens(int $user_id, array $params = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+
+        $resolver->setDefined('state')
+            ->setAllowedValues('state', ['all', 'active', 'inactive'])
+        ;
+
+        return $this->get('users/'.self::encodePath($user_id).'/impersonation_tokens', $resolver->resolve($params));
+    }
+
+    public function userImpersonationToken(int $user_id, int $impersonation_token_id): mixed
+    {
+        return $this->get('users/'.self::encodePath($user_id).'/impersonation_tokens/'.self::encodePath($impersonation_token_id));
+    }
+
+    public function createImpersonationToken(int $user_id, string $name, array $scopes, ?string $expires_at = null): mixed
+    {
+        return $this->post('users/'.self::encodePath($user_id).'/impersonation_tokens', [
+            'name' => $name,
+            'scopes' => $scopes,
+            'expires_at' => $expires_at,
+        ]);
+    }
+
+    public function removeImpersonationToken(int $user_id, int $impersonation_token_id): mixed
+    {
+        return $this->delete('users/'.self::encodePath($user_id).'/impersonation_tokens/'.self::encodePath($impersonation_token_id));
+    }
+
+    /**
+     * @param array $parameters {
+     *
+     *     @var string             $action      include only events of a particular action type
+     *     @var string             $target_type include only events of a particular target type
+     *     @var \DateTimeInterface $before      include only events created before a particular date
+     *     @var \DateTimeInterface $after       include only events created after a particular date
+     *     @var string             $sort        Sort events in asc or desc order by created_at (default is desc)
+     * }
+     */
+    public function events(int $user_id, array $parameters = []): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+        $datetimeNormalizer = function (Options $resolver, \DateTimeInterface $value): string {
+            return $value->format('Y-m-d');
+        };
+
+        $resolver->setDefined('action')
+            ->setAllowedValues('action', ['created', 'updated', 'closed', 'reopened', 'pushed', 'commented', 'merged', 'joined', 'left', 'destroyed', 'expired'])
+        ;
+        $resolver->setDefined('target_type')
+            ->setAllowedValues('target_type', ['issue', 'milestone', 'merge_request', 'note', 'project', 'snippet', 'user'])
+        ;
+        $resolver->setDefined('before')
+            ->setAllowedTypes('before', \DateTimeInterface::class)
+            ->setNormalizer('before', $datetimeNormalizer);
+        $resolver->setDefined('after')
+            ->setAllowedTypes('after', \DateTimeInterface::class)
+            ->setNormalizer('after', $datetimeNormalizer)
+        ;
+        $resolver->setDefined('sort')
+            ->setAllowedValues('sort', ['asc', 'desc'])
+        ;
+
+        return $this->get('users/'.self::encodePath($user_id).'/events', $resolver->resolve($parameters));
+    }
+
+    /**
+     * Deletes a user’s authentication identity using the provider name associated with that identity.
+     */
+    public function removeUserIdentity(int $user_id, string $provider): mixed
+    {
+        return $this->delete('users/'.self::encodePath($user_id).'/identities/'.self::encodePath($provider));
+    }
+}
diff --git a/src/Api/Version.php b/src/Api/Version.php
new file mode 100644
index 000000000..8976a320e
--- /dev/null
+++ b/src/Api/Version.php
@@ -0,0 +1,23 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Api;
+
+class Version extends AbstractApi
+{
+    public function show(): mixed
+    {
+        return $this->get('version');
+    }
+}
diff --git a/src/Api/Wiki.php b/src/Api/Wiki.php
new file mode 100644
index 000000000..fbb05551b
--- /dev/null
+++ b/src/Api/Wiki.php
@@ -0,0 +1,59 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Api;
+
+class Wiki extends AbstractApi
+{
+    /**
+     * @param array<string,mixed> $params
+     */
+    public function create(int|string $project_id, array $params): mixed
+    {
+        return $this->post($this->getProjectPath($project_id, 'wikis'), $params);
+    }
+
+    public function show(int|string $project_id, string $wiki_slug): mixed
+    {
+        return $this->get($this->getProjectPath($project_id, 'wikis/'.self::encodePath($wiki_slug)));
+    }
+
+    /**
+     * @param array<string,mixed> $params     {
+     *
+     *     @var bool $with_content Include pages' content
+     * }
+     */
+    public function showAll(int|string $project_id, array $params): mixed
+    {
+        $resolver = $this->createOptionsResolver();
+        $resolver->setDefined('with_content')
+            ->setAllowedTypes('with_content', 'bool');
+
+        return $this->get($this->getProjectPath($project_id, 'wikis'), $resolver->resolve($params));
+    }
+
+    /**
+     * @param array<string,mixed> $params
+     */
+    public function update(int|string $project_id, string $wiki_slug, array $params): mixed
+    {
+        return $this->put($this->getProjectPath($project_id, 'wikis/'.self::encodePath($wiki_slug)), $params);
+    }
+
+    public function remove(int|string $project_id, string $wiki_slug): mixed
+    {
+        return $this->delete($this->getProjectPath($project_id, 'wikis/'.self::encodePath($wiki_slug)));
+    }
+}
diff --git a/src/Client.php b/src/Client.php
new file mode 100644
index 000000000..ee4ff8a7b
--- /dev/null
+++ b/src/Client.php
@@ -0,0 +1,349 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab;
+
+use Gitlab\Api\DeployKeys;
+use Gitlab\Api\Deployments;
+use Gitlab\Api\Environments;
+use Gitlab\Api\Events;
+use Gitlab\Api\Groups;
+use Gitlab\Api\GroupsBoards;
+use Gitlab\Api\GroupsEpics;
+use Gitlab\Api\GroupsMilestones;
+use Gitlab\Api\IssueBoards;
+use Gitlab\Api\IssueLinks;
+use Gitlab\Api\Issues;
+use Gitlab\Api\IssuesStatistics;
+use Gitlab\Api\Jobs;
+use Gitlab\Api\Keys;
+use Gitlab\Api\MergeRequests;
+use Gitlab\Api\Milestones;
+use Gitlab\Api\ProjectNamespaces;
+use Gitlab\Api\Projects;
+use Gitlab\Api\Repositories;
+use Gitlab\Api\RepositoryFiles;
+use Gitlab\Api\ResourceIterationEvents;
+use Gitlab\Api\ResourceLabelEvents;
+use Gitlab\Api\ResourceMilestoneEvents;
+use Gitlab\Api\ResourceStateEvents;
+use Gitlab\Api\ResourceWeightEvents;
+use Gitlab\Api\Schedules;
+use Gitlab\Api\Search;
+use Gitlab\Api\Snippets;
+use Gitlab\Api\SystemHooks;
+use Gitlab\Api\Tags;
+use Gitlab\Api\Users;
+use Gitlab\Api\Version;
+use Gitlab\Api\Wiki;
+use Gitlab\HttpClient\Builder;
+use Gitlab\HttpClient\Plugin\Authentication;
+use Gitlab\HttpClient\Plugin\ExceptionThrower;
+use Gitlab\HttpClient\Plugin\History;
+use Http\Client\Common\HttpMethodsClientInterface;
+use Http\Client\Common\Plugin\AddHostPlugin;
+use Http\Client\Common\Plugin\HeaderDefaultsPlugin;
+use Http\Client\Common\Plugin\HistoryPlugin;
+use Http\Client\Common\Plugin\RedirectPlugin;
+use Psr\Http\Client\ClientInterface;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\StreamFactoryInterface;
+
+/**
+ * Simple API wrapper for Gitlab.
+ *
+ * @author Matt Humphrey <matt@m4tt.co>
+ */
+class Client
+{
+    /**
+     * The private token authentication method.
+     *
+     * @var string
+     */
+    public const AUTH_HTTP_TOKEN = 'http_token';
+
+    /**
+     * The job token authentication method.
+     *
+     * @var string
+     */
+    public const AUTH_HTTP_JOB_TOKEN = 'http_job_token';
+
+    /**
+     * The OAuth 2 token authentication method.
+     *
+     * @var string
+     */
+    public const AUTH_OAUTH_TOKEN = 'oauth_token';
+
+    /**
+     * The default base URL.
+     *
+     * @var string
+     */
+    private const BASE_URL = 'https://gitlab.com';
+
+    /**
+     * The default user agent header.
+     *
+     * @var string
+     */
+    private const USER_AGENT = 'gitlab-php-api-client/12.0';
+
+    private readonly Builder $httpClientBuilder;
+    private readonly History $responseHistory;
+
+    public function __construct(?Builder $httpClientBuilder = null)
+    {
+        $this->httpClientBuilder = $builder = $httpClientBuilder ?? new Builder();
+        $this->responseHistory = new History();
+
+        $builder->addPlugin(new ExceptionThrower());
+        $builder->addPlugin(new HistoryPlugin($this->responseHistory));
+        $builder->addPlugin(new HeaderDefaultsPlugin([
+            'User-Agent' => self::USER_AGENT,
+        ]));
+        $builder->addPlugin(new RedirectPlugin());
+
+        $this->setUrl(self::BASE_URL);
+    }
+
+    /**
+     * Create a Gitlab\Client using an HTTP client.
+     */
+    public static function createWithHttpClient(ClientInterface $httpClient): self
+    {
+        $builder = new Builder($httpClient);
+
+        return new self($builder);
+    }
+
+    public function deployKeys(): DeployKeys
+    {
+        return new DeployKeys($this);
+    }
+
+    public function deployments(): Deployments
+    {
+        return new Deployments($this);
+    }
+
+    public function environments(): Environments
+    {
+        return new Environments($this);
+    }
+
+    public function events(): Events
+    {
+        return new Events($this);
+    }
+
+    public function groups(): Groups
+    {
+        return new Groups($this);
+    }
+
+    public function groupsBoards(): GroupsBoards
+    {
+        return new GroupsBoards($this);
+    }
+
+    public function groupsEpics(): GroupsEpics
+    {
+        return new GroupsEpics($this);
+    }
+
+    public function groupsMilestones(): GroupsMilestones
+    {
+        return new GroupsMilestones($this);
+    }
+
+    public function issueBoards(): IssueBoards
+    {
+        return new IssueBoards($this);
+    }
+
+    public function issueLinks(): IssueLinks
+    {
+        return new IssueLinks($this);
+    }
+
+    public function issues(): Issues
+    {
+        return new Issues($this);
+    }
+
+    public function resourceIterationEvents(): ResourceIterationEvents
+    {
+        return new ResourceIterationEvents($this);
+    }
+
+    public function resourceLabelEvents(): ResourceLabelEvents
+    {
+        return new ResourceLabelEvents($this);
+    }
+
+    public function resourceMilestoneEvents(): ResourceMilestoneEvents
+    {
+        return new ResourceMilestoneEvents($this);
+    }
+
+    public function resourceStateEvents(): ResourceStateEvents
+    {
+        return new ResourceStateEvents($this);
+    }
+
+    public function resourceWeightEvents(): ResourceWeightEvents
+    {
+        return new ResourceWeightEvents($this);
+    }
+
+    public function issuesStatistics(): IssuesStatistics
+    {
+        return new IssuesStatistics($this);
+    }
+
+    public function jobs(): Jobs
+    {
+        return new Jobs($this);
+    }
+
+    public function keys(): Keys
+    {
+        return new Keys($this);
+    }
+
+    public function mergeRequests(): MergeRequests
+    {
+        return new MergeRequests($this);
+    }
+
+    public function milestones(): Milestones
+    {
+        return new Milestones($this);
+    }
+
+    public function namespaces(): ProjectNamespaces
+    {
+        return new ProjectNamespaces($this);
+    }
+
+    public function projects(): Projects
+    {
+        return new Projects($this);
+    }
+
+    public function repositories(): Repositories
+    {
+        return new Repositories($this);
+    }
+
+    public function repositoryFiles(): RepositoryFiles
+    {
+        return new RepositoryFiles($this);
+    }
+
+    public function search(): Search
+    {
+        return new Search($this);
+    }
+
+    public function schedules(): Schedules
+    {
+        return new Schedules($this);
+    }
+
+    public function snippets(): Snippets
+    {
+        return new Snippets($this);
+    }
+
+    public function systemHooks(): SystemHooks
+    {
+        return new SystemHooks($this);
+    }
+
+    public function tags(): Tags
+    {
+        return new Tags($this);
+    }
+
+    public function users(): Users
+    {
+        return new Users($this);
+    }
+
+    public function version(): Version
+    {
+        return new Version($this);
+    }
+
+    public function wiki(): Wiki
+    {
+        return new Wiki($this);
+    }
+
+    /**
+     * Authenticate a user for all next requests.
+     *
+     * @param string      $token      Gitlab private token
+     * @param string      $authMethod One of the AUTH_* class constants
+     */
+    public function authenticate(string $token, string $authMethod, ?string $sudo = null): void
+    {
+        $this->getHttpClientBuilder()->removePlugin(Authentication::class);
+        $this->getHttpClientBuilder()->addPlugin(new Authentication($authMethod, $token, $sudo));
+    }
+
+    public function setUrl(string $url): void
+    {
+        $uri = $this->getHttpClientBuilder()->getUriFactory()->createUri($url);
+
+        $this->getHttpClientBuilder()->removePlugin(AddHostPlugin::class);
+        $this->getHttpClientBuilder()->addPlugin(new AddHostPlugin($uri));
+    }
+
+    /**
+     * Get the last response.
+     */
+    public function getLastResponse(): ?ResponseInterface
+    {
+        return $this->responseHistory->getLastResponse();
+    }
+
+    /**
+     * Get the HTTP client.
+     */
+    public function getHttpClient(): HttpMethodsClientInterface
+    {
+        return $this->getHttpClientBuilder()->getHttpClient();
+    }
+
+    /**
+     * Get the stream factory.
+     */
+    public function getStreamFactory(): StreamFactoryInterface
+    {
+        return $this->getHttpClientBuilder()->getStreamFactory();
+    }
+
+    /**
+     * Get the HTTP client builder.
+     */
+    protected function getHttpClientBuilder(): Builder
+    {
+        return $this->httpClientBuilder;
+    }
+}
diff --git a/src/Exception/ApiLimitExceededException.php b/src/Exception/ApiLimitExceededException.php
new file mode 100644
index 000000000..46cd23b27
--- /dev/null
+++ b/src/Exception/ApiLimitExceededException.php
@@ -0,0 +1,22 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Exception;
+
+/**
+ * @author Graham Campbell <hello@gjcampbell.co.uk>
+ */
+class ApiLimitExceededException extends RuntimeException
+{
+}
diff --git a/src/Exception/ErrorException.php b/src/Exception/ErrorException.php
new file mode 100644
index 000000000..156bb1afd
--- /dev/null
+++ b/src/Exception/ErrorException.php
@@ -0,0 +1,22 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Exception;
+
+/**
+ * @author Graham Campbell <hello@gjcampbell.co.uk>
+ */
+class ErrorException extends \ErrorException implements ExceptionInterface
+{
+}
diff --git a/src/Exception/ExceptionInterface.php b/src/Exception/ExceptionInterface.php
new file mode 100644
index 000000000..5779ed0cf
--- /dev/null
+++ b/src/Exception/ExceptionInterface.php
@@ -0,0 +1,24 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Exception;
+
+use Http\Client\Exception;
+
+/**
+ * @author Graham Campbell <hello@gjcampbell.co.uk>
+ */
+interface ExceptionInterface extends Exception
+{
+}
diff --git a/src/Exception/RuntimeException.php b/src/Exception/RuntimeException.php
new file mode 100644
index 000000000..fe3f876c2
--- /dev/null
+++ b/src/Exception/RuntimeException.php
@@ -0,0 +1,22 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Exception;
+
+/**
+ * @author Graham Campbell <hello@gjcampbell.co.uk>
+ */
+class RuntimeException extends \RuntimeException implements ExceptionInterface
+{
+}
diff --git a/src/Exception/ValidationFailedException.php b/src/Exception/ValidationFailedException.php
new file mode 100644
index 000000000..2c9aa27b2
--- /dev/null
+++ b/src/Exception/ValidationFailedException.php
@@ -0,0 +1,22 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Exception;
+
+/**
+ * @author Graham Campbell <hello@gjcampbell.co.uk>
+ */
+class ValidationFailedException extends ErrorException
+{
+}
diff --git a/src/HttpClient/Builder.php b/src/HttpClient/Builder.php
new file mode 100644
index 000000000..e94248f50
--- /dev/null
+++ b/src/HttpClient/Builder.php
@@ -0,0 +1,156 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\HttpClient;
+
+use Http\Client\Common\HttpMethodsClient;
+use Http\Client\Common\HttpMethodsClientInterface;
+use Http\Client\Common\Plugin;
+use Http\Client\Common\Plugin\Cache\Generator\HeaderCacheKeyGenerator;
+use Http\Client\Common\Plugin\CachePlugin;
+use Http\Client\Common\PluginClientFactory;
+use Http\Discovery\Psr17FactoryDiscovery;
+use Http\Discovery\Psr18ClientDiscovery;
+use Psr\Cache\CacheItemPoolInterface;
+use Psr\Http\Client\ClientInterface;
+use Psr\Http\Message\RequestFactoryInterface;
+use Psr\Http\Message\StreamFactoryInterface;
+use Psr\Http\Message\UriFactoryInterface;
+
+/**
+ * The HTTP client builder class.
+ *
+ * This will allow you to fluently add and remove plugins.
+ *
+ * @author Tobias Nyholm <tobias.nyholm@gmail.com>
+ * @author Graham Campbell <hello@gjcampbell.co.uk>
+ */
+final class Builder
+{
+    private readonly ClientInterface $httpClient;
+    private readonly RequestFactoryInterface $requestFactory;
+    private readonly StreamFactoryInterface $streamFactory;
+    private readonly UriFactoryInterface $uriFactory;
+
+    /**
+     * @var Plugin[]
+     */
+    private array $plugins = [];
+
+    private ?CachePlugin $cachePlugin;
+
+    private ?HttpMethodsClientInterface $pluginClient;
+
+    public function __construct(
+        ?ClientInterface $httpClient = null,
+        ?RequestFactoryInterface $requestFactory = null,
+        ?StreamFactoryInterface $streamFactory = null,
+        ?UriFactoryInterface $uriFactory = null
+    ) {
+        $this->httpClient = $httpClient ?? Psr18ClientDiscovery::find();
+        $this->requestFactory = $requestFactory ?? Psr17FactoryDiscovery::findRequestFactory();
+        $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory();
+        $this->uriFactory = $uriFactory ?? Psr17FactoryDiscovery::findUriFactory();
+
+        $this->plugins = [];
+        $this->cachePlugin = null;
+        $this->pluginClient = null;
+    }
+
+    public function getHttpClient(): HttpMethodsClientInterface
+    {
+        if (null === $this->pluginClient) {
+            $plugins = $this->plugins;
+            if (null !== $this->cachePlugin) {
+                $plugins[] = $this->cachePlugin;
+            }
+
+            $this->pluginClient = new HttpMethodsClient(
+                (new PluginClientFactory())->createClient($this->httpClient, $plugins),
+                $this->requestFactory,
+                $this->streamFactory
+            );
+        }
+
+        return $this->pluginClient;
+    }
+
+    /**
+     * Get the request factory.
+     */
+    public function getRequestFactory(): RequestFactoryInterface
+    {
+        return $this->requestFactory;
+    }
+
+    /**
+     * Get the stream factory.
+     */
+    public function getStreamFactory(): StreamFactoryInterface
+    {
+        return $this->streamFactory;
+    }
+
+    /**
+     * Get the URI factory.
+     */
+    public function getUriFactory(): UriFactoryInterface
+    {
+        return $this->uriFactory;
+    }
+
+    /**
+     * Add a new plugin to the end of the plugin chain.
+     */
+    public function addPlugin(Plugin $plugin): void
+    {
+        $this->plugins[] = $plugin;
+        $this->pluginClient = null;
+    }
+
+    /**
+     * Remove a plugin by its fully qualified class name (FQCN).
+     */
+    public function removePlugin(string $fqcn): void
+    {
+        foreach ($this->plugins as $idx => $plugin) {
+            if ($plugin instanceof $fqcn) {
+                unset($this->plugins[$idx]);
+                $this->pluginClient = null;
+            }
+        }
+    }
+
+    /**
+     * Add a cache plugin to cache responses locally.
+     */
+    public function addCache(CacheItemPoolInterface $cachePool, array $config = []): void
+    {
+        if (!isset($config['cache_key_generator'])) {
+            $config['cache_key_generator'] = new HeaderCacheKeyGenerator(['Authorization', 'Cookie', 'Accept', 'Content-type']);
+        }
+
+        $this->cachePlugin = CachePlugin::clientCache($cachePool, $this->streamFactory, $config);
+        $this->pluginClient = null;
+    }
+
+    /**
+     * Remove the cache plugin.
+     */
+    public function removeCache(): void
+    {
+        $this->cachePlugin = null;
+        $this->pluginClient = null;
+    }
+}
diff --git a/src/HttpClient/Message/ResponseMediator.php b/src/HttpClient/Message/ResponseMediator.php
new file mode 100644
index 000000000..ef66fae67
--- /dev/null
+++ b/src/HttpClient/Message/ResponseMediator.php
@@ -0,0 +1,170 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\HttpClient\Message;
+
+use Gitlab\Exception\RuntimeException;
+use Gitlab\HttpClient\Util\JsonArray;
+use Psr\Http\Message\ResponseInterface;
+
+/**
+ * Utilities to parse response headers and content.
+ */
+final class ResponseMediator
+{
+    /**
+     * The content type header.
+     *
+     * @var string
+     */
+    public const CONTENT_TYPE_HEADER = 'Content-Type';
+
+    /**
+     * The JSON content type identifier.
+     *
+     * @var string
+     */
+    public const JSON_CONTENT_TYPE = 'application/json';
+
+    /**
+     * The octet stream content type identifier.
+     *
+     * @var string
+     */
+    public const STREAM_CONTENT_TYPE = 'application/octet-stream';
+
+    /**
+     * The multipart form data content type identifier.
+     *
+     * @var string
+     */
+    public const MULTIPART_CONTENT_TYPE = 'multipart/form-data';
+
+    /**
+     * Return the response body as a string or JSON array if content type is JSON.
+     */
+    public static function getContent(ResponseInterface $response): array|string
+    {
+        $body = (string) $response->getBody();
+
+        if (!\in_array($body, ['', 'null', 'true', 'false'], true) && 0 === \strpos($response->getHeaderLine(self::CONTENT_TYPE_HEADER), self::JSON_CONTENT_TYPE)) {
+            return JsonArray::decode($body);
+        }
+
+        return $body;
+    }
+
+    /**
+     * Extract pagination URIs from Link header.
+     *
+     * @return array<string,string>
+     */
+    public static function getPagination(ResponseInterface $response): array
+    {
+        $header = self::getHeader($response, 'Link');
+
+        if (null === $header) {
+            return [];
+        }
+
+        $pagination = [];
+        foreach (\explode(',', $header) as $link) {
+            \preg_match('/<(.*)>; rel="(.*)"/i', \trim($link, ','), $match);
+
+            /** @var string[] $match */
+            if (3 === \count($match)) {
+                $pagination[$match[2]] = $match[1];
+            }
+        }
+
+        return $pagination;
+    }
+
+    /**
+     * Get the value for a single header.
+     */
+    private static function getHeader(ResponseInterface $response, string $name): ?string
+    {
+        $headers = $response->getHeader($name);
+
+        return \array_shift($headers);
+    }
+
+    /**
+     * Get the error message from the response if present.
+     */
+    public static function getErrorMessage(ResponseInterface $response): ?string
+    {
+        try {
+            $content = self::getContent($response);
+        } catch (RuntimeException $e) {
+            return null;
+        }
+
+        if (!\is_array($content)) {
+            return null;
+        }
+
+        if (isset($content['message'])) {
+            $message = $content['message'];
+
+            if (\is_string($message)) {
+                return $message;
+            }
+
+            if (\is_array($message)) {
+                return self::getMessageAsString($content['message']);
+            }
+        }
+
+        if (isset($content['error_description'])) {
+            $error = $content['error_description'];
+
+            if (\is_string($error)) {
+                return $error;
+            }
+        }
+
+        if (isset($content['error'])) {
+            $error = $content['error'];
+
+            if (\is_string($error)) {
+                return $error;
+            }
+        }
+
+        return null;
+    }
+
+    private static function getMessageAsString(array $message): string
+    {
+        $format = '"%s" %s';
+        $errors = [];
+
+        foreach ($message as $field => $messages) {
+            if (\is_array($messages)) {
+                $messages = \array_unique($messages);
+                foreach ($messages as $error) {
+                    $errors[] = \sprintf($format, $field, $error);
+                }
+            } elseif (\is_int($field)) {
+                $errors[] = $messages;
+            } else {
+                $errors[] = \sprintf($format, $field, $messages);
+            }
+        }
+
+        return \implode(', ', $errors);
+    }
+}
diff --git a/src/HttpClient/Plugin/Authentication.php b/src/HttpClient/Plugin/Authentication.php
new file mode 100644
index 000000000..148a523f9
--- /dev/null
+++ b/src/HttpClient/Plugin/Authentication.php
@@ -0,0 +1,92 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\HttpClient\Plugin;
+
+use Gitlab\Client;
+use Gitlab\Exception\RuntimeException;
+use Http\Client\Common\Plugin;
+use Http\Promise\Promise;
+use Psr\Http\Message\RequestInterface;
+use Psr\Http\Message\ResponseInterface;
+
+/**
+ * Add authentication to the request.
+ *
+ * @author Tobias Nyholm <tobias.nyholm@gmail.com>
+ * @author Fabien Bourigault <bourigaultfabien@gmail.com>
+ *
+ * @internal
+ */
+final class Authentication implements Plugin
+{
+    /**
+     * @var array<string,string>
+     */
+    private readonly array $headers;
+
+    public function __construct(string $method, string $token, ?string $sudo = null)
+    {
+        $this->headers = self::buildHeaders($method, $token, $sudo);
+    }
+
+    /**
+     * Handle the request and return the response coming from the next callable.
+     *
+     * @return Promise<ResponseInterface>
+     */
+    public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise
+    {
+        foreach ($this->headers as $header => $value) {
+            $request = $request->withHeader($header, $value);
+        }
+
+        return $next($request);
+    }
+
+    /**
+     * Build the headers to be attached to the request.
+     *
+     * @throws RuntimeException
+     *
+     * @return array<string,string>
+     */
+    private static function buildHeaders(string $method, string $token, ?string $sudo = null): array
+    {
+        $headers = [];
+
+        switch ($method) {
+            case Client::AUTH_HTTP_TOKEN:
+                $headers['PRIVATE-TOKEN'] = $token;
+
+                break;
+            case Client::AUTH_HTTP_JOB_TOKEN:
+                $headers['JOB-TOKEN'] = $token;
+
+                break;
+            case Client::AUTH_OAUTH_TOKEN:
+                $headers['Authorization'] = \sprintf('Bearer %s', $token);
+
+                break;
+            default:
+                throw new RuntimeException(\sprintf('Authentication method "%s" not implemented.', $method));
+        }
+
+        if (null !== $sudo) {
+            $headers['SUDO'] = $sudo;
+        }
+
+        return $headers;
+    }
+}
diff --git a/src/HttpClient/Plugin/ExceptionThrower.php b/src/HttpClient/Plugin/ExceptionThrower.php
new file mode 100644
index 000000000..d8d76caa7
--- /dev/null
+++ b/src/HttpClient/Plugin/ExceptionThrower.php
@@ -0,0 +1,73 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\HttpClient\Plugin;
+
+use Gitlab\Exception\ApiLimitExceededException;
+use Gitlab\Exception\ErrorException;
+use Gitlab\Exception\ExceptionInterface;
+use Gitlab\Exception\RuntimeException;
+use Gitlab\Exception\ValidationFailedException;
+use Gitlab\HttpClient\Message\ResponseMediator;
+use Http\Client\Common\Plugin;
+use Http\Promise\Promise;
+use Psr\Http\Message\RequestInterface;
+use Psr\Http\Message\ResponseInterface;
+
+/**
+ * A plugin to remember the last response.
+ *
+ * @author Tobias Nyholm <tobias.nyholm@gmail.com>
+ * @author Fabien Bourigault <bourigaultfabien@gmail.com>
+ *
+ * @internal
+ */
+final class ExceptionThrower implements Plugin
+{
+    /**
+     * Handle the request and return the response coming from the next callable.
+     *
+     * @return Promise<ResponseInterface>
+     */
+    public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise
+    {
+        return $next($request)->then(function (ResponseInterface $response): ResponseInterface {
+            $status = $response->getStatusCode();
+
+            if ($status >= 400 && $status < 600) {
+                throw self::createException($status, ResponseMediator::getErrorMessage($response) ?? $response->getReasonPhrase());
+            }
+
+            return $response;
+        });
+    }
+
+    /**
+     * Create an exception from a status code and error message.
+     *
+     * @return ErrorException|RuntimeException
+     */
+    private static function createException(int $status, string $message): ExceptionInterface
+    {
+        if (400 === $status || 422 === $status) {
+            return new ValidationFailedException($message, $status);
+        }
+
+        if (429 === $status) {
+            return new ApiLimitExceededException($message, $status);
+        }
+
+        return new RuntimeException($message, $status);
+    }
+}
diff --git a/src/HttpClient/Plugin/History.php b/src/HttpClient/Plugin/History.php
new file mode 100644
index 000000000..5e41ca93d
--- /dev/null
+++ b/src/HttpClient/Plugin/History.php
@@ -0,0 +1,60 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\HttpClient\Plugin;
+
+use Http\Client\Common\Plugin\Journal;
+use Psr\Http\Client\ClientExceptionInterface;
+use Psr\Http\Message\RequestInterface;
+use Psr\Http\Message\ResponseInterface;
+
+/**
+ * A plugin to remember the last response.
+ *
+ * @author Tobias Nyholm <tobias.nyholm@gmail.com>
+ *
+ * @internal
+ */
+final class History implements Journal
+{
+    private ?ResponseInterface $lastResponse;
+
+    public function __construct()
+    {
+        $this->lastResponse = null;
+    }
+
+    /**
+     * Get the last response.
+     */
+    public function getLastResponse(): ?ResponseInterface
+    {
+        return $this->lastResponse;
+    }
+
+    /**
+     * Record a successful call.
+     */
+    public function addSuccess(RequestInterface $request, ResponseInterface $response): void
+    {
+        $this->lastResponse = $response;
+    }
+
+    /**
+     * Record a failed call.
+     */
+    public function addFailure(RequestInterface $request, ClientExceptionInterface $exception): void
+    {
+    }
+}
diff --git a/src/HttpClient/Util/JsonArray.php b/src/HttpClient/Util/JsonArray.php
new file mode 100644
index 000000000..59a9c49f1
--- /dev/null
+++ b/src/HttpClient/Util/JsonArray.php
@@ -0,0 +1,61 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\HttpClient\Util;
+
+use Gitlab\Exception\RuntimeException;
+
+/**
+ * @internal
+ */
+final class JsonArray
+{
+    /**
+     * Decode a JSON string into a PHP array.
+     *
+     * @throws RuntimeException
+     */
+    public static function decode(string $json): array
+    {
+        /** @var scalar|array|null */
+        $data = \json_decode($json, true);
+
+        if (\JSON_ERROR_NONE !== \json_last_error()) {
+            throw new RuntimeException(\sprintf('json_decode error: %s', \json_last_error_msg()));
+        }
+
+        if (!\is_array($data)) {
+            throw new RuntimeException(\sprintf('json_decode error: Expected JSON of type array, %s given.', \get_debug_type($data)));
+        }
+
+        return $data;
+    }
+
+    /**
+     * Encode a PHP array into a JSON string.
+     *
+     * @throws RuntimeException
+     */
+    public static function encode(array $value): string
+    {
+        $json = \json_encode($value);
+
+        if (\JSON_ERROR_NONE !== \json_last_error()) {
+            throw new RuntimeException(\sprintf('json_encode error: %s', \json_last_error_msg()));
+        }
+
+        /** @var string */
+        return $json;
+    }
+}
diff --git a/src/HttpClient/Util/QueryStringBuilder.php b/src/HttpClient/Util/QueryStringBuilder.php
new file mode 100644
index 000000000..462f5ff47
--- /dev/null
+++ b/src/HttpClient/Util/QueryStringBuilder.php
@@ -0,0 +1,76 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\HttpClient\Util;
+
+/**
+ * @internal
+ */
+final class QueryStringBuilder
+{
+    /**
+     * Encode a query as a query string according to RFC 3986.
+     *
+     * Indexed arrays are encoded using empty squared brackets ([]) unlike
+     * `http_build_query`.
+     */
+    public static function build(array $query): string
+    {
+        return \sprintf('?%s', \implode('&', \array_map(function ($value, $key): string {
+            return self::encode($value, $key);
+        }, $query, \array_keys($query))));
+    }
+
+    /**
+     * Encode a value.
+     */
+    private static function encode(mixed $query, int|string $prefix): string
+    {
+        if (!\is_array($query)) {
+            return self::rawurlencode($prefix).'='.self::rawurlencode($query);
+        }
+
+        $isList = self::isList($query);
+
+        return \implode('&', \array_map(function ($value, $key) use ($prefix, $isList): string {
+            $prefix = $isList ? $prefix.'[]' : $prefix.'['.$key.']';
+
+            return self::encode($value, $prefix);
+        }, $query, \array_keys($query)));
+    }
+
+    /**
+     * Tell if the given array is a list.
+     */
+    private static function isList(array $query): bool
+    {
+        if (0 === \count($query) || !isset($query[0])) {
+            return false;
+        }
+
+        return \array_keys($query) === \range(0, \count($query) - 1);
+    }
+
+    /**
+     * Encode a value like rawurlencode, but return "0" when false is given.
+     */
+    private static function rawurlencode(mixed $value): string
+    {
+        if (false === $value) {
+            return '0';
+        }
+
+        return \rawurlencode((string) $value);
+    }
+}
diff --git a/src/ResultPager.php b/src/ResultPager.php
new file mode 100644
index 000000000..d2ab7ba2e
--- /dev/null
+++ b/src/ResultPager.php
@@ -0,0 +1,211 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab;
+
+use Closure;
+use Generator;
+use Gitlab\Api\AbstractApi;
+use Gitlab\Exception\RuntimeException;
+use Gitlab\HttpClient\Message\ResponseMediator;
+use ValueError;
+
+/**
+ * This is the result pager class.
+ *
+ * @author Ramon de la Fuente <ramon@future500.nl>
+ * @author Mitchel Verschoof <mitchel@future500.nl>
+ * @author Graham Campbell <hello@gjcampbell.co.uk>
+ */
+final class ResultPager implements ResultPagerInterface
+{
+    /**
+     * The default number of entries to request per page.
+     *
+     * @var int
+     */
+    private const PER_PAGE = 50;
+
+    private readonly Client $client;
+
+    private readonly int $perPage;
+
+    /**
+     * @var array<string,string>
+     */
+    private array $pagination;
+
+    public function __construct(Client $client, ?int $perPage = null)
+    {
+        if (null !== $perPage && ($perPage < 1 || $perPage > 100)) {
+            throw new ValueError(\sprintf('%s::__construct(): Argument #2 ($perPage) must be between 1 and 100, or null', self::class));
+        }
+
+        $this->client = $client;
+        $this->perPage = $perPage ?? self::PER_PAGE;
+        $this->pagination = [];
+    }
+
+    /**
+     * Fetch a single result from an api call.
+     *
+     * @throws \Http\Client\Exception
+     */
+    public function fetch(AbstractApi $api, string $method, array $parameters = []): array
+    {
+        $result = self::bindPerPage($api, $this->perPage)->$method(...$parameters);
+
+        if (!\is_array($result)) {
+            throw new RuntimeException('Pagination of this endpoint is not supported.');
+        }
+
+        $this->postFetch();
+
+        return $result;
+    }
+
+    /**
+     * Fetch all results from an api call.
+     *
+     * @throws \Http\Client\Exception
+     */
+    public function fetchAll(AbstractApi $api, string $method, array $parameters = []): array
+    {
+        return \iterator_to_array($this->fetchAllLazy($api, $method, $parameters));
+    }
+
+    /**
+     * Lazily fetch all results from an api call.
+     *
+     * @throws \Http\Client\Exception
+     */
+    public function fetchAllLazy(AbstractApi $api, string $method, array $parameters = []): Generator
+    {
+        /** @var mixed $value */
+        foreach ($this->fetch($api, $method, $parameters) as $value) {
+            yield $value;
+        }
+
+        while ($this->hasNext()) {
+            /** @var mixed $value */
+            foreach ($this->fetchNext() as $value) {
+                yield $value;
+            }
+        }
+    }
+
+    /**
+     * Check to determine the availability of a next page.
+     */
+    public function hasNext(): bool
+    {
+        return isset($this->pagination['next']);
+    }
+
+    /**
+     * Fetch the next page.
+     *
+     * @throws \Http\Client\Exception
+     */
+    public function fetchNext(): array
+    {
+        return $this->get('next');
+    }
+
+    /**
+     * Check to determine the availability of a previous page.
+     */
+    public function hasPrevious(): bool
+    {
+        return isset($this->pagination['prev']);
+    }
+
+    /**
+     * Fetch the previous page.
+     *
+     * @throws \Http\Client\Exception
+     */
+    public function fetchPrevious(): array
+    {
+        return $this->get('prev');
+    }
+
+    /**
+     * Fetch the first page.
+     *
+     * @throws \Http\Client\Exception
+     */
+    public function fetchFirst(): array
+    {
+        return $this->get('first');
+    }
+
+    /**
+     * Fetch the last page.
+     *
+     * @throws \Http\Client\Exception
+     */
+    public function fetchLast(): array
+    {
+        return $this->get('last');
+    }
+
+    /**
+     * Refresh the pagination property.
+     */
+    private function postFetch(): void
+    {
+        $response = $this->client->getLastResponse();
+
+        $this->pagination = null === $response ? [] : ResponseMediator::getPagination($response);
+    }
+
+    /**
+     * @throws \Http\Client\Exception
+     */
+    private function get(string $key): array
+    {
+        $pagination = $this->pagination[$key] ?? null;
+
+        if (null === $pagination) {
+            return [];
+        }
+
+        $result = $this->client->getHttpClient()->get($pagination);
+
+        $content = ResponseMediator::getContent($result);
+
+        if (!\is_array($content)) {
+            throw new RuntimeException('Pagination of this endpoint is not supported.');
+        }
+
+        $this->postFetch();
+
+        return $content;
+    }
+
+    private static function bindPerPage(AbstractApi $api, int $perPage): AbstractApi
+    {
+        /** @var Closure(AbstractApi): AbstractApi */
+        $closure = Closure::bind(static function (AbstractApi $api) use ($perPage): AbstractApi {
+            $clone = clone $api;
+
+            $clone->perPage = $perPage;
+
+            return $clone;
+        }, null, AbstractApi::class);
+
+        return $closure($api);
+    }
+}
diff --git a/src/ResultPagerInterface.php b/src/ResultPagerInterface.php
new file mode 100644
index 000000000..445455e05
--- /dev/null
+++ b/src/ResultPagerInterface.php
@@ -0,0 +1,87 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab;
+
+use Generator;
+use Gitlab\Api\AbstractApi;
+
+/**
+ * This is the result pager interface.
+ *
+ * @author Ramon de la Fuente <ramon@future500.nl>
+ * @author Mitchel Verschoof <mitchel@future500.nl>
+ * @author Graham Campbell <hello@gjcampbell.co.uk>
+ */
+interface ResultPagerInterface
+{
+    /**
+     * Fetch a single result from an api call.
+     *
+     * @throws \Http\Client\Exception
+     */
+    public function fetch(AbstractApi $api, string $method, array $parameters = []): array;
+
+    /**
+     * Fetch all results from an api call.
+     *
+     * @throws \Http\Client\Exception
+     */
+    public function fetchAll(AbstractApi $api, string $method, array $parameters = []): array;
+
+    /**
+     * Lazily fetch all results from an api call.
+     *
+     * @throws \Http\Client\Exception
+     */
+    public function fetchAllLazy(AbstractApi $api, string $method, array $parameters = []): Generator;
+
+    /**
+     * Check to determine the availability of a next page.
+     */
+    public function hasNext(): bool;
+
+    /**
+     * Fetch the next page.
+     *
+     * @throws \Http\Client\Exception
+     */
+    public function fetchNext(): array;
+
+    /**
+     * Check to determine the availability of a previous page.
+     */
+    public function hasPrevious(): bool;
+
+    /**
+     * Fetch the previous page.
+     *
+     * @throws \Http\Client\Exception
+     */
+    public function fetchPrevious(): array;
+
+    /**
+     * Fetch the first page.
+     *
+     * @throws \Http\Client\Exception
+     */
+    public function fetchFirst(): array;
+
+    /**
+     * Fetch the last page.
+     *
+     * @throws \Http\Client\Exception
+     */
+    public function fetchLast(): array;
+}
diff --git a/test/Gitlab/Tests/Api/AbstractApiTest.php b/test/Gitlab/Tests/Api/AbstractApiTest.php
deleted file mode 100644
index aa1eeaea2..000000000
--- a/test/Gitlab/Tests/Api/AbstractApiTest.php
+++ /dev/null
@@ -1,177 +0,0 @@
-<?php namespace Gitlab\Tests\Api;
-
-use Gitlab\Api\AbstractApi;
-use Gitlab\Client;
-use Gitlab\HttpClient\Message\Response;
-
-class AbstractApiTest extends TestCase
-{
-    /**
-     * @test
-     */
-    public function shouldPassGETRequestToClient()
-    {
-        $response = $this->getResponse('value');
-
-        $httpClient = $this->getHttpMock();
-        $httpClient
-            ->expects($this->any())
-            ->method('get')
-            ->with('/path', array('param1' => 'param1value'), array('header1' => 'header1value'))
-            ->will($this->returnValue($response));
-
-        $client = $this->getClientMock();
-        $client->setHttpClient($httpClient);
-
-        $api = $this->getAbstractApiObject($client);
-        $this->assertEquals('value', $api->get('/path', array('param1' => 'param1value'), array('header1' => 'header1value')));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldPassPOSTRequestToClient()
-    {
-        $response = $this->getResponse('value');
-
-        $httpClient = $this->getHttpMock();
-        $httpClient
-            ->expects($this->any())
-            ->method('post')
-            ->with('/path', array('param1' => 'param1value'), array('header1' => 'header1value'))
-            ->will($this->returnValue($response));
-
-        $client = $this->getClientMock();
-        $client->setHttpClient($httpClient);
-
-        $api = $this->getAbstractApiObject($client);
-        $this->assertEquals('value', $api->post('/path', array('param1' => 'param1value'), array('header1' => 'header1value')));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldPassPUTRequestToClient()
-    {
-        $response = $this->getResponse('value');
-
-        $httpClient = $this->getHttpMock();
-        $httpClient
-            ->expects($this->any())
-            ->method('put')
-            ->with('/path', array('param1' => 'param1value'), array('header1' => 'header1value'))
-            ->will($this->returnValue($response));
-
-        $client = $this->getClientMock();
-        $client->setHttpClient($httpClient);
-
-        $api = $this->getAbstractApiObject($client);
-        $this->assertEquals('value', $api->put('/path', array('param1' => 'param1value'), array('header1' => 'header1value')));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldPassDELETERequestToClient()
-    {
-        $response = $this->getResponse('value');
-
-        $httpClient = $this->getHttpMock();
-        $httpClient
-            ->expects($this->any())
-            ->method('delete')
-            ->with('/path', array('param1' => 'param1value'), array('header1' => 'header1value'))
-            ->will($this->returnValue($response));
-
-        $client = $this->getClientMock();
-        $client->setHttpClient($httpClient);
-
-        $api = $this->getAbstractApiObject($client);
-        $this->assertEquals('value', $api->delete('/path', array('param1' => 'param1value'), array('header1' => 'header1value')));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldPassPATCHRequestToClient()
-    {
-        $response = $this->getResponse('value');
-
-        $httpClient = $this->getHttpMock();
-        $httpClient
-            ->expects($this->any())
-            ->method('patch')
-            ->with('/path', array('param1' => 'param1value'), array('header1' => 'header1value'))
-            ->will($this->returnValue($response));
-
-        $client = $this->getClientMock();
-        $client->setHttpClient($httpClient);
-
-        $api = $this->getAbstractApiObject($client);
-        $this->assertEquals('value', $api->patch('/path', array('param1' => 'param1value'), array('header1' => 'header1value')));
-    }
-
-    /**
-     * @param mixed $value
-     * @return Response
-     */
-    protected function getResponse($value)
-    {
-        $response = new Response();
-        $response->setContent($value);
-
-        return $response;
-    }
-
-    /**
-     * @param Client $client
-     * @return AbstractApiTestInstance
-     */
-    protected function getAbstractApiObject(Client $client)
-    {
-        return new AbstractApiTestInstance($client);
-    }
-}
-
-class AbstractApiTestInstance extends AbstractApi
-{
-    /**
-     * {@inheritDoc}
-     */
-    public function get($path, array $parameters = array(), $requestHeaders = array())
-    {
-        return parent::get($path, $parameters, $requestHeaders);
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function post($path, array $parameters = array(), $requestHeaders = array(), array $files = array())
-    {
-        return parent::post($path, $parameters, $requestHeaders, $files);
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function patch($path, array $parameters = array(), $requestHeaders = array())
-    {
-        return parent::patch($path, $parameters, $requestHeaders);
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function put($path, array $parameters = array(), $requestHeaders = array())
-    {
-        return parent::put($path, $parameters, $requestHeaders);
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function delete($path, array $parameters = array(), $requestHeaders = array())
-    {
-        return parent::delete($path, $parameters, $requestHeaders);
-    }
-}
diff --git a/test/Gitlab/Tests/Api/ApiTestCase.php b/test/Gitlab/Tests/Api/ApiTestCase.php
deleted file mode 100644
index a695d97cb..000000000
--- a/test/Gitlab/Tests/Api/ApiTestCase.php
+++ /dev/null
@@ -1,23 +0,0 @@
-<?php namespace Gitlab\Tests\Api;
-
-abstract class ApiTestCase extends TestCase
-{
-    abstract protected function getApiClass();
-
-    /**
-     * @param array $methods
-     * @return \PHPUnit_Framework_MockObject_MockObject|mixed
-     */
-    protected function getApiMock($methods = array())
-    {
-        $client = $this->getClientMock();
-
-        $methods = array_merge(array('get', 'post', 'postRaw', 'patch', 'delete', 'put', 'head'), $methods);
-
-        return $this->getMockBuilder($this->getApiClass())
-            ->setMethods($methods)
-            ->setConstructorArgs(array($client))
-            ->getMock()
-        ;
-    }
-}
diff --git a/test/Gitlab/Tests/Api/DeployKeysTest.php b/test/Gitlab/Tests/Api/DeployKeysTest.php
deleted file mode 100644
index a17dd312c..000000000
--- a/test/Gitlab/Tests/Api/DeployKeysTest.php
+++ /dev/null
@@ -1,51 +0,0 @@
-<?php namespace Gitlab\Tests\Api;
-
-class DeployKeysTest extends ApiTestCase
-{
-    /**
-     * @test
-     */
-    public function shouldGetAllDeployKeys()
-    {
-        $expectedArray = $this->getMultipleDeployKeysData();
-
-        $api = $this->getMultipleDeployKeysRequestMock('deploy_keys', $expectedArray);
-
-        $this->assertEquals($expectedArray, $api->all());
-    }
-
-    protected function getMultipleDeployKeysRequestMock($path, $expectedArray = array(), $page = 1, $per_page = 20, $order_by = 'id', $sort = 'asc')
-    {
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with($path, array('page' => $page, 'per_page' => $per_page, 'order_by' => $order_by, 'sort' => $sort))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        return $api;
-    }
-
-    protected function getMultipleDeployKeysData()
-    {
-        return array(
-            array(
-                'id' => 1,
-                'title' => 'Public key',
-                'key' => 'ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIEAiPWx6WM4lhHNedGfBpPJNPpZ7yKu+dnn1SJejgt4596k6YjzGGphH2TUxwKzxcKDKKezwkpfnxPkSMkuEspGRt/aZZ9wa++Oi7Qkr8prgHc4soW6NUlfDzpvZK2H5E7eQaSeP3SAwGmQKUFHCddNaP0L+hM7zhFNzjFvpaMgJw0=',
-                'created_at' => '2013-10-02T10:12:29Z'
-            ),
-            array(
-                'id' => 3,
-                'title' => 'Another Public key',
-                'key' => 'ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIEAiPWx6WM4lhHNedGfBpPJNPpZ7yKu+dnn1SJejgt4596k6YjzGGphH2TUxwKzxcKDKKezwkpfnxPkSMkuEspGRt/aZZ9wa++Oi7Qkr8prgHc4soW6NUlfDzpvZK2H5E7eQaSeP3SAwGmQKUFHCddNaP0L+hM7zhFNzjFvpaMgJw0=',
-                'created_at' => '2013-10-02T11:12:29Z'
-            )
-        );
-    }
-
-    protected function getApiClass()
-    {
-        return 'Gitlab\Api\DeployKeys';
-    }
-}
diff --git a/test/Gitlab/Tests/Api/GroupsTest.php b/test/Gitlab/Tests/Api/GroupsTest.php
deleted file mode 100644
index 20fd1fd58..000000000
--- a/test/Gitlab/Tests/Api/GroupsTest.php
+++ /dev/null
@@ -1,264 +0,0 @@
-<?php namespace Gitlab\Tests\Api;
-
-use Gitlab\Api\AbstractApi;
-
-class GroupsTest extends ApiTestCase
-{
-    /**
-     * @test
-     */
-    public function shouldGetAllGroups()
-    {
-        $expectedArray = array(
-            array('id' => 1, 'name' => 'A group'),
-            array('id' => 2, 'name' => 'Another group'),
-        );
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('groups', array('page' => 1, 'per_page' => 10))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->all(1, 10));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldNotNeedPaginationWhenGettingGroups()
-    {
-        $expectedArray = array(
-            array('id' => 1, 'name' => 'A group'),
-            array('id' => 2, 'name' => 'Another group'),
-        );
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('groups', array('page' => 1, 'per_page' => AbstractApi::PER_PAGE))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->all());
-    }
-
-    /**
-     * @test
-     */
-    public function shouldSearchGroups()
-    {
-        $expectedArray = array(
-            array('id' => 1, 'name' => 'A group'),
-            array('id' => 2, 'name' => 'Another group'),
-        );
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('groups?search=some%20group', array('page' => 1, 'per_page' => AbstractApi::PER_PAGE))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->search('some group'));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldSearchGroupsWithPagination()
-    {
-        $expectedArray = array(
-            array('id' => 1, 'name' => 'A group'),
-            array('id' => 2, 'name' => 'Another group'),
-        );
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('groups?search=group', array('page' => 2, 'per_page' => 5))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->search('group', 2, 5));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldShowGroup()
-    {
-        $expectedArray = array('id' => 1, 'name' => 'A group');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('groups/1')
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->show(1));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldCreateGroup()
-    {
-        $expectedArray = array('id' => 1, 'name' => 'A new group');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('post')
-            ->with('groups', array('name' => 'A new group', 'path' => 'a-new-group', 'description' => null, 'visibility_level' => 0))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->create('A new group', 'a-new-group'));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldCreateGroupWithDescriptionAndVisLevel()
-    {
-        $expectedArray = array('id' => 1, 'name' => 'A new group', 'visibility_level' => 2);
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('post')
-            ->with('groups', array('name' => 'A new group', 'path' => 'a-new-group', 'description' => 'Description', 'visibility_level' => 2))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->create('A new group', 'a-new-group', 'Description', 2));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldUpdateGroup()
-    {
-        $expectedArray = array('id' => 3, 'name' => 'Group name', 'path' => 'group-path');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('put')
-            ->with('groups/3', array('name' => 'Group name', 'path' => 'group-path'))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->update(3, array('name' => 'Group name', 'path' => 'group-path')));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldTransferProjectToGroup()
-    {
-        $expectedBool = true;
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('post')
-            ->with('groups/1/projects/2')
-            ->will($this->returnValue($expectedBool))
-        ;
-
-        $this->assertEquals($expectedBool, $api->transfer(1, 2));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetMembers()
-    {
-        $expectedArray = array(
-            array('id' => 1, 'name' => 'Matt'),
-            array('id' => 2, 'name' => 'Bob')
-        );
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('groups/1/members')
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->members(1));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldAddMember()
-    {
-        $expectedArray = array('id' => 1, 'name' => 'Matt');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('post')
-            ->with('groups/1/members', array('user_id' => 2, 'access_level' => 3))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->addMember(1, 2, 3));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldSaveMember()
-    {
-        $expectedArray = array('id' => 1, 'name' => 'Matt');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('put')
-            ->with('groups/1/members/2', array('access_level' => 4))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->saveMember(1, 2, 4));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldRemoveMember()
-    {
-        $expectedBool = true;
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('delete')
-            ->with('groups/1/members/2')
-            ->will($this->returnValue($expectedBool))
-        ;
-
-        $this->assertEquals($expectedBool, $api->removeMember(1, 2));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldRemoveGroup()
-    {
-        $expectedBool = true;
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('delete')
-            ->with('groups/1')
-            ->will($this->returnValue($expectedBool))
-        ;
-
-        $this->assertEquals($expectedBool, $api->remove(1));
-    }
-
-    protected function getApiClass()
-    {
-        return 'Gitlab\Api\Groups';
-    }
-}
diff --git a/test/Gitlab/Tests/Api/IssuesTest.php b/test/Gitlab/Tests/Api/IssuesTest.php
deleted file mode 100644
index db2424a55..000000000
--- a/test/Gitlab/Tests/Api/IssuesTest.php
+++ /dev/null
@@ -1,194 +0,0 @@
-<?php namespace Gitlab\Tests\Api;
-
-use Gitlab\Api\AbstractApi;
-
-class IssuesTest extends ApiTestCase
-{
-    /**
-     * @test
-     */
-    public function shouldGetAllIssues()
-    {
-        $expectedArray = array(
-            array('id' => 1, 'title' => 'An issue'),
-            array('id' => 2, 'title' => 'Another issue'),
-        );
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('issues', array('page' => 1, 'per_page' => AbstractApi::PER_PAGE))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->all());
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetProjectIssuesWithPagination()
-    {
-        $expectedArray = array(
-            array('id' => 1, 'title' => 'An issue'),
-            array('id' => 2, 'title' => 'Another issue'),
-        );
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('projects/1/issues', array('page' => 2, 'per_page' => 5))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->all(1, 2, 5));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetProjectIssuesWithParams()
-    {
-        $expectedArray = array(
-            array('id' => 1, 'title' => 'An issue'),
-            array('id' => 2, 'title' => 'Another issue'),
-        );
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('projects/1/issues', array('page' => 2, 'per_page' => 5, 'order_by' => 'created_at', 'sort' => 'desc', 'labels' => 'foo,bar', 'state' => 'open'))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->all(1, 2, 5, array('order_by' => 'created_at', 'sort' => 'desc', 'labels' => 'foo,bar', 'state' => 'open')));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldShowIssue()
-    {
-        $expectedArray = array('id' => 2, 'title' => 'Another issue');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('projects/1/issues?iid=2')
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->show(1, 2));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldCreateIssue()
-    {
-        $expectedArray = array('id' => 3, 'title' => 'A new issue');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('post')
-            ->with('projects/1/issues', array('title' => 'A new issue', 'labels' => 'foo,bar'))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->create(1, array('title' => 'A new issue', 'labels' => 'foo,bar')));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldUpdateIssue()
-    {
-        $expectedArray = array('id' => 2, 'title' => 'A renamed issue');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('put')
-            ->with('projects/1/issues/2', array('title' => 'A renamed issue', 'labels' => 'foo'))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->update(1, 2, array('title' => 'A renamed issue', 'labels' => 'foo')));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetIssueComments()
-    {
-        $expectedArray = array(
-            array('id' => 1, 'body' => 'A comment'),
-            array('id' => 2, 'body' => 'Another comment')
-        );
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('projects/1/issues/2/notes')
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->showComments(1, 2));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetIssueComment()
-    {
-        $expectedArray = array('id' => 3, 'body' => 'A new comment');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('projects/1/issues/2/notes/3')
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->showComment(1, 2, 3));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldCreateComment()
-    {
-        $expectedArray = array('id' => 3, 'body' => 'A new comment');
-
-        $api = $this->getApiMock();
-        $api->expects($this->exactly(2))
-            ->method('post')
-            ->with('projects/1/issues/2/notes', array('body' => 'A new comment'))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->addComment(1, 2, array('body' => 'A new comment')));
-        $this->assertEquals($expectedArray, $api->addComment(1, 2, 'A new comment'));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldUpdateComment()
-    {
-        $expectedArray = array('id' => 3, 'body' => 'An edited comment');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('put')
-            ->with('projects/1/issues/2/notes/3', array('body' => 'An edited comment'))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->updateComment(1, 2, 3, 'An edited comment'));
-    }
-
-    protected function getApiClass()
-    {
-        return 'Gitlab\Api\Issues';
-    }
-}
diff --git a/test/Gitlab/Tests/Api/MergeRequestsTest.php b/test/Gitlab/Tests/Api/MergeRequestsTest.php
deleted file mode 100644
index bd6e6eb2b..000000000
--- a/test/Gitlab/Tests/Api/MergeRequestsTest.php
+++ /dev/null
@@ -1,368 +0,0 @@
-<?php namespace Gitlab\Tests\Api;
-
-use Gitlab\Api\AbstractApi;
-use Gitlab\Api\MergeRequests;
-
-class MergeRequestsTest extends ApiTestCase
-{
-    /**
-     * @test
-     */
-    public function shouldGetMergeRequestListWithDefaultParams()
-    {
-        $expectedArray = $this->getMultipleMergeRequestsData();
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('projects/1/merge_requests', array('page' => 1, 'per_page' => AbstractApi::PER_PAGE, 'state' => MergeRequests::STATE_ALL, 'order_by' => MergeRequests::ORDER_BY, 'sort' => MergeRequests::SORT))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->getList(1));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetAll()
-    {
-        $expectedArray = $this->getMultipleMergeRequestsData();
-
-        $api = $this->getApiMock(array('getList'));
-        $api->expects($this->once())
-            ->method('getList')
-            ->with(1, MergeRequests::STATE_ALL, 1, AbstractApi::PER_PAGE, MergeRequests::ORDER_BY, MergeRequests::SORT)
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->all(1));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetAllWithParams()
-    {
-        $expectedArray = $this->getMultipleMergeRequestsData();
-
-        $api = $this->getApiMock(array('getList'));
-        $api->expects($this->once())
-            ->method('getList')
-            ->with(1, MergeRequests::STATE_ALL, 2, 5,  'title', 'desc')
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->all(1, 2, 5, 'title', 'desc'));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetMerged()
-    {
-        $expectedArray = $this->getMultipleMergeRequestsData();
-
-        $api = $this->getApiMock(array('getList'));
-        $api->expects($this->once())
-            ->method('getList')
-            ->with(1, MergeRequests::STATE_MERGED, 1, AbstractApi::PER_PAGE, MergeRequests::ORDER_BY, MergeRequests::SORT)
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->merged(1));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetMergedWithParams()
-    {
-        $expectedArray = $this->getMultipleMergeRequestsData();
-
-        $api = $this->getApiMock(array('getList'));
-        $api->expects($this->once())
-            ->method('getList')
-            ->with(1, MergeRequests::STATE_MERGED, 3, 15, 'updated_at', 'asc')
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->merged(1, 3, 15, 'updated_at', 'asc'));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetOpened()
-    {
-        $expectedArray = $this->getMultipleMergeRequestsData();
-
-        $api = $this->getApiMock(array('getList'));
-        $api->expects($this->once())
-            ->method('getList')
-            ->with(1, MergeRequests::STATE_OPENED, 1, AbstractApi::PER_PAGE, MergeRequests::ORDER_BY, MergeRequests::SORT)
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->opened(1));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetOpenedWithParams()
-    {
-        $expectedArray = $this->getMultipleMergeRequestsData();
-
-        $api = $this->getApiMock(array('getList'));
-        $api->expects($this->once())
-            ->method('getList')
-            ->with(1, MergeRequests::STATE_OPENED, 2, 4, 'title', 'desc')
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->opened(1, 2, 4, 'title', 'desc'));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetClosed()
-    {
-        $expectedArray = $this->getMultipleMergeRequestsData();
-
-        $api = $this->getApiMock(array('getList'));
-        $api->expects($this->once())
-            ->method('getList')
-            ->with(1, MergeRequests::STATE_CLOSED, 1, AbstractApi::PER_PAGE, MergeRequests::ORDER_BY, MergeRequests::SORT)
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->closed(1));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetClosedWithParams()
-    {
-        $expectedArray = $this->getMultipleMergeRequestsData();
-
-        $api = $this->getApiMock(array('getList'));
-        $api->expects($this->once())
-            ->method('getList')
-            ->with(1, MergeRequests::STATE_CLOSED, 2, 4, 'title', 'desc')
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->closed(1, 2, 4, 'title', 'desc'));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldShowMergeRequest()
-    {
-        $expectedArray = array('id' => 2, 'name' => 'A merge request');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('projects/1/merge_request/2')
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->show(1, 2));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldCreateMergeRequestWithoutOptionalParams()
-    {
-        $expectedArray = array('id' => 3, 'title' => 'Merge Request');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('post')
-            ->with('projects/1/merge_requests', array(
-                'title' => 'Merge Request',
-                'target_branch' => 'master',
-                'source_branch' => 'develop',
-                'description' => null,
-                'assignee_id' => null,
-                'target_project_id' => null
-            ))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->create(1, 'develop', 'master', 'Merge Request'));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldCreateMergeRequestWithOptionalParams()
-    {
-        $expectedArray = array('id' => 3, 'title' => 'Merge Request');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('post')
-            ->with('projects/1/merge_requests', array(
-                'title' => 'Merge Request',
-                'target_branch' => 'master',
-                'source_branch' => 'develop',
-                'description' => 'Some changes',
-                'assignee_id' => 6,
-                'target_project_id' => 20
-            ))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->create(1, 'develop', 'master', 'Merge Request', 6, 20, 'Some changes'));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldUpdateMergeRequest()
-    {
-        $expectedArray = array('id' => 2, 'title' => 'Updated title');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('put')
-            ->with('projects/1/merge_request/2', array('title' => 'Updated title', 'description' => 'No so many changes now', 'state_event' => 'close'))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->update(1, 2, array(
-            'title' => 'Updated title',
-            'description' => 'No so many changes now',
-            'state_event' => 'close'
-        )));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldMergeMergeRequest()
-    {
-        $expectedArray = array('id' => 2, 'title' => 'Updated title');
-
-        $api = $this->getApiMock();
-        $api->expects($this->exactly(2))
-            ->method('put')
-            ->with('projects/1/merge_request/2/merge', array('merge_commit_message' => 'Accepted'))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->merge(1, 2, 'Accepted'));
-        $this->assertEquals($expectedArray, $api->merge(1, 2, array('merge_commit_message' => 'Accepted')));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetMergeRequestNotes()
-    {
-        $expectedArray = array(
-            array('id' => 1, 'body' => 'A comment'),
-            array('id' => 2, 'body' => 'Another comment')
-        );
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('projects/1/merge_requests/2/notes')
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->showNotes(1, 2));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetMergeRequestComments()
-    {
-        $expectedArray = array(
-            array('id' => 1, 'note' => 'A comment'),
-            array('id' => 2, 'note' => 'Another comment')
-        );
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('projects/1/merge_request/2/comments')
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->showComments(1, 2));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldAddMergeRequestComment()
-    {
-        $expectedArray = array('id' => 2, 'title' => 'A comment');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('post')
-            ->with('projects/1/merge_request/2/comments', array('note' => 'A comment'))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->addComment(1, 2, 'A comment'));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetMergeRequestChanges()
-    {
-        $expectedArray = array('id' => 1, 'title' => 'A merge request');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('projects/1/merge_request/2/changes')
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->changes(1, 2));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetMergeRequestByIid()
-    {
-        $expectedArray = array('id' => 1, 'title' => 'A merge request');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('projects/1/merge_requests', array('iid' => 2))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->getByIid(1, 2));
-    }
-
-    protected function getMultipleMergeRequestsData()
-    {
-        return array(
-            array('id' => 1, 'title' => 'A merge request'),
-            array('id' => 2, 'title' => 'Another merge request')
-        );
-    }
-
-    protected function getApiClass()
-    {
-        return 'Gitlab\Api\MergeRequests';
-    }
-}
diff --git a/test/Gitlab/Tests/Api/MilestonesTest.php b/test/Gitlab/Tests/Api/MilestonesTest.php
deleted file mode 100644
index e22c2370f..000000000
--- a/test/Gitlab/Tests/Api/MilestonesTest.php
+++ /dev/null
@@ -1,100 +0,0 @@
-<?php namespace Gitlab\Tests\Api;
-
-class MilestonesTest extends ApiTestCase
-{
-    /**
-     * @test
-     */
-    public function shouldGetAllMilestones()
-    {
-        $expectedArray = array(
-            array('id' => 1, 'title' => 'A milestone'),
-            array('id' => 2, 'title' => 'Another milestone'),
-        );
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('projects/1/milestones')
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->all(1));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldShowMilestone()
-    {
-        $expectedArray = array('id' => 1, 'name' => 'A milestone');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('projects/1/milestones/2')
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->show(1, 2));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldCreateMilestone()
-    {
-        $expectedArray = array('id' => 3, 'title' => 'A new milestone');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('post')
-            ->with('projects/1/milestones', array('description' => 'Some text', 'title' => 'A new milestone'))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->create(1, array('description' => 'Some text', 'title' => 'A new milestone')));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldUpdateMilestone()
-    {
-        $expectedArray = array('id' => 3, 'title' => 'Updated milestone');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('put')
-            ->with('projects/1/milestones/3', array('title' => 'Updated milestone', 'due_date' => '2015-04-01', 'state_event' => 'close'))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->update(1, 3, array('title' => 'Updated milestone', 'due_date' => '2015-04-01', 'state_event' => 'close')));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetMilestonesIssues()
-    {
-        $expectedArray = array(
-            array('id' => 1, 'title' => 'An issue'),
-            array('id' => 2, 'title' => 'Another issue'),
-        );
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('projects/1/milestones/3/issues')
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->issues(1, 3));
-    }
-
-    protected function getApiClass()
-    {
-        return 'Gitlab\Api\Milestones';
-    }
-}
diff --git a/test/Gitlab/Tests/Api/ProjectNamespacesTest.php b/test/Gitlab/Tests/Api/ProjectNamespacesTest.php
deleted file mode 100644
index 1feb5ff1f..000000000
--- a/test/Gitlab/Tests/Api/ProjectNamespacesTest.php
+++ /dev/null
@@ -1,70 +0,0 @@
-<?php namespace Gitlab\Tests\Api;
-
-use Gitlab\Api\AbstractApi;
-
-class ProjectNamespacesTest extends ApiTestCase
-{
-    /**
-     * @test
-     */
-    public function shouldGetAllNamespaces()
-    {
-        $expectedArray = array(
-            array('id' => 1, 'name' => 'bespokes'),
-            array('id' => 2, 'name' => 'internal')
-        );
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('namespaces', array('page' => 1, 'per_page' => 10))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->all(1, 10));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldNotNeedPaginationWhenGettingNamespaces()
-    {
-        $expectedArray = array(
-            array('id' => 1, 'name' => 'bespokes'),
-            array('id' => 2, 'name' => 'internal')
-        );
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('namespaces', array('page' => 1, 'per_page' => AbstractApi::PER_PAGE))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->all());
-    }
-    /**
-     * @test
-     */
-    public function shouldSearchNamespaces()
-    {
-        $expectedArray = array(
-            array('id' => 1, 'name' => 'bespokes'),
-            array('id' => 2, 'name' => 'internal')
-        );
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('namespaces', array('search' => 'term', 'page' => 1, 'per_page' => 10))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->search('term', 1, 10));
-    }
-
-    protected function getApiClass()
-    {
-        return 'Gitlab\Api\ProjectNamespaces';
-    }
-}
diff --git a/test/Gitlab/Tests/Api/ProjectsTest.php b/test/Gitlab/Tests/Api/ProjectsTest.php
deleted file mode 100644
index 6bfd0ff46..000000000
--- a/test/Gitlab/Tests/Api/ProjectsTest.php
+++ /dev/null
@@ -1,998 +0,0 @@
-<?php namespace Gitlab\Tests\Api;
-
-use Gitlab\Api\AbstractApi;
-use Gitlab\Api\Projects;
-
-class ProjectsTest extends ApiTestCase
-{
-    /**
-     * @test
-     */
-    public function shouldGetAllProjects()
-    {
-        $expectedArray = $this->getMultipleProjectsData();
-
-        $api = $this->getMultipleProjectsRequestMock('projects/all', $expectedArray);
-
-        $this->assertEquals($expectedArray, $api->all());
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetAllProjectsSortedByName()
-    {
-        $expectedArray = $this->getMultipleProjectsData();
-
-        $api = $this->getMultipleProjectsRequestMock('projects/all', $expectedArray, 1, 5, 'name', 'asc');
-
-        $this->assertEquals($expectedArray, $api->all(1, 5, 'name'));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldNotNeedPaginationWhenGettingProjects()
-    {
-        $expectedArray = $this->getMultipleProjectsData();
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('projects/all', array('page' => 1, 'per_page' => AbstractApi::PER_PAGE, 'order_by' => Projects::ORDER_BY, 'sort' => Projects::SORT))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->all());
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetAccessibleProjects()
-    {
-        $expectedArray = $this->getMultipleProjectsData();
-
-        $api = $this->getMultipleProjectsRequestMock('projects', $expectedArray, 2, 7);
-
-        $this->assertEquals($expectedArray, $api->accessible(2, 7));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetOwnedProjects()
-    {
-        $expectedArray = $this->getMultipleProjectsData();
-
-        $api = $this->getMultipleProjectsRequestMock('projects/owned', $expectedArray, 3, 50);
-
-        $this->assertEquals($expectedArray, $api->owned(3, 50));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldSearchProjects()
-    {
-        $expectedArray = $this->getMultipleProjectsData();
-
-        $api = $this->getMultipleProjectsRequestMock('projects/search/a%20project', $expectedArray);
-        $this->assertEquals($expectedArray, $api->search('a project'));
-
-        $api = $this->getMultipleProjectsRequestMock('projects/search/a%2Eproject', $expectedArray);
-        $this->assertEquals($expectedArray, $api->search('a.project'));
-
-        $api = $this->getMultipleProjectsRequestMock('projects/search/a%2Fproject', $expectedArray);
-        $this->assertEquals($expectedArray, $api->search('a/project'));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldShowProject()
-    {
-        $expectedArray = array('id' => 1, 'name' => 'Project Name');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('projects/1')
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->show(1));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldCreateProject()
-    {
-        $expectedArray = array('id' => 1, 'name' => 'Project Name');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('post')
-            ->with('projects', array('name' => 'Project Name', 'issues_enabled' => true))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->create('Project Name', array(
-            'issues_enabled' => true
-        )));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldUpdateProject()
-    {
-        $expectedArray = array('id' => 1, 'name' => 'Updated Name');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('put')
-            ->with('projects/1', array('name' => 'Updated Name', 'issues_enabled' => true))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->update(1, array(
-            'name' => 'Updated Name',
-            'issues_enabled' => true
-        )));
-    }
-    
-    /**
-     * @test
-     */
-    public function shouldArchiveProject()
-    {
-        $expectedArray = array('id' => 1, 'archived' => true);
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('post')
-            ->with('projects/1/archive')
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->archive(1));
-    }
-    
-    /**
-     * @test
-     */
-    public function shouldUnarchiveProject()
-    {
-        $expectedArray = array('id' => 1, 'archived' => false);
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('post')
-            ->with('projects/1/unarchive')
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->unarchive(1));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldCreateProjectForUser()
-    {
-        $expectedArray = array('id' => 1, 'name' => 'Project Name');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('post')
-            ->with('projects/user/1', array('name' => 'Project Name', 'issues_enabled' => true))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->createForUser(1, 'Project Name', array(
-            'issues_enabled' => true
-        )));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldRemoveProject()
-    {
-        $expectedBool = true;
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('delete')
-            ->with('projects/1')
-            ->will($this->returnValue($expectedBool))
-        ;
-
-        $this->assertEquals($expectedBool, $api->remove(1));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetBuilds()
-    {
-        $expectedArray = array(
-            array('id' => 1, 'status' => 'success'),
-            array('id' => 2, 'status' => 'failed')
-        );
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('projects/1/builds')
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->builds(1));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetBuildsWithScope()
-    {
-        $expectedArray = array(
-            array('id' => 1, 'status' => 'success'),
-        );
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('projects/1/builds', array('scope' => 'success'))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->builds(1, 'success'));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetBuildsWithMultipleScopes()
-    {
-        $expectedArray = array(
-            array('id' => 1, 'status' => 'success'),
-            array('id' => 1, 'status' => 'failed'),
-        );
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('projects/1/builds', array('scope' => array('success', 'failed')))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->builds(1, array('success', 'failed')));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetBuild()
-    {
-        $expectedArray = array('id' => 2);
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('projects/1/builds/2')
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->build(1, 2));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetTrace()
-    {
-        $expectedString = 'runner trace of a specific build';
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('projects/1/builds/2/trace')
-            ->will($this->returnValue($expectedString))
-        ;
-
-        $this->assertEquals($expectedString, $api->trace(1, 2));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetMembers()
-    {
-        $expectedArray = array(
-            array('id' => 1, 'name' => 'Matt'),
-            array('id' => 2, 'name' => 'Bob')
-        );
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('projects/1/members')
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->members(1));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetMembersWithQuery()
-    {
-        $expectedArray = array(
-            array('id' => 1, 'name' => 'Matt')
-        );
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('projects/1/members', array('query' => 'at'))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->members(1, 'at'));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetMember()
-    {
-        $expectedArray = array('id' => 2, 'name' => 'Matt');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('projects/1/members/2')
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->member(1, 2));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldAddMember()
-    {
-        $expectedArray = array('id' => 1, 'name' => 'Matt');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('post')
-            ->with('projects/1/members', array('user_id' => 2, 'access_level' => 3))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->addMember(1, 2, 3));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldSaveMember()
-    {
-        $expectedArray = array('id' => 1, 'name' => 'Matt');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('put')
-            ->with('projects/1/members/2', array('access_level' => 4))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->saveMember(1, 2, 4));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldRemoveMember()
-    {
-        $expectedBool = true;
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('delete')
-            ->with('projects/1/members/2')
-            ->will($this->returnValue($expectedBool))
-        ;
-
-        $this->assertEquals($expectedBool, $api->removeMember(1, 2));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetHooks()
-    {
-        $expectedArray = array(
-            array('id' => 1, 'name' => 'Test hook'),
-            array('id' => 2, 'name' => 'Another hook'),
-        );
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('projects/1/hooks')
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->hooks(1));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetHook()
-    {
-        $expectedArray = array('id' => 2, 'name' => 'Another hook');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('projects/1/hooks/2')
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->hook(1, 2));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldAddHook()
-    {
-        $expectedArray = array('id' => 3, 'name' => 'A new hook', 'url' => 'http://www.example.com');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('post')
-            ->with('projects/1/hooks', array('url' => 'http://www.example.com', 'push_events' => true, 'issues_events' => true, 'merge_requests_events' => true))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->addHook(1, 'http://www.example.com', array('push_events' => true, 'issues_events' => true, 'merge_requests_events' => true)));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldAddHookWithOnlyUrl()
-    {
-        $expectedArray = array('id' => 3, 'name' => 'A new hook', 'url' => 'http://www.example.com');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('post')
-            ->with('projects/1/hooks', array('url' => 'http://www.example.com', 'push_events' => true))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->addHook(1, 'http://www.example.com'));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldAddHookWithoutPushEvents()
-    {
-        $expectedArray = array('id' => 3, 'name' => 'A new hook', 'url' => 'http://www.example.com');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('post')
-            ->with('projects/1/hooks', array('url' => 'http://www.example.com', 'push_events' => false))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->addHook(1, 'http://www.example.com', array('push_events' => false)));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldUpdateHook()
-    {
-        $expectedArray = array('id' => 3, 'name' => 'A new hook', 'url' => 'http://www.example.com');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('put')
-            ->with('projects/1/hooks/3', array('url' => 'http://www.example-test.com', 'push_events' => false))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->updateHook(1, 3, array('url' => 'http://www.example-test.com', 'push_events' => false)));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldRemoveHook()
-    {
-        $expectedBool = true;
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('delete')
-            ->with('projects/1/hooks/2')
-            ->will($this->returnValue($expectedBool))
-        ;
-
-        $this->assertEquals($expectedBool, $api->removeHook(1, 2));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetKeys()
-    {
-        $expectedArray = array(
-            array('id' => 1, 'title' => 'test-key'),
-            array('id' => 2, 'title' => 'another-key')
-        );
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('projects/1/keys')
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->keys(1));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetKey()
-    {
-        $expectedArray = array('id' => 2, 'title' => 'another-key');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('projects/1/keys/2')
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->key(1, 2));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldAddKey()
-    {
-        $expectedArray = array('id' => 3, 'title' => 'new-key');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('post')
-            ->with('projects/1/keys', array('title' => 'new-key', 'key' => '...'))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->addKey(1, 'new-key', '...'));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldRemoveKey()
-    {
-        $expectedBool = true;
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('delete')
-            ->with('projects/1/keys/3')
-            ->will($this->returnValue($expectedBool))
-        ;
-
-        $this->assertEquals($expectedBool, $api->removeKey(1, 3));
-    }
-
-    /**
-     * @test
-     */
-    public function shoudEnableKey()
-    {
-        $expectedBool = true;
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('post')
-            ->with('projects/1/keys/3/enable')
-            ->will($this->returnValue($expectedBool))
-        ;
-
-        $this->assertEquals($expectedBool, $api->enableKey(1, 3));
-    }
-
-    /**
-     * @test
-     */
-    public function shoudDisableKey()
-    {
-        $expectedBool = true;
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('delete')
-            ->with('projects/1/keys/3/disable')
-            ->will($this->returnValue($expectedBool))
-        ;
-
-        $this->assertEquals($expectedBool, $api->disableKey(1, 3));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetEvents()
-    {
-        $expectedArray = array(
-            array('id' => 1, 'title' => 'An event'),
-            array('id' => 2, 'title' => 'Another event')
-        );
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('projects/1/events', array(
-                'page' => 1,
-                'per_page' => AbstractApi::PER_PAGE
-            ))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->events(1));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetEventsWithPagination()
-    {
-        $expectedArray = array(
-            array('id' => 1, 'title' => 'An event'),
-            array('id' => 2, 'title' => 'Another event')
-        );
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('projects/1/events', array(
-                'page' => 2,
-                'per_page' => 15
-            ))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->events(1, 2, 15));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetLabels()
-    {
-        $expectedArray = array(
-            array('name' => 'bug', 'color' => '#000000'),
-            array('name' => 'feature', 'color' => '#ff0000')
-        );
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('projects/1/labels')
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->labels(1));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldAddLabel()
-    {
-        $expectedArray = array('name' => 'bug', 'color' => '#000000');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('post')
-            ->with('projects/1/labels', array('name' => 'wont-fix', 'color' => '#ffffff'))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->addLabel(1, array('name' => 'wont-fix', 'color' => '#ffffff')));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldUpdateLabel()
-    {
-        $expectedArray = array('name' => 'bug', 'color' => '#00ffff');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('put')
-            ->with('projects/1/labels', array('name' => 'bug', 'new_name' => 'big-bug', 'color' => '#00ffff'))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->updateLabel(1, array('name' => 'bug', 'new_name' => 'big-bug', 'color' => '#00ffff')));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldRemoveLabel()
-    {
-        $expectedBool = true;
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('delete')
-            ->with('projects/1/labels', array('name' => 'bug'))
-            ->will($this->returnValue($expectedBool))
-        ;
-
-        $this->assertEquals($expectedBool, $api->removeLabel(1, 'bug'));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldCreateForkRelation()
-    {
-        $expectedArray = array('project_id' => 1, 'forked_id' => 2);
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('post')
-            ->with('projects/1/fork/2')
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->createForkRelation(1, 2));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldRemoveForkRelation()
-    {
-        $expectedBool = true;
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('delete')
-            ->with('projects/2/fork')
-            ->will($this->returnValue($expectedBool))
-        ;
-
-        $this->assertEquals($expectedBool, $api->removeForkRelation(2));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldSetService()
-    {
-        $expectedBool = true;
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('put')
-            ->with('projects/1/services/hipchat', array('param' => 'value'))
-            ->will($this->returnValue($expectedBool))
-        ;
-
-        $this->assertEquals($expectedBool, $api->setService(1, 'hipchat', array('param' => 'value')));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldRemoveService()
-    {
-        $expectedBool = true;
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('delete')
-            ->with('projects/1/services/hipchat')
-            ->will($this->returnValue($expectedBool))
-        ;
-
-        $this->assertEquals($expectedBool, $api->removeService(1, 'hipchat'));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetVariables()
-    {
-        $expectedArray = array(
-            array('key' => 'ftp_username', 'value' => 'ftp'),
-            array('key' => 'ftp_password', 'value' => 'somepassword')
-        );
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('projects/1/variables')
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->variables(1));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetVariable()
-    {
-        $expectedArray = array('key' => 'ftp_username', 'value' => 'ftp');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('projects/1/variables/ftp_username')
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->variable(1, 'ftp_username'));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldAddVariable()
-    {
-        $expectedKey   = 'ftp_port';
-        $expectedValue = '21';
-
-        $expectedArray = array(
-            'key'   => $expectedKey,
-            'value' => $expectedValue,
-        );
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('post')
-            ->with('projects/1/variables', $expectedArray)
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->addVariable(1, $expectedKey, $expectedValue));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldUpdateVariable()
-    {
-        $expectedKey   = 'ftp_port';
-        $expectedValue = '22';
-
-        $expectedArray = array(
-            'key'   => 'ftp_port',
-            'value' => '22',
-        );
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('put')
-            ->with('projects/1/variables/'.$expectedKey, array('value' => $expectedValue))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->updateVariable(1, $expectedKey, $expectedValue));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldRemoveVariable()
-    {
-        $expectedBool = true;
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('delete')
-            ->with('projects/1/variables/ftp_password')
-            ->will($this->returnValue($expectedBool))
-        ;
-
-        $this->assertEquals($expectedBool, $api->removeVariable(1, 'ftp_password'));
-    }
-
-    protected function getMultipleProjectsRequestMock($path, $expectedArray = array(), $page = 1, $per_page = 20, $order_by = 'created_at', $sort = 'asc')
-    {
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with($path, array('page' => $page, 'per_page' => $per_page, 'order_by' => $order_by, 'sort' => $sort))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        return $api;
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetDeployments()
-    {
-        $expectedArray = array(
-            array('id' => 1, 'sha' => '0000001'),
-            array('id' => 2, 'sha' => '0000002'),
-        );
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('projects/1/deployments', array(
-                'page' => 1,
-                'per_page' => AbstractApi::PER_PAGE
-            ))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->deployments(1));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetDeploymentsWithPagination()
-    {
-        $expectedArray = array(
-            array('id' => 1, 'sha' => '0000001'),
-            array('id' => 2, 'sha' => '0000002'),
-        );
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('projects/1/deployments', array(
-                'page' => 2,
-                'per_page' => 15
-            ))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->deployments(1, 2, 15));
-    }
-
-    protected function getMultipleProjectsData()
-    {
-        return array(
-            array('id' => 1, 'name' => 'A project'),
-            array('id' => 2, 'name' => 'Another project')
-        );
-    }
-
-    protected function getApiClass()
-    {
-        return 'Gitlab\Api\Projects';
-    }
-}
diff --git a/test/Gitlab/Tests/Api/RepositoriesTest.php b/test/Gitlab/Tests/Api/RepositoriesTest.php
deleted file mode 100644
index db1887c29..000000000
--- a/test/Gitlab/Tests/Api/RepositoriesTest.php
+++ /dev/null
@@ -1,621 +0,0 @@
-<?php namespace Gitlab\Tests\Api;
-
-use Gitlab\Api\AbstractApi;
-
-class RepositoriesTest extends ApiTestCase
-{
-    /**
-     * @test
-     */
-    public function shouldGetBranches()
-    {
-        $expectedArray = array(
-            array('name' => 'master'),
-            array('name' => 'develop')
-        );
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('projects/1/repository/branches')
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->branches(1));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetBranch()
-    {
-        $expectedArray = array('name' => 'master');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('projects/1/repository/branches/master')
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->branch(1, 'master'));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldCreateBranch()
-    {
-        $expectedArray = array('name' => 'feature');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('post')
-            ->with('projects/1/repository/branches', array('branch_name' => 'feature', 'ref' => 'master'))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->createBranch(1, 'feature', 'master'));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldDeleteBranch()
-    {
-        $expectedBool = true;
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('delete')
-            ->with('projects/1/repository/branches/master')
-            ->will($this->returnValue($expectedBool))
-        ;
-
-        $this->assertEquals($expectedBool, $api->deleteBranch(1, 'master'));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldProtectBranch()
-    {
-        $expectedArray = array('name' => 'master');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('put')
-            ->with('projects/1/repository/branches/master/protect', array('developers_can_push' => false, 'developers_can_merge' => false))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->protectBranch(1, 'master'));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldProtectBranchWithPermissions()
-    {
-        $expectedArray = array('name' => 'master');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('put')
-            ->with('projects/1/repository/branches/master/protect', array('developers_can_push' => true, 'developers_can_merge' => true))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->protectBranch(1, 'master', true, true));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldUnprotectBranch()
-    {
-        $expectedArray = array('name' => 'master');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('put')
-            ->with('projects/1/repository/branches/master/unprotect')
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->unprotectBranch(1, 'master'));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetTags()
-    {
-        $expectedArray = array(
-            array('name' => '1.0'),
-            array('name' => '1.1')
-        );
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('projects/1/repository/tags')
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->tags(1));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldCreateTag()
-    {
-        $expectedArray = array('name' => '1.0');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('post')
-            ->with('projects/1/repository/tags', array(
-                'tag_name' => '1.0',
-                'ref' => 'abcd1234',
-                'message' => '1.0 release'
-            ))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->createTag(1, '1.0', 'abcd1234', '1.0 release'));
-    }
-
-	/**
-	 * @test
-	 */
-	public function shouldCreateRelease() {
-		$project_id  = 1;
-		$tagName     = 'sometag';
-		$description = '1.0 release';
-
-		$expectedArray = array( 'name' => $tagName );
-
-		$api = $this->getApiMock();
-		$api->expects( $this->once())
-		    ->method('post')
-		    ->with( 'projects/' . $project_id . '/repository/tags/' . $tagName . '/release', array(
-			    'id' => $project_id,
-			    'tag_name' => $tagName,
-			    'description' => $description
-		    ))
-		    ->will($this->returnValue($expectedArray))
-		;
-
-		$this->assertEquals( $expectedArray, $api->createRelease( $project_id, $tagName, $description ) );
-	}
-
-	/**
-	 * @test
-	 */
-	public function shouldUpdateRelease() {
-		$project_id  = 1;
-		$tagName     = 'sometag';
-		$description = '1.0 release';
-
-		$expectedArray = array( 'description' => $tagName );
-
-		$api = $this->getApiMock();
-		$api->expects( $this->once())
-		    ->method('put')
-		    ->with( 'projects/' . $project_id . '/repository/tags/' . $tagName . '/release', array(
-			    'id' => $project_id,
-			    'tag_name' => $tagName,
-			    'description' => $description
-		    ))
-		    ->will($this->returnValue($expectedArray))
-		;
-
-		$this->assertEquals( $expectedArray, $api->updateRelease( $project_id, $tagName, $description ) );
-	}
-
-    /**
-     * @test
-     */
-    public function shouldGetCommits()
-    {
-        $expectedArray = array(
-            array('id' => 'abcd1234', 'title' => 'A commit'),
-            array('id' => 'efgh5678', 'title' => 'Another commit')
-        );
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('projects/1/repository/commits', array('page' => 0, 'per_page' => AbstractApi::PER_PAGE, 'ref_name' => null))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->commits(1));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetCommitBuilds()
-    {
-        $expectedArray = array(
-            array('id' => 'abcd1234', 'status' => 'failed'),
-            array('id' => 'efgh5678', 'status' => 'success')
-        );
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('projects/1/repository/commits/abcd12345/builds', array('page' => 0, 'per_page' => AbstractApi::PER_PAGE, 'scope' => null))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->commitBuilds(1, 'abcd12345'));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetCommitBuildsWithScope()
-    {
-        $expectedArray = array(
-            array('id' => 'abcd1234', 'status' => 'success'),
-        );
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('projects/1/repository/commits/abcd12345/builds', array('page' => 0, 'per_page' => AbstractApi::PER_PAGE, 'scope' => 'success'))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->commitBuilds(1, 'abcd12345', 'success'));
-    }
-
-
-    /**
-     * @test
-     */
-    public function shouldGetCommitsWithParams()
-    {
-        $expectedArray = array(
-            array('id' => 'abcd1234', 'title' => 'A commit'),
-            array('id' => 'efgh5678', 'title' => 'Another commit')
-        );
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('projects/1/repository/commits', array('page' => 2, 'per_page' => 25, 'ref_name' => 'master'))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->commits(1, 2, 25, 'master'));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetCommit()
-    {
-        $expectedArray = array('id' => 'abcd1234', 'title' => 'A commit');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('projects/1/repository/commits/abcd1234')
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->commit(1, 'abcd1234'));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetCommitComments()
-    {
-        $expectedArray = array(
-            array('note' => 'A commit message'),
-            array('note' => 'Another commit message')
-        );
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('projects/1/repository/commits/abcd1234/comments')
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->commitComments(1, 'abcd1234'));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldCreateCommitComment()
-    {
-        $expectedArray = array('id' => 2, 'title' => 'A new comment');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('post')
-            ->with('projects/1/repository/commits/abcd1234/comments', array('note' => 'A new comment'))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->createCommitComment(1, 'abcd1234', 'A new comment'));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldCreateCommitCommentWithParams()
-    {
-        $expectedArray = array('id' => 2, 'title' => 'A new comment');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('post')
-            ->with('projects/1/repository/commits/abcd1234/comments', array(
-                'note' => 'A new comment',
-                'path' => '/some/file.txt',
-                'line' => 123, 'line_type' => 'old'
-            ))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->createCommitComment(1, 'abcd1234', 'A new comment', array(
-            'path' => '/some/file.txt',
-            'line' => 123,
-            'line_type' => 'old'
-        )));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldCompare()
-    {
-        $expectedArray = array('commit' => 'object');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('projects/1/repository/compare?from=master&to=feature')
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->compare(1, 'master', 'feature'));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetDiff()
-    {
-        $expectedArray = array(
-            array('diff' => '--- ...'),
-            array('diff' => '+++ ...')
-        );
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('projects/1/repository/commits/abcd1234/diff')
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->diff(1, 'abcd1234'));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetTree()
-    {
-        $expectedArray = array(
-            array('name' => 'file1.txt'),
-            array('name' => 'file2.csv')
-        );
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('projects/1/repository/tree')
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->tree(1));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetTreeWithParams()
-    {
-        $expectedArray = array(
-            array('name' => 'dir/file1.txt'),
-            array('name' => 'dir/file2.csv')
-        );
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('projects/1/repository/tree', array('path' => 'dir/', 'ref_name' => 'master'))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->tree(1, array('path' => 'dir/', 'ref_name' => 'master')));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetBlob()
-    {
-        $expectedString = 'something in a file';
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('projects/1/repository/commits/abcd1234/blob', array('filepath' => 'dir/file1.txt'))
-            ->will($this->returnValue($expectedString))
-        ;
-
-        $this->assertEquals($expectedString, $api->blob(1, 'abcd1234', 'dir/file1.txt'));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetFile()
-    {
-        $expectedArray = array('file_name' => 'file1.txt', 'file_path' => 'dir/file1.txt');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('projects/1/repository/files', array('file_path' => 'dir/file1.txt', 'ref' => 'abcd1234'))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->getFile(1, 'dir/file1.txt', 'abcd1234'));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldCreateFile()
-    {
-        $expectedArray = array('file_name' => 'file1.txt', 'file_path' => 'dir/file1.txt');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('post')
-            ->with('projects/1/repository/files', array(
-                'file_path' => 'dir/file1.txt',
-                'branch_name' => 'master',
-                'encoding' => null,
-                'content' => 'some contents',
-                'commit_message' => 'Added new file'
-            ))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->createFile(1, 'dir/file1.txt', 'some contents', 'master', 'Added new file'));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldCreateFileWithEncoding()
-    {
-        $expectedArray = array('file_name' => 'file1.txt', 'file_path' => 'dir/file1.txt');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('post')
-            ->with('projects/1/repository/files', array(
-                'file_path' => 'dir/file1.txt',
-                'branch_name' => 'master',
-                'encoding' => 'text',
-                'content' => 'some contents',
-                'commit_message' => 'Added new file'
-            ))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->createFile(1, 'dir/file1.txt', 'some contents', 'master', 'Added new file', 'text'));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldUpdateFile()
-    {
-        $expectedArray = array('file_name' => 'file1.txt', 'file_path' => 'dir/file1.txt');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('put')
-            ->with('projects/1/repository/files', array(
-                'file_path' => 'dir/file1.txt',
-                'branch_name' => 'master',
-                'encoding' => null,
-                'content' => 'some new contents',
-                'commit_message' => 'Updated new file'
-            ))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->updateFile(1, 'dir/file1.txt', 'some new contents', 'master', 'Updated new file'));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldUpdateFileWithEncoding()
-    {
-        $expectedArray = array('file_name' => 'file1.txt', 'file_path' => 'dir/file1.txt');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('put')
-            ->with('projects/1/repository/files', array(
-                'file_path' => 'dir/file1.txt',
-                'branch_name' => 'master',
-                'encoding' => 'base64',
-                'content' => 'some new contents',
-                'commit_message' => 'Updated file'
-            ))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->updateFile(1, 'dir/file1.txt', 'some new contents', 'master', 'Updated file', 'base64'));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldDeleteFile()
-    {
-        $expectedBool = true;
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('delete')
-            ->with('projects/1/repository/files', array('file_path' => 'dir/file1.txt', 'branch_name' => 'master', 'commit_message' => 'Deleted file'))
-            ->will($this->returnValue($expectedBool))
-        ;
-
-        $this->assertEquals($expectedBool, $api->deleteFile(1, 'dir/file1.txt', 'master', 'Deleted file'));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetContributors()
-    {
-        $expectedArray = array(
-            array('name' => 'Matt'),
-            array('name' => 'Bob')
-        );
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('projects/1/repository/contributors')
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->contributors(1));
-    }
-
-    protected function getApiClass()
-    {
-        return 'Gitlab\Api\Repositories';
-    }
-} 
\ No newline at end of file
diff --git a/test/Gitlab/Tests/Api/SnippetsTest.php b/test/Gitlab/Tests/Api/SnippetsTest.php
deleted file mode 100644
index 694aac595..000000000
--- a/test/Gitlab/Tests/Api/SnippetsTest.php
+++ /dev/null
@@ -1,114 +0,0 @@
-<?php namespace Gitlab\Tests\Api;
-
-class SnippetsTest extends ApiTestCase
-{
-    /**
-     * @test
-     */
-    public function shouldGetAllSnippets()
-    {
-        $expectedArray = array(
-            array('id' => 1, 'title' => 'A snippet'),
-            array('id' => 2, 'title' => 'Another snippet'),
-        );
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('projects/1/snippets')
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->all(1));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldShowSnippet()
-    {
-        $expectedArray = array('id' => 2, 'title' => 'Another snippet');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('projects/1/snippets/2')
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->show(1, 2));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldCreateSnippet()
-    {
-        $expectedArray = array('id' => 3, 'title' => 'A new snippet');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('post')
-            ->with('projects/1/snippets', array('title' => 'A new snippet', 'code' => 'A file', 'file_name' => 'file.txt'))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->create(1, 'A new snippet', 'file.txt', 'A file'));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldUpdateSnippet()
-    {
-        $expectedArray = array('id' => 3, 'title' => 'Updated snippet');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('put')
-            ->with('projects/1/snippets/3', array('title' => 'Updated snippet', 'code' => 'New content', 'file_name' => 'new_file.txt'))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->update(1, 3, array('file_name' => 'new_file.txt', 'code' => 'New content', 'title' => 'Updated snippet')));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldShowContent()
-    {
-        $expectedString = 'New content';
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('projects/1/snippets/3/raw')
-            ->will($this->returnValue($expectedString))
-        ;
-
-        $this->assertEquals($expectedString, $api->content(1, 3));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldRemoveSnippet()
-    {
-        $expectedBool = true;
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('delete')
-            ->with('projects/1/snippets/3')
-            ->will($this->returnValue($expectedBool))
-        ;
-
-        $this->assertEquals($expectedBool, $api->remove(1, 3));
-    }
-
-    protected function getApiClass()
-    {
-        return 'Gitlab\Api\Snippets';
-    }
-}
diff --git a/test/Gitlab/Tests/Api/SystemHooksTest.php b/test/Gitlab/Tests/Api/SystemHooksTest.php
deleted file mode 100644
index 3bf16bce6..000000000
--- a/test/Gitlab/Tests/Api/SystemHooksTest.php
+++ /dev/null
@@ -1,80 +0,0 @@
-<?php namespace Gitlab\Tests\Api;
-
-class SystemHooksTest extends ApiTestCase
-{
-    /**
-     * @test
-     */
-    public function shouldGetAllHooks()
-    {
-        $expectedArray = array(
-            array('id' => 1, 'url' => 'http://www.example.com'),
-            array('id' => 2, 'url' => 'http://www.example.org'),
-        );
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('hooks')
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->all());
-    }
-
-    /**
-     * @test
-     */
-    public function shouldCreateHook()
-    {
-        $expectedArray = array('id' => 3, 'url' => 'http://www.example.net');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('post')
-            ->with('hooks', array('url' => 'http://www.example.net'))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->create('http://www.example.net'));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldTestHook()
-    {
-        $expectedBool = true;
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('hooks/3')
-            ->will($this->returnValue($expectedBool))
-        ;
-
-        $this->assertEquals($expectedBool, $api->test(3));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldRemoveHook()
-    {
-        $expectedBool = true;
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('delete')
-            ->with('hooks/3')
-            ->will($this->returnValue($expectedBool))
-        ;
-
-        $this->assertEquals($expectedBool, $api->remove(3));
-    }
-
-    protected function getApiClass()
-    {
-        return 'Gitlab\Api\SystemHooks';
-    }
-}
diff --git a/test/Gitlab/Tests/Api/TestCase.php b/test/Gitlab/Tests/Api/TestCase.php
deleted file mode 100644
index cba51e5e2..000000000
--- a/test/Gitlab/Tests/Api/TestCase.php
+++ /dev/null
@@ -1,38 +0,0 @@
-<?php namespace Gitlab\Tests\Api;
-
-use Buzz\Client\Curl;
-
-use Gitlab\Client;
-use Gitlab\HttpClient\HttpClientInterface;
-
-abstract class TestCase extends \PHPUnit_Framework_TestCase
-{
-    /**
-     * @return Client
-     */
-    protected function getClientMock()
-    {
-        return new Client($this->getHttpMock());
-    }
-
-    /**
-     * @return \PHPUnit_Framework_MockObject_MockObject|HttpClientInterface
-     */
-    protected function getHttpMock()
-    {
-        return $this->getMock('Gitlab\HttpClient\HttpClient', array(), array(null, array(), $this->getHttpClientMock()));
-    }
-
-    /**
-     * @return \PHPUnit_Framework_MockObject_MockObject|Curl
-     */
-    protected function getHttpClientMock()
-    {
-        $httpClient = $this->getMock('Buzz\Client\Curl', array('send'));
-        $httpClient
-            ->expects($this->any())
-            ->method('send');
-
-        return $httpClient;
-    }
-}
diff --git a/test/Gitlab/Tests/Api/UsersTest.php b/test/Gitlab/Tests/Api/UsersTest.php
deleted file mode 100644
index a1441da00..000000000
--- a/test/Gitlab/Tests/Api/UsersTest.php
+++ /dev/null
@@ -1,459 +0,0 @@
-<?php namespace Gitlab\Tests\Api;
-
-use Gitlab\Api\AbstractApi;
-
-class UsersTest extends ApiTestCase
-{
-    /**
-     * @test
-     */
-    public function shouldGetAllUsers()
-    {
-        $expectedArray = array(
-            array('id' => 1, 'name' => 'Matt'),
-            array('id' => 2, 'name' => 'John'),
-        );
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('users', array('active' => null, 'page' => 1, 'per_page' => 10))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->all(null, 1, 10));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetActiveUsers()
-    {
-        $expectedArray = array(
-            array('id' => 1, 'name' => 'Matt'),
-            array('id' => 2, 'name' => 'John'),
-        );
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('users', array('active' => true, 'page' => 1, 'per_page' => AbstractApi::PER_PAGE))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->all(true));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldNotNeedPaginationWhenGettingUsers()
-    {
-        $expectedArray = array(
-            array('id' => 1, 'name' => 'Matt'),
-            array('id' => 2, 'name' => 'John'),
-        );
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('users', array('active' => null, 'page' => 1, 'per_page' => AbstractApi::PER_PAGE))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->all());
-    }
-
-    /**
-     * @test
-     */
-    public function shouldSearchUsers()
-    {
-        $expectedArray = array(
-            array('id' => 1, 'name' => 'Matt')
-        );
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('users', array('search' => 'ma', 'active' => null, 'page' => 1, 'per_page' => AbstractApi::PER_PAGE))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->search('ma'));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldSearchActiveUsers()
-    {
-        $expectedArray = array(
-            array('id' => 1, 'name' => 'Matt')
-        );
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('users', array('search' => 'ma', 'active' => true, 'page' => 1, 'per_page' => AbstractApi::PER_PAGE))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->search('ma', true));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldSearchActiveUsersWithPagination()
-    {
-        $expectedArray = array(
-            array('id' => 1, 'name' => 'Matt')
-        );
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('users', array('search' => 'ma', 'active' => true, 'page' => 2, 'per_page' => 5))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->search('ma', true, 2, 5));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldShowUser()
-    {
-        $expectedArray = array('id' => 1, 'name' => 'Matt');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('users/1')
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->show(1));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldCreateUser()
-    {
-        $expectedArray = array('id' => 3, 'name' => 'Billy');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('post')
-            ->with('users', array('email' => 'billy@example.com', 'password' => 'password'))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->create('billy@example.com', 'password'));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldCreateUserWithAdditionalInfo()
-    {
-        $expectedArray = array('id' => 3, 'name' => 'Billy');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('post')
-            ->with('users', array('email' => 'billy@example.com', 'password' => 'password', 'name' => 'Billy', 'bio' => 'A person'))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->create('billy@example.com', 'password', array('name' => 'Billy', 'bio' => 'A person')));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldUpdateUser()
-    {
-        $expectedArray = array('id' => 3, 'name' => 'Billy Bob');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('put')
-            ->with('users/3', array('name' => 'Billy Bob'))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->update(3, array('name' => 'Billy Bob')));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldRemoveUser()
-    {
-        $expectedBool = true;
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('delete')
-            ->with('users/1')
-            ->will($this->returnValue($expectedBool))
-        ;
-
-        $this->assertEquals($expectedBool, $api->remove(1));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldBlockUser()
-    {
-        $expectedBool = true;
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('put')
-            ->with('users/1/block')
-            ->will($this->returnValue($expectedBool))
-        ;
-
-        $this->assertEquals($expectedBool, $api->block(1));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldUnblockUser()
-    {
-        $expectedBool = true;
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('put')
-            ->with('users/1/unblock')
-            ->will($this->returnValue($expectedBool))
-        ;
-
-        $this->assertEquals($expectedBool, $api->unblock(1));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldShowCurrentUser()
-    {
-        $expectedArray = array('id' => 1, 'name' => 'Matt');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('user')
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->me());
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetCurrentUserKeys()
-    {
-        $expectedArray = array(
-            array('id' => 1, 'title' => 'A key'),
-            array('id' => 2, 'name' => 'Another key'),
-        );
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('user/keys')
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->keys(1));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetCurrentUserKey()
-    {
-        $expectedArray = array('id' => 1, 'title' => 'A key');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('user/keys/1')
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->key(1));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldCreateKeyForCurrentUser()
-    {
-        $expectedArray = array('id' => 3, 'title' => 'A new key');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('post')
-            ->with('user/keys', array('title' => 'A new key', 'key' => '...'))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->createKey('A new key', '...'));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldDeleteKeyForCurrentUser()
-    {
-        $expectedBool = true;
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('delete')
-            ->with('user/keys/3')
-            ->will($this->returnValue($expectedBool))
-        ;
-
-        $this->assertEquals($expectedBool, $api->removeKey(3));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetUserKeys()
-    {
-        $expectedArray = array(
-            array('id' => 1, 'title' => 'A key'),
-            array('id' => 2, 'name' => 'Another key'),
-        );
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('users/1/keys')
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->userKeys(1));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetUserKey()
-    {
-        $expectedArray = array('id' => 2, 'title' => 'Another key');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('users/1/keys/2')
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->userKey(1, 2));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldCreateKeyForUser()
-    {
-        $expectedArray = array('id' => 3, 'title' => 'A new key');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('post')
-            ->with('users/1/keys', array('title' => 'A new key', 'key' => '...'))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->createKeyForUser(1, 'A new key', '...'));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldDeleteKeyForUser()
-    {
-        $expectedBool = true;
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('delete')
-            ->with('users/1/keys/3')
-            ->will($this->returnValue($expectedBool))
-        ;
-
-        $this->assertEquals($expectedBool, $api->removeUserKey(1, 3));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldAttemptLogin()
-    {
-        $expectedArray = array('id' => 1, 'name' => 'Matt');
-
-        $api = $this->getApiMock();
-        $api->expects($this->exactly(2))
-            ->method('post')
-            ->with('session', array('login' => 'matt', 'password' => 'password', 'email' => 'matt'))
-            ->will($this->returnValue($expectedArray))
-        ;
-
-        $this->assertEquals($expectedArray, $api->session('matt', 'password'));
-        $this->assertEquals($expectedArray, $api->login('matt', 'password'));
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetUserEmails()
-    {
-        $expectedArray = array(
-            array('id' => 1, 'email' => 'foo@bar.baz'),
-            array('id' => 2, 'email' => 'foo@bar.qux'),
-        );
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('user/emails')
-            ->will($this->returnValue($expectedArray));
-
-        $this->assertEquals($expectedArray, $api->emails());
-    }
-
-    /**
-     * @test
-     */
-    public function shouldGetSpecificUserEmail()
-    {
-        $expectedArray = array('id' => 1, 'email' => 'foo@bar.baz');
-
-        $api = $this->getApiMock();
-        $api->expects($this->once())
-            ->method('get')
-            ->with('user/emails/1')
-            ->will($this->returnValue($expectedArray));
-
-        $this->assertEquals($expectedArray, $api->email(1));
-    }
-
-    protected function getApiClass()
-    {
-        return 'Gitlab\Api\Users';
-    }
-}
diff --git a/test/bootstrap.php b/test/bootstrap.php
deleted file mode 100644
index 83bc8b088..000000000
--- a/test/bootstrap.php
+++ /dev/null
@@ -1,18 +0,0 @@
-<?php
-
-function includeIfExists($file)
-{
-    if (file_exists($file)) {
-        return include $file;
-    }
-}
-
-if ((!$loader = includeIfExists(__DIR__.'/../vendor/autoload.php')) && (!$loader = includeIfExists(__DIR__.'/../../../.composer/autoload.php'))) {
-    die('You must set up the project dependencies, run the following commands:'.PHP_EOL.
-        'curl -s http://getcomposer.org/installer | php'.PHP_EOL.
-        'php composer.phar install'.PHP_EOL);
-}
-
-$loader->add('Gitlab\Tests', __DIR__);
-
-return $loader;
\ No newline at end of file
diff --git a/tests/Api/DeployKeysTest.php b/tests/Api/DeployKeysTest.php
new file mode 100644
index 000000000..bbe1648b9
--- /dev/null
+++ b/tests/Api/DeployKeysTest.php
@@ -0,0 +1,59 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Tests\Api;
+
+use Gitlab\Api\DeployKeys;
+use PHPUnit\Framework\Attributes\Test;
+
+class DeployKeysTest extends TestCase
+{
+    #[Test]
+    public function shouldGetAllDeployKeys(): void
+    {
+        $expectedArray = $this->getMultipleDeployKeysData();
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('deploy_keys', ['page' => 2, 'per_page' => 5])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->all(['page' => 2, 'per_page' => 5]));
+    }
+
+    protected function getMultipleDeployKeysData(): array
+    {
+        return [
+            [
+                'id' => 1,
+                'title' => 'Public key',
+                'key' => 'ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIEAiPWx6WM4lhHNedGfBpPJNPpZ7yKu+dnn1SJejgt4596k6YjzGGphH2TUxwKzxcKDKKezwkpfnxPkSMkuEspGRt/aZZ9wa++Oi7Qkr8prgHc4soW6NUlfDzpvZK2H5E7eQaSeP3SAwGmQKUFHCddNaP0L+hM7zhFNzjFvpaMgJw0=',
+                'created_at' => '2013-10-02T10:12:29Z',
+            ],
+            [
+                'id' => 3,
+                'title' => 'Another Public key',
+                'key' => 'ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIEAiPWx6WM4lhHNedGfBpPJNPpZ7yKu+dnn1SJejgt4596k6YjzGGphH2TUxwKzxcKDKKezwkpfnxPkSMkuEspGRt/aZZ9wa++Oi7Qkr8prgHc4soW6NUlfDzpvZK2H5E7eQaSeP3SAwGmQKUFHCddNaP0L+hM7zhFNzjFvpaMgJw0=',
+                'created_at' => '2013-10-02T11:12:29Z',
+            ],
+        ];
+    }
+
+    protected function getApiClass(): string
+    {
+        return DeployKeys::class;
+    }
+}
diff --git a/tests/Api/DeploymentsTest.php b/tests/Api/DeploymentsTest.php
new file mode 100644
index 000000000..d6859206b
--- /dev/null
+++ b/tests/Api/DeploymentsTest.php
@@ -0,0 +1,326 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Tests\Api;
+
+use Gitlab\Api\Deployments;
+use PHPUnit\Framework\Attributes\Test;
+use PHPUnit\Framework\MockObject\MockObject;
+
+class DeploymentsTest extends TestCase
+{
+    #[Test]
+    public function shouldGetAllDeployments(): void
+    {
+        $expectedArray = $this->getMultipleDeploymentsData();
+
+        $api = $this->getMultipleDeploymentsRequestMock('projects/1/deployments', $expectedArray, []);
+
+        $this->assertEquals($expectedArray, $api->all(1));
+    }
+
+    #[Test]
+    public function shouldShowDeployment(): void
+    {
+        $expectedArray = [
+            [
+                'created_at' => '2016-08-11T11:32:35.444Z',
+                'deployable' => [
+                    'commit' => [
+                        'author_email' => 'admin@example.com',
+                        'author_name' => 'Administrator',
+                        'created_at' => '2016-08-11T13:28:26.000+02:00',
+                        'id' => 'a91957a858320c0e17f3a0eca7cfacbff50ea29a',
+                        'message' => 'Merge branch \'rename-readme\' into \'master\'
+
+Rename README
+
+
+
+See merge request !2',
+                        'short_id' => 'a91957a8',
+                        'title' => 'Merge branch \'rename-readme\' into \'master\'
+',
+                    ],
+                    'coverage' => null,
+                    'created_at' => '2016-08-11T11:32:24.456Z',
+                    'finished_at' => '2016-08-11T11:32:35.145Z',
+                    'id' => 664,
+                    'name' => 'deploy',
+                    'ref' => 'master',
+                    'runner' => null,
+                    'stage' => 'deploy',
+                    'started_at' => null,
+                    'status' => 'success',
+                    'tag' => false,
+                    'user' => [
+                        'avatar_url' => 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+                        'bio' => null,
+                        'created_at' => '2016-08-11T07:09:20.351Z',
+                        'id' => 1,
+                        'linkedin' => '',
+                        'location' => null,
+                        'name' => 'Administrator',
+                        'skype' => '',
+                        'state' => 'active',
+                        'twitter' => '',
+                        'username' => 'root',
+                        'web_url' => 'http://localhost:3000/root',
+                        'website_url' => '',
+                    ],
+                ],
+                'environment' => [
+                    'external_url' => 'https://about.gitlab.com',
+                    'id' => 9,
+                    'name' => 'production',
+                ],
+                'id' => 42,
+                'iid' => 2,
+                'ref' => 'master',
+                'sha' => 'a91957a858320c0e17f3a0eca7cfacbff50ea29a',
+                'user' => [
+                    'avatar_url' => 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+                    'id' => 1,
+                    'name' => 'Administrator',
+                    'state' => 'active',
+                    'username' => 'root',
+                    'web_url' => 'http://localhost:3000/root',
+                ],
+            ],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/deployments/42')
+            ->willReturn($expectedArray);
+        $this->assertEquals($expectedArray, $api->show(1, 42));
+    }
+
+    private function getMultipleDeploymentsData(): array
+    {
+        return [
+            [
+                'created_at' => '2016-08-11T07:36:40.222Z',
+                'deployable' => [
+                    'commit' => [
+                        'author_email' => 'admin@example.com',
+                        'author_name' => 'Administrator',
+                        'created_at' => '2016-08-11T09:36:01.000+02:00',
+                        'id' => '99d03678b90d914dbb1b109132516d71a4a03ea8',
+                        'message' => 'Merge branch \'new-title\' into \'master\'
+
+Update README
+
+
+
+See merge request !1',
+                        'short_id' => '99d03678',
+                        'title' => 'Merge branch \'new-title\' into \'master\'
+',
+                    ],
+                    'coverage' => null,
+                    'created_at' => '2016-08-11T07:36:27.357Z',
+                    'finished_at' => '2016-08-11T07:36:39.851Z',
+                    'id' => 657,
+                    'name' => 'deploy',
+                    'ref' => 'master',
+                    'runner' => null,
+                    'stage' => 'deploy',
+                    'started_at' => null,
+                    'status' => 'success',
+                    'tag' => false,
+                    'user' => [
+                        'avatar_url' => 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+                        'bio' => null,
+                        'created_at' => '2016-08-11T07:09:20.351Z',
+                        'id' => 1,
+                        'linkedin' => '',
+                        'location' => null,
+                        'name' => 'Administrator',
+                        'skype' => '',
+                        'state' => 'active',
+                        'twitter' => '',
+                        'username' => 'root',
+                        'web_url' => 'http://localhost:3000/root',
+                        'website_url' => '',
+                    ],
+                ],
+                'environment' => [
+                    'external_url' => 'https://about.gitlab.com',
+                    'id' => 9,
+                    'name' => 'production',
+                ],
+                'id' => 41,
+                'iid' => 1,
+                'ref' => 'master',
+                'sha' => '99d03678b90d914dbb1b109132516d71a4a03ea8',
+                'user' => [
+                    'avatar_url' => 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+                    'id' => 1,
+                    'name' => 'Administrator',
+                    'state' => 'active',
+                    'username' => 'root',
+                    'web_url' => 'http://localhost:3000/root',
+                ],
+            ],
+            [
+                'created_at' => '2016-08-11T11:32:35.444Z',
+                'deployable' => [
+                    'commit' => [
+                        'author_email' => 'admin@example.com',
+                        'author_name' => 'Administrator',
+                        'created_at' => '2016-08-11T13:28:26.000+02:00',
+                        'id' => 'a91957a858320c0e17f3a0eca7cfacbff50ea29a',
+                        'message' => 'Merge branch \'rename-readme\' into \'master\'
+
+Rename README
+
+
+
+See merge request !2',
+                        'short_id' => 'a91957a8',
+                        'title' => 'Merge branch \'rename-readme\' into \'master\'
+',
+                    ],
+                    'coverage' => null,
+                    'created_at' => '2016-08-11T11:32:24.456Z',
+                    'finished_at' => '2016-08-11T11:32:35.145Z',
+                    'id' => 664,
+                    'name' => 'deploy',
+                    'ref' => 'master',
+                    'runner' => null,
+                    'stage' => 'deploy',
+                    'started_at' => null,
+                    'status' => 'success',
+                    'tag' => false,
+                    'user' => [
+                        'avatar_url' => 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+                        'bio' => null,
+                        'created_at' => '2016-08-11T07:09:20.351Z',
+                        'id' => 1,
+                        'linkedin' => '',
+                        'location' => null,
+                        'name' => 'Administrator',
+                        'skype' => '',
+                        'state' => 'active',
+                        'twitter' => '',
+                        'username' => 'root',
+                        'web_url' => 'http://localhost:3000/root',
+                        'website_url' => '',
+                    ],
+                ],
+                'environment' => [
+                    'external_url' => 'https://about.gitlab.com',
+                    'id' => 9,
+                    'name' => 'production',
+                ],
+                'id' => 42,
+                'iid' => 2,
+                'ref' => 'master',
+                'sha' => 'a91957a858320c0e17f3a0eca7cfacbff50ea29a',
+                'user' => [
+                    'avatar_url' => 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+                    'id' => 1,
+                    'name' => 'Administrator',
+                    'state' => 'active',
+                    'username' => 'root',
+                    'web_url' => 'http://localhost:3000/root',
+                ],
+            ],
+        ];
+    }
+
+    protected function getMultipleDeploymentsRequestMock(string $path, array $expectedArray, array $expectedParameters): MockObject
+    {
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with($path, $expectedParameters)
+            ->willReturn($expectedArray);
+
+        return $api;
+    }
+
+    #[Test]
+    public function shouldGetAllDeploymentsSortedByCreatedAt(): void
+    {
+        $expectedArray = $this->getMultipleDeploymentsData();
+
+        $api = $this->getMultipleDeploymentsRequestMock(
+            'projects/1/deployments',
+            $expectedArray,
+            ['page' => 1, 'per_page' => 5, 'order_by' => 'created_at', 'sort' => 'asc']
+        );
+
+        $this->assertEquals(
+            $expectedArray,
+            $api->all(1, ['page' => 1, 'per_page' => 5, 'order_by' => 'created_at', 'sort' => 'asc'])
+        );
+    }
+
+    protected function getApiClass(): string
+    {
+        return Deployments::class;
+    }
+
+    #[Test]
+    public function shouldAllowDeploymentFilterByStatus(): void
+    {
+        $expectedArray = $this->getMultipleDeploymentsData();
+
+        $api = $this->getMultipleDeploymentsRequestMock(
+            'projects/1/deployments',
+            $expectedArray,
+            ['status' => 'success']
+        );
+
+        $this->assertEquals(
+            $expectedArray,
+            $api->all(1, ['status' => 'success'])
+        );
+    }
+
+    #[Test]
+    public function shouldAllowFilterByEnvironment(): void
+    {
+        $expectedArray = $this->getMultipleDeploymentsData();
+
+        $api = $this->getMultipleDeploymentsRequestMock(
+            'projects/1/deployments',
+            $expectedArray,
+            ['environment' => 'production']
+        );
+
+        $this->assertEquals(
+            $expectedArray,
+            $api->all(1, ['environment' => 'production'])
+        );
+    }
+
+    #[Test]
+    public function shouldAllowEmptyArrayIfAllExcludedByFilter(): void
+    {
+        $expectedArray = $this->getMultipleDeploymentsData();
+
+        $api = $this->getMultipleDeploymentsRequestMock(
+            'projects/1/deployments',
+            [],
+            ['environment' => 'test']
+        );
+
+        $this->assertEquals([], $api->all(1, ['environment' => 'test'])
+        );
+    }
+}
diff --git a/tests/Api/EnvironmentsTest.php b/tests/Api/EnvironmentsTest.php
new file mode 100644
index 000000000..5af2c4adf
--- /dev/null
+++ b/tests/Api/EnvironmentsTest.php
@@ -0,0 +1,202 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Tests\Api;
+
+use Gitlab\Api\Environments;
+use PHPUnit\Framework\Attributes\Test;
+
+class EnvironmentsTest extends TestCase
+{
+    #[Test]
+    public function shouldGetAllEnvironments(): void
+    {
+        $expectedArray = [
+            [
+                'id' => 1,
+                'name' => 'review/fix-foo',
+                'slug' => 'review-fix-foo-dfjre3',
+                'external_url' => 'https://review-fix-foo-dfjre3.example.gitlab.com',
+            ],
+            [
+                'id' => 2,
+                'name' => 'review/fix-bar',
+                'slug' => 'review-fix-bar-dfjre4',
+                'external_url' => 'https://review-fix-bar-dfjre4.example.gitlab.com',
+            ],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/environments')
+            ->willReturn($expectedArray);
+        $this->assertEquals($expectedArray, $api->all(1));
+    }
+
+    #[Test]
+    public function shouldFilterEnvironmentByName(): void
+    {
+        $expected = [
+            [
+                'id' => 2,
+                'name' => 'review/fix-bar',
+                'slug' => 'review-fix-bar-dfjre4',
+                'external_url' => 'https://review-fix-bar-dfjre4.example.gitlab.com',
+            ],
+        ];
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/environments')
+            ->willReturn($expected);
+        $this->assertEquals($expected, $api->all(1, ['name' => 'review/fix-bar']));
+    }
+
+    #[Test]
+    public function shouldGetSingleEnvironment(): void
+    {
+        $expected = [
+            'id' => 1,
+            'name' => 'review/fix-foo',
+            'slug' => 'review-fix-foo-dfjre3',
+            'external_url' => 'https://review-fix-foo-dfjre3.example.gitlab.com',
+            'latest_deployment' => [
+                'created_at' => '2016-08-11T07:36:40.222Z',
+                'deployable' => [
+                    'commit' => [
+                        'author_email' => 'admin@example.com',
+                        'author_name' => 'Administrator',
+                        'created_at' => '2016-08-11T09:36:01.000+02:00',
+                        'id' => '99d03678b90d914dbb1b109132516d71a4a03ea8',
+                        'message' => 'Merge branch \'new-title\' into \'master\'
+
+Update README
+
+
+
+See merge request !1',
+                        'short_id' => '99d03678',
+                        'title' => 'Merge branch \'new-title\' into \'master\'
+',
+                    ],
+                    'coverage' => null,
+                    'created_at' => '2016-08-11T07:36:27.357Z',
+                    'finished_at' => '2016-08-11T07:36:39.851Z',
+                    'id' => 657,
+                    'name' => 'deploy',
+                    'ref' => 'master',
+                    'runner' => null,
+                    'stage' => 'deploy',
+                    'started_at' => null,
+                    'status' => 'success',
+                    'tag' => false,
+                    'user' => [
+                        'avatar_url' => 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+                        'bio' => null,
+                        'created_at' => '2016-08-11T07:09:20.351Z',
+                        'id' => 1,
+                        'linkedin' => '',
+                        'location' => null,
+                        'name' => 'Administrator',
+                        'skype' => '',
+                        'state' => 'active',
+                        'twitter' => '',
+                        'username' => 'root',
+                        'web_url' => 'http://localhost:3000/root',
+                        'website_url' => '',
+                    ],
+                ],
+                'id' => 41,
+                'iid' => 1,
+                'ref' => 'master',
+                'sha' => '99d03678b90d914dbb1b109132516d71a4a03ea8',
+                'user' => [
+                    'avatar_url' => 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+                    'id' => 1,
+                    'name' => 'Administrator',
+                    'state' => 'active',
+                    'username' => 'root',
+                    'web_url' => 'http://localhost:3000/root',
+                ],
+            ],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/environments/1')
+            ->willReturn($expected);
+        $this->assertEquals($expected, $api->show(1, 1));
+    }
+
+    #[Test]
+    public function shouldCreateEnvironment(): void
+    {
+        $expectedArray = [
+            [
+                'id' => 3,
+                'name' => 'review/fix-baz',
+                'slug' => 'review-fix-baz-dfjre5',
+                'external_url' => 'https://review-fix-baz-dfjre5.example.gitlab.com',
+                'tier' => 'production',
+            ],
+        ];
+
+        $params = [
+            'name' => 'review/fix-baz',
+            'external_url' => 'https://review-fix-baz-dfjre5.example.gitlab.com',
+            'tier' => 'production',
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/environments', $params)
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->create(1, $params));
+    }
+
+    #[Test]
+    public function shouldRemoveEnvironment(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('delete')
+            ->with('projects/1/environments/3')
+            ->willReturn($expectedBool);
+        $this->assertEquals($expectedBool, $api->remove(1, 3));
+    }
+
+    #[Test]
+    public function shouldStopEnvironment(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/environments/3/stop')
+            ->willReturn($expectedBool);
+        $this->assertEquals($expectedBool, $api->stop(1, 3));
+    }
+
+    protected function getApiClass(): string
+    {
+        return Environments::class;
+    }
+}
diff --git a/tests/Api/EventsTest.php b/tests/Api/EventsTest.php
new file mode 100644
index 000000000..fb39b9c84
--- /dev/null
+++ b/tests/Api/EventsTest.php
@@ -0,0 +1,61 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Niclas Hoyer <info@niclashoyer.de>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Tests\Api;
+
+use Gitlab\Api\Events;
+use PHPUnit\Framework\Attributes\Test;
+
+class EventsTest extends TestCase
+{
+    protected function getApiClass(): string
+    {
+        return Events::class;
+    }
+
+    #[Test]
+    public function shouldGetAllEvents(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'target_type' => 'Issue'],
+            ['id' => 2, 'target_type' => null],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('events', [])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->all());
+    }
+
+    #[Test]
+    public function shouldGetEventsAfter(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'target_type' => 'Issue'],
+            ['id' => 2, 'target_type' => null],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('events', ['after' => '1970-01-01'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->all(['after' => new \DateTime('1970-01-01')]));
+    }
+}
diff --git a/tests/Api/GroupBoardsTest.php b/tests/Api/GroupBoardsTest.php
new file mode 100644
index 000000000..4ebf53bf5
--- /dev/null
+++ b/tests/Api/GroupBoardsTest.php
@@ -0,0 +1,225 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Tests\Api;
+
+use Gitlab\Api\GroupsBoards;
+use PHPUnit\Framework\Attributes\Test;
+
+class GroupBoardsTest extends TestCase
+{
+    #[Test]
+    public function shouldGetAllBoards(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'title' => 'A board'],
+            ['id' => 2, 'title' => 'Another board'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('boards', [])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->all());
+    }
+
+    #[Test]
+    public function shouldShowIssueBoard(): void
+    {
+        $expectedArray = ['id' => 2, 'name' => 'Another issue board'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('groups/1/boards/2')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->show(1, 2));
+    }
+
+    #[Test]
+    public function shouldCreateIssueBoard(): void
+    {
+        $expectedArray = ['id' => 3, 'name' => 'A new issue board'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('groups/1/boards', ['name' => 'A new issue board'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->create(1, ['name' => 'A new issue board']));
+    }
+
+    #[Test]
+    public function shouldUpdateIssueBoard(): void
+    {
+        $expectedArray = ['id' => 2, 'name' => 'A renamed issue board'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('put')
+            ->with('groups/1/boards/2', ['name' => 'A renamed issue board', 'labels' => 'foo'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->update(1, 2, ['name' => 'A renamed issue board', 'labels' => 'foo']));
+    }
+
+    #[Test]
+    public function shouldRemoveIssueBoard(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('delete')
+            ->with('groups/1/boards/2')
+            ->willReturn($expectedBool);
+
+        $this->assertEquals($expectedBool, $api->remove(1, 2));
+    }
+
+    #[Test]
+    public function shouldGetAllLists(): void
+    {
+        $expectedArray = [
+            [
+                'id' => 1,
+                'label' => [
+                    'name' => 'First label',
+                    'color' => '#F0AD4E',
+                    'description' => null,
+                ],
+                'position' => 1,
+            ], [
+                'id' => 2,
+                'label' => [
+                    'name' => 'Second label',
+                    'color' => '#F0AD4E',
+                    'description' => null,
+                ],
+                'position' => 2,
+            ],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('groups/1/boards/2/lists')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->allLists(1, 2));
+    }
+
+    #[Test]
+    public function shouldGetList(): void
+    {
+        $expectedArray = [
+            [
+                'id' => 3,
+                'label' => [
+                    'name' => 'Some label',
+                    'color' => '#F0AD4E',
+                    'description' => null,
+                ],
+                'position' => 3,
+            ],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('groups/1/boards/2/lists/3')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->showList(1, 2, 3));
+    }
+
+    #[Test]
+    public function shouldCreateList(): void
+    {
+        $expectedArray = [
+            [
+                'id' => 3,
+                'label' => [
+                    'name' => 'Some label',
+                    'color' => '#F0AD4E',
+                    'description' => null,
+                ],
+                'position' => 3,
+            ],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('groups/1/boards/2/lists', ['label_id' => 4])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->createList(1, 2, 4));
+    }
+
+    #[Test]
+    public function shouldUpdateList(): void
+    {
+        $expectedArray = [
+            [
+                'id' => 3,
+                'label' => [
+                    'name' => 'Some label',
+                    'color' => '#F0AD4E',
+                    'description' => null,
+                ],
+                'position' => 1,
+            ],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('put')
+            ->with('groups/5/boards/2/lists/3', ['position' => 1])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->updateList(5, 2, 3, 1));
+    }
+
+    #[Test]
+    public function shouldDeleteList(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('delete')
+            ->with('groups/1/boards/2/lists/3')
+            ->willReturn($expectedBool);
+
+        $this->assertEquals($expectedBool, $api->deleteList(1, 2, 3));
+    }
+
+    protected function getApiClass(): string
+    {
+        return GroupsBoards::class;
+    }
+}
diff --git a/tests/Api/GroupsEpicsTest.php b/tests/Api/GroupsEpicsTest.php
new file mode 100644
index 000000000..8ba51f1ba
--- /dev/null
+++ b/tests/Api/GroupsEpicsTest.php
@@ -0,0 +1,121 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Tests\Api;
+
+use Gitlab\Api\GroupsEpics;
+use PHPUnit\Framework\Attributes\Test;
+
+class GroupsEpicsTest extends TestCase
+{
+    #[Test]
+    public function shouldGetAllEpics(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'title' => 'A epic'],
+            ['id' => 2, 'title' => 'Another epic'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('groups/1/epics')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->all(1));
+    }
+
+    #[Test]
+    public function shouldShowEpic(): void
+    {
+        $expectedArray = ['id' => 1, 'name' => 'A epic'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('groups/1/epics/2')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->show(1, 2));
+    }
+
+    #[Test]
+    public function shouldCreateEpic(): void
+    {
+        $expectedArray = ['id' => 3, 'title' => 'A new epic'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('groups/1/epics', ['description' => 'Some text', 'title' => 'A new epic'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->create(1, ['description' => 'Some text', 'title' => 'A new epic']));
+    }
+
+    #[Test]
+    public function shouldUpdateEpic(): void
+    {
+        $expectedArray = ['id' => 3, 'title' => 'Updated epic'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('put')
+            ->with('groups/1/epics/3', ['title' => 'Updated epic', 'description' => 'Updated description', 'state_event' => 'close'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->update(1, 3, ['title' => 'Updated epic', 'description' => 'Updated description', 'state_event' => 'close']));
+    }
+
+    #[Test]
+    public function shouldRemoveEpic(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('delete')
+            ->with('groups/1/epics/2')
+            ->willReturn($expectedBool);
+
+        $this->assertEquals($expectedBool, $api->remove(1, 2));
+    }
+
+    #[Test]
+    public function shouldGetEpicsIssues(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'title' => 'An issue'],
+            ['id' => 2, 'title' => 'Another issue'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('groups/1/epics/2/issues')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->issues(1, 2));
+    }
+
+    protected function getApiClass(): string
+    {
+        return GroupsEpics::class;
+    }
+}
diff --git a/tests/Api/GroupsMilestonesTest.php b/tests/Api/GroupsMilestonesTest.php
new file mode 100644
index 000000000..963761170
--- /dev/null
+++ b/tests/Api/GroupsMilestonesTest.php
@@ -0,0 +1,227 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Tests\Api;
+
+use Gitlab\Api\GroupsMilestones;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\Test;
+
+class GroupsMilestonesTest extends TestCase
+{
+    #[Test]
+    public function shouldGetAllMilestones(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'title' => 'A milestone'],
+            ['id' => 2, 'title' => 'Another milestone'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('groups/1/milestones')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->all(1));
+    }
+
+    #[Test]
+    public function shouldGetAllMilestonesWithParameterOneIidsValue(): void
+    {
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('groups/1/milestones', ['iids' => [456]])
+        ;
+
+        $api->all(1, ['iids' => [456]]);
+    }
+
+    #[Test]
+    public function shouldGetAllMilestonesWithParameterTwoIidsValues(): void
+    {
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('groups/1/milestones', ['iids' => [456, 789]])
+        ;
+
+        $api->all(1, ['iids' => [456, 789]]);
+    }
+
+    public static function getAllMilestonesWithParameterStateDataProvider(): array
+    {
+        return [
+            GroupsMilestones::STATE_ACTIVE => [GroupsMilestones::STATE_ACTIVE],
+            GroupsMilestones::STATE_CLOSED => [GroupsMilestones::STATE_CLOSED],
+        ];
+    }
+
+    #[Test]
+    #[DataProvider('getAllMilestonesWithParameterStateDataProvider')]
+    public function shouldGetAllMilestonesWithParameterState(string $state): void
+    {
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('groups/1/milestones', ['state' => $state])
+        ;
+
+        $api->all(1, ['state' => $state]);
+    }
+
+    #[Test]
+    public function shouldGetAllMilestonesWithParameterSearch(): void
+    {
+        $searchValue = 'abc';
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('groups/1/milestones', ['search' => $searchValue])
+        ;
+
+        $api->all(1, ['search' => $searchValue]);
+    }
+
+    #[Test]
+    public function shouldGetAllMilestonesWithParameterUpdatedBefore(): void
+    {
+        $updatedBefore = new \DateTimeImmutable('2023-11-25T08:00:00Z');
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('groups/1/milestones', ['updated_before' => '2023-11-25T08:00:00.000Z'])
+        ;
+
+        $api->all(1, ['updated_before' => $updatedBefore]);
+    }
+
+    #[Test]
+    public function shouldGetAllMilestonesWithParameterUpdatedAfter(): void
+    {
+        $updatedAfter = new \DateTimeImmutable('2023-11-25T08:00:00Z');
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('groups/1/milestones', ['updated_after' => '2023-11-25T08:00:00.000Z'])
+        ;
+
+        $api->all(1, ['updated_after' => $updatedAfter]);
+    }
+
+    #[Test]
+    public function shouldShowMilestone(): void
+    {
+        $expectedArray = ['id' => 1, 'name' => 'A milestone'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('groups/1/milestones/2')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->show(1, 2));
+    }
+
+    #[Test]
+    public function shouldCreateMilestone(): void
+    {
+        $expectedArray = ['id' => 3, 'title' => 'A new milestone'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('groups/1/milestones', ['description' => 'Some text', 'title' => 'A new milestone'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->create(1, ['description' => 'Some text', 'title' => 'A new milestone']));
+    }
+
+    #[Test]
+    public function shouldUpdateMilestone(): void
+    {
+        $expectedArray = ['id' => 3, 'title' => 'Updated milestone'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('put')
+            ->with('groups/1/milestones/3', ['title' => 'Updated milestone', 'due_date' => '2015-04-01', 'state_event' => 'close'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->update(1, 3, ['title' => 'Updated milestone', 'due_date' => '2015-04-01', 'state_event' => 'close']));
+    }
+
+    #[Test]
+    public function shouldRemoveMilestone(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('delete')
+            ->with('groups/1/milestones/2')
+            ->willReturn($expectedBool);
+
+        $this->assertEquals($expectedBool, $api->remove(1, 2));
+    }
+
+    #[Test]
+    public function shouldGetMilestonesIssues(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'title' => 'An issue'],
+            ['id' => 2, 'title' => 'Another issue'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('groups/1/milestones/3/issues')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->issues(1, 3));
+    }
+
+    #[Test]
+    public function shouldGetMilestonesMergeRequests(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'title' => 'A merge request'],
+            ['id' => 2, 'title' => 'Another merge request'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('groups/1/milestones/3/merge_requests')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->mergeRequests(1, 3));
+    }
+
+    protected function getApiClass(): string
+    {
+        return GroupsMilestones::class;
+    }
+}
diff --git a/tests/Api/GroupsTest.php b/tests/Api/GroupsTest.php
new file mode 100644
index 000000000..3b90e6f2f
--- /dev/null
+++ b/tests/Api/GroupsTest.php
@@ -0,0 +1,885 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Tests\Api;
+
+use DateTime;
+use Gitlab\Api\Groups;
+use PHPUnit\Framework\Attributes\Test;
+
+class GroupsTest extends TestCase
+{
+    #[Test]
+    public function shouldGetAllGroups(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'name' => 'A group'],
+            ['id' => 2, 'name' => 'Another group'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('groups', ['page' => 1, 'per_page' => 10])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->all(['page' => 1, 'per_page' => 10]));
+    }
+
+    #[Test]
+    public function shouldGetAllGroupsWithBooleanParam(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'name' => 'A group'],
+            ['id' => 2, 'name' => 'Another group'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('groups', ['all_available' => 'false'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->all(['all_available' => false]));
+    }
+
+    #[Test]
+    public function shouldGetAllTopLevelGroupsWithoutSubgroups(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'name' => 'A group'],
+            ['id' => 2, 'name' => 'Another group'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('groups', ['top_level_only' => 'true'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->all(['top_level_only' => true]));
+    }
+
+    #[Test]
+    public function shouldGetAllGroupProjectsWithBooleanParam(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'name' => 'A group'],
+            ['id' => 2, 'name' => 'Another group'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('groups/1/projects', ['archived' => 'false'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->projects(1, ['archived' => false]));
+    }
+
+    #[Test]
+    public function shouldNotNeedPaginationWhenGettingGroups(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'name' => 'A group'],
+            ['id' => 2, 'name' => 'Another group'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('groups', [])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->all());
+    }
+
+    #[Test]
+    public function shouldShowGroup(): void
+    {
+        $expectedArray = ['id' => 1, 'name' => 'A group'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('groups/1')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->show(1));
+    }
+
+    #[Test]
+    public function shouldCreateGroup(): void
+    {
+        $expectedArray = ['id' => 1, 'name' => 'A new group'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('groups', ['name' => 'A new group', 'path' => 'a-new-group', 'visibility' => 'private'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->create('A new group', 'a-new-group'));
+    }
+
+    #[Test]
+    public function shouldCreateGroupWithDescriptionAndVisibility(): void
+    {
+        $expectedArray = ['id' => 1, 'name' => 'A new group', 'visibility_level' => 2];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('groups', ['name' => 'A new group', 'path' => 'a-new-group', 'description' => 'Description', 'visibility' => 'public'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->create('A new group', 'a-new-group', 'Description', 'public'));
+    }
+
+    #[Test]
+    public function shouldCreateGroupWithDescriptionVisibilityAndParentId(): void
+    {
+        $expectedArray = ['id' => 1, 'name' => 'A new group', 'visibility_level' => 2, 'parent_id' => 666];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('groups', ['name' => 'A new group', 'path' => 'a-new-group', 'description' => 'Description', 'visibility' => 'public', 'parent_id' => 666])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->create('A new group', 'a-new-group', 'Description', 'public', null, null, 666));
+    }
+
+    #[Test]
+    public function shouldUpdateGroup(): void
+    {
+        $expectedArray = ['id' => 3, 'name' => 'Group name', 'path' => 'group-path'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('put')
+            ->with('groups/3', ['name' => 'Group name', 'path' => 'group-path'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->update(3, ['name' => 'Group name', 'path' => 'group-path']));
+    }
+
+    #[Test]
+    public function shouldTransferProjectToGroup(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('groups/1/projects/2')
+            ->willReturn($expectedBool);
+
+        $this->assertEquals($expectedBool, $api->transfer(1, 2));
+    }
+
+    #[Test]
+    public function shouldGetAllMembers(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'name' => 'Matt'],
+            ['id' => 2, 'name' => 'Bob'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('groups/1/members/all')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->allMembers(1));
+    }
+
+    #[Test]
+    public function shouldGetAllMember(): void
+    {
+        $expectedArray = ['id' => 2, 'name' => 'Bob'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('groups/1/members/all/2')
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->allMember(1, 2));
+    }
+
+    #[Test]
+    public function shouldGetMembers(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'name' => 'Matt'],
+            ['id' => 2, 'name' => 'Bob'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('groups/1/members')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->members(1));
+    }
+
+    #[Test]
+    public function shouldAddMember(): void
+    {
+        $tomorrow = (new DateTime('tomorrow'));
+        $expectedArray = ['id' => 1, 'name' => 'Matt', 'expires_at' => $tomorrow];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('groups/1/members', [
+                'user_id' => 2, 'access_level' => 10, 'expires_at' => $tomorrow->format('Y-m-d'),
+            ])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->addMember(1, 2, 10, ['expires_at' => $tomorrow]));
+    }
+
+    #[Test]
+    public function shouldSaveMember(): void
+    {
+        $expectedArray = ['id' => 1, 'name' => 'Matt'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('put')
+            ->with('groups/1/members/2', ['access_level' => 4])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->saveMember(1, 2, 4));
+    }
+
+    #[Test]
+    public function shouldRemoveMember(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('delete')
+            ->with('groups/1/members/2')
+            ->willReturn($expectedBool);
+
+        $this->assertEquals($expectedBool, $api->removeMember(1, 2));
+    }
+
+    #[Test]
+    public function shouldRemoveGroup(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('delete')
+            ->with('groups/1')
+            ->willReturn($expectedBool);
+
+        $this->assertEquals($expectedBool, $api->remove(1));
+    }
+
+    #[Test]
+    public function shouldGetAllSubgroups(): void
+    {
+        $expectedArray = [
+            ['id' => 101, 'name' => 'A subgroup'],
+            ['id' => 1 - 2, 'name' => 'Another subggroup'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('groups/1/subgroups', ['page' => 1, 'per_page' => 10])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->subgroups(1, ['page' => 1, 'per_page' => 10]));
+    }
+
+    #[Test]
+    public function shouldGetAllIssues(): void
+    {
+        $expectedArray = [
+            ['id' => 101, 'name' => 'An issue'],
+            ['id' => 102, 'name' => 'Another issue'],
+            ['id' => 103, 'name' => 'A third issue'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('groups/1/issues', ['page' => 1, 'per_page' => 10])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->issues(1, ['page' => 1, 'per_page' => 10]));
+    }
+
+    #[Test]
+    public function shouldGetLabels(): void
+    {
+        $expectedArray = [
+            ['id' => 987, 'name' => 'bug', 'color' => '#000000'],
+            ['id' => 123, 'name' => 'feature', 'color' => '#ff0000'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('groups/1/labels')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->labels(1));
+    }
+
+    #[Test]
+    public function shouldAddLabel(): void
+    {
+        $expectedArray = ['name' => 'bug', 'color' => '#000000'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('groups/1/labels', ['name' => 'wont-fix', 'color' => '#ffffff'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->addLabel(1, ['name' => 'wont-fix', 'color' => '#ffffff']));
+    }
+
+    #[Test]
+    public function shouldUpdateLabel(): void
+    {
+        $expectedArray = ['name' => 'bug', 'color' => '#00ffff'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('put')
+            ->with('groups/1/labels/123', ['new_name' => 'big-bug', 'color' => '#00ffff'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->updateLabel(1, 123, ['new_name' => 'big-bug', 'color' => '#00ffff']));
+    }
+
+    #[Test]
+    public function shouldRemoveLabel(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('delete')
+            ->with('groups/1/labels/456', [])
+            ->willReturn($expectedBool);
+
+        $this->assertEquals($expectedBool, $api->removeLabel(1, 456));
+    }
+
+    public function shouldGetVariables(): void
+    {
+        $expectedArray = [
+            ['key' => 'ftp_username', 'value' => 'ftp'],
+            ['key' => 'ftp_password', 'value' => 'somepassword'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('groups/1/variables')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->variables(1));
+    }
+
+    #[Test]
+    public function shouldGetVariable(): void
+    {
+        $expectedArray = ['key' => 'ftp_username', 'value' => 'ftp'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('groups/1/variables/ftp_username')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->variable(1, 'ftp_username'));
+    }
+
+    public function shouldAddVariable(): void
+    {
+        $expectedKey = 'ftp_port';
+        $expectedValue = '21';
+
+        $expectedArray = [
+            'key' => $expectedKey,
+            'value' => $expectedValue,
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('groups/1/variables', $expectedArray)
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->addVariable(1, $expectedKey, $expectedValue));
+    }
+
+    #[Test]
+    public function shouldAddVariableWithProtected(): void
+    {
+        $expectedArray = [
+            'key' => 'DEPLOY_SERVER',
+            'value' => 'stage.example.com',
+            'protected' => true,
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('groups/1/variables', $expectedArray)
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->addVariable(1, 'DEPLOY_SERVER', 'stage.example.com', true));
+    }
+
+    #[Test]
+    public function shouldUpdateVariable(): void
+    {
+        $expectedKey = 'ftp_port';
+        $expectedValue = '22';
+
+        $expectedArray = [
+            'key' => 'ftp_port',
+            'value' => '22',
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('put')
+            ->with('groups/1/variables/'.$expectedKey, ['value' => $expectedValue])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->updateVariable(1, $expectedKey, $expectedValue));
+    }
+
+    #[Test]
+    public function shouldUpdateVariableWithProtected(): void
+    {
+        $expectedArray = [
+            'key' => 'DEPLOY_SERVER',
+            'value' => 'stage.example.com',
+            'protected' => true,
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('put')
+            ->with('groups/1/variables/DEPLOY_SERVER', ['value' => 'stage.example.com', 'protected' => true])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->updateVariable(1, 'DEPLOY_SERVER', 'stage.example.com', true));
+    }
+
+    #[Test]
+    public function shouldRemoveVariable(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('delete')
+            ->with('groups/1/variables/ftp_password')
+            ->willReturn($expectedBool);
+
+        $this->assertEquals($expectedBool, $api->removeVariable(1, 'ftp_password'));
+    }
+
+    protected function getApiClass(): string
+    {
+        return Groups::class;
+    }
+
+    #[Test]
+    public function shouldGetAllGroupProjectsWithIssuesEnabled(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'name' => 'A group', 'issues_enabled' => true],
+            ['id' => 2, 'name' => 'Another group', 'issues_enabled' => true],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('groups/1/projects', ['with_issues_enabled' => 'true'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->projects(1, ['with_issues_enabled' => true]));
+    }
+
+    #[Test]
+    public function shouldGetAllGroupProjectsWithMergeRequestsEnabled(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'name' => 'A group', 'merge_requests_enabled' => true],
+            ['id' => 2, 'name' => 'Another group', 'merge_requests_enabled' => true],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('groups/1/projects', ['with_merge_requests_enabled' => 'true'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->projects(1, ['with_merge_requests_enabled' => true]));
+    }
+
+    #[Test]
+    public function shouldGetAllGroupProjectsSharedToGroup(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'name' => 'A project', 'shared_with_groups' => [1]],
+            ['id' => 2, 'name' => 'Another project', 'shared_with_groups' => [1]],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('groups/1/projects', ['with_shared' => 'true'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->projects(1, ['with_shared' => true]));
+    }
+
+    #[Test]
+    public function shouldGetAllGroupProjectsIncludingSubsgroups(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'name' => 'A project'],
+            ['id' => 2, 'name' => 'Another project', 'shared_with_groups' => [1]],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('groups/1/projects', ['include_subgroups' => 'true'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->projects(1, ['include_subgroups' => true]));
+    }
+
+    #[Test]
+    public function shouldGetAllGroupProjectsIncludingCustomAttributes(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'name' => 'A project', 'custom_Attr' => true],
+            ['id' => 2, 'name' => 'Another project', 'custom_Attr' => true],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('groups/1/projects', ['with_custom_attributes' => 'true'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->projects(1, ['with_custom_attributes' => true]));
+    }
+
+    #[Test]
+    public function shouldGetIterations(): void
+    {
+        $expectedArray = [
+            [
+                'id' => 5,
+                'iid' => 2,
+                'sequence' => 1,
+                'group_id' => 123,
+                'title' => '2022: Sprint 1',
+                'description' => '',
+                'state' => 3,
+                'created_at' => '2021-09-29T21:24:43.913Z',
+                'updated_at' => '2022-03-29T19:09:08.368Z',
+                'start_date' => '2022-01-10',
+                'due_date' => '2022-01-23',
+                'web_url' => 'https://example.com/groups/example/-/iterations/34',
+            ],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('groups/1/iterations')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->iterations(1));
+    }
+
+    #[Test]
+    public function shouldGetPackages(): void
+    {
+        $expectedArray = [
+            'id' => 2,
+            'name' => '@foo/bar',
+            'version' => '1.0.3',
+            'package_type' => 'npm',
+            '_links' => [
+                'web_path' => '/namespace1/project1/-/packages/1',
+                'delete_api_path' => '/namespace1/project1/-/packages/1',
+            ],
+            'created_at' => '2019-11-27T03:37:38.711Z',
+            'pipelines' => [
+                'id' => 123,
+                'status' => 'pending',
+                'ref' => 'new-pipeline',
+                'sha' => 'a91957a858320c0e17f3a0eca7cfacbff50ea29a',
+                'web_url' => 'https://example.com/foo/bar/pipelines/47',
+                'created_at' => '2016-08-11T11:28:34.085Z',
+                'updated_at' => '2016-08-11T11:32:35.169Z',
+                'user' => [
+                    'name' => 'Administrator',
+                    'avatar_url' => 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+                ],
+            ],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('groups/1/packages')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->packages(1));
+    }
+
+    #[Test]
+    public function shouldGetGroupMergeRequests(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'title' => 'A merge request'],
+            ['id' => 2, 'title' => 'Another merge request'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('groups/1/merge_requests')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->mergeRequests(1, []));
+    }
+
+    #[Test]
+    public function shouldGetDeployTokens(): void
+    {
+        $expectedArray = [
+            [
+                'id' => 1,
+                'name' => 'MyToken',
+                'username' => 'gitlab+deploy-token-1',
+                'expires_at' => '2020-02-14T00:00:00.000Z',
+                'revoked' => false,
+                'expired' => false,
+                'scopes' => [
+                    'read_repository',
+                    'read_registry',
+                ],
+            ],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('groups/1/deploy_tokens')
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->deployTokens(1));
+    }
+
+    #[Test]
+    public function shouldGetActiveDeployTokens(): void
+    {
+        $expectedArray = [
+            [
+                'id' => 1,
+                'name' => 'MyToken',
+                'username' => 'gitlab+deploy-token-1',
+                'expires_at' => '2020-02-14T00:00:00.000Z',
+                'revoked' => false,
+                'expired' => true,
+                'scopes' => [
+                    'read_repository',
+                    'read_registry',
+                ],
+            ],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('groups/1/deploy_tokens', ['active' => true])
+            ->willReturn([]);
+
+        $this->assertEquals([], $api->deployTokens(1, true));
+    }
+
+    #[Test]
+    public function shouldGetInactiveDeployTokens(): void
+    {
+        $expectedArray = [
+            [
+                'id' => 1,
+                'name' => 'MyToken',
+                'username' => 'gitlab+deploy-token-1',
+                'expires_at' => '2020-02-14T00:00:00.000Z',
+                'revoked' => false,
+                'expired' => true,
+                'scopes' => [
+                    'read_repository',
+                    'read_registry',
+                ],
+            ],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('groups/1/deploy_tokens', ['active' => false])
+            ->willReturn([]);
+
+        $this->assertEquals([], $api->deployTokens(1, false));
+    }
+
+    #[Test]
+    public function shouldCreateDeployToken(): void
+    {
+        $expectedArray = [
+            'id' => 1,
+            'name' => 'My Deploy Token',
+            'username' => 'custom-user',
+            'token' => 'jMRvtPNxrn3crTAGukpZ',
+            'expires_at' => '2021-01-01T00:00:00.000Z',
+            'revoked' => false,
+            'expired' => false,
+            'scopes' => [
+                'read_repository',
+                'read_registry',
+            ],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with(
+                'groups/1/deploy_tokens',
+                [
+                    'name' => 'My Deploy Token',
+                    'scopes' => [
+                        'read_repository',
+                        'read_registry',
+                    ],
+                    'expires_at' => (new DateTime('2021-01-01'))->format('c'),
+                ]
+            )
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->createDeployToken(1, [
+            'name' => 'My Deploy Token',
+            'scopes' => [
+                'read_repository',
+                'read_registry',
+            ],
+            'expires_at' => new DateTime('2021-01-01'),
+        ]));
+    }
+
+    #[Test]
+    public function shouldDeleteDeployToken(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('delete')
+            ->with('groups/1/deploy_tokens/2')
+            ->willReturn($expectedBool);
+
+        $this->assertEquals($expectedBool, $api->deleteDeployToken(1, 2));
+    }
+
+    #[Test]
+    public function shouldSearchGroups(): void
+    {
+        $expectedArray = [
+            ['id' => 6, 'name' => 'Project 6 bla'],
+            ['id' => 7, 'name' => 'Project 7 bla'],
+            ['id' => 8, 'name' => 'Project 8 bla'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('groups/123/search', [
+                'scope' => 'projects',
+                'confidential' => 'false',
+                'search' => 'bla',
+                'order_by' => 'created_at',
+                'sort' => 'desc',
+            ])
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->search(123, [
+            'scope' => 'projects',
+            'confidential' => false,
+            'search' => 'bla',
+            'order_by' => 'created_at',
+            'sort' => 'desc',
+        ]));
+    }
+}
diff --git a/tests/Api/IssueBoardsTest.php b/tests/Api/IssueBoardsTest.php
new file mode 100644
index 000000000..45f83fb17
--- /dev/null
+++ b/tests/Api/IssueBoardsTest.php
@@ -0,0 +1,225 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Tests\Api;
+
+use Gitlab\Api\IssueBoards;
+use PHPUnit\Framework\Attributes\Test;
+
+class IssueBoardsTest extends TestCase
+{
+    #[Test]
+    public function shouldGetAllBoards(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'title' => 'A board'],
+            ['id' => 2, 'title' => 'Another board'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('boards', [])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->all());
+    }
+
+    #[Test]
+    public function shouldShowIssueBoard(): void
+    {
+        $expectedArray = ['id' => 2, 'name' => 'Another issue board'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/boards/2')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->show(1, 2));
+    }
+
+    #[Test]
+    public function shouldCreateIssueBoard(): void
+    {
+        $expectedArray = ['id' => 3, 'name' => 'A new issue board'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/boards', ['name' => 'A new issue board'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->create(1, ['name' => 'A new issue board']));
+    }
+
+    #[Test]
+    public function shouldUpdateIssueBoard(): void
+    {
+        $expectedArray = ['id' => 2, 'name' => 'A renamed issue board'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('put')
+            ->with('projects/1/boards/2', ['name' => 'A renamed issue board', 'labels' => 'foo'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->update(1, 2, ['name' => 'A renamed issue board', 'labels' => 'foo']));
+    }
+
+    #[Test]
+    public function shouldRemoveIssueBoard(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('delete')
+            ->with('projects/1/boards/2')
+            ->willReturn($expectedBool);
+
+        $this->assertEquals($expectedBool, $api->remove(1, 2));
+    }
+
+    #[Test]
+    public function shouldGetAllLists(): void
+    {
+        $expectedArray = [
+            [
+                'id' => 1,
+                'label' => [
+                    'name' => 'First label',
+                    'color' => '#F0AD4E',
+                    'description' => null,
+                ],
+                'position' => 1,
+            ], [
+                'id' => 2,
+                'label' => [
+                    'name' => 'Second label',
+                    'color' => '#F0AD4E',
+                    'description' => null,
+                ],
+                'position' => 2,
+            ],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/boards/2/lists')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->allLists(1, 2));
+    }
+
+    #[Test]
+    public function shouldGetList(): void
+    {
+        $expectedArray = [
+            [
+                'id' => 3,
+                'label' => [
+                    'name' => 'Some label',
+                    'color' => '#F0AD4E',
+                    'description' => null,
+                ],
+                'position' => 3,
+            ],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/boards/2/lists/3')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->showList(1, 2, 3));
+    }
+
+    #[Test]
+    public function shouldCreateList(): void
+    {
+        $expectedArray = [
+            [
+                'id' => 3,
+                'label' => [
+                    'name' => 'Some label',
+                    'color' => '#F0AD4E',
+                    'description' => null,
+                ],
+                'position' => 3,
+            ],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/boards/2/lists', ['label_id' => 4])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->createList(1, 2, 4));
+    }
+
+    #[Test]
+    public function shouldUpdateList(): void
+    {
+        $expectedArray = [
+            [
+                'id' => 3,
+                'label' => [
+                    'name' => 'Some label',
+                    'color' => '#F0AD4E',
+                    'description' => null,
+                ],
+                'position' => 1,
+            ],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('put')
+            ->with('projects/5/boards/2/lists/3', ['position' => 1])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->updateList(5, 2, 3, 1));
+    }
+
+    #[Test]
+    public function shouldDeleteList(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('delete')
+            ->with('projects/1/boards/2/lists/3')
+            ->willReturn($expectedBool);
+
+        $this->assertEquals($expectedBool, $api->deleteList(1, 2, 3));
+    }
+
+    protected function getApiClass(): string
+    {
+        return IssueBoards::class;
+    }
+}
diff --git a/tests/Api/IssueLinksTest.php b/tests/Api/IssueLinksTest.php
new file mode 100644
index 000000000..ed400ad7f
--- /dev/null
+++ b/tests/Api/IssueLinksTest.php
@@ -0,0 +1,83 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Tests\Api;
+
+use Gitlab\Api\IssueLinks;
+use PHPUnit\Framework\Attributes\Test;
+
+class IssueLinksTest extends TestCase
+{
+    /**
+     * {@inheritdoc}
+     */
+    protected function getApiClass(): string
+    {
+        return IssueLinks::class;
+    }
+
+    #[Test]
+    public function shouldGetIssueLinks(): void
+    {
+        $expectedArray = [
+            ['issue_link_id' => 100],
+            ['issue_link_id' => 101],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/issues/10/links')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->all(1, 10));
+    }
+
+    #[Test]
+    public function shouldCreateIssueLink(): void
+    {
+        $expectedArray = [
+            'source_issue' => ['iid' => 10, 'project_id' => 1],
+            'target_issue' => ['iid' => 20, 'project_id' => 2],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/issues/10/links', ['target_project_id' => 2, 'target_issue_iid' => 20])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->create(1, 10, 2, 20));
+    }
+
+    #[Test]
+    public function shouldRemoveIssueLink(): void
+    {
+        $expectedArray = [
+            'source_issue' => ['iid' => 10, 'project_id' => 1],
+            'target_issue' => ['iid' => 20, 'project_id' => 2],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('delete')
+            ->with('projects/1/issues/10/links/100')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->remove(1, 10, 100));
+    }
+}
diff --git a/tests/Api/IssueSubscribeTest.php b/tests/Api/IssueSubscribeTest.php
new file mode 100644
index 000000000..140a83130
--- /dev/null
+++ b/tests/Api/IssueSubscribeTest.php
@@ -0,0 +1,52 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Tests\Api;
+
+use Gitlab\Api\Issues;
+
+/**
+ * @method Issues|\PHPUnit_Framework_MockObject_MockObject getApiMock()
+ */
+class IssueSubscribeTest extends TestCase
+{
+    public function testSubscribeIssue(): void
+    {
+        $expectedValue = '';
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/issues/2/subscribe')
+            ->willReturn($expectedValue);
+
+        $this->assertEquals($expectedValue, $api->subscribe(1, 2));
+    }
+
+    public function testUnsubscribeIssue(): void
+    {
+        $expectedValue = '';
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/issues/2/unsubscribe')
+            ->willReturn($expectedValue);
+
+        $this->assertEquals($expectedValue, $api->unsubscribe(1, 2));
+    }
+
+    protected function getApiClass(): string
+    {
+        return Issues::class;
+    }
+}
diff --git a/tests/Api/IssuesStatisticsTest.php b/tests/Api/IssuesStatisticsTest.php
new file mode 100644
index 000000000..1b83cdb92
--- /dev/null
+++ b/tests/Api/IssuesStatisticsTest.php
@@ -0,0 +1,100 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Tests\Api;
+
+use Gitlab\Api\IssuesStatistics;
+use PHPUnit\Framework\Attributes\Test;
+
+class IssuesStatisticsTest extends TestCase
+{
+    #[Test]
+    public function shouldGetAll(): void
+    {
+        $expectedArray = [];
+
+        $now = new \DateTime();
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('issues_statistics', [
+                'milestone' => '',
+                'labels' => '',
+                'scope' => 'created-by-me',
+                'author_id' => 1,
+                'author_username' => '',
+                'assignee_id' => 1,
+                'assignee_username' => '',
+                'my_reaction_emoji' => '',
+                'search' => '',
+                'created_after' => $now->format('c'),
+                'created_before' => $now->format('c'),
+                'updated_after' => $now->format('c'),
+                'updated_before' => $now->format('c'),
+                'confidential' => 'false',
+            ])
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->all([
+            'milestone' => '',
+            'labels' => '',
+            'scope' => 'created-by-me',
+            'author_id' => 1,
+            'author_username' => '',
+            'assignee_id' => 1,
+            'assignee_username' => '',
+            'my_reaction_emoji' => '',
+            'search' => '',
+            'created_after' => $now,
+            'created_before' => $now,
+            'updated_after' => $now,
+            'updated_before' => $now,
+            'confidential' => false,
+        ]));
+    }
+
+    #[Test]
+    public function shouldGetProject(): void
+    {
+        $expectedArray = [];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/issues_statistics', [])
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->project(1, []));
+    }
+
+    #[Test]
+    public function shouldGetGroup(): void
+    {
+        $expectedArray = [];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('groups/1/issues_statistics', [])
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->group(1, []));
+    }
+
+    protected function getApiClass(): string
+    {
+        return IssuesStatistics::class;
+    }
+}
diff --git a/tests/Api/IssuesTest.php b/tests/Api/IssuesTest.php
new file mode 100644
index 000000000..ce72acb41
--- /dev/null
+++ b/tests/Api/IssuesTest.php
@@ -0,0 +1,602 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Tests\Api;
+
+use Gitlab\Api\Issues;
+use PHPUnit\Framework\Attributes\Test;
+
+class IssuesTest extends TestCase
+{
+    #[Test]
+    public function shouldGetAllIssues(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'title' => 'An issue'],
+            ['id' => 2, 'title' => 'Another issue'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('issues', [])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->all());
+    }
+
+    #[Test]
+    public function shouldGetAllGroupIssues(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'title' => 'An issue'],
+            ['id' => 2, 'title' => 'Another issue'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('groups/1/issues', [])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->group(1));
+    }
+
+    #[Test]
+    public function shouldGetGroupIssuesWithPagination(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'title' => 'An issue'],
+            ['id' => 2, 'title' => 'Another issue'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('groups/1/issues', ['page' => 2, 'per_page' => 5])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->group(1, ['page' => 2, 'per_page' => 5]));
+    }
+
+    #[Test]
+    public function shouldGetGroupIssuesWithParams(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'title' => 'An issue'],
+            ['id' => 2, 'title' => 'Another issue'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('groups/1/issues', ['order_by' => 'created_at', 'sort' => 'desc', 'labels' => 'foo,bar', 'state' => 'opened', 'iteration_title' => 'Title', 'assignee_id' => 1])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->group(1, ['order_by' => 'created_at', 'sort' => 'desc', 'labels' => 'foo,bar', 'state' => 'opened', 'iteration_title' => 'Title', 'assignee_id' => 1]));
+    }
+
+    #[Test]
+    public function shouldGetProjectIssuesWithPagination(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'title' => 'An issue'],
+            ['id' => 2, 'title' => 'Another issue'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/issues', ['page' => 2, 'per_page' => 5])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->all(1, ['page' => 2, 'per_page' => 5]));
+    }
+
+    #[Test]
+    public function shouldGetProjectIssuesWithParams(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'title' => 'An issue'],
+            ['id' => 2, 'title' => 'Another issue'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/issues', ['order_by' => 'created_at', 'sort' => 'desc', 'labels' => 'foo,bar', 'state' => 'opened', 'iteration_id' => 1, 'assignee_id' => 2])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->all(1, ['order_by' => 'created_at', 'sort' => 'desc', 'labels' => 'foo,bar', 'state' => 'opened', 'iteration_id' => 1, 'assignee_id' => 2]));
+    }
+
+    #[Test]
+    public function shouldShowIssue(): void
+    {
+        $expectedArray = ['id' => 2, 'title' => 'Another issue'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/issues/2')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->show(1, 2));
+    }
+
+    #[Test]
+    public function shouldCreateIssue(): void
+    {
+        $expectedArray = ['id' => 3, 'title' => 'A new issue'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/issues', ['title' => 'A new issue', 'labels' => 'foo,bar'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->create(1, ['title' => 'A new issue', 'labels' => 'foo,bar']));
+    }
+
+    #[Test]
+    public function shouldUpdateIssue(): void
+    {
+        $expectedArray = ['id' => 2, 'title' => 'A renamed issue'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('put')
+            ->with('projects/1/issues/2', ['title' => 'A renamed issue', 'labels' => 'foo'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->update(1, 2, ['title' => 'A renamed issue', 'labels' => 'foo']));
+    }
+
+    #[Test]
+    public function shouldReorderIssue(): void
+    {
+        $expectedArray = ['id' => 2, 'title' => 'A reordered issue'];
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('put')
+            ->with('projects/1/issues/2/reorder', ['move_after_id' => 3, 'move_before_id' => 4])
+            ->willReturn($expectedArray)
+        ;
+        $this->assertEquals($expectedArray, $api->reorder(1, 2, ['move_after_id' => 3, 'move_before_id' => 4]));
+    }
+
+    #[Test]
+    public function shouldMoveIssue(): void
+    {
+        $expectedArray = ['id' => 2, 'title' => 'A moved issue'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/issues/2/move', ['to_project_id' => 3])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->move(1, 2, 3));
+    }
+
+    #[Test]
+    public function shouldGetNotes(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'body' => 'A note'],
+            ['id' => 2, 'body' => 'Another note'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/issues/2/notes')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->showNotes(1, 2));
+    }
+
+    #[Test]
+    public function shouldGetNote(): void
+    {
+        $expectedArray = ['id' => 3, 'body' => 'A new note'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/issues/2/notes/3')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->showNote(1, 2, 3));
+    }
+
+    #[Test]
+    public function shouldCreateNote(): void
+    {
+        $expectedArray = ['id' => 3, 'body' => 'A new note'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/issues/2/notes', ['body' => 'A new note'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->addNote(1, 2, 'A new note'));
+    }
+
+    #[Test]
+    public function shouldUpdateNote(): void
+    {
+        $expectedArray = ['id' => 3, 'body' => 'An edited comment'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('put')
+            ->with('projects/1/issues/2/notes/3', ['body' => 'An edited comment'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->updateNote(1, 2, 3, 'An edited comment'));
+    }
+
+    #[Test]
+    public function shouldRemoveNote(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('delete')
+            ->with('projects/1/issues/2/notes/3')
+            ->willReturn($expectedBool);
+
+        $this->assertEquals($expectedBool, $api->removeNote(1, 2, 3));
+    }
+
+    #[Test]
+    public function shouldGetIssueDiscussions(): void
+    {
+        $expectedArray = [
+            ['id' => 'abc', 'body' => 'A discussion'],
+            ['id' => 'def', 'body' => 'Another discussion'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/issues/2/discussions')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->showDiscussions(1, 2));
+    }
+
+    #[Test]
+    public function shouldGetIssueDiscussion(): void
+    {
+        $expectedArray = ['id' => 'abc', 'body' => 'A discussion'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/issues/2/discussions/abc')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->showDiscussion(1, 2, 'abc'));
+    }
+
+    #[Test]
+    public function shouldCreateDiscussion(): void
+    {
+        $expectedArray = ['id' => 'abc', 'body' => 'A new discussion'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/issues/2/discussions', ['body' => 'A new discussion'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->addDiscussion(1, 2, 'A new discussion'));
+    }
+
+    #[Test]
+    public function shouldCreateDiscussionNote(): void
+    {
+        $expectedArray = ['id' => 3, 'body' => 'A new discussion note'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/issues/2/discussions/abc/notes', ['body' => 'A new discussion note'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->addDiscussionNote(1, 2, 'abc', 'A new discussion note'));
+    }
+
+    #[Test]
+    public function shouldUpdateDiscussionNote(): void
+    {
+        $expectedArray = ['id' => 3, 'body' => 'An edited discussion note'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('put')
+            ->with('projects/1/issues/2/discussions/abc/notes/3', ['body' => 'An edited discussion note'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->updateDiscussionNote(1, 2, 'abc', 3, 'An edited discussion note'));
+    }
+
+    #[Test]
+    public function shouldRemoveDiscussionNote(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('delete')
+            ->with('projects/1/issues/2/discussions/abc/notes/3')
+            ->willReturn($expectedBool);
+
+        $this->assertEquals($expectedBool, $api->removeDiscussionNote(1, 2, 'abc', 3));
+    }
+
+    #[Test]
+    public function shouldSetTimeEstimate(): void
+    {
+        $expectedArray = ['time_estimate' => 14400, 'total_time_spent' => 0, 'human_time_estimate' => '4h', 'human_total_time_spent' => null];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/issues/2/time_estimate', ['duration' => '4h'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->setTimeEstimate(1, 2, '4h'));
+    }
+
+    #[Test]
+    public function shouldResetTimeEstimate(): void
+    {
+        $expectedArray = ['time_estimate' => 0, 'total_time_spent' => 0, 'human_time_estimate' => null, 'human_total_time_spent' => null];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/issues/2/reset_time_estimate')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->resetTimeEstimate(1, 2));
+    }
+
+    #[Test]
+    public function shouldAddSpentTime(): void
+    {
+        $expectedArray = ['time_estimate' => 0, 'total_time_spent' => 14400, 'human_time_estimate' => null, 'human_total_time_spent' => '4h'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/issues/2/add_spent_time', ['duration' => '4h'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->addSpentTime(1, 2, '4h'));
+    }
+
+    #[Test]
+    public function shouldResetSpentTime(): void
+    {
+        $expectedArray = ['time_estimate' => 0, 'total_time_spent' => 0, 'human_time_estimate' => null, 'human_total_time_spent' => null];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/issues/2/reset_spent_time')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->resetSpentTime(1, 2));
+    }
+
+    #[Test]
+    public function shouldGetIssueTimeStats(): void
+    {
+        $expectedArray = ['time_estimate' => 14400, 'total_time_spent' => 5400, 'human_time_estimate' => '4h', 'human_total_time_spent' => '1h 30m'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/issues/2/time_stats')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->getTimeStats(1, 2));
+    }
+
+    #[Test]
+    public function shouldIssueAwardEmoji(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'name' => 'sparkles'],
+            ['id' => 2, 'name' => 'heart_eyes'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/issues/2/award_emoji')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->awardEmoji(1, 2));
+    }
+
+    #[Test]
+    public function shouldRevokeAwardEmoji(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('delete')
+            ->with('projects/1/issues/2/award_emoji/3')
+            ->willReturn($expectedBool);
+
+        $this->assertEquals(true, $api->removeAwardEmoji(1, 2, 3));
+    }
+
+    #[Test]
+    public function shouldGetIssueClosedByMergeRequests(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'iid' => '1111', 'title' => 'Just saving the world'],
+            ['id' => 2, 'iid' => '1112', 'title' => 'Adding new feature to get merge requests that close an issue'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/issues/2/closed_by')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->closedByMergeRequests(1, 2));
+    }
+
+    #[Test]
+    public function shouldGetIssueRelatedMergeRequests(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'iid' => '1111', 'title' => 'Just saving the world'],
+            ['id' => 2, 'iid' => '1112', 'title' => 'Adding new feature to get merge requests that close an issue'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/issues/2/related_merge_requests')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->relatedMergeRequests(1, 2));
+    }
+
+    #[Test]
+    public function shouldGetProjectIssuesByAssignee(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'title' => 'An issue'],
+            ['id' => 2, 'title' => 'Another issue'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/issues', ['assignee_id' => 1])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->all(1, ['assignee_id' => 1]));
+    }
+
+    #[Test]
+    public function shouldGetIssueParticipants(): void
+    {
+        $expectedArray = [
+            [
+                'id' => 1,
+                'name' => 'John Doe1',
+                'username' => 'user1',
+                'state' => 'active',
+                'avatar_url' => 'http://www.gravatar.com/avatar/c922747a93b40d1ea88262bf1aebee62?s=80&d=identicon',
+                'web_url' => 'http://localhost/user1',
+            ],
+            [
+                'id' => 5,
+                'name' => 'John Doe5',
+                'username' => 'user5',
+                'state' => 'active',
+                'avatar_url' => 'http://www.gravatar.com/avatar/4aea8cf834ed91844a2da4ff7ae6b491?s=80&d=identicon',
+                'web_url' => 'http://localhost/user5',
+            ],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/issues/2/participants')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->showParticipants(1, 2));
+    }
+
+    #[Test]
+    public function shouldGetIssueResourceLabelEvents(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'resource_type' => 'Issue', 'action' => 'add'],
+            ['id' => 2, 'resource_type' => 'Issue', 'action' => 'add'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/issues/2/resource_label_events')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->showResourceLabelEvents(1, 2));
+    }
+
+    #[Test]
+    public function shouldGetIssueResourceLabelEvent(): void
+    {
+        $expectedArray = ['id' => 1, 'resource_type' => 'Issue', 'action' => 'add'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/issues/2/resource_label_events/3')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->showResourceLabelEvent(1, 2, 3));
+    }
+
+    protected function getApiClass(): string
+    {
+        return Issues::class;
+    }
+}
diff --git a/tests/Api/JobsTest.php b/tests/Api/JobsTest.php
new file mode 100644
index 000000000..6753353dd
--- /dev/null
+++ b/tests/Api/JobsTest.php
@@ -0,0 +1,272 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Tests\Api;
+
+use Gitlab\Api\Jobs;
+use GuzzleHttp\Psr7\Response;
+use PHPUnit\Framework\Attributes\Test;
+
+class JobsTest extends TestCase
+{
+    #[Test]
+    public function shouldGetAllJobs(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'name' => 'A job'],
+            ['id' => 2, 'name' => 'Another job'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/jobs', [
+                'scope' => ['pending'],
+            ])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->all(1, ['scope' => Jobs::SCOPE_PENDING]));
+    }
+
+    #[Test]
+    public function shouldGetPipelineJobs(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'name' => 'A job'],
+            ['id' => 2, 'name' => 'Another job'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/pipelines/2/jobs', [
+                'scope' => ['pending', 'running'],
+            ])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->pipelineJobs(1, 2, ['scope' => [Jobs::SCOPE_PENDING, Jobs::SCOPE_RUNNING]]));
+    }
+
+    #[Test]
+    public function shouldGetPipelineJobsIncludingRetried(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'name' => 'A job'],
+            ['id' => 2, 'name' => 'Another job'],
+            ['id' => 3, 'name' => 'A job'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/pipelines/2/jobs', [
+                'scope' => ['pending', 'running'],
+                'include_retried' => true,
+            ])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->pipelineJobs(1, 2, ['scope' => [Jobs::SCOPE_PENDING, Jobs::SCOPE_RUNNING], 'include_retried' => true]));
+    }
+
+    #[Test]
+    public function shouldGetPipelineBridges(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'name' => 'A bridge job'],
+            ['id' => 2, 'name' => 'Another bridge job'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/pipelines/2/bridges', [
+                'scope' => ['pending', 'running'],
+            ])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->pipelineBridges(1, 2, ['scope' => [Jobs::SCOPE_PENDING, Jobs::SCOPE_RUNNING]]));
+    }
+
+    #[Test]
+    public function shouldGetJob(): void
+    {
+        $expectedArray = ['id' => 3, 'name' => 'A job'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/jobs/3')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->show(1, 3));
+    }
+
+    #[Test]
+    public function shouldGetArtifacts(): void
+    {
+        $returnedStream = new Response(200, [], 'foobar');
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('getAsResponse')
+            ->with('projects/1/jobs/3/artifacts')
+            ->willReturn($returnedStream);
+
+        $this->assertEquals('foobar', $api->artifacts(1, 3)->getContents());
+    }
+
+    #[Test]
+    public function shouldGetArtifactsByJobId(): void
+    {
+        $returnedStream = new Response(200, [], 'foobar');
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('getAsResponse')
+            ->with('projects/1/jobs/3/artifacts/artifact_path')
+            ->willReturn($returnedStream);
+
+        $this->assertEquals('foobar', $api->artifactByJobId(1, 3, 'artifact_path')->getContents());
+    }
+
+    #[Test]
+    public function shouldGetArtifactsByRefName(): void
+    {
+        $returnedStream = new Response(200, [], 'foobar');
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('getAsResponse')
+            ->with('projects/1/jobs/artifacts/master/download', [
+                'job' => 'job name',
+            ])
+            ->willReturn($returnedStream);
+
+        $this->assertEquals('foobar', $api->artifactsByRefName(1, 'master', 'job name')->getContents());
+    }
+
+    #[Test]
+    public function shouldGetArtifactByRefName(): void
+    {
+        $returnedStream = new Response(200, [], 'foobar');
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('getAsResponse')
+            ->with('projects/1/jobs/artifacts/master/raw/artifact_path', [
+                'job' => 'job name',
+            ])
+            ->willReturn($returnedStream);
+
+        $this->assertEquals('foobar', $api->artifactByRefName(1, 'master', 'job name', 'artifact_path')->getContents());
+    }
+
+    #[Test]
+    public function shouldGetTrace(): void
+    {
+        $expectedString = 'some trace';
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/jobs/3/trace')
+            ->willReturn($expectedString);
+
+        $this->assertEquals($expectedString, $api->trace(1, 3));
+    }
+
+    #[Test]
+    public function shouldCancel(): void
+    {
+        $expectedArray = ['id' => 3, 'name' => 'A job'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/jobs/3/cancel')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->cancel(1, 3));
+    }
+
+    #[Test]
+    public function shouldRetry(): void
+    {
+        $expectedArray = ['id' => 3, 'name' => 'A job'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/jobs/3/retry')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->retry(1, 3));
+    }
+
+    #[Test]
+    public function shouldErase(): void
+    {
+        $expectedArray = ['id' => 3, 'name' => 'A job'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/jobs/3/erase')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->erase(1, 3));
+    }
+
+    #[Test]
+    public function shouldKeepArtifacts(): void
+    {
+        $expectedArray = ['id' => 3, 'name' => 'A job'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/jobs/3/artifacts/keep')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->keepArtifacts(1, 3));
+    }
+
+    #[Test]
+    public function shouldPlay(): void
+    {
+        $expectedArray = ['id' => 3, 'name' => 'A job'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/jobs/3/play')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->play(1, 3));
+    }
+
+    protected function getApiClass(): string
+    {
+        return Jobs::class;
+    }
+}
diff --git a/tests/Api/KeysTest.php b/tests/Api/KeysTest.php
new file mode 100644
index 000000000..08c7f2680
--- /dev/null
+++ b/tests/Api/KeysTest.php
@@ -0,0 +1,39 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Tests\Api;
+
+use Gitlab\Api\Keys;
+use PHPUnit\Framework\Attributes\Test;
+
+class KeysTest extends TestCase
+{
+    #[Test]
+    public function shouldShowKey(): void
+    {
+        $expectedArray = ['id' => 1, 'title' => 'A key', 'key' => 'ssh-rsa key', 'created_at' => '2016-01-01T01:00:00.000Z'];
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('keys/1')
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->show(1));
+    }
+
+    protected function getApiClass(): string
+    {
+        return Keys::class;
+    }
+}
diff --git a/tests/Api/MergeRequestsTest.php b/tests/Api/MergeRequestsTest.php
new file mode 100644
index 000000000..c0803014a
--- /dev/null
+++ b/tests/Api/MergeRequestsTest.php
@@ -0,0 +1,825 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Tests\Api;
+
+use Gitlab\Api\MergeRequests;
+use PHPUnit\Framework\Attributes\Test;
+
+class MergeRequestsTest extends TestCase
+{
+    #[Test]
+    public function shouldGetAll(): void
+    {
+        $expectedArray = $this->getMultipleMergeRequestsData();
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/merge_requests', [])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->all(1));
+    }
+
+    #[Test]
+    public function shouldGetAllWithNoProject(): void
+    {
+        $expectedArray = $this->getMultipleMergeRequestsData();
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('merge_requests', [])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->all());
+    }
+
+    #[Test]
+    public function shouldGetAllWithParams(): void
+    {
+        $expectedArray = $this->getMultipleMergeRequestsData();
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/merge_requests', [
+                'page' => 2,
+                'per_page' => 5,
+                'labels' => 'label1,label2,label3',
+                'milestone' => 'milestone1',
+                'order_by' => 'updated_at',
+                'state' => 'all',
+                'sort' => 'desc',
+                'scope' => 'all',
+                'author_id' => 1,
+                'assignee_id' => 1,
+                'source_branch' => 'develop',
+                'target_branch' => 'master',
+                'with_merge_status_recheck' => true,
+                'approved_by_ids' => [1],
+            ])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->all(1, [
+            'page' => 2,
+            'per_page' => 5,
+            'labels' => 'label1,label2,label3',
+            'milestone' => 'milestone1',
+            'order_by' => 'updated_at',
+            'state' => 'all',
+            'sort' => 'desc',
+            'scope' => 'all',
+            'author_id' => 1,
+            'assignee_id' => 1,
+            'source_branch' => 'develop',
+            'target_branch' => 'master',
+            'with_merge_status_recheck' => true,
+            'approved_by_ids' => [1],
+        ]));
+    }
+
+    #[Test]
+    public function shouldGetAllWithDateTimeParams(): void
+    {
+        $expectedArray = $this->getMultipleMergeRequestsData();
+
+        $createdAfter = new \DateTime('2018-01-01 00:00:00');
+        $createdBefore = new \DateTime('2018-01-31 12:00:00.123+03:00');
+
+        $expectedWithArray = [
+            'created_after' => '2018-01-01T00:00:00.000Z',
+            'created_before' => '2018-01-31T09:00:00.123Z',
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/merge_requests', $expectedWithArray)
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals(
+            $expectedArray,
+            $api->all(1, ['created_after' => $createdAfter, 'created_before' => $createdBefore])
+        );
+    }
+
+    #[Test]
+    public function shouldShowMergeRequest(): void
+    {
+        $expectedArray = ['id' => 2, 'name' => 'A merge request'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/merge_requests/2')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->show(1, 2));
+    }
+
+    #[Test]
+    public function shouldShowMergeRequestWithOptionalParameters(): void
+    {
+        $expectedArray = [
+            'id' => 2,
+            'name' => 'A merge request',
+            'diverged_commits_count' => 0,
+            'rebase_in_progress' => false,
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/merge_requests/2', ['include_diverged_commits_count' => true,  'include_rebase_in_progress' => true])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->show(1, 2, [
+            'include_diverged_commits_count' => true,
+            'include_rebase_in_progress' => true,
+        ]));
+    }
+
+    #[Test]
+    public function shouldCreateMergeRequestWithoutOptionalParams(): void
+    {
+        $expectedArray = ['id' => 3, 'title' => 'Merge Request'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/merge_requests', [
+                'title' => 'Merge Request',
+                'target_branch' => 'master',
+                'source_branch' => 'develop',
+            ])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->create(1, 'develop', 'master', 'Merge Request'));
+    }
+
+    #[Test]
+    public function shouldCreateMergeRequestWithOptionalParams(): void
+    {
+        $expectedArray = ['id' => 3, 'title' => 'Merge Request'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/merge_requests', [
+                'title' => 'Merge Request',
+                'target_branch' => 'master',
+                'source_branch' => 'develop',
+                'assignee_id' => 6,
+                'target_project_id' => 20,
+                'description' => 'Some changes',
+                'remove_source_branch' => true,
+            ])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals(
+            $expectedArray,
+            $api->create(
+                1,
+                'develop',
+                'master',
+                'Merge Request',
+                ['assignee_id' => 6, 'target_project_id' => 20, 'description' => 'Some changes', 'remove_source_branch' => true]
+            )
+        );
+    }
+
+    #[Test]
+    public function shouldUpdateMergeRequest(): void
+    {
+        $expectedArray = ['id' => 2, 'title' => 'Updated title'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('put')
+            ->with('projects/1/merge_requests/2', ['title' => 'Updated title', 'description' => 'No so many changes now', 'state_event' => 'close'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->update(1, 2, [
+            'title' => 'Updated title',
+            'description' => 'No so many changes now',
+            'state_event' => 'close',
+        ]));
+    }
+
+    #[Test]
+    public function shouldMergeMergeRequest(): void
+    {
+        $expectedArray = ['id' => 2, 'title' => 'Updated title'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('put')
+            ->with('projects/1/merge_requests/2/merge', ['merge_commit_message' => 'Accepted'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->merge(1, 2, ['merge_commit_message' => 'Accepted']));
+    }
+
+    #[Test]
+    public function shouldGetNotes(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'body' => 'A note'],
+            ['id' => 2, 'body' => 'Another note'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/merge_requests/2/notes')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->showNotes(1, 2));
+    }
+
+    #[Test]
+    public function shouldGetNote(): void
+    {
+        $expectedArray = ['id' => 3, 'body' => 'A new note'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/merge_requests/2/notes/3')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->showNote(1, 2, 3));
+    }
+
+    #[Test]
+    public function shouldCreateNote(): void
+    {
+        $expectedArray = ['id' => 3, 'body' => 'A new note'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/merge_requests/2/notes', ['body' => 'A new note'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->addNote(1, 2, 'A new note'));
+    }
+
+    #[Test]
+    public function shouldUpdateNote(): void
+    {
+        $expectedArray = ['id' => 3, 'body' => 'An edited comment'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('put')
+            ->with('projects/1/merge_requests/2/notes/3', ['body' => 'An edited comment'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->updateNote(1, 2, 3, 'An edited comment'));
+    }
+
+    #[Test]
+    public function shouldRemoveNote(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('delete')
+            ->with('projects/1/merge_requests/2/notes/3')
+            ->willReturn($expectedBool);
+
+        $this->assertEquals($expectedBool, $api->removeNote(1, 2, 3));
+    }
+
+    #[Test]
+    public function shouldGetMergeRequestParticipants(): void
+    {
+        $expectedArray = [
+            [
+                'id' => 1,
+                'name' => 'John Doe1',
+                'username' => 'user1',
+                'state' => 'active',
+                'avatar_url' => 'http://www.gravatar.com/avatar/c922747a93b40d1ea88262bf1aebee62?s=80&d=identicon',
+                'web_url' => 'http://localhost/user1',
+            ],
+            [
+                'id' => 5,
+                'name' => 'John Doe5',
+                'username' => 'user5',
+                'state' => 'active',
+                'avatar_url' => 'http://www.gravatar.com/avatar/4aea8cf834ed91844a2da4ff7ae6b491?s=80&d=identicon',
+                'web_url' => 'http://localhost/user5',
+            ],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/merge_requests/2/participants')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->showParticipants(1, 2));
+    }
+
+    #[Test]
+    public function shouldGetMergeRequestChanges(): void
+    {
+        $expectedArray = ['id' => 1, 'title' => 'A merge request'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/merge_requests/2/changes')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->changes(1, 2));
+    }
+
+    #[Test]
+    public function shouldGetMergeRequestDiscussions(): void
+    {
+        $expectedArray = [
+            ['id' => 'abc', 'body' => 'A discussion'],
+            ['id' => 'def', 'body' => 'Another discussion'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/merge_requests/2/discussions')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->showDiscussions(1, 2));
+    }
+
+    #[Test]
+    public function shouldGetMergeRequestDiscussion(): void
+    {
+        $expectedArray = ['id' => 'abc', 'body' => 'A discussion'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/merge_requests/2/discussions/abc')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->showDiscussion(1, 2, 'abc'));
+    }
+
+    #[Test]
+    public function shouldCreateDiscussion(): void
+    {
+        $expectedArray = ['id' => 'abc', 'body' => 'A new discussion'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/merge_requests/2/discussions', ['body' => 'A new discussion'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->addDiscussion(1, 2, ['body' => 'A new discussion']));
+    }
+
+    #[Test]
+    public function shouldResolveDiscussion(): void
+    {
+        $expectedArray = ['id' => 'abc', 'resolved' => true];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('put')
+            ->with('projects/1/merge_requests/2/discussions/abc', ['resolved' => true])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->resolveDiscussion(1, 2, 'abc', true));
+    }
+
+    #[Test]
+    public function shouldUnresolveDiscussion(): void
+    {
+        $expectedArray = ['id' => 'abc', 'resolved' => false];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('put')
+            ->with('projects/1/merge_requests/2/discussions/abc', ['resolved' => false])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->resolveDiscussion(1, 2, 'abc', false));
+    }
+
+    #[Test]
+    public function shouldCreateDiscussionNote(): void
+    {
+        $expectedArray = ['id' => 3, 'body' => 'A new discussion note'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/merge_requests/2/discussions/abc/notes', ['body' => 'A new discussion note'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->addDiscussionNote(1, 2, 'abc', 'A new discussion note'));
+    }
+
+    #[Test]
+    public function shouldUpdateDiscussionNote(): void
+    {
+        $expectedArray = ['id' => 3, 'body' => 'An edited discussion note'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('put')
+            ->with('projects/1/merge_requests/2/discussions/abc/notes/3', ['body' => 'An edited discussion note'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->updateDiscussionNote(1, 2, 'abc', 3, ['body' => 'An edited discussion note']));
+    }
+
+    #[Test]
+    public function shouldRemoveDiscussionNote(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('delete')
+            ->with('projects/1/merge_requests/2/discussions/abc/notes/3')
+            ->willReturn($expectedBool);
+
+        $this->assertEquals($expectedBool, $api->removeDiscussionNote(1, 2, 'abc', 3));
+    }
+
+    #[Test]
+    public function shouldGetIssuesClosedByMergeRequest(): void
+    {
+        $expectedArray = ['id' => 1, 'title' => 'A merge request'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/merge_requests/2/closes_issues')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->closesIssues(1, 2));
+    }
+
+    #[Test]
+    public function shouldGetMergeRequestByIid(): void
+    {
+        $expectedArray = ['id' => 1, 'title' => 'A merge request'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/merge_requests', ['iids' => [2]])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->all(1, ['iids' => [2]]));
+    }
+
+    #[Test]
+    public function shouldApproveMergeRequest(): void
+    {
+        $expectedArray = ['id' => 1, 'title' => 'Approvals API'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/merge_requests/2/approve')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->approve(1, 2));
+    }
+
+    #[Test]
+    public function shouldUnApproveMergeRequest(): void
+    {
+        $expectedArray = ['id' => 1, 'title' => 'Approvals API'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/merge_requests/2/unapprove')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->unapprove(1, 2));
+    }
+
+    #[Test]
+    public function shouldGetMergeRequestApprovals(): void
+    {
+        $expectedArray = ['id' => 1, 'title' => 'Approvals API'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/merge_requests', ['iids' => [2]])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->all(1, ['iids' => [2]]));
+    }
+
+    #[Test]
+    public function shouldIssueMergeRequestAwardEmoji(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'name' => 'sparkles'],
+            ['id' => 2, 'name' => 'heart_eyes'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/merge_requests/2/award_emoji')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->awardEmoji(1, 2));
+    }
+
+    #[Test]
+    public function shouldRevokeMergeRequestAwardEmoji(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('delete')
+            ->with('projects/1/merge_requests/2/award_emoji/3')
+            ->willReturn($expectedBool);
+
+        $this->assertEquals(true, $api->removeAwardEmoji(1, 2, 3));
+    }
+
+    #[Test]
+    public function shoudGetApprovalState(): void
+    {
+        $expectedArray = [
+            'approval_rules_overwritten' => 1,
+            'rules' => [],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/merge_requests/2/approval_state')
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->approvalState(1, 2));
+    }
+
+    #[Test]
+    public function shoudGetLevelRules(): void
+    {
+        $expectedArray = [
+            [
+                'id' => 1,
+                'name' => 'Foo',
+                'rule_type' => 'regular',
+                'eligible_approvers' => [],
+                'approvals_required' => 1,
+                'users' => [],
+                'groups' => [],
+                'contains_hidden_groups' => null,
+                'section' => null,
+                'source_rule' => null,
+                'overridden' => null,
+            ],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/merge_requests/2/approval_rules')
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->levelRules(1, 2));
+    }
+
+    #[Test]
+    public function shoudCreateLevelRuleWithoutOptionalParameters(): void
+    {
+        $expectedArray = [
+            'id' => 20892835,
+            'name' => 'Foo',
+            'rule_type' => 'regular',
+            'eligible_approvers' => [],
+            'approvals_required' => 3,
+            'users' => [],
+            'groups' => [],
+            'contains_hidden_groups' => null,
+            'section' => null,
+            'source_rule' => null,
+            'overridden' => null,
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with(
+                'projects/1/merge_requests/2/approval_rules',
+                [
+                    'name' => 'Foo',
+                    'approvals_required' => 3,
+                ]
+            )
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->createLevelRule(1, 2, 'Foo', 3));
+    }
+
+    #[Test]
+    public function shoudCreateLevelRuleWithOptionalParameters(): void
+    {
+        $expectedArray = [
+            'id' => 20892835,
+            'name' => 'Foo',
+            'rule_type' => 'regular',
+            'eligible_approvers' => [],
+            'approvals_required' => 3,
+            'users' => [1951878],
+            'groups' => [104121],
+            'contains_hidden_groups' => null,
+            'section' => null,
+            'source_rule' => null,
+            'overridden' => null,
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with(
+                'projects/1/merge_requests/2/approval_rules',
+                [
+                    'name' => 'Foo',
+                    'approvals_required' => 3,
+                    'user_ids' => [1951878],
+                    'group_ids' => [104121],
+                ]
+            )
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->createLevelRule(1, 2, 'Foo', 3, [
+            'user_ids' => [1951878],
+            'group_ids' => [104121],
+        ]));
+    }
+
+    #[Test]
+    public function shoudUpdateLevelRuleWithoutOptionalParameters(): void
+    {
+        $expectedArray = [
+            'id' => 20892835,
+            'name' => 'Foo',
+            'rule_type' => 'regular',
+            'eligible_approvers' => [],
+            'approvals_required' => 3,
+            'users' => [],
+            'groups' => [],
+            'contains_hidden_groups' => null,
+            'section' => null,
+            'source_rule' => null,
+            'overridden' => null,
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('put')
+            ->with(
+                'projects/1/merge_requests/2/approval_rules/20892835',
+                [
+                    'name' => 'Foo',
+                    'approvals_required' => 3,
+                ]
+            )
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->updateLevelRule(1, 2, 20892835, 'Foo', 3));
+    }
+
+    #[Test]
+    public function shoudUpdateLevelRuleWithOptionalParameters(): void
+    {
+        $expectedArray = [
+            'id' => 20892835,
+            'name' => 'Foo',
+            'rule_type' => 'regular',
+            'eligible_approvers' => [],
+            'approvals_required' => 3,
+            'users' => [1951878],
+            'groups' => [104121],
+            'contains_hidden_groups' => null,
+            'section' => null,
+            'source_rule' => null,
+            'overridden' => null,
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('put')
+            ->with(
+                'projects/1/merge_requests/2/approval_rules/20892835',
+                [
+                    'name' => 'Foo',
+                    'approvals_required' => 3,
+                    'user_ids' => [1951878],
+                    'group_ids' => [104121],
+                ]
+            )
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->updateLevelRule(1, 2, 20892835, 'Foo', 3, [
+            'user_ids' => [1951878],
+            'group_ids' => [104121],
+        ]));
+    }
+
+    #[Test]
+    public function shoudDeleteLevelRule(): void
+    {
+        $expectedValue = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('delete')
+            ->with('projects/1/merge_requests/2/approval_rules/3')
+            ->willReturn($expectedValue);
+
+        $this->assertEquals($expectedValue, $api->deleteLevelRule(1, 2, 3));
+    }
+
+    protected function getMultipleMergeRequestsData(): array
+    {
+        return [
+            ['id' => 1, 'title' => 'A merge request'],
+            ['id' => 2, 'title' => 'Another merge request'],
+        ];
+    }
+
+    protected function getApiClass(): string
+    {
+        return MergeRequests::class;
+    }
+
+    #[Test]
+    public function shouldRebaseMergeRequest(): void
+    {
+        $expectedArray = ['rebase_in_progress' => true];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('put')
+            ->with('projects/1/merge_requests/2/rebase', ['skip_ci' => true])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->rebase(1, 2, [
+            'skip_ci' => true,
+        ]));
+    }
+}
diff --git a/tests/Api/MilestonesTest.php b/tests/Api/MilestonesTest.php
new file mode 100644
index 000000000..bb9104be1
--- /dev/null
+++ b/tests/Api/MilestonesTest.php
@@ -0,0 +1,139 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Tests\Api;
+
+use Gitlab\Api\Milestones;
+use PHPUnit\Framework\Attributes\Test;
+
+class MilestonesTest extends TestCase
+{
+    #[Test]
+    public function shouldGetAllMilestones(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'title' => 'A milestone'],
+            ['id' => 2, 'title' => 'Another milestone'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/milestones')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->all(1));
+    }
+
+    #[Test]
+    public function shouldShowMilestone(): void
+    {
+        $expectedArray = ['id' => 1, 'name' => 'A milestone'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/milestones/2')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->show(1, 2));
+    }
+
+    #[Test]
+    public function shouldCreateMilestone(): void
+    {
+        $expectedArray = ['id' => 3, 'title' => 'A new milestone'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/milestones', ['description' => 'Some text', 'title' => 'A new milestone'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->create(1, ['description' => 'Some text', 'title' => 'A new milestone']));
+    }
+
+    #[Test]
+    public function shouldUpdateMilestone(): void
+    {
+        $expectedArray = ['id' => 3, 'title' => 'Updated milestone'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('put')
+            ->with('projects/1/milestones/3', ['title' => 'Updated milestone', 'due_date' => '2015-04-01', 'state_event' => 'close'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->update(1, 3, ['title' => 'Updated milestone', 'due_date' => '2015-04-01', 'state_event' => 'close']));
+    }
+
+    #[Test]
+    public function shouldRemoveMilestone(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('delete')
+            ->with('projects/1/milestones/2')
+            ->willReturn($expectedBool);
+
+        $this->assertEquals($expectedBool, $api->remove(1, 2));
+    }
+
+    #[Test]
+    public function shouldGetMilestonesIssues(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'title' => 'An issue'],
+            ['id' => 2, 'title' => 'Another issue'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/milestones/3/issues')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->issues(1, 3));
+    }
+
+    #[Test]
+    public function shouldGetMilestonesMergeRequests(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'title' => 'A merge request'],
+            ['id' => 2, 'title' => 'Another merge request'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/milestones/3/merge_requests')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->mergeRequests(1, 3));
+    }
+
+    protected function getApiClass(): string
+    {
+        return Milestones::class;
+    }
+}
diff --git a/tests/Api/PackagesTest.php b/tests/Api/PackagesTest.php
new file mode 100644
index 000000000..96553b6df
--- /dev/null
+++ b/tests/Api/PackagesTest.php
@@ -0,0 +1,116 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Tests\Api;
+
+use Gitlab\Api\Packages;
+use PHPUnit\Framework\Attributes\Test;
+
+final class PackagesTest extends TestCase
+{
+    #[Test]
+    public function shouldGetAllPackages(): void
+    {
+        $expectedArray = [
+            [
+                'id' => 3,
+                'name' => 'Hello/0.1@mycompany/stable',
+                'conan_package_name' => 'Hello',
+                'version' => '0.1',
+                'package_type' => 'conan',
+                '_links' => [
+                    'web_path' => '/foo/bar/-/packages/3',
+                    'delete_api_path' => 'https://gitlab.example.com/api/v4/projects/1/packages/3',
+                ],
+                'created_at' => '2029-12-16T20:33:34.316Z',
+                'tags' => [],
+            ],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/packages')
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->all(1));
+    }
+
+    #[Test]
+    public function shouldShowPackage(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'name' => 'com/mycompany/my-app', 'version' => '1.0-SNAPSHOT', 'package_type' => 'maven'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/packages/1')
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->show(1, 1));
+    }
+
+    #[Test]
+    public function shouldGetAllPackageFiles(): void
+    {
+        $expectedArray = [
+            ['id' => 25, 'file_name' => 'my-app-1.5-20181107.152550-1.jar', 'size' => 2421],
+            ['id' => 26, 'file_name' => 'my-app-1.5-20181107.152550-1.pom', 'size' => 1122],
+            ['id' => 27, 'file_name' => 'maven-metadata.xml', 'size' => 767],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/packages/1/package_files')
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->allFiles(1, 1));
+    }
+
+    #[Test]
+    public function shouldRemovePackage(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('delete')
+            ->with('projects/1/packages/1')
+            ->willReturn($expectedBool);
+
+        $this->assertEquals($expectedBool, $api->remove(1, 1));
+    }
+
+    #[Test]
+    public function shouldRemovePackageFile(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('delete')
+            ->with('projects/1/packages/1/package_files/25')
+            ->willReturn($expectedBool);
+
+        $this->assertEquals($expectedBool, $api->removeFile(1, 1, 25));
+    }
+
+    protected function getApiClass(): string
+    {
+        return Packages::class;
+    }
+}
diff --git a/tests/Api/ProjectNamespacesTest.php b/tests/Api/ProjectNamespacesTest.php
new file mode 100644
index 000000000..7ac81c59b
--- /dev/null
+++ b/tests/Api/ProjectNamespacesTest.php
@@ -0,0 +1,59 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Tests\Api;
+
+use Gitlab\Api\ProjectNamespaces;
+use PHPUnit\Framework\Attributes\Test;
+
+class ProjectNamespacesTest extends TestCase
+{
+    #[Test]
+    public function shouldGetAllNamespaces(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'name' => 'bespokes'],
+            ['id' => 2, 'name' => 'internal'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('namespaces', [])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->all());
+    }
+
+    #[Test]
+    public function shouldShowNamespace(): void
+    {
+        $expectedArray = ['id' => 1, 'name' => 'internal'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('namespaces/1')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->show(1));
+    }
+
+    protected function getApiClass(): string
+    {
+        return ProjectNamespaces::class;
+    }
+}
diff --git a/tests/Api/ProjectsTest.php b/tests/Api/ProjectsTest.php
new file mode 100644
index 000000000..62b5dfcc1
--- /dev/null
+++ b/tests/Api/ProjectsTest.php
@@ -0,0 +1,2768 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Tests\Api;
+
+use DateTime;
+use Gitlab\Api\Projects;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\Test;
+use PHPUnit\Framework\MockObject\MockObject;
+
+class ProjectsTest extends TestCase
+{
+    #[Test]
+    public function shouldGetAllProjects(): void
+    {
+        $expectedArray = $this->getMultipleProjectsData();
+
+        $api = $this->getMultipleProjectsRequestMock('projects', $expectedArray);
+
+        $this->assertEquals($expectedArray, $api->all());
+    }
+
+    #[Test]
+    public function shouldGetAllProjectsSortedByName(): void
+    {
+        $expectedArray = $this->getMultipleProjectsData();
+
+        $api = $this->getMultipleProjectsRequestMock(
+            'projects',
+            $expectedArray,
+            ['page' => 1, 'per_page' => 5, 'order_by' => 'name', 'sort' => 'asc']
+        );
+
+        $this->assertEquals(
+            $expectedArray,
+            $api->all(['page' => 1, 'per_page' => 5, 'order_by' => 'name', 'sort' => 'asc'])
+        );
+    }
+
+    #[Test]
+    public function shouldNotNeedPaginationWhenGettingProjects(): void
+    {
+        $expectedArray = $this->getMultipleProjectsData();
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects')
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->all());
+    }
+
+    #[Test]
+    public function shouldGetAccessibleProjects(): void
+    {
+        $expectedArray = $this->getMultipleProjectsData();
+
+        $api = $this->getMultipleProjectsRequestMock('projects', $expectedArray);
+
+        $this->assertEquals($expectedArray, $api->all());
+    }
+
+    #[Test]
+    public function shouldGetOwnedProjects(): void
+    {
+        $expectedArray = $this->getMultipleProjectsData();
+
+        $api = $this->getMultipleProjectsRequestMock('projects', $expectedArray, ['owned' => 'true']);
+
+        $this->assertEquals($expectedArray, $api->all(['owned' => true]));
+    }
+
+    #[Test]
+    public function shouldGetNotArchivedProjects(): void
+    {
+        $expectedArray = $this->getMultipleProjectsData();
+
+        $api = $this->getMultipleProjectsRequestMock('projects', $expectedArray, ['archived' => 'false']);
+
+        $this->assertEquals($expectedArray, $api->all(['archived' => false]));
+    }
+
+    #[Test]
+    #[DataProvider('possibleAccessLevels')]
+    public function shouldGetProjectsWithMinimumAccessLevel($level): void
+    {
+        $expectedArray = $this->getMultipleProjectsData();
+
+        $api = $this->getMultipleProjectsRequestMock('projects', $expectedArray, ['min_access_level' => $level]);
+
+        $this->assertEquals($expectedArray, $api->all(['min_access_level' => $level]));
+    }
+
+    #[Test]
+    public function shouldSearchProjects(): void
+    {
+        $expectedArray = $this->getMultipleProjectsData();
+
+        $api = $this->getMultipleProjectsRequestMock('projects', $expectedArray, ['search' => 'a project']);
+        $this->assertEquals($expectedArray, $api->all(['search' => 'a project']));
+    }
+
+    #[Test]
+    public function shouldSearchProjectsWithNamespace(): void
+    {
+        $expectedArray = $this->getMultipleProjectsDataWithNamespace();
+
+        $api = $this->getMultipleProjectsRequestMock('projects', $expectedArray, ['search' => 'a_project', 'search_namespaces' => 'true']);
+        $this->assertEquals($expectedArray, $api->all(['search' => 'a_project', 'search_namespaces' => true]));
+    }
+
+    #[Test]
+    public function shouldGetProjectsAfterId(): void
+    {
+        $expectedArray = $this->getMultipleProjectsData();
+
+        $api = $this->getMultipleProjectsRequestMock('projects', $expectedArray, ['id_after' => 0]);
+
+        $this->assertEquals($expectedArray, $api->all(['id_after' => 0]));
+    }
+
+    #[Test]
+    public function shouldGetProjectsWithLastActivityAfter(): void
+    {
+        $unixEpochDateTime = new DateTime('@0');
+
+        $expectedArray = $this->getMultipleProjectsData();
+
+        $api = $this->getMultipleProjectsRequestMock('projects', $expectedArray, ['last_activity_after' => $unixEpochDateTime->format('c')]);
+
+        $this->assertEquals($expectedArray, $api->all(['last_activity_after' => $unixEpochDateTime]));
+    }
+
+    #[Test]
+    public function shouldGetProjectsWithLastActivityBefore(): void
+    {
+        $now = new DateTime();
+
+        $expectedArray = $this->getMultipleProjectsData();
+
+        $api = $this->getMultipleProjectsRequestMock('projects', $expectedArray, ['last_activity_before' => $now->format('c')]);
+
+        $this->assertEquals($expectedArray, $api->all(['last_activity_before' => $now]));
+    }
+
+    #[Test]
+    public function shouldGetProjectsWithoutFailedRepositoryChecksum(): void
+    {
+        $expectedArray = $this->getMultipleProjectsData();
+
+        $api = $this->getMultipleProjectsRequestMock('projects', $expectedArray, ['repository_checksum_failed' => 'false']);
+
+        $this->assertEquals($expectedArray, $api->all(['repository_checksum_failed' => false]));
+    }
+
+    #[Test]
+    public function shouldGetProjectsWithDefaultRepositoryStorage(): void
+    {
+        $expectedArray = $this->getMultipleProjectsData();
+
+        $api = $this->getMultipleProjectsRequestMock('projects', $expectedArray, ['repository_storage' => 'default']);
+
+        $this->assertEquals($expectedArray, $api->all(['repository_storage' => 'default']));
+    }
+
+    #[Test]
+    public function shouldGetStarredProjects(): void
+    {
+        $expectedArray = $this->getMultipleProjectsData();
+
+        $api = $this->getMultipleProjectsRequestMock('projects', $expectedArray, ['starred' => 'true']);
+
+        $this->assertEquals($expectedArray, $api->all(['starred' => true]));
+    }
+
+    #[Test]
+    public function shouldGetProjectsWithoutFailedWikiChecksum(): void
+    {
+        $expectedArray = $this->getMultipleProjectsData();
+
+        $api = $this->getMultipleProjectsRequestMock('projects', $expectedArray, ['wiki_checksum_failed' => 'false']);
+
+        $this->assertEquals($expectedArray, $api->all(['wiki_checksum_failed' => false]));
+    }
+
+    #[Test]
+    public function shouldGetProjectsWithCustomAttributes(): void
+    {
+        $expectedArray = $this->getMultipleProjectsData();
+
+        $api = $this->getMultipleProjectsRequestMock('projects', $expectedArray, ['with_custom_attributes' => 'true']);
+
+        $this->assertEquals($expectedArray, $api->all(['with_custom_attributes' => true]));
+    }
+
+    #[Test]
+    public function shouldGetProjectsWithPhpProgrammingLanguage(): void
+    {
+        $expectedArray = $this->getMultipleProjectsData();
+
+        $api = $this->getMultipleProjectsRequestMock('projects', $expectedArray, ['with_programming_language' => 'php']);
+
+        $this->assertEquals($expectedArray, $api->all(['with_programming_language' => 'php']));
+    }
+
+    #[Test]
+    public function shouldShowProject(): void
+    {
+        $expectedArray = ['id' => 1, 'name' => 'Project Name'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1')
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->show(1));
+    }
+
+    #[Test]
+    public function shouldShowProjectWithStatistics(): void
+    {
+        $expectedArray = [
+            'id' => 1,
+            'name' => 'Project Name',
+            'statistics' => [
+                'commit_count' => 37,
+                'storage_size' => 1038090,
+                'repository_size' => 1038090,
+                'lfs_objects_size' => 0,
+                'job_artifacts_size' => 0,
+            ],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1', ['statistics' => true])
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->show(1, ['statistics' => true]));
+    }
+
+    #[Test]
+    public function shouldCreateProject(): void
+    {
+        $expectedArray = ['id' => 1, 'name' => 'Project Name'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects', ['name' => 'Project Name', 'issues_enabled' => true])
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->create('Project Name', [
+            'issues_enabled' => true,
+        ]));
+    }
+
+    #[Test]
+    public function shouldUpdateProject(): void
+    {
+        $expectedArray = ['id' => 1, 'name' => 'Updated Name'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('put')
+            ->with('projects/1', ['name' => 'Updated Name', 'issues_enabled' => true])
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->update(1, [
+            'name' => 'Updated Name',
+            'issues_enabled' => true,
+        ]));
+    }
+
+    #[Test]
+    public function shouldArchiveProject(): void
+    {
+        $expectedArray = ['id' => 1, 'archived' => true];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/archive')
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->archive(1));
+    }
+
+    #[Test]
+    public function shouldUnarchiveProject(): void
+    {
+        $expectedArray = ['id' => 1, 'archived' => false];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/unarchive')
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->unarchive(1));
+    }
+
+    #[Test]
+    public function shouldCreateProjectForUser(): void
+    {
+        $expectedArray = ['id' => 1, 'name' => 'Project Name'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/user/1', ['name' => 'Project Name', 'issues_enabled' => true])
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->createForUser(1, 'Project Name', [
+            'issues_enabled' => true,
+        ]));
+    }
+
+    #[Test]
+    public function shouldRemoveProject(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('delete')
+            ->with('projects/1')
+            ->willReturn($expectedBool);
+
+        $this->assertEquals($expectedBool, $api->remove(1));
+    }
+
+    #[Test]
+    public function shouldGetPipelines(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'status' => 'success', 'ref' => 'new-pipeline'],
+            ['id' => 2, 'status' => 'failed', 'ref' => 'new-pipeline'],
+            ['id' => 3, 'status' => 'pending', 'ref' => 'test-pipeline'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/pipelines')
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->pipelines(1));
+    }
+
+    #[Test]
+    public function shouldGetTriggers(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'description' => 'foo', 'token' => '6d056f63e50fe6f8c5f8f4aa10edb7'],
+            ['id' => 2, 'description' => 'bar', 'token' => '7bde01aa4f8f5c8f6ef05e36f650d6'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/triggers')
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->triggers(1));
+    }
+
+    #[Test]
+    public function shouldGetTrigger(): void
+    {
+        $expectedArray = [
+            'id' => 3,
+            'description' => 'foo',
+            'token' => '6d056f63e50fe6f8c5f8f4aa10edb7',
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/triggers/3')
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->trigger(1, 3));
+    }
+
+    #[Test]
+    public function shouldGetProjectIssues(): void
+    {
+        $expectedArray = $this->getProjectIssuesExpectedArray();
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/issues')
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->issues(1));
+    }
+
+    #[Test]
+    public function shouldGetProjectUsers(): void
+    {
+        $expectedArray = $this->getProjectUsersExpectedArray();
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+        ->method('get')
+        ->with('projects/1/users')
+        ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->users(1));
+    }
+
+    #[Test]
+    public function shouldGetProjectIssuesParameters(): void
+    {
+        $expectedArray = $this->getProjectIssuesExpectedArray();
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/issues')
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->issues(1, ['state' => 'opened']));
+    }
+
+    /**
+     * Get expected array for tests which check project issues method.
+     *
+     * @return array
+     *               Project issues list
+     */
+    public function getProjectIssuesExpectedArray(): array
+    {
+        return [
+            [
+                'state' => 'opened',
+                'description' => 'Ratione dolores corrupti mollitia soluta quia.',
+                'author' => [
+                    'state' => 'active',
+                    'id' => 18,
+                    'web_url' => 'https://gitlab.example.com/eileen.lowe',
+                    'name' => 'Alexandra Bashirian',
+                    'avatar_url' => null,
+                    'username' => 'eileen.lowe',
+                ],
+                'milestone' => [
+                    'project_id' => 1,
+                    'description' => 'Ducimus nam enim ex consequatur cumque ratione.',
+                    'state' => 'closed',
+                    'due_date' => null,
+                    'iid' => 2,
+                    'created_at' => '2016-01-04T15:31:39.996Z',
+                    'title' => 'v4.0',
+                    'id' => 17,
+                    'updated_at' => '2016-01-04T15:31:39.996Z',
+                ],
+                'project_id' => 1,
+                'assignees' => [
+                    [
+                        'state' => 'active',
+                        'id' => 1,
+                        'name' => 'Administrator',
+                        'web_url' => 'https://gitlab.example.com/root',
+                        'avatar_url' => null,
+                        'username' => 'root',
+                    ],
+                ],
+                'assignee' => [
+                    'state' => 'active',
+                    'id' => 1,
+                    'name' => 'Administrator',
+                    'web_url' => 'https://gitlab.example.com/root',
+                    'avatar_url' => null,
+                    'username' => 'root',
+                ],
+                'updated_at' => '2016-01-04T15:31:51.081Z',
+                'closed_at' => null,
+                'closed_by' => null,
+                'id' => 76,
+                'title' => 'Consequatur vero maxime deserunt laboriosam est voluptas dolorem.',
+                'created_at' => '2016-01-04T15:31:51.081Z',
+                'iid' => 6,
+                'labels' => [],
+                'user_notes_count' => 1,
+                'due_date' => '2016-07-22',
+                'web_url' => 'http://example.com/example/example/issues/6',
+                'confidential' => false,
+                'weight' => null,
+                'discussion_locked' => false,
+                'time_stats' => [
+                    'time_estimate' => 0,
+                    'total_time_spent' => 0,
+                    'human_time_estimate' => null,
+                    'human_total_time_spent' => null,
+                ],
+            ],
+        ];
+    }
+
+    /**
+     * Get expected array for tests which check project users method.
+     */
+    public function getProjectUsersExpectedArray(): array
+    {
+        return [
+            [
+                'id' => 1,
+                'name' => 'John Doe',
+                'username' => 'john.doe',
+                'state' => 'active',
+                'avatar_url' => 'https://example.com',
+                'web_url' => 'https://gitlab.com/john.doe',
+            ],
+        ];
+    }
+
+    #[Test]
+    public function shouldGetBoards(): void
+    {
+        $expectedArray = $this->getProjectIssuesExpectedArray();
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/boards')
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->boards(1));
+    }
+
+    /**
+     * Get expected array for tests which check project boards.
+     *
+     * @return array
+     *               Project issues list
+     */
+    public function getProjectBoardsExpectedArray(): array
+    {
+        return [
+            [
+                'id' => 1,
+                'project' => [
+                    'id' => 5,
+                    'name' => 'Diaspora Project Site',
+                    'name_with_namespace' => 'Diaspora / Diaspora Project Site',
+                    'path' => 'diaspora-project-site',
+                    'path_with_namespace' => 'diaspora/diaspora-project-site',
+                    'http_url_to_repo' => 'http://example.com/diaspora/diaspora-project-site.git',
+                    'web_url' => 'http://example.com/diaspora/diaspora-project-site',
+                ],
+                'milestone' => [
+                    'id' => 12,
+                    'title' => '10.0',
+                ],
+                'lists' => [
+                    [
+                        'id' => 1,
+                        'label' => [
+                            'name' => 'Testing',
+                            'color' => '#F0AD4E',
+                            'description' => null,
+                        ],
+                        'position' => 1,
+                    ],
+                    [
+                        'id' => 2,
+                        'label' => [
+                            'name' => 'Ready',
+                            'color' => '#FF0000',
+                            'description' => null,
+                        ],
+                        'position' => 2,
+                    ],
+                    [
+                        'id' => 3,
+                        'label' => [
+                            'name' => 'Production',
+                            'color' => '#FF5F00',
+                            'description' => null,
+                        ],
+                        'position' => 3,
+                    ],
+                ],
+            ],
+        ];
+    }
+
+    #[Test]
+    public function shouldGetIterations(): void
+    {
+        $expectedArray = [
+            [
+                'id' => 5,
+                'iid' => 2,
+                'sequence' => 1,
+                'group_id' => 123,
+                'title' => '2022: Sprint 1',
+                'description' => '',
+                'state' => 3,
+                'created_at' => '2021-09-29T21:24:43.913Z',
+                'updated_at' => '2022-03-29T19:09:08.368Z',
+                'start_date' => '2022-01-10',
+                'due_date' => '2022-01-23',
+                'web_url' => 'https://example.com/groups/example/-/iterations/34',
+            ],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/iterations')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->iterations(1));
+    }
+
+    #[Test]
+    public function shouldCreateTrigger(): void
+    {
+        $expectedArray = [
+            'id' => 4,
+            'description' => 'foobar',
+            'token' => '6d056f63e50fe6f8c5f8f4aa10edb7',
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/triggers', ['description' => 'foobar'])
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->createTrigger(1, 'foobar'));
+    }
+
+    #[Test]
+    public function shouldRemoveTrigger(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('delete')
+            ->with('projects/1/triggers/2')
+            ->willReturn($expectedBool);
+
+        $this->assertEquals($expectedBool, $api->removeTrigger(1, 2));
+    }
+
+    #[Test]
+    public function shouldTriggerPipeline(): void
+    {
+        $expectedArray = [
+            'id' => 4,
+            'sha' => 'commit_hash',
+            'ref' => 'master',
+            'status' => 'pending',
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with(
+                'projects/1/trigger/pipeline',
+                ['ref' => 'master', 'token' => 'some_token', 'variables' => ['VAR_1' => 'value 1']]
+            )
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->triggerPipeline(1, 'master', 'some_token', ['VAR_1' => 'value 1']));
+    }
+
+    #[Test]
+    public function shouldGetPipelinesWithBooleanParam(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'status' => 'success', 'ref' => 'new-pipeline'],
+            ['id' => 2, 'status' => 'failed', 'ref' => 'new-pipeline'],
+            ['id' => 3, 'status' => 'pending', 'ref' => 'test-pipeline'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/pipelines', ['yaml_errors' => 'false'])
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->pipelines(1, ['yaml_errors' => false]));
+    }
+
+    #[Test]
+    public function shouldGetPipelineWithDateParam(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'status' => 'success', 'ref' => 'new-pipeline'],
+            ['id' => 2, 'status' => 'failed', 'ref' => 'new-pipeline'],
+            ['id' => 3, 'status' => 'pending', 'ref' => 'test-pipeline'],
+        ];
+
+        $updated_after = new DateTime('2018-01-01 00:00:00');
+        $updated_before = new DateTime('2018-01-31 00:00:00');
+
+        $expectedWithArray = [
+            'updated_after' => $updated_after->format('Y-m-d'),
+            'updated_before' => $updated_before->format('Y-m-d'),
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/pipelines', $expectedWithArray)
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->pipelines(1, [
+            'updated_after' => $updated_after,
+            'updated_before' => $updated_before,
+        ]));
+    }
+
+    #[Test]
+    public function shouldGetPipelinesWithSHA(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'status' => 'success', 'ref' => 'new-pipeline'],
+            ['id' => 2, 'status' => 'failed', 'ref' => 'new-pipeline'],
+            ['id' => 3, 'status' => 'pending', 'ref' => 'test-pipeline'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/pipelines', ['sha' => '123'])
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->pipelines(1, ['sha' => '123']));
+    }
+
+    #[Test]
+    public function shouldGetPipeline(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'status' => 'success', 'ref' => 'new-pipeline'],
+            ['id' => 2, 'status' => 'failed', 'ref' => 'new-pipeline'],
+            ['id' => 3, 'status' => 'pending', 'ref' => 'test-pipeline'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/pipelines/3')
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->pipeline(1, 3));
+    }
+
+    #[Test]
+    public function shouldGetPipelineJobs(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'status' => 'success', 'stage' => 'Build'],
+            ['id' => 2, 'status' => 'failed', 'stage' => 'Build'],
+            ['id' => 3, 'status' => 'pending', 'stage' => 'Build'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/pipelines/3/jobs')
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->pipelineJobs(1, 3));
+    }
+
+    #[Test]
+    public function shouldGetPipelineVariables(): void
+    {
+        $expectedArray = [
+            ['key' => 'foo', 'value' => 'bar'],
+            ['key' => 'baz', 'value' => '1234'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/pipelines/3/variables')
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->pipelineVariables(1, 3));
+    }
+
+    #[Test]
+    public function shouldGetPipelineTestReport(): void
+    {
+        $expectedArray = [
+            'total_time' => 0.011809,
+            'total_count' => 8,
+            'success_count' => 8,
+            'failed_count' => 0,
+            'skipped_count' => 0,
+            'error_count' => 0,
+            'test_suites' => [],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/pipelines/3/test_report')
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->pipelineTestReport(1, 3));
+    }
+
+    #[Test]
+    public function shouldGetPipelineTestReportSummary(): void
+    {
+        $expectedArray = [
+            'total_time' => 0.011809,
+            'total_count' => 8,
+            'success_count' => 8,
+            'failed_count' => 0,
+            'skipped_count' => 0,
+            'error_count' => 0,
+            'test_suites' => [],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/pipelines/3/test_report_summary')
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->pipelineTestReportSummary(1, 3));
+    }
+
+    #[Test]
+    public function shouldCreatePipeline(): void
+    {
+        $expectedArray = [
+            ['id' => 4, 'status' => 'created', 'ref' => 'test-pipeline'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/pipeline', [], [], [], ['ref' => 'test-pipeline'])
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->createPipeline(1, 'test-pipeline'));
+    }
+
+    #[Test]
+    public function shouldCreatePipelineWithVariables(): void
+    {
+        $expectedArray = [
+            ['id' => 4, 'status' => 'created', 'ref' => 'test-pipeline'],
+        ];
+        $variables = [
+            [
+                'key' => 'test_var_1',
+                'value' => 'test_value_1',
+            ],
+            [
+                'key' => 'test_var_2',
+                'variable_type' => 'file',
+                'value' => 'test_value_2',
+            ],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/pipeline', ['variables' => $variables], [], [], ['ref' => 'test-pipeline'])
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->createPipeline(1, 'test-pipeline', $variables));
+    }
+
+    #[Test]
+    public function shouldRetryPipeline(): void
+    {
+        $expectedArray = [
+            ['id' => 5, 'status' => 'pending', 'ref' => 'test-pipeline'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/pipelines/4/retry')
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->retryPipeline(1, 4));
+    }
+
+    #[Test]
+    public function shouldCancelPipeline(): void
+    {
+        $expectedArray = [
+            ['id' => 6, 'status' => 'cancelled', 'ref' => 'test-pipeline'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/pipelines/6/cancel')
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->cancelPipeline(1, 6));
+    }
+
+    #[Test]
+    public function shouldDeletePipeline(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('delete')
+            ->with('projects/1/pipelines/3')
+            ->willReturn($expectedBool);
+
+        $this->assertEquals($expectedBool, $api->deletePipeline(1, 3));
+    }
+
+    #[Test]
+    public function shouldGetAllMembers(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'name' => 'Matt'],
+            ['id' => 2, 'name' => 'Bob'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/members/all')
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->allMembers(1));
+    }
+
+    #[Test]
+    public function shouldGetAllMember(): void
+    {
+        $expectedArray = ['id' => 2, 'name' => 'Bob'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/members/all/2')
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->allMember(1, 2));
+    }
+
+    #[Test]
+    public function shouldGetMembers(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'name' => 'Matt'],
+            ['id' => 2, 'name' => 'Bob'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/members')
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->members(1));
+    }
+
+    #[Test]
+    public function shouldGetMembersWithQuery(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'name' => 'Matt'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/members', ['query' => 'at'])
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->members(1, ['query' => 'at']));
+    }
+
+    #[Test]
+    public function shouldGetMembersWithNullQuery(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'name' => 'Matt'],
+            ['id' => 2, 'name' => 'Bob'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/members')
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->members(1));
+    }
+
+    #[Test]
+    public function shouldGetMembersWithPagination(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'name' => 'Matt'],
+            ['id' => 2, 'name' => 'Bob'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/members', [
+                'page' => 2,
+                'per_page' => 15,
+            ])
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->members(1, ['page' => 2, 'per_page' => 15]));
+    }
+
+    #[Test]
+    public function shouldGetMember(): void
+    {
+        $expectedArray = ['id' => 2, 'name' => 'Matt'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/members/2')
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->member(1, 2));
+    }
+
+    #[Test]
+    public function shouldAddMember(): void
+    {
+        $expectedArray = ['id' => 1, 'name' => 'Matt'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/members', ['user_id' => 2, 'access_level' => 3])
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->addMember(1, 2, 3));
+    }
+
+    #[Test]
+    public function shouldAddMemberWithExpiration(): void
+    {
+        // tomorrow
+        $expiration = \date('Y-m-d', \time() + 86400);
+        $expectedArray = [
+            'user_id' => 3,
+            'access_level' => 3,
+            'expires_at' => $expiration,
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/members', ['user_id' => 3, 'access_level' => 3, 'expires_at' => $expiration])
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->addMember(1, 3, 3, $expiration));
+    }
+
+    #[Test]
+    public function shouldSaveMember(): void
+    {
+        $expectedArray = ['id' => 1, 'name' => 'Matt'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('put')
+            ->with('projects/1/members/2', ['access_level' => 4])
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->saveMember(1, 2, 4));
+    }
+
+    #[Test]
+    public function shouldSaveMemberWithExpiration(): void
+    {
+        // tomorrow
+        $expiration = \date('Y-m-d', \time() + 86400);
+        $expectedArray = [
+            'user_id' => 3,
+            'access_level' => 4,
+            'expires_at' => $expiration,
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('put')
+            ->with('projects/1/members/3', ['access_level' => 4, 'expires_at' => $expiration])
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->saveMember(1, 3, 4, $expiration));
+    }
+
+    #[Test]
+    public function shouldRemoveMember(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('delete')
+            ->with('projects/1/members/2')
+            ->willReturn($expectedBool);
+
+        $this->assertEquals($expectedBool, $api->removeMember(1, 2));
+    }
+
+    #[Test]
+    public function shouldGetHooks(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'name' => 'Test hook'],
+            ['id' => 2, 'name' => 'Another hook'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/hooks')
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->hooks(1));
+    }
+
+    #[Test]
+    public function shouldGetHook(): void
+    {
+        $expectedArray = ['id' => 2, 'name' => 'Another hook'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/hooks/2')
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->hook(1, 2));
+    }
+
+    #[Test]
+    public function shouldAddHook(): void
+    {
+        $expectedArray = ['id' => 3, 'name' => 'A new hook', 'url' => 'http://www.example.com'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/hooks', [
+                'url' => 'http://www.example.com',
+                'push_events' => true,
+                'issues_events' => true,
+                'merge_requests_events' => true,
+            ])
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->addHook(
+            1,
+            'http://www.example.com',
+            ['push_events' => true, 'issues_events' => true, 'merge_requests_events' => true]
+        ));
+    }
+
+    #[Test]
+    public function shouldAddHookWithOnlyUrl(): void
+    {
+        $expectedArray = ['id' => 3, 'name' => 'A new hook', 'url' => 'http://www.example.com'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/hooks', ['url' => 'http://www.example.com', 'push_events' => true])
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->addHook(1, 'http://www.example.com'));
+    }
+
+    #[Test]
+    public function shouldAddHookWithoutPushEvents(): void
+    {
+        $expectedArray = ['id' => 3, 'name' => 'A new hook', 'url' => 'http://www.example.com'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/hooks', ['url' => 'http://www.example.com', 'push_events' => false])
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->addHook(1, 'http://www.example.com', ['push_events' => false]));
+    }
+
+    #[Test]
+    public function shouldUpdateHook(): void
+    {
+        $expectedArray = ['id' => 3, 'name' => 'A new hook', 'url' => 'http://www.example.com'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('put')
+            ->with('projects/1/hooks/3', ['url' => 'http://www.example-test.com', 'push_events' => false])
+            ->willReturn($expectedArray);
+
+        $this->assertEquals(
+            $expectedArray,
+            $api->updateHook(1, 3, ['url' => 'http://www.example-test.com', 'push_events' => false])
+        );
+    }
+
+    #[Test]
+    public function shouldRemoveHook(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('delete')
+            ->with('projects/1/hooks/2')
+            ->willReturn($expectedBool);
+
+        $this->assertEquals($expectedBool, $api->removeHook(1, 2));
+    }
+
+    #[Test]
+    public function shouldTransfer(): void
+    {
+        $expectedArray = [
+            'id' => 1,
+            'name' => 'Project Name',
+            'namespace' => ['name' => 'a_namespace'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('put')
+            ->with('projects/1/transfer', ['namespace' => 'a_namespace'])
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->transfer(1, 'a_namespace'));
+    }
+
+    #[Test]
+    public function shouldGetDeployKeys(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'title' => 'test-key'],
+            ['id' => 2, 'title' => 'another-key'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/deploy_keys')
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->deployKeys(1));
+    }
+
+    #[Test]
+    public function shouldGetDeployKey(): void
+    {
+        $expectedArray = ['id' => 2, 'title' => 'another-key'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/deploy_keys/2')
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->deployKey(1, 2));
+    }
+
+    #[Test]
+    public function shouldAddKey(): void
+    {
+        $expectedArray = ['id' => 3, 'title' => 'new-key', 'can_push' => false];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/deploy_keys', ['title' => 'new-key', 'key' => '...', 'can_push' => false])
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->addDeployKey(1, 'new-key', '...'));
+    }
+
+    #[Test]
+    public function shouldAddKeyWithPushOption(): void
+    {
+        $expectedArray = ['id' => 3, 'title' => 'new-key', 'can_push' => true];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/deploy_keys', ['title' => 'new-key', 'key' => '...', 'can_push' => true])
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->addDeployKey(1, 'new-key', '...', true));
+    }
+
+    #[Test]
+    public function shouldDeleteDeployKey(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('delete')
+            ->with('projects/1/deploy_keys/3')
+            ->willReturn($expectedBool);
+
+        $this->assertEquals($expectedBool, $api->deleteDeployKey(1, 3));
+    }
+
+    #[Test]
+    public function shoudEnableDeployKey(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/deploy_keys/3/enable')
+            ->willReturn($expectedBool);
+
+        $this->assertEquals($expectedBool, $api->enableDeployKey(1, 3));
+    }
+
+    #[Test]
+    public function shouldGetDeployTokens(): void
+    {
+        $expectedArray = [
+            [
+                'id' => 1,
+                'name' => 'MyToken',
+                'username' => 'gitlab+deploy-token-1',
+                'expires_at' => '2020-02-14T00:00:00.000Z',
+                'revoked' => false,
+                'expired' => false,
+                'scopes' => [
+                    'read_repository',
+                    'read_registry',
+                ],
+            ],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/deploy_tokens')
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->deployTokens(1));
+    }
+
+    #[Test]
+    public function shouldGetActiveDeployTokens(): void
+    {
+        $expectedArray = [
+            [
+                'id' => 1,
+                'name' => 'MyToken',
+                'username' => 'gitlab+deploy-token-1',
+                'expires_at' => '2020-02-14T00:00:00.000Z',
+                'revoked' => false,
+                'expired' => true,
+                'scopes' => [
+                    'read_repository',
+                    'read_registry',
+                ],
+            ],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/deploy_tokens', ['active' => true])
+            ->willReturn([]);
+
+        $this->assertEquals([], $api->deployTokens(1, true));
+    }
+
+    #[Test]
+    public function shouldGetInactiveDeployTokens(): void
+    {
+        $expectedArray = [
+            [
+                'id' => 1,
+                'name' => 'MyToken',
+                'username' => 'gitlab+deploy-token-1',
+                'expires_at' => '2020-02-14T00:00:00.000Z',
+                'revoked' => false,
+                'expired' => true,
+                'scopes' => [
+                    'read_repository',
+                    'read_registry',
+                ],
+            ],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/deploy_tokens', ['active' => false])
+            ->willReturn([]);
+
+        $this->assertEquals([], $api->deployTokens(1, false));
+    }
+
+    #[Test]
+    public function shouldCreateDeployToken(): void
+    {
+        $expectedArray = [
+            'id' => 1,
+            'name' => 'My Deploy Token',
+            'username' => 'custom-user',
+            'token' => 'jMRvtPNxrn3crTAGukpZ',
+            'expires_at' => '2021-01-01T00:00:00.000Z',
+            'revoked' => false,
+            'expired' => false,
+            'scopes' => [
+                'read_repository',
+                'read_registry',
+            ],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with(
+                'projects/1/deploy_tokens',
+                [
+                    'name' => 'My Deploy Token',
+                    'scopes' => [
+                        'read_repository',
+                        'read_registry',
+                    ],
+                    'expires_at' => (new DateTime('2021-01-01'))->format('c'),
+                ]
+            )
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->createDeployToken(1, [
+            'name' => 'My Deploy Token',
+            'scopes' => [
+                'read_repository',
+                'read_registry',
+            ],
+            'expires_at' => new DateTime('2021-01-01'),
+        ]));
+    }
+
+    #[Test]
+    public function shouldDeleteDeployToken(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('delete')
+            ->with('projects/1/deploy_tokens/2')
+            ->willReturn($expectedBool);
+
+        $this->assertEquals($expectedBool, $api->deleteDeployToken(1, 2));
+    }
+
+    #[Test]
+    public function shouldGetEvents(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'title' => 'An event'],
+            ['id' => 2, 'title' => 'Another event'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/events', [])
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->events(1));
+    }
+
+    #[Test]
+    public function shouldGetEventsWithDateTimeParams(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'title' => 'An event'],
+            ['id' => 2, 'title' => 'Another event'],
+        ];
+
+        $after = new DateTime('2018-01-01 00:00:00');
+        $before = new DateTime('2018-01-31 00:00:00');
+
+        $expectedWithArray = [
+            'after' => $after->format('Y-m-d'),
+            'before' => $before->format('Y-m-d'),
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/events', $expectedWithArray)
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->events(1, ['after' => $after, 'before' => $before]));
+    }
+
+    #[Test]
+    public function shouldGetEventsWithPagination(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'title' => 'An event'],
+            ['id' => 2, 'title' => 'Another event'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/events', [
+                'page' => 2,
+                'per_page' => 15,
+            ])
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->events(1, ['page' => 2, 'per_page' => 15]));
+    }
+
+    #[Test]
+    public function shouldGetLabels(): void
+    {
+        $expectedArray = [
+            ['id' => 987, 'name' => 'bug', 'color' => '#000000'],
+            ['id' => 123, 'name' => 'feature', 'color' => '#ff0000'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/labels')
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->labels(1));
+    }
+
+    #[Test]
+    public function shouldAddLabel(): void
+    {
+        $expectedArray = ['name' => 'bug', 'color' => '#000000'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/labels', ['name' => 'wont-fix', 'color' => '#ffffff'])
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->addLabel(1, ['name' => 'wont-fix', 'color' => '#ffffff']));
+    }
+
+    #[Test]
+    public function shouldUpdateLabel(): void
+    {
+        $expectedArray = ['name' => 'bug', 'color' => '#00ffff'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('put')
+            ->with('projects/1/labels/123', ['new_name' => 'big-bug', 'color' => '#00ffff'])
+            ->willReturn($expectedArray);
+
+        $this->assertEquals(
+            $expectedArray,
+            $api->updateLabel(1, 123, ['new_name' => 'big-bug', 'color' => '#00ffff'])
+        );
+    }
+
+    #[Test]
+    public function shouldRemoveLabel(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('delete')
+            ->with('projects/1/labels/456', [])
+            ->willReturn($expectedBool);
+
+        $this->assertEquals($expectedBool, $api->removeLabel(1, 456));
+    }
+
+    #[Test]
+    public function shouldGetLanguages(): void
+    {
+        $expectedArray = ['php' => 100];
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->languages(1));
+    }
+
+    #[Test]
+    public function shouldForkWithNamespace(): void
+    {
+        $expectedArray = [
+            'namespace' => 'new_namespace',
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/fork', $expectedArray)
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->fork(1, [
+            'namespace' => 'new_namespace',
+        ]));
+    }
+
+    #[Test]
+    public function shouldForkWithNamespaceAndPath(): void
+    {
+        $expectedArray = [
+            'namespace' => 'new_namespace',
+            'path' => 'new_path',
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/fork', $expectedArray)
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->fork(1, [
+            'namespace' => 'new_namespace',
+            'path' => 'new_path',
+        ]));
+    }
+
+    #[Test]
+    public function shouldForkWithNamespaceAndPathAndName(): void
+    {
+        $expectedArray = [
+            'namespace' => 'new_namespace',
+            'path' => 'new_path',
+            'name' => 'new_name',
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/fork', $expectedArray)
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->fork(1, [
+            'namespace' => 'new_namespace',
+            'path' => 'new_path',
+            'name' => 'new_name',
+        ]));
+    }
+
+    #[Test]
+    public function shouldCreateForkRelation(): void
+    {
+        $expectedArray = ['project_id' => 1, 'forked_id' => 2];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/fork/2')
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->createForkRelation(1, 2));
+    }
+
+    #[Test]
+    public function shouldRemoveForkRelation(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('delete')
+            ->with('projects/2/fork')
+            ->willReturn($expectedBool);
+
+        $this->assertEquals($expectedBool, $api->removeForkRelation(2));
+    }
+
+    #[Test]
+    public function shouldGetForks(): void
+    {
+        $expectedArray = [
+            [
+                'id' => 2,
+                'forked_from_project' => [
+                    'id' => 1,
+                ],
+            ],
+            [
+                'id' => 3,
+                'forked_from_project' => [
+                    'id' => 1,
+                ],
+            ],
+        ];
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/forks')
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->forks(1));
+    }
+
+    #[Test]
+    public function shouldGetForksUsingParameters(): void
+    {
+        $expectedArray = [
+            [
+                'id' => 2,
+                'forked_from_project' => [
+                    'id' => 1,
+                ],
+            ],
+            [
+                'id' => 3,
+                'forked_from_project' => [
+                    'id' => 1,
+                ],
+            ],
+        ];
+        $updated_after = new DateTime('2018-01-01 00:00:00');
+        $updated_before = new DateTime('2018-01-31 00:00:00');
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/forks', [
+                'archived' => 'false',
+                'visibility' => 'public',
+                'order_by' => 'id',
+                'sort' => 'asc',
+                'search' => 'term',
+                'simple' => 'true',
+                'owned' => 'false',
+                'membership' => 'false',
+                'starred' => 'false',
+                'statistics' => 'false',
+                'with_issues_enabled' => 'false',
+                'with_merge_requests_enabled' => 'false',
+                'min_access_level' => 30,
+                'updated_after' => $updated_after->format('c'),
+                'updated_before' => $updated_before->format('c'),
+                'with_custom_attributes' => 'true',
+            ])
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->forks(1, [
+            'archived' => false,
+            'visibility' => 'public',
+            'order_by' => 'id',
+            'sort' => 'asc',
+            'search' => 'term',
+            'simple' => true,
+            'owned' => false,
+            'membership' => false,
+            'starred' => false,
+            'statistics' => false,
+            'with_issues_enabled' => false,
+            'with_merge_requests_enabled' => false,
+            'min_access_level' => 30,
+            'updated_after' => $updated_after,
+            'updated_before' => $updated_before,
+            'with_custom_attributes' => true,
+        ]));
+    }
+
+    #[Test]
+    public function shouldSetService(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('put')
+            ->with('projects/1/services/hipchat', ['param' => 'value'])
+            ->willReturn($expectedBool);
+
+        $this->assertEquals($expectedBool, $api->setService(1, 'hipchat', ['param' => 'value']));
+    }
+
+    #[Test]
+    public function shouldRemoveService(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('delete')
+            ->with('projects/1/services/hipchat')
+            ->willReturn($expectedBool);
+
+        $this->assertEquals($expectedBool, $api->removeService(1, 'hipchat'));
+    }
+
+    #[Test]
+    public function shouldGetVariables(): void
+    {
+        $expectedArray = [
+            ['key' => 'ftp_username', 'value' => 'ftp'],
+            ['key' => 'ftp_password', 'value' => 'somepassword'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/variables')
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->variables(1));
+    }
+
+    #[Test]
+    public function shouldGetVariable(): void
+    {
+        $expectedArray = ['key' => 'ftp_username', 'value' => 'ftp'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/variables/ftp_username')
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->variable(1, 'ftp_username'));
+    }
+
+    #[Test]
+    public function shouldAddVariable(): void
+    {
+        $expectedKey = 'ftp_port';
+        $expectedValue = '21';
+
+        $expectedArray = [
+            'key' => $expectedKey,
+            'value' => $expectedValue,
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/variables', $expectedArray)
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->addVariable(1, $expectedKey, $expectedValue));
+    }
+
+    #[Test]
+    public function shouldAddVariableWithProtected(): void
+    {
+        $expectedArray = [
+            'key' => 'DEPLOY_SERVER',
+            'value' => 'stage.example.com',
+            'protected' => true,
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/variables', $expectedArray)
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->addVariable(1, 'DEPLOY_SERVER', 'stage.example.com', true));
+    }
+
+    #[Test]
+    public function shouldAddVariableWithEnvironment(): void
+    {
+        $expectedArray = [
+            'key' => 'DEPLOY_SERVER',
+            'value' => 'stage.example.com',
+            'environment_scope' => 'staging',
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/variables', $expectedArray)
+            ->willReturn($expectedArray);
+
+        $this->assertEquals(
+            $expectedArray,
+            $api->addVariable(1, 'DEPLOY_SERVER', 'stage.example.com', null, 'staging')
+        );
+    }
+
+    #[Test]
+    public function shouldAddVariableWithProtectionAndEnvironment(): void
+    {
+        $expectedArray = [
+            'key' => 'DEPLOY_SERVER',
+            'value' => 'stage.example.com',
+            'protected' => true,
+            'environment_scope' => 'staging',
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/variables', $expectedArray)
+            ->willReturn($expectedArray);
+
+        $this->assertEquals(
+            $expectedArray,
+            $api->addVariable(1, 'DEPLOY_SERVER', 'stage.example.com', true, 'staging')
+        );
+    }
+
+    #[Test]
+    public function shouldAddVariableWithEnvironmentAndVariableType(): void
+    {
+        $expectedArray = [
+            'key' => 'DEPLOY_SERVER',
+            'value' => 'stage.example.com',
+            'environment_scope' => 'staging',
+            'variable_type' => 'file',
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/variables', $expectedArray)
+            ->willReturn($expectedArray);
+
+        $this->assertEquals(
+            $expectedArray,
+            $api->addVariable(1, 'DEPLOY_SERVER', 'stage.example.com', null, 'staging', ['variable_type' => 'file'])
+        );
+    }
+
+    #[Test]
+    public function shouldAddVariableWithEnvironmentFromParameterList(): void
+    {
+        $expectedArray = [
+            'key' => 'DEPLOY_SERVER',
+            'value' => 'stage.example.com',
+            'environment_scope' => 'staging',
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/variables', $expectedArray)
+            ->willReturn($expectedArray);
+
+        $this->assertEquals(
+            $expectedArray,
+            $api->addVariable(1, 'DEPLOY_SERVER', 'stage.example.com', null, 'staging', ['environment_scope' => 'production'])
+        );
+    }
+
+    #[Test]
+    public function shouldUpdateVariable(): void
+    {
+        $expectedKey = 'ftp_port';
+        $expectedValue = '22';
+
+        $expectedArray = [
+            'key' => 'ftp_port',
+            'value' => '22',
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('put')
+            ->with('projects/1/variables/'.$expectedKey, ['value' => $expectedValue])
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->updateVariable(1, $expectedKey, $expectedValue));
+    }
+
+    #[Test]
+    public function shouldUpdateVariableWithProtected(): void
+    {
+        $expectedArray = [
+            'key' => 'DEPLOY_SERVER',
+            'value' => 'stage.example.com',
+            'protected' => true,
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('put')
+            ->with('projects/1/variables/DEPLOY_SERVER', ['value' => 'stage.example.com', 'protected' => true])
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->updateVariable(1, 'DEPLOY_SERVER', 'stage.example.com', true));
+    }
+
+    #[Test]
+    public function shouldUpdateVariableWithEnvironment(): void
+    {
+        $expectedArray = [
+            'key' => 'DEPLOY_SERVER',
+            'value' => 'stage.example.com',
+            'environment_scope' => 'staging',
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('put')
+            ->with(
+                'projects/1/variables/DEPLOY_SERVER',
+                ['value' => 'stage.example.com', 'environment_scope' => 'staging']
+            )
+            ->willReturn($expectedArray);
+
+        $this->assertEquals(
+            $expectedArray,
+            $api->updateVariable(1, 'DEPLOY_SERVER', 'stage.example.com', null, 'staging')
+        );
+    }
+
+    #[Test]
+    public function shouldUpdateVariableWithProtectedAndEnvironment(): void
+    {
+        $expectedArray = [
+            'key' => 'DEPLOY_SERVER',
+            'value' => 'stage.example.com',
+            'protected' => true,
+            'environment_scope' => 'staging',
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('put')
+            ->with(
+                'projects/1/variables/DEPLOY_SERVER',
+                ['value' => 'stage.example.com', 'protected' => true, 'environment_scope' => 'staging']
+            )
+            ->willReturn($expectedArray);
+
+        $this->assertEquals(
+            $expectedArray,
+            $api->updateVariable(1, 'DEPLOY_SERVER', 'stage.example.com', true, 'staging')
+        );
+    }
+
+    #[Test]
+    public function shouldUpdateVariableWithEnvironmentAndVariableType(): void
+    {
+        $expectedArray = [
+            'key' => 'DEPLOY_SERVER',
+            'value' => 'stage.example.com',
+            'environment_scope' => 'staging',
+            'variable_type' => 'file',
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('put')
+            ->with(
+                'projects/1/variables/DEPLOY_SERVER',
+                ['value' => 'stage.example.com', 'environment_scope' => 'staging', 'variable_type' => 'file']
+            )
+            ->willReturn($expectedArray);
+
+        $this->assertEquals(
+            $expectedArray,
+            $api->updateVariable(1, 'DEPLOY_SERVER', 'stage.example.com', null, 'staging', ['variable_type' => 'file'])
+        );
+    }
+
+    #[Test]
+    public function shouldUpdateVariableWithEnvironmentFromParameterList(): void
+    {
+        $expectedArray = [
+            'key' => 'DEPLOY_SERVER',
+            'value' => 'stage.example.com',
+            'environment_scope' => 'staging',
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('put')
+            ->with(
+                'projects/1/variables/DEPLOY_SERVER',
+                ['value' => 'stage.example.com', 'environment_scope' => 'staging']
+            )
+            ->willReturn($expectedArray);
+
+        $this->assertEquals(
+            $expectedArray,
+            $api->updateVariable(1, 'DEPLOY_SERVER', 'stage.example.com', null, 'staging', ['environment_scope' => 'production'])
+        );
+    }
+
+    #[Test]
+    public function shouldRemoveVariable(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('delete')
+            ->with('projects/1/variables/ftp_password')
+            ->willReturn($expectedBool);
+
+        $this->assertEquals($expectedBool, $api->removeVariable(1, 'ftp_password'));
+    }
+
+    protected function getMultipleProjectsRequestMock($path, $expectedArray = [], $expectedParameters = []): MockObject
+    {
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with($path, $expectedParameters)
+            ->willReturn($expectedArray);
+
+        return $api;
+    }
+
+    #[Test]
+    public function shouldGetDeployments(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'sha' => '0000001'],
+            ['id' => 2, 'sha' => '0000002'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/deployments', [])
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->deployments(1));
+    }
+
+    #[Test]
+    public function shouldGetDeploymentsWithPagination(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'sha' => '0000001'],
+            ['id' => 2, 'sha' => '0000002'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/deployments', [
+                'page' => 2,
+                'per_page' => 15,
+            ])
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->deployments(1, ['page' => 2, 'per_page' => 15]));
+    }
+
+    #[Test]
+    public function shouldGetDeploymentsSorted(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'sha' => '0000001'],
+            ['id' => 2, 'sha' => '0000002'],
+            ['id' => 3, 'sha' => '0000003'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/deployments', [
+                'order_by' => 'id',
+                'sort' => 'asc',
+            ])
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->deployments(1, ['order_by' => 'id', 'sort' => 'asc']));
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/deployments', [
+                'order_by' => 'id',
+                'sort' => 'desc',
+            ])
+            ->willReturn(\array_reverse($expectedArray));
+
+        $this->assertEquals(\array_reverse($expectedArray), $api->deployments(1, ['order_by' => 'id', 'sort' => 'desc']));
+    }
+
+    #[Test]
+    public function shouldGetDeploymentsFiltered(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'sha' => '0000001'],
+            ['id' => 2, 'sha' => '0000002'],
+            ['id' => 3, 'sha' => '0000003'],
+        ];
+
+        $time = new DateTime('now');
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/deployments', [
+                'updated_after' => $time->format('c'),
+            ])
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->deployments(1, ['updated_after' => $time]));
+    }
+
+    protected function getMultipleProjectsData(): array
+    {
+        return [
+            ['id' => 1, 'name' => 'A project'],
+            ['id' => 2, 'name' => 'Another project'],
+        ];
+    }
+
+    protected function getMultipleProjectsDataWithNamespace(): array
+    {
+        return [
+            ['id' => 1, 'name' => 'A project', 'namespace' => ['id' => 4, 'name' => 'A namespace', 'path' => 'a_namespace']],
+            ['id' => 2, 'name' => 'Another project', 'namespace' => ['id' => 5, 'name' => 'Another namespace', 'path' => 'another_namespace']],
+        ];
+    }
+
+    public static function possibleAccessLevels(): array
+    {
+        return [
+            [10],
+            [20],
+            [30],
+            [40],
+            [50],
+        ];
+    }
+
+    public function getBadgeExpectedArray(): array
+    {
+        return [
+            [
+                'id' => 1,
+                'link_url' => 'http://example.com/ci_status.svg?project=%{project_path}&ref=%{default_branch}',
+                'image_url' => 'https://shields.io/my/badge',
+                'rendered_link_url' => 'http://example.com/ci_status.svg?project=example-org/example-project&ref=master',
+                'rendered_image_url' => 'https://shields.io/my/badge',
+                'kind' => 'project',
+            ],
+            [
+                'id' => 2,
+                'link_url' => 'http://example.com/ci_status.svg?project=%{project_path}&ref=%{default_branch}',
+                'image_url' => 'https://shields.io/my/badge',
+                'rendered_link_url' => 'http://example.com/ci_status.svg?project=example-org/example-project&ref=master',
+                'rendered_image_url' => 'https://shields.io/my/badge',
+                'kind' => 'group',
+            ],
+        ];
+    }
+
+    #[Test]
+    public function shouldGetBadges(): void
+    {
+        $expectedArray = $this->getBadgeExpectedArray();
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/badges')
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->badges(1));
+    }
+
+    #[Test]
+    public function shouldGetBadge(): void
+    {
+        $expectedBadgesArray = $this->getBadgeExpectedArray();
+        $expectedArray = [
+            $expectedBadgesArray[0],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/badges/1')
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->badge(1, 1));
+    }
+
+    #[Test]
+    public function shouldAddBadge(): void
+    {
+        $link_url = 'http://example.com/ci_status.svg?project=%{project_path}&ref=%{default_branch}';
+        $image_url = 'https://shields.io/my/badge';
+        $expectedArray = [
+            'id' => 3,
+            'link_url' => $link_url,
+            'image_url' => $image_url,
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/badges', ['link_url' => $link_url, 'image_url' => $image_url])
+            ->willReturn($expectedArray);
+
+        $this->assertEquals(
+            $expectedArray,
+            $api->addBadge(1, ['link_url' => $link_url, 'image_url' => $image_url])
+        );
+    }
+
+    #[Test]
+    public function shouldUpdateBadge(): void
+    {
+        $image_url = 'https://shields.io/my/new/badge';
+        $expectedArray = [
+            'id' => 2,
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('put')
+            ->with('projects/1/badges/2')
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->updateBadge(1, 2, ['image_url' => $image_url]));
+    }
+
+    #[Test]
+    public function shouldRemoveBadge(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('delete')
+            ->with('projects/1/badges/1')
+            ->willReturn($expectedBool);
+
+        $this->assertEquals($expectedBool, $api->removeBadge(1, 1));
+    }
+
+    #[Test]
+    public function shouldAddProtectedBranch(): void
+    {
+        $expectedArray = [
+            'name' => 'master',
+            'push_access_level' => [
+                'access_level' => 0,
+                'access_level_description' => 'No one',
+            ],
+            'merge_access_levels' => [
+                'access_level' => 0,
+                'access_level_description' => 'Developers + Maintainers',
+            ],
+        ];
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with(
+                'projects/1/protected_branches',
+                ['name' => 'master', 'push_access_level' => 0, 'merge_access_level' => 30]
+            )
+            ->willReturn($expectedArray);
+        $this->assertEquals($expectedArray, $api->addProtectedBranch(1, ['name' => 'master', 'push_access_level' => 0, 'merge_access_level' => 30]));
+    }
+
+    #[Test]
+    public function shouldRemoveProtectedBranch(): void
+    {
+        $expectedBool = true;
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('delete')
+            ->with(
+                'projects/1/protected_branches/test-branch'
+            )
+            ->willReturn($expectedBool);
+
+        $this->assertEquals($expectedBool, $api->deleteProtectedBranch(1, 'test-branch'));
+    }
+
+    #[Test]
+    public function shoudGetApprovalsConfiguration(): void
+    {
+        $expectedArray = [
+            'approvers' => [],
+            'approver_groups' => [],
+            'approvals_before_merge' => 1,
+            'reset_approvals_on_push' => true,
+            'disable_overriding_approvers_per_merge_request' => null,
+            'merge_requests_author_approval' => null,
+            'merge_requests_disable_committers_approval' => null,
+            'require_password_to_approve' => null,
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/approvals')
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->approvalsConfiguration(1));
+    }
+
+    #[Test]
+    public function shoudGetApprovalsRules(): void
+    {
+        $expectedArray = [
+            [
+                'id' => 1,
+                'name' => 'All Members',
+                'rule_type' => 'any_approver',
+                'eligible_approvers' => [],
+                'approvals_required' => 1,
+                'users' => [],
+                'groups' => [],
+                'contains_hidden_groups' => false,
+                'protected_branches' => [],
+            ],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/approval_rules')
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->approvalsRules(1));
+    }
+
+    #[Test]
+    public function shoudCreateApprovalsRule(): void
+    {
+        $expectedArray = [
+            [
+                'id' => 1,
+                'name' => 'All Members',
+                'rule_type' => 'any_approver',
+            ],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/approval_rules/', ['name' => 'All Members', 'rule_type' => 'any_approver'])
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->createApprovalsRule(1, [
+            'name' => 'All Members',
+            'rule_type' => 'any_approver',
+        ]));
+    }
+
+    #[Test]
+    public function shoudUpdateApprovalsRule(): void
+    {
+        $expectedArray = [
+            [
+                'name' => 'Updated Name',
+            ],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('put')
+            ->with('projects/1/approval_rules/1', ['name' => 'Updated Name'])
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->updateApprovalsRule(1, 1, [
+            'name' => 'Updated Name',
+        ]));
+    }
+
+    #[Test]
+    public function shoudDeleteApprovalsRule(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('delete')
+            ->with('projects/1/approval_rules/1')
+            ->willReturn($expectedBool);
+
+        $this->assertEquals($expectedBool, $api->deleteApprovalsRule(1, 1));
+    }
+
+    #[Test]
+    public function shouldDeleteAllMergedBranches(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('delete')
+            ->with('projects/1/repository/merged_branches')
+            ->willReturn($expectedBool);
+
+        $this->assertEquals($expectedBool, $api->deleteAllMergedBranches(1));
+    }
+
+    #[Test]
+    public function shouldGetProtectedBranches(): void
+    {
+        $expectedArray = [
+            [
+                'id' => 1,
+                'name' => 'master',
+                'push_access_levels' => [
+                    'access_level' => 0,
+                    'access_level_description' => 'No one',
+                    'user_id' => null,
+                    'group_id' => null,
+                ],
+                'merge_access_levels' => [
+                    'access_level' => 40,
+                    'access_level_description' => 'Maintainers',
+                    'user_id' => null,
+                    'group_id' => null,
+                ],
+                'unprotect_access_levels' => [],
+                'code_owner_approval_required' => false,
+            ],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/protected_branches')
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->protectedBranches(1));
+    }
+
+    #[Test]
+    public function shouldGetProjectAccessTokens(): void
+    {
+        $expectedArray = [
+            [
+                'user_id' => 141,
+                'scopes' => [
+                    'api',
+                ],
+                'name' => 'token',
+                'expires_at' => '2021-01-31',
+                'id' => 42,
+                'active' => true,
+                'created_at' => '2021-01-20T22:11:48.151Z',
+                'revoked' => false,
+            ],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/access_tokens')
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->projectAccessTokens(1));
+    }
+
+    #[Test]
+    public function shouldGetProjectAccessToken(): void
+    {
+        $expectedArray = [
+            'user_id' => 141,
+            'scopes' => [
+                'api',
+            ],
+            'name' => 'token',
+            'expires_at' => '2021-01-31',
+            'id' => 42,
+            'active' => true,
+            'created_at' => '2021-01-20T22:11:48.151Z',
+            'revoked' => false,
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/access_tokens/42')
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->projectAccessToken(1, 42));
+    }
+
+    #[Test]
+    public function shouldCreateProjectAccessToken(): void
+    {
+        $expectedArray = [
+            'scopes' => [
+                'api',
+                'read_repository',
+            ],
+            'active' => true,
+            'name' => 'test',
+            'revoked' => false,
+            'created_at' => '2021-01-21T19:35:37.921Z',
+            'user_id' => 166,
+            'id' => 58,
+            'expires_at' => '2021-01-31',
+            'token' => 'D4y...Wzr',
+        ];
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with(
+                'projects/1/access_tokens',
+                [
+                    'name' => 'test_token',
+                    'scopes' => [
+                        'api',
+                        'read_repository',
+                    ],
+                    'access_level' => 30,
+                    'expires_at' => '2021-01-31',
+                ]
+            )
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->createProjectAccessToken(1, [
+            'name' => 'test_token',
+            'scopes' => [
+                'api',
+                'read_repository',
+            ],
+            'access_level' => 30,
+            'expires_at' => new DateTime('2021-01-31'),
+        ]));
+    }
+
+    #[Test]
+    public function shouldDeleteProjectAccessToken(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('delete')
+            ->with('projects/1/access_tokens/2')
+            ->willReturn($expectedBool);
+
+        $this->assertEquals($expectedBool, $api->deleteProjectAccessToken(1, 2));
+    }
+
+    #[Test]
+    public function shouldUploadAvatar(): void
+    {
+        $emptyPNGContents = 'iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAYAAABccqhmAAACYElEQVR42u3UMQEAAAjDMFCO9GEAByQSerQrmQJeagMAAwAMADAAwAAAAwAMADAAwAAAAwAMADAAwAAAAwAMADAAwAAAAwAMADAAwAAAAwAMADAAwAAAAwAMADAAwAAAAwAMADAAwAAAAwAMADAAwADAAAwADAAwAMAAAAMADAAwAMAAAAMADAAwAMAAAAMADAAwAMAAAAMADAAwAMAAAAMADAAwAMAAAAMADAAwAMAAAAMADAAwAMAAAAMADAAwAMAAAAMAAzAAMADAAAADAAwAMADAAAADAAwAMADAAAADAAwAMADAAAADAAwAMADAAAADAAwAMADAAAADAAwAMADAAAADAAwAMADAAAADAAwAMADAAAADAAwADMAAwAAAAwAMADAAwAAAAwAMADAAwAAAAwAMADAAwAAAAwAMADAAwAAAAwAMADAAwAAAAwAMADAAwAAAAwAMADAAwAAAAwAMADAAwAAAAwAMADAAMAAZwAAAAwAMADAAwAAAAwAMADAAwAAAAwAMADAAwAAAAwAMADAAwAAAAwAMADAAwAAAAwAMADAAwAAAAwAMADAAwAAAAwAMADAAwAAAAwAMADAAMADAAAADAAwAMADAAAADAAwAMADAAAADAAwAMADAAAADAAwAMADAAAADAAwAMADAAAADAAwAMADAAAADAAwAMADAAAADAAwAMADAAAADAAwAMAAwAMAAAAMADAAwAMAAAAMADAAwAMAAAAMADAAwAMAAAAMADAAwAMAAAAMADAAwAMAAAAMADAAwAMAAAAMADAAwAMAAAAMADAAwAOCybrx+H1CTHLYAAAAASUVORK5CYII=';
+        $fileName = \uniqid().'.png';
+        $expectedArray = ['id' => 1, 'name' => 'Project Name', 'avatar_url' => 'https://gitlab.example.com/uploads/-/system/project/avatar/1/'.$fileName];
+        \file_put_contents($fileName, \base64_decode($emptyPNGContents));
+        $this->assertFileExists($fileName);
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('put')
+            ->with('projects/1', [], [], ['avatar' => $fileName])
+            ->willReturn($expectedArray);
+        $this->assertEquals($expectedArray, $api->uploadAvatar(1, $fileName));
+        \unlink($fileName);
+    }
+
+    #[Test]
+    public function shouldAddProtectedTag(): void
+    {
+        $expectedArray = [
+            'name' => 'release-*',
+            'create_access_level' => [
+                ['access_level' => 40, 'access_level_description' => 'Maintainers'],
+                ['group_id' => 123],
+            ],
+        ];
+        $api = $this->getApiMock();
+        $params = [
+            'name' => 'release-*',
+            'create_access_level' => 40,
+            'allowed_to_create' => [['group_id' => 123]],
+        ];
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/protected_tags', $params)
+            ->willReturn($expectedArray);
+        $this->assertEquals($expectedArray, $api->addProtectedTag(1, $params));
+    }
+
+    #[Test]
+    public function shouldRemoveProtectedTag(): void
+    {
+        $expectedBool = true;
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('delete')
+            ->with(
+                'projects/1/protected_tags/release-%2A'
+            )
+            ->willReturn($expectedBool);
+
+        $this->assertEquals($expectedBool, $api->deleteProtectedTag(1, 'release-*'));
+    }
+
+    protected function getApiClass(): string
+    {
+        return Projects::class;
+    }
+
+    #[Test]
+    public function shouldSearchGroups(): void
+    {
+        $expectedArray = [
+            ['id' => 6, 'title' => 'Issue 6 bla'],
+            ['id' => 7, 'title' => 'Issue 7 bla'],
+            ['id' => 8, 'title' => 'Issue 8 bla'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/123/search', [
+                'scope' => 'issues',
+                'confidential' => 'false',
+                'search' => 'bla',
+                'order_by' => 'created_at',
+                'sort' => 'desc',
+            ])
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->search(123, [
+            'scope' => 'issues',
+            'confidential' => false,
+            'search' => 'bla',
+            'order_by' => 'created_at',
+            'sort' => 'desc',
+        ]));
+    }
+}
diff --git a/tests/Api/RepositoriesTest.php b/tests/Api/RepositoriesTest.php
new file mode 100644
index 000000000..3b5d73342
--- /dev/null
+++ b/tests/Api/RepositoriesTest.php
@@ -0,0 +1,659 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Tests\Api;
+
+use Gitlab\Api\Repositories;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\Test;
+
+class RepositoriesTest extends TestCase
+{
+    #[Test]
+    public function shouldGetBranches(): void
+    {
+        $expectedArray = [
+            ['name' => 'master'],
+            ['name' => 'develop'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/repository/branches', ['search' => '^term'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->branches(1, ['search' => '^term']));
+    }
+
+    #[Test]
+    public function shouldGetBranch(): void
+    {
+        $expectedArray = ['name' => 'master'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/repository/branches/master')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->branch(1, 'master'));
+    }
+
+    #[Test]
+    public function shouldCreateBranch(): void
+    {
+        $expectedArray = ['name' => 'feature'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/repository/branches', ['branch' => 'feature', 'ref' => 'master'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->createBranch(1, 'feature', 'master'));
+    }
+
+    #[Test]
+    public function shouldDeleteBranch(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('delete')
+            ->with('projects/1/repository/branches/feature%2FTEST-15')
+            ->willReturn($expectedBool);
+
+        $this->assertEquals($expectedBool, $api->deleteBranch(1, 'feature/TEST-15'));
+    }
+
+    #[Test]
+    public function shouldProtectBranch(): void
+    {
+        $expectedArray = ['name' => 'master'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('put')
+            ->with('projects/1/repository/branches/master/protect', ['developers_can_push' => false, 'developers_can_merge' => false])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->protectBranch(1, 'master'));
+    }
+
+    #[Test]
+    public function shouldProtectBranchWithPermissions(): void
+    {
+        $expectedArray = ['name' => 'master'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('put')
+            ->with('projects/1/repository/branches/master/protect', ['developers_can_push' => true, 'developers_can_merge' => true])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->protectBranch(1, 'master', true, true));
+    }
+
+    #[Test]
+    public function shouldUnprotectBranch(): void
+    {
+        $expectedArray = ['name' => 'master'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('put')
+            ->with('projects/1/repository/branches/master/unprotect')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->unprotectBranch(1, 'master'));
+    }
+
+    #[Test]
+    public function shouldGetTags(): void
+    {
+        $expectedArray = [
+            ['name' => '1.0'],
+            ['name' => '1.1'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/repository/tags')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->tags(1, ['search' => '^term']));
+    }
+
+    #[Test]
+    public function shouldCreateTag(): void
+    {
+        $expectedArray = ['name' => '1.0'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/repository/tags', [
+                'tag_name' => '1.0',
+                'ref' => 'abcd1234',
+                'message' => '1.0 release',
+            ])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->createTag(1, '1.0', 'abcd1234', '1.0 release'));
+    }
+
+    #[Test]
+    public function shouldCreateRelease(): void
+    {
+        $project_id = 1;
+        $tagName = 'sometag';
+        $description = '1.0 release';
+
+        $expectedArray = ['name' => $tagName];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/'.$project_id.'/releases', [
+                'id' => $project_id,
+                'tag_name' => $tagName,
+                'description' => $description,
+            ])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->createRelease($project_id, $tagName, $description));
+    }
+
+    #[Test]
+    public function shouldUpdateRelease(): void
+    {
+        $project_id = 1;
+        $tagName = 'sometag';
+        $description = '1.0 release';
+
+        $expectedArray = ['description' => $tagName];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('put')
+            ->with('projects/'.$project_id.'/releases/'.$tagName, [
+                'id' => $project_id,
+                'tag_name' => $tagName,
+                'description' => $description,
+            ])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->updateRelease($project_id, $tagName, $description));
+    }
+
+    #[Test]
+    public function shouldGetReleases(): void
+    {
+        $project_id = 1;
+
+        $expectedArray = [
+            [
+                'tag_name' => 'v0.2',
+                'description' => '## CHANGELOG\r\n\r\n- Escape label and milestone titles to prevent XSS in GFM autocomplete. !2740\r\n- Prevent private snippets from being embeddable.\r\n- Add subresources removal to member destroy service.',
+                'name' => 'Awesome app v0.2 beta',
+            ],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/releases')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->releases($project_id));
+    }
+
+    #[Test]
+    public function shouldGetCommits(): void
+    {
+        $expectedArray = [
+            ['id' => 'abcd1234', 'title' => 'A commit'],
+            ['id' => 'efgh5678', 'title' => 'Another commit'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/repository/commits', [])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->commits(1));
+    }
+
+    #[Test]
+    public function shouldGetCommitsWithParams(): void
+    {
+        $expectedArray = [
+            ['id' => 'abcd1234', 'title' => 'A commit'],
+            ['id' => 'efgh5678', 'title' => 'Another commit'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/repository/commits', ['page' => 2, 'per_page' => 25, 'ref_name' => 'master', 'all' => 'true', 'with_stats' => 'true', 'path' => 'file_path/file_name'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->commits(1, ['page' => 2, 'per_page' => 25, 'ref_name' => 'master', 'all' => true, 'with_stats' => true, 'path' => 'file_path/file_name']));
+    }
+
+    #[Test]
+    public function shouldGetCommitsWithTimeParams(): void
+    {
+        $expectedArray = [
+            ['id' => 'abcd1234', 'title' => 'A commit'],
+            ['id' => 'efgh5678', 'title' => 'Another commit'],
+        ];
+
+        $since = new \DateTime('2018-01-01 00:00:00');
+        $until = new \DateTime('2018-01-31 00:00:00');
+
+        $expectedWithArray = [
+            'since' => $since->format(\DATE_ATOM),
+            'until' => $until->format(\DATE_ATOM),
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/repository/commits', $expectedWithArray)
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->commits(1, ['since' => $since, 'until' => $until]));
+    }
+
+    #[Test]
+    public function shouldGetCommit(): void
+    {
+        $expectedArray = ['id' => 'abcd1234', 'title' => 'A commit'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/repository/commits/abcd1234')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->commit(1, 'abcd1234'));
+    }
+
+    #[Test]
+    public function shouldGetCommitRefs(): void
+    {
+        $expectedArray = [
+            ['type' => 'branch', 'name' => 'master'],
+            ['type' => 'tag', 'name' => 'v1.1.0'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/repository/commits/abcd1234/refs')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->commitRefs(1, 'abcd1234'));
+    }
+
+    #[Test]
+    #[DataProvider('dataGetCommitRefsWithParams')]
+    public function shouldGetCommitRefsWithParams(string $type, array $expectedArray): void
+    {
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/repository/commits/abcd1234/refs', ['type' => $type])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->commitRefs(1, 'abcd1234', ['type' => $type]));
+    }
+
+    public static function dataGetCommitRefsWithParams(): array
+    {
+        return [
+            'type_tag' => [
+                'type' => Repositories::TYPE_TAG,
+                'expectedArray' => [['type' => 'tag', 'name' => 'v1.1.0']],
+            ],
+            'type_branch' => [
+                'type' => Repositories::TYPE_BRANCH,
+                'expectedArray' => [['type' => 'branch', 'name' => 'master']],
+            ],
+        ];
+    }
+
+    #[Test]
+    public function shouldCreateCommit(): void
+    {
+        $expectedArray = ['title' => 'Initial commit.', 'author_name' => 'John Doe', 'author_email' => 'john@example.com'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/repository/commits')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->createCommit(1, [
+            'branch' => 'master',
+            'commit_message' => 'Initial commit.',
+            'actions' => [
+                [
+                    'action' => 'create',
+                    'file_path' => 'README.md',
+                    'content' => '# My new project',
+                ],
+                [
+                    'action' => 'create',
+                    'file_path' => 'LICENSE',
+                    'content' => 'MIT License...',
+                ],
+            ],
+            'author_name' => 'John Doe',
+            'author_email' => 'john@example.com',
+        ]));
+    }
+
+    #[Test]
+    public function shouldRevertCommit(): void
+    {
+        $expectedArray = ['title' => 'Initial commit.', 'author_name' => 'John Doe', 'author_email' => 'john@example.com'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/repository/commits/abcd1234/revert')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->revertCommit(1, 'develop', 'abcd1234'));
+    }
+
+    #[Test]
+    public function shouldGetCommitComments(): void
+    {
+        $expectedArray = [
+            ['note' => 'A commit message'],
+            ['note' => 'Another commit message'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/repository/commits/abcd1234/comments')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->commitComments(1, 'abcd1234'));
+    }
+
+    #[Test]
+    public function shouldCreateCommitComment(): void
+    {
+        $expectedArray = ['id' => 2, 'title' => 'A new comment'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/repository/commits/abcd1234/comments', ['note' => 'A new comment'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->createCommitComment(1, 'abcd1234', 'A new comment'));
+    }
+
+    #[Test]
+    public function shouldCreateCommitCommentWithParams(): void
+    {
+        $expectedArray = ['id' => 2, 'title' => 'A new comment'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/repository/commits/abcd1234/comments', [
+                'note' => 'A new comment',
+                'path' => '/some/file.txt',
+                'line' => 123, 'line_type' => 'old',
+            ])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->createCommitComment(1, 'abcd1234', 'A new comment', [
+            'path' => '/some/file.txt',
+            'line' => 123,
+            'line_type' => 'old',
+        ]));
+    }
+
+    #[Test]
+    public function shouldCompareStraight(): void
+    {
+        $expectedArray = ['commit' => 'object'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/repository/compare', ['from' => 'master', 'to' => 'feature', 'straight' => 'true'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->compare(1, 'master', 'feature', true));
+    }
+
+    #[Test]
+    public function shouldNotCompareStraight(): void
+    {
+        $expectedArray = ['commit' => 'object'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/repository/compare', ['from' => 'master', 'to' => 'feature', 'straight' => 'false'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->compare(1, 'master', 'feature'));
+    }
+
+    #[Test]
+    public function shouldCompareComplexBranchName(): void
+    {
+        $expectedArray = ['commit' => 'object'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/repository/compare', ['from' => 'master', 'to' => 'feature/760.fake-branch', 'straight' => 'true'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->compare(1, 'master', 'feature/760.fake-branch', true));
+    }
+
+    #[Test]
+    public function shouldCompareWithFromProjectId(): void
+    {
+        $expectedArray = ['commit' => 'object'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/repository/compare', ['from' => 'master', 'to' => 'feature', 'straight' => 'true', 'from_project_id' => '123'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->compare(1, 'master', 'feature', true, '123'));
+    }
+
+    #[Test]
+    public function shouldGetDiff(): void
+    {
+        $expectedArray = [
+            ['diff' => '--- ...'],
+            ['diff' => '+++ ...'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/repository/commits/abcd1234/diff')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->diff(1, 'abcd1234'));
+    }
+
+    #[Test]
+    public function shouldGetTree(): void
+    {
+        $expectedArray = [
+            ['name' => 'file1.txt'],
+            ['name' => 'file2.csv'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/repository/tree')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->tree(1));
+    }
+
+    #[Test]
+    public function shouldGetTreeWithParams(): void
+    {
+        $expectedArray = [
+            ['name' => 'dir/file1.txt'],
+            ['name' => 'dir/file2.csv'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/repository/tree', ['path' => 'dir/', 'ref_name' => 'master'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->tree(1, ['path' => 'dir/', 'ref_name' => 'master']));
+    }
+
+    #[Test]
+    public function shouldGetContributors(): void
+    {
+        $expectedArray = [
+            ['name' => 'Matt'],
+            ['name' => 'Bob'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/repository/contributors')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->contributors(1));
+    }
+
+    #[Test]
+    public function shouldGetMergeBase(): void
+    {
+        $expectedArray = [
+            'id' => 'abcd1234abcd1234abcd1234abcd1234abcd1234',
+            'short_id' => 'abcd1234',
+            'title' => 'A commit',
+            'created_at' => '2018-01-01T00:00:00.000Z',
+            'parent_ids' => [
+                'efgh5678efgh5678efgh5678efgh5678efgh5678',
+            ],
+            'message' => 'A commit',
+            'author_name' => 'Jane Doe',
+            'author_email' => 'jane@example.org',
+            'authored_date' => '2018-01-01T00:00:00.000Z',
+            'committer_name' => 'Jane Doe',
+            'committer_email' => 'jane@example.org',
+            'committed_date' => '2018-01-01T00:00:00.000Z',
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/repository/merge_base', ['refs' => ['efgh5678efgh5678efgh5678efgh5678efgh5678', '1234567812345678123456781234567812345678']])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->mergeBase(1, ['efgh5678efgh5678efgh5678efgh5678efgh5678', '1234567812345678123456781234567812345678']));
+    }
+
+    #[Test]
+    public function shouldCherryPick(): void
+    {
+        $expectedArray = [
+            'id' => 'abcd1234abcd1234abcd1234abcd1234abcd1234',
+            'short_id' => 'abcd1234',
+            'title' => 'A commit',
+            'author_name' => 'Example User',
+            'author_email' => 'jane@example.org',
+            'authored_date' => '2018-01-01T00:00:00.000Z',
+            'created_at' => '2018-01-01T00:00:00.000Z',
+            'committer_name' => 'Jane Doe',
+            'committer_email' => 'jane@example.org',
+            'committed_date' => '2018-01-01T00:00:00.000Z',
+            'message' => 'A commit',
+            'parent_ids' => [
+                'efgh5678efgh5678efgh5678efgh5678efgh5678',
+            ],
+            'web_url' => 'https://gitlab.example.com/thedude/gitlab-foss/-/commit/abcd1234abcd1234abcd1234abcd1234abcd1234',
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/repository/commits/123456123456/cherry_pick', ['branch' => 'feature_branch'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->cherryPick(1, '123456123456', ['branch' => 'feature_branch']));
+    }
+
+    protected function getApiClass(): string
+    {
+        return Repositories::class;
+    }
+}
diff --git a/tests/Api/RepositoryFilesTest.php b/tests/Api/RepositoryFilesTest.php
new file mode 100644
index 000000000..d9dfdf3b4
--- /dev/null
+++ b/tests/Api/RepositoryFilesTest.php
@@ -0,0 +1,267 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Tests\Api;
+
+use Gitlab\Api\RepositoryFiles;
+use PHPUnit\Framework\Attributes\Test;
+
+class RepositoryFilesTest extends TestCase
+{
+    #[Test]
+    public function shouldGetBlob(): void
+    {
+        $expectedString = 'something in a file';
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/repository/files/dir%2Ffile1.txt/raw', ['ref' => 'abcd1234'])
+            ->willReturn($expectedString);
+
+        $this->assertEquals($expectedString, $api->getRawFile(1, 'dir/file1.txt', 'abcd1234'));
+    }
+
+    #[Test]
+    public function shouldGetFile(): void
+    {
+        $expectedArray = ['file_name' => 'file1.txt', 'file_path' => 'dir/file1.txt'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/repository/files/dir%2Ffile1.txt', ['ref' => 'abcd1234'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->getFile(1, 'dir/file1.txt', 'abcd1234'));
+    }
+
+    #[Test]
+    public function shouldCreateFile(): void
+    {
+        $expectedArray = ['file_name' => 'file1.txt', 'file_path' => 'dir/file1.txt'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/repository/files/dir%2Ffile1.txt', [
+                'file_path' => 'dir/file1.txt',
+                'branch' => 'master',
+                'content' => 'some contents',
+                'commit_message' => 'Added new file',
+            ])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->createFile(1, [
+            'file_path' => 'dir/file1.txt',
+            'content' => 'some contents',
+            'branch' => 'master',
+            'commit_message' => 'Added new file',
+        ]));
+    }
+
+    #[Test]
+    public function shouldCreateFileWithEncoding(): void
+    {
+        $expectedArray = ['file_name' => 'file1.txt', 'file_path' => 'dir/file1.txt'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/repository/files/dir%2Ffile1.txt', [
+                'file_path' => 'dir/file1.txt',
+                'branch' => 'master',
+                'encoding' => 'text',
+                'content' => 'some contents',
+                'commit_message' => 'Added new file',
+            ])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->createFile(1, [
+            'file_path' => 'dir/file1.txt',
+            'content' => 'some contents',
+            'branch' => 'master',
+            'commit_message' => 'Added new file',
+            'encoding' => 'text',
+        ]));
+    }
+
+    #[Test]
+    public function shouldCreateFileWithAuthor(): void
+    {
+        $expectedArray = ['file_name' => 'file1.txt', 'file_path' => 'dir/file1.txt'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/repository/files/dir%2Ffile1.txt', [
+                'file_path' => 'dir/file1.txt',
+                'branch' => 'master',
+                'content' => 'some contents',
+                'commit_message' => 'Added new file',
+                'author_email' => 'gitlab@example.com',
+                'author_name' => 'GitLab User',
+            ])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->createFile(1, [
+            'file_path' => 'dir/file1.txt',
+            'content' => 'some contents',
+            'branch' => 'master',
+            'commit_message' => 'Added new file',
+            'author_email' => 'gitlab@example.com',
+            'author_name' => 'GitLab User',
+        ]));
+    }
+
+    #[Test]
+    public function shouldUpdateFile(): void
+    {
+        $expectedArray = ['file_name' => 'file1.txt', 'file_path' => 'dir/file1.txt'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('put')
+            ->with('projects/1/repository/files/dir%2Ffile1.txt', [
+                'file_path' => 'dir/file1.txt',
+                'branch' => 'master',
+                'content' => 'some new contents',
+                'commit_message' => 'Updated new file',
+            ])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->updateFile(1, [
+            'file_path' => 'dir/file1.txt',
+            'content' => 'some new contents',
+            'branch' => 'master',
+            'commit_message' => 'Updated new file',
+        ]));
+    }
+
+    #[Test]
+    public function shouldUpdateFileWithEncoding(): void
+    {
+        $expectedArray = ['file_name' => 'file1.txt', 'file_path' => 'dir/file1.txt'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('put')
+            ->with('projects/1/repository/files/dir%2Ffile1.txt', [
+                'file_path' => 'dir/file1.txt',
+                'branch' => 'master',
+                'encoding' => 'base64',
+                'content' => 'c29tZSBuZXcgY29udGVudHM=',
+                'commit_message' => 'Updated file',
+            ])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->updateFile(1, [
+            'file_path' => 'dir/file1.txt',
+            'content' => 'c29tZSBuZXcgY29udGVudHM=',
+            'branch' => 'master',
+            'commit_message' => 'Updated file',
+            'encoding' => 'base64',
+        ]));
+    }
+
+    #[Test]
+    public function shouldUpdateFileWithAuthor(): void
+    {
+        $expectedArray = ['file_name' => 'file1.txt', 'file_path' => 'dir/file1.txt'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('put')
+            ->with('projects/1/repository/files/dir%2Ffile1.txt', [
+                'file_path' => 'dir/file1.txt',
+                'branch' => 'master',
+                'content' => 'some new contents',
+                'commit_message' => 'Updated file',
+                'author_email' => 'gitlab@example.com',
+                'author_name' => 'GitLab User',
+            ])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->updateFile(1, [
+            'file_path' => 'dir/file1.txt',
+            'content' => 'some new contents',
+            'branch' => 'master',
+            'commit_message' => 'Updated file',
+            'author_email' => 'gitlab@example.com',
+            'author_name' => 'GitLab User',
+        ]));
+    }
+
+    #[Test]
+    public function shouldDeleteFile(): void
+    {
+        $expectedArray = ['file_name' => 'app/project.rb', 'branch' => 'master'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('delete')
+            ->with('projects/1/repository/files/dir%2Ffile1.txt', [
+                'file_path' => 'dir/file1.txt',
+                'branch' => 'master',
+                'commit_message' => 'Deleted file',
+            ])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->deleteFile(1, [
+            'file_path' => 'dir/file1.txt',
+            'branch' => 'master',
+            'commit_message' => 'Deleted file',
+        ]));
+    }
+
+    #[Test]
+    public function shouldDeleteFileWithAuthor(): void
+    {
+        $expectedArray = ['file_name' => 'app/project.rb', 'branch' => 'master'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('delete')
+            ->with('projects/1/repository/files/dir%2Ffile1.txt', [
+                'file_path' => 'dir/file1.txt',
+                'branch' => 'master',
+                'commit_message' => 'Deleted file',
+                'author_email' => 'gitlab@example.com',
+                'author_name' => 'GitLab User',
+            ])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->deleteFile(1, [
+            'file_path' => 'dir/file1.txt',
+            'branch' => 'master',
+            'commit_message' => 'Deleted file',
+            'author_email' => 'gitlab@example.com',
+            'author_name' => 'GitLab User',
+        ]));
+    }
+
+    protected function getApiClass(): string
+    {
+        return RepositoryFiles::class;
+    }
+}
diff --git a/tests/Api/ResourceIterationEventsTest.php b/tests/Api/ResourceIterationEventsTest.php
new file mode 100644
index 000000000..28b86ecd6
--- /dev/null
+++ b/tests/Api/ResourceIterationEventsTest.php
@@ -0,0 +1,135 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Tests\Api;
+
+use Gitlab\Api\ResourceIterationEvents;
+use PHPUnit\Framework\Attributes\Test;
+
+class ResourceIterationEventsTest extends TestCase
+{
+    #[Test]
+    public function shouldGetAllEvents(): void
+    {
+        $expectedArray = [
+            [
+                'id' => 142,
+                'user' => [
+                    'id' => 1,
+                    'name' => 'Administrator',
+                    'username' => 'root',
+                    'state' => 'active',
+                    'avatar_url' => 'https=>//www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+                    'web_url' => 'http=>//gitlab.example.com/root',
+                ],
+                'created_at' => '2018-08-20T13=>38=>20.077Z',
+                'resource_type' => 'Issue',
+                'resource_id' => 253,
+                'iteration' => [
+                    'id' => 50,
+                    'iid' => 9,
+                    'group_id' => 5,
+                    'title' => 'Iteration I',
+                    'description' => 'Ipsum Lorem',
+                    'state' => 1,
+                    'created_at' => '2020-01-27T05=>07=>12.573Z',
+                    'updated_at' => '2020-01-27T05=>07=>12.573Z',
+                    'due_date' => null,
+                    'start_date' => null,
+                ],
+                'action' => 'add',
+            ],
+            [
+                'id' => 143,
+                'user' => [
+                    'id' => 1,
+                    'name' => 'Administrator',
+                    'username' => 'root',
+                    'state' => 'active',
+                    'avatar_url' => 'https=>//www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+                    'web_url' => 'http=>//gitlab.example.com/root',
+                ],
+                'created_at' => '2018-08-21T14=>38=>20.077Z',
+                'resource_type' => 'Issue',
+                'resource_id' => 253,
+                'iteration' => [
+                    'id' => 53,
+                    'iid' => 13,
+                    'group_id' => 5,
+                    'title' => 'Iteration II',
+                    'description' => 'Ipsum Lorem ipsum',
+                    'state' => 2,
+                    'created_at' => '2020-01-27T05=>07=>12.573Z',
+                    'updated_at' => '2020-01-27T05=>07=>12.573Z',
+                    'due_date' => null,
+                    'start_date' => null,
+                ],
+                'action' => 'remove',
+            ],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/issues/253/resource_iteration_events', [])
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->all(1, 253));
+    }
+
+    #[Test]
+    public function shouldShowEvent(): void
+    {
+        $expectedArray = [
+            'id' => 142,
+            'user' => [
+                'id' => 1,
+                'name' => 'Administrator',
+                'username' => 'root',
+                'state' => 'active',
+                'avatar_url' => 'https=>//www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+                'web_url' => 'http=>//gitlab.example.com/root',
+            ],
+            'created_at' => '2018-08-20T13=>38=>20.077Z',
+            'resource_type' => 'Issue',
+            'resource_id' => 253,
+            'iteration' => [
+                'id' => 50,
+                'iid' => 9,
+                'group_id' => 5,
+                'title' => 'Iteration I',
+                'description' => 'Ipsum Lorem',
+                'state' => 1,
+                'created_at' => '2020-01-27T05=>07=>12.573Z',
+                'updated_at' => '2020-01-27T05=>07=>12.573Z',
+                'due_date' => null,
+                'start_date' => null,
+            ],
+            'action' => 'add',
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/issues/253/resource_iteration_events/142', [])
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->show(1, 253, 142));
+    }
+
+    protected function getApiClass(): string
+    {
+        return ResourceIterationEvents::class;
+    }
+}
diff --git a/tests/Api/ResourceLabelEventsTest.php b/tests/Api/ResourceLabelEventsTest.php
new file mode 100644
index 000000000..8a2651efb
--- /dev/null
+++ b/tests/Api/ResourceLabelEventsTest.php
@@ -0,0 +1,117 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Tests\Api;
+
+use Gitlab\Api\ResourceLabelEvents;
+use PHPUnit\Framework\Attributes\Test;
+
+class ResourceLabelEventsTest extends TestCase
+{
+    #[Test]
+    public function shouldGetAllEvents(): void
+    {
+        $expectedArray = [
+            [
+                'id' => 142,
+                'user' => [
+                    'id' => 1,
+                    'name' => 'Administrator',
+                    'username' => 'root',
+                    'state' => 'active',
+                    'avatar_url' => 'https=>//www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+                    'web_url' => 'http=>//gitlab.example.com/root',
+                ],
+                'created_at' => '2018-08-20T13=>38=>20.077Z',
+                'resource_type' => 'Issue',
+                'resource_id' => 253,
+                'label' => [
+                    'id' => 73,
+                    'name' => 'a1',
+                    'color' => '#34495E',
+                    'description' => '',
+                ],
+                'action' => 'add',
+            ],
+            [
+                'id' => 143,
+                'user' => [
+                    'id' => 1,
+                    'name' => 'Administrator',
+                    'username' => 'root',
+                    'state' => 'active',
+                    'avatar_url' => 'https=>//www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+                    'web_url' => 'http=>//gitlab.example.com/root',
+                ],
+                'created_at' => '2018-08-20T13=>38=>20.077Z',
+                'resource_type' => 'Issue',
+                'resource_id' => 253,
+                'label' => [
+                    'id' => 74,
+                    'name' => 'p1',
+                    'color' => '#0033CC',
+                    'description' => '',
+                ],
+                'action' => 'remove',
+            ],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/issues/253/resource_label_events', [])
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->all(1, 253));
+    }
+
+    #[Test]
+    public function shouldShowEvent(): void
+    {
+        $expectedArray = [
+            'id' => 142,
+            'user' => [
+                'id' => 1,
+                'name' => 'Administrator',
+                'username' => 'root',
+                'state' => 'active',
+                'avatar_url' => 'https=>//www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+                'web_url' => 'http=>//gitlab.example.com/root',
+            ],
+            'created_at' => '2018-08-20T13=>38=>20.077Z',
+            'resource_type' => 'Issue',
+            'resource_id' => 253,
+            'label' => [
+                'id' => 73,
+                'name' => 'a1',
+                'color' => '#34495E',
+                'description' => '',
+            ],
+            'action' => 'add',
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/issues/253/resource_label_events/142', [])
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->show(1, 253, 142));
+    }
+
+    protected function getApiClass(): string
+    {
+        return ResourceLabelEvents::class;
+    }
+}
diff --git a/tests/Api/ResourceMilestoneEventsTest.php b/tests/Api/ResourceMilestoneEventsTest.php
new file mode 100644
index 000000000..4fb353355
--- /dev/null
+++ b/tests/Api/ResourceMilestoneEventsTest.php
@@ -0,0 +1,138 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Tests\Api;
+
+use Gitlab\Api\ResourceMilestoneEvents;
+use PHPUnit\Framework\Attributes\Test;
+
+class ResourceMilestoneEventsTest extends TestCase
+{
+    #[Test]
+    public function shouldGetAllEvents(): void
+    {
+        $expectedArray = [
+            [
+                'id' => 142,
+                'user' => [
+                    'id' => 1,
+                    'name' => 'Administrator',
+                    'username' => 'root',
+                    'state' => 'active',
+                    'avatar_url' => 'https=>//www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+                    'web_url' => 'http=>//gitlab.example.com/root',
+                ],
+                'created_at' => '2018-08-20T13=>38=>20.077Z',
+                'resource_type' => 'Issue',
+                'resource_id' => 253,
+                'milestone' => [
+                    'id' => 61,
+                    'iid' => 9,
+                    'project_id' => 7,
+                    'title' => 'v1.2',
+                    'description' => 'Ipsum Lorem',
+                    'state' => 'active',
+                    'created_at' => '2020-01-27T05=>07=>12.573Z',
+                    'updated_at' => '2020-01-27T05=>07=>12.573Z',
+                    'due_date' => null,
+                    'start_date' => null,
+                    'web_url' => 'http=>//gitlab.example.com=>3000/group/project/-/milestones/9',
+                ],
+                'action' => 'add',
+            ],
+            [
+                'id' => 143,
+                'user' => [
+                    'id' => 1,
+                    'name' => 'Administrator',
+                    'username' => 'root',
+                    'state' => 'active',
+                    'avatar_url' => 'https=>//www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+                    'web_url' => 'http=>//gitlab.example.com/root',
+                ],
+                'created_at' => '2018-08-21T14=>38=>20.077Z',
+                'resource_type' => 'Issue',
+                'resource_id' => 253,
+                'milestone' => [
+                    'id' => 61,
+                    'iid' => 9,
+                    'project_id' => 7,
+                    'title' => 'v1.2',
+                    'description' => 'Ipsum Lorem',
+                    'state' => 'active',
+                    'created_at' => '2020-01-27T05=>07=>12.573Z',
+                    'updated_at' => '2020-01-27T05=>07=>12.573Z',
+                    'due_date' => null,
+                    'start_date' => null,
+                    'web_url' => 'http=>//gitlab.example.com=>3000/group/project/-/milestones/9',
+                ],
+                'action' => 'remove',
+            ],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/issues/253/resource_milestone_events', [])
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->all(1, 253));
+    }
+
+    #[Test]
+    public function shouldShowEvent(): void
+    {
+        $expectedArray = [
+            'id' => 142,
+            'user' => [
+                'id' => 1,
+                'name' => 'Administrator',
+                'username' => 'root',
+                'state' => 'active',
+                'avatar_url' => 'https=>//www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+                'web_url' => 'http=>//gitlab.example.com/root',
+            ],
+            'created_at' => '2018-08-20T13=>38=>20.077Z',
+            'resource_type' => 'Issue',
+            'resource_id' => 253,
+            'milestone' => [
+                'id' => 61,
+                'iid' => 9,
+                'project_id' => 7,
+                'title' => 'v1.2',
+                'description' => 'Ipsum Lorem',
+                'state' => 'active',
+                'created_at' => '2020-01-27T05=>07=>12.573Z',
+                'updated_at' => '2020-01-27T05=>07=>12.573Z',
+                'due_date' => null,
+                'start_date' => null,
+                'web_url' => 'http=>//gitlab.example.com=>3000/group/project/-/milestones/9',
+            ],
+            'action' => 'add',
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/issues/253/resource_milestone_events/142', [])
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->show(1, 253, 142));
+    }
+
+    protected function getApiClass(): string
+    {
+        return ResourceMilestoneEvents::class;
+    }
+}
diff --git a/tests/Api/ResourceStateEventsTest.php b/tests/Api/ResourceStateEventsTest.php
new file mode 100644
index 000000000..cad7b7109
--- /dev/null
+++ b/tests/Api/ResourceStateEventsTest.php
@@ -0,0 +1,99 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Tests\Api;
+
+use Gitlab\Api\ResourceStateEvents;
+use PHPUnit\Framework\Attributes\Test;
+
+class ResourceStateEventsTest extends TestCase
+{
+    #[Test]
+    public function shouldGetAllEvents(): void
+    {
+        $expectedArray = [
+            [
+                'id' => 142,
+                'user' => [
+                    'id' => 1,
+                    'name' => 'Administrator',
+                    'username' => 'root',
+                    'state' => 'active',
+                    'avatar_url' => 'https=>//www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+                    'web_url' => 'http=>//gitlab.example.com/root',
+                ],
+                'created_at' => '2018-08-20T13=>38=>20.077Z',
+                'resource_type' => 'Issue',
+                'resource_id' => 11,
+                'state' => 'opened',
+            ],
+            [
+                'id' => 143,
+                'user' => [
+                    'id' => 1,
+                    'name' => 'Administrator',
+                    'username' => 'root',
+                    'state' => 'active',
+                    'avatar_url' => 'https=>//www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+                    'web_url' => 'http=>//gitlab.example.com/root',
+                ],
+                'created_at' => '2018-08-21T14=>38=>20.077Z',
+                'resource_type' => 'Issue',
+                'resource_id' => 11,
+                'state' => 'closed',
+            ],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/issues/11/resource_state_events', [])
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->all(1, 11));
+    }
+
+    #[Test]
+    public function shouldShowEvent(): void
+    {
+        $expectedArray = [
+            'id' => 142,
+            'user' => [
+                'id' => 1,
+                'name' => 'Administrator',
+                'username' => 'root',
+                'state' => 'active',
+                'avatar_url' => 'https=>//www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+                'web_url' => 'http=>//gitlab.example.com/root',
+            ],
+            'created_at' => '2018-08-20T13=>38=>20.077Z',
+            'resource_type' => 'Issue',
+            'resource_id' => 11,
+            'state' => 'opened',
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/issues/11/resource_state_events/142', [])
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->show(1, 11, 142));
+    }
+
+    protected function getApiClass(): string
+    {
+        return ResourceStateEvents::class;
+    }
+}
diff --git a/tests/Api/ResourceWeightEventsTest.php b/tests/Api/ResourceWeightEventsTest.php
new file mode 100644
index 000000000..d554de7da
--- /dev/null
+++ b/tests/Api/ResourceWeightEventsTest.php
@@ -0,0 +1,96 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Tests\Api;
+
+use Gitlab\Api\ResourceWeightEvents;
+use PHPUnit\Framework\Attributes\Test;
+
+class ResourceWeightEventsTest extends TestCase
+{
+    #[Test]
+    public function shouldGetAllEvents(): void
+    {
+        $expectedArray = [
+            [
+                'id' => 142,
+                'user' => [
+                    'id' => 1,
+                    'name' => 'Administrator',
+                    'username' => 'root',
+                    'state' => 'active',
+                    'avatar_url' => 'https=>//www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+                    'web_url' => 'http=>//gitlab.example.com/root',
+                ],
+                'created_at' => '2018-08-20T13=>38=>20.077Z',
+                'issue_id' => 253,
+                'weight' => 3,
+            ],
+            [
+                'id' => 143,
+                'user' => [
+                    'id' => 1,
+                    'name' => 'Administrator',
+                    'username' => 'root',
+                    'state' => 'active',
+                    'avatar_url' => 'https=>//www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+                    'web_url' => 'http=>//gitlab.example.com/root',
+                ],
+                'created_at' => '2018-08-21T14=>38=>20.077Z',
+                'issue_id' => 253,
+                'weight' => 2,
+            ],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/issues/253/resource_weight_events', [])
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->all(1, 253));
+    }
+
+    #[Test]
+    public function shouldShowEvent(): void
+    {
+        $expectedArray = [
+            'id' => 142,
+            'user' => [
+                'id' => 1,
+                'name' => 'Administrator',
+                'username' => 'root',
+                'state' => 'active',
+                'avatar_url' => 'https=>//www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+                'web_url' => 'http=>//gitlab.example.com/root',
+            ],
+            'created_at' => '2018-08-20T13=>38=>20.077Z',
+            'issue_id' => 253,
+            'weight' => 3,
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/issues/253/resource_weight_events/142', [])
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->show(1, 253, 142));
+    }
+
+    protected function getApiClass(): string
+    {
+        return ResourceWeightEvents::class;
+    }
+}
diff --git a/tests/Api/ScheduleTest.php b/tests/Api/ScheduleTest.php
new file mode 100644
index 000000000..e5f64e98f
--- /dev/null
+++ b/tests/Api/ScheduleTest.php
@@ -0,0 +1,220 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Tests\Api;
+
+use Gitlab\Api\Schedules;
+use PHPUnit\Framework\Attributes\Test;
+
+class ScheduleTest extends TestCase
+{
+    #[Test]
+    public function shouldCreateSchedule(): void
+    {
+        $expectedArray = [
+            'id' => 13,
+            'description' => 'Test schedule pipeline',
+            'ref' => 'master',
+            'cron' => '* * * * *',
+            'cron_timezone' => 'Asia/Tokyo',
+            'next_run_at' => '2017-05-19T13:41:00.000Z',
+            'active' => true,
+            'created_at' => '2017-05-19T13:31:08.849Z',
+            'updated_at' => '2017-05-19T13:40:17.727Z',
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/pipeline_schedules', [
+                'id' => 13,
+                'description' => 'Test schedule pipeline',
+                'ref' => 'master',
+                'cron' => '* * * * *',
+                'cron_timezone' => 'Asia/Tokyo',
+                'next_run_at' => '2017-05-19T13:41:00.000Z',
+                'active' => true,
+                'created_at' => '2017-05-19T13:31:08.849Z',
+                'updated_at' => '2017-05-19T13:40:17.727Z',
+            ])
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->create(
+            1,
+            [
+                'id' => 13,
+                'description' => 'Test schedule pipeline',
+                'ref' => 'master',
+                'cron' => '* * * * *',
+                'cron_timezone' => 'Asia/Tokyo',
+                'next_run_at' => '2017-05-19T13:41:00.000Z',
+                'active' => true,
+                'created_at' => '2017-05-19T13:31:08.849Z',
+                'updated_at' => '2017-05-19T13:40:17.727Z',
+            ]
+        ));
+    }
+
+    #[Test]
+    public function shouldShowSchedule(): void
+    {
+        $expectedArray = ['id' => 1, 'name' => 'A schedule'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/pipeline_schedules/2')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->show(1, 2));
+    }
+
+    #[Test]
+    public function shouldShowAllSchedule(): void
+    {
+        $expectedArray = ['id' => 1, 'name' => 'A schedule'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/pipeline_schedules')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->showAll(1));
+    }
+
+    #[Test]
+    public function shouldUpdateSchedule(): void
+    {
+        $expectedArray = ['id' => 3, 'title' => 'Updated schedule'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('put')
+            ->with('projects/1/pipeline_schedules/3', ['title' => 'Updated schedule', 'due_date' => '2015-04-01', 'state_event' => 'close'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->update(1, 3, ['title' => 'Updated schedule', 'due_date' => '2015-04-01', 'state_event' => 'close']));
+    }
+
+    #[Test]
+    public function shouldRemoveSchedule(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('delete')
+            ->with('projects/1/pipeline_schedules/2')
+            ->willReturn($expectedBool);
+
+        $this->assertEquals($expectedBool, $api->remove(1, 2));
+    }
+
+    #[Test]
+    public function shouldCreateScheduleVariable(): void
+    {
+        $expectedArray = [
+            'key' => 'FOO_BAR',
+            'variable_type' => 'env_var',
+            'value' => 'BAZ',
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/pipeline_schedules/2/variables', $expectedArray)
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->addVariable(
+            1,
+            2,
+            $expectedArray
+        ));
+    }
+
+    #[Test]
+    public function shouldUpdateScheduleVariable(): void
+    {
+        $variabelName = 'FOO_BAR';
+        $expectedArray = [
+            'key' => $variabelName,
+            'variable_type' => 'env_var',
+            'value' => 'BAZ',
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('put')
+            ->with('projects/1/pipeline_schedules/2/variables/'.$variabelName, $expectedArray)
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->updateVariable(
+            1,
+            2,
+            $variabelName,
+            $expectedArray
+        ));
+    }
+
+    #[Test]
+    public function shouldRemoveScheduleVariable(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('delete')
+            ->with('projects/1/pipeline_schedules/2/variables/FOO_BAR')
+            ->willReturn($expectedBool);
+
+        $this->assertEquals($expectedBool, $api->removeVariable(1, 2, 'FOO_BAR'));
+    }
+
+    #[Test]
+    public function shouldTakeOwnership(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/pipeline_schedules/2/take_ownership')
+            ->willReturn($expectedBool);
+
+        $this->assertEquals($expectedBool, $api->takeOwnership(1, 2));
+    }
+
+    #[Test]
+    public function shouldPlay(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/pipeline_schedules/2/play')
+            ->willReturn($expectedBool);
+
+        $this->assertEquals($expectedBool, $api->play(1, 2));
+    }
+
+    protected function getApiClass(): string
+    {
+        return Schedules::class;
+    }
+}
diff --git a/tests/Api/SearchTest.php b/tests/Api/SearchTest.php
new file mode 100644
index 000000000..cbb39a2e8
--- /dev/null
+++ b/tests/Api/SearchTest.php
@@ -0,0 +1,56 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Tests\Api;
+
+use Gitlab\Api\Search;
+use PHPUnit\Framework\Attributes\Test;
+
+class SearchTest extends TestCase
+{
+    #[Test]
+    public function shouldGetAll(): void
+    {
+        $expectedArray = [
+            ['id' => 6, 'name' => 'Project 6 bla'],
+            ['id' => 7, 'name' => 'Project 7 bla'],
+            ['id' => 8, 'name' => 'Project 8 bla'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('search', [
+                'scope' => 'projects',
+                'confidential' => 'false',
+                'search' => 'bla',
+                'order_by' => 'created_at',
+                'sort' => 'desc',
+            ])
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->all([
+            'scope' => 'projects',
+            'confidential' => false,
+            'search' => 'bla',
+            'order_by' => 'created_at',
+            'sort' => 'desc',
+        ]));
+    }
+
+    protected function getApiClass(): string
+    {
+        return Search::class;
+    }
+}
diff --git a/tests/Api/SnippetsTest.php b/tests/Api/SnippetsTest.php
new file mode 100644
index 000000000..0d75c5c94
--- /dev/null
+++ b/tests/Api/SnippetsTest.php
@@ -0,0 +1,226 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Tests\Api;
+
+use Gitlab\Api\Snippets;
+use PHPUnit\Framework\Attributes\Test;
+
+class SnippetsTest extends TestCase
+{
+    #[Test]
+    public function shouldGetAllSnippets(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'title' => 'A snippet'],
+            ['id' => 2, 'title' => 'Another snippet'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/snippets')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->all(1));
+    }
+
+    #[Test]
+    public function shouldShowSnippet(): void
+    {
+        $expectedArray = ['id' => 2, 'title' => 'Another snippet'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/snippets/2')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->show(1, 2));
+    }
+
+    #[Test]
+    public function shouldCreateSnippet(): void
+    {
+        $expectedArray = ['id' => 3, 'title' => 'A new snippet'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/snippets', ['title' => 'A new snippet', 'code' => 'A file', 'file_name' => 'file.txt', 'visibility' => 'public'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->create(1, 'A new snippet', 'file.txt', 'A file', 'public'));
+    }
+
+    #[Test]
+    public function shouldUpdateSnippet(): void
+    {
+        $expectedArray = ['id' => 3, 'title' => 'Updated snippet'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('put')
+            ->with('projects/1/snippets/3', ['title' => 'Updated snippet', 'code' => 'New content', 'file_name' => 'new_file.txt'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->update(1, 3, ['file_name' => 'new_file.txt', 'code' => 'New content', 'title' => 'Updated snippet']));
+    }
+
+    #[Test]
+    public function shouldShowContent(): void
+    {
+        $expectedString = 'New content';
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/snippets/3/raw')
+            ->willReturn($expectedString);
+
+        $this->assertEquals($expectedString, $api->content(1, 3));
+    }
+
+    #[Test]
+    public function shouldRemoveSnippet(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('delete')
+            ->with('projects/1/snippets/3')
+            ->willReturn($expectedBool);
+
+        $this->assertEquals($expectedBool, $api->remove(1, 3));
+    }
+
+    #[Test]
+    public function shouldGetNotes(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'body' => 'A note'],
+            ['id' => 2, 'body' => 'Another note'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/snippets/2/notes')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->showNotes(1, 2));
+    }
+
+    #[Test]
+    public function shouldGetNote(): void
+    {
+        $expectedArray = ['id' => 3, 'body' => 'A new note'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/snippets/2/notes/3')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->showNote(1, 2, 3));
+    }
+
+    #[Test]
+    public function shouldCreateNote(): void
+    {
+        $expectedArray = ['id' => 3, 'body' => 'A new note'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/snippets/2/notes', ['body' => 'A new note'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->addNote(1, 2, 'A new note'));
+    }
+
+    #[Test]
+    public function shouldUpdateNote(): void
+    {
+        $expectedArray = ['id' => 3, 'body' => 'An edited comment'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('put')
+            ->with('projects/1/snippets/2/notes/3', ['body' => 'An edited comment'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->updateNote(1, 2, 3, 'An edited comment'));
+    }
+
+    #[Test]
+    public function shouldRemoveNote(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('delete')
+            ->with('projects/1/snippets/2/notes/3')
+            ->willReturn($expectedBool);
+
+        $this->assertEquals($expectedBool, $api->removeNote(1, 2, 3));
+    }
+
+    #[Test]
+    public function shouldIssueSnippetAwardEmoji(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'name' => 'sparkles'],
+            ['id' => 2, 'name' => 'heart_eyes'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/snippets/2/award_emoji')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->awardEmoji(1, 2));
+    }
+
+    #[Test]
+    public function shouldRevokeSnippetAwardEmoji(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('delete')
+            ->with('projects/1/snippets/2/award_emoji/3')
+            ->willReturn($expectedBool);
+
+        $this->assertEquals(true, $api->removeAwardEmoji(1, 2, 3));
+    }
+
+    protected function getApiClass(): string
+    {
+        return Snippets::class;
+    }
+}
diff --git a/tests/Api/SystemHooksTest.php b/tests/Api/SystemHooksTest.php
new file mode 100644
index 000000000..6c0d38457
--- /dev/null
+++ b/tests/Api/SystemHooksTest.php
@@ -0,0 +1,87 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Tests\Api;
+
+use Gitlab\Api\SystemHooks;
+use PHPUnit\Framework\Attributes\Test;
+
+class SystemHooksTest extends TestCase
+{
+    #[Test]
+    public function shouldGetAllHooks(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'url' => 'http://www.example.com'],
+            ['id' => 2, 'url' => 'http://www.example.org'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('hooks')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->all());
+    }
+
+    #[Test]
+    public function shouldCreateHook(): void
+    {
+        $expectedArray = ['id' => 3, 'url' => 'http://www.example.net'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('hooks', ['url' => 'http://www.example.net'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->create('http://www.example.net'));
+    }
+
+    #[Test]
+    public function shouldTestHook(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('hooks/3')
+            ->willReturn($expectedBool);
+
+        $this->assertEquals($expectedBool, $api->test(3));
+    }
+
+    #[Test]
+    public function shouldRemoveHook(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('delete')
+            ->with('hooks/3')
+            ->willReturn($expectedBool);
+
+        $this->assertEquals($expectedBool, $api->remove(3));
+    }
+
+    protected function getApiClass(): string
+    {
+        return SystemHooks::class;
+    }
+}
diff --git a/tests/Api/TagsTest.php b/tests/Api/TagsTest.php
new file mode 100644
index 000000000..2be94e67e
--- /dev/null
+++ b/tests/Api/TagsTest.php
@@ -0,0 +1,151 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Tests\Api;
+
+use Gitlab\Api\Tags;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\Test;
+
+class TagsTest extends TestCase
+{
+    #[Test]
+    public function shouldGetAllTags(): void
+    {
+        $expectedArray = [
+            ['name' => 'v1.0.0'],
+            ['name' => 'v1.1.0'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/repository/tags')
+            ->willReturn($expectedArray);
+        $this->assertEquals($expectedArray, $api->all(1));
+    }
+
+    #[Test]
+    public function shouldShowTag(): void
+    {
+        $expectedArray = [
+            ['name' => 'v1.0.0'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/repository/tags/v1.0.0')
+            ->willReturn($expectedArray);
+        $this->assertEquals($expectedArray, $api->show(1, 'v1.0.0'));
+    }
+
+    #[Test]
+    public function shouldCreateTag(): void
+    {
+        $expectedArray = [
+            ['name' => 'v1.1.0'],
+        ];
+
+        $params = [
+            'id' => 1,
+            'tag_name' => 'v1.1.0',
+            'ref' => 'ref/heads/master',
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/repository/tags', $params)
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->create(1, $params));
+    }
+
+    #[Test]
+    public function shouldRemoveTag(): void
+    {
+        $expectedArray = [
+            ['name' => 'v1.1.0'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('delete')
+            ->with('projects/1/repository/tags/v1.1.0')
+            ->willReturn($expectedArray);
+        $this->assertEquals($expectedArray, $api->remove(1, 'v1.1.0'));
+    }
+
+    #[Test]
+    #[DataProvider('releaseDataProvider')]
+    public function shouldCreateRelease(string $releaseName, string $description, array $expectedResult): void
+    {
+        $params = [
+            'description' => $description,
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/repository/tags/'.\str_replace('/', '%2F', $releaseName).'/release', $params)
+            ->willReturn($expectedResult);
+
+        $this->assertEquals($expectedResult, $api->createRelease(1, $releaseName, $params));
+    }
+
+    #[Test]
+    #[DataProvider('releaseDataProvider')]
+    public function shouldUpdateRelease(string $releaseName, string $description, array $expectedResult): void
+    {
+        $params = [
+            'description' => $description,
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('put')
+            ->with('projects/1/repository/tags/'.\str_replace('/', '%2F', $releaseName).'/release', $params)
+            ->willReturn($expectedResult);
+
+        $this->assertEquals($expectedResult, $api->updateRelease(1, $releaseName, $params));
+    }
+
+    public static function releaseDataProvider(): array
+    {
+        return [
+            [
+                'releaseName' => 'v1.1.0',
+                'description' => 'Amazing release. Wow',
+                'expectedResult' => [
+                    'tag_name' => '1.0.0',
+                    'description' => 'Amazing release. Wow',
+                ],
+            ],
+            [
+                'releaseName' => 'version/1.1.0',
+                'description' => 'Amazing release. Wow',
+                'expectedResult' => [
+                    'tag_name' => 'version/1.1.0',
+                    'description' => 'Amazing release. Wow',
+                ],
+            ],
+        ];
+    }
+
+    protected function getApiClass(): string
+    {
+        return Tags::class;
+    }
+}
diff --git a/tests/Api/TestCase.php b/tests/Api/TestCase.php
new file mode 100644
index 000000000..34f192c60
--- /dev/null
+++ b/tests/Api/TestCase.php
@@ -0,0 +1,42 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Tests\Api;
+
+use Gitlab\Client;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase as BaseTestCase;
+use Psr\Http\Client\ClientInterface;
+
+abstract class TestCase extends BaseTestCase
+{
+    abstract protected function getApiClass(): string;
+
+    protected function getApiMock(array $methods = []): MockObject
+    {
+        $httpClient = $this->getMockBuilder(ClientInterface::class)
+            ->onlyMethods(['sendRequest'])
+            ->getMock();
+        $httpClient
+            ->expects($this->any())
+            ->method('sendRequest');
+
+        $client = Client::createWithHttpClient($httpClient);
+
+        return $this->getMockBuilder($this->getApiClass())
+            ->onlyMethods(\array_merge(['getAsResponse', 'get', 'post', 'delete', 'put'], $methods))
+            ->setConstructorArgs([$client, null])
+            ->getMock();
+    }
+}
diff --git a/tests/Api/UsersTest.php b/tests/Api/UsersTest.php
new file mode 100644
index 000000000..fdda26930
--- /dev/null
+++ b/tests/Api/UsersTest.php
@@ -0,0 +1,885 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Tests\Api;
+
+use Gitlab\Api\Users;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\Test;
+use PHPUnit\Framework\MockObject\MockObject;
+
+class UsersTest extends TestCase
+{
+    #[Test]
+    public function shouldGetAllUsers(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'name' => 'Matt'],
+            ['id' => 2, 'name' => 'John'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('users', [])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->all());
+    }
+
+    #[Test]
+    public function shouldGetActiveUsers(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'name' => 'Matt'],
+            ['id' => 2, 'name' => 'John'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('users', ['active' => true])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->all(['active' => true]));
+    }
+
+    #[Test]
+    public function shouldGetUsersWithDateTimeParams(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'name' => 'Matt'],
+            ['id' => 2, 'name' => 'John'],
+        ];
+
+        $createdAfter = new \DateTime('2018-01-01 00:00:00');
+        $createdBefore = new \DateTime('2018-01-31 00:00:00');
+
+        $expectedWithArray = [
+            'created_after' => $createdAfter->format(\DATE_ATOM),
+            'created_before' => $createdBefore->format(\DATE_ATOM),
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('users', $expectedWithArray)
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals(
+            $expectedArray,
+            $api->all(['created_after' => $createdAfter, 'created_before' => $createdBefore])
+        );
+    }
+
+    #[Test]
+    public function shouldShowUser(): void
+    {
+        $expectedArray = ['id' => 1, 'name' => 'Matt'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('users/1')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->show(1));
+    }
+
+    protected function getUsersMembershipsData(): array
+    {
+        return [
+            [
+                'source_id' => 1,
+                'source_name' => 'Project one',
+                'source_type' => 'Project',
+                'access_level' => '20',
+            ],
+            [
+                'source_id' => 3,
+                'source_name' => 'Group three',
+                'source_type' => 'Namespace',
+                'access_level' => '20',
+            ],
+        ];
+    }
+
+    protected function getUsersMembershipsRequestMock($path, $expectedArray = [], $expectedParameters = []): MockObject
+    {
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with($path, $expectedParameters)
+            ->willReturn($expectedArray)
+        ;
+
+        return $api;
+    }
+
+    #[Test]
+    public function shouldShowUsersMemberships(): void
+    {
+        $expectedArray = $this->getUsersMembershipsData();
+
+        $api = $this->getUsersMembershipsRequestMock('users/1/memberships', $expectedArray);
+
+        $this->assertEquals($expectedArray, $api->usersMemberships(1));
+    }
+
+    #[Test]
+    public function shouldShowUsersMembershipsWithTypeProject(): void
+    {
+        $expectedArray = [$this->getUsersMembershipsData()[0]];
+
+        $api = $this->getUsersMembershipsRequestMock('users/1/memberships', $expectedArray, ['type' => 'Project']);
+
+        $this->assertEquals($expectedArray, $api->usersMemberships(1, ['type' => 'Project']));
+    }
+
+    #[Test]
+    public function shouldShowUsersMembershipsWithTypeNamespace(): void
+    {
+        $expectedArray = [$this->getUsersMembershipsData()[1]];
+
+        $api = $this->getUsersMembershipsRequestMock('users/1/memberships', $expectedArray, ['type' => 'Namespace']);
+
+        $this->assertEquals($expectedArray, $api->usersMemberships(1, ['type' => 'Namespace']));
+    }
+
+    protected function getUsersProjectsData(): array
+    {
+        return [
+            ['id' => 1, 'name' => 'matt-project-1'],
+            ['id' => 2, 'name' => 'matt-project-2'],
+        ];
+    }
+
+    protected function getUsersProjectsRequestMock($path, $expectedArray = [], $expectedParameters = []): MockObject
+    {
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with($path, $expectedParameters)
+            ->willReturn($expectedArray)
+        ;
+
+        return $api;
+    }
+
+    #[Test]
+    public function shouldShowUsersProjects(): void
+    {
+        $expectedArray = $this->getUsersProjectsData();
+
+        $api = $this->getUsersProjectsRequestMock('users/1/projects', $expectedArray);
+
+        $this->assertEquals($expectedArray, $api->usersProjects(1));
+    }
+
+    #[Test]
+    public function shouldShowUsersProjectsWithLimit(): void
+    {
+        $expectedArray = [$this->getUsersProjectsData()[0]];
+
+        $api = $this->getUsersProjectsRequestMock('users/1/projects', $expectedArray, ['per_page' => 1]);
+
+        $this->assertEquals($expectedArray, $api->usersProjects(1, ['per_page' => 1]));
+    }
+
+    #[Test]
+    public function shouldGetAllUsersProjectsSortedByName(): void
+    {
+        $expectedArray = $this->getUsersProjectsData();
+
+        $api = $this->getUsersProjectsRequestMock(
+            'users/1/projects',
+            $expectedArray,
+            ['page' => 1, 'per_page' => 5, 'order_by' => 'name', 'sort' => 'asc']
+        );
+
+        $this->assertEquals(
+            $expectedArray,
+            $api->usersProjects(1, ['page' => 1, 'per_page' => 5, 'order_by' => 'name', 'sort' => 'asc'])
+        );
+    }
+
+    #[Test]
+    public function shouldGetNotArchivedUsersProjects(): void
+    {
+        $expectedArray = $this->getUsersProjectsData();
+
+        $api = $this->getUsersProjectsRequestMock('users/1/projects', $expectedArray, ['archived' => 'false']);
+
+        $this->assertEquals($expectedArray, $api->usersProjects(1, ['archived' => false]));
+    }
+
+    #[Test]
+    public function shouldGetOwnedUsersProjects(): void
+    {
+        $expectedArray = $this->getUsersProjectsData();
+
+        $api = $this->getUsersProjectsRequestMock('users/1/projects', $expectedArray, ['owned' => 'true']);
+
+        $this->assertEquals($expectedArray, $api->usersProjects(1, ['owned' => true]));
+    }
+
+    public static function possibleAccessLevels(): array
+    {
+        return [
+            [10],
+            [20],
+            [30],
+            [40],
+            [50],
+        ];
+    }
+
+    #[Test]
+    #[DataProvider('possibleAccessLevels')]
+    public function shouldGetProjectsWithMinimumAccessLevel($level): void
+    {
+        $expectedArray = $this->getUsersProjectsData();
+
+        $api = $this->getUsersProjectsRequestMock('users/1/projects', $expectedArray, ['min_access_level' => $level]);
+
+        $this->assertEquals($expectedArray, $api->usersProjects(1, ['min_access_level' => $level]));
+    }
+
+    #[Test]
+    public function shouldSearchUsersProjects(): void
+    {
+        $expectedArray = $this->getUsersProjectsData();
+
+        $api = $this->getUsersProjectsRequestMock('users/1/projects', $expectedArray, ['search' => 'a project']);
+        $this->assertEquals($expectedArray, $api->usersProjects(1, ['search' => 'a project']));
+    }
+
+    #[Test]
+    public function shouldShowUsersStarredProjects(): void
+    {
+        $expectedArray = $this->getUsersProjectsData();
+
+        $api = $this->getUsersProjectsRequestMock('users/1/starred_projects', $expectedArray);
+
+        $this->assertEquals($expectedArray, $api->usersStarredProjects(1));
+    }
+
+    #[Test]
+    public function shouldShowUsersStarredProjectsWithLimit(): void
+    {
+        $expectedArray = [$this->getUsersProjectsData()[0]];
+
+        $api = $this->getUsersProjectsRequestMock('users/1/starred_projects', $expectedArray, ['per_page' => 1]);
+
+        $this->assertEquals($expectedArray, $api->usersStarredProjects(1, ['per_page' => 1]));
+    }
+
+    #[Test]
+    public function shouldGetAllUsersStarredProjectsSortedByName(): void
+    {
+        $expectedArray = $this->getUsersProjectsData();
+
+        $api = $this->getUsersProjectsRequestMock(
+            'users/1/starred_projects',
+            $expectedArray,
+            ['page' => 1, 'per_page' => 5, 'order_by' => 'name', 'sort' => 'asc']
+        );
+
+        $this->assertEquals(
+            $expectedArray,
+            $api->usersStarredProjects(1, ['page' => 1, 'per_page' => 5, 'order_by' => 'name', 'sort' => 'asc'])
+        );
+    }
+
+    #[Test]
+    public function shouldGetNotArchivedUsersStarredProjects(): void
+    {
+        $expectedArray = $this->getUsersProjectsData();
+
+        $api = $this->getUsersProjectsRequestMock('users/1/starred_projects', $expectedArray, ['archived' => 'false']);
+
+        $this->assertEquals($expectedArray, $api->usersStarredProjects(1, ['archived' => false]));
+    }
+
+    #[Test]
+    public function shouldGetOwnedUsersStarredProjects(): void
+    {
+        $expectedArray = $this->getUsersProjectsData();
+
+        $api = $this->getUsersProjectsRequestMock('users/1/starred_projects', $expectedArray, ['owned' => 'true']);
+
+        $this->assertEquals($expectedArray, $api->usersStarredProjects(1, ['owned' => true]));
+    }
+
+    #[Test]
+    #[DataProvider('possibleAccessLevels')]
+    public function shouldGetStarredProjectsWithMinimumAccessLevel($level): void
+    {
+        $expectedArray = $this->getUsersProjectsData();
+
+        $api = $this->getUsersProjectsRequestMock('users/1/starred_projects', $expectedArray, ['min_access_level' => $level]);
+
+        $this->assertEquals($expectedArray, $api->usersStarredProjects(1, ['min_access_level' => $level]));
+    }
+
+    #[Test]
+    public function shouldSearchUsersStarredProjects(): void
+    {
+        $expectedArray = $this->getUsersProjectsData();
+
+        $api = $this->getUsersProjectsRequestMock('users/1/starred_projects', $expectedArray, ['search' => 'a project']);
+        $this->assertEquals($expectedArray, $api->usersStarredProjects(1, ['search' => 'a project']));
+    }
+
+    #[Test]
+    public function shouldCreateUser(): void
+    {
+        $expectedArray = ['id' => 3, 'name' => 'Billy'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('users', ['email' => 'billy@example.com', 'password' => 'password'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->create('billy@example.com', 'password'));
+    }
+
+    #[Test]
+    public function shouldCreateUserWithAdditionalInfo(): void
+    {
+        $expectedArray = ['id' => 3, 'name' => 'Billy'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('users', ['email' => 'billy@example.com', 'password' => 'password', 'name' => 'Billy', 'bio' => 'A person'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->create('billy@example.com', 'password', ['name' => 'Billy', 'bio' => 'A person']));
+    }
+
+    #[Test]
+    public function shouldUpdateUser(): void
+    {
+        $expectedArray = ['id' => 3, 'name' => 'Billy Bob'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('put')
+            ->with('users/3', ['name' => 'Billy Bob'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->update(3, ['name' => 'Billy Bob']));
+
+        $expectedArray = ['id' => 4, 'avatar_url' => 'http://localhost:3000/uploads/user/avatar/4/image.jpg'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('put')
+            ->with('users/4', [], [], ['avatar' => '/some/image.jpg'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->update(4, [], ['avatar' => '/some/image.jpg']));
+    }
+
+    #[Test]
+    public function shouldRemoveUser(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('delete')
+            ->with('users/1')
+            ->willReturn($expectedBool);
+
+        $this->assertEquals($expectedBool, $api->remove(1));
+    }
+
+    #[Test]
+    public function shouldBlockUser(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('users/1/block')
+            ->willReturn($expectedBool);
+
+        $this->assertEquals($expectedBool, $api->block(1));
+    }
+
+    #[Test]
+    public function shouldUnblockUser(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('users/1/unblock')
+            ->willReturn($expectedBool);
+
+        $this->assertEquals($expectedBool, $api->unblock(1));
+    }
+
+    #[Test]
+    public function shouldActivateUser(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('users/1/activate')
+            ->willReturn($expectedBool);
+
+        $this->assertEquals($expectedBool, $api->activate(1));
+    }
+
+    #[Test]
+    public function shouldDeactivateUser(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('users/1/deactivate')
+            ->willReturn($expectedBool);
+
+        $this->assertEquals($expectedBool, $api->deactivate(1));
+    }
+
+    #[Test]
+    public function shouldShowCurrentUser(): void
+    {
+        $expectedArray = ['id' => 1, 'name' => 'Matt'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('user')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->me());
+    }
+
+    #[Test]
+    public function shouldGetCurrentUserKeys(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'title' => 'A key'],
+            ['id' => 2, 'name' => 'Another key'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('user/keys')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->keys(1));
+    }
+
+    #[Test]
+    public function shouldGetCurrentUserKey(): void
+    {
+        $expectedArray = ['id' => 1, 'title' => 'A key'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('user/keys/1')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->key(1));
+    }
+
+    #[Test]
+    public function shouldCreateKeyForCurrentUser(): void
+    {
+        $expectedArray = ['id' => 3, 'title' => 'A new key'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('user/keys', ['title' => 'A new key', 'key' => '...'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->createKey('A new key', '...'));
+    }
+
+    #[Test]
+    public function shouldDeleteKeyForCurrentUser(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('delete')
+            ->with('user/keys/3')
+            ->willReturn($expectedBool);
+
+        $this->assertEquals($expectedBool, $api->removeKey(3));
+    }
+
+    #[Test]
+    public function shouldGetUserKeys(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'title' => 'A key'],
+            ['id' => 2, 'name' => 'Another key'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('users/1/keys')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->userKeys(1));
+    }
+
+    #[Test]
+    public function shouldGetUserKey(): void
+    {
+        $expectedArray = ['id' => 2, 'title' => 'Another key'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('users/1/keys/2')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->userKey(1, 2));
+    }
+
+    #[Test]
+    public function shouldCreateKeyForUser(): void
+    {
+        $expectedArray = ['id' => 3, 'title' => 'A new key'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('users/1/keys', ['title' => 'A new key', 'key' => '...'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->createKeyForUser(1, 'A new key', '...'));
+    }
+
+    #[Test]
+    public function shouldDeleteKeyForUser(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('delete')
+            ->with('users/1/keys/3')
+            ->willReturn($expectedBool);
+
+        $this->assertEquals($expectedBool, $api->removeUserKey(1, 3));
+    }
+
+    #[Test]
+    public function shouldGetUserEmails(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'email' => 'foo@bar.baz'],
+            ['id' => 2, 'email' => 'foo@bar.qux'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('user/emails')
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->emails());
+    }
+
+    #[Test]
+    public function shouldGetSpecificUserEmail(): void
+    {
+        $expectedArray = ['id' => 1, 'email' => 'foo@bar.baz'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('user/emails/1')
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->email(1));
+    }
+
+    #[Test]
+    public function shouldGetEmailsForUser(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'email' => 'foo@bar.baz'],
+            ['id' => 2, 'email' => 'foo@bar.qux'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('users/1/emails')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->userEmails(1));
+    }
+
+    #[Test]
+    public function shouldCreateEmailForUser(): void
+    {
+        $expectedArray = ['id' => 3, 'email' => 'foo@bar.example'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('users/1/emails', ['email' => 'foo@bar.example', 'skip_confirmation' => false])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->createEmailForUser(1, 'foo@bar.example'));
+    }
+
+    #[Test]
+    public function shouldCreateConfirmedEmailForUser(): void
+    {
+        $expectedArray = ['id' => 4, 'email' => 'foo@baz.example'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('users/1/emails', ['email' => 'foo@baz.example', 'skip_confirmation' => true])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->createEmailForUser(1, 'foo@baz.example', true));
+    }
+
+    #[Test]
+    public function shouldDeleteEmailForUser(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('delete')
+            ->with('users/1/emails/3')
+            ->willReturn($expectedBool);
+
+        $this->assertEquals($expectedBool, $api->removeUserEmail(1, 3));
+    }
+
+    #[Test]
+    public function shouldGetCurrentUserImpersonationTokens(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'name' => 'A Name', 'revoked' => false],
+            ['id' => 2, 'name' => 'A Name', 'revoked' => false],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('users/1/impersonation_tokens')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->userImpersonationTokens(1));
+    }
+
+    #[Test]
+    public function shouldGetUserImpersonationToken(): void
+    {
+        $expectedArray = ['id' => 2, 'name' => 'name'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('users/1/impersonation_tokens/1')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->userImpersonationToken(1, 1));
+    }
+
+    #[Test]
+    public function shouldCreateImpersonationTokenForUser(): void
+    {
+        $expectedArray = ['id' => 1, 'name' => 'name'];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('users/1/impersonation_tokens', ['name' => 'name', 'scopes' => ['api'], 'expires_at' => null])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->createImpersonationToken(1, 'name', ['api']));
+    }
+
+    #[Test]
+    public function shouldDeleteImpersonationTokenForUser(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('delete')
+            ->with('users/1/impersonation_tokens/1')
+            ->willReturn($expectedBool);
+
+        $this->assertEquals($expectedBool, $api->removeImpersonationToken(1, 1));
+    }
+
+    #[Test]
+    public function shouldGetCurrentUserActiveImpersonationTokens(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'name' => 'A Name', 'revoked' => true],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('users/1/impersonation_tokens')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->userImpersonationTokens(1, ['state' => 'active']));
+    }
+
+    #[Test]
+    public function shouldGetCurrentUserInactiveImpersonationTokens(): void
+    {
+        $expectedArray = [
+            ['id' => 2, 'name' => 'A Name', 'revoked' => false],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('users/1/impersonation_tokens')
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->userImpersonationTokens(1, ['state' => 'inactive']));
+    }
+
+    protected function getApiClass(): string
+    {
+        return Users::class;
+    }
+
+    #[Test]
+    public function shouldGetEvents(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'title' => 'An event'],
+            ['id' => 2, 'title' => 'Another event'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('users/1/events', [])
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->events(1));
+    }
+
+    #[Test]
+    public function shouldGetEventsWithDateTimeParams(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'title' => 'An event'],
+            ['id' => 2, 'title' => 'Another event'],
+        ];
+
+        $after = new \DateTime('2018-01-01 00:00:00');
+        $before = new \DateTime('2018-01-31 00:00:00');
+
+        $expectedWithArray = [
+            'after' => $after->format('Y-m-d'),
+            'before' => $before->format('Y-m-d'),
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('users/1/events', $expectedWithArray)
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->events(1, ['after' => $after, 'before' => $before]));
+    }
+
+    #[Test]
+    public function shouldGetEventsWithPagination(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'title' => 'An event'],
+            ['id' => 2, 'title' => 'Another event'],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('users/1/events', [
+                'page' => 2,
+                'per_page' => 15,
+            ])
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->events(1, ['page' => 2, 'per_page' => 15]));
+    }
+
+    #[Test]
+    public function getRemoveUserIdentity(): void
+    {
+        $expectedArray = [
+            ['id' => 1, 'identities' => []],
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('delete')
+            ->with('users/1/identities/test')
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->removeUserIdentity(1, 'test'));
+    }
+}
diff --git a/tests/Api/VersionTest.php b/tests/Api/VersionTest.php
new file mode 100644
index 000000000..63080a79c
--- /dev/null
+++ b/tests/Api/VersionTest.php
@@ -0,0 +1,42 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Tests\Api;
+
+use Gitlab\Api\Version;
+use PHPUnit\Framework\Attributes\Test;
+
+class VersionTest extends TestCase
+{
+    #[Test]
+    public function shouldShowVersion(): void
+    {
+        $expectedArray = [
+            'version' => '8.13.0-pre',
+            'revision' => '4e963fe',
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('version')
+            ->willReturn($expectedArray);
+        $this->assertEquals($expectedArray, $api->show());
+    }
+
+    protected function getApiClass(): string
+    {
+        return Version::class;
+    }
+}
diff --git a/tests/Api/WikiTest.php b/tests/Api/WikiTest.php
new file mode 100644
index 000000000..4bb28b52c
--- /dev/null
+++ b/tests/Api/WikiTest.php
@@ -0,0 +1,129 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Tests\Api;
+
+use Gitlab\Api\Wiki;
+use PHPUnit\Framework\Attributes\Test;
+
+class WikiTest extends TestCase
+{
+    #[Test]
+    public function shouldCreateWiki(): void
+    {
+        $expectedArray = [
+            'format' => 'markdown',
+            'slug' => 'Test-Wiki',
+            'title' => 'Test Wiki',
+            'content' => 'This is the test Wiki',
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('post')
+            ->with('projects/1/wikis', [
+                'format' => 'markdown',
+                'title' => 'Test Wiki',
+                'content' => 'This is the test Wiki',
+            ])
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->create(
+            1,
+            [
+                'format' => 'markdown',
+                'title' => 'Test Wiki',
+                'content' => 'This is the test Wiki',
+            ]
+        ));
+    }
+
+    #[Test]
+    public function shouldShowWiki(): void
+    {
+        $expectedArray = [
+            'slug' => 'Test-Wiki',
+            'title' => 'Test Wiki',
+            'format' => 'markdown',
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/wikis/Test-Wiki')
+            ->willReturn($expectedArray);
+
+        $this->assertEquals($expectedArray, $api->show(1, 'Test-Wiki'));
+    }
+
+    #[Test]
+    public function shouldShowAllWiki(): void
+    {
+        $expectedArray = [
+            'slug' => 'Test-Wiki',
+            'title' => 'Test Wiki',
+            'format' => 'markdown',
+        ];
+
+        $params = ['with_content' => true];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('get')
+            ->with('projects/1/wikis', $params)
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->showAll(1, $params));
+    }
+
+    #[Test]
+    public function shouldUpdateWiki(): void
+    {
+        $expectedArray = [
+            'slug' => 'Test-Wiki',
+            'title' => 'Test Wiki',
+            'format' => 'markdown',
+            'content' => 'This is the test Wiki that has been updated',
+        ];
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('put')
+            ->with('projects/1/wikis/Test-Wiki', ['content' => 'This is the test Wiki that has been updated'])
+            ->willReturn($expectedArray)
+        ;
+
+        $this->assertEquals($expectedArray, $api->update(1, 'Test-Wiki', ['content' => 'This is the test Wiki that has been updated']));
+    }
+
+    #[Test]
+    public function shouldRemoveWiki(): void
+    {
+        $expectedBool = true;
+
+        $api = $this->getApiMock();
+        $api->expects($this->once())
+            ->method('delete')
+            ->with('projects/1/wikis/Test-Wiki')
+            ->willReturn($expectedBool);
+
+        $this->assertEquals($expectedBool, $api->remove(1, 'Test-Wiki'));
+    }
+
+    protected function getApiClass(): string
+    {
+        return Wiki::class;
+    }
+}
diff --git a/tests/ClientTest.php b/tests/ClientTest.php
new file mode 100644
index 000000000..72bff09db
--- /dev/null
+++ b/tests/ClientTest.php
@@ -0,0 +1,30 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Tests;
+
+use Gitlab\Client;
+use Http\Client\Common\HttpMethodsClient;
+use PHPUnit\Framework\TestCase;
+
+class ClientTest extends TestCase
+{
+    public function testCreateClient(): void
+    {
+        $client = new Client();
+
+        $this->assertInstanceOf(Client::class, $client);
+        $this->assertInstanceOf(HttpMethodsClient::class, $client->getHttpClient());
+    }
+}
diff --git a/tests/HttpClient/BuilderTest.php b/tests/HttpClient/BuilderTest.php
new file mode 100644
index 000000000..56ce8a5a7
--- /dev/null
+++ b/tests/HttpClient/BuilderTest.php
@@ -0,0 +1,72 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Tests\HttpClient;
+
+use Gitlab\HttpClient\Builder;
+use Http\Client\Common\HttpMethodsClientInterface;
+use Http\Client\Common\Plugin;
+use PHPUnit\Framework\Attributes\Before;
+use PHPUnit\Framework\TestCase;
+use Psr\Http\Client\ClientInterface;
+use Psr\Http\Message\RequestFactoryInterface;
+use Psr\Http\Message\StreamFactoryInterface;
+
+/**
+ * @author Fabien Bourigault <bourigaultfabien@gmail.com>
+ */
+class BuilderTest extends TestCase
+{
+    private Builder $subject;
+
+    #[Before]
+    public function initBuilder(): void
+    {
+        $this->subject = new Builder(
+            $this->createMock(ClientInterface::class),
+            $this->createMock(RequestFactoryInterface::class),
+            $this->createMock(StreamFactoryInterface::class)
+        );
+    }
+
+    public function testAddPluginShouldInvalidateHttpClient(): void
+    {
+        $client = $this->subject->getHttpClient();
+
+        $this->subject->addPlugin($this->createMock(Plugin::class));
+
+        $this->assertNotSame($client, $this->subject->getHttpClient());
+    }
+
+    public function testRemovePluginShouldInvalidateHttpClient(): void
+    {
+        $this->subject->addPlugin($this->createMock(Plugin::class));
+
+        $client = $this->subject->getHttpClient();
+
+        $this->subject->removePlugin(Plugin::class);
+
+        $this->assertNotSame($client, $this->subject->getHttpClient());
+    }
+
+    public function testHttpClientShouldBeAnHttpMethodsClient(): void
+    {
+        $this->assertInstanceOf(HttpMethodsClientInterface::class, $this->subject->getHttpClient());
+    }
+
+    public function testStreamFactoryShouldBeAStreamFactory(): void
+    {
+        $this->assertInstanceOf(StreamFactoryInterface::class, $this->subject->getStreamFactory());
+    }
+}
diff --git a/tests/HttpClient/Message/ResponseMediatorTest.php b/tests/HttpClient/Message/ResponseMediatorTest.php
new file mode 100644
index 000000000..7266ecfa5
--- /dev/null
+++ b/tests/HttpClient/Message/ResponseMediatorTest.php
@@ -0,0 +1,93 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Tests\HttpClient\Message;
+
+use Gitlab\Exception\RuntimeException;
+use Gitlab\HttpClient\Message\ResponseMediator;
+use GuzzleHttp\Psr7\Response;
+use GuzzleHttp\Psr7\Utils;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @author Tobias Nyholm <tobias.nyholm@gmail.com>
+ * @author Graham Campbell <hello@gjcampbell.co.uk>
+ */
+class ResponseMediatorTest extends TestCase
+{
+    public function testGetContent(): void
+    {
+        $response = new Response(
+            200,
+            ['Content-Type' => 'application/json'],
+            Utils::streamFor('{"foo": "bar"}')
+        );
+
+        $this->assertSame(['foo' => 'bar'], ResponseMediator::getContent($response));
+    }
+
+    public function testGetContentNotJson(): void
+    {
+        $response = new Response(
+            200,
+            [],
+            Utils::streamFor('foobar')
+        );
+
+        $this->assertSame('foobar', ResponseMediator::getContent($response));
+    }
+
+    public function testGetContentInvalidJson(): void
+    {
+        $response = new Response(
+            200,
+            ['Content-Type' => 'application/json'],
+            Utils::streamFor('foobar')
+        );
+
+        $this->expectException(RuntimeException::class);
+        $this->expectExceptionMessage('json_decode error: Syntax error');
+
+        ResponseMediator::getContent($response);
+    }
+
+    public function testGetErrrorMessageInvalidJson(): void
+    {
+        $response = new Response(
+            200,
+            ['Content-Type' => 'application/json'],
+            Utils::streamFor('foobar')
+        );
+
+        $this->assertNull(ResponseMediator::getErrorMessage($response));
+    }
+
+    public function testGetPagination(): void
+    {
+        $header = '<https://example.gitlab.com>; rel="first",<https://example.gitlab.com>; rel="next",<https://example.gitlab.com>; rel="prev",<https://example.gitlab.com>; rel="last"';
+
+        $pagination = [
+            'first' => 'https://example.gitlab.com',
+            'next' => 'https://example.gitlab.com',
+            'prev' => 'https://example.gitlab.com',
+            'last' => 'https://example.gitlab.com',
+        ];
+
+        // response mock
+        $response = new Response(200, ['link' => $header]);
+        $result = ResponseMediator::getPagination($response);
+
+        $this->assertSame($pagination, $result);
+    }
+}
diff --git a/tests/HttpClient/Util/QueryStringBuilderTest.php b/tests/HttpClient/Util/QueryStringBuilderTest.php
new file mode 100644
index 000000000..e3466897a
--- /dev/null
+++ b/tests/HttpClient/Util/QueryStringBuilderTest.php
@@ -0,0 +1,98 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Tests\HttpClient\Util;
+
+use Generator;
+use Gitlab\HttpClient\Util\QueryStringBuilder;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\TestCase;
+
+class QueryStringBuilderTest extends TestCase
+{
+    #[DataProvider('queryStringProvider')]
+    public function testBuild(array $query, string $expected): void
+    {
+        $this->assertSame(\sprintf('?%s', $expected), QueryStringBuilder::build($query));
+    }
+
+    public static function queryStringProvider(): Generator
+    {
+        yield 'indexed array' => [
+            [
+                'iids' => [88, 86],
+            ],
+            'iids%5B%5D=88&iids%5B%5D=86',
+        ];
+
+        yield 'non-indexed array with only numeric keys' => [
+            [
+                'iids' => [0 => 88, 2 => 86],
+            ],
+            'iids%5B0%5D=88&iids%5B2%5D=86',
+        ];
+
+        yield 'indexed array with multiple entries' => [
+            [
+                'source_branch' => 'test_source',
+                'target_branch' => 'test_master',
+                'title' => 'test',
+            ],
+            'source_branch=test_source&target_branch=test_master&title=test',
+        ];
+
+        yield 'boolean encoding with multiple entries' => [
+            [
+                'push_events' => false,
+                'merge_requests_events' => 1,
+            ],
+            'push_events=0&merge_requests_events=1',
+        ];
+
+        yield 'deeply nested array' => [
+            [
+                'search' => 'a project',
+                'owned' => 'true',
+                'iids' => [88, 86],
+                'assoc' => [
+                    'a' => 'b',
+                    'c' => [
+                        'd' => 'e',
+                        'f' => 'g',
+                    ],
+                ],
+                'nested' => [
+                    'a' => [
+                        [
+                            'b' => 'c',
+                        ],
+                        [
+                            'd' => 'e',
+                            'f' => [
+                                'g' => 'h',
+                                'i' => 'j',
+                                'k' => [87, 89],
+                            ],
+                        ],
+                    ],
+                ],
+            ],
+            'search=a%20project&owned=true&iids%5B%5D=88&iids%5B%5D=86'.
+            '&assoc%5Ba%5D=b&assoc%5Bc%5D%5Bd%5D=e&assoc%5Bc%5D%5Bf%5D=g'.
+            '&nested%5Ba%5D%5B%5D%5Bb%5D=c&nested%5Ba%5D%5B%5D%5Bd%5D=e'.
+            '&nested%5Ba%5D%5B%5D%5Bf%5D%5Bg%5D=h&nested%5Ba%5D%5B%5D%5Bf%5D%5Bi%5D=j'.
+            '&nested%5Ba%5D%5B%5D%5Bf%5D%5Bk%5D%5B%5D=87&nested%5Ba%5D%5B%5D%5Bf%5D%5Bk%5D%5B%5D=89',
+        ];
+    }
+}
diff --git a/tests/IntegrationTest.php b/tests/IntegrationTest.php
new file mode 100644
index 000000000..e095c4eac
--- /dev/null
+++ b/tests/IntegrationTest.php
@@ -0,0 +1,47 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Gitlab API library.
+ *
+ * (c) Matt Humphrey <matth@windsor-telecom.co.uk>
+ * (c) Graham Campbell <hello@gjcampbell.co.uk>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Gitlab\Tests;
+
+use Gitlab\Client;
+use Gitlab\Exception\RuntimeException;
+use PHPUnit\Framework\TestCase;
+
+class IntegrationTest extends TestCase
+{
+    public function testRepoContributors(): void
+    {
+        $client = new Client();
+
+        $response = $client
+            ->repositories()
+            ->contributors(16155465);
+
+        $this->assertIsArray($response);
+        $this->assertTrue(isset($response[2]));
+        $this->assertTrue(isset($response[2]['name']));
+    }
+
+    public function testRepoNotFound(): void
+    {
+        $client = new Client();
+
+        $this->expectException(RuntimeException::class);
+        $this->expectExceptionMessage('404 Project Not Found');
+
+        $client
+            ->repositories()
+            ->contributors(1);
+    }
+}
diff --git a/vendor-bin/phpstan/composer.json b/vendor-bin/phpstan/composer.json
new file mode 100644
index 000000000..a1bd73c47
--- /dev/null
+++ b/vendor-bin/phpstan/composer.json
@@ -0,0 +1,11 @@
+{
+    "require": {
+        "php": "^8.1",
+        "phpstan/phpstan": "2.1.6",
+        "phpstan/phpstan-deprecation-rules": "2.0.1",
+        "phpstan/phpstan-strict-rules": "2.0.3"
+    },
+    "config": {
+        "preferred-install": "dist"
+    }
+}
diff --git a/vendor-bin/phpunit/composer.json b/vendor-bin/phpunit/composer.json
new file mode 100644
index 000000000..21464a1e9
--- /dev/null
+++ b/vendor-bin/phpunit/composer.json
@@ -0,0 +1,9 @@
+{
+    "require": {
+        "php": "^8.1",
+        "phpunit/phpunit": "^10.5.45 || ^11.5.9"
+    },
+    "config": {
+        "preferred-install": "dist"
+    }
+}
diff --git a/vendor-bin/psalm/composer.json b/vendor-bin/psalm/composer.json
new file mode 100644
index 000000000..feebf503a
--- /dev/null
+++ b/vendor-bin/psalm/composer.json
@@ -0,0 +1,9 @@
+{
+    "require": {
+        "php": "^8.1",
+        "psalm/phar": "5.26.1"
+    },
+    "config": {
+        "preferred-install": "dist"
+    }
+}
