diff --git a/.gitattributes b/.gitattributes
index 0e4649d6eb1ff20a12f46b96fb0425a45514d09d..15b8fe602d9bda9a7bb8ea7a6664dad46ce50f81 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -2,11 +2,10 @@
 
 /doc export-ignore
 /tests export-ignore
-/.coveralls.yml export-ignore
-/.scrutinizer.yml export-ignore
 /.gitattributes export-ignore
 /.github export-ignore
 /.gitignore export-ignore
-/.travis.yml export-ignore
+/ecs.php export-ignore
+/rector.php export-ignore
 /phpunit.xml.dist export-ignore
 /README.md export-ignore
diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index 871735ad142f8e851489973c151c5ae0efeb3c01..ecc429e1b72aa2d5f45b3634b1d5b14fea056d99 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -1,25 +1,16 @@
-# Contributing
+# Aux contributeurs
 
-First of all, **thank you** for contributing.
+Tout d’abord, **merci** pour votre contribution.
 
-Bugs or feature requests can be posted online on the GitHub issues section of the project.
+Les bogues ou les demandes de fonctionnalités peuvent être publiés en ligne dans la section des problèmes GitHub du projet.
 
-Few rules to ease code reviews and merges:
+Quelques règles pour faciliter les revues de code et les fusions :
 
-- You MUST follow the [PSR-1](http://www.php-fig.org/psr/psr-1/), [PSR-2](http://www.php-fig.org/psr/psr-2/) and [PSR-4](http://www.php-fig.org/psr/psr-4/) coding standards.
-- You MUST run the test suite.
-- You MUST write (or update) unit tests when bugs are fixed or features are added.
-- You SHOULD write documentation.
+- Vous DEVEZ suivre le [PSR-1](http://www.php-fig.org/psr/psr-1/), [PSR-2](http://www.php-fig.org/psr /psr-2/) et [PSR-4](http://www.php-fig.org/psr/psr-4/).
+- Vous DEVEZ exécuter la suite de tests.
+- Vous DEVEZ écrire (ou mettre à jour) des tests lorsque des bogues sont corrigés ou que des fonctionnalités sont ajoutées.
+- Vous DEVRIEZ rédiger de la documentation.
 
-We use [Git-Flow](http://jeffkreeftmeijer.com/2010/why-arent-you-using-git-flow/) to automate our git branching workflow.
+[Git-Flow](http://jeffkreeftmeijer.com/2010/why-arent-you-using-git-flow/) est vivement recommandé.
 
-To contribute use [Pull Requests](https://help.github.com/articles/using-pull-requests), please, write commit messages that make sense, and rebase your branch before submitting your PR.
-
-May be asked to squash your commits too. This is used to "clean" your Pull Request before merging it, avoiding commits such as fix tests, fix 2, fix 3, etc.
-
-Run test suite
-------------
-
-* install composer: `curl -s http://getcomposer.org/installer | php`
-* install dependencies: `php composer.phar install`
-* run tests: `vendor/bin/phpunit`
+Merci de mettre à jour votre dépôt avant de soumettre votre Pull Request.
diff --git a/.github/ISSUE_TEMPLATE/1_Bug_report.md b/.github/ISSUE_TEMPLATE/1_Bug_report.md
new file mode 100644
index 0000000000000000000000000000000000000000..fca7e51ad4167b41c7f40a2ce9c13db4904f8d8e
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/1_Bug_report.md
@@ -0,0 +1,21 @@
+---
+name: 🐛 Bug Report
+about: ⚠️ For all bug reports except security issues (see below)
+labels: bug
+
+---
+
+**Version(s)** : x.y.z
+
+**Description**
+<!-- A clear and concise description of the problem. -->
+
+**How to reproduce?**
+<!-- Code or configuration needed to reproduce the problem. If it is a complex bug,
+      create a "bug reproducer" -->
+
+**Possible solution**
+<!--- Optional: only if you have any suggestions on a fix/bug reason -->
+
+**Additional context**
+<!-- Optional: any other context regarding the issue: log messages, screenshots, etc. -->
diff --git a/.github/ISSUE_TEMPLATE/2_Feature_request.md b/.github/ISSUE_TEMPLATE/2_Feature_request.md
new file mode 100644
index 0000000000000000000000000000000000000000..70a8ac072d049bcfc3542b33b0f75ab69a9fcb0a
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/2_Feature_request.md
@@ -0,0 +1,13 @@
+---
+name: 🎉 Feature request
+about: Ideas for new features and improvements
+labels: feature
+
+---
+
+**Description**
+<!-- A clear and concise description of the new feature. -->
+
+**Example**
+<!-- A simple example of the new feature in action (include PHP code, YAML configuration, etc.)
+      If the new feature changes an existing feature, include a before/after comparison. -->
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/3_Documentation_issue.md b/.github/ISSUE_TEMPLATE/3_Documentation_issue.md
new file mode 100644
index 0000000000000000000000000000000000000000..387d7c145263108a4dc7552cd4ddbd21d46856f6
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/3_Documentation_issue.md
@@ -0,0 +1,15 @@
+---
+name: 📖 Documentation issue
+about: If a typo or section is out of date
+labels: documentation
+
+---
+
+**Affected page**
+<!-- Indicate the page (link or specific area) that needs to be corrected. -->
+
+**What is written**
+<!-- Copy/Paste from the concerned section. -->
+
+**What should be written**
+<!-- Your proposed correction. -->
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000000000000000000000000000000000000..dde06cf341221f7468a2cd6e426d292df2d61398
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,5 @@
+blank_issues_enabled: false
+contact_links:
+  - name: Support Question
+    url: https://spomky-labs.com/contact/
+    about: We use GitHub issues only to discuss about bugs and new features. For this kind of questions about using the library, please use Stackoverflow (or similar) or send a quote request at https://spomky-labs.com/contact/
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index acd0be9fc0307d0c7271ccaee52314d8d9bf29aa..68ac3b4dc0f4b0bbbf3f7a394c2defd3a1b1bbb9 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -1,21 +1,15 @@
-| Q             | A
-| ------------- | ---
-| Branch?       | master
-| Bug fix?      | yes/no
-| New feature?  | yes/no
-| BC breaks?    | yes/no
-| Deprecations? | yes/no
-| Tests pass?   | yes/no
-| Fixed tickets | #... <!-- #-prefixed issue number(s), if any -->
-| License       | MIT
-| Tests added   |  <!--highly recommended for new features-->
-| Doc PR        |  <!--highly recommended for new features-->
-
+| Q                       | A
+| ----------------------- | ---
+| Correction de bogues?   | oui/non
+| Nouvelle fonctionnalité | oui/non
+| Dépréciations?          | oui/non
+| Tickets                | Corrige #...
 <!--
-Fill in this template according to the PR you're about to submit.
-Replace this comment by a description of what your PR is solving.
+Remplacez cet avis par un court README pour votre fonctionnalité/correction de bogues. Cela aidera les autres à
+comprendre votre PR. Cela peut être utilisé comme point de départ pour la documentation.
 
-Please consider the following requirement:
-* Modification of existing tests should be avoided unless deemed necessary.
-* You MUST never open a PR related to a security issue. Contact Spomky in private at https://gitter.im/Spomky/
+En outre:
+  - Ajoutez toujours des tests et assurez-vous qu’ils réussissent.
+  - Ne jamais rompre la rétrocompatibilité, sauf si c’est l’objet de votre PR.
+  - Votre dépôt doit être à jour.
 -->
diff --git a/.github/stale.yml b/.github/stale.yml
index dc90e5a1c3aad4818a813606b52fdecd2fdf6782..98284be67bc99e9f9ca7af7ac4fc2de3790f88dd 100644
--- a/.github/stale.yml
+++ b/.github/stale.yml
@@ -1,17 +1,8 @@
-# Number of days of inactivity before an issue becomes stale
 daysUntilStale: 60
-# Number of days of inactivity before a stale issue is closed
 daysUntilClose: 7
-# Issues with these labels will never be considered stale
-exemptLabels:
-  - pinned
-  - security
-# Label to use when marking an issue as stale
 staleLabel: wontfix
-# Comment to post when marking an issue as stale. Set to `false` to disable
 markComment: >
-  This issue has been automatically marked as stale because it has not had
-  recent activity. It will be closed if no further activity occurs. Thank you
-  for your contributions.
-# Comment to post when closing a stale issue. Set to `false` to disable
+  Ce problème a été automatiquement marqué comme périmé car il n'a pas eu
+  d’activité récente. Il sera fermé dans 7 jours si aucune autre activité ne se produit. Merci
+  pour votre contribution.
 closeComment: false
diff --git a/.github/workflows/coding-standards.yml b/.github/workflows/coding-standards.yml
new file mode 100644
index 0000000000000000000000000000000000000000..6674b28b4c3111dbfbd21d5671e658d6f0ab7f5f
--- /dev/null
+++ b/.github/workflows/coding-standards.yml
@@ -0,0 +1,34 @@
+name: Coding Standards
+
+on: [push]
+
+jobs:
+  tests:
+    runs-on: ${{ matrix.operating-system }}
+    strategy:
+      matrix:
+        operating-system: [ubuntu-latest]
+        php-versions: ['8.0', '8.1']
+    name: PHP ${{ matrix.php-versions }} Test on ${{ matrix.operating-system }}
+
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v2
+        with:
+          ref: ${{ github.head_ref }}
+
+      - name: Setup PHP, with composer and extensions
+        uses: shivammathur/setup-php@v2
+        with:
+          php-version: ${{ matrix.php-versions }}
+          extensions: mbstring
+          coverage: xdebug
+
+      - name: Install the application
+        run: |
+          composer install --no-progress --prefer-dist --optimize-autoloader
+          yarn install --force
+          yarn build
+
+      - name: Coding Standards Checks
+        run: make ci-cs
diff --git a/.github/workflows/mutation-tests.yml b/.github/workflows/mutation-tests.yml
new file mode 100644
index 0000000000000000000000000000000000000000..8189563870f57d51eeef306a072d8137eaacca73
--- /dev/null
+++ b/.github/workflows/mutation-tests.yml
@@ -0,0 +1,37 @@
+name: Mutation Testing
+
+on: [push]
+
+jobs:
+  tests:
+    runs-on: ${{ matrix.operating-system }}
+    strategy:
+      matrix:
+        operating-system: [ubuntu-latest]
+        php-versions: ['8.0', '8.1']
+    name: PHP ${{ matrix.php-versions }} Test on ${{ matrix.operating-system }}
+
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v2
+        with:
+          ref: ${{ github.head_ref }}
+
+      - name: Setup PHP, with composer and extensions
+        uses: shivammathur/setup-php@v2
+        with:
+          php-version: ${{ matrix.php-versions }}
+          extensions: mbstring
+          coverage: xdebug
+
+      - name: Install the application
+        run: |
+          composer install --no-progress --prefer-dist --optimize-autoloader
+          yarn install --force
+          yarn build
+
+      - name: Fetch Git base reference
+        run: git fetch --depth=1 origin $GITHUB_BASE_REF
+
+      - name: Infection
+        run: make ci-mu
diff --git a/.github/workflows/rector_checkstyle.yaml b/.github/workflows/rector_checkstyle.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..0b86d0dc7577a5fa3d4971ac608db774a6568336
--- /dev/null
+++ b/.github/workflows/rector_checkstyle.yaml
@@ -0,0 +1,32 @@
+name: Rector Checkstyle
+
+on: [push]
+
+jobs:
+  tests:
+    runs-on: ${{ matrix.operating-system }}
+    strategy:
+      matrix:
+        operating-system: [ ubuntu-latest ]
+        php-versions: ['8.0', '8.1']
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v2
+        with:
+          ref: ${{ github.head_ref }}
+
+      - name: Setup PHP, with composer and extensions
+        uses: shivammathur/setup-php@v2
+        with:
+          php-version: ${{ matrix.php-versions }}
+          extensions: mbstring
+          coverage: none
+
+      - name: Install the application
+        run: |
+          composer install --no-progress --prefer-dist --optimize-autoloader
+          yarn install --force
+          yarn build
+
+      - name: Rector
+        run: make ci-rector
diff --git a/.github/workflows/static-analyze.yml b/.github/workflows/static-analyze.yml
new file mode 100644
index 0000000000000000000000000000000000000000..0233057064c6de9539728096048fd95882688fe8
--- /dev/null
+++ b/.github/workflows/static-analyze.yml
@@ -0,0 +1,35 @@
+name: Static Analyze
+
+on: [push]
+
+jobs:
+  tests:
+    runs-on: ${{ matrix.operating-system }}
+    strategy:
+      matrix:
+        operating-system: [ubuntu-latest]
+        php-versions: ['8.0', '8.1']
+    name: PHP ${{ matrix.php-versions }} Test on ${{ matrix.operating-system }}
+
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v2
+        with:
+          ref: ${{ github.head_ref }}
+
+      - name: Setup PHP, with composer and extensions
+        uses: shivammathur/setup-php@v2
+        with:
+          php-version: ${{ matrix.php-versions }}
+          extensions: mbstring
+          coverage: xdebug
+          tools: cs2pr
+
+      - name: Install the application
+        run: |
+          composer install --no-progress --prefer-dist --optimize-autoloader
+          yarn install --force
+          yarn build
+
+      - name: Static Analyze Checks
+        run: make ci-st
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000000000000000000000000000000000000..668b92c7d5ee5e6bae66c1ff4e7a40ee7e46d894
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,34 @@
+name: Unit and Functional Tests
+
+on: [push]
+
+jobs:
+  tests:
+    runs-on: ${{ matrix.operating-system }}
+    strategy:
+      matrix:
+        operating-system: [ ubuntu-latest ]
+        php-versions: ['8.0', '8.1']
+    name: PHP ${{ matrix.php-versions }} Test on ${{ matrix.operating-system }}
+
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v2
+        with:
+          ref: ${{ github.head_ref }}
+
+      - name: Setup PHP, with composer and extensions
+        uses: shivammathur/setup-php@v2
+        with:
+          php-version: ${{ matrix.php-versions }}
+          extensions: mbstring
+          coverage: xdebug
+
+      - name: Install the application
+        run: |
+          composer install --no-progress --prefer-dist --optimize-autoloader
+          yarn install --force
+          yarn build
+
+      - name: Run tests
+        run: make all
diff --git a/.php_cs.dist b/.php_cs.dist
deleted file mode 100644
index 7410dff65902e3357954ccf3abd9868467a93434..0000000000000000000000000000000000000000
--- a/.php_cs.dist
+++ /dev/null
@@ -1,61 +0,0 @@
-<?php
-
-$header = 'The MIT License (MIT)
-
-Copyright (c) 2014-2019 Spomky-Labs
-
-This software may be modified and distributed under the terms
-of the MIT license.  See the LICENSE file for details.';
-
-$finder = PhpCsFixer\Finder::create()
-    ->in(__DIR__.'/src')
-    ->in(__DIR__.'/tests')
-;
-
-return PhpCsFixer\Config::create()
-    ->setRules([
-        '@PSR1' => true,
-        '@PSR2' => true,
-        '@Symfony' => true,
-        '@DoctrineAnnotation' => true,
-        '@PHP70Migration' => true,
-        '@PHP71Migration' => true,
-        'strict_param' => true,
-        'strict_comparison' => true,
-        'array_syntax' => ['syntax' => 'short'],
-        'array_indentation' => true,
-        'ordered_imports' => true,
-        'protected_to_private' => true,
-        'declare_strict_types' => true,
-        'native_function_invocation' => [
-            'include' => ['@compiler_optimized'],
-            'scope' => 'namespaced',
-        ],
-        'mb_str_functions' => true,
-        'method_chaining_indentation' => true,
-        'linebreak_after_opening_tag' => true,
-        'combine_consecutive_issets' => true,
-        'combine_consecutive_unsets' => true,
-        'compact_nullable_typehint' => true,
-        'no_superfluous_phpdoc_tags' => true,
-        'no_superfluous_elseif' => true,
-        'phpdoc_trim_consecutive_blank_line_separation' => true,
-        'phpdoc_order' => true,
-        'pow_to_exponentiation' => true,
-        'simplified_null_return' => true,
-        'header_comment' => [
-            'header' => $header,
-        ],
-        'align_multiline_comment' => [
-            'comment_type' => 'all_multiline',
-        ],
-        'php_unit_test_annotation' => [
-            'case' => 'snake',
-            'style' => 'annotation',
-        ],
-        'php_unit_test_case_static_method_calls' => true,
-    ])
-    ->setRiskyAllowed(true)
-    ->setUsingCache(true)
-    ->setFinder($finder)
-    ;
diff --git a/.scrutinizer.yml b/.scrutinizer.yml
deleted file mode 100644
index 61330d01ad1d963e2337564259a0766a50f7abd3..0000000000000000000000000000000000000000
--- a/.scrutinizer.yml
+++ /dev/null
@@ -1,37 +0,0 @@
-before_commands:
-    - "composer install --prefer-dist"
-
-checks:
-    php:
-        code_rating: true
-        duplication: false
-
-build:
-    nodes:
-        analysis:
-            tests:
-                override:
-                    - php-scrutinizer-run
-
-tools:
-    php_sim: false
-    php_changetracking: true
-    sensiolabs_security_checker: true
-    php_mess_detector: true
-    php_code_sniffer: true
-    php_analyzer: true
-    php_code_coverage: false
-    php_cpd: true
-    php_pdepend:
-        excluded_dirs: [vendor/*, doc/*, tests/*]
-filter:
-    excluded_paths: [vendor/*, doc/*, tests/*]
-build_failure_conditions:
-    - 'elements.rating(<= D).exists'               # No classes/methods with a rating of D or worse
-    - 'elements.rating(<= D).new.exists'           # No new classes/methods with a rating of D or worse
-    - 'patches.label("Doc Comments").exists'       # No doc comments patches allowed
-    - 'patches.label("Spacing").new.count > 1'     # More than 1 new spacing patch
-    - 'issues.label("coding-style").exists'        # No coding style issues allowed
-    - 'issues.label("coding-style").new.exists'    # No new coding style issues allowed
-    - 'issues.severity(>= MAJOR).new.exists'       # New issues of major or higher severity
-    - 'project.metric("scrutinizer.quality", < 9)' # Code Quality Rating drops below 6
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 835b621d0640ca510b1e545a75ff8841bf0d943c..0000000000000000000000000000000000000000
--- a/.travis.yml
+++ /dev/null
@@ -1,57 +0,0 @@
-sudo: false
-language: php
-
-php:
-    - 7.2
-    - 7.3
-    - 7.4
-    - nightly
-
-cache:
-    directories:
-        - $HOME/.composer/cache
-        - vendor
-
-before_script:
-    - mkdir -p build/logs
-
-before_install:
-    - mv ~/.phpenv/versions/$(phpenv version-name)/etc/conf.d/xdebug.ini{,.disabled} || echo "xdebug not available"
-
-install: travis_retry composer install
-
-jobs:
-    allow_failures:
-        - php: nightly
-
-    include:
-        - stage: Metrics and quality
-          env: COVERAGE
-          before_script:
-              - mv ~/.phpenv/versions/$(phpenv version-name)/etc/conf.d/xdebug.ini{.disabled,}
-              - if [[ ! $(php -m | grep -si xdebug) ]]; then echo "xdebug required for coverage"; exit 1; fi
-          script:
-              - vendor/bin/phpunit --coverage-clover build/logs/clover.xml
-          after_script:
-              - ./vendor/bin/php-coveralls --no-interaction
-
-        - stage: Metrics and quality
-          env: STATIC_ANALYSIS
-          script:
-              - ./vendor/bin/phpstan analyse
-
-        - stage: Metrics and quality
-          env: CODING_STANDARDS
-          before_script:
-            - wget https://cs.symfony.com/download/php-cs-fixer-v2.phar -O php-cs-fixer
-            - chmod a+x php-cs-fixer
-          script:
-            - ./php-cs-fixer fix --dry-run --stop-on-violation --using-cache=no
-
-        - stage: Security Check
-          env: SECURITY_CHECK
-          before_script:
-              - wget -c https://get.sensiolabs.org/security-checker.phar
-              - chmod +x security-checker.phar
-          script:
-              - ./security-checker.phar security:check
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000000000000000000000000000000000000..e96bc55c1ef080a10890c5464edf77574bd03665
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,81 @@
+########################
+#         CI/CD        #
+########################
+
+ci-cs: vendor ## Check all files using defined rules (CI/CD)
+	vendor/bin/ecs check
+
+ci-st: vendor ## Run static analyse (CI/CD)
+	vendor/bin/phpstan analyse --error-format=checkstyle | cs2pr
+
+ci-rector: vendor ## Check all files using Rector (CI/CD)
+	vendor/bin/rector process --ansi --dry-run
+
+ci-mu: vendor ## Mutation tests (CI/CD)
+	vendor/bin/infection --logger-github --git-diff-filter=AM -s --threads=$(nproc) --min-msi=70 --min-covered-msi=50 --test-framework-options="--exclude-group=Performance"
+
+########################
+#      Everyday        #
+########################
+
+all: vendor ## Run all tests
+	vendor/bin/phpunit --color
+
+tu: vendor ## Run only unit tests
+	vendor/bin/phpunit --color tests/Unit
+
+ti: vendor ## Run only integration tests
+	vendor/bin/phpunit --color tests/Integration
+
+tf: vendor ## Run only functional tests
+	vendor/bin/phpunit --color tests/Functional
+
+st: vendor ## Run static analyse
+	vendor/bin/phpstan analyse
+
+
+########################
+#      Every PR        #
+########################
+
+cs: vendor ## Fix all files using defined rules
+	vendor/bin/ecs check --fix
+
+rector: vendor ## Check all files using Rector
+	vendor/bin/rector process
+
+
+########################
+#        Others        #
+########################
+
+twig-lint: vendor ## All Twig template checks
+	bin/console lint:twig templates/
+
+mu: vendor ## Mutation tests
+	vendor/bin/infection -s --threads=$(nproc) --min-msi=70 --min-covered-msi=50 --test-framework-options="--exclude-group=Performance"
+
+db: vendor ## Create the database (should only be used in local env
+	bin/console doctrine:database:drop --env=test --force
+	bin/console doctrine:database:create --env=test
+	bin/console doctrine:schema:create --env=test
+
+clean: vendor ## Cleanup the var folder
+	rm -rf var
+
+cc: vendor ## Show test coverage rates (HTML)
+	vendor/bin/phpunit --coverage-html ./build
+
+vendor: composer.json composer.lock
+	composer validate
+	composer install
+
+
+########################
+#      Default         #
+########################
+
+.DEFAULT_GOAL := help
+help:
+	@grep -E '(^[a-zA-Z_-]+:.*?##.*$$)|(^##)' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[32m%-30s\033[0m %s\n", $$1, $$2}' | sed -e 's/\[32m##/[33m/'
+.PHONY: help
diff --git a/README.md b/README.md
index 9b9508288ac66445cb2546434dc5d04ba61f32fa..f4eb3c6dbdcbd6f5f293974dbe9b0226d5fc5c10 100644
--- a/README.md
+++ b/README.md
@@ -1,12 +1,13 @@
 TOTP / HOTP library in PHP
 ==========================
 
-[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/Spomky-Labs/otphp/badges/quality-score.png?b=v10.0)](https://scrutinizer-ci.com/g/Spomky-Labs/otphp/?branch=v10.0)
-[![Coverage Status](https://coveralls.io/repos/Spomky-Labs/otphp/badge.svg?branch=v10.0&service=github)](https://coveralls.io/github/Spomky-Labs/otphp?branch=v10.0)
-[![Build Status](https://travis-ci.org/Spomky-Labs/otphp.svg?branch=v10.0)](https://travis-ci.org/Spomky-Labs/otphp)
-[![GuardRails badge](https://badges.guardrails.io/Spomky-Labs/otphp.svg)](https://www.guardrails.io)
+![Build Status](https://github.com/spomky-labs/otphp/workflows/Coding%20Standards/badge.svg)
+![Build Status](https://github.com/spomky-labs/otphp/workflows/Rector%20Checkstyle/badge.svg)
+![Build Status](https://github.com/spomky-labs/otphp/workflows/Static%20Analyze/badge.svg)
 
-[![SensioLabsInsight](https://insight.sensiolabs.com/projects/49e5925d-0dd8-4b89-a215-5eb33b4d96d9/big.png)](https://insight.sensiolabs.com/projects/49e5925d-0dd8-4b89-a215-5eb33b4d96d9)
+![Build Status](https://github.com/spomky-labs/otphp/workflows/Unit%20and%20Functional%20Tests/badge.svg)
+
+![Build Status](https://github.com/spomky-labs/otphp/workflows/Mutation%20Testing/badge.svg)
 
 [![Latest Stable Version](https://poser.pugx.org/spomky-labs/otphp/v/stable.png)](https://packagist.org/packages/spomky-labs/otphp)
 [![Total Downloads](https://poser.pugx.org/spomky-labs/otphp/downloads.png)](https://packagist.org/packages/spomky-labs/otphp)
@@ -26,7 +27,7 @@ The documentation of this project is available in the [*doc* folder](doc/index.m
 
 I bring solutions to your problems and answer your questions.
 
-If you really love that project and the work I have done or if you want I prioritize your issues, then you can help me out for a couple of :beers: or more!
+If you really love that project, and the work I have done or if you want I prioritize your issues, then you can help me out for a couple of :beers: or more!
 
 [Become a sponsor](https://github.com/sponsors/Spomky)
 
@@ -42,10 +43,6 @@ Please report all issues in [the repository bug tracker](hhttps://github.com/Spo
 
 Also make sure to [follow these best practices](.github/CONTRIBUTING.md).
 
-## Security Issues
-
-**If you think you have found a security issue, DO NOT open an issue**. [You MUST submit your issue here](https://gitter.im/Spomky/).
-
 ## Licence
 
 This software is release under the [MIT licence](LICENSE).
diff --git a/composer.json b/composer.json
index 8979e8f06e6c4839032193da917a958266915c37..e3d9eb5731d8ef22a7d47ccb3bfc84e0fb634d5c 100644
--- a/composer.json
+++ b/composer.json
@@ -16,23 +16,23 @@
         }
     ],
     "require": {
-        "php": "^7.2|^8.0",
+        "php": "^8.0",
         "ext-mbstring": "*",
         "paragonie/constant_time_encoding": "^2.0",
         "beberlei/assert": "^3.0",
-        "thecodingmachine/safe": "^0.1.14|^1.0"
+        "thecodingmachine/safe": "^1.0|^2.0"
     },
     "require-dev": {
-        "phpunit/phpunit": "^8.0",
-        "php-coveralls/php-coveralls": "^2.0",
-        "phpstan/phpstan": "^0.12",
-        "phpstan/phpstan-beberlei-assert": "^0.12",
-        "phpstan/phpstan-deprecation-rules": "^0.12",
-        "phpstan/phpstan-phpunit": "^0.12",
-        "phpstan/phpstan-strict-rules": "^0.12",
-        "thecodingmachine/phpstan-safe-rule": "^1.0"
-    },
-    "suggest": {
+        "infection/infection": "^0.26",
+        "phpunit/phpunit": "^9.5",
+        "phpstan/phpstan": "^1.0",
+        "phpstan/phpstan-beberlei-assert": "^1.0",
+        "phpstan/phpstan-deprecation-rules": "^1.0",
+        "phpstan/phpstan-phpunit": "^1.0",
+        "phpstan/phpstan-strict-rules": "^1.0",
+        "thecodingmachine/phpstan-safe-rule": "^1.0",
+        "rector/rector": "^0.12.11",
+        "symplify/easy-coding-standard": "^10.0"
     },
     "autoload": {
         "psr-4": { "OTPHP\\": "src/" }
@@ -40,11 +40,18 @@
     "autoload-dev": {
         "psr-4": { "OTPHP\\Test\\": "tests/" }
     },
-    "extra": {
-        "branch-alias": {
-            "v10.0": "10.0.x-dev",
-            "v9.0": "9.0.x-dev",
-            "v8.3": "8.3.x-dev"
-        }
+    "config": {
+        "allow-plugins": {
+            "phpstan/extension-installer": true,
+            "infection/extension-installer": true,
+            "composer/package-versions-deprecated": true,
+            "symfony/flex": true,
+            "symfony/runtime": true
+        },
+        "optimize-autoloader": true,
+        "preferred-install": {
+            "*": "dist"
+        },
+        "sort-packages": true
     }
 }
diff --git a/doc/UPGRADE_v10-v11.md b/doc/UPGRADE_v10-v11.md
new file mode 100644
index 0000000000000000000000000000000000000000..08b102746f88e6311f3b8dca790e34a27d489301
--- /dev/null
+++ b/doc/UPGRADE_v10-v11.md
@@ -0,0 +1 @@
+# Upgrade from `v10.x` to `v11.x`
diff --git a/ecs.php b/ecs.php
new file mode 100644
index 0000000000000000000000000000000000000000..0454311c1a3abf2b5b57852481ffc240a2ec51c0
--- /dev/null
+++ b/ecs.php
@@ -0,0 +1,124 @@
+<?php
+
+declare(strict_types=1);
+
+use PhpCsFixer\Fixer\Alias\MbStrFunctionsFixer;
+use PhpCsFixer\Fixer\ArrayNotation\ArraySyntaxFixer;
+use PhpCsFixer\Fixer\ClassNotation\ProtectedToPrivateFixer;
+use PhpCsFixer\Fixer\Comment\HeaderCommentFixer;
+use PhpCsFixer\Fixer\ConstantNotation\NativeConstantInvocationFixer;
+use PhpCsFixer\Fixer\ControlStructure\NoSuperfluousElseifFixer;
+use PhpCsFixer\Fixer\FunctionNotation\NativeFunctionInvocationFixer;
+use PhpCsFixer\Fixer\Import\GlobalNamespaceImportFixer;
+use PhpCsFixer\Fixer\Import\OrderedImportsFixer;
+use PhpCsFixer\Fixer\LanguageConstruct\CombineConsecutiveIssetsFixer;
+use PhpCsFixer\Fixer\LanguageConstruct\CombineConsecutiveUnsetsFixer;
+use PhpCsFixer\Fixer\Phpdoc\AlignMultilineCommentFixer;
+use PhpCsFixer\Fixer\Phpdoc\NoSuperfluousPhpdocTagsFixer;
+use PhpCsFixer\Fixer\Phpdoc\PhpdocOrderFixer;
+use PhpCsFixer\Fixer\Phpdoc\PhpdocTrimConsecutiveBlankLineSeparationFixer;
+use PhpCsFixer\Fixer\PhpTag\LinebreakAfterOpeningTagFixer;
+use PhpCsFixer\Fixer\PhpUnit\PhpUnitTestAnnotationFixer;
+use PhpCsFixer\Fixer\PhpUnit\PhpUnitTestCaseStaticMethodCallsFixer;
+use PhpCsFixer\Fixer\PhpUnit\PhpUnitTestClassRequiresCoversFixer;
+use PhpCsFixer\Fixer\ReturnNotation\SimplifiedNullReturnFixer;
+use PhpCsFixer\Fixer\Strict\DeclareStrictTypesFixer;
+use PhpCsFixer\Fixer\Strict\StrictComparisonFixer;
+use PhpCsFixer\Fixer\Strict\StrictParamFixer;
+use PhpCsFixer\Fixer\Whitespace\ArrayIndentationFixer;
+use PhpCsFixer\Fixer\Whitespace\CompactNullableTypehintFixer;
+use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
+use Symplify\EasyCodingStandard\ValueObject\Option;
+use Symplify\EasyCodingStandard\ValueObject\Set\SetList;
+
+return static function (ContainerConfigurator $containerConfigurator): void {
+    $header = '';
+
+    $containerConfigurator->import(SetList::PSR_12);
+    $containerConfigurator->import(SetList::PHP_CS_FIXER);
+    $containerConfigurator->import(SetList::PHP_CS_FIXER_RISKY);
+    $containerConfigurator->import(SetList::CLEAN_CODE);
+    $containerConfigurator->import(SetList::SYMFONY);
+    $containerConfigurator->import(SetList::DOCTRINE_ANNOTATIONS);
+    $containerConfigurator->import(SetList::SPACES);
+    $containerConfigurator->import(SetList::PHPUNIT);
+    $containerConfigurator->import(SetList::SYMPLIFY);
+    $containerConfigurator->import(SetList::ARRAY);
+    $containerConfigurator->import(SetList::COMMON);
+    $containerConfigurator->import(SetList::COMMENTS);
+    $containerConfigurator->import(SetList::CONTROL_STRUCTURES);
+    $containerConfigurator->import(SetList::DOCBLOCK);
+    $containerConfigurator->import(SetList::NAMESPACES);
+    $containerConfigurator->import(SetList::STRICT);
+
+    $services = $containerConfigurator->services();
+    $services->set(StrictParamFixer::class);
+    $services->set(StrictComparisonFixer::class);
+    $services->set(ArraySyntaxFixer::class)
+        ->call('configure', [[
+            'syntax' => 'short',
+        ]])
+    ;
+    $services->set(ArrayIndentationFixer::class);
+    $services->set(OrderedImportsFixer::class);
+    $services->set(ProtectedToPrivateFixer::class);
+    $services->set(DeclareStrictTypesFixer::class);
+    $services->set(NativeConstantInvocationFixer::class);
+    $services->set(NativeFunctionInvocationFixer::class)
+        ->call('configure', [[
+            'include' => ['@compiler_optimized'],
+            'scope' => 'namespaced',
+            'strict' => true,
+        ]])
+    ;
+    $services->set(MbStrFunctionsFixer::class);
+    $services->set(LinebreakAfterOpeningTagFixer::class);
+    $services->set(CombineConsecutiveIssetsFixer::class);
+    $services->set(CombineConsecutiveUnsetsFixer::class);
+    $services->set(CompactNullableTypehintFixer::class);
+    $services->set(NoSuperfluousElseifFixer::class);
+    $services->set(NoSuperfluousPhpdocTagsFixer::class);
+    $services->set(PhpdocTrimConsecutiveBlankLineSeparationFixer::class);
+    $services->set(PhpdocOrderFixer::class);
+    $services->set(SimplifiedNullReturnFixer::class);
+    $services->set(HeaderCommentFixer::class)
+        ->call('configure', [[
+            'header' => $header,
+        ]])
+    ;
+    $services->set(AlignMultilineCommentFixer::class)
+        ->call('configure', [[
+            'comment_type' => 'all_multiline',
+        ]])
+    ;
+    $services->set(PhpUnitTestAnnotationFixer::class)
+        ->call('configure', [[
+            'style' => 'annotation',
+        ]])
+    ;
+    $services->set(PhpUnitTestCaseStaticMethodCallsFixer::class);
+    $services->set(GlobalNamespaceImportFixer::class)
+        ->call('configure', [[
+            'import_classes' => true,
+            'import_constants' => true,
+            'import_functions' => true,
+        ]])
+    ;
+
+    $services->remove(PhpUnitTestClassRequiresCoversFixer::class);
+
+    $parameters = $containerConfigurator->parameters();
+    $parameters
+        ->set(Option::PARALLEL, true)
+        ->set(Option::PATHS, [__DIR__])
+        ->set(Option::SKIP, [
+            __DIR__ . '/src/Kernel.php',
+            __DIR__ . '/assets',
+            __DIR__ . '/bin',
+            __DIR__ . '/config',
+            __DIR__ . '/heroku',
+            __DIR__ . '/public',
+            __DIR__ . '/var',
+        ])
+    ;
+};
diff --git a/infection.json.dist b/infection.json.dist
new file mode 100644
index 0000000000000000000000000000000000000000..f02cbf8717b764e2f6f7a8a46b034e03c49218e8
--- /dev/null
+++ b/infection.json.dist
@@ -0,0 +1,16 @@
+{
+    "source": {
+        "directories": [
+            "src"
+        ]
+    },
+    "logs": {
+        "text": "infection.log"
+    },
+    "mutators": {
+        "@default": true,
+        "global-ignoreSourceCodeByRegex": [
+           "\\$this->logger.*"
+        ]
+    }
+}
\ No newline at end of file
diff --git a/phpstan.neon b/phpstan.neon
index c9be896234126222af33e0537715d52b7f619922..5f2b3faa8f0294fa4606e64dd7edff999c070cd3 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -1,5 +1,5 @@
 parameters:
-    level: 7
+    level: max
     paths:
         - src
         - tests
@@ -11,3 +11,4 @@ includes:
     - vendor/phpstan/phpstan-deprecation-rules/rules.neon
     - vendor/thecodingmachine/phpstan-safe-rule/phpstan-safe-rule.neon
     - vendor/phpstan/phpstan-beberlei-assert/extension.neon
+    - vendor/phpstan/phpstan-phpunit/rules.neon
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index 327a1d1e08e4f9063c1d1fdbb26d9a3e861c1ed3..52370048ae694c783b7a7133a71d0b53924faa2a 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -1,28 +1,18 @@
 <?xml version="1.0" encoding="UTF-8"?>
-<phpunit
-    backupGlobals="false"
-    backupStaticAttributes="false"
-    convertErrorsToExceptions="true"
-    convertNoticesToExceptions="true"
-    convertWarningsToExceptions="true"
-    processIsolation="false"
-    stopOnFailure="false"
-    bootstrap="vendor/autoload.php"
-    colors="true">
-    <testsuites>
-        <testsuite name="OTP Test Suite">
-            <directory suffix="Test.php">./tests</directory>
-        </testsuite>
-    </testsuites>
-
-    <filter>
-        <whitelist>
-            <directory suffix=".php">./</directory>
-            <exclude>
-                <directory>./tests</directory>
-                <directory>./doc</directory>
-                <directory>./vendor</directory>
-            </exclude>
-        </whitelist>
-    </filter>
+<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" backupGlobals="false" backupStaticAttributes="false" convertErrorsToExceptions="true" convertNoticesToExceptions="true" convertWarningsToExceptions="true" processIsolation="false" stopOnFailure="false" bootstrap="vendor/autoload.php" colors="true" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd">
+  <coverage>
+    <include>
+      <directory suffix=".php">./</directory>
+    </include>
+    <exclude>
+      <directory>./tests</directory>
+      <directory>./doc</directory>
+      <directory>./vendor</directory>
+    </exclude>
+  </coverage>
+  <testsuites>
+    <testsuite name="OTP Test Suite">
+      <directory suffix="Test.php">./tests</directory>
+    </testsuite>
+  </testsuites>
 </phpunit>
diff --git a/rector.php b/rector.php
new file mode 100644
index 0000000000000000000000000000000000000000..7ccb5c0926b5895dacfd3b8a3e4d1a9e403d73d3
--- /dev/null
+++ b/rector.php
@@ -0,0 +1,34 @@
+<?php
+
+declare(strict_types=1);
+
+use Rector\Core\Configuration\Option;
+use Rector\Doctrine\Set\DoctrineSetList;
+use Rector\Php74\Rector\Property\TypedPropertyRector;
+use Rector\PHPUnit\Set\PHPUnitSetList;
+use Rector\Set\ValueObject\SetList;
+use Rector\Symfony\Set\SymfonySetList;
+use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
+
+return static function (ContainerConfigurator $containerConfigurator): void {
+    $containerConfigurator->import(SetList::DEAD_CODE);
+    $containerConfigurator->import(SetList::PHP_80);
+    $containerConfigurator->import(SymfonySetList::SYMFONY_52);
+    $containerConfigurator->import(SymfonySetList::SYMFONY_CODE_QUALITY);
+    $containerConfigurator->import(SymfonySetList::SYMFONY_52_VALIDATOR_ATTRIBUTES);
+    $containerConfigurator->import(SymfonySetList::SYMFONY_CONSTRUCTOR_INJECTION);
+    $containerConfigurator->import(SymfonySetList::ANNOTATIONS_TO_ATTRIBUTES);
+    $containerConfigurator->import(DoctrineSetList::DOCTRINE_CODE_QUALITY);
+    $containerConfigurator->import(DoctrineSetList::ANNOTATIONS_TO_ATTRIBUTES);
+    $containerConfigurator->import(PHPUnitSetList::PHPUNIT_EXCEPTION);
+    $containerConfigurator->import(PHPUnitSetList::PHPUNIT_SPECIFIC_METHOD);
+    $containerConfigurator->import(PHPUnitSetList::PHPUNIT_91);
+    $containerConfigurator->import(PHPUnitSetList::PHPUNIT_YIELD_DATA_PROVIDER);
+    $parameters = $containerConfigurator->parameters();
+    $parameters->set(Option::PATHS, [__DIR__ . '/src', __DIR__ . '/tests']);
+    $parameters->set(Option::AUTO_IMPORT_NAMES, true);
+    $parameters->set(Option::IMPORT_SHORT_CLASSES, true);
+
+    $services = $containerConfigurator->services();
+    $services->set(TypedPropertyRector::class);
+};
diff --git a/src/Factory.php b/src/Factory.php
index 70df63945588b71f02959064dae491ad3b1b2588..42b5fbf79c2a7d7999b3e3726e4093532d60ac81 100644
--- a/src/Factory.php
+++ b/src/Factory.php
@@ -2,21 +2,11 @@
 
 declare(strict_types=1);
 
-/*
- * The MIT License (MIT)
- *
- * Copyright (c) 2014-2019 Spomky-Labs
- *
- * This software may be modified and distributed under the terms
- * of the MIT license.  See the LICENSE file for details.
- */
-
 namespace OTPHP;
 
 use Assert\Assertion;
+use function count;
 use InvalidArgumentException;
-use function Safe\parse_url;
-use function Safe\sprintf;
 use Throwable;
 
 /**
@@ -27,12 +17,10 @@ final class Factory implements FactoryInterface
     public static function loadFromProvisioningUri(string $uri): OTPInterface
     {
         try {
-            $parsed_url = parse_url($uri);
+            $parsed_url = Url::fromString($uri);
         } catch (Throwable $throwable) {
             throw new InvalidArgumentException('Not a valid OTP provisioning URI', $throwable->getCode(), $throwable);
         }
-        Assertion::isArray($parsed_url, 'Not a valid OTP provisioning URI');
-        self::checkData($parsed_url);
 
         $otp = self::createOTP($parsed_url);
 
@@ -41,68 +29,46 @@ final class Factory implements FactoryInterface
         return $otp;
     }
 
-    /**
-     * @param array<string, mixed> $data
-     */
-    private static function populateParameters(OTPInterface &$otp, array $data): void
+    private static function populateParameters(OTPInterface $otp, Url $data): void
     {
-        foreach ($data['query'] as $key => $value) {
+        foreach ($data->getQuery() as $key => $value) {
             $otp->setParameter($key, $value);
         }
     }
 
-    /**
-     * @param array<string, mixed> $data
-     */
-    private static function populateOTP(OTPInterface &$otp, array $data): void
+    private static function populateOTP(OTPInterface $otp, Url $data): void
     {
         self::populateParameters($otp, $data);
-        $result = explode(':', rawurldecode(mb_substr($data['path'], 1)));
+        $result = explode(':', rawurldecode(mb_substr($data->getPath(), 1)));
 
-        if (2 > \count($result)) {
+        if (count($result) < 2) {
             $otp->setIssuerIncludedAsParameter(false);
 
             return;
         }
 
-        if (null !== $otp->getIssuer()) {
+        if ($otp->getIssuer() !== null) {
             Assertion::eq($result[0], $otp->getIssuer(), 'Invalid OTP: invalid issuer in parameter');
             $otp->setIssuerIncludedAsParameter(true);
         }
         $otp->setIssuer($result[0]);
     }
 
-    /**
-     * @param array<string, mixed> $data
-     */
-    private static function checkData(array &$data): void
-    {
-        foreach (['scheme', 'host', 'path', 'query'] as $key) {
-            Assertion::keyExists($data, $key, 'Not a valid OTP provisioning URI');
-        }
-        Assertion::eq('otpauth', $data['scheme'], 'Not a valid OTP provisioning URI');
-        parse_str($data['query'], $data['query']);
-        Assertion::keyExists($data['query'], 'secret', 'Not a valid OTP provisioning URI');
-    }
-
-    /**
-     * @param array<string, mixed> $parsed_url
-     */
-    private static function createOTP(array $parsed_url): OTPInterface
+    private static function createOTP(Url $parsed_url): OTPInterface
     {
-        switch ($parsed_url['host']) {
+        switch ($parsed_url->getHost()) {
             case 'totp':
-                $totp = TOTP::create($parsed_url['query']['secret']);
-                $totp->setLabel(self::getLabel($parsed_url['path']));
+                $totp = TOTP::create($parsed_url->getSecret());
+                $totp->setLabel(self::getLabel($parsed_url->getPath()));
 
                 return $totp;
             case 'hotp':
-                $hotp = HOTP::create($parsed_url['query']['secret']);
-                $hotp->setLabel(self::getLabel($parsed_url['path']));
+                $hotp = HOTP::create($parsed_url->getSecret());
+                $hotp->setLabel(self::getLabel($parsed_url->getPath()));
 
                 return $hotp;
             default:
-                throw new InvalidArgumentException(sprintf('Unsupported "%s" OTP type', $parsed_url['host']));
+                throw new InvalidArgumentException(sprintf('Unsupported "%s" OTP type', $parsed_url->getHost()));
         }
     }
 
@@ -110,6 +76,6 @@ final class Factory implements FactoryInterface
     {
         $result = explode(':', rawurldecode(mb_substr($data, 1)));
 
-        return 2 === \count($result) ? $result[1] : $result[0];
+        return count($result) === 2 ? $result[1] : $result[0];
     }
 }
diff --git a/src/FactoryInterface.php b/src/FactoryInterface.php
index 00acc2d043bae100f648995dfb1300778e90edc0..74386adebcbdd27516a152f3963b38ec35bb4a7c 100644
--- a/src/FactoryInterface.php
+++ b/src/FactoryInterface.php
@@ -2,22 +2,13 @@
 
 declare(strict_types=1);
 
-/*
- * The MIT License (MIT)
- *
- * Copyright (c) 2014-2019 Spomky-Labs
- *
- * This software may be modified and distributed under the terms
- * of the MIT license.  See the LICENSE file for details.
- */
-
 namespace OTPHP;
 
 interface FactoryInterface
 {
     /**
-     * This method is the unique public method of the class.
-     * It can load a provisioning Uri and convert it into an OTP object.
+     * This method is the unique public method of the class. It can load a provisioning Uri and convert it into an OTP
+     * object.
      */
     public static function loadFromProvisioningUri(string $uri): OTPInterface;
 }
diff --git a/src/HOTP.php b/src/HOTP.php
index a2f4a23951a1210886aa2e6460210eeb8d42f893..49d5c232614e408102f0db4ef403cb204ac92a99 100644
--- a/src/HOTP.php
+++ b/src/HOTP.php
@@ -2,15 +2,6 @@
 
 declare(strict_types=1);
 
-/*
- * The MIT License (MIT)
- *
- * Copyright (c) 2014-2019 Spomky-Labs
- *
- * This software may be modified and distributed under the terms
- * of the MIT license.  See the LICENSE file for details.
- */
-
 namespace OTPHP;
 
 use Assert\Assertion;
@@ -23,29 +14,28 @@ final class HOTP extends OTP implements HOTPInterface
         $this->setCounter($counter);
     }
 
-    public static function create(?string $secret = null, int $counter = 0, string $digest = 'sha1', int $digits = 6): HOTPInterface
-    {
+    public static function create(
+        ?string $secret = null,
+        int $counter = 0,
+        string $digest = 'sha1',
+        int $digits = 6
+    ): HOTPInterface {
         return new self($secret, $counter, $digest, $digits);
     }
 
-    protected function setCounter(int $counter): void
-    {
-        $this->setParameter('counter', $counter);
-    }
-
     public function getCounter(): int
     {
-        return $this->getParameter('counter');
-    }
+        $value = $this->getParameter('counter');
+        Assertion::integer($value, 'Invalid "counter" parameter.');
 
-    private function updateCounter(int $counter): void
-    {
-        $this->setCounter($counter);
+        return $value;
     }
 
     public function getProvisioningUri(): string
     {
-        return $this->generateURI('hotp', ['counter' => $this->getCounter()]);
+        return $this->generateURI('hotp', [
+            'counter' => $this->getCounter(),
+        ]);
     }
 
     /**
@@ -55,7 +45,7 @@ final class HOTP extends OTP implements HOTPInterface
     {
         Assertion::greaterOrEqualThan($counter, 0, 'The counter must be at least 0.');
 
-        if (null === $counter) {
+        if ($counter === null) {
             $counter = $this->getCounter();
         } elseif ($counter < $this->getCounter()) {
             return false;
@@ -64,6 +54,33 @@ final class HOTP extends OTP implements HOTPInterface
         return $this->verifyOtpWithWindow($otp, $counter, $window);
     }
 
+    protected function setCounter(int $counter): void
+    {
+        $this->setParameter('counter', $counter);
+    }
+
+    /**
+     * @return array<string, callable>
+     */
+    protected function getParameterMap(): array
+    {
+        return array_merge(
+            parent::getParameterMap(),
+            [
+                'counter' => static function ($value): int {
+                    Assertion::greaterOrEqualThan((int) $value, 0, 'Counter must be at least 0.');
+
+                    return (int) $value;
+                },
+            ]
+        );
+    }
+
+    private function updateCounter(int $counter): void
+    {
+        $this->setCounter($counter);
+    }
+
     private function getWindow(?int $window): int
     {
         return abs($window ?? 0);
@@ -83,21 +100,4 @@ final class HOTP extends OTP implements HOTPInterface
 
         return false;
     }
-
-    /**
-     * @return array<string, mixed>
-     */
-    protected function getParameterMap(): array
-    {
-        $v = array_merge(
-            parent::getParameterMap(),
-            ['counter' => function ($value): int {
-                Assertion::greaterOrEqualThan((int) $value, 0, 'Counter must be at least 0.');
-
-                return (int) $value;
-            }]
-        );
-
-        return $v;
-    }
 }
diff --git a/src/HOTPInterface.php b/src/HOTPInterface.php
index 336ce1055878b6e3436e7a0eaf0df238bc921798..a311cb29d85c9e99aa1bfa79cb8260bca32e65b7 100644
--- a/src/HOTPInterface.php
+++ b/src/HOTPInterface.php
@@ -2,15 +2,6 @@
 
 declare(strict_types=1);
 
-/*
- * The MIT License (MIT)
- *
- * Copyright (c) 2014-2019 Spomky-Labs
- *
- * This software may be modified and distributed under the terms
- * of the MIT license.  See the LICENSE file for details.
- */
-
 namespace OTPHP;
 
 interface HOTPInterface extends OTPInterface
@@ -25,5 +16,10 @@ interface HOTPInterface extends OTPInterface
      *
      * If the secret is null, a random 64 bytes secret will be generated.
      */
-    public static function create(?string $secret = null, int $counter = 0, string $digest = 'sha1', int $digits = 6): self;
+    public static function create(
+        ?string $secret = null,
+        int $counter = 0,
+        string $digest = 'sha1',
+        int $digits = 6
+    ): self;
 }
diff --git a/src/OTP.php b/src/OTP.php
index 932bcf97e0329ec8bb9d6f730d7b201df328cb01..2ff11dedc21d85de8cb950ce7f95330c5c6b3fc2 100644
--- a/src/OTP.php
+++ b/src/OTP.php
@@ -2,22 +2,16 @@
 
 declare(strict_types=1);
 
-/*
- * The MIT License (MIT)
- *
- * Copyright (c) 2014-2019 Spomky-Labs
- *
- * This software may be modified and distributed under the terms
- * of the MIT license.  See the LICENSE file for details.
- */
-
 namespace OTPHP;
 
 use Assert\Assertion;
+use function chr;
+use function count;
+use Exception;
 use ParagonIE\ConstantTime\Base32;
 use RuntimeException;
-use function Safe\ksort;
-use function Safe\sprintf;
+use function Safe\unpack;
+use const STR_PAD_LEFT;
 
 abstract class OTP implements OTPInterface
 {
@@ -37,6 +31,11 @@ abstract class OTP implements OTPInterface
         return str_replace($placeholder, $provisioning_uri, $uri);
     }
 
+    public function at(int $timestamp): string
+    {
+        return $this->generateOTP($timestamp);
+    }
+
     /**
      * The OTP at the specified input.
      */
@@ -46,24 +45,23 @@ abstract class OTP implements OTPInterface
 
         $hmac = array_values(unpack('C*', $hash));
 
-        $offset = ($hmac[\count($hmac) - 1] & 0xF);
+        $offset = ($hmac[count($hmac) - 1] & 0xF);
         $code = ($hmac[$offset + 0] & 0x7F) << 24 | ($hmac[$offset + 1] & 0xFF) << 16 | ($hmac[$offset + 2] & 0xFF) << 8 | ($hmac[$offset + 3] & 0xFF);
         $otp = $code % (10 ** $this->getDigits());
 
         return str_pad((string) $otp, $this->getDigits(), '0', STR_PAD_LEFT);
     }
 
-    public function at(int $timestamp): string
-    {
-        return $this->generateOTP($timestamp);
-    }
-
     /**
      * @param array<string, mixed> $options
      */
     protected function filterOptions(array &$options): void
     {
-        foreach (['algorithm' => 'sha1', 'period' => 30, 'digits' => 6] as $key => $default) {
+        foreach ([
+            'algorithm' => 'sha1',
+            'period' => 30,
+            'digits' => 6,
+        ] as $key => $default) {
             if (isset($options[$key]) && $default === $options[$key]) {
                 unset($options[$key]);
             }
@@ -84,14 +82,24 @@ abstract class OTP implements OTPInterface
         $this->filterOptions($options);
         $params = str_replace(['+', '%7E'], ['%20', '~'], http_build_query($options));
 
-        return sprintf('otpauth://%s/%s?%s', $type, rawurlencode((null !== $this->getIssuer() ? $this->getIssuer().':' : '').$label), $params);
+        return sprintf(
+            'otpauth://%s/%s?%s',
+            $type,
+            rawurlencode(($this->getIssuer() !== null ? $this->getIssuer() . ':' : '') . $label),
+            $params
+        );
+    }
+
+    protected function compareOTP(string $safe, string $user): bool
+    {
+        return hash_equals($safe, $user);
     }
 
     private function getDecodedSecret(): string
     {
         try {
             return Base32::decodeUpper($this->getSecret());
-        } catch (\Exception $e) {
+        } catch (Exception) {
             throw new RuntimeException('Unable to decode the secret. Is it correctly base32 encoded?');
         }
     }
@@ -99,16 +107,11 @@ abstract class OTP implements OTPInterface
     private function intToByteString(int $int): string
     {
         $result = [];
-        while (0 !== $int) {
-            $result[] = \chr($int & 0xFF);
+        while ($int !== 0) {
+            $result[] = chr($int & 0xFF);
             $int >>= 8;
         }
 
-        return str_pad(implode(array_reverse($result)), 8, "\000", STR_PAD_LEFT);
-    }
-
-    protected function compareOTP(string $safe, string $user): bool
-    {
-        return hash_equals($safe, $user);
+        return str_pad(implode('', array_reverse($result)), 8, "\000", STR_PAD_LEFT);
     }
 }
diff --git a/src/OTPInterface.php b/src/OTPInterface.php
index 66e163d5d78a36dc3470ff0f355323998972048c..6c28a3f7549bfddf8cf79c4215a8bc1681df32c3 100644
--- a/src/OTPInterface.php
+++ b/src/OTPInterface.php
@@ -2,15 +2,6 @@
 
 declare(strict_types=1);
 
-/*
- * The MIT License (MIT)
- *
- * Copyright (c) 2014-2019 Spomky-Labs
- *
- * This software may be modified and distributed under the terms
- * of the MIT license.  See the LICENSE file for details.
- */
-
 namespace OTPHP;
 
 interface OTPInterface
@@ -21,8 +12,8 @@ interface OTPInterface
     public function at(int $timestamp): string;
 
     /**
-     * Verify that the OTP is valid with the specified input.
-     * If no input is provided, the input is set to a default value or false is returned.
+     * Verify that the OTP is valid with the specified input. If no input is provided, the input is set to a default
+     * value or false is returned.
      */
     public function verify(string $otp, ?int $input = null, ?int $window = null): bool;
 
diff --git a/src/ParameterTrait.php b/src/ParameterTrait.php
index 69fa774db7c3c22d25875b07841819966a79f6f7..74c43d11b2571b65e35e4779572630e427da0688 100644
--- a/src/ParameterTrait.php
+++ b/src/ParameterTrait.php
@@ -2,43 +2,25 @@
 
 declare(strict_types=1);
 
-/*
- * The MIT License (MIT)
- *
- * Copyright (c) 2014-2019 Spomky-Labs
- *
- * This software may be modified and distributed under the terms
- * of the MIT license.  See the LICENSE file for details.
- */
-
 namespace OTPHP;
 
+use function array_key_exists;
 use Assert\Assertion;
 use InvalidArgumentException;
 use ParagonIE\ConstantTime\Base32;
-use function Safe\sprintf;
 
 trait ParameterTrait
 {
     /**
      * @var array<string, mixed>
      */
-    private $parameters = [];
+    private array $parameters = [];
 
-    /**
-     * @var string|null
-     */
-    private $issuer;
+    private null|string $issuer = null;
 
-    /**
-     * @var string|null
-     */
-    private $label;
+    private null|string $label = null;
 
-    /**
-     * @var bool
-     */
-    private $issuer_included_as_parameter = true;
+    private bool $issuer_included_as_parameter = true;
 
     /**
      * @return array<string, mixed>
@@ -47,7 +29,7 @@ trait ParameterTrait
     {
         $parameters = $this->parameters;
 
-        if (null !== $this->getIssuer() && true === $this->isIssuerIncludedAsParameter()) {
+        if ($this->getIssuer() !== null && $this->isIssuerIncludedAsParameter() === true) {
             $parameters['issuer'] = $this->getIssuer();
         }
 
@@ -56,12 +38,10 @@ trait ParameterTrait
 
     public function getSecret(): string
     {
-        return $this->getParameter('secret');
-    }
+        $value = $this->getParameter('secret');
+        Assertion::string($value, 'Invalid "secret" parameter.');
 
-    private function setSecret(?string $secret): void
-    {
-        $this->setParameter('secret', $secret);
+        return $value;
     }
 
     public function getLabel(): ?string
@@ -96,27 +76,23 @@ trait ParameterTrait
 
     public function getDigits(): int
     {
-        return $this->getParameter('digits');
-    }
+        $value = $this->getParameter('digits');
+        Assertion::integer($value, 'Invalid "digits" parameter.');
 
-    private function setDigits(int $digits): void
-    {
-        $this->setParameter('digits', $digits);
+        return $value;
     }
 
     public function getDigest(): string
     {
-        return $this->getParameter('algorithm');
-    }
+        $value = $this->getParameter('algorithm');
+        Assertion::string($value, 'Invalid "algorithm" parameter.');
 
-    private function setDigest(string $digest): void
-    {
-        $this->setParameter('algorithm', $digest);
+        return $value;
     }
 
     public function hasParameter(string $parameter): bool
     {
-        return \array_key_exists($parameter, $this->parameters);
+        return array_key_exists($parameter, $this->parameters);
     }
 
     public function getParameter(string $parameter)
@@ -132,20 +108,20 @@ trait ParameterTrait
     {
         $map = $this->getParameterMap();
 
-        if (true === \array_key_exists($parameter, $map)) {
+        if (array_key_exists($parameter, $map) === true) {
             $callback = $map[$parameter];
             $value = $callback($value);
         }
 
         if (property_exists($this, $parameter)) {
-            $this->$parameter = $value;
+            $this->{$parameter} = $value;
         } else {
             $this->parameters[$parameter] = $value;
         }
     }
 
     /**
-     * @return array<string, mixed>
+     * @return array<string, callable>
      */
     protected function getParameterMap(): array
     {
@@ -155,21 +131,20 @@ trait ParameterTrait
 
                 return $value;
             },
-            'secret' => function ($value): string {
-                if (null === $value) {
+            'secret' => static function ($value): string {
+                if ($value === null) {
                     $value = Base32::encodeUpper(random_bytes(64));
                 }
-                $value = trim(mb_strtoupper($value), '=');
 
-                return $value;
+                return mb_strtoupper(trim($value, '='));
             },
-            'algorithm' => function ($value): string {
+            'algorithm' => static function ($value): string {
                 $value = mb_strtolower($value);
                 Assertion::inArray($value, hash_algos(), sprintf('The "%s" digest is not supported.', $value));
 
                 return $value;
             },
-            'digits' => function ($value): int {
+            'digits' => static function ($value): int {
                 Assertion::greaterThan($value, 0, 'Digits must be at least 1.');
 
                 return (int) $value;
@@ -182,11 +157,26 @@ trait ParameterTrait
         ];
     }
 
+    private function setSecret(?string $secret): void
+    {
+        $this->setParameter('secret', $secret);
+    }
+
+    private function setDigits(int $digits): void
+    {
+        $this->setParameter('digits', $digits);
+    }
+
+    private function setDigest(string $digest): void
+    {
+        $this->setParameter('algorithm', $digest);
+    }
+
     private function hasColon(string $value): bool
     {
         $colons = [':', '%3A', '%3a'];
         foreach ($colons as $colon) {
-            if (false !== mb_strpos($value, $colon)) {
+            if (str_contains($value, $colon)) {
                 return true;
             }
         }
diff --git a/src/TOTP.php b/src/TOTP.php
index 588b37f17cb450a7f70d46d461519b12415a0b20..75805094e7fad2761194cd729105014ba1f0d972 100644
--- a/src/TOTP.php
+++ b/src/TOTP.php
@@ -2,19 +2,9 @@
 
 declare(strict_types=1);
 
-/*
- * The MIT License (MIT)
- *
- * Copyright (c) 2014-2019 Spomky-Labs
- *
- * This software may be modified and distributed under the terms
- * of the MIT license.  See the LICENSE file for details.
- */
-
 namespace OTPHP;
 
 use Assert\Assertion;
-use function Safe\ksort;
 
 final class TOTP extends OTP implements TOTPInterface
 {
@@ -25,29 +15,30 @@ final class TOTP extends OTP implements TOTPInterface
         $this->setEpoch($epoch);
     }
 
-    public static function create(?string $secret = null, int $period = 30, string $digest = 'sha1', int $digits = 6, int $epoch = 0): TOTPInterface
-    {
+    public static function create(
+        ?string $secret = null,
+        int $period = 30,
+        string $digest = 'sha1',
+        int $digits = 6,
+        int $epoch = 0
+    ): TOTPInterface {
         return new self($secret, $period, $digest, $digits, $epoch);
     }
 
-    protected function setPeriod(int $period): void
-    {
-        $this->setParameter('period', $period);
-    }
-
     public function getPeriod(): int
     {
-        return $this->getParameter('period');
-    }
+        $value = $this->getParameter('period');
+        Assertion::integer($value, 'Invalid "epoch" period.');
 
-    private function setEpoch(int $epoch): void
-    {
-        $this->setParameter('epoch', $epoch);
+        return $value;
     }
 
     public function getEpoch(): int
     {
-        return $this->getParameter('epoch');
+        $value = $this->getParameter('epoch');
+        Assertion::integer($value, 'Invalid "epoch" parameter.');
+
+        return $value;
     }
 
     public function at(int $timestamp): string
@@ -67,80 +58,52 @@ final class TOTP extends OTP implements TOTPInterface
     {
         $timestamp = $this->getTimestamp($timestamp);
 
-        if (null === $window) {
+        if ($window === null) {
             return $this->compareOTP($this->at($timestamp), $otp);
         }
 
         return $this->verifyOtpWithWindow($otp, $timestamp, $window);
     }
 
-    private function verifyOtpWithWindow(string $otp, int $timestamp, int $window): bool
-    {
-        $window = abs($window);
-
-        for ($i = 0; $i <= $window; ++$i) {
-            $next = $i * $this->getPeriod() + $timestamp;
-            $previous = -$i * $this->getPeriod() + $timestamp;
-            $valid = $this->compareOTP($this->at($next), $otp) ||
-                $this->compareOTP($this->at($previous), $otp);
-
-            if ($valid) {
-                return true;
-            }
-        }
-
-        return false;
-    }
-
-    private function getTimestamp(?int $timestamp): int
-    {
-        $timestamp = $timestamp ?? time();
-        Assertion::greaterOrEqualThan($timestamp, 0, 'Timestamp must be at least 0.');
-
-        return $timestamp;
-    }
-
     public function getProvisioningUri(): string
     {
         $params = [];
-        if (30 !== $this->getPeriod()) {
+        if ($this->getPeriod() !== 30) {
             $params['period'] = $this->getPeriod();
         }
 
-        if (0 !== $this->getEpoch()) {
+        if ($this->getEpoch() !== 0) {
             $params['epoch'] = $this->getEpoch();
         }
 
         return $this->generateURI('totp', $params);
     }
 
-    private function timecode(int $timestamp): int
+    protected function setPeriod(int $period): void
     {
-        return (int) floor(($timestamp - $this->getEpoch()) / $this->getPeriod());
+        $this->setParameter('period', $period);
     }
 
     /**
-     * @return array<string, mixed>
+     * @return array<string, callable>
      */
     protected function getParameterMap(): array
     {
-        $v = array_merge(
+        return array_merge(
             parent::getParameterMap(),
             [
-                'period' => function ($value): int {
+                'period' => static function ($value): int {
                     Assertion::greaterThan((int) $value, 0, 'Period must be at least 1.');
 
                     return (int) $value;
                 },
-                'epoch' => function ($value): int {
+                'epoch' => static function ($value): int {
                     Assertion::greaterOrEqualThan((int) $value, 0, 'Epoch must be greater than or equal to 0.');
 
                     return (int) $value;
                 },
             ]
         );
-
-        return $v;
     }
 
     /**
@@ -150,10 +113,46 @@ final class TOTP extends OTP implements TOTPInterface
     {
         parent::filterOptions($options);
 
-        if (isset($options['epoch']) && 0 === $options['epoch']) {
+        if (isset($options['epoch']) && $options['epoch'] === 0) {
             unset($options['epoch']);
         }
 
         ksort($options);
     }
+
+    private function setEpoch(int $epoch): void
+    {
+        $this->setParameter('epoch', $epoch);
+    }
+
+    private function verifyOtpWithWindow(string $otp, int $timestamp, int $window): bool
+    {
+        $window = abs($window);
+
+        for ($i = 0; $i <= $window; ++$i) {
+            $next = $i * $this->getPeriod() + $timestamp;
+            $previous = -$i * $this->getPeriod() + $timestamp;
+            $valid = $this->compareOTP($this->at($next), $otp) ||
+                $this->compareOTP($this->at($previous), $otp);
+
+            if ($valid) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    private function getTimestamp(?int $timestamp): int
+    {
+        $timestamp = $timestamp ?? time();
+        Assertion::greaterOrEqualThan($timestamp, 0, 'Timestamp must be at least 0.');
+
+        return $timestamp;
+    }
+
+    private function timecode(int $timestamp): int
+    {
+        return (int) floor(($timestamp - $this->getEpoch()) / $this->getPeriod());
+    }
 }
diff --git a/src/TOTPInterface.php b/src/TOTPInterface.php
index a19fe7c0b82b4fee5645f31381ad1756cca6cb78..d79fd55991f818b1254b154862a49730e8b6052f 100644
--- a/src/TOTPInterface.php
+++ b/src/TOTPInterface.php
@@ -2,15 +2,6 @@
 
 declare(strict_types=1);
 
-/*
- * The MIT License (MIT)
- *
- * Copyright (c) 2014-2019 Spomky-Labs
- *
- * This software may be modified and distributed under the terms
- * of the MIT license.  See the LICENSE file for details.
- */
-
 namespace OTPHP;
 
 interface TOTPInterface extends OTPInterface
@@ -20,7 +11,12 @@ interface TOTPInterface extends OTPInterface
      *
      * If the secret is null, a random 64 bytes secret will be generated.
      */
-    public static function create(?string $secret = null, int $period = 30, string $digest = 'sha1', int $digits = 6): self;
+    public static function create(
+        ?string $secret = null,
+        int $period = 30,
+        string $digest = 'sha1',
+        int $digits = 6
+    ): self;
 
     /**
      * Return the TOTP at the current time.
diff --git a/src/Url.php b/src/Url.php
new file mode 100644
index 0000000000000000000000000000000000000000..3fe7a8da6295804e990f78d53517061a53ffa6d6
--- /dev/null
+++ b/src/Url.php
@@ -0,0 +1,74 @@
+<?php
+
+declare(strict_types=1);
+
+namespace OTPHP;
+
+use Assert\Assertion;
+use function Safe\parse_url;
+
+/**
+ * @internal
+ */
+final class Url
+{
+    public function __construct(
+        private string $scheme,
+        private string $host,
+        private string $path,
+        private string $secret,
+        /** @var array<string, mixed> $query */
+        private array $query
+    ) {
+    }
+
+    public function getScheme(): string
+    {
+        return $this->scheme;
+    }
+
+    public function getHost(): string
+    {
+        return $this->host;
+    }
+
+    public function getPath(): string
+    {
+        return $this->path;
+    }
+
+    public function getSecret(): string
+    {
+        return $this->secret;
+    }
+
+    /**
+     * @return array<string, mixed>
+     */
+    public function getQuery(): array
+    {
+        return $this->query;
+    }
+
+    public static function fromString(string $uri): self
+    {
+        $parsed_url = parse_url($uri);
+        Assertion::isArray($parsed_url, 'Not a valid OTP provisioning URI');
+        foreach (['scheme', 'host', 'path', 'query'] as $key) {
+            Assertion::keyExists($parsed_url, $key, 'Not a valid OTP provisioning URI');
+            Assertion::string($parsed_url[$key], 'Not a valid OTP provisioning URI');
+        }
+        $scheme = $parsed_url['scheme'];
+        Assertion::eq('otpauth', $scheme, 'Not a valid OTP provisioning URI');
+        $host = $parsed_url['host'];
+        $path = $parsed_url['path'];
+        $query = $parsed_url['query'];
+        $parsedQuery = [];
+        parse_str($query, $parsedQuery);
+        Assertion::keyExists($parsedQuery, 'secret', 'Not a valid OTP provisioning URI');
+        $secret = $parsedQuery['secret'];
+        unset($parsedQuery['secret']);
+
+        return new self($scheme, $host, $path, $secret, $parsedQuery);
+    }
+}
diff --git a/tests/FactoryTest.php b/tests/FactoryTest.php
index bde87d656348e837bf3704710aeaa22c7d513a42..dea4e03f4e03543ca0d3f928ceb00981e68268fe 100644
--- a/tests/FactoryTest.php
+++ b/tests/FactoryTest.php
@@ -2,15 +2,6 @@
 
 declare(strict_types=1);
 
-/*
- * The MIT License (MIT)
- *
- * Copyright (c) 2014-2019 Spomky-Labs
- *
- * This software may be modified and distributed under the terms
- * of the MIT license.  See the LICENSE file for details.
- */
-
 namespace OTPHP\Test;
 
 use InvalidArgumentException;
@@ -19,6 +10,9 @@ use OTPHP\HOTP;
 use OTPHP\TOTP;
 use PHPUnit\Framework\TestCase;
 
+/**
+ * @internal
+ */
 final class FactoryTest extends TestCase
 {
     /**
@@ -30,16 +24,16 @@ final class FactoryTest extends TestCase
         $result = Factory::loadFromProvisioningUri($otp);
 
         static::assertInstanceOf(TOTP::class, $result);
-        static::assertEquals('My Project', $result->getIssuer());
-        static::assertEquals('alice@foo.bar', $result->getLabel());
-        static::assertEquals('sha512', $result->getDigest());
-        static::assertEquals(8, $result->getDigits());
-        static::assertEquals(20, $result->getPeriod());
-        static::assertEquals('bar.baz', $result->getParameter('foo'));
-        static::assertEquals('JDDK4U6G3BJLEZ7Y', $result->getSecret());
+        static::assertSame('My Project', $result->getIssuer());
+        static::assertSame('alice@foo.bar', $result->getLabel());
+        static::assertSame('sha512', $result->getDigest());
+        static::assertSame(8, $result->getDigits());
+        static::assertSame(20, $result->getPeriod());
+        static::assertSame('bar.baz', $result->getParameter('foo'));
+        static::assertSame('JDDK4U6G3BJLEZ7Y', $result->getSecret());
         static::assertFalse($result->hasParameter('image'));
         static::assertTrue($result->isIssuerIncludedAsParameter());
-        static::assertEquals($otp, $result->getProvisioningUri());
+        static::assertSame($otp, $result->getProvisioningUri());
     }
 
     /**
@@ -64,15 +58,15 @@ final class FactoryTest extends TestCase
         $result = Factory::loadFromProvisioningUri($otp);
 
         static::assertInstanceOf(HOTP::class, $result);
-        static::assertEquals('My Project', $result->getIssuer());
-        static::assertEquals('alice@foo.bar', $result->getLabel());
-        static::assertEquals('sha1', $result->getDigest());
-        static::assertEquals(8, $result->getDigits());
-        static::assertEquals(1000, $result->getCounter());
-        static::assertEquals('JDDK4U6G3BJLEZ7Y', $result->getSecret());
-        static::assertEquals('https://foo.bar/baz', $result->getParameter('image'));
+        static::assertSame('My Project', $result->getIssuer());
+        static::assertSame('alice@foo.bar', $result->getLabel());
+        static::assertSame('sha1', $result->getDigest());
+        static::assertSame(8, $result->getDigits());
+        static::assertSame(1000, $result->getCounter());
+        static::assertSame('JDDK4U6G3BJLEZ7Y', $result->getSecret());
+        static::assertSame('https://foo.bar/baz', $result->getParameter('image'));
         static::assertTrue($result->isIssuerIncludedAsParameter());
-        static::assertEquals($otp, $result->getProvisioningUri());
+        static::assertSame($otp, $result->getProvisioningUri());
     }
 
     /**
@@ -151,13 +145,13 @@ final class FactoryTest extends TestCase
 
         static::assertInstanceOf(TOTP::class, $result);
         static::assertNull($result->getIssuer());
-        static::assertEquals('My Test - Auth', $result->getLabel());
-        static::assertEquals('sha1', $result->getDigest());
-        static::assertEquals(6, $result->getDigits());
-        static::assertEquals(30, $result->getPeriod());
-        static::assertEquals('JDDK4U6G3BJLEZ7Y', $result->getSecret());
+        static::assertSame('My Test - Auth', $result->getLabel());
+        static::assertSame('sha1', $result->getDigest());
+        static::assertSame(6, $result->getDigits());
+        static::assertSame(30, $result->getPeriod());
+        static::assertSame('JDDK4U6G3BJLEZ7Y', $result->getSecret());
         static::assertFalse($result->isIssuerIncludedAsParameter());
-        static::assertEquals($otp, $result->getProvisioningUri());
+        static::assertSame($otp, $result->getProvisioningUri());
     }
 
     /**
@@ -169,7 +163,7 @@ final class FactoryTest extends TestCase
         $totp = Factory::loadFromProvisioningUri($uri);
 
         static::assertInstanceOf(TOTP::class, $totp);
-        static::assertEquals('JDDK4U6G3BJLEQ', $totp->getSecret());
-        static::assertEquals('otpauth://totp/My%20Test%20-%20Auth?secret=JDDK4U6G3BJLEQ', $totp->getProvisioningUri());
+        static::assertSame('JDDK4U6G3BJLEQ', $totp->getSecret());
+        static::assertSame('otpauth://totp/My%20Test%20-%20Auth?secret=JDDK4U6G3BJLEQ', $totp->getProvisioningUri());
     }
 }
diff --git a/tests/HOTPTest.php b/tests/HOTPTest.php
index e8da1c1f3b97395c3fb4c62f71b18c198fff1244..4026de20cfc175577d5878531260fc8c9e9294d3 100644
--- a/tests/HOTPTest.php
+++ b/tests/HOTPTest.php
@@ -2,22 +2,17 @@
 
 declare(strict_types=1);
 
-/*
- * The MIT License (MIT)
- *
- * Copyright (c) 2014-2019 Spomky-Labs
- *
- * This software may be modified and distributed under the terms
- * of the MIT license.  See the LICENSE file for details.
- */
-
 namespace OTPHP\Test;
 
 use Assert\Assertion;
 use InvalidArgumentException;
 use OTPHP\HOTP;
 use PHPUnit\Framework\TestCase;
+use RuntimeException;
 
+/**
+ * @internal
+ */
 final class HOTPTest extends TestCase
 {
     /**
@@ -109,13 +104,14 @@ final class HOTPTest extends TestCase
         HOTP::create('JDDK4U6G3BJLEZ7Y', 0, 'foo');
     }
 
-    /**xpectedExceptionMessage
+    /**
+     * xpectedExceptionMessage.
      *
      * @test
      */
     public function secretShouldBeBase32Encoded(): void
     {
-        $this->expectException(InvalidArgumentException::class);
+        $this->expectException(RuntimeException::class);
         $this->expectExceptionMessage('Unable to decode the secret. Is it correctly base32 encoded?');
         $secret = random_bytes(32);
 
@@ -130,7 +126,7 @@ final class HOTPTest extends TestCase
     {
         $otp = HOTP::create();
 
-        static::assertRegExp('/^[A-Z2-7]+$/', $otp->getSecret());
+        static::assertMatchesRegularExpression('/^[A-Z2-7]+$/', $otp->getSecret());
     }
 
     /**
@@ -141,7 +137,10 @@ final class HOTPTest extends TestCase
         $otp = $this->createHOTP(8, 'sha1', 1000);
         $otp->setParameter('image', 'https://foo.bar/baz');
 
-        static::assertEquals('otpauth://hotp/My%20Project%3Aalice%40foo.bar?counter=1000&digits=8&image=https%3A%2F%2Ffoo.bar%2Fbaz&issuer=My%20Project&secret=JDDK4U6G3BJLEZ7Y', $otp->getProvisioningUri());
+        static::assertSame(
+            'otpauth://hotp/My%20Project%3Aalice%40foo.bar?counter=1000&digits=8&image=https%3A%2F%2Ffoo.bar%2Fbaz&issuer=My%20Project&secret=JDDK4U6G3BJLEZ7Y',
+            $otp->getProvisioningUri()
+        );
     }
 
     /**
@@ -163,7 +162,7 @@ final class HOTPTest extends TestCase
 
         static::assertTrue($otp->verify('98449994'));
         static::assertFalse($otp->verify('11111111', 1099));
-        static::assertEquals($otp->getCounter(), 1101);
+        static::assertSame($otp->getCounter(), 1101);
     }
 
     /**
@@ -178,8 +177,14 @@ final class HOTPTest extends TestCase
         static::assertFalse($otp->verify('59647237', 2000, 50));
     }
 
-    private function createHOTP(int $digits, string $digest, int $counter, string $secret = 'JDDK4U6G3BJLEZ7Y', string $label = 'alice@foo.bar', string $issuer = 'My Project'): HOTP
-    {
+    private function createHOTP(
+        int $digits,
+        string $digest,
+        int $counter,
+        string $secret = 'JDDK4U6G3BJLEZ7Y',
+        string $label = 'alice@foo.bar',
+        string $issuer = 'My Project'
+    ): HOTP {
         $otp = HOTP::create($secret, $counter, $digest, $digits);
         $otp->setLabel($label);
         $otp->setIssuer($issuer);
diff --git a/tests/TOTPTest.php b/tests/TOTPTest.php
index ce955ebd51d2626cf2cd5331f522fc454a821ced..59cf1160aff6002b6ec0145c903fd5172f78a534 100644
--- a/tests/TOTPTest.php
+++ b/tests/TOTPTest.php
@@ -2,23 +2,19 @@
 
 declare(strict_types=1);
 
-/*
- * The MIT License (MIT)
- *
- * Copyright (c) 2014-2019 Spomky-Labs
- *
- * This software may be modified and distributed under the terms
- * of the MIT license.  See the LICENSE file for details.
- */
-
 namespace OTPHP\Test;
 
 use Assert\Assertion;
 use InvalidArgumentException;
 use OTPHP\TOTP;
+use OTPHP\TOTPInterface;
 use ParagonIE\ConstantTime\Base32;
 use PHPUnit\Framework\TestCase;
+use RuntimeException;
 
+/**
+ * @internal
+ */
 final class TOTPTest extends TestCase
 {
     /**
@@ -43,7 +39,10 @@ final class TOTPTest extends TestCase
         $otp->setIssuer('My Project');
         $otp->setParameter('foo', 'bar.baz');
 
-        static::assertEquals('otpauth://totp/My%20Project%3Aalice%40foo.bar?algorithm=sha512&digits=8&epoch=100&foo=bar.baz&issuer=My%20Project&period=20&secret=JDDK4U6G3BJLEZ7Y', $otp->getProvisioningUri());
+        static::assertSame(
+            'otpauth://totp/My%20Project%3Aalice%40foo.bar?algorithm=sha512&digits=8&epoch=100&foo=bar.baz&issuer=My%20Project&period=20&secret=JDDK4U6G3BJLEZ7Y',
+            $otp->getProvisioningUri()
+        );
     }
 
     /**
@@ -53,7 +52,7 @@ final class TOTPTest extends TestCase
     {
         $otp = TOTP::create();
 
-        static::assertRegExp('/^[A-Z2-7]+$/', $otp->getSecret());
+        static::assertMatchesRegularExpression('/^[A-Z2-7]+$/', $otp->getSecret());
     }
 
     /**
@@ -81,7 +80,7 @@ final class TOTPTest extends TestCase
      */
     public function secretShouldBeBase32Encoded(): void
     {
-        $this->expectException(\RuntimeException::class);
+        $this->expectException(RuntimeException::class);
         $this->expectExceptionMessage('Unable to decode the secret. Is it correctly base32 encoded?');
         $secret = random_bytes(32);
 
@@ -96,7 +95,10 @@ final class TOTPTest extends TestCase
     {
         $otp = $this->createTOTP(6, 'sha1', 30);
 
-        static::assertEquals('otpauth://totp/My%20Project%3Aalice%40foo.bar?issuer=My%20Project&secret=JDDK4U6G3BJLEZ7Y', $otp->getProvisioningUri());
+        static::assertSame(
+            'otpauth://totp/My%20Project%3Aalice%40foo.bar?issuer=My%20Project&secret=JDDK4U6G3BJLEZ7Y',
+            $otp->getProvisioningUri()
+        );
     }
 
     /**
@@ -106,9 +108,9 @@ final class TOTPTest extends TestCase
     {
         $otp = $this->createTOTP(6, 'sha1', 30);
 
-        static::assertEquals('855783', $otp->at(0));
-        static::assertEquals('762124', $otp->at(319690800));
-        static::assertEquals('139664', $otp->at(1301012137));
+        static::assertSame('855783', $otp->at(0));
+        static::assertSame('762124', $otp->at(319690800));
+        static::assertSame('139664', $otp->at(1301012137));
     }
 
     /**
@@ -118,9 +120,9 @@ final class TOTPTest extends TestCase
     {
         $otp = $this->createTOTP(6, 'sha1', 30, 'JDDK4U6G3BJLEZ7Y', 'alice@foo.bar', 'My Project', 100);
 
-        static::assertEquals('855783', $otp->at(100));
-        static::assertEquals('762124', $otp->at(319690900));
-        static::assertEquals('139664', $otp->at(1301012237));
+        static::assertSame('855783', $otp->at(100));
+        static::assertSame('762124', $otp->at(319690900));
+        static::assertSame('139664', $otp->at(1301012237));
     }
 
     /**
@@ -144,7 +146,7 @@ final class TOTPTest extends TestCase
     {
         $otp = $this->createTOTP(6, 'sha1', 30);
 
-        static::assertEquals($otp->now(), $otp->at(time()));
+        static::assertSame($otp->now(), $otp->at(time()));
     }
 
     /**
@@ -197,21 +199,24 @@ final class TOTPTest extends TestCase
     {
         $otp = $this->createTOTP(9, 'sha512', 10);
 
-        static::assertEquals('otpauth://totp/My%20Project%3Aalice%40foo.bar?algorithm=sha512&digits=9&issuer=My%20Project&period=10&secret=JDDK4U6G3BJLEZ7Y', $otp->getProvisioningUri());
+        static::assertSame(
+            'otpauth://totp/My%20Project%3Aalice%40foo.bar?algorithm=sha512&digits=9&issuer=My%20Project&period=10&secret=JDDK4U6G3BJLEZ7Y',
+            $otp->getProvisioningUri()
+        );
     }
 
     /**
      * @dataProvider dataVectors
      *
-     * @param \OTPHP\TOTPInterface $totp
-     * @param int                  $timestamp
-     * @param string               $expected_value
+     * @param TOTPInterface $totp
+     * @param int           $timestamp
+     * @param string        $expected_value
      *
      * @test
      */
     public function vectors($totp, $timestamp, $expected_value): void
     {
-        static::assertEquals($expected_value, $totp->at($timestamp));
+        static::assertSame($expected_value, $totp->at($timestamp));
         static::assertTrue($totp->verify($expected_value, $timestamp));
     }
 
@@ -225,25 +230,30 @@ final class TOTPTest extends TestCase
     {
         $totp_sha1 = $this->createTOTP(8, 'sha1', 30, Base32::encodeUpper('12345678901234567890'));
         $totp_sha256 = $this->createTOTP(8, 'sha256', 30, Base32::encodeUpper('12345678901234567890123456789012'));
-        $totp_sha512 = $this->createTOTP(8, 'sha512', 30, Base32::encodeUpper('1234567890123456789012345678901234567890123456789012345678901234'));
+        $totp_sha512 = $this->createTOTP(
+            8,
+            'sha512',
+            30,
+            Base32::encodeUpper('1234567890123456789012345678901234567890123456789012345678901234')
+        );
 
         return [
-            [$totp_sha1,   59, '94287082'],
+            [$totp_sha1, 59, '94287082'],
             [$totp_sha256, 59, '46119246'],
             [$totp_sha512, 59, '90693936'],
-            [$totp_sha1,   1111111109, '07081804'],
+            [$totp_sha1, 1111111109, '07081804'],
             [$totp_sha256, 1111111109, '68084774'],
             [$totp_sha512, 1111111109, '25091201'],
-            [$totp_sha1,   1111111111, '14050471'],
+            [$totp_sha1, 1111111111, '14050471'],
             [$totp_sha256, 1111111111, '67062674'],
             [$totp_sha512, 1111111111, '99943326'],
-            [$totp_sha1,   1234567890, '89005924'],
+            [$totp_sha1, 1234567890, '89005924'],
             [$totp_sha256, 1234567890, '91819424'],
             [$totp_sha512, 1234567890, '93441116'],
-            [$totp_sha1,   2000000000, '69279037'],
+            [$totp_sha1, 2000000000, '69279037'],
             [$totp_sha256, 2000000000, '90698825'],
             [$totp_sha512, 2000000000, '38618901'],
-            [$totp_sha1,   20000000000, '65353130'],
+            [$totp_sha1, 20000000000, '65353130'],
             [$totp_sha256, 20000000000, '77737706'],
             [$totp_sha512, 20000000000, '47863826'],
         ];
@@ -288,12 +298,31 @@ final class TOTPTest extends TestCase
     {
         $otp = $this->createTOTP(6, 'sha1', 30, 'DJBSWY3DPEHPK3PXP', 'alice@google.com', 'My Big Compagny');
 
-        static::assertEquals('http://chart.apis.google.com/chart?cht=qr&chs=250x250&chl=otpauth%3A%2F%2Ftotp%2FMy%2520Big%2520Compagny%253Aalice%2540google.com%3Fissuer%3DMy%2520Big%2520Compagny%26secret%3DDJBSWY3DPEHPK3PXP', $otp->getQrCodeUri('http://chart.apis.google.com/chart?cht=qr&chs=250x250&chl={PROVISIONING_URI}', '{PROVISIONING_URI}'));
-        static::assertEquals('http://api.qrserver.com/v1/create-qr-code/?color=5330FF&bgcolor=70FF7E&data=otpauth%3A%2F%2Ftotp%2FMy%2520Big%2520Compagny%253Aalice%2540google.com%3Fissuer%3DMy%2520Big%2520Compagny%26secret%3DDJBSWY3DPEHPK3PXP&qzone=2&margin=0&size=300x300&ecc=H', $otp->getQrCodeUri('http://api.qrserver.com/v1/create-qr-code/?color=5330FF&bgcolor=70FF7E&data=[DATA HERE]&qzone=2&margin=0&size=300x300&ecc=H', '[DATA HERE]'));
+        static::assertSame(
+            'http://chart.apis.google.com/chart?cht=qr&chs=250x250&chl=otpauth%3A%2F%2Ftotp%2FMy%2520Big%2520Compagny%253Aalice%2540google.com%3Fissuer%3DMy%2520Big%2520Compagny%26secret%3DDJBSWY3DPEHPK3PXP',
+            $otp->getQrCodeUri(
+                'http://chart.apis.google.com/chart?cht=qr&chs=250x250&chl={PROVISIONING_URI}',
+                '{PROVISIONING_URI}'
+            )
+        );
+        static::assertSame(
+            'http://api.qrserver.com/v1/create-qr-code/?color=5330FF&bgcolor=70FF7E&data=otpauth%3A%2F%2Ftotp%2FMy%2520Big%2520Compagny%253Aalice%2540google.com%3Fissuer%3DMy%2520Big%2520Compagny%26secret%3DDJBSWY3DPEHPK3PXP&qzone=2&margin=0&size=300x300&ecc=H',
+            $otp->getQrCodeUri(
+                'http://api.qrserver.com/v1/create-qr-code/?color=5330FF&bgcolor=70FF7E&data=[DATA HERE]&qzone=2&margin=0&size=300x300&ecc=H',
+                '[DATA HERE]'
+            )
+        );
     }
 
-    private function createTOTP(int $digits, string $digest, int $period, string $secret = 'JDDK4U6G3BJLEZ7Y', string $label = 'alice@foo.bar', string $issuer = 'My Project', int $epoch = 0): TOTP
-    {
+    private function createTOTP(
+        int $digits,
+        string $digest,
+        int $period,
+        string $secret = 'JDDK4U6G3BJLEZ7Y',
+        string $label = 'alice@foo.bar',
+        string $issuer = 'My Project',
+        int $epoch = 0
+    ): TOTP {
         $otp = TOTP::create($secret, $period, $digest, $digits, $epoch);
         $otp->setLabel($label);
         $otp->setIssuer($issuer);