diff --git a/.codeclimate.yml b/.codeclimate.yml
deleted file mode 100644
index e2a9c4b..0000000
--- a/.codeclimate.yml
+++ /dev/null
@@ -1,42 +0,0 @@
-version: "2"
-plugins:
- git-legal:
- enabled: true
- fixme:
- enabled: true
- sonar-php:
- enabled: true
- config:
- tests_patterns:
- - src/test/**
- - app/src/test/**
- minimum_severity: major
-checks:
- argument-count:
- config:
- threshold: 5
- complex-logic:
- config:
- threshold: 10
- file-lines:
- config:
- threshold: 250
- method-complexity:
- config:
- threshold: 5
- method-count:
- config:
- threshold: 20
- method-lines:
- config:
- threshold: 25
- nested-control-flow:
- config:
- threshold: 4
- return-statements:
- config:
- threshold: 4
- similar-code:
- enabled: false
- identical-code:
- enabled: false
diff --git a/.editorconfig b/.editorconfig
index 90845fa..8b4a43d 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -6,11 +6,20 @@ root = true
# Unix-style newlines with a newline ending every file
[*]
indent_style = space
-indent_size = 2
+indent_size = 4
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
charset = utf-8
[*.md]
-indent_size = 4
+indent_size = 1
+
+[*.php]
+indent_size = 1
+
+[*.toml]
+indent_size = 2
+
+[*.{yml,yaml,json,neon}]
+indent_size = 2
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index e7aa8c8..50b4ccf 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -1,14 +1,15 @@
+---
version: 2
updates:
-- package-ecosystem: composer
- directory: "/"
- schedule:
- interval: daily
- time: "04:00"
- open-pull-requests-limit: 10
- reviewers:
- - otherguy
- assignees:
- - otherguy
- labels:
- - "dependabot \U0001F916"
+ - package-ecosystem: composer
+ directory: /
+ schedule:
+ interval: weekly
+ open-pull-requests-limit: 10
+ labels: [dependencies]
+ - package-ecosystem: github-actions
+ directory: /
+ schedule:
+ interval: weekly
+ open-pull-requests-limit: 5
+ labels: [dependencies, github-actions]
diff --git a/.github/reaction.yml b/.github/reaction.yml
deleted file mode 100644
index 1aa0c1c..0000000
--- a/.github/reaction.yml
+++ /dev/null
@@ -1,24 +0,0 @@
-# Configuration for reaction-comments - https://github.com/dessant/reaction-comments
-
-# Issues and pull requests with these labels accept reaction comments.
-# Set to `[]` to disable
-exemptLabels: []
-
-# Replace matching comments with this message, `{comment-author}` is an
-# optional placeholder. Set to `false` to disable
-reactionComment: >
- :wave: @{comment-author}, did you mean to use
- a [reaction](https://git.io/vhzhC) instead?
-# Limit to only `issues` or `pulls`
-# only: issues
-
-# Optionally, specify configuration settings just for `issues` or `pulls`
-# issues:
-# exemptLabels:
-# - party-parrot
-
-# pulls:
-# reactionComment: false
-
-# Repository to extend settings from
-# _extends: repo
diff --git a/.github/stale.yml b/.github/stale.yml
deleted file mode 100644
index bbad62e..0000000
--- a/.github/stale.yml
+++ /dev/null
@@ -1,22 +0,0 @@
-# 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:
- - WIP
- - pinned
-
-# 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
-closeComment: true
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..da4d85a
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,85 @@
+---
+name: CI
+'on':
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+permissions:
+ contents: read
+jobs:
+ lint:
+ name: Lint
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
+ with:
+ persist-credentials: false
+ - name: Markdown Lint
+ uses: DavidAnson/markdownlint-cli2-action@v23
+ with:
+ globs: |
+ **/*.md
+ config: ".markdownlint.yaml"
+
+ - name: YAML Lint
+ uses: karancode/yamllint-github-action@master
+ with:
+ yamllint_config_filepath: .yamllint.yml
+ yamllint_comment: true
+ env:
+ GITHUB_ACCESS_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ test:
+ name: PHP ${{ matrix.php }} on ${{ matrix.os }}
+ needs: lint
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest]
+ php: ['8.3', '8.4']
+ steps:
+ - name: Checkout
+ uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
+ with:
+ persist-credentials: false
+ - name: Setup PHP
+ uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f
+ with:
+ php-version: ${{ matrix.php }}
+ extensions: json, mbstring
+ coverage: ${{ matrix.php == '8.3' && 'pcov' || 'none' }}
+ tools: composer:v2
+ - name: Cache Composer dependencies
+ uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830
+ with:
+ path: ~/.composer/cache
+ key: composer-${{ matrix.php }}-${{ hashFiles('composer.json') }}
+ restore-keys: composer-${{ matrix.php }}-
+ - name: Install dependencies
+ run: composer install --prefer-dist --no-interaction --no-progress
+ - name: Lint (Pint)
+ run: vendor/bin/pint --test
+ - name: Static analysis (PHPStan)
+ run: vendor/bin/phpstan analyse --no-progress
+ - name: Rector dry-run
+ run: vendor/bin/rector --dry-run
+ - name: Run tests
+ run: |
+ mkdir -p build
+ if [ "${{ matrix.php }}" = "8.3" ]; then
+ XDEBUG_MODE=coverage vendor/bin/phpunit
+ else
+ vendor/bin/phpunit --no-coverage
+ fi
+ - name: Upload coverage to Coveralls
+ if: matrix.php == '8.3'
+ env:
+ COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }}
+ run: |-
+ if [ -n "$COVERALLS_REPO_TOKEN" ]; then
+ vendor/bin/php-coveralls --coverage_clover build/cov.xml --json_path build/coverage.json -v
+ else
+ echo "Skipping Coveralls upload — COVERALLS_REPO_TOKEN not set."
+ fi
diff --git a/.gitignore b/.gitignore
index 546aee2..114f637 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,10 +1,12 @@
### VisualStudioCode template
.vscode/*
-!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
+### Local environment
+.env
+
### Composer template
composer.phar
/vendor/
@@ -15,3 +17,8 @@ composer.phar
### PHPUnit
build
.phpunit.result.cache
+
+### Local agent/tooling scratch
+.code-review-graph/
+.docs/
+tasks/
diff --git a/.markdownlint.yaml b/.markdownlint.yaml
new file mode 100644
index 0000000..bbbfc0f
--- /dev/null
+++ b/.markdownlint.yaml
@@ -0,0 +1,161 @@
+---
+# Default state for all rules
+default: true
+# Path to configuration file to extend
+extends:
+# MD001/heading-increment : Heading levels should only increment by one level at a time
+MD001: true
+# MD003/heading-style : Heading style
+MD003:
+ style: consistent
+# MD004/ul-style : Unordered list style
+MD004:
+ style: consistent
+# MD005/list-indent : Inconsistent indentation for list items at the same level
+MD005: true
+# MD007/ul-indent : Unordered list indentation
+MD007:
+ indent: 2
+ start_indented: false
+ start_indent: 2
+# MD009/no-trailing-spaces : Trailing spaces
+MD009:
+ br_spaces: 2
+ list_item_empty_lines: false
+ strict: false
+# MD010/no-hard-tabs : Hard tabs
+MD010:
+ code_blocks: true
+ ignore_code_languages: []
+ spaces_per_tab: 1
+# MD011/no-reversed-links : Reversed link syntax
+MD011: true
+# MD012/no-multiple-blanks : Multiple consecutive blank lines
+MD012:
+ maximum: 1
+# MD013/line-length : Line length
+MD013:
+ line_length: 1000
+ heading_line_length: 120
+ code_block_line_length: 120
+ code_blocks: true
+ tables: true
+ headings: true
+ strict: false
+ stern: false
+# MD014/commands-show-output : Dollar signs used before commands without showing output
+MD014: true
+
+# MD018/no-missing-space-atx : No space after hash on atx style heading
+MD018: true
+
+# MD019/no-multiple-space-atx : Multiple spaces after hash on atx style heading
+MD019: true
+
+# MD020/no-missing-space-closed-atx : No space inside hashes on closed atx style heading
+MD020: true
+
+# MD021/no-multiple-space-closed-atx : Multiple spaces inside hashes on closed atx style heading
+MD021: true
+# MD022/blanks-around-headings : Headings should be surrounded by blank lines
+MD022:
+ lines_above: 1
+ lines_below: 1
+# MD023/heading-start-left : Headings must start at the beginning of the line
+MD023: true
+# MD024/no-duplicate-heading : Multiple headings with the same content
+MD024:
+ siblings_only: true
+# MD025/single-title/single-h1 : Multiple top-level headings in the same document
+MD025:
+ level: 1
+ front_matter_title: ^\s*title\s*[:=]
+# MD026/no-trailing-punctuation : Trailing punctuation in heading
+MD026:
+ punctuation: .,;:!。,;:!
+# MD027/no-multiple-space-blockquote : Multiple spaces after blockquote symbol
+MD027: true
+
+# MD028/no-blanks-blockquote : Blank line inside blockquote
+MD028: true
+# MD029/ol-prefix : Ordered list item prefix
+MD029:
+ style: one_or_ordered
+# MD030/list-marker-space : Spaces after list markers
+MD030:
+ ul_single: 1
+ ol_single: 1
+ ul_multi: 1
+ ol_multi: 1
+# MD031/blanks-around-fences : Fenced code blocks should be surrounded by blank lines
+MD031:
+ list_items: true
+# MD032/blanks-around-lists : Lists should be surrounded by blank lines
+MD032: true
+# MD033/no-inline-html : Inline HTML
+MD033:
+ allowed_elements: []
+# MD034/no-bare-urls : Bare URL used
+MD034: false
+# MD035/hr-style : Horizontal rule style
+MD035:
+ style: consistent
+# MD036/no-emphasis-as-heading : Emphasis used instead of a heading
+MD036:
+ punctuation: .,;:!?。,;:!?
+# MD037/no-space-in-emphasis : Spaces inside emphasis markers
+MD037: true
+
+# MD038/no-space-in-code : Spaces inside code span elements
+MD038: true
+
+# MD039/no-space-in-links : Spaces inside link text
+MD039: true
+# MD040/fenced-code-language : Fenced code blocks should have a language specified
+MD040:
+ allowed_languages: []
+ language_only: false
+# MD041/first-line-heading/first-line-h1 : First line in a file should be a top-level heading
+MD041:
+ level: 1
+ front_matter_title: ^\s*title\s*[:=]
+# MD042/no-empty-links : No empty links
+MD042: true
+# MD044/proper-names : Proper names should have the correct capitalization
+MD044:
+ names: []
+ code_blocks: true
+ html_elements: true
+# MD045/no-alt-text : Images should have alternate text (alt text)
+MD045: true
+# MD046/code-block-style : Code block style
+MD046:
+ style: consistent
+# MD047/single-trailing-newline : Files should end with a single newline character
+MD047: true
+# MD048/code-fence-style : Code fence style
+MD048:
+ style: consistent
+# MD049/emphasis-style : Emphasis style
+MD049:
+ style: consistent
+# MD050/strong-style : Strong style
+MD050:
+ style: consistent
+# MD051/link-fragments : Link fragments should be valid
+MD051: true
+# MD052/reference-links-images : Reference links and images should use a label that is defined
+MD052:
+ shortcut_syntax: false
+# MD053/link-image-reference-definitions : Link and image reference definitions should be needed
+MD053:
+ ignored_definitions:
+ - //
+# MD054/link-image-style : Link and image style
+MD054:
+ autolink: true
+ inline: true
+ full: true
+ collapsed: true
+ shortcut: true
+ url_inline: true
diff --git a/.markdownlintignore b/.markdownlintignore
new file mode 100644
index 0000000..4b88a02
--- /dev/null
+++ b/.markdownlintignore
@@ -0,0 +1,4 @@
+vendor/
+build/
+.git/
+node_modules/
diff --git a/.mergify.yml b/.mergify.yml
deleted file mode 100644
index c3bd9a9..0000000
--- a/.mergify.yml
+++ /dev/null
@@ -1,28 +0,0 @@
-pull_request_rules:
-
- # Automatically merge Dependabot Pull Requests
- - name: Automatic merge for Dependabot
- conditions:
- - author=dependabot-preview[bot]
- - status-success=Travis CI - Pull Request
- - base=master
- actions:
- merge:
- method: squash
- strict: smart
-
- # Delete branch after automatic merge
- - name: Delete branch after merge
- conditions:
- - merged
- actions:
- delete_head_branch: {}
-
- # Remove outdated reviews
- - name: Remove outdated reviews
- conditions:
- - base=master
- actions:
- dismiss_reviews:
- approved: True
- changes_requested: True
diff --git a/.qlty/.gitignore b/.qlty/.gitignore
new file mode 100644
index 0000000..3036618
--- /dev/null
+++ b/.qlty/.gitignore
@@ -0,0 +1,7 @@
+*
+!configs
+!configs/**
+!hooks
+!hooks/**
+!qlty.toml
+!.gitignore
diff --git a/.qlty/qlty.toml b/.qlty/qlty.toml
new file mode 100644
index 0000000..a953214
--- /dev/null
+++ b/.qlty/qlty.toml
@@ -0,0 +1,108 @@
+# This file was automatically generated by `qlty init`.
+# You can modify it to suit your needs.
+# We recommend you to commit this file to your repository.
+#
+# This configuration is used by both Qlty CLI and Qlty Cloud.
+#
+# Qlty CLI -- Code quality toolkit for developers
+# Qlty Cloud -- Fully automated Code Health Platform
+#
+# Try Qlty Cloud: https://qlty.sh
+#
+# For a guide to configuration, visit https://qlty.sh/d/config
+# Or for a full reference, visit https://qlty.sh/d/qlty-toml
+config_version = "0"
+
+exclude_patterns = [
+ "rector.php",
+ "*_min.*",
+ "*-min.*",
+ "*.min.*",
+ "**/.yarn/**",
+ "**/*.d.ts",
+ "**/assets/**",
+ "**/bower_components/**",
+ "**/build/**",
+ "**/cache/**",
+ "**/config/**",
+ "**/db/**",
+ "**/deps/**",
+ "**/dist/**",
+ "**/extern/**",
+ "**/external/**",
+ "**/generated/**",
+ "**/Godeps/**",
+ "**/gradlew/**",
+ "**/mvnw/**",
+ "**/node_modules/**",
+ "**/protos/**",
+ "**/seed/**",
+ "**/target/**",
+ "**/templates/**",
+ "**/testdata/**",
+ "**/vendor/**",
+]
+
+test_patterns = [
+ "**/test/**",
+ "**/spec/**",
+ "**/*.test.*",
+ "**/*.spec.*",
+ "**/*_test.*",
+ "**/*_spec.*",
+ "**/test_*.*",
+ "**/spec_*.*",
+]
+
+[smells]
+mode = "comment"
+
+[[triage]]
+match.rules = ["radarlint-php:php:S1192"]
+match.file_patterns = ["tests/**"]
+set.ignored = true
+
+[[source]]
+name = "default"
+default = true
+
+
+[[plugin]]
+name = "actionlint"
+
+[[plugin]]
+name = "editorconfig-checker"
+mode = "comment"
+
+[[plugin]]
+name = "osv-scanner"
+
+[[plugin]]
+name = "php-codesniffer"
+mode = "comment"
+
+[[plugin]]
+name = "phpstan"
+mode = "comment"
+
+# prepare_script: install composer deps so phpstan can resolve PSR-18,
+# PHPUnit attributes, and project classes during sandboxed analysis.
+# autoload_script: use the project's autoloader (with require-dev installed)
+# instead of qlty's bare phpstan tool autoloader.
+# copy_configs_into_tool_install = false: keep phpstan.neon at the project
+# root so its relative `path:` rules under ignoreErrors continue to match.
+[plugins.definitions.phpstan.drivers.lint]
+prepare_script = "~/.qlty/cache/tools/composer/generic-42cc848a9f5e/composer.phar install --no-scripts --ignore-platform-reqs --no-interaction --no-progress"
+autoload_script = "--autoload-file=vendor/autoload.php"
+copy_configs_into_tool_install = false
+
+[[plugin]]
+name = "radarlint-php"
+mode = "comment"
+
+[[plugin]]
+name = "ripgrep"
+mode = "comment"
+
+[[plugin]]
+name = "trufflehog"
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 4c5eddc..0000000
--- a/.travis.yml
+++ /dev/null
@@ -1,29 +0,0 @@
-language: php
-
-php:
- - "7.3"
- - "7.4"
- - "8.0"
-
-env:
- global:
- # This is a write-only ID that can only post test reports.
- - CC_TEST_REPORTER_ID=7d37e7331b1457f8c3a1857f8bd93a33c720f54dfd8bae66fa2a0fed468a3118
- - GIT_COMMITTED_AT=$(if [ "$TRAVIS_PULL_REQUEST" == "false" ]; then git log -1 --pretty=format:%ct; else git log -1 --skip 1 --pretty=format:%ct; fi)
- - XDEBUG_MODE=coverage
-
-install:
- - curl -s http://getcomposer.org/installer | php
- - php composer.phar install --no-interaction
- - curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter
- - chmod +x ./cc-test-reporter
- - ./cc-test-reporter before-build
-
-script:
- - mkdir -p build
- - vendor/bin/phpunit
-
-after_success:
- - travis_retry php vendor/bin/php-coveralls --coverage_clover build/cov.xml --json_path build/coverage.json
- - cp build/cov.xml clover.xml
- - if [ "$TRAVIS_PULL_REQUEST" == "false" ]; then ./cc-test-reporter after-build --coverage-input-type clover --exit-code $TRAVIS_TEST_RESULT; fi
diff --git a/.yamlfix.toml b/.yamlfix.toml
new file mode 100644
index 0000000..88b2d4e
--- /dev/null
+++ b/.yamlfix.toml
@@ -0,0 +1,19 @@
+# .yamlfix.toml
+# Configuration for YAML formatting and linting
+allow_duplicate_keys = false
+comments_min_spaces_from_content = 2
+comments_require_starting_space = true
+comments_whitelines = 1
+whitelines = 1
+section_whitelines = 1
+explicit_start = true
+indent_mapping = 2
+indent_offset = 2
+indent_sequence = 4
+line_length = 300
+none_representation = ""
+quote_basic_values = false
+quote_keys_and_basic_values = false
+preserve_quotes = true
+quote_representation = '\"'
+sequence_style = "keep_style"
diff --git a/.yamllint.yml b/.yamllint.yml
new file mode 100644
index 0000000..9aaee2f
--- /dev/null
+++ b/.yamllint.yml
@@ -0,0 +1,13 @@
+---
+extends: default
+yaml-files: ['*.yaml', '*.yml']
+rules:
+ line-length:
+ max: 300
+ level: warning
+ignore: |-
+ **/node_modules
+ **/vendor
+ .git
+ .specify
+ specs/
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..a0d5662
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,56 @@
+# 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.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [2.0.0] — 2026-04-27
+
+A full modernization. See the [README upgrade section](README.md#upgrading-from-1x) for migration steps.
+
+### Added
+
+- `Otherguy\Currency\Currency` backed enum with one case per supported ISO-4217 code, plus `displayName()` and `tryFromCode()` helpers.
+- `Frankfurter` driver — free, key-less access to `api.frankfurter.dev`.
+- `CurrencyApi` driver — latest, historical, and conversion support for `api.currencyapi.com`.
+- `FastForex` driver — latest, historical, and conversion support for `api.fastforex.io`.
+- `DriverFactory::register()` / `unregister()` / `build()` — instance-based registry for plugging in custom drivers.
+- `ConversionResult::rateAsFloat()` and `allAsFloats()` helpers for callers that need legacy float output.
+- `MockCurrencyDriver::withRates()` for seeding test rates.
+- `composer check` aggregate script (`lint` + `analyse` + `rector` + `test`).
+- GitHub Actions CI workflow (PHP 8.3 + 8.4, Pint, PHPStan, Rector, PHPUnit, Coveralls).
+- PHPStan at `level: max`, Laravel Pint, Rector, all wired into CI.
+- `tests/Support/MockHttpClient.php` — in-process PSR-18 test double.
+
+### Changed
+
+- **PHP 8.3+** required (was `>=7.3`).
+- HTTP layer is now **PSR-18 / PSR-17**. `BaseCurrencyDriver::__construct()` takes a `Psr\Http\Client\ClientInterface` and a `Psr\Http\Message\RequestFactoryInterface`.
+- `ConversionResult::rate()`, `convert()`, and `all()` now return `Brick\Math\BigDecimal` instead of `float`.
+- Driver `date()`, `historical()`, and `convert()` accept `?DateTimeInterface` instead of strings/ints/intervals.
+- Default protocol flipped to `https`.
+- `CurrencyDriverContract` no longer declares `apiRequest()`; the base implementation is now `protected`.
+- `ExchangeRatesApi` driver rewritten to target `api.apilayer.com/exchangerates_data` (the original endpoint was discontinued in 2021). `accessKey()` and `convert()` are now functional.
+- `ConversionResult` properties `originalBaseCurrency` and `originalConversionRates` are `readonly`.
+- `BaseCurrencyDriver::apiRequest()` uses `JSON_THROW_ON_ERROR`; failures wrap the underlying `JsonException` as `getPrevious()` on the resulting `ApiException`.
+- Test suite uses namespaced classes (`Otherguy\Currency\Tests\…`) with PHPUnit `#[Test]` attributes and an in-process PSR-18 mock.
+
+### Deprecated
+
+- `Otherguy\Currency\Symbol` and its static helpers (`all()`, `name()`, `names()`). Kept as a shim that emits `E_USER_DEPRECATED` on first use; will be removed in 3.0. Use `Otherguy\Currency\Currency` instead.
+
+### Removed
+
+- `nikic/php-parser` dependency (was unused at runtime).
+- Duplicate `phpunit/php-code-coverage` declaration (PHPUnit pulls it transitively).
+- `Helpers\DateHelper::parse()` and `create()`. Use `new DateTimeImmutable(...)` directly.
+- Travis CI configuration (`.travis.yml`), `.mergify.yml`, stale GitHub config files.
+
+### Fixed
+
+- `ExchangeRatesApi` driver no longer hits a dead endpoint.
+- Currency math now uses arbitrary-precision decimals; round-trip rebasing (`setBaseCurrency`) is lossless within the configured scale (default 8).
+
+### Security
+
+- HTTP requests default to TLS. `->secure()` is a no-op toggle to HTTPS; there is no opt-out to plaintext without subclassing.
+
+[2.0.0]: https://github.com/otherguy/php-currency-api/releases/tag/v2.0.0
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..c5f415f
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,140 @@
+# Contributing
+
+Thanks for considering a contribution! This guide covers the local workflow and the bar this project holds itself to.
+
+## Getting started
+
+### Prerequisites
+
+You will need the following tools installed on your system to run the full suite of checks:
+
+- **PHP 8.3+** and **Composer**
+- **[Lefthook](https://github.com/evilmartians/lefthook)**: Git hooks manager.
+- **[markdownlint-cli2](https://github.com/DavidAnson/markdownlint-cli2)**: Markdown linter.
+- **[yamllint](https://github.com/adrienverge/yamllint)**: YAML linter.
+- **[Qlty](https://qlty.sh)**: Code quality toolkit.
+
+On macOS, you can install most of these via Homebrew:
+
+```bash
+brew install lefthook markdownlint-cli2 yamllint
+curl -L https://qlty.sh/get | sh
+```
+
+### Setup
+
+```bash
+git clone https://github.com/otherguy/php-currency-api.git
+cd php-currency-api
+composer install
+lefthook install
+```
+
+`composer check` runs the same gates as CI (Pint, PHPStan, Rector, PHPUnit). It should be green before you push.
+
+## Project commands
+
+| Command | What it does |
+|--------------------------|-------------------------------------------------|
+| `composer test` | PHPUnit |
+| `composer test:coverage` | PHPUnit with coverage (requires pcov or Xdebug) |
+| `composer lint` | Pint check (read-only) |
+| `composer lint:fix` | Pint apply |
+| `composer analyse` | PHPStan at `level: max` |
+| `composer rector` | Rector dry-run |
+| `composer rector:fix` | Rector apply |
+| `composer check` | All four PHP checks in order |
+| `markdownlint-cli2` | Lint all Markdown files |
+| `yamllint .` | Lint all YAML files |
+| `qlty check` | Run Qlty quality checks |
+
+## Code style
+
+- PHP **8.3+**, `declare(strict_types=1)` in every file.
+- PSR-12 with 4-space indentation. Pint enforces this; don't fight it.
+- Real types over `@var` docblocks. Constructor property promotion is preferred when it reads naturally.
+- Names describe behavior, not implementation. No `Manager`, `Wrapper`, `Helper` unless it genuinely is one.
+- Comments are evergreen — explain *why* the code looks weird, not *what* it does, and never reference past versions of the code.
+
+## Testing
+
+- Tests live under `tests/`, namespaced `Otherguy\Currency\Tests\…`.
+- Use PHPUnit `#[Test]` attributes (no `/** @test */`).
+- HTTP is mocked with [`tests/Support/MockHttpClient.php`](tests/Support/MockHttpClient.php), an in-process PSR-18 double. Build drivers via `tests/Support/DriverHarness.php`:
+
+ ```php
+ $harness = new DriverHarness();
+ $harness->http->enqueue(JsonResponse::ok('{"success":true,"rates":{"EUR":0.92}}'));
+ $driver = $harness->make('fixerio');
+
+ $result = $driver->accessKey('key')->from('USD')->to('EUR')->get();
+
+ $this->assertSame('0.92', (string) $result->rate(Currency::EUR));
+ $this->assertStringContainsString('access_key=key', $harness->http->lastRequest()->getUri()->getQuery());
+ ```
+
+- Coverage target: **≥ 98% on `src/`**. New code without tests is unlikely to be merged.
+- Tests must exercise real code paths. Don't write tests that only verify mock behavior.
+
+### Coverage driver
+
+`composer test:coverage` (and the `vendor/bin/phpunit` invocation in CI) needs a coverage driver loaded — without one, the suite reports `No tests executed!` because `phpunit.xml` has `failOnWarning="true"`. Two options:
+
+- **pcov** (recommended — faster, coverage-only):
+
+ ```bash
+ brew install shivammathur/extensions/pcov@8.5 # match your PHP version
+ # or, if shivammathur tap is unreachable:
+ pecl install pcov
+ ```
+
+- **Xdebug** (richer features, slower):
+
+ ```bash
+ pecl install xdebug
+ ```
+
+Verify with `php -m | grep -iE 'pcov|xdebug'`. CI installs Xdebug on the PHP 8.3 leg via `shivammathur/setup-php`.
+
+## Static analysis
+
+- PHPStan runs at `level: max`. If you hit a genuine `mixed` from upstream JSON, prefer narrowing with assertions or specific type guards. The existing `ignoreErrors` block in `phpstan.neon` is scoped to `src/Drivers/*.php` for unverifiable provider responses — please don't widen it.
+- Rector checks are advisory in CI but blocking on PR. If Rector suggests a rewrite that loses meaning, exclude the rule rather than ignoring the diff.
+
+## Adding a driver
+
+See the [driver guide](README.md#adding-a-new-driver) in the root README for the full walkthrough. The short version:
+
+1. Extend `BaseCurrencyDriver`, set `$apiURL`, `$protocol`, default `$baseCurrency`.
+2. Implement `get()`, `historical()`, `convert()` against the provider's endpoints.
+3. Override `apiRequest()` only if the provider's error envelope differs from raw HTTP failures.
+4. Register in `DriverFactory`'s built-in map (or expose via `register()` for third-party drivers).
+5. Add tests under `tests/Drivers/` using `DriverHarness` and `MockHttpClient`.
+
+## Pull request checklist
+
+- [ ] `composer check` is green locally.
+- [ ] New behavior has tests.
+- [ ] Public API changes are documented in `README.md` and listed in `CHANGELOG.md` under `## [Unreleased]`.
+- [ ] BC breaks include a README upgrade note with a before/after snippet.
+- [ ] Commit messages are descriptive (`fix:` / `feat:` / `chore:` prefixes are welcome but not required).
+
+## Releasing (maintainers)
+
+1. Bump the version in the relevant `CHANGELOG.md` heading and move `[Unreleased]` items under it.
+2. Tag: `git tag -s vX.Y.Z -m "Release X.Y.Z"`.
+3. Push: `git push origin main --tags`.
+4. Create a GitHub release pasting the changelog entry.
+5. Packagist auto-syncs; verify the new version appears.
+
+## Reporting issues
+
+When filing a bug, please include:
+
+- PHP version (`php -v`).
+- Library version (`composer show otherguy/php-currency-api`).
+- The PSR-18 client you're using.
+- A minimal reproduction (driver, fluent chain, observed vs. expected).
+- The full exception trace if any.
+
+Thanks again — this library is healthier with every contribution.
diff --git a/README.md b/README.md
index adcc417..e67d7af 100644
--- a/README.md
+++ b/README.md
@@ -1,237 +1,552 @@
-# 💱 Wrapper for popular Currency Exchange Rate APIs
+# 💱 PHP Currency API
-_A PHP API Wrapper to offer a unified programming interface for popular Currency Rate APIs._
+
+
+_A PHP API Wrapper offering a unified, fluent programming interface for popular currency exchange rate APIs._
[](https://packagist.org/packages/otherguy/php-currency-api)
[](https://packagist.org/packages/otherguy/php-currency-api)
[](https://packagist.org/packages/otherguy/php-currency-api)
-[](https://travis-ci.com/otherguy/php-currency-api)
-[](https://coveralls.io/github/otherguy/php-currency-api?branch=master)
-[](https://codeclimate.com/github/otherguy/php-currency-api)
-[](https://codeclimate.com/github/otherguy/php-currency-api)
+[](https://github.com/otherguy/php-currency-api/actions)
+[](https://coveralls.io/github/otherguy/php-currency-api?branch=main)
[](LICENSE.md)
-Dont worry about your favorite currency conversion service suddenly shutting down or switching plans on you. Switch away easily, without changing your code.
+Don't worry about your favorite currency conversion service shutting down or changing plans. Switch providers without changing your code.
+
+## What's new in 2.0
-## Inspiration 💅
+- **PHP 8.3+** with strict types everywhere.
+- **PSR-18 / PSR-17** HTTP layer — bring your own client (Guzzle, Symfony, anything PSR-compliant).
+- **`brick/math` `BigDecimal`** for precise rate math instead of floats.
+- **`Currency` backed enum** replaces the old `Symbol` constants class (which is kept as a deprecation shim).
+- **New `frankfurter` driver** — free, no API key required.
+- **New `currencyapi` and `fastforex` drivers** — provider parity with TripTally's backend FX stack.
+- **Rewritten `exchangeratesapi` driver** — now points at the working `api.apilayer.com` endpoint with full `convert()` support.
+- **Pluggable `DriverFactory`** — register your own provider at runtime.
-I needed a currency conversion API for my travel website but could not find a good PHP package. The idea of the
-[`Rackbeat/php-currency-api`](https://github.com/Rackbeat/php-currency-api) package came closest but unfortunately it
-was just a stub and not implemented.
+You can find detailed instructions on how to upgrade from `1.x` to `2.x` in [UPGRADING.md](UPGRADING.md).
-## Features 🌈
+## Features
-* Support for [multiple different APIs](#supported-apis-) through the use of drivers
-* A [fluent interface](#fluent-interface) to make retrieving exchange rates convenient and fast
-* Consistent return interface that is independent of the driver being used
-* [Calculations](#conversion-result) can be made based on the returned data
+- Multiple drivers behind a single interface — switch providers by changing one string.
+- Fluent setter chain (`source`, `to`, `amount`, `date`, …) on every driver.
+- `ConversionResult` value object with lossless rebasing (`setBaseCurrency()`).
+- Hermetic test surface — inject any PSR-18 client, including in-memory mocks.
-## Supported APIs 🌐
+## Supported APIs
-| Service | Identifier |
-|------------------------------------------------------|---------------------|
-| [FixerIO](https://fixer.io) | `fixerio` |
-| [CurrencyLayer](https://currencylayer.com) | `currencylayer` |
-| [Open Exchange Rates](https://openexchangerates.org) | `openexchangerates` |
-| [Exchange Rates API](https://exchangeratesapi.io) | `exchangeratesapi` |
+| Service | Identifier |
+|------------------------------------------------------------------------------------|---------------------|
+| [Frankfurter](https://www.frankfurter.dev) | `frankfurter` |
+| [FixerIO](https://fixer.io) | `fixerio` |
+| [CurrencyLayer](https://currencylayer.com) | `currencylayer` |
+| [Open Exchange Rates](https://openexchangerates.org) | `openexchangerates` |
+| [APILayer Exchange Rates](https://apilayer.com/marketplace/exchangerates_data-api) | `exchangeratesapi` |
+| [CurrencyAPI](https://currencyapi.com) | `currencyapi` |
+| [fastFOREX](https://fastforex.io) | `fastforex` |
-_If you want to see more services added, feel free to [open an issue](https://github.com/otherguy/php-currency-api/issues)!_
+A `mock` driver is also bundled for testing without network access.
-## Prerequisites 📚
+_Want another provider? [Open an issue](https://github.com/otherguy/php-currency-api/issues) — or register a custom driver at runtime (see below)._
-* `PHP 8.x` or `PHP 7.3+` or higher (tested on both `7.3` and `7.4`)
-* The [`composer`](https://getcomposer.org) dependency manager for PHP
-* An account with one or more of the [API providers](#supported-apis-) listed above
+## Requirements
-## Installation 🚀
+- PHP **8.3** or higher.
+- A PSR-18 HTTP client and PSR-17 request factory of your choice.
+- An API account with the chosen provider, except for `frankfurter`.
-Simply require the package using `composer` and you're good to go!
+## Installation
```bash
-$ composer require otherguy/php-currency-api
+composer require otherguy/php-currency-api
```
-## Usage 🛠
+You also need a PSR-18 client and PSR-17 factory. The most common choice is Guzzle:
-### Currency Symbol Helper
+```bash
+composer require guzzlehttp/guzzle http-interop/http-factory-guzzle
+```
-The [`Otherguy\Currency\Symbol`](src/Symbol.php) class provides constants for each supported currency.
+Alternatively, with Symfony HttpClient:
-> !**Note:** You are not required to use `Otherguy\Currency\Symbol` to specify symbols. It's simply a convenience helper
-> and does not need to be used. You can simply pass strings like `'USD', 'EUR', ...` to all methods.
+```bash
+composer require symfony/http-client nyholm/psr7
+```
+
+## Quickstart
```php
-// 'USD'
-$symbol = Otherguy\Currency\Symbol::USD;
+use Otherguy\Currency\Currency;
+use Otherguy\Currency\DriverFactory;
+
+$result = DriverFactory::make('frankfurter')
+ ->from(Currency::USD)
+ ->to([Currency::EUR, Currency::GBP])
+ ->get();
+
+echo $result->rate(Currency::EUR); // BigDecimal '0.92'
+echo $result->convert(100, Currency::USD, Currency::EUR); // BigDecimal '92.00'
```
-Use the `all()` method to retrieve an array of all currency symbols:
+`DriverFactory::make()` auto-discovers Guzzle if it's installed and wires up a default PSR-18 client. To inject your own:
```php
-// [ 'AED', 'AFN', ... 'ZWL' ]
-$symbols = Otherguy\Currency\Symbol::all();
+use GuzzleHttp\Client;
+use Http\Factory\Guzzle\RequestFactory;
+use Otherguy\Currency\DriverFactory;
+
+$factory = new DriverFactory();
+$driver = $factory->build('fixerio', new Client(), new RequestFactory());
+
+$result = $driver->accessKey('YOUR_KEY')
+ ->from(Currency::EUR)
+ ->to(Currency::USD)
+ ->get();
```
-The `names()` method returns an associative array with currency names instead:
+### Bring your own HTTP client (Symfony + nyholm/psr7)
```php
-// [ 'AED' => 'United Arab Emirates Dirham', 'AFN' => 'Afghan Afghani', ... ]
-$symbols = Otherguy\Currency\Symbol::names();
+use Nyholm\Psr7\Factory\Psr17Factory;
+use Otherguy\Currency\DriverFactory;
+use Symfony\Component\HttpClient\Psr18Client;
+
+$psr17 = new Psr17Factory();
+$client = new Psr18Client();
+
+$driver = (new DriverFactory())->build('frankfurter', $client, $psr17);
```
-To get the name of a single currency, use the `name()` method:
+## Usage
+
+### The `Currency` enum
+
+`Otherguy\Currency\Currency` is a backed enum with one case per ISO-4217 code (plus a few common crypto/precious-metal codes).
```php
-// 'United States Dollar'
-$symbols = Otherguy\Currency\Symbol::name(Otherguy\Currency\Symbol::USD);
+use Otherguy\Currency\Currency;
+
+Currency::USD->value; // 'USD'
+Currency::USD->displayName(); // 'United States Dollar'
+Currency::tryFromCode('EUR'); // Currency::EUR
+Currency::tryFromCode('XYZ'); // null
+Currency::cases(); // every supported currency
```
-### Initialize API Instance
+Every method that takes a currency accepts either the enum or its string code, so plain `'USD'` keeps working.
+
+### Setting the access key
+
+Most providers require authentication. `accessKey()` is sugar for `config('access_key', …)` and is wired per-driver to the right query-string parameter.
```php
-$currency = Otherguy\Currency\DriverFactory::make('fixerio'); // driver identifier from supported drivers.
+$driver->accessKey('YOUR_KEY');
```
-To get a list of supported drivers, use the `getDrivers()` method:
+Frankfurter has no API key — calling `accessKey()` on it throws `ApiException`. CurrencyAPI is the exception to the query-string rule: its driver sends the key in the `apikey` request header.
+
+Provider-specific key mapping:
+
+| Driver | `accessKey()` mapping |
+| --- | --- |
+| `fixerio` | `access_key` query parameter |
+| `currencylayer` | `access_key` query parameter |
+| `openexchangerates` | `app_id` query parameter |
+| `exchangeratesapi` | `apikey` query parameter |
+| `currencyapi` | `apikey` request header |
+| `fastforex` | `api_key` query parameter |
+| `frankfurter` | no key; throws `ApiException` |
+
+### Configuration options
+
+For provider-specific options use `config()`:
```php
-// [ 'mock', 'fixerio', 'currencylayer', ... ]
-$drivers = Otherguy\Currency\DriverFactory::getDrivers()
+$driver->config('format', '1'); // CurrencyLayer pretty-printed JSON
```
-### Set Access Key
+### Base currency
-Most API providers require you to sign up and use your issued access key to authenticate against their API. You can
-specify your access key like so:
+`from()` and `source()` are aliases.
```php
-$currency->accessKey('your-access-token-goes-here');
+$driver->from(Currency::USD);
+$driver->source('USD');
```
-### Set Configuration Options
+Each driver has its own default base currency: `EUR` for FixerIO, APILayer Exchange Rates, and Frankfurter; `USD` for CurrencyLayer, Open Exchange Rates, CurrencyAPI, fastFOREX, and the mock driver. Most providers only allow base-currency changes on paid plans — they'll respond with an error envelope which the driver translates into `ApiException`.
+
+### Target currencies
-To set further configuration options, you can use the `config()` method. An example is
-[CurrencyLayer's JSON formatting option](https://currencylayer.com/documentation#format).
+`to()` and `currencies()` are aliases. Pass a single currency, an array, or variadic arguments. Pass nothing (or an empty array) to ask for every currency the provider supports.
```php
-$currency->config('format', '1');
+$driver->to(Currency::BTC);
+$driver->currencies([Currency::BTC, Currency::EUR, Currency::USD]);
+$driver->to(Currency::EUR, Currency::GBP);
```
-### Set Base Currency
+### Latest rates
-You can use either `from()` or `source()` to set the base currency. The methods are identical.
+```php
+$driver->get(); // current rates for the configured target currencies
+$driver->get(Currency::DKK); // override base currency for this call
+```
-> !**Note:** Each driver sets its own default base currency. [FixerIO](https://fixer.io) uses `EUR` as base currency
-> while [CurrencyLayer](https://currencylayer.com) uses `USD`.
+### Historical rates
-Most services only allow you to change the base currency in their paid plans. The driver will throw a
-`Otherguy\Currency\Exceptions\ApiException` if your current plan does not allow changing the base currency.
+Dates must be `DateTimeInterface` (or `null`).
```php
-$currency->source(Otherguy\Currency\Symbol::USD);
-$currency->from(Otherguy\Currency\Symbol::USD);
+use DateTimeImmutable;
+
+$driver->date(new DateTimeImmutable('2010-01-01'))->historical();
+$driver->historical(new DateTimeImmutable('2018-07-01'));
```
-### Set Return Currencies
+### Convert amount
-You can use either `to()` or `symbols()` to set the return currencies. The methods are identical. Pass a single currency
-or an array of currency symbols to either of these methods.
+```php
+$driver->convert(10.00, Currency::USD, Currency::THB);
+$driver->convert(122.50, Currency::NPR, Currency::EUR, new DateTimeImmutable('2019-01-01'));
+```
+
+For providers without a native `/convert` endpoint (e.g. Frankfurter), the driver fetches the rate via `get()` / `historical()` and multiplies client-side using `BigDecimal`.
+
+CurrencyAPI and fastFOREX both expose native latest conversion endpoints. For dated conversions, their drivers fetch historical rates and return a `ConversionResult` for the requested pair.
+
+### Fluent chain
-> !**Note:** Pass an empty array to return all currency symbols supported by this driver. This is the default if you
-> don't call the method at all.
-
```php
-$currency->to(Otherguy\Currency\Symbol::BTC);
-$currency->symbols([Otherguy\Currency\Symbol::BTC, Otherguy\Currency\Symbol::EUR, Otherguy\Currency\Symbol::USD]);
+DriverFactory::make('fixerio')->from(Currency::USD)->to(Currency::EUR)->get();
+DriverFactory::make('fixerio')->from(Currency::USD)->to(Currency::NPR)->date(new DateTimeImmutable('2013-03-02'))->historical();
+DriverFactory::make('fixerio')->from(Currency::USD)->to(Currency::NPR)->amount(12.10)->convert();
```
-### Latest Rates
+### `ConversionResult`
-This retrieves the most recent exchange rates and returns a [`ConversionResult`](#conversion-result) object.
+`get()` and `historical()` return a [`ConversionResult`](src/Results/ConversionResult.php). Rates are stored as `BigDecimal` and rebasing is lossless (default scale: 8 decimals).
```php
-$currency->get(); // Get latest rates for selected symbols, using set base currency
-$currency->get('DKK'); // Get latest rates for selected symbols, using DKK as base currency
+use Brick\Math\BigDecimal;
+
+$result = DriverFactory::make('frankfurter')
+ ->from(Currency::USD)
+ ->to([Currency::EUR, Currency::GBP])
+ ->get();
+
+$result->all(); // ['USD' => BigDecimal '1', 'EUR' => BigDecimal '0.89', 'GBP' => BigDecimal '0.79']
+$result->allAsFloats(); // legacy float view
+$result->getBaseCurrency(); // 'USD'
+$result->getDate(); // '2026-04-25'
+$result->rate(Currency::EUR); // BigDecimal '0.89'
+$result->rateAsFloat(Currency::EUR);// 0.89
+
+$result->convert(5.0, Currency::EUR, Currency::USD); // BigDecimal '5.618...'
+
+$rebased = $result->setBaseCurrency(Currency::EUR);
+$rebased->getBaseCurrency(); // 'EUR'
+$rebased->originalBaseCurrency; // 'USD' — readonly, never mutated
```
-### Historical Rates
+`rate()` on a code that wasn't fetched throws `Otherguy\Currency\Exceptions\CurrencyException`. To convert between two arbitrary currencies, request both in the original `get()` / `historical()` call.
-To retrieve historical exchange rates, use the `historical()` method. Note that you need to specify a date either as a
-method parameter or by using the `date()` methods. See [Fluent Interface](#fluent-interface) for more information.
+## Registering custom drivers
+
+The factory is instance-based. Bring your own driver class (extending `BaseCurrencyDriver`) and register it:
+
+```php
+use Otherguy\Currency\DriverFactory;
+
+$factory = new DriverFactory();
+$factory->register('mybank', \Acme\MyBankDriver::class);
+
+$driver = $factory->build('mybank');
+```
+
+The static `DriverFactory::make($name)` continues to work via a process-wide default factory — `DriverFactory::setDefault($factory)` lets you swap it for tests.
+
+### Adding a new driver
+
+A driver is the bridge between this library's fluent interface and a specific upstream rate provider. Every driver implements [`CurrencyDriverContract`](src/Drivers/CurrencyDriverContract.php) by extending [`BaseCurrencyDriver`](src/Drivers/BaseCurrencyDriver.php).
+
+The base class supplies:
+
+- All fluent setters (`source`, `from`, `currencies`, `to`, `amount`, `date`, `config`, `accessKey`, `secure`).
+- A PSR-18 / PSR-17 HTTP layer in `apiRequest()` that builds the URI, merges `$httpParams` with per-call params, decodes JSON with `JSON_THROW_ON_ERROR`, and wraps every failure mode in `ApiException`.
+
+You only need to:
+
+1. Set the right defaults for `$apiURL`, `$protocol`, and `$baseCurrency`.
+2. Implement `get()`, `historical()`, and `convert()`.
+3. Override `apiRequest()` only if the provider's successful HTTP response can still carry an error envelope, such as `success: false`.
+
+### Driver skeleton
+
+```php
+currencies($forCurrency);
+ }
+
+ $response = $this->apiRequest('latest', [
+ 'base' => $this->getBaseCurrency(),
+ 'symbols' => implode(',', $this->getSymbols()),
+ ]);
+
+ return new ConversionResult(
+ (string) $response['base'],
+ (string) $response['date'],
+ $response['rates'],
+ );
+ }
+
+ public function historical(
+ ?DateTimeInterface $date = null,
+ string|Currency|array $forCurrency = [],
+ ): ConversionResult {
+ if ($date instanceof DateTimeInterface) {
+ $this->date($date);
+ }
+ if ($forCurrency !== []) {
+ $this->currencies($forCurrency);
+ }
+ if ($this->getDate() === null) {
+ throw new ApiException('Date is required for historical().');
+ }
+
+ $response = $this->apiRequest('history/' . $this->getDate(), [
+ 'base' => $this->getBaseCurrency(),
+ ]);
+
+ return new ConversionResult(
+ (string) $response['base'],
+ (string) $response['date'],
+ $response['rates'],
+ );
+ }
+
+ public function convert(
+ ?float $amount = null,
+ string|Currency|null $fromCurrency = null,
+ string|Currency|null $toCurrency = null,
+ ?DateTimeInterface $date = null,
+ ): ConversionResult {
+ if ($amount !== null) {
+ $this->amount = $amount;
+ }
+ if ($fromCurrency !== null) {
+ $this->source($fromCurrency);
+ }
+ if ($toCurrency !== null) {
+ $this->to($toCurrency);
+ }
+ if ($date instanceof DateTimeInterface) {
+ $this->date($date);
+ }
+
+ if ($this->amount === null) {
+ throw new ApiException('An amount is required for convert().');
+ }
+ if ($this->currencies === []) {
+ throw new ApiException('A target currency is required for convert().');
+ }
+
+ $target = $this->getSymbols()[0];
+ $response = $this->apiRequest('convert', [
+ 'from' => $this->getBaseCurrency(),
+ 'to' => $target,
+ 'amount' => $this->amount,
+ ]);
+
+ return new ConversionResult(
+ $this->getBaseCurrency(),
+ isset($response['date']) ? (string) $response['date'] : null,
+ [$target => $response['result']],
+ );
+ }
+}
+```
+
+For providers without a native conversion endpoint, fetch a rate through `get()` / `historical()` and multiply client-side with `BigDecimal`; [`Frankfurter`](src/Drivers/Frankfurter.php) is the compact example.
+
+### Driver authentication
+
+`accessKey()` defaults to writing `access_key=...` into `$httpParams`. If your provider uses a different parameter name, override it:
```php
-$currency->date('2010-01-01')->historical();
-$currency->historical('2018-07-01');
+#[\Override]
+public function accessKey(string $accessKey): static
+{
+ return $this->config('apikey', $accessKey);
+}
```
-### Convert Amount
+For header authentication, write to `$httpHeaders`:
-Use the `convert()` method to convert amounts between currencies.
+```php
+#[\Override]
+public function accessKey(string $accessKey): static
+{
+ $this->httpHeaders['apikey'] = $accessKey;
-> !**Note:** Most API providers don't allow access to this method using your free account. You can still use the
-> [Latest Rates](#latest-rates) or [Historical Rates](#historical-rates) endpoints and perform calculations or conversions
-> on the [`ConversionResult`](#conversion-result) object.
+ return $this;
+}
+```
+
+If the provider has no keys, throw to make misuse loud:
```php
-$currency->convert(10.00, 'USD', 'THB'); // Convert 10 USD to THB
-$currency->convert(122.50, 'NPR', 'EUR', '2019-01-01'); // Convert 122.50 NPR to EUR using the rates from January 1st, 2019
+#[\Override]
+public function accessKey(string $accessKey): static
+{
+ throw new ApiException('MyProvider does not require an API key.');
+}
```
-### Fluent Interface
+### Provider-specific error envelopes
-Most methods can be used with a _fluent interface_, allowing you to chain method calls for more readable code:
+Many providers return HTTP 200 with an error body. Override `apiRequest()` to translate those into `ApiException` before the value reaches `get()` / `historical()` / `convert()`:
```php
-// Namespaces are omitted for readability!
-DriverFactory::make('driver')->from(Symbol::USD)->to(Symbol::EUR)->get();
-DriverFactory::make('driver')->from(Symbol::USD)->to(Symbol::NPR)->date('2013-03-02')->historical();
-DriverFactory::make('driver')->from(Symbol::USD)->to(Symbol::NPR)->amount(12.10)->convert();
+#[\Override]
+protected function apiRequest(string $endpoint, array $params = []): array
+{
+ $response = parent::apiRequest($endpoint, $params);
+
+ if (($response['success'] ?? null) !== true) {
+ $info = (string) ($response['error']['info'] ?? 'Unknown API error.');
+ throw new ApiException($info);
+ }
+
+ return $response;
+}
```
-### Conversion Result
+### First-party driver registration
+
+For first-party drivers shipped with this package, add the class to the built-in map in [`DriverFactory`](src/DriverFactory.php):
-The [`get()`](#latest-rates) and [`historical()`](#historical-rates) endpoints return a
-[`ConversionResult`](src/Results/ConversionResult.php) object. This object allows you to perform calculations and
-conversions easily.
+```php
+public function __construct(?array $drivers = null)
+{
+ $this->drivers = $drivers ?? [
+ // ...
+ 'myprovider' => MyProvider::class,
+ ];
+}
+```
-> !**Note:** Even though free accounts of most providers do not allow you to change the base currency, you can still
-> use the `ConversionResult` object to change the base currency later. This might not be as accurate as changing the
-> base currency directly, though.
+For third-party drivers, use runtime registration as shown above. `register()` returns `$this`, `unregister(string $name)` removes a driver, and `build()` accepts optional PSR-18 + PSR-17 collaborators. If those collaborators are omitted, the factory tries to auto-discover Guzzle.
-> !**Note:** To convert between two currencies, you need to request both of them in your initial [`get()`](#latest-rates)
-> or [`historical()`](#historical-rates) request. You can not convert between currencies that have not been fetched!
+### Driver tests
-See the following code for some examples of what you can do with the `ConversionResult` object.
+Driver tests live under `tests/Drivers/`. Use [`tests/Support/DriverHarness.php`](tests/Support/DriverHarness.php) to wire up an in-process PSR-18 mock:
```php
-$result = DriverFactory::make('driver')->from(Symbol::USD)->to([Symbol::EUR, Symbol::GBP])->get();
+use Otherguy\Currency\Currency;
+use Otherguy\Currency\Tests\Support\DriverHarness;
+use Otherguy\Currency\Tests\Support\JsonResponse;
+use PHPUnit\Framework\Attributes\Test;
+use PHPUnit\Framework\TestCase;
+
+class MyProviderTest extends TestCase
+{
+ private DriverHarness $harness;
+
+ protected function setUp(): void
+ {
+ $this->harness = new DriverHarness();
+ }
+
+ #[Test]
+ public function get_parses_provider_envelope(): void
+ {
+ $this->harness->http->enqueue(JsonResponse::ok(json_encode([
+ 'base' => 'USD',
+ 'date' => '2026-04-01',
+ 'rates' => ['EUR' => 0.92],
+ ], JSON_THROW_ON_ERROR)));
+
+ $result = $this->harness->make('myprovider')
+ ->accessKey('test-key')
+ ->from(Currency::USD)
+ ->to(Currency::EUR)
+ ->get();
+
+ $this->assertSame('0.92', (string) $result->rate(Currency::EUR));
+
+ $request = $this->harness->http->lastRequest();
+ $this->assertNotNull($request);
+ $this->assertStringContainsString('apikey=test-key', $request->getUri()->getQuery());
+ }
+}
+```
+
+`DriverHarness` instantiates a fresh `MockHttpClient` on each test. Use `enqueue()` to queue responses, `lastRequest()` to assert URI/query/headers, and `sentRequests()` for multi-request flows.
-// [ 'USD' => 1.00, 'EUR' => 0.89, 'GBP' => 0.79 ]
-$result->all();
+### Driver checklist
-// 'USD'
-$result->getBaseCurrency();
+- [ ] `declare(strict_types=1)` and `Override` attributes where you override.
+- [ ] `$apiURL` does not include the `https://` prefix; `BaseCurrencyDriver` adds the protocol.
+- [ ] `get()`, `historical()`, and `convert()` return `ConversionResult`, not arrays.
+- [ ] Error envelopes are wrapped in `ApiException` so callers see one consistent failure type.
+- [ ] PHPStan is clean at `level: max` (`composer analyse`).
+- [ ] Tests cover happy path, error envelope, and any `accessKey()` quirks.
+- [ ] First-party drivers are registered in `DriverFactory` and listed in the Supported APIs table.
-// '2019-06-11'
-$result->getDate();
+For real examples, browse the existing drivers. They range from thin happy-path code in [`Frankfurter`](src/Drivers/Frankfurter.php), to header authentication in [`CurrencyApi`](src/Drivers/CurrencyApi.php), to envelope translation in [`FixerIo`](src/Drivers/FixerIo.php), [`CurrencyLayer`](src/Drivers/CurrencyLayer.php), and [`ExchangeRatesApi`](src/Drivers/ExchangeRatesApi.php).
-// 0.89
-$result->rate(Symbol::EUR);
+## Testing
-// CurrencyException("No conversion result for BTC!");
-$result->rate(Symbol::BTC);
+The library exposes `Otherguy\Currency\Drivers\MockCurrencyDriver` for consumers writing tests without a network. Seed it with rates and use it like any other driver:
-// 5.618
-$result->convert(5.0, Symbol::EUR, Symbol::USD);
+```php
+use Otherguy\Currency\Drivers\MockCurrencyDriver;
-// [ 'USD' => 1.13, 'EUR' => 1.00, 'GBP' => 0.89 ]
-$result->setBaseCurrency(Symbol::EUR)->all();
+$driver = (new MockCurrencyDriver(/* PSR-18 + factory */))
+ ->withRates(['EUR' => '0.92', 'GBP' => '0.79']);
-// 1.12
-$result->setBaseCurrency(Symbol::GBP)->rate(Symbol::EUR);
+$driver->get()->rate('EUR'); // BigDecimal '0.92'
```
-## Contributing 🚧
+For testing this library itself, see `tests/Support/MockHttpClient.php` — a tiny in-process PSR-18 double that records sent requests and replays queued responses. CONTRIBUTING.md walks through it.
+
+## Project commands
+
+| Command | What it does |
+|-------------------------------|---------------------------------------|
+| `composer test` | Run the test suite |
+| `composer test:coverage` | Run with coverage (requires Xdebug) |
+| `composer lint` | Pint code-style check (read-only) |
+| `composer lint:fix` | Pint auto-fix |
+| `composer analyse` | PHPStan at level max |
+| `composer rector` | Rector dry-run |
+| `composer rector:fix` | Rector apply |
+| `composer check` | All of the above, in order |
+
+## Contributing
+
+Pull requests are welcome — please run `composer check` before opening one. Coverage target is ≥ 98% on `src/`. See [`CONTRIBUTING.md`](CONTRIBUTING.md) for the full guide.
+
+## License
-[Pull Requests](https://github.com/otherguy/php-currency-api/pulls) are more than welcome! I'm striving for 100% test
-coverage for this repository so please make sure to add tests for your code.
+[MIT](LICENSE.md).
diff --git a/UPGRADING.md b/UPGRADING.md
new file mode 100644
index 0000000..2bb3135
--- /dev/null
+++ b/UPGRADING.md
@@ -0,0 +1,69 @@
+
+# Upgrading from 1.x
+
+| Area | 1.x | 2.0 |
+| --- | --- | --- |
+| PHP | `>=7.3` | `^8.3` |
+| HTTP client | Hard-wired Guzzle | Any PSR-18 client + PSR-17 factory |
+| Currency catalog | `Symbol` constants | `Currency` backed enum (`Symbol` remains as a deprecation shim) |
+| Rate type | `float` | `Brick\Math\BigDecimal` |
+| Dates | strings, timestamps, intervals, date objects | `?DateTimeInterface` |
+| Default protocol | `http` | `https` |
+
+Required changes:
+
+1. Bump composer constraints:
+
+ ```bash
+ composer require otherguy/php-currency-api:^2.0
+ composer require guzzlehttp/guzzle:^7.9 http-interop/http-factory-guzzle:^1.2
+ ```
+
+ If you do not want Guzzle, install any PSR-18 client and PSR-17 factory and pass them to `DriverFactory::build()`.
+
+2. Wrap date strings in `DateTimeImmutable`:
+
+ ```diff
+ - $driver->historical('2018-07-01');
+ + $driver->historical(new DateTimeImmutable('2018-07-01'));
+
+ - $driver->convert(122.50, 'NPR', 'EUR', '2019-01-01');
+ + $driver->convert(122.50, 'NPR', 'EUR', new DateTimeImmutable('2019-01-01'));
+ ```
+
+3. Treat rates as `BigDecimal`:
+
+ ```diff
+ - $rate = $result->rate('EUR'); // float
+ - $total = $rate * 100;
+ + $rate = $result->rate('EUR'); // BigDecimal
+ + $total = $rate->multipliedBy(100);
+ ```
+
+ For legacy reporting or JSON output, use `rateAsFloat()` or `allAsFloats()`.
+
+4. Prefer `Currency` over `Symbol` in new code:
+
+ ```diff
+ - use Otherguy\Currency\Symbol;
+ - $driver->to(Symbol::EUR);
+ + use Otherguy\Currency\Currency;
+ + $driver->to(Currency::EUR);
+ ```
+
+ Plain strings such as `'USD'` still work everywhere a currency is accepted, so this can be incremental.
+
+5. Update direct driver construction if you used it:
+
+ ```diff
+ - $driver = new FixerIo(new GuzzleHttp\Client());
+ + $driver = (new DriverFactory())->build('fixerio', new GuzzleHttp\Client(), new Http\Factory\Guzzle\RequestFactory());
+ ```
+
+ `DriverFactory::make('fixerio')` still works and auto-discovers Guzzle.
+
+Provider notes:
+
+- `exchangeratesapi` now targets APILayer's `api.apilayer.com/exchangerates_data` API. `accessKey()` now sends the required `apikey` parameter, and `convert()` is implemented.
+- 2.0 adds `frankfurter`, `currencyapi`, and `fastforex` as built-in driver identifiers.
+- `apiRequest()` is now protected. Call `get()`, `historical()`, or `convert()` from consumers, or extend the driver for custom behavior.
diff --git a/composer.json b/composer.json
index 1a1339c..e39bc63 100644
--- a/composer.json
+++ b/composer.json
@@ -1,64 +1,81 @@
{
- "name": "otherguy/php-currency-api",
- "description": "A PHP API Wrapper to offer a unified programming interface for popular Currency Rate APIs.",
- "keywords": [
- "currency",
- "currency rates",
- "exchange rates",
- "currency conversion",
+ "name": "otherguy/php-currency-api",
+ "description": "A PHP API Wrapper to offer a unified programming interface for popular Currency Rate APIs.",
+ "keywords": [
"currency",
- "currency-exchange-rates",
- "composer-package",
- "php-library",
- "currency-converter-php",
- "currency-api",
+ "currency-rates",
"exchange-rates",
- "currency-rate-apis",
- "fluent-interface",
- "api-wrapper",
- "currency-converter",
"currency-conversion",
- "currency-exchange",
+ "currency-converter",
+ "currency-api",
"exchange-rate-api",
- "exchange-rates-api",
- "php-package",
- "php8",
- "php74",
- "php73"
- ],
- "type": "library",
- "license": "MIT",
- "authors": [
- {
- "name": "Alexander Graf",
- "email": "alex@otherguy.io"
- }
- ],
- "require": {
- "php": ">=7.3",
+ "fluent-interface",
+ "psr-18",
+ "php8"
+ ],
+ "type": "library",
+ "license": "MIT",
+ "authors": [
+ {
+ "name": "Alexander Graf",
+ "email": "alex@otherguy.io"
+ }
+ ],
+ "require": {
+ "php": "^8.3",
"ext-json": "*",
- "guzzlehttp/guzzle": "^7.2"
- },
- "require-dev": {
- "nikic/php-parser": "^4.10",
- "php-coveralls/php-coveralls": "^2.4",
- "phpunit/php-code-coverage": "^9.2",
- "phpunit/phpunit": "^9.4"
- },
- "suggest": {
- "ext-curl": "Faster HTTP requests"
- },
- "autoload": {
- "psr-4": {
- "Otherguy\\Currency\\": "src/"
- }
- },
- "autoload-dev": {
- "psr-4": {
- "Currency\\Tests\\": "tests/"
- }
- },
- "config": {
- "sort-packages": true
- }
+ "brick/math": "^0.12",
+ "psr/http-client": "^1.0",
+ "psr/http-factory": "^1.1",
+ "psr/http-message": "^1.1 || ^2.0"
+ },
+ "require-dev": {
+ "guzzlehttp/guzzle": "^7.9",
+ "http-interop/http-factory-guzzle": "^1.2",
+ "laravel/pint": "^1.18",
+ "php-coveralls/php-coveralls": "^2.7",
+ "phpstan/phpstan": "^2.0",
+ "phpstan/phpstan-phpunit": "^2.0",
+ "phpunit/phpunit": "^12.0",
+ "rector/rector": "^2.0"
+ },
+ "suggest": {
+ "guzzlehttp/guzzle": "PSR-18 HTTP client (^7.9), still the de-facto choice and what the test suite uses",
+ "symfony/http-client": "Alternative PSR-18 client (^7.0); pair with nyholm/psr7 for PSR-17 factories",
+ "nyholm/psr7": "Lightweight PSR-7/PSR-17 implementation, recommended with symfony/http-client"
+ },
+ "autoload": {
+ "psr-4": {
+ "Otherguy\\Currency\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Otherguy\\Currency\\Tests\\": "tests/"
+ }
+ },
+ "scripts": {
+ "test": "phpunit --no-coverage",
+ "test:coverage": "XDEBUG_MODE=coverage phpunit --coverage-text",
+ "lint": "pint --test",
+ "lint:fix": "pint",
+ "analyse": "phpstan analyse",
+ "rector": "rector --dry-run",
+ "rector:fix": "rector",
+ "check": [
+ "@lint",
+ "@analyse",
+ "@rector",
+ "@test"
+ ]
+ },
+ "config": {
+ "platform": {
+ "php": "8.3.30"
+ },
+ "sort-packages": true,
+ "allow-plugins": {
+ "php-http/discovery": true
+ }
+ }
}
diff --git a/composer.lock b/composer.lock
index e67206d..78c327f 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,26 +4,248 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "271a92e359371033df60fa8d63e37268",
+ "content-hash": "94f4767d77d28e0fc91f1404d6596ff0",
"packages": [
+ {
+ "name": "brick/math",
+ "version": "0.12.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/brick/math.git",
+ "reference": "866551da34e9a618e64a819ee1e01c20d8a588ba"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/brick/math/zipball/866551da34e9a618e64a819ee1e01c20d8a588ba",
+ "reference": "866551da34e9a618e64a819ee1e01c20d8a588ba",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^8.1"
+ },
+ "require-dev": {
+ "php-coveralls/php-coveralls": "^2.2",
+ "phpunit/phpunit": "^10.1",
+ "vimeo/psalm": "6.8.8"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Brick\\Math\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "Arbitrary-precision arithmetic library",
+ "keywords": [
+ "Arbitrary-precision",
+ "BigInteger",
+ "BigRational",
+ "arithmetic",
+ "bigdecimal",
+ "bignum",
+ "bignumber",
+ "brick",
+ "decimal",
+ "integer",
+ "math",
+ "mathematics",
+ "rational"
+ ],
+ "support": {
+ "issues": "https://github.com/brick/math/issues",
+ "source": "https://github.com/brick/math/tree/0.12.3"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/BenMorel",
+ "type": "github"
+ }
+ ],
+ "time": "2025-02-28T13:11:00+00:00"
+ },
+ {
+ "name": "psr/http-client",
+ "version": "1.0.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/http-client.git",
+ "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90",
+ "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.0 || ^8.0",
+ "psr/http-message": "^1.0 || ^2.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Http\\Client\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interface for HTTP clients",
+ "homepage": "https://github.com/php-fig/http-client",
+ "keywords": [
+ "http",
+ "http-client",
+ "psr",
+ "psr-18"
+ ],
+ "support": {
+ "source": "https://github.com/php-fig/http-client"
+ },
+ "time": "2023-09-23T14:17:50+00:00"
+ },
+ {
+ "name": "psr/http-factory",
+ "version": "1.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/http-factory.git",
+ "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
+ "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.1",
+ "psr/http-message": "^1.0 || ^2.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Http\\Message\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories",
+ "keywords": [
+ "factory",
+ "http",
+ "message",
+ "psr",
+ "psr-17",
+ "psr-7",
+ "request",
+ "response"
+ ],
+ "support": {
+ "source": "https://github.com/php-fig/http-factory"
+ },
+ "time": "2024-04-15T12:06:14+00:00"
+ },
+ {
+ "name": "psr/http-message",
+ "version": "2.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/http-message.git",
+ "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71",
+ "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2 || ^8.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Http\\Message\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interface for HTTP messages",
+ "homepage": "https://github.com/php-fig/http-message",
+ "keywords": [
+ "http",
+ "http-message",
+ "psr",
+ "psr-7",
+ "request",
+ "response"
+ ],
+ "support": {
+ "source": "https://github.com/php-fig/http-message/tree/2.0"
+ },
+ "time": "2023-04-04T09:54:51+00:00"
+ }
+ ],
+ "packages-dev": [
{
"name": "guzzlehttp/guzzle",
- "version": "7.8.1",
+ "version": "7.10.0",
"source": {
"type": "git",
"url": "https://github.com/guzzle/guzzle.git",
- "reference": "41042bc7ab002487b876a0683fc8dce04ddce104"
+ "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/guzzle/guzzle/zipball/41042bc7ab002487b876a0683fc8dce04ddce104",
- "reference": "41042bc7ab002487b876a0683fc8dce04ddce104",
+ "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4",
+ "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4",
"shasum": ""
},
"require": {
"ext-json": "*",
- "guzzlehttp/promises": "^1.5.3 || ^2.0.1",
- "guzzlehttp/psr7": "^1.9.1 || ^2.5.1",
+ "guzzlehttp/promises": "^2.3",
+ "guzzlehttp/psr7": "^2.8",
"php": "^7.2.5 || ^8.0",
"psr/http-client": "^1.0",
"symfony/deprecation-contracts": "^2.2 || ^3.0"
@@ -34,9 +256,9 @@
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2",
"ext-curl": "*",
- "php-http/client-integration-tests": "dev-master#2c025848417c1135031fdf9c728ee53d0a7ceaee as 3.0.999",
+ "guzzle/client-integration-tests": "3.0.2",
"php-http/message-factory": "^1.1",
- "phpunit/phpunit": "^8.5.36 || ^9.6.15",
+ "phpunit/phpunit": "^8.5.39 || ^9.6.20",
"psr/log": "^1.1 || ^2.0 || ^3.0"
},
"suggest": {
@@ -112,6 +334,10 @@
"rest",
"web service"
],
+ "support": {
+ "issues": "https://github.com/guzzle/guzzle/issues",
+ "source": "https://github.com/guzzle/guzzle/tree/7.10.0"
+ },
"funding": [
{
"url": "https://github.com/GrahamCampbell",
@@ -126,20 +352,20 @@
"type": "tidelift"
}
],
- "time": "2023-12-03T20:35:24+00:00"
+ "time": "2025-08-23T22:36:01+00:00"
},
{
"name": "guzzlehttp/promises",
- "version": "2.0.2",
+ "version": "2.3.0",
"source": {
"type": "git",
"url": "https://github.com/guzzle/promises.git",
- "reference": "bbff78d96034045e58e13dedd6ad91b5d1253223"
+ "reference": "481557b130ef3790cf82b713667b43030dc9c957"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/guzzle/promises/zipball/bbff78d96034045e58e13dedd6ad91b5d1253223",
- "reference": "bbff78d96034045e58e13dedd6ad91b5d1253223",
+ "url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957",
+ "reference": "481557b130ef3790cf82b713667b43030dc9c957",
"shasum": ""
},
"require": {
@@ -147,7 +373,7 @@
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2",
- "phpunit/phpunit": "^8.5.36 || ^9.6.15"
+ "phpunit/phpunit": "^8.5.44 || ^9.6.25"
},
"type": "library",
"extra": {
@@ -191,6 +417,10 @@
"keywords": [
"promise"
],
+ "support": {
+ "issues": "https://github.com/guzzle/promises/issues",
+ "source": "https://github.com/guzzle/promises/tree/2.3.0"
+ },
"funding": [
{
"url": "https://github.com/GrahamCampbell",
@@ -205,20 +435,20 @@
"type": "tidelift"
}
],
- "time": "2023-12-03T20:19:20+00:00"
+ "time": "2025-08-22T14:34:08+00:00"
},
{
"name": "guzzlehttp/psr7",
- "version": "2.6.2",
+ "version": "2.9.0",
"source": {
"type": "git",
"url": "https://github.com/guzzle/psr7.git",
- "reference": "45b30f99ac27b5ca93cb4831afe16285f57b8221"
+ "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/guzzle/psr7/zipball/45b30f99ac27b5ca93cb4831afe16285f57b8221",
- "reference": "45b30f99ac27b5ca93cb4831afe16285f57b8221",
+ "url": "https://api.github.com/repos/guzzle/psr7/zipball/7d0ed42f28e42d61352a7a79de682e5e67fec884",
+ "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884",
"shasum": ""
},
"require": {
@@ -233,8 +463,9 @@
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2",
- "http-interop/http-factory-tests": "^0.9",
- "phpunit/phpunit": "^8.5.36 || ^9.6.15"
+ "http-interop/http-factory-tests": "0.9.0",
+ "jshttp/mime-db": "1.54.0.1",
+ "phpunit/phpunit": "^8.5.44 || ^9.6.25"
},
"suggest": {
"laminas/laminas-httphandlerrunner": "Emit PSR-7 responses"
@@ -287,260 +518,76 @@
"homepage": "https://github.com/Tobion"
},
{
- "name": "Márk Sági-Kazár",
- "email": "mark.sagikazar@gmail.com",
- "homepage": "https://sagikazarmark.hu"
- }
- ],
- "description": "PSR-7 message implementation that also provides common utility methods",
- "keywords": [
- "http",
- "message",
- "psr-7",
- "request",
- "response",
- "stream",
- "uri",
- "url"
- ],
- "funding": [
- {
- "url": "https://github.com/GrahamCampbell",
- "type": "github"
- },
- {
- "url": "https://github.com/Nyholm",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7",
- "type": "tidelift"
- }
- ],
- "time": "2023-12-03T20:05:35+00:00"
- },
- {
- "name": "psr/http-client",
- "version": "1.0.3",
- "source": {
- "type": "git",
- "url": "https://github.com/php-fig/http-client.git",
- "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90",
- "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90",
- "shasum": ""
- },
- "require": {
- "php": "^7.0 || ^8.0",
- "psr/http-message": "^1.0 || ^2.0"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "1.0.x-dev"
- }
- },
- "autoload": {
- "psr-4": {
- "Psr\\Http\\Client\\": "src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "PHP-FIG",
- "homepage": "https://www.php-fig.org/"
- }
- ],
- "description": "Common interface for HTTP clients",
- "homepage": "https://github.com/php-fig/http-client",
- "keywords": [
- "http",
- "http-client",
- "psr",
- "psr-18"
- ],
- "time": "2023-09-23T14:17:50+00:00"
- },
- {
- "name": "psr/http-factory",
- "version": "1.0.2",
- "source": {
- "type": "git",
- "url": "https://github.com/php-fig/http-factory.git",
- "reference": "e616d01114759c4c489f93b099585439f795fe35"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/php-fig/http-factory/zipball/e616d01114759c4c489f93b099585439f795fe35",
- "reference": "e616d01114759c4c489f93b099585439f795fe35",
- "shasum": ""
- },
- "require": {
- "php": ">=7.0.0",
- "psr/http-message": "^1.0 || ^2.0"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "1.0.x-dev"
- }
- },
- "autoload": {
- "psr-4": {
- "Psr\\Http\\Message\\": "src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "PHP-FIG",
- "homepage": "https://www.php-fig.org/"
- }
- ],
- "description": "Common interfaces for PSR-7 HTTP message factories",
- "keywords": [
- "factory",
- "http",
- "message",
- "psr",
- "psr-17",
- "psr-7",
- "request",
- "response"
- ],
- "time": "2023-04-10T20:10:41+00:00"
- },
- {
- "name": "psr/http-message",
- "version": "2.0",
- "source": {
- "type": "git",
- "url": "https://github.com/php-fig/http-message.git",
- "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71",
- "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71",
- "shasum": ""
- },
- "require": {
- "php": "^7.2 || ^8.0"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "2.0.x-dev"
- }
- },
- "autoload": {
- "psr-4": {
- "Psr\\Http\\Message\\": "src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "PHP-FIG",
- "homepage": "https://www.php-fig.org/"
- }
- ],
- "description": "Common interface for HTTP messages",
- "homepage": "https://github.com/php-fig/http-message",
- "keywords": [
- "http",
- "http-message",
- "psr",
- "psr-7",
- "request",
- "response"
- ],
- "time": "2023-04-04T09:54:51+00:00"
- },
- {
- "name": "ralouphie/getallheaders",
- "version": "3.0.3",
- "source": {
- "type": "git",
- "url": "https://github.com/ralouphie/getallheaders.git",
- "reference": "120b605dfeb996808c31b6477290a714d356e822"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822",
- "reference": "120b605dfeb996808c31b6477290a714d356e822",
- "shasum": ""
- },
- "require": {
- "php": ">=5.6"
- },
- "require-dev": {
- "php-coveralls/php-coveralls": "^2.1",
- "phpunit/phpunit": "^5 || ^6.5"
- },
- "type": "library",
- "autoload": {
- "files": [
- "src/getallheaders.php"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
+ "name": "Márk Sági-Kazár",
+ "email": "mark.sagikazar@gmail.com",
+ "homepage": "https://sagikazarmark.hu"
+ }
],
- "authors": [
+ "description": "PSR-7 message implementation that also provides common utility methods",
+ "keywords": [
+ "http",
+ "message",
+ "psr-7",
+ "request",
+ "response",
+ "stream",
+ "uri",
+ "url"
+ ],
+ "support": {
+ "issues": "https://github.com/guzzle/psr7/issues",
+ "source": "https://github.com/guzzle/psr7/tree/2.9.0"
+ },
+ "funding": [
{
- "name": "Ralph Khattar",
- "email": "ralph.khattar@gmail.com"
+ "url": "https://github.com/GrahamCampbell",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/Nyholm",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7",
+ "type": "tidelift"
}
],
- "description": "A polyfill for getallheaders.",
- "time": "2019-03-08T08:55:37+00:00"
+ "time": "2026-03-10T16:41:02+00:00"
},
{
- "name": "symfony/deprecation-contracts",
- "version": "v2.5.2",
+ "name": "http-interop/http-factory-guzzle",
+ "version": "1.2.1",
"source": {
"type": "git",
- "url": "https://github.com/symfony/deprecation-contracts.git",
- "reference": "e8b495ea28c1d97b5e0c121748d6f9b53d075c66"
+ "url": "https://github.com/http-interop/http-factory-guzzle.git",
+ "reference": "c2c859ceb05c3f42e710b60555f4c35b6a4a3995"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/e8b495ea28c1d97b5e0c121748d6f9b53d075c66",
- "reference": "e8b495ea28c1d97b5e0c121748d6f9b53d075c66",
+ "url": "https://api.github.com/repos/http-interop/http-factory-guzzle/zipball/c2c859ceb05c3f42e710b60555f4c35b6a4a3995",
+ "reference": "c2c859ceb05c3f42e710b60555f4c35b6a4a3995",
"shasum": ""
},
"require": {
- "php": ">=7.1"
+ "guzzlehttp/psr7": "^1.7||^2.0",
+ "php": ">=7.3",
+ "psr/http-factory": "^1.0"
},
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-main": "2.5-dev"
- },
- "thanks": {
- "name": "symfony/contracts",
- "url": "https://github.com/symfony/contracts"
- }
+ "provide": {
+ "psr/http-factory-implementation": "^1.0"
+ },
+ "require-dev": {
+ "http-interop/http-factory-tests": "^0.9",
+ "phpunit/phpunit": "^9.5"
+ },
+ "suggest": {
+ "guzzlehttp/psr7": "Includes an HTTP factory starting in version 2.0"
},
+ "type": "library",
"autoload": {
- "files": [
- "function.php"
- ]
+ "psr-4": {
+ "Http\\Factory\\Guzzle\\": "src/"
+ }
},
"notification-url": "https://packagist.org/downloads/",
"license": [
@@ -548,65 +595,63 @@
],
"authors": [
{
- "name": "Nicolas Grekas",
- "email": "p@tchwork.com"
- },
- {
- "name": "Symfony Community",
- "homepage": "https://symfony.com/contributors"
+ "name": "PHP-FIG",
+ "homepage": "http://www.php-fig.org/"
}
],
- "description": "A generic function and convention to trigger deprecation notices",
- "homepage": "https://symfony.com",
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
+ "description": "An HTTP Factory using Guzzle PSR7",
+ "keywords": [
+ "factory",
+ "http",
+ "psr-17",
+ "psr-7"
],
- "time": "2022-01-02T09:53:40+00:00"
- }
- ],
- "packages-dev": [
+ "support": {
+ "issues": "https://github.com/http-interop/http-factory-guzzle/issues",
+ "source": "https://github.com/http-interop/http-factory-guzzle/tree/1.2.1"
+ },
+ "time": "2025-12-15T11:28:16+00:00"
+ },
{
- "name": "doctrine/instantiator",
- "version": "1.5.0",
+ "name": "laravel/pint",
+ "version": "v1.29.1",
"source": {
"type": "git",
- "url": "https://github.com/doctrine/instantiator.git",
- "reference": "0a0fa9780f5d4e507415a065172d26a98d02047b"
+ "url": "https://github.com/laravel/pint.git",
+ "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/doctrine/instantiator/zipball/0a0fa9780f5d4e507415a065172d26a98d02047b",
- "reference": "0a0fa9780f5d4e507415a065172d26a98d02047b",
+ "url": "https://api.github.com/repos/laravel/pint/zipball/0770e9b7fafd50d4586881d456d6eb41c9247a80",
+ "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80",
"shasum": ""
},
"require": {
- "php": "^7.1 || ^8.0"
+ "ext-json": "*",
+ "ext-mbstring": "*",
+ "ext-tokenizer": "*",
+ "ext-xml": "*",
+ "php": "^8.2.0"
},
"require-dev": {
- "doctrine/coding-standard": "^9 || ^11",
- "ext-pdo": "*",
- "ext-phar": "*",
- "phpbench/phpbench": "^0.16 || ^1",
- "phpstan/phpstan": "^1.4",
- "phpstan/phpstan-phpunit": "^1",
- "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5",
- "vimeo/psalm": "^4.30 || ^5.4"
+ "friendsofphp/php-cs-fixer": "^3.95.1",
+ "illuminate/view": "^12.56.0",
+ "larastan/larastan": "^3.9.6",
+ "laravel-zero/framework": "^12.1.0",
+ "mockery/mockery": "^1.6.12",
+ "nunomaduro/termwind": "^2.4.0",
+ "pestphp/pest": "^3.8.6",
+ "shipfastlabs/agent-detector": "^1.1.3"
},
- "type": "library",
+ "bin": [
+ "builds/pint"
+ ],
+ "type": "project",
"autoload": {
"psr-4": {
- "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/"
+ "App\\": "app/",
+ "Database\\Seeders\\": "database/seeders/",
+ "Database\\Factories\\": "database/factories/"
}
},
"notification-url": "https://packagist.org/downloads/",
@@ -615,45 +660,38 @@
],
"authors": [
{
- "name": "Marco Pivetta",
- "email": "ocramius@gmail.com",
- "homepage": "https://ocramius.github.io/"
+ "name": "Nuno Maduro",
+ "email": "enunomaduro@gmail.com"
}
],
- "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors",
- "homepage": "https://www.doctrine-project.org/projects/instantiator.html",
+ "description": "An opinionated code formatter for PHP.",
+ "homepage": "https://laravel.com",
"keywords": [
- "constructor",
- "instantiate"
- ],
- "funding": [
- {
- "url": "https://www.doctrine-project.org/sponsorship.html",
- "type": "custom"
- },
- {
- "url": "https://www.patreon.com/phpdoctrine",
- "type": "patreon"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finstantiator",
- "type": "tidelift"
- }
+ "dev",
+ "format",
+ "formatter",
+ "lint",
+ "linter",
+ "php"
],
- "time": "2022-12-30T00:15:36+00:00"
+ "support": {
+ "issues": "https://github.com/laravel/pint/issues",
+ "source": "https://github.com/laravel/pint"
+ },
+ "time": "2026-04-20T15:26:14+00:00"
},
{
"name": "myclabs/deep-copy",
- "version": "1.11.1",
+ "version": "1.13.4",
"source": {
"type": "git",
"url": "https://github.com/myclabs/DeepCopy.git",
- "reference": "7284c22080590fb39f2ffa3e9057f10a4ddd0e0c"
+ "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/7284c22080590fb39f2ffa3e9057f10a4ddd0e0c",
- "reference": "7284c22080590fb39f2ffa3e9057f10a4ddd0e0c",
+ "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a",
+ "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a",
"shasum": ""
},
"require": {
@@ -661,11 +699,12 @@
},
"conflict": {
"doctrine/collections": "<1.6.8",
- "doctrine/common": "<2.13.3 || >=3,<3.2.2"
+ "doctrine/common": "<2.13.3 || >=3 <3.2.2"
},
"require-dev": {
"doctrine/collections": "^1.6.8",
"doctrine/common": "^2.13.3 || ^3.2.2",
+ "phpspec/prophecy": "^1.10",
"phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13"
},
"type": "library",
@@ -689,35 +728,41 @@
"object",
"object graph"
],
+ "support": {
+ "issues": "https://github.com/myclabs/DeepCopy/issues",
+ "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4"
+ },
"funding": [
{
"url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy",
"type": "tidelift"
}
],
- "time": "2023-03-08T13:26:56+00:00"
+ "time": "2025-08-01T08:46:24+00:00"
},
{
"name": "nikic/php-parser",
- "version": "v4.17.1",
+ "version": "v5.7.0",
"source": {
"type": "git",
"url": "https://github.com/nikic/PHP-Parser.git",
- "reference": "a6303e50c90c355c7eeee2c4a8b27fe8dc8fef1d"
+ "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/a6303e50c90c355c7eeee2c4a8b27fe8dc8fef1d",
- "reference": "a6303e50c90c355c7eeee2c4a8b27fe8dc8fef1d",
+ "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82",
+ "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82",
"shasum": ""
},
"require": {
+ "ext-ctype": "*",
+ "ext-json": "*",
"ext-tokenizer": "*",
- "php": ">=7.0"
+ "php": ">=7.4"
},
"require-dev": {
"ircmaxell/php-yacc": "^0.0.7",
- "phpunit/phpunit": "^6.5 || ^7.0 || ^8.0 || ^9.0"
+ "phpunit/phpunit": "^9.0"
},
"bin": [
"bin/php-parse"
@@ -725,7 +770,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "4.9-dev"
+ "dev-master": "5.x-dev"
}
},
"autoload": {
@@ -747,24 +792,29 @@
"parser",
"php"
],
- "time": "2023-08-13T19:53:39+00:00"
+ "support": {
+ "issues": "https://github.com/nikic/PHP-Parser/issues",
+ "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0"
+ },
+ "time": "2025-12-06T11:56:16+00:00"
},
{
"name": "phar-io/manifest",
- "version": "2.0.3",
+ "version": "2.0.4",
"source": {
"type": "git",
"url": "https://github.com/phar-io/manifest.git",
- "reference": "97803eca37d319dfa7826cc2437fc020857acb53"
+ "reference": "54750ef60c58e43759730615a392c31c80e23176"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phar-io/manifest/zipball/97803eca37d319dfa7826cc2437fc020857acb53",
- "reference": "97803eca37d319dfa7826cc2437fc020857acb53",
+ "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176",
+ "reference": "54750ef60c58e43759730615a392c31c80e23176",
"shasum": ""
},
"require": {
"ext-dom": "*",
+ "ext-libxml": "*",
"ext-phar": "*",
"ext-xmlwriter": "*",
"phar-io/version": "^3.0.1",
@@ -803,7 +853,17 @@
}
],
"description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)",
- "time": "2021-07-20T11:28:43+00:00"
+ "support": {
+ "issues": "https://github.com/phar-io/manifest/issues",
+ "source": "https://github.com/phar-io/manifest/tree/2.0.4"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/theseer",
+ "type": "github"
+ }
+ ],
+ "time": "2024-03-03T12:33:53+00:00"
},
{
"name": "phar-io/version",
@@ -850,36 +910,40 @@
}
],
"description": "Library for handling version information and constraints",
+ "support": {
+ "issues": "https://github.com/phar-io/version/issues",
+ "source": "https://github.com/phar-io/version/tree/3.2.1"
+ },
"time": "2022-02-21T01:04:05+00:00"
},
{
"name": "php-coveralls/php-coveralls",
- "version": "v2.7.0",
+ "version": "v2.9.1",
"source": {
"type": "git",
"url": "https://github.com/php-coveralls/php-coveralls.git",
- "reference": "b36fa4394e519dafaddc04ae03976bc65a25ba15"
+ "reference": "916bdb118597f61ce6715fb738ab8f234b89a2cb"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/php-coveralls/php-coveralls/zipball/b36fa4394e519dafaddc04ae03976bc65a25ba15",
- "reference": "b36fa4394e519dafaddc04ae03976bc65a25ba15",
+ "url": "https://api.github.com/repos/php-coveralls/php-coveralls/zipball/916bdb118597f61ce6715fb738ab8f234b89a2cb",
+ "reference": "916bdb118597f61ce6715fb738ab8f234b89a2cb",
"shasum": ""
},
"require": {
"ext-json": "*",
"ext-simplexml": "*",
"guzzlehttp/guzzle": "^6.0 || ^7.0",
- "php": "^7.0 || ^8.0",
- "psr/log": "^1.0 || ^2.0",
- "symfony/config": "^2.1 || ^3.0 || ^4.0 || ^5.0 || ^6.0 || ^7.0",
- "symfony/console": "^2.1 || ^3.0 || ^4.0 || ^5.0 || ^6.0 || ^7.0",
- "symfony/stopwatch": "^2.0 || ^3.0 || ^4.0 || ^5.0 || ^6.0 || ^7.0",
- "symfony/yaml": "^2.0.5 || ^3.0 || ^4.0 || ^5.0 || ^6.0 || ^7.0"
+ "php": "^7.4 || ^8.0",
+ "psr/log": "^1.0 || ^2.0 || ^3.0",
+ "symfony/config": "^5.4 || ^6.4 || ^7.0 || ^8.0",
+ "symfony/console": "^5.4 || ^6.4 || ^7.0 || ^8.0",
+ "symfony/stopwatch": "^5.4 || ^6.4 || ^7.0 || ^8.0",
+ "symfony/yaml": "^5.4 || ^6.4 || ^7.0 || ^8.0"
},
"require-dev": {
- "phpunit/phpunit": "^4.8.35 || ^5.4.3 || ^6.0 || ^7.0 || >=8.0 <8.5.29 || >=9.0 <9.5.23",
- "sanmai/phpunit-legacy-adapter": "^6.1 || ^8.0"
+ "phpspec/prophecy-phpunit": "^2.4",
+ "phpunit/phpunit": "^9.6.29 || ^10.5.58 || ^11.5.43"
},
"suggest": {
"symfony/http-kernel": "Allows Symfony integration"
@@ -929,39 +993,150 @@
"github",
"test"
],
- "time": "2023-11-22T10:21:01+00:00"
+ "support": {
+ "issues": "https://github.com/php-coveralls/php-coveralls/issues",
+ "source": "https://github.com/php-coveralls/php-coveralls/tree/v2.9.1"
+ },
+ "time": "2025-12-18T13:08:37+00:00"
+ },
+ {
+ "name": "phpstan/phpstan",
+ "version": "2.1.51",
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phpstan/phpstan/zipball/dc3b523c45e714c70de2ac5113b958223b55dc59",
+ "reference": "dc3b523c45e714c70de2ac5113b958223b55dc59",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.4|^8.0"
+ },
+ "conflict": {
+ "phpstan/phpstan-shim": "*"
+ },
+ "bin": [
+ "phpstan",
+ "phpstan.phar"
+ ],
+ "type": "library",
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "PHPStan - PHP Static Analysis Tool",
+ "keywords": [
+ "dev",
+ "static analysis"
+ ],
+ "support": {
+ "docs": "https://phpstan.org/user-guide/getting-started",
+ "forum": "https://github.com/phpstan/phpstan/discussions",
+ "issues": "https://github.com/phpstan/phpstan/issues",
+ "security": "https://github.com/phpstan/phpstan/security/policy",
+ "source": "https://github.com/phpstan/phpstan-src"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/ondrejmirtes",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/phpstan",
+ "type": "github"
+ }
+ ],
+ "time": "2026-04-21T18:22:01+00:00"
+ },
+ {
+ "name": "phpstan/phpstan-phpunit",
+ "version": "2.0.16",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phpstan/phpstan-phpunit.git",
+ "reference": "6ab598e1bc106e6827fd346ae4a12b4a5d634c32"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/6ab598e1bc106e6827fd346ae4a12b4a5d634c32",
+ "reference": "6ab598e1bc106e6827fd346ae4a12b4a5d634c32",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.4 || ^8.0",
+ "phpstan/phpstan": "^2.1.32"
+ },
+ "conflict": {
+ "phpunit/phpunit": "<7.0"
+ },
+ "require-dev": {
+ "nikic/php-parser": "^5",
+ "php-parallel-lint/php-parallel-lint": "^1.2",
+ "phpstan/phpstan-deprecation-rules": "^2.0",
+ "phpstan/phpstan-strict-rules": "^2.0",
+ "phpunit/phpunit": "^9.6"
+ },
+ "type": "phpstan-extension",
+ "extra": {
+ "phpstan": {
+ "includes": [
+ "extension.neon",
+ "rules.neon"
+ ]
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "PHPStan\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "PHPUnit extensions and rules for PHPStan",
+ "keywords": [
+ "static analysis"
+ ],
+ "support": {
+ "issues": "https://github.com/phpstan/phpstan-phpunit/issues",
+ "source": "https://github.com/phpstan/phpstan-phpunit/tree/2.0.16"
+ },
+ "time": "2026-02-14T09:05:21+00:00"
},
{
"name": "phpunit/php-code-coverage",
- "version": "9.2.29",
+ "version": "12.5.6",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/php-code-coverage.git",
- "reference": "6a3a87ac2bbe33b25042753df8195ba4aa534c76"
+ "reference": "876099a072646c7745f673d7aeab5382c4439691"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/6a3a87ac2bbe33b25042753df8195ba4aa534c76",
- "reference": "6a3a87ac2bbe33b25042753df8195ba4aa534c76",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/876099a072646c7745f673d7aeab5382c4439691",
+ "reference": "876099a072646c7745f673d7aeab5382c4439691",
"shasum": ""
},
"require": {
"ext-dom": "*",
"ext-libxml": "*",
"ext-xmlwriter": "*",
- "nikic/php-parser": "^4.15",
- "php": ">=7.3",
- "phpunit/php-file-iterator": "^3.0.3",
- "phpunit/php-text-template": "^2.0.2",
- "sebastian/code-unit-reverse-lookup": "^2.0.2",
- "sebastian/complexity": "^2.0",
- "sebastian/environment": "^5.1.2",
- "sebastian/lines-of-code": "^1.0.3",
- "sebastian/version": "^3.0.1",
- "theseer/tokenizer": "^1.2.0"
+ "nikic/php-parser": "^5.7.0",
+ "php": ">=8.3",
+ "phpunit/php-text-template": "^5.0",
+ "sebastian/complexity": "^5.0",
+ "sebastian/environment": "^8.0.3",
+ "sebastian/lines-of-code": "^4.0",
+ "sebastian/version": "^6.0",
+ "theseer/tokenizer": "^2.0.1"
},
"require-dev": {
- "phpunit/phpunit": "^9.3"
+ "phpunit/phpunit": "^12.5.1"
},
"suggest": {
"ext-pcov": "PHP extension that provides line coverage",
@@ -970,7 +1145,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "9.2-dev"
+ "dev-main": "12.5.x-dev"
}
},
"autoload": {
@@ -996,38 +1171,55 @@
"testing",
"xunit"
],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues",
+ "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy",
+ "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.6"
+ },
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage",
+ "type": "tidelift"
}
],
- "time": "2023-09-19T04:57:46+00:00"
+ "time": "2026-04-15T08:23:17+00:00"
},
{
"name": "phpunit/php-file-iterator",
- "version": "3.0.6",
+ "version": "6.0.1",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/php-file-iterator.git",
- "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf"
+ "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf",
- "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5",
+ "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5",
"shasum": ""
},
"require": {
- "php": ">=7.3"
+ "php": ">=8.3"
},
"require-dev": {
- "phpunit/phpunit": "^9.3"
+ "phpunit/phpunit": "^12.0"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "3.0-dev"
+ "dev-main": "6.0-dev"
}
},
"autoload": {
@@ -1052,34 +1244,51 @@
"filesystem",
"iterator"
],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues",
+ "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy",
+ "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.1"
+ },
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator",
+ "type": "tidelift"
}
],
- "time": "2021-12-02T12:48:52+00:00"
+ "time": "2026-02-02T14:04:18+00:00"
},
{
"name": "phpunit/php-invoker",
- "version": "3.1.1",
+ "version": "6.0.0",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/php-invoker.git",
- "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67"
+ "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/5a10147d0aaf65b58940a0b72f71c9ac0423cc67",
- "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/12b54e689b07a25a9b41e57736dfab6ec9ae5406",
+ "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406",
"shasum": ""
},
"require": {
- "php": ">=7.3"
+ "php": ">=8.3"
},
"require-dev": {
"ext-pcntl": "*",
- "phpunit/phpunit": "^9.3"
+ "phpunit/phpunit": "^12.0"
},
"suggest": {
"ext-pcntl": "*"
@@ -1087,7 +1296,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "3.1-dev"
+ "dev-main": "6.0-dev"
}
},
"autoload": {
@@ -1111,38 +1320,43 @@
"keywords": [
"process"
],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-invoker/issues",
+ "security": "https://github.com/sebastianbergmann/php-invoker/security/policy",
+ "source": "https://github.com/sebastianbergmann/php-invoker/tree/6.0.0"
+ },
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
}
],
- "time": "2020-09-28T05:58:55+00:00"
+ "time": "2025-02-07T04:58:58+00:00"
},
{
"name": "phpunit/php-text-template",
- "version": "2.0.4",
+ "version": "5.0.0",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/php-text-template.git",
- "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28"
+ "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28",
- "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/e1367a453f0eda562eedb4f659e13aa900d66c53",
+ "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53",
"shasum": ""
},
"require": {
- "php": ">=7.3"
+ "php": ">=8.3"
},
"require-dev": {
- "phpunit/phpunit": "^9.3"
+ "phpunit/phpunit": "^12.0"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "2.0-dev"
+ "dev-main": "5.0-dev"
}
},
"autoload": {
@@ -1166,38 +1380,43 @@
"keywords": [
"template"
],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-text-template/issues",
+ "security": "https://github.com/sebastianbergmann/php-text-template/security/policy",
+ "source": "https://github.com/sebastianbergmann/php-text-template/tree/5.0.0"
+ },
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
}
],
- "time": "2020-10-26T05:33:50+00:00"
+ "time": "2025-02-07T04:59:16+00:00"
},
{
"name": "phpunit/php-timer",
- "version": "5.0.3",
+ "version": "8.0.0",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/php-timer.git",
- "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2"
+ "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2",
- "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc",
+ "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc",
"shasum": ""
},
"require": {
- "php": ">=7.3"
+ "php": ">=8.3"
},
"require-dev": {
- "phpunit/phpunit": "^9.3"
+ "phpunit/phpunit": "^12.0"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "5.0-dev"
+ "dev-main": "8.0-dev"
}
},
"autoload": {
@@ -1221,60 +1440,60 @@
"keywords": [
"timer"
],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-timer/issues",
+ "security": "https://github.com/sebastianbergmann/php-timer/security/policy",
+ "source": "https://github.com/sebastianbergmann/php-timer/tree/8.0.0"
+ },
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
}
],
- "time": "2020-10-26T13:16:10+00:00"
+ "time": "2025-02-07T04:59:38+00:00"
},
{
"name": "phpunit/phpunit",
- "version": "9.6.15",
+ "version": "12.5.23",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git",
- "reference": "05017b80304e0eb3f31d90194a563fd53a6021f1"
+ "reference": "c54fcf3d6bcb6e96ac2f7e40097dc37b5f139969"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/05017b80304e0eb3f31d90194a563fd53a6021f1",
- "reference": "05017b80304e0eb3f31d90194a563fd53a6021f1",
+ "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/c54fcf3d6bcb6e96ac2f7e40097dc37b5f139969",
+ "reference": "c54fcf3d6bcb6e96ac2f7e40097dc37b5f139969",
"shasum": ""
},
"require": {
- "doctrine/instantiator": "^1.3.1 || ^2",
"ext-dom": "*",
"ext-json": "*",
"ext-libxml": "*",
"ext-mbstring": "*",
"ext-xml": "*",
"ext-xmlwriter": "*",
- "myclabs/deep-copy": "^1.10.1",
- "phar-io/manifest": "^2.0.3",
- "phar-io/version": "^3.0.2",
- "php": ">=7.3",
- "phpunit/php-code-coverage": "^9.2.28",
- "phpunit/php-file-iterator": "^3.0.5",
- "phpunit/php-invoker": "^3.1.1",
- "phpunit/php-text-template": "^2.0.3",
- "phpunit/php-timer": "^5.0.2",
- "sebastian/cli-parser": "^1.0.1",
- "sebastian/code-unit": "^1.0.6",
- "sebastian/comparator": "^4.0.8",
- "sebastian/diff": "^4.0.3",
- "sebastian/environment": "^5.1.3",
- "sebastian/exporter": "^4.0.5",
- "sebastian/global-state": "^5.0.1",
- "sebastian/object-enumerator": "^4.0.3",
- "sebastian/resource-operations": "^3.0.3",
- "sebastian/type": "^3.2",
- "sebastian/version": "^3.0.2"
- },
- "suggest": {
- "ext-soap": "To be able to generate mocks based on WSDL files",
- "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage"
+ "myclabs/deep-copy": "^1.13.4",
+ "phar-io/manifest": "^2.0.4",
+ "phar-io/version": "^3.2.1",
+ "php": ">=8.3",
+ "phpunit/php-code-coverage": "^12.5.6",
+ "phpunit/php-file-iterator": "^6.0.1",
+ "phpunit/php-invoker": "^6.0.0",
+ "phpunit/php-text-template": "^5.0.0",
+ "phpunit/php-timer": "^8.0.0",
+ "sebastian/cli-parser": "^4.2.0",
+ "sebastian/comparator": "^7.1.6",
+ "sebastian/diff": "^7.0.0",
+ "sebastian/environment": "^8.1.0",
+ "sebastian/exporter": "^7.0.2",
+ "sebastian/global-state": "^8.0.2",
+ "sebastian/object-enumerator": "^7.0.0",
+ "sebastian/recursion-context": "^7.0.1",
+ "sebastian/type": "^6.0.3",
+ "sebastian/version": "^6.0.0",
+ "staabm/side-effects-detector": "^1.0.5"
},
"bin": [
"phpunit"
@@ -1282,7 +1501,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "9.6-dev"
+ "dev-main": "12.5-dev"
}
},
"autoload": {
@@ -1311,40 +1530,42 @@
"testing",
"xunit"
],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/phpunit/issues",
+ "security": "https://github.com/sebastianbergmann/phpunit/security/policy",
+ "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.23"
+ },
"funding": [
{
- "url": "https://phpunit.de/sponsors.html",
- "type": "custom"
- },
- {
- "url": "https://github.com/sebastianbergmann",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit",
- "type": "tidelift"
+ "url": "https://phpunit.de/sponsoring.html",
+ "type": "other"
}
],
- "time": "2023-12-01T16:55:19+00:00"
+ "time": "2026-04-18T06:12:49+00:00"
},
{
"name": "psr/container",
- "version": "1.1.1",
+ "version": "2.0.2",
"source": {
"type": "git",
"url": "https://github.com/php-fig/container.git",
- "reference": "8622567409010282b7aeebe4bb841fe98b58dcaf"
+ "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/php-fig/container/zipball/8622567409010282b7aeebe4bb841fe98b58dcaf",
- "reference": "8622567409010282b7aeebe4bb841fe98b58dcaf",
+ "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963",
+ "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963",
"shasum": ""
},
"require": {
- "php": ">=7.2.0"
+ "php": ">=7.4.0"
},
"type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0.x-dev"
+ }
+ },
"autoload": {
"psr-4": {
"Psr\\Container\\": "src/"
@@ -1369,34 +1590,38 @@
"container-interop",
"psr"
],
- "time": "2021-03-05T17:36:06+00:00"
+ "support": {
+ "issues": "https://github.com/php-fig/container/issues",
+ "source": "https://github.com/php-fig/container/tree/2.0.2"
+ },
+ "time": "2021-11-05T16:47:00+00:00"
},
{
"name": "psr/log",
- "version": "1.1.4",
+ "version": "3.0.2",
"source": {
"type": "git",
"url": "https://github.com/php-fig/log.git",
- "reference": "d49695b909c3b7628b6289db5479a1c204601f11"
+ "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11",
- "reference": "d49695b909c3b7628b6289db5479a1c204601f11",
+ "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
+ "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
"shasum": ""
},
"require": {
- "php": ">=5.3.0"
+ "php": ">=8.0.0"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "1.1.x-dev"
+ "dev-master": "3.x-dev"
}
},
"autoload": {
"psr-4": {
- "Psr\\Log\\": "Psr/Log/"
+ "Psr\\Log\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
@@ -1416,136 +1641,139 @@
"psr",
"psr-3"
],
- "time": "2021-05-03T11:20:27+00:00"
+ "support": {
+ "source": "https://github.com/php-fig/log/tree/3.0.2"
+ },
+ "time": "2024-09-11T13:17:53+00:00"
},
{
- "name": "sebastian/cli-parser",
- "version": "1.0.1",
+ "name": "ralouphie/getallheaders",
+ "version": "3.0.3",
"source": {
"type": "git",
- "url": "https://github.com/sebastianbergmann/cli-parser.git",
- "reference": "442e7c7e687e42adc03470c7b668bc4b2402c0b2"
+ "url": "https://github.com/ralouphie/getallheaders.git",
+ "reference": "120b605dfeb996808c31b6477290a714d356e822"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/442e7c7e687e42adc03470c7b668bc4b2402c0b2",
- "reference": "442e7c7e687e42adc03470c7b668bc4b2402c0b2",
+ "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822",
+ "reference": "120b605dfeb996808c31b6477290a714d356e822",
"shasum": ""
},
"require": {
- "php": ">=7.3"
+ "php": ">=5.6"
},
"require-dev": {
- "phpunit/phpunit": "^9.3"
+ "php-coveralls/php-coveralls": "^2.1",
+ "phpunit/phpunit": "^5 || ^6.5"
},
"type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "1.0-dev"
- }
- },
"autoload": {
- "classmap": [
- "src/"
+ "files": [
+ "src/getallheaders.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
- "BSD-3-Clause"
- ],
- "authors": [
- {
- "name": "Sebastian Bergmann",
- "email": "sebastian@phpunit.de",
- "role": "lead"
- }
+ "MIT"
],
- "description": "Library for parsing CLI options",
- "homepage": "https://github.com/sebastianbergmann/cli-parser",
- "funding": [
+ "authors": [
{
- "url": "https://github.com/sebastianbergmann",
- "type": "github"
+ "name": "Ralph Khattar",
+ "email": "ralph.khattar@gmail.com"
}
],
- "time": "2020-09-28T06:08:49+00:00"
+ "description": "A polyfill for getallheaders.",
+ "support": {
+ "issues": "https://github.com/ralouphie/getallheaders/issues",
+ "source": "https://github.com/ralouphie/getallheaders/tree/develop"
+ },
+ "time": "2019-03-08T08:55:37+00:00"
},
{
- "name": "sebastian/code-unit",
- "version": "1.0.8",
+ "name": "rector/rector",
+ "version": "2.4.2",
"source": {
"type": "git",
- "url": "https://github.com/sebastianbergmann/code-unit.git",
- "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120"
+ "url": "https://github.com/rectorphp/rector.git",
+ "reference": "e645b6463c6a88ea5b44b17d3387d35a912c7946"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/1fc9f64c0927627ef78ba436c9b17d967e68e120",
- "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120",
+ "url": "https://api.github.com/repos/rectorphp/rector/zipball/e645b6463c6a88ea5b44b17d3387d35a912c7946",
+ "reference": "e645b6463c6a88ea5b44b17d3387d35a912c7946",
"shasum": ""
},
"require": {
- "php": ">=7.3"
+ "php": "^7.4|^8.0",
+ "phpstan/phpstan": "^2.1.48"
},
- "require-dev": {
- "phpunit/phpunit": "^9.3"
+ "conflict": {
+ "rector/rector-doctrine": "*",
+ "rector/rector-downgrade-php": "*",
+ "rector/rector-phpunit": "*",
+ "rector/rector-symfony": "*"
},
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "1.0-dev"
- }
+ "suggest": {
+ "ext-dom": "To manipulate phpunit.xml via the custom-rule command"
},
+ "bin": [
+ "bin/rector"
+ ],
+ "type": "library",
"autoload": {
- "classmap": [
- "src/"
+ "files": [
+ "bootstrap.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
- "BSD-3-Clause"
+ "MIT"
],
- "authors": [
- {
- "name": "Sebastian Bergmann",
- "email": "sebastian@phpunit.de",
- "role": "lead"
- }
+ "description": "Instant Upgrade and Automated Refactoring of any PHP code",
+ "homepage": "https://getrector.com/",
+ "keywords": [
+ "automation",
+ "dev",
+ "migration",
+ "refactoring"
],
- "description": "Collection of value objects that represent the PHP code units",
- "homepage": "https://github.com/sebastianbergmann/code-unit",
+ "support": {
+ "issues": "https://github.com/rectorphp/rector/issues",
+ "source": "https://github.com/rectorphp/rector/tree/2.4.2"
+ },
"funding": [
{
- "url": "https://github.com/sebastianbergmann",
+ "url": "https://github.com/tomasvotruba",
"type": "github"
}
],
- "time": "2020-10-26T13:08:54+00:00"
+ "time": "2026-04-16T13:07:34+00:00"
},
{
- "name": "sebastian/code-unit-reverse-lookup",
- "version": "2.0.3",
+ "name": "sebastian/cli-parser",
+ "version": "4.2.0",
"source": {
"type": "git",
- "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git",
- "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5"
+ "url": "https://github.com/sebastianbergmann/cli-parser.git",
+ "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5",
- "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5",
+ "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/90f41072d220e5c40df6e8635f5dafba2d9d4d04",
+ "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04",
"shasum": ""
},
"require": {
- "php": ">=7.3"
+ "php": ">=8.3"
},
"require-dev": {
- "phpunit/phpunit": "^9.3"
+ "phpunit/phpunit": "^12.0"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "2.0-dev"
+ "dev-main": "4.2-dev"
}
},
"autoload": {
@@ -1560,45 +1788,68 @@
"authors": [
{
"name": "Sebastian Bergmann",
- "email": "sebastian@phpunit.de"
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
}
],
- "description": "Looks up which function or method a line of code belongs to",
- "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/",
+ "description": "Library for parsing CLI options",
+ "homepage": "https://github.com/sebastianbergmann/cli-parser",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/cli-parser/issues",
+ "security": "https://github.com/sebastianbergmann/cli-parser/security/policy",
+ "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.0"
+ },
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser",
+ "type": "tidelift"
}
],
- "time": "2020-09-28T05:30:19+00:00"
+ "time": "2025-09-14T09:36:45+00:00"
},
{
"name": "sebastian/comparator",
- "version": "4.0.8",
+ "version": "7.1.6",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/comparator.git",
- "reference": "fa0f136dd2334583309d32b62544682ee972b51a"
+ "reference": "c769009dee98f494e0edc3fd4f4087501688f11e"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/fa0f136dd2334583309d32b62544682ee972b51a",
- "reference": "fa0f136dd2334583309d32b62544682ee972b51a",
+ "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/c769009dee98f494e0edc3fd4f4087501688f11e",
+ "reference": "c769009dee98f494e0edc3fd4f4087501688f11e",
"shasum": ""
},
"require": {
- "php": ">=7.3",
- "sebastian/diff": "^4.0",
- "sebastian/exporter": "^4.0"
+ "ext-dom": "*",
+ "ext-mbstring": "*",
+ "php": ">=8.3",
+ "sebastian/diff": "^7.0",
+ "sebastian/exporter": "^7.0"
},
"require-dev": {
- "phpunit/phpunit": "^9.3"
+ "phpunit/phpunit": "^12.2"
+ },
+ "suggest": {
+ "ext-bcmath": "For comparing BcMath\\Number objects"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "4.0-dev"
+ "dev-main": "7.1-dev"
}
},
"autoload": {
@@ -1635,39 +1886,56 @@
"compare",
"equality"
],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/comparator/issues",
+ "security": "https://github.com/sebastianbergmann/comparator/security/policy",
+ "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.6"
+ },
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator",
+ "type": "tidelift"
}
],
- "time": "2022-09-14T12:41:17+00:00"
+ "time": "2026-04-14T08:23:15+00:00"
},
{
"name": "sebastian/complexity",
- "version": "2.0.2",
+ "version": "5.0.0",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/complexity.git",
- "reference": "739b35e53379900cc9ac327b2147867b8b6efd88"
+ "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/739b35e53379900cc9ac327b2147867b8b6efd88",
- "reference": "739b35e53379900cc9ac327b2147867b8b6efd88",
+ "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/bad4316aba5303d0221f43f8cee37eb58d384bbb",
+ "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb",
"shasum": ""
},
"require": {
- "nikic/php-parser": "^4.7",
- "php": ">=7.3"
+ "nikic/php-parser": "^5.0",
+ "php": ">=8.3"
},
"require-dev": {
- "phpunit/phpunit": "^9.3"
+ "phpunit/phpunit": "^12.0"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "2.0-dev"
+ "dev-main": "5.0-dev"
}
},
"autoload": {
@@ -1688,39 +1956,44 @@
],
"description": "Library for calculating the complexity of PHP code units",
"homepage": "https://github.com/sebastianbergmann/complexity",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/complexity/issues",
+ "security": "https://github.com/sebastianbergmann/complexity/security/policy",
+ "source": "https://github.com/sebastianbergmann/complexity/tree/5.0.0"
+ },
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
}
],
- "time": "2020-10-26T15:52:27+00:00"
+ "time": "2025-02-07T04:55:25+00:00"
},
{
"name": "sebastian/diff",
- "version": "4.0.5",
+ "version": "7.0.0",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/diff.git",
- "reference": "74be17022044ebaaecfdf0c5cd504fc9cd5a7131"
+ "reference": "7ab1ea946c012266ca32390913653d844ecd085f"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/74be17022044ebaaecfdf0c5cd504fc9cd5a7131",
- "reference": "74be17022044ebaaecfdf0c5cd504fc9cd5a7131",
+ "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/7ab1ea946c012266ca32390913653d844ecd085f",
+ "reference": "7ab1ea946c012266ca32390913653d844ecd085f",
"shasum": ""
},
"require": {
- "php": ">=7.3"
+ "php": ">=8.3"
},
"require-dev": {
- "phpunit/phpunit": "^9.3",
- "symfony/process": "^4.2 || ^5"
+ "phpunit/phpunit": "^12.0",
+ "symfony/process": "^7.2"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "4.0-dev"
+ "dev-main": "7.0-dev"
}
},
"autoload": {
@@ -1750,33 +2023,38 @@
"unidiff",
"unified diff"
],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/diff/issues",
+ "security": "https://github.com/sebastianbergmann/diff/security/policy",
+ "source": "https://github.com/sebastianbergmann/diff/tree/7.0.0"
+ },
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
}
],
- "time": "2023-05-07T05:35:17+00:00"
+ "time": "2025-02-07T04:55:46+00:00"
},
{
"name": "sebastian/environment",
- "version": "5.1.5",
+ "version": "8.1.0",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/environment.git",
- "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed"
+ "reference": "b121608b28a13f721e76ffbbd386d08eff58f3f6"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/830c43a844f1f8d5b7a1f6d6076b784454d8b7ed",
- "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed",
+ "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/b121608b28a13f721e76ffbbd386d08eff58f3f6",
+ "reference": "b121608b28a13f721e76ffbbd386d08eff58f3f6",
"shasum": ""
},
"require": {
- "php": ">=7.3"
+ "php": ">=8.3"
},
"require-dev": {
- "phpunit/phpunit": "^9.3"
+ "phpunit/phpunit": "^12.0"
},
"suggest": {
"ext-posix": "*"
@@ -1784,7 +2062,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "5.1-dev"
+ "dev-main": "8.1-dev"
}
},
"autoload": {
@@ -1803,46 +2081,63 @@
}
],
"description": "Provides functionality to handle HHVM/PHP environments",
- "homepage": "http://www.github.com/sebastianbergmann/environment",
+ "homepage": "https://github.com/sebastianbergmann/environment",
"keywords": [
"Xdebug",
"environment",
"hhvm"
],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/environment/issues",
+ "security": "https://github.com/sebastianbergmann/environment/security/policy",
+ "source": "https://github.com/sebastianbergmann/environment/tree/8.1.0"
+ },
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/environment",
+ "type": "tidelift"
}
],
- "time": "2023-02-03T06:03:51+00:00"
+ "time": "2026-04-15T12:13:01+00:00"
},
{
"name": "sebastian/exporter",
- "version": "4.0.5",
+ "version": "7.0.2",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/exporter.git",
- "reference": "ac230ed27f0f98f597c8a2b6eb7ac563af5e5b9d"
+ "reference": "016951ae10980765e4e7aee491eb288c64e505b7"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/ac230ed27f0f98f597c8a2b6eb7ac563af5e5b9d",
- "reference": "ac230ed27f0f98f597c8a2b6eb7ac563af5e5b9d",
+ "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/016951ae10980765e4e7aee491eb288c64e505b7",
+ "reference": "016951ae10980765e4e7aee491eb288c64e505b7",
"shasum": ""
},
"require": {
- "php": ">=7.3",
- "sebastian/recursion-context": "^4.0"
+ "ext-mbstring": "*",
+ "php": ">=8.3",
+ "sebastian/recursion-context": "^7.0"
},
"require-dev": {
- "ext-mbstring": "*",
- "phpunit/phpunit": "^9.3"
+ "phpunit/phpunit": "^12.0"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "4.0-dev"
+ "dev-main": "7.0-dev"
}
},
"autoload": {
@@ -1882,44 +2177,58 @@
"export",
"exporter"
],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/exporter/issues",
+ "security": "https://github.com/sebastianbergmann/exporter/security/policy",
+ "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.2"
+ },
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter",
+ "type": "tidelift"
}
],
- "time": "2022-09-14T06:03:37+00:00"
+ "time": "2025-09-24T06:16:11+00:00"
},
{
"name": "sebastian/global-state",
- "version": "5.0.6",
+ "version": "8.0.2",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/global-state.git",
- "reference": "bde739e7565280bda77be70044ac1047bc007e34"
+ "reference": "ef1377171613d09edd25b7816f05be8313f9115d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/bde739e7565280bda77be70044ac1047bc007e34",
- "reference": "bde739e7565280bda77be70044ac1047bc007e34",
+ "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/ef1377171613d09edd25b7816f05be8313f9115d",
+ "reference": "ef1377171613d09edd25b7816f05be8313f9115d",
"shasum": ""
},
"require": {
- "php": ">=7.3",
- "sebastian/object-reflector": "^2.0",
- "sebastian/recursion-context": "^4.0"
+ "php": ">=8.3",
+ "sebastian/object-reflector": "^5.0",
+ "sebastian/recursion-context": "^7.0"
},
"require-dev": {
"ext-dom": "*",
- "phpunit/phpunit": "^9.3"
- },
- "suggest": {
- "ext-uopz": "*"
+ "phpunit/phpunit": "^12.0"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "5.0-dev"
+ "dev-main": "8.0-dev"
}
},
"autoload": {
@@ -1938,43 +2247,60 @@
}
],
"description": "Snapshotting of global state",
- "homepage": "http://www.github.com/sebastianbergmann/global-state",
+ "homepage": "https://www.github.com/sebastianbergmann/global-state",
"keywords": [
"global state"
],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/global-state/issues",
+ "security": "https://github.com/sebastianbergmann/global-state/security/policy",
+ "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.2"
+ },
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state",
+ "type": "tidelift"
}
],
- "time": "2023-08-02T09:26:13+00:00"
+ "time": "2025-08-29T11:29:25+00:00"
},
{
"name": "sebastian/lines-of-code",
- "version": "1.0.3",
+ "version": "4.0.0",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/lines-of-code.git",
- "reference": "c1c2e997aa3146983ed888ad08b15470a2e22ecc"
+ "reference": "97ffee3bcfb5805568d6af7f0f893678fc076d2f"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/c1c2e997aa3146983ed888ad08b15470a2e22ecc",
- "reference": "c1c2e997aa3146983ed888ad08b15470a2e22ecc",
+ "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/97ffee3bcfb5805568d6af7f0f893678fc076d2f",
+ "reference": "97ffee3bcfb5805568d6af7f0f893678fc076d2f",
"shasum": ""
},
"require": {
- "nikic/php-parser": "^4.6",
- "php": ">=7.3"
+ "nikic/php-parser": "^5.0",
+ "php": ">=8.3"
},
"require-dev": {
- "phpunit/phpunit": "^9.3"
+ "phpunit/phpunit": "^12.0"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "1.0-dev"
+ "dev-main": "4.0-dev"
}
},
"autoload": {
@@ -1995,40 +2321,45 @@
],
"description": "Library for counting the lines of code in PHP source code",
"homepage": "https://github.com/sebastianbergmann/lines-of-code",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/lines-of-code/issues",
+ "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy",
+ "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.0"
+ },
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
}
],
- "time": "2020-11-28T06:42:11+00:00"
+ "time": "2025-02-07T04:57:28+00:00"
},
{
"name": "sebastian/object-enumerator",
- "version": "4.0.4",
+ "version": "7.0.0",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/object-enumerator.git",
- "reference": "5c9eeac41b290a3712d88851518825ad78f45c71"
+ "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/5c9eeac41b290a3712d88851518825ad78f45c71",
- "reference": "5c9eeac41b290a3712d88851518825ad78f45c71",
+ "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/1effe8e9b8e068e9ae228e542d5d11b5d16db894",
+ "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894",
"shasum": ""
},
"require": {
- "php": ">=7.3",
- "sebastian/object-reflector": "^2.0",
- "sebastian/recursion-context": "^4.0"
+ "php": ">=8.3",
+ "sebastian/object-reflector": "^5.0",
+ "sebastian/recursion-context": "^7.0"
},
"require-dev": {
- "phpunit/phpunit": "^9.3"
+ "phpunit/phpunit": "^12.0"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "4.0-dev"
+ "dev-main": "7.0-dev"
}
},
"autoload": {
@@ -2048,38 +2379,43 @@
],
"description": "Traverses array structures and object graphs to enumerate all referenced objects",
"homepage": "https://github.com/sebastianbergmann/object-enumerator/",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/object-enumerator/issues",
+ "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy",
+ "source": "https://github.com/sebastianbergmann/object-enumerator/tree/7.0.0"
+ },
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
}
],
- "time": "2020-10-26T13:12:34+00:00"
+ "time": "2025-02-07T04:57:48+00:00"
},
{
"name": "sebastian/object-reflector",
- "version": "2.0.4",
+ "version": "5.0.0",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/object-reflector.git",
- "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7"
+ "reference": "4bfa827c969c98be1e527abd576533293c634f6a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/b4f479ebdbf63ac605d183ece17d8d7fe49c15c7",
- "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7",
+ "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/4bfa827c969c98be1e527abd576533293c634f6a",
+ "reference": "4bfa827c969c98be1e527abd576533293c634f6a",
"shasum": ""
},
"require": {
- "php": ">=7.3"
+ "php": ">=8.3"
},
"require-dev": {
- "phpunit/phpunit": "^9.3"
+ "phpunit/phpunit": "^12.0"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "2.0-dev"
+ "dev-main": "5.0-dev"
}
},
"autoload": {
@@ -2099,38 +2435,43 @@
],
"description": "Allows reflection of object attributes, including inherited and non-public ones",
"homepage": "https://github.com/sebastianbergmann/object-reflector/",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/object-reflector/issues",
+ "security": "https://github.com/sebastianbergmann/object-reflector/security/policy",
+ "source": "https://github.com/sebastianbergmann/object-reflector/tree/5.0.0"
+ },
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
}
],
- "time": "2020-10-26T13:14:26+00:00"
+ "time": "2025-02-07T04:58:17+00:00"
},
{
"name": "sebastian/recursion-context",
- "version": "4.0.5",
+ "version": "7.0.1",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/recursion-context.git",
- "reference": "e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1"
+ "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1",
- "reference": "e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1",
+ "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/0b01998a7d5b1f122911a66bebcb8d46f0c82d8c",
+ "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c",
"shasum": ""
},
"require": {
- "php": ">=7.3"
+ "php": ">=8.3"
},
"require-dev": {
- "phpunit/phpunit": "^9.3"
+ "phpunit/phpunit": "^12.0"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "4.0-dev"
+ "dev-main": "7.0-dev"
}
},
"autoload": {
@@ -2158,89 +2499,55 @@
],
"description": "Provides functionality to recursively process PHP variables",
"homepage": "https://github.com/sebastianbergmann/recursion-context",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/recursion-context/issues",
+ "security": "https://github.com/sebastianbergmann/recursion-context/security/policy",
+ "source": "https://github.com/sebastianbergmann/recursion-context/tree/7.0.1"
+ },
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
- }
- ],
- "time": "2023-02-03T06:07:39+00:00"
- },
- {
- "name": "sebastian/resource-operations",
- "version": "3.0.3",
- "source": {
- "type": "git",
- "url": "https://github.com/sebastianbergmann/resource-operations.git",
- "reference": "0f4443cb3a1d92ce809899753bc0d5d5a8dd19a8"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/0f4443cb3a1d92ce809899753bc0d5d5a8dd19a8",
- "reference": "0f4443cb3a1d92ce809899753bc0d5d5a8dd19a8",
- "shasum": ""
- },
- "require": {
- "php": ">=7.3"
- },
- "require-dev": {
- "phpunit/phpunit": "^9.0"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "3.0-dev"
- }
- },
- "autoload": {
- "classmap": [
- "src/"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "BSD-3-Clause"
- ],
- "authors": [
+ },
{
- "name": "Sebastian Bergmann",
- "email": "sebastian@phpunit.de"
- }
- ],
- "description": "Provides a list of PHP built-in functions that operate on resources",
- "homepage": "https://www.github.com/sebastianbergmann/resource-operations",
- "funding": [
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
{
- "url": "https://github.com/sebastianbergmann",
- "type": "github"
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context",
+ "type": "tidelift"
}
],
- "time": "2020-09-28T06:45:17+00:00"
+ "time": "2025-08-13T04:44:59+00:00"
},
{
"name": "sebastian/type",
- "version": "3.2.1",
+ "version": "6.0.3",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/type.git",
- "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7"
+ "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7",
- "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7",
+ "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/e549163b9760b8f71f191651d22acf32d56d6d4d",
+ "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d",
"shasum": ""
},
"require": {
- "php": ">=7.3"
+ "php": ">=8.3"
},
"require-dev": {
- "phpunit/phpunit": "^9.5"
+ "phpunit/phpunit": "^12.0"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "3.2-dev"
+ "dev-main": "6.0-dev"
}
},
"autoload": {
@@ -2261,35 +2568,52 @@
],
"description": "Collection of value objects that represent the types of the PHP type system",
"homepage": "https://github.com/sebastianbergmann/type",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/type/issues",
+ "security": "https://github.com/sebastianbergmann/type/security/policy",
+ "source": "https://github.com/sebastianbergmann/type/tree/6.0.3"
+ },
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/type",
+ "type": "tidelift"
}
],
- "time": "2023-02-03T06:13:03+00:00"
+ "time": "2025-08-09T06:57:12+00:00"
},
{
"name": "sebastian/version",
- "version": "3.0.2",
+ "version": "6.0.0",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/version.git",
- "reference": "c6c1022351a901512170118436c764e473f6de8c"
+ "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c6c1022351a901512170118436c764e473f6de8c",
- "reference": "c6c1022351a901512170118436c764e473f6de8c",
+ "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/3e6ccf7657d4f0a59200564b08cead899313b53c",
+ "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c",
"shasum": ""
},
"require": {
- "php": ">=7.3"
+ "php": ">=8.3"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "3.0-dev"
+ "dev-main": "6.0-dev"
}
},
"autoload": {
@@ -2310,210 +2634,106 @@
],
"description": "Library that helps with managing the version number of Git-hosted PHP projects",
"homepage": "https://github.com/sebastianbergmann/version",
- "funding": [
- {
- "url": "https://github.com/sebastianbergmann",
- "type": "github"
- }
- ],
- "time": "2020-09-28T06:39:44+00:00"
- },
- {
- "name": "symfony/config",
- "version": "v5.4.31",
- "source": {
- "type": "git",
- "url": "https://github.com/symfony/config.git",
- "reference": "dd5ea39de228813aba0c23c3a4153da2a4cf3cd9"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/symfony/config/zipball/dd5ea39de228813aba0c23c3a4153da2a4cf3cd9",
- "reference": "dd5ea39de228813aba0c23c3a4153da2a4cf3cd9",
- "shasum": ""
- },
- "require": {
- "php": ">=7.2.5",
- "symfony/deprecation-contracts": "^2.1|^3",
- "symfony/filesystem": "^4.4|^5.0|^6.0",
- "symfony/polyfill-ctype": "~1.8",
- "symfony/polyfill-php80": "^1.16",
- "symfony/polyfill-php81": "^1.22"
- },
- "conflict": {
- "symfony/finder": "<4.4"
- },
- "require-dev": {
- "symfony/event-dispatcher": "^4.4|^5.0|^6.0",
- "symfony/finder": "^4.4|^5.0|^6.0",
- "symfony/messenger": "^4.4|^5.0|^6.0",
- "symfony/service-contracts": "^1.1|^2|^3",
- "symfony/yaml": "^4.4|^5.0|^6.0"
- },
- "suggest": {
- "symfony/yaml": "To use the yaml reference dumper"
- },
- "type": "library",
- "autoload": {
- "psr-4": {
- "Symfony\\Component\\Config\\": ""
- },
- "exclude-from-classmap": [
- "/Tests/"
- ]
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/version/issues",
+ "security": "https://github.com/sebastianbergmann/version/security/policy",
+ "source": "https://github.com/sebastianbergmann/version/tree/6.0.0"
},
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Fabien Potencier",
- "email": "fabien@symfony.com"
- },
- {
- "name": "Symfony Community",
- "homepage": "https://symfony.com/contributors"
- }
- ],
- "description": "Helps you find, load, combine, autofill and validate configuration values of any kind",
- "homepage": "https://symfony.com",
"funding": [
{
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
+ "url": "https://github.com/sebastianbergmann",
"type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
}
],
- "time": "2023-11-09T08:22:43+00:00"
+ "time": "2025-02-07T05:00:38+00:00"
},
{
- "name": "symfony/console",
- "version": "v5.4.31",
+ "name": "staabm/side-effects-detector",
+ "version": "1.0.5",
"source": {
"type": "git",
- "url": "https://github.com/symfony/console.git",
- "reference": "11ac5f154e0e5c4c77af83ad11ead9165280b92a"
+ "url": "https://github.com/staabm/side-effects-detector.git",
+ "reference": "d8334211a140ce329c13726d4a715adbddd0a163"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/console/zipball/11ac5f154e0e5c4c77af83ad11ead9165280b92a",
- "reference": "11ac5f154e0e5c4c77af83ad11ead9165280b92a",
+ "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163",
+ "reference": "d8334211a140ce329c13726d4a715adbddd0a163",
"shasum": ""
},
"require": {
- "php": ">=7.2.5",
- "symfony/deprecation-contracts": "^2.1|^3",
- "symfony/polyfill-mbstring": "~1.0",
- "symfony/polyfill-php73": "^1.9",
- "symfony/polyfill-php80": "^1.16",
- "symfony/service-contracts": "^1.1|^2|^3",
- "symfony/string": "^5.1|^6.0"
- },
- "conflict": {
- "psr/log": ">=3",
- "symfony/dependency-injection": "<4.4",
- "symfony/dotenv": "<5.1",
- "symfony/event-dispatcher": "<4.4",
- "symfony/lock": "<4.4",
- "symfony/process": "<4.4"
- },
- "provide": {
- "psr/log-implementation": "1.0|2.0"
+ "ext-tokenizer": "*",
+ "php": "^7.4 || ^8.0"
},
"require-dev": {
- "psr/log": "^1|^2",
- "symfony/config": "^4.4|^5.0|^6.0",
- "symfony/dependency-injection": "^4.4|^5.0|^6.0",
- "symfony/event-dispatcher": "^4.4|^5.0|^6.0",
- "symfony/lock": "^4.4|^5.0|^6.0",
- "symfony/process": "^4.4|^5.0|^6.0",
- "symfony/var-dumper": "^4.4|^5.0|^6.0"
- },
- "suggest": {
- "psr/log": "For using the console logger",
- "symfony/event-dispatcher": "",
- "symfony/lock": "",
- "symfony/process": ""
+ "phpstan/extension-installer": "^1.4.3",
+ "phpstan/phpstan": "^1.12.6",
+ "phpunit/phpunit": "^9.6.21",
+ "symfony/var-dumper": "^5.4.43",
+ "tomasvotruba/type-coverage": "1.0.0",
+ "tomasvotruba/unused-public": "1.0.0"
},
"type": "library",
"autoload": {
- "psr-4": {
- "Symfony\\Component\\Console\\": ""
- },
- "exclude-from-classmap": [
- "/Tests/"
+ "classmap": [
+ "lib/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
- "authors": [
- {
- "name": "Fabien Potencier",
- "email": "fabien@symfony.com"
- },
- {
- "name": "Symfony Community",
- "homepage": "https://symfony.com/contributors"
- }
- ],
- "description": "Eases the creation of beautiful and testable command line interfaces",
- "homepage": "https://symfony.com",
+ "description": "A static analysis tool to detect side effects in PHP code",
"keywords": [
- "cli",
- "command-line",
- "console",
- "terminal"
+ "static analysis"
],
+ "support": {
+ "issues": "https://github.com/staabm/side-effects-detector/issues",
+ "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5"
+ },
"funding": [
{
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
+ "url": "https://github.com/staabm",
"type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
}
],
- "time": "2023-10-31T07:58:33+00:00"
+ "time": "2024-10-20T05:08:20+00:00"
},
{
- "name": "symfony/filesystem",
- "version": "v5.4.25",
+ "name": "symfony/config",
+ "version": "v7.4.8",
"source": {
"type": "git",
- "url": "https://github.com/symfony/filesystem.git",
- "reference": "0ce3a62c9579a53358d3a7eb6b3dfb79789a6364"
+ "url": "https://github.com/symfony/config.git",
+ "reference": "2d19dde43fa2ff720b9a40763ace7226594f503b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/filesystem/zipball/0ce3a62c9579a53358d3a7eb6b3dfb79789a6364",
- "reference": "0ce3a62c9579a53358d3a7eb6b3dfb79789a6364",
+ "url": "https://api.github.com/repos/symfony/config/zipball/2d19dde43fa2ff720b9a40763ace7226594f503b",
+ "reference": "2d19dde43fa2ff720b9a40763ace7226594f503b",
"shasum": ""
},
"require": {
- "php": ">=7.2.5",
- "symfony/polyfill-ctype": "~1.8",
- "symfony/polyfill-mbstring": "~1.8",
- "symfony/polyfill-php80": "^1.16"
+ "php": ">=8.2",
+ "symfony/deprecation-contracts": "^2.5|^3",
+ "symfony/filesystem": "^7.1|^8.0",
+ "symfony/polyfill-ctype": "~1.8"
+ },
+ "conflict": {
+ "symfony/finder": "<6.4",
+ "symfony/service-contracts": "<2.5"
+ },
+ "require-dev": {
+ "symfony/event-dispatcher": "^6.4|^7.0|^8.0",
+ "symfony/finder": "^6.4|^7.0|^8.0",
+ "symfony/messenger": "^6.4|^7.0|^8.0",
+ "symfony/service-contracts": "^2.5|^3",
+ "symfony/yaml": "^6.4|^7.0|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
- "Symfony\\Component\\Filesystem\\": ""
+ "Symfony\\Component\\Config\\": ""
},
"exclude-from-classmap": [
"/Tests/"
@@ -2533,8 +2753,11 @@
"homepage": "https://symfony.com/contributors"
}
],
- "description": "Provides basic utilities for the filesystem",
+ "description": "Helps you find, load, combine, autofill and validate configuration values of any kind",
"homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/config/tree/v7.4.8"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -2544,53 +2767,69 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2023-05-31T13:04:02+00:00"
+ "time": "2026-03-24T13:12:05+00:00"
},
{
- "name": "symfony/polyfill-ctype",
- "version": "v1.28.0",
+ "name": "symfony/console",
+ "version": "v7.4.8",
"source": {
"type": "git",
- "url": "https://github.com/symfony/polyfill-ctype.git",
- "reference": "ea208ce43cbb04af6867b4fdddb1bdbf84cc28cb"
+ "url": "https://github.com/symfony/console.git",
+ "reference": "1e92e39c51f95b88e3d66fa2d9f06d1fb45dd707"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/ea208ce43cbb04af6867b4fdddb1bdbf84cc28cb",
- "reference": "ea208ce43cbb04af6867b4fdddb1bdbf84cc28cb",
+ "url": "https://api.github.com/repos/symfony/console/zipball/1e92e39c51f95b88e3d66fa2d9f06d1fb45dd707",
+ "reference": "1e92e39c51f95b88e3d66fa2d9f06d1fb45dd707",
"shasum": ""
},
"require": {
- "php": ">=7.1"
+ "php": ">=8.2",
+ "symfony/deprecation-contracts": "^2.5|^3",
+ "symfony/polyfill-mbstring": "~1.0",
+ "symfony/service-contracts": "^2.5|^3",
+ "symfony/string": "^7.2|^8.0"
+ },
+ "conflict": {
+ "symfony/dependency-injection": "<6.4",
+ "symfony/dotenv": "<6.4",
+ "symfony/event-dispatcher": "<6.4",
+ "symfony/lock": "<6.4",
+ "symfony/process": "<6.4"
},
"provide": {
- "ext-ctype": "*"
+ "psr/log-implementation": "1.0|2.0|3.0"
},
- "suggest": {
- "ext-ctype": "For best performance"
+ "require-dev": {
+ "psr/log": "^1|^2|^3",
+ "symfony/config": "^6.4|^7.0|^8.0",
+ "symfony/dependency-injection": "^6.4|^7.0|^8.0",
+ "symfony/event-dispatcher": "^6.4|^7.0|^8.0",
+ "symfony/http-foundation": "^6.4|^7.0|^8.0",
+ "symfony/http-kernel": "^6.4|^7.0|^8.0",
+ "symfony/lock": "^6.4|^7.0|^8.0",
+ "symfony/messenger": "^6.4|^7.0|^8.0",
+ "symfony/process": "^6.4|^7.0|^8.0",
+ "symfony/stopwatch": "^6.4|^7.0|^8.0",
+ "symfony/var-dumper": "^6.4|^7.0|^8.0"
},
"type": "library",
- "extra": {
- "branch-alias": {
- "dev-main": "1.28-dev"
- },
- "thanks": {
- "name": "symfony/polyfill",
- "url": "https://github.com/symfony/polyfill"
- }
- },
"autoload": {
- "files": [
- "bootstrap.php"
- ],
"psr-4": {
- "Symfony\\Polyfill\\Ctype\\": ""
- }
+ "Symfony\\Component\\Console\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
@@ -2598,22 +2837,25 @@
],
"authors": [
{
- "name": "Gert de Pagter",
- "email": "BackEndTea@gmail.com"
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
- "description": "Symfony polyfill for ctype functions",
+ "description": "Eases the creation of beautiful and testable command line interfaces",
"homepage": "https://symfony.com",
"keywords": [
- "compatibility",
- "ctype",
- "polyfill",
- "portable"
+ "cli",
+ "command-line",
+ "console",
+ "terminal"
],
+ "support": {
+ "source": "https://github.com/symfony/console/tree/v7.4.8"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -2623,50 +2865,48 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2023-01-26T09:26:14+00:00"
+ "time": "2026-03-30T13:54:39+00:00"
},
{
- "name": "symfony/polyfill-intl-grapheme",
- "version": "v1.28.0",
+ "name": "symfony/deprecation-contracts",
+ "version": "v3.6.0",
"source": {
"type": "git",
- "url": "https://github.com/symfony/polyfill-intl-grapheme.git",
- "reference": "875e90aeea2777b6f135677f618529449334a612"
+ "url": "https://github.com/symfony/deprecation-contracts.git",
+ "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/875e90aeea2777b6f135677f618529449334a612",
- "reference": "875e90aeea2777b6f135677f618529449334a612",
+ "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62",
+ "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62",
"shasum": ""
},
"require": {
- "php": ">=7.1"
- },
- "suggest": {
- "ext-intl": "For best performance"
+ "php": ">=8.1"
},
"type": "library",
"extra": {
- "branch-alias": {
- "dev-main": "1.28-dev"
- },
"thanks": {
- "name": "symfony/polyfill",
- "url": "https://github.com/symfony/polyfill"
+ "url": "https://github.com/symfony/contracts",
+ "name": "symfony/contracts"
+ },
+ "branch-alias": {
+ "dev-main": "3.6-dev"
}
},
"autoload": {
"files": [
- "bootstrap.php"
- ],
- "psr-4": {
- "Symfony\\Polyfill\\Intl\\Grapheme\\": ""
- }
+ "function.php"
+ ]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
@@ -2682,16 +2922,11 @@
"homepage": "https://symfony.com/contributors"
}
],
- "description": "Symfony polyfill for intl's grapheme_* functions",
+ "description": "A generic function and convention to trigger deprecation notices",
"homepage": "https://symfony.com",
- "keywords": [
- "compatibility",
- "grapheme",
- "intl",
- "polyfill",
- "portable",
- "shim"
- ],
+ "support": {
+ "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -2706,47 +2941,37 @@
"type": "tidelift"
}
],
- "time": "2023-01-26T09:26:14+00:00"
+ "time": "2024-09-25T14:21:43+00:00"
},
{
- "name": "symfony/polyfill-intl-normalizer",
- "version": "v1.28.0",
+ "name": "symfony/filesystem",
+ "version": "v7.4.8",
"source": {
"type": "git",
- "url": "https://github.com/symfony/polyfill-intl-normalizer.git",
- "reference": "8c4ad05dd0120b6a53c1ca374dca2ad0a1c4ed92"
+ "url": "https://github.com/symfony/filesystem.git",
+ "reference": "58b9790d12f9670b7f53a1c1738febd3108970a5"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/8c4ad05dd0120b6a53c1ca374dca2ad0a1c4ed92",
- "reference": "8c4ad05dd0120b6a53c1ca374dca2ad0a1c4ed92",
+ "url": "https://api.github.com/repos/symfony/filesystem/zipball/58b9790d12f9670b7f53a1c1738febd3108970a5",
+ "reference": "58b9790d12f9670b7f53a1c1738febd3108970a5",
"shasum": ""
},
"require": {
- "php": ">=7.1"
+ "php": ">=8.2",
+ "symfony/polyfill-ctype": "~1.8",
+ "symfony/polyfill-mbstring": "~1.8"
},
- "suggest": {
- "ext-intl": "For best performance"
+ "require-dev": {
+ "symfony/process": "^6.4|^7.0|^8.0"
},
"type": "library",
- "extra": {
- "branch-alias": {
- "dev-main": "1.28-dev"
- },
- "thanks": {
- "name": "symfony/polyfill",
- "url": "https://github.com/symfony/polyfill"
- }
- },
"autoload": {
- "files": [
- "bootstrap.php"
- ],
"psr-4": {
- "Symfony\\Polyfill\\Intl\\Normalizer\\": ""
+ "Symfony\\Component\\Filesystem\\": ""
},
- "classmap": [
- "Resources/stubs"
+ "exclude-from-classmap": [
+ "/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
@@ -2755,24 +2980,19 @@
],
"authors": [
{
- "name": "Nicolas Grekas",
- "email": "p@tchwork.com"
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
- "description": "Symfony polyfill for intl's Normalizer class and related functions",
+ "description": "Provides basic utilities for the filesystem",
"homepage": "https://symfony.com",
- "keywords": [
- "compatibility",
- "intl",
- "normalizer",
- "polyfill",
- "portable",
- "shim"
- ],
+ "support": {
+ "source": "https://github.com/symfony/filesystem/tree/v7.4.8"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -2782,44 +3002,45 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2023-01-26T09:26:14+00:00"
+ "time": "2026-03-24T13:12:05+00:00"
},
{
- "name": "symfony/polyfill-mbstring",
- "version": "v1.28.0",
+ "name": "symfony/polyfill-ctype",
+ "version": "v1.37.0",
"source": {
"type": "git",
- "url": "https://github.com/symfony/polyfill-mbstring.git",
- "reference": "42292d99c55abe617799667f454222c54c60e229"
+ "url": "https://github.com/symfony/polyfill-ctype.git",
+ "reference": "141046a8f9477948ff284fa65be2095baafb94f2"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/42292d99c55abe617799667f454222c54c60e229",
- "reference": "42292d99c55abe617799667f454222c54c60e229",
+ "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2",
+ "reference": "141046a8f9477948ff284fa65be2095baafb94f2",
"shasum": ""
},
"require": {
- "php": ">=7.1"
+ "php": ">=7.2"
},
"provide": {
- "ext-mbstring": "*"
+ "ext-ctype": "*"
},
"suggest": {
- "ext-mbstring": "For best performance"
+ "ext-ctype": "For best performance"
},
"type": "library",
"extra": {
- "branch-alias": {
- "dev-main": "1.28-dev"
- },
"thanks": {
- "name": "symfony/polyfill",
- "url": "https://github.com/symfony/polyfill"
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
}
},
"autoload": {
@@ -2827,7 +3048,7 @@
"bootstrap.php"
],
"psr-4": {
- "Symfony\\Polyfill\\Mbstring\\": ""
+ "Symfony\\Polyfill\\Ctype\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
@@ -2836,23 +3057,25 @@
],
"authors": [
{
- "name": "Nicolas Grekas",
- "email": "p@tchwork.com"
+ "name": "Gert de Pagter",
+ "email": "BackEndTea@gmail.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
- "description": "Symfony polyfill for the Mbstring extension",
+ "description": "Symfony polyfill for ctype functions",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
- "mbstring",
+ "ctype",
"polyfill",
- "portable",
- "shim"
+ "portable"
],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -2862,38 +3085,42 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2023-07-28T09:04:16+00:00"
+ "time": "2026-04-10T16:19:22+00:00"
},
{
- "name": "symfony/polyfill-php73",
- "version": "v1.28.0",
+ "name": "symfony/polyfill-intl-grapheme",
+ "version": "v1.37.0",
"source": {
"type": "git",
- "url": "https://github.com/symfony/polyfill-php73.git",
- "reference": "fe2f306d1d9d346a7fee353d0d5012e401e984b5"
+ "url": "https://github.com/symfony/polyfill-intl-grapheme.git",
+ "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/fe2f306d1d9d346a7fee353d0d5012e401e984b5",
- "reference": "fe2f306d1d9d346a7fee353d0d5012e401e984b5",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/4864388bfbd3001ce88e234fab652acd91fdc57e",
+ "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e",
"shasum": ""
},
"require": {
- "php": ">=7.1"
+ "php": ">=7.2"
+ },
+ "suggest": {
+ "ext-intl": "For best performance"
},
"type": "library",
"extra": {
- "branch-alias": {
- "dev-main": "1.28-dev"
- },
"thanks": {
- "name": "symfony/polyfill",
- "url": "https://github.com/symfony/polyfill"
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
}
},
"autoload": {
@@ -2901,11 +3128,8 @@
"bootstrap.php"
],
"psr-4": {
- "Symfony\\Polyfill\\Php73\\": ""
- },
- "classmap": [
- "Resources/stubs"
- ]
+ "Symfony\\Polyfill\\Intl\\Grapheme\\": ""
+ }
},
"notification-url": "https://packagist.org/downloads/",
"license": [
@@ -2921,14 +3145,19 @@
"homepage": "https://symfony.com/contributors"
}
],
- "description": "Symfony polyfill backporting some PHP 7.3+ features to lower PHP versions",
+ "description": "Symfony polyfill for intl's grapheme_* functions",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
+ "grapheme",
+ "intl",
"polyfill",
"portable",
"shim"
],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.37.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -2938,38 +3167,42 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2023-01-26T09:26:14+00:00"
+ "time": "2026-04-26T13:13:48+00:00"
},
{
- "name": "symfony/polyfill-php80",
- "version": "v1.28.0",
+ "name": "symfony/polyfill-intl-normalizer",
+ "version": "v1.37.0",
"source": {
"type": "git",
- "url": "https://github.com/symfony/polyfill-php80.git",
- "reference": "6caa57379c4aec19c0a12a38b59b26487dcfe4b5"
+ "url": "https://github.com/symfony/polyfill-intl-normalizer.git",
+ "reference": "3833d7255cc303546435cb650316bff708a1c75c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/6caa57379c4aec19c0a12a38b59b26487dcfe4b5",
- "reference": "6caa57379c4aec19c0a12a38b59b26487dcfe4b5",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c",
+ "reference": "3833d7255cc303546435cb650316bff708a1c75c",
"shasum": ""
},
"require": {
- "php": ">=7.1"
+ "php": ">=7.2"
+ },
+ "suggest": {
+ "ext-intl": "For best performance"
},
"type": "library",
"extra": {
- "branch-alias": {
- "dev-main": "1.28-dev"
- },
"thanks": {
- "name": "symfony/polyfill",
- "url": "https://github.com/symfony/polyfill"
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
}
},
"autoload": {
@@ -2977,7 +3210,7 @@
"bootstrap.php"
],
"psr-4": {
- "Symfony\\Polyfill\\Php80\\": ""
+ "Symfony\\Polyfill\\Intl\\Normalizer\\": ""
},
"classmap": [
"Resources/stubs"
@@ -2988,10 +3221,6 @@
"MIT"
],
"authors": [
- {
- "name": "Ion Bazan",
- "email": "ion.bazan@gmail.com"
- },
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
@@ -3001,14 +3230,19 @@
"homepage": "https://symfony.com/contributors"
}
],
- "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions",
+ "description": "Symfony polyfill for intl's Normalizer class and related functions",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
+ "intl",
+ "normalizer",
"polyfill",
"portable",
"shim"
],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.37.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -3018,38 +3252,46 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2023-01-26T09:26:14+00:00"
+ "time": "2024-09-09T11:45:10+00:00"
},
{
- "name": "symfony/polyfill-php81",
- "version": "v1.28.0",
+ "name": "symfony/polyfill-mbstring",
+ "version": "v1.37.0",
"source": {
"type": "git",
- "url": "https://github.com/symfony/polyfill-php81.git",
- "reference": "7581cd600fa9fd681b797d00b02f068e2f13263b"
+ "url": "https://github.com/symfony/polyfill-mbstring.git",
+ "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/7581cd600fa9fd681b797d00b02f068e2f13263b",
- "reference": "7581cd600fa9fd681b797d00b02f068e2f13263b",
+ "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6a21eb99c6973357967f6ce3708cd55a6bec6315",
+ "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315",
"shasum": ""
},
"require": {
- "php": ">=7.1"
+ "ext-iconv": "*",
+ "php": ">=7.2"
+ },
+ "provide": {
+ "ext-mbstring": "*"
+ },
+ "suggest": {
+ "ext-mbstring": "For best performance"
},
"type": "library",
"extra": {
- "branch-alias": {
- "dev-main": "1.28-dev"
- },
"thanks": {
- "name": "symfony/polyfill",
- "url": "https://github.com/symfony/polyfill"
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
}
},
"autoload": {
@@ -3057,11 +3299,8 @@
"bootstrap.php"
],
"psr-4": {
- "Symfony\\Polyfill\\Php81\\": ""
- },
- "classmap": [
- "Resources/stubs"
- ]
+ "Symfony\\Polyfill\\Mbstring\\": ""
+ }
},
"notification-url": "https://packagist.org/downloads/",
"license": [
@@ -3077,14 +3316,18 @@
"homepage": "https://symfony.com/contributors"
}
],
- "description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions",
+ "description": "Symfony polyfill for the Mbstring extension",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
+ "mbstring",
"polyfill",
"portable",
"shim"
],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.37.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -3094,52 +3337,56 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2023-01-26T09:26:14+00:00"
+ "time": "2026-04-10T17:25:58+00:00"
},
{
"name": "symfony/service-contracts",
- "version": "v2.5.2",
+ "version": "v3.6.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/service-contracts.git",
- "reference": "4b426aac47d6427cc1a1d0f7e2ac724627f5966c"
+ "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/service-contracts/zipball/4b426aac47d6427cc1a1d0f7e2ac724627f5966c",
- "reference": "4b426aac47d6427cc1a1d0f7e2ac724627f5966c",
+ "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43",
+ "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43",
"shasum": ""
},
"require": {
- "php": ">=7.2.5",
- "psr/container": "^1.1",
- "symfony/deprecation-contracts": "^2.1|^3"
+ "php": ">=8.1",
+ "psr/container": "^1.1|^2.0",
+ "symfony/deprecation-contracts": "^2.5|^3"
},
"conflict": {
"ext-psr": "<1.1|>=2"
},
- "suggest": {
- "symfony/service-implementation": ""
- },
"type": "library",
"extra": {
- "branch-alias": {
- "dev-main": "2.5-dev"
- },
"thanks": {
- "name": "symfony/contracts",
- "url": "https://github.com/symfony/contracts"
+ "url": "https://github.com/symfony/contracts",
+ "name": "symfony/contracts"
+ },
+ "branch-alias": {
+ "dev-main": "3.6-dev"
}
},
"autoload": {
"psr-4": {
"Symfony\\Contracts\\Service\\": ""
- }
+ },
+ "exclude-from-classmap": [
+ "/Test/"
+ ]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
@@ -3165,6 +3412,9 @@
"interoperability",
"standards"
],
+ "support": {
+ "source": "https://github.com/symfony/service-contracts/tree/v3.6.1"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -3174,30 +3424,34 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2022-05-30T19:17:29+00:00"
+ "time": "2025-07-15T11:30:57+00:00"
},
{
"name": "symfony/stopwatch",
- "version": "v5.4.21",
+ "version": "v7.4.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/stopwatch.git",
- "reference": "f83692cd869a6f2391691d40a01e8acb89e76fee"
+ "reference": "70a852d72fec4d51efb1f48dcd968efcaf5ccb89"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/stopwatch/zipball/f83692cd869a6f2391691d40a01e8acb89e76fee",
- "reference": "f83692cd869a6f2391691d40a01e8acb89e76fee",
+ "url": "https://api.github.com/repos/symfony/stopwatch/zipball/70a852d72fec4d51efb1f48dcd968efcaf5ccb89",
+ "reference": "70a852d72fec4d51efb1f48dcd968efcaf5ccb89",
"shasum": ""
},
"require": {
- "php": ">=7.2.5",
- "symfony/service-contracts": "^1|^2|^3"
+ "php": ">=8.2",
+ "symfony/service-contracts": "^2.5|^3"
},
"type": "library",
"autoload": {
@@ -3224,6 +3478,9 @@
],
"description": "Provides a way to profile code",
"homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/stopwatch/tree/v7.4.8"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -3233,43 +3490,48 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2023-02-14T08:03:56+00:00"
+ "time": "2026-03-24T13:12:05+00:00"
},
{
"name": "symfony/string",
- "version": "v5.4.31",
+ "version": "v7.4.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/string.git",
- "reference": "2765096c03f39ddf54f6af532166e42aaa05b24b"
+ "reference": "114ac57257d75df748eda23dd003878080b8e688"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/string/zipball/2765096c03f39ddf54f6af532166e42aaa05b24b",
- "reference": "2765096c03f39ddf54f6af532166e42aaa05b24b",
+ "url": "https://api.github.com/repos/symfony/string/zipball/114ac57257d75df748eda23dd003878080b8e688",
+ "reference": "114ac57257d75df748eda23dd003878080b8e688",
"shasum": ""
},
"require": {
- "php": ">=7.2.5",
+ "php": ">=8.2",
+ "symfony/deprecation-contracts": "^2.5|^3.0",
"symfony/polyfill-ctype": "~1.8",
- "symfony/polyfill-intl-grapheme": "~1.0",
+ "symfony/polyfill-intl-grapheme": "~1.33",
"symfony/polyfill-intl-normalizer": "~1.0",
- "symfony/polyfill-mbstring": "~1.0",
- "symfony/polyfill-php80": "~1.15"
+ "symfony/polyfill-mbstring": "~1.0"
},
"conflict": {
- "symfony/translation-contracts": ">=3.0"
+ "symfony/translation-contracts": "<2.5"
},
"require-dev": {
- "symfony/error-handler": "^4.4|^5.0|^6.0",
- "symfony/http-client": "^4.4|^5.0|^6.0",
- "symfony/translation-contracts": "^1.1|^2",
- "symfony/var-exporter": "^4.4|^5.0|^6.0"
+ "symfony/emoji": "^7.1|^8.0",
+ "symfony/http-client": "^6.4|^7.0|^8.0",
+ "symfony/intl": "^6.4|^7.0|^8.0",
+ "symfony/translation-contracts": "^2.5|^3.0",
+ "symfony/var-exporter": "^6.4|^7.0|^8.0"
},
"type": "library",
"autoload": {
@@ -3307,6 +3569,9 @@
"utf-8",
"utf8"
],
+ "support": {
+ "source": "https://github.com/symfony/string/tree/v7.4.8"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -3316,40 +3581,41 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2023-11-09T08:19:44+00:00"
+ "time": "2026-03-24T13:12:05+00:00"
},
{
"name": "symfony/yaml",
- "version": "v5.4.31",
+ "version": "v7.4.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/yaml.git",
- "reference": "f387675d7f5fc4231f7554baa70681f222f73563"
+ "reference": "c58fdf7b3d6c2995368264c49e4e8b05bcff2883"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/yaml/zipball/f387675d7f5fc4231f7554baa70681f222f73563",
- "reference": "f387675d7f5fc4231f7554baa70681f222f73563",
+ "url": "https://api.github.com/repos/symfony/yaml/zipball/c58fdf7b3d6c2995368264c49e4e8b05bcff2883",
+ "reference": "c58fdf7b3d6c2995368264c49e4e8b05bcff2883",
"shasum": ""
},
"require": {
- "php": ">=7.2.5",
- "symfony/deprecation-contracts": "^2.1|^3",
+ "php": ">=8.2",
+ "symfony/deprecation-contracts": "^2.5|^3",
"symfony/polyfill-ctype": "^1.8"
},
"conflict": {
- "symfony/console": "<5.3"
+ "symfony/console": "<6.4"
},
"require-dev": {
- "symfony/console": "^5.3|^6.0"
- },
- "suggest": {
- "symfony/console": "For validating YAML files using the lint command"
+ "symfony/console": "^6.4|^7.0|^8.0"
},
"bin": [
"Resources/bin/yaml-lint"
@@ -3379,6 +3645,9 @@
],
"description": "Loads and dumps YAML files",
"homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/yaml/tree/v7.4.8"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -3388,32 +3657,36 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2023-11-03T14:41:28+00:00"
+ "time": "2026-03-24T13:12:05+00:00"
},
{
"name": "theseer/tokenizer",
- "version": "1.2.1",
+ "version": "2.0.1",
"source": {
"type": "git",
"url": "https://github.com/theseer/tokenizer.git",
- "reference": "34a41e998c2183e22995f158c581e7b5e755ab9e"
+ "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/theseer/tokenizer/zipball/34a41e998c2183e22995f158c581e7b5e755ab9e",
- "reference": "34a41e998c2183e22995f158c581e7b5e755ab9e",
+ "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4",
+ "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4",
"shasum": ""
},
"require": {
"ext-dom": "*",
"ext-tokenizer": "*",
"ext-xmlwriter": "*",
- "php": "^7.2 || ^8.0"
+ "php": "^8.1"
},
"type": "library",
"autoload": {
@@ -3433,24 +3706,31 @@
}
],
"description": "A small library for converting tokenized PHP source code into XML and potentially other formats",
+ "support": {
+ "issues": "https://github.com/theseer/tokenizer/issues",
+ "source": "https://github.com/theseer/tokenizer/tree/2.0.1"
+ },
"funding": [
{
"url": "https://github.com/theseer",
"type": "github"
}
],
- "time": "2021-07-28T10:34:58+00:00"
+ "time": "2025-12-08T11:19:18+00:00"
}
],
"aliases": [],
"minimum-stability": "stable",
- "stability-flags": [],
+ "stability-flags": {},
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
- "php": ">=7.3",
+ "php": "^8.3",
"ext-json": "*"
},
- "platform-dev": [],
- "plugin-api-version": "1.1.0"
+ "platform-dev": {},
+ "platform-overrides": {
+ "php": "8.3.30"
+ },
+ "plugin-api-version": "2.9.0"
}
diff --git a/examples/example.php b/examples/example.php
index 623d900..dba5cee 100644
--- a/examples/example.php
+++ b/examples/example.php
@@ -1,10 +1,37 @@
accessKey('your-access-key-goes-here');
-$result = $fixer->from(Symbol::EUR)->get(Symbol::USD);
+// Frankfurter is free and key-less, so this example runs end-to-end with no setup.
+$frankfurter = DriverFactory::make('frankfurter');
+
+$result = $frankfurter
+ ->from(Currency::EUR)
+ ->to([Currency::USD, Currency::GBP, Currency::JPY])
+ ->get();
+
+echo 'Base: ', $result->getBaseCurrency(), PHP_EOL;
+echo 'Date: ', $result->getDate() ?? '(unknown)', PHP_EOL;
+echo 'EUR -> USD: ', $result->rate(Currency::USD), PHP_EOL;
+echo 'EUR -> GBP: ', $result->rate(Currency::GBP), PHP_EOL;
+echo '100 EUR -> JPY: ', $result->convert(100, Currency::EUR, Currency::JPY), PHP_EOL;
+
+// Rebase the same dataset to USD without re-fetching.
+$rebased = $result->setBaseCurrency(Currency::USD);
+echo 'USD -> EUR: ', $rebased->rate(Currency::EUR), PHP_EOL;
-print_r($result->all());
+// For paid providers, swap driver name and pass an access key:
+//
+// $fixer = DriverFactory::make('fixerio');
+// $fixer->accessKey('your-fixer-io-key')->from(Currency::EUR)->to(Currency::USD)->get();
+//
+// $currencyApi = DriverFactory::make('currencyapi');
+// $currencyApi->accessKey('your-currencyapi-key')->from(Currency::USD)->to(Currency::EUR)->get();
+//
+// $fastForex = DriverFactory::make('fastforex');
+// $fastForex->accessKey('your-fastforex-key')->from(Currency::USD)->to(Currency::EUR)->get();
diff --git a/lefthook.yml b/lefthook.yml
new file mode 100644
index 0000000..c1016f9
--- /dev/null
+++ b/lefthook.yml
@@ -0,0 +1,24 @@
+---
+# Lefthook configuration
+# For more information: https://github.com/evilmartians/lefthook/blob/master/docs/configuration.md
+
+pre-commit:
+ parallel: true
+ commands:
+ pint:
+ glob: "*.php"
+ run: vendor/bin/pint --test {staged_files}
+ phpstan:
+ run: vendor/bin/phpstan analyse --no-progress
+ rector:
+ run: vendor/bin/rector --dry-run
+ phpunit:
+ run: vendor/bin/phpunit --no-coverage
+ markdownlint:
+ glob: "*.md"
+ run: markdownlint-cli2 {staged_files}
+ yamllint:
+ glob: "*.{yml,yaml}"
+ run: yamllint {staged_files}
+ qlty:
+ run: ~/.qlty/bin/qlty check
diff --git a/phpcs.xml.dist b/phpcs.xml.dist
new file mode 100644
index 0000000..589ce73
--- /dev/null
+++ b/phpcs.xml.dist
@@ -0,0 +1,16 @@
+
+
+ PSR-12 checks for qlty/php-codesniffer.
+
+ src
+ examples
+ rector.php
+
+ build/*
+ tests/*
+ vendor/*
+
+
+
+
+
diff --git a/phpstan.neon b/phpstan.neon
new file mode 100644
index 0000000..18eb39a
--- /dev/null
+++ b/phpstan.neon
@@ -0,0 +1,23 @@
+parameters:
+ level: max
+ paths:
+ - src
+ - tests
+ treatPhpDocTypesAsCertain: false
+ tmpDir: build/phpstan
+ ignoreErrors:
+ -
+ identifier: cast.string
+ path: src/Drivers/*.php
+ -
+ identifier: cast.int
+ path: src/Drivers/*.php
+ -
+ identifier: encapsedStringPart.nonString
+ path: src/Drivers/*.php
+ -
+ identifier: binaryOp.invalid
+ path: src/Drivers/*.php
+ -
+ identifier: offsetAccess.nonOffsetAccessible
+ path: src/Drivers/*.php
diff --git a/phpunit.xml b/phpunit.xml
index 59f01b0..c0f7e1a 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -1,25 +1,31 @@
-
-
-
- ./src
-
-
- ./vendor
- ./tests
- ./examples
-
-
-
-
-
-
-
-
- ./tests/
-
-
-
-
-
+
+
+
+ tests
+
+
+
+
+ src
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pint.json b/pint.json
new file mode 100644
index 0000000..6788de6
--- /dev/null
+++ b/pint.json
@@ -0,0 +1,12 @@
+{
+ "preset": "psr12",
+ "rules": {
+ "declare_strict_types": true,
+ "ordered_imports": {
+ "sort_algorithm": "alpha"
+ },
+ "no_unused_imports": true,
+ "single_quote": true,
+ "trailing_comma_in_multiline": true
+ }
+}
diff --git a/rector.php b/rector.php
new file mode 100644
index 0000000..1d4d4ae
--- /dev/null
+++ b/rector.php
@@ -0,0 +1,21 @@
+withPaths([
+ __DIR__ . '/src',
+ __DIR__ . '/tests',
+ ])
+ ->withSets([
+ LevelSetList::UP_TO_PHP_83,
+ SetList::CODE_QUALITY,
+ SetList::TYPE_DECLARATION,
+ PHPUnitSetList::PHPUNIT_100,
+ ])
+ ->withImportNames(removeUnusedImports: true);
diff --git a/renovate.json b/renovate.json
deleted file mode 100644
index f45d8f1..0000000
--- a/renovate.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "extends": [
- "config:base"
- ]
-}
diff --git a/resources/README.md b/resources/README.md
new file mode 100644
index 0000000..a1c479e
--- /dev/null
+++ b/resources/README.md
@@ -0,0 +1,17 @@
+# Resources
+
+This folder contains assets used for the project, such as social preview images.
+
+## Open Graph Preview Image
+
+The open graph preview image is provided as an SVG vector file (`open-graph-preview.svg`) to ensure it's easily editable and scalable.
+
+If you need to generate a PNG version of the open graph image (e.g., for platforms that don't support SVG), you can use `npx` (requires Node.js) with the `sharp-cli` tool.
+
+Run the following command in your terminal from the root directory of the project:
+
+```bash
+npx -y sharp-cli@latest -i resources/open-graph-preview.svg -o resources/open-graph-preview.png
+```
+
+This will correctly read the SVG's 1280x640 dimensions and render a high-quality `open-graph-preview.png` without any cropping or weird padding issues that can happen with built-in OS tools.
diff --git a/resources/open-graph-preview.png b/resources/open-graph-preview.png
new file mode 100644
index 0000000..1392f2b
Binary files /dev/null and b/resources/open-graph-preview.png differ
diff --git a/resources/open-graph-preview.psd b/resources/open-graph-preview.psd
deleted file mode 100644
index 105af84..0000000
Binary files a/resources/open-graph-preview.psd and /dev/null differ
diff --git a/resources/open-graph-preview.svg b/resources/open-graph-preview.svg
new file mode 100644
index 0000000..a29f4e5
--- /dev/null
+++ b/resources/open-graph-preview.svg
@@ -0,0 +1,105 @@
+
diff --git a/src/Currency.php b/src/Currency.php
new file mode 100644
index 0000000..0a3ccc3
--- /dev/null
+++ b/src/Currency.php
@@ -0,0 +1,364 @@
+ 'United Arab Emirates Dirham',
+ 'AFN' => 'Afghan Afghani',
+ 'ALL' => 'Albanian Lek',
+ 'AMD' => 'Armenian Dram',
+ 'ANG' => 'Netherlands Antillean Guilder',
+ 'AOA' => 'Angolan Kwanza',
+ 'ARS' => 'Argentine Peso',
+ 'AUD' => 'Australian Dollar',
+ 'AWG' => 'Aruban Florin',
+ 'AZN' => 'Azerbaijani Manat',
+ 'BAM' => 'Bosnia Herzegovina Convertible Mark',
+ 'BBD' => 'Barbadian Dollar',
+ 'BDT' => 'Bangladeshi Taka',
+ 'BGN' => 'Bulgarian Lev',
+ 'BHD' => 'Bahraini Dinar',
+ 'BIF' => 'Burundian Franc',
+ 'BMD' => 'Bermudan Dollar',
+ 'BND' => 'Brunei Dollar',
+ 'BOB' => 'Bolivian Boliviano',
+ 'BRL' => 'Brazilian Real',
+ 'BSD' => 'Bahamian Dollar',
+ 'BTC' => 'Bitcoin',
+ 'BTN' => 'Bhutanese Ngultrum',
+ 'BWP' => 'Botswanan Pula',
+ 'BYR' => 'Belarusian Ruble',
+ 'BYN' => 'New Belarusian Ruble',
+ 'BZD' => 'Belize Dollar',
+ 'CAD' => 'Canadian Dollar',
+ 'CDF' => 'Congolese Franc',
+ 'CHF' => 'Swiss Franc',
+ 'CLF' => 'Chilean Unit of Account',
+ 'CLP' => 'Chilean Peso',
+ 'CNY' => 'Chinese Yuan',
+ 'COP' => 'Colombian Peso',
+ 'CRC' => 'Costa Rican Colón',
+ 'CUC' => 'Cuban Convertible Peso',
+ 'CUP' => 'Cuban Peso',
+ 'CVE' => 'Cape Verdean Escudo',
+ 'CZK' => 'Czech Republic Koruna',
+ 'DJF' => 'Djiboutian Franc',
+ 'DKK' => 'Danish Krone',
+ 'DOP' => 'Dominican Peso',
+ 'DZD' => 'Algerian Dinar',
+ 'EGP' => 'Egyptian Pound',
+ 'ERN' => 'Eritrean Nakfa',
+ 'ETB' => 'Ethiopian Birr',
+ 'EUR' => 'Euro',
+ 'FJD' => 'Fijian Dollar',
+ 'FKP' => 'Falkland Islands Pound',
+ 'GBP' => 'British Pound Sterling',
+ 'GEL' => 'Georgian Lari',
+ 'GGP' => 'Guernsey Pound',
+ 'GHS' => 'Ghanaian Cedi',
+ 'GIP' => 'Gibraltar Pound',
+ 'GMD' => 'Gambian Dalasi',
+ 'GNF' => 'Guinean Franc',
+ 'GTQ' => 'Guatemalan Quetzal',
+ 'GYD' => 'Guyanaese Dollar',
+ 'HKD' => 'Hong Kong Dollar',
+ 'HNL' => 'Honduran Lempira',
+ 'HRK' => 'Croatian Kuna',
+ 'HTG' => 'Haitian Gourde',
+ 'HUF' => 'Hungarian Forint',
+ 'IDR' => 'Indonesian Rupiah',
+ 'ILS' => 'Israeli New Sheqel',
+ 'IMP' => 'Manx pound',
+ 'INR' => 'Indian Rupee',
+ 'IQD' => 'Iraqi Dinar',
+ 'IRR' => 'Iranian Rial',
+ 'ISK' => 'Icelandic Króna',
+ 'JEP' => 'Jersey Pound',
+ 'JMD' => 'Jamaican Dollar',
+ 'JOD' => 'Jordanian Dinar',
+ 'JPY' => 'Japanese Yen',
+ 'KES' => 'Kenyan Shilling',
+ 'KGS' => 'Kyrgystani Som',
+ 'KHR' => 'Cambodian Riel',
+ 'KMF' => 'Comorian Franc',
+ 'KPW' => 'North Korean Won',
+ 'KRW' => 'South Korean Won',
+ 'KWD' => 'Kuwaiti Dinar',
+ 'KYD' => 'Cayman Islands Dollar',
+ 'KZT' => 'Kazakhstani Tenge',
+ 'LAK' => 'Laotian Kip',
+ 'LBP' => 'Lebanese Pound',
+ 'LKR' => 'Sri Lankan Rupee',
+ 'LRD' => 'Liberian Dollar',
+ 'LSL' => 'Lesotho Loti',
+ 'LTL' => 'Lithuanian Litas',
+ 'LVL' => 'Latvian Lats',
+ 'LYD' => 'Libyan Dinar',
+ 'MAD' => 'Moroccan Dirham',
+ 'MDL' => 'Moldovan Leu',
+ 'MGA' => 'Malagasy Ariary',
+ 'MKD' => 'Macedonian Denar',
+ 'MMK' => 'Myanma Kyat',
+ 'MNT' => 'Mongolian Tugrik',
+ 'MOP' => 'Macanese Pataca',
+ 'MRO' => 'Mauritanian Ouguiya',
+ 'MUR' => 'Mauritian Rupee',
+ 'MVR' => 'Maldivian Rufiyaa',
+ 'MWK' => 'Malawian Kwacha',
+ 'MXN' => 'Mexican Peso',
+ 'MYR' => 'Malaysian Ringgit',
+ 'MZN' => 'Mozambican Metical',
+ 'NAD' => 'Namibian Dollar',
+ 'NGN' => 'Nigerian Naira',
+ 'NIO' => 'Nicaraguan Córdoba',
+ 'NOK' => 'Norwegian Krone',
+ 'NPR' => 'Nepalese Rupee',
+ 'NZD' => 'New Zealand Dollar',
+ 'OMR' => 'Omani Rial',
+ 'PAB' => 'Panamanian Balboa',
+ 'PEN' => 'Peruvian Nuevo Sol',
+ 'PGK' => 'Papua New Guinean Kina',
+ 'PHP' => 'Philippine Peso',
+ 'PKR' => 'Pakistani Rupee',
+ 'PLN' => 'Polish Zloty',
+ 'PYG' => 'Paraguayan Guarani',
+ 'QAR' => 'Qatari Rial',
+ 'RON' => 'Romanian Leu',
+ 'RSD' => 'Serbian Dinar',
+ 'RUB' => 'Russian Ruble',
+ 'RWF' => 'Rwandan Franc',
+ 'SAR' => 'Saudi Riyal',
+ 'SBD' => 'Solomon Islands Dollar',
+ 'SCR' => 'Seychellois Rupee',
+ 'SDG' => 'Sudanese Pound',
+ 'SEK' => 'Swedish Krona',
+ 'SGD' => 'Singapore Dollar',
+ 'SHP' => 'Saint Helena Pound',
+ 'SLL' => 'Sierra Leonean Leone',
+ 'SOS' => 'Somali Shilling',
+ 'SRD' => 'Surinamese Dollar',
+ 'STD' => 'São Tomé and Príncipe Dobra',
+ 'SVC' => 'Salvadoran Colón',
+ 'SYP' => 'Syrian Pound',
+ 'SZL' => 'Swazi Lilangeni',
+ 'THB' => 'Thai Baht',
+ 'TJS' => 'Tajikistani Somoni',
+ 'TMT' => 'Turkmenistani Manat',
+ 'TND' => 'Tunisian Dinar',
+ 'TOP' => 'Tongan Paʻanga',
+ 'TRY' => 'Turkish Lira',
+ 'TTD' => 'Trinidad and Tobago Dollar',
+ 'TWD' => 'New Taiwan Dollar',
+ 'TZS' => 'Tanzanian Shilling',
+ 'UAH' => 'Ukrainian Hryvnia',
+ 'UGX' => 'Ugandan Shilling',
+ 'USD' => 'United States Dollar',
+ 'UYU' => 'Uruguayan Peso',
+ 'UZS' => 'Uzbekistan Som',
+ 'VEF' => 'Venezuelan Bolívar Fuerte',
+ 'VND' => 'Vietnamese Dong',
+ 'VUV' => 'Vanuatu Vatu',
+ 'WST' => 'Samoan Tala',
+ 'XAF' => 'CFA Franc BEAC',
+ 'XAG' => 'Silver',
+ 'XAU' => 'Gold',
+ 'XCD' => 'East Caribbean Dollar',
+ 'XDR' => 'Special Drawing Rights',
+ 'XOF' => 'CFA Franc BCEAO',
+ 'XPF' => 'CFP Franc',
+ 'YER' => 'Yemeni Rial',
+ 'ZAR' => 'South African Rand',
+ 'ZMW' => 'Zambian Kwacha',
+ 'ZWL' => 'Zimbabwean Dollar',
+ ];
+
+ public static function tryFromCode(string $code): ?self
+ {
+ return self::tryFrom($code);
+ }
+
+ public function displayName(): string
+ {
+ return self::NAMES[$this->value] ?? $this->value;
+ }
+
+ /**
+ * Coerce a string code or Currency enum to its uppercase string code.
+ */
+ public static function code(string|self $code): string
+ {
+ return $code instanceof self ? $code->value : $code;
+ }
+}
diff --git a/src/DriverFactory.php b/src/DriverFactory.php
index 3356698..d0d02a8 100644
--- a/src/DriverFactory.php
+++ b/src/DriverFactory.php
@@ -1,58 +1,180 @@
- MockCurrencyDriver::class,
- 'fixerio' => FixerIo::class,
- 'currencylayer' => CurrencyLayer::class,
- 'openexchangerates' => OpenExchangeRates::class,
- 'exchangeratesapi' => ExchangeRatesApi::class,
- ];
-
- /**
- * @param string $name
- * @param ClientInterface|null $client
- *
- * @return CurrencyDriverContract
- *
- * @throws DriverNotFoundException
- */
- public static function make(string $name, ClientInterface $client = null): CurrencyDriverContract
- {
- if (!isset(static::DRIVERS[$name])) {
- throw new DriverNotFoundException("{$name} is not a valid driver.");
- }
-
- $class = static::DRIVERS[$name];
-
- // If no client is specified, create a HTTPClient instance.
- $client = $client == null ? new HTTPClient() : $client;
- return new $class($client);
- }
-
- /**
- * Get all of the available drivers.
- *
- * @return array
- */
- public static function getDrivers(): array
- {
- return self::DRIVERS;
- }
+ /**
+ * @var array>
+ */
+ private array $drivers;
+
+ private static ?self $defaultInstance = null;
+
+ /**
+ * @param array>|null $drivers
+ */
+ public function __construct(?array $drivers = null)
+ {
+ $this->drivers = $drivers ?? [
+ 'mock' => MockCurrencyDriver::class,
+ 'fixerio' => FixerIo::class,
+ 'currencylayer' => CurrencyLayer::class,
+ 'openexchangerates' => OpenExchangeRates::class,
+ 'exchangeratesapi' => ExchangeRatesApi::class,
+ 'frankfurter' => Frankfurter::class,
+ 'currencyapi' => CurrencyApi::class,
+ 'fastforex' => FastForex::class,
+ ];
+ }
+
+ /**
+ * @param class-string $driverClass
+ */
+ public function register(string $name, string $driverClass): self
+ {
+ $this->drivers[$name] = $driverClass;
+
+ return $this;
+ }
+
+ public function unregister(string $name): self
+ {
+ unset($this->drivers[$name]);
+
+ return $this;
+ }
+
+ /**
+ * @return array>
+ */
+ public function drivers(): array
+ {
+ return $this->drivers;
+ }
+
+ /**
+ * @throws DriverNotFoundException
+ */
+ public function build(
+ string $name,
+ ?ClientInterface $httpClient = null,
+ ?RequestFactoryInterface $requestFactory = null,
+ ): CurrencyDriverContract {
+ if (!isset($this->drivers[$name])) {
+ throw new DriverNotFoundException("{$name} is not a valid driver.");
+ }
+
+ $class = $this->drivers[$name];
+ $client = $httpClient ?? $this->defaultClient();
+ $factory = $requestFactory ?? $this->defaultRequestFactory();
+
+ return new $class($client, $factory);
+ }
+
+ /**
+ * Static facade preserved for backwards compatibility.
+ *
+ * @throws DriverNotFoundException
+ */
+ public static function make(
+ string $name,
+ ?ClientInterface $httpClient = null,
+ ?RequestFactoryInterface $requestFactory = null,
+ ): CurrencyDriverContract {
+ return self::default()->build($name, $httpClient, $requestFactory);
+ }
+
+ /**
+ * @return array>
+ */
+ public static function getDrivers(): array
+ {
+ return self::default()->drivers();
+ }
+
+ public static function default(): self
+ {
+ return self::$defaultInstance ??= new self();
+ }
+
+ public static function setDefault(?self $instance): void
+ {
+ self::$defaultInstance = $instance;
+ }
+
+ private function defaultClient(): ClientInterface
+ {
+ if (!class_exists(GuzzleClient::class)) {
+ throw new MissingDependencyException(
+ 'No PSR-18 HTTP client supplied and guzzlehttp/guzzle is '
+ . 'not installed. Either install guzzlehttp/guzzle, or pass '
+ . 'a ClientInterface to DriverFactory::make().',
+ );
+ }
+
+ $client = $this->buildDefaultClient();
+ if (!$client instanceof ClientInterface) {
+ throw new MissingDependencyException(
+ 'The installed guzzlehttp/guzzle package does not provide a PSR-18 '
+ . 'ClientInterface implementation.',
+ );
+ }
+
+ return $client;
+ }
+
+ private function defaultRequestFactory(): RequestFactoryInterface
+ {
+ if (!class_exists(GuzzleRequestFactory::class)) {
+ throw new MissingDependencyException(
+ 'No PSR-17 RequestFactory supplied and '
+ . 'http-interop/http-factory-guzzle is not installed. '
+ . 'Either install http-interop/http-factory-guzzle, or pass '
+ . 'a RequestFactoryInterface to DriverFactory::make().',
+ );
+ }
+
+ $requestFactory = $this->buildDefaultRequestFactory();
+ if (!$requestFactory instanceof RequestFactoryInterface) {
+ throw new MissingDependencyException(
+ 'The installed http-interop/http-factory-guzzle package does not '
+ . 'provide a PSR-17 RequestFactoryInterface implementation.',
+ );
+ }
+
+ return $requestFactory;
+ }
+
+ private function buildDefaultClient(): object
+ {
+ $class = GuzzleClient::class;
+
+ return new $class();
+ }
+
+ private function buildDefaultRequestFactory(): object
+ {
+ $class = GuzzleRequestFactory::class;
+
+ return new $class();
+ }
}
diff --git a/src/Drivers/BaseCurrencyDriver.php b/src/Drivers/BaseCurrencyDriver.php
index a8f8fc1..1a8c32b 100644
--- a/src/Drivers/BaseCurrencyDriver.php
+++ b/src/Drivers/BaseCurrencyDriver.php
@@ -1,227 +1,342 @@
- 'application/json',
- 'Content-Type' => 'application/json',
- ];
-
- protected $currencies = [];
- protected $baseCurrency = 'USD';
- protected $amount = 0.00;
- protected $date = null;
-
- protected $httpClient = null;
- protected $httpParams = [];
-
- /**
- * BaseDriver constructor.
- *
- * @param ClientInterface $client
- */
- public function __construct(ClientInterface $client)
- {
- $this->httpClient = $client;
- }
-
- /**
- * @param string $baseCurrency
- *
- * @return self
- */
- public function source(string $baseCurrency): CurrencyDriverContract
- {
- $this->baseCurrency = $baseCurrency;
- return $this;
- }
-
- /**
- * Alias for 'source'.
- *
- * @param string $baseCurrency
- *
- * @return CurrencyDriverContract
- * @see CurrencyDriverContract::source()
- *
- */
- public function from(string $baseCurrency): CurrencyDriverContract
- {
- return $this->source($baseCurrency);
- }
-
- /**
- * @param string|array $symbols
- *
- * @return self
- */
- public function currencies($symbols = []): CurrencyDriverContract
- {
- $this->currencies = (array)$symbols;
- return $this;
- }
-
- /**
- * Alias for 'currencies'.
- *
- * @param array $symbols
- *
- * @return CurrencyDriverContract
- * @see CurrencyDriverContract::currencies()
- *
- */
- public function to($symbols = []): CurrencyDriverContract
- {
- return $this->currencies($symbols);
- }
-
- /**
- * @param double|integer|float $amount
- *
- * @return self
- */
- public function amount($amount): CurrencyDriverContract
- {
- $this->amount = $amount;
- return $this;
- }
-
- /**
- * @param int|string|DateTime|DateInterval|DateTimeInterface $date
- *
- * @return self
- *
- * @throws Exception
- */
- public function date($date): CurrencyDriverContract
- {
- if ($date === null) {
- return $this;
- }
-
- $this->date = DateHelper::format($date, 'Y-m-d');
- return $this;
- }
-
- /**
- * Returns the date in 'YYYY-mm-dd' format or null if not set.
- *
- * @return string|null
- */
- public function getDate(): ?string
- {
- return $this->date;
- }
-
- /**
- * @return array
- */
- public function getSymbols(): array
- {
- return $this->currencies;
- }
-
- /**
- * @return string
- */
- public function getBaseCurrency(): string
- {
- return $this->baseCurrency;
- }
-
- /**
- * @return self
- */
- public function secure(): CurrencyDriverContract
- {
- $this->protocol = 'https';
- return $this;
- }
-
- /**
- * @return string
- */
- public function getProtocol(): string
- {
- return $this->protocol;
- }
-
- /**
- * Set a config parameter.
- *
- * @param string $key
- * @param string $value
- *
- * @return self
- */
- public function config(string $key, string $value): CurrencyDriverContract
- {
- $this->httpParams[$key] = $value;
- return $this;
- }
-
- /**
- * Sets the API key to use.
- *
- * Shortcut for config('access_key', $accessKey)
- *
- * @param string $accessKey Your API key.
- *
- * @return self
- * @see CurrencyDriverContract::config()
- *
- */
- public function accessKey(string $accessKey): CurrencyDriverContract
- {
- $this->config('access_key', $accessKey);
- return $this;
- }
-
- /**
- * Performs an HTTP request.
- *
- * @param string $endpoint The API endpoint.
- * @param array $params The query parameters for this request.
- * @param string $method The HTTP method (defaults to 'GET').
- *
- * @return array|bool The response as decoded JSON.
- *
- * @throws ApiException
- */
- function apiRequest(string $endpoint, array $params = [], string $method = 'GET')
- {
- $url = sprintf('%s://%s/%s', $this->getProtocol(), $this->apiURL, $endpoint);
-
- try {
- $response = $this->httpClient->request($method, $url, ['query' => array_merge($this->httpParams, $params)])->getBody();
- } catch (GuzzleException $e) {
- throw new ApiException($e->getMessage(), $e->getCode(), $e);
- }
-
- $data = json_decode($response->getContents(), true);
-
- // Check for JSON errors
- if (json_last_error() !== JSON_ERROR_NONE || !is_array($data)) {
- throw new ApiException(json_last_error_msg(), json_last_error());
- }
-
- // Otherwise return data.
- return $data;
- }
+ protected string $apiURL = 'localhost';
+ protected string $protocol = 'https';
+ protected string $baseCurrency;
+
+ /** @var list */
+ protected array $currencies = [];
+
+ protected ?float $amount = null;
+ protected ?string $date = null;
+
+ /** @var array */
+ protected array $httpParams = [];
+
+ /** @var array */
+ protected array $httpHeaders = [];
+
+ public function __construct(
+ private readonly ClientInterface $httpClient,
+ private readonly RequestFactoryInterface $requestFactory,
+ ) {
+ if (!isset($this->baseCurrency)) {
+ $this->baseCurrency = 'USD';
+ }
+ }
+
+ public function source(string|Currency $baseCurrency): static
+ {
+ $this->baseCurrency = Currency::code($baseCurrency);
+
+ return $this;
+ }
+
+ public function from(string|Currency $baseCurrency): static
+ {
+ return $this->source($baseCurrency);
+ }
+
+ public function currencies(string|Currency|array $symbols = []): static
+ {
+ $list = is_array($symbols) ? $symbols : [$symbols];
+
+ $this->currencies = array_values(array_map(
+ Currency::code(...),
+ $list,
+ ));
+
+ return $this;
+ }
+
+ public function to(string|Currency|array $symbols = []): static
+ {
+ return $this->currencies($symbols);
+ }
+
+ public function amount(?float $amount): static
+ {
+ $this->amount = $amount;
+
+ return $this;
+ }
+
+ public function date(?DateTimeInterface $date): static
+ {
+ $this->date = DateHelper::format($date);
+
+ return $this;
+ }
+
+ public function getDate(): ?string
+ {
+ return $this->date;
+ }
+
+ /**
+ * @return list
+ */
+ public function getSymbols(): array
+ {
+ return $this->currencies;
+ }
+
+ public function getBaseCurrency(): string
+ {
+ return $this->baseCurrency;
+ }
+
+ public function secure(): static
+ {
+ $this->protocol = 'https';
+
+ return $this;
+ }
+
+ public function get(string|Currency|array $forCurrency = []): ConversionResult
+ {
+ if ($forCurrency !== []) {
+ $this->currencies($forCurrency);
+ }
+
+ $response = $this->apiRequest('latest', [
+ 'base' => $this->getBaseCurrency(),
+ 'symbols' => implode(',', $this->getSymbols()),
+ ]);
+
+ return new ConversionResult(
+ $this->responseString($response, 'base', static::class),
+ $this->responseString($response, 'date', static::class),
+ $this->responseRates($response, 'rates', static::class),
+ );
+ }
+
+ public function historical(
+ ?DateTimeInterface $date = null,
+ string|Currency|array $forCurrency = [],
+ ): ConversionResult {
+ if ($date instanceof DateTimeInterface) {
+ $this->date($date);
+ }
+
+ if ($forCurrency !== []) {
+ $this->currencies($forCurrency);
+ }
+
+ if ($this->getDate() === null) {
+ throw new ApiException('Date needs to be set!');
+ }
+
+ $response = $this->apiRequest($this->getDate(), [
+ 'base' => $this->getBaseCurrency(),
+ 'symbols' => implode(',', $this->getSymbols()),
+ ]);
+
+ return new ConversionResult(
+ $this->responseString($response, 'base', static::class),
+ $this->responseString($response, 'date', static::class),
+ $this->responseRates($response, 'rates', static::class),
+ );
+ }
+
+ public function convert(
+ ?float $amount = null,
+ string|Currency|null $fromCurrency = null,
+ string|Currency|null $toCurrency = null,
+ ?DateTimeInterface $date = null,
+ ): ConversionResult {
+ if ($date instanceof DateTimeInterface) {
+ $this->date($date);
+ }
+
+ if ($amount !== null) {
+ $this->amount = $amount;
+ }
+
+ if ($fromCurrency !== null) {
+ $this->baseCurrency = Currency::code($fromCurrency);
+ }
+
+ if ($toCurrency !== null) {
+ $this->currencies = [Currency::code($toCurrency)];
+ }
+
+ $target = $this->currencies[0] ?? null;
+ if ($target === null) {
+ throw new ApiException('A target currency is required for convert().');
+ }
+ if ($this->amount === null) {
+ throw new ApiException('An amount is required for convert().');
+ }
+
+ $params = [
+ 'from' => $this->getBaseCurrency(),
+ 'to' => $target,
+ 'amount' => $this->amount,
+ ];
+
+ if ($this->getDate() !== null) {
+ $params['date'] = $this->getDate();
+ }
+
+ $response = $this->apiRequest('convert', $params);
+
+ $rate = BigDecimal::of($this->responseString($response, 'result', static::class))
+ ->dividedBy(BigDecimal::of((string) $this->amount), ConversionResult::DEFAULT_SCALE, RoundingMode::HALF_UP);
+
+ return new ConversionResult(
+ $this->getBaseCurrency(),
+ $this->optionalResponseString($response, 'date') ?? $this->getDate(),
+ [$target => $rate],
+ );
+ }
+
+ public function getProtocol(): string
+ {
+ return $this->protocol;
+ }
+
+ public function config(string $key, string $value): static
+ {
+ $this->httpParams[$key] = $value;
+
+ return $this;
+ }
+
+ public function accessKey(string $accessKey): static
+ {
+ return $this->config('access_key', $accessKey);
+ }
+
+ /**
+ * Performs an HTTP GET against the driver's API and decodes the JSON body.
+ *
+ * @param array $params
+ *
+ * @return array
+ *
+ * @throws ApiException
+ */
+ protected function apiRequest(string $endpoint, array $params = []): array
+ {
+ $query = http_build_query([...$this->httpParams, ...$params]);
+ $uri = sprintf(
+ '%s://%s/%s%s',
+ $this->protocol,
+ $this->apiURL,
+ ltrim($endpoint, '/'),
+ $query === '' ? '' : '?' . $query,
+ );
+
+ $request = $this->requestFactory->createRequest('GET', $uri)
+ ->withHeader('Accept', 'application/json');
+
+ foreach ($this->httpHeaders as $name => $value) {
+ $request = $request->withHeader($name, $value);
+ }
+
+ try {
+ $response = $this->httpClient->sendRequest($request);
+ } catch (ClientExceptionInterface $e) {
+ throw new ApiException($e->getMessage(), $e->getCode(), $e);
+ }
+
+ $body = (string) $response->getBody();
+ $statusCode = $response->getStatusCode();
+
+ if ($statusCode < 200 || $statusCode >= 300) {
+ throw new ApiException(trim($body) === '' ? "API request failed with HTTP {$statusCode}." : $body, $statusCode);
+ }
+
+ try {
+ /** @var array $data */
+ $data = json_decode($body, true, flags: JSON_THROW_ON_ERROR);
+ } catch (JsonException $e) {
+ throw new ApiException($e->getMessage(), $e->getCode(), $e);
+ }
+
+ if (!is_array($data)) {
+ throw new ApiException('Expected JSON object from API, got ' . get_debug_type($data) . '.');
+ }
+
+ return $data;
+ }
+
+ /**
+ * @param array $response
+ */
+ protected function responseString(array $response, string $key, string $provider): string
+ {
+ $value = $response[$key] ?? null;
+ if (!is_scalar($value)) {
+ throw new ApiException("{$provider} response did not contain {$key}.");
+ }
+
+ return (string) $value;
+ }
+
+ /**
+ * @param array $response
+ */
+ protected function optionalResponseString(array $response, string $key): ?string
+ {
+ $value = $response[$key] ?? null;
+
+ return is_scalar($value) ? (string) $value : null;
+ }
+
+ /**
+ * @param array $response
+ */
+ protected function responseInt(array $response, string $key, string $provider): int
+ {
+ $value = $response[$key] ?? null;
+ if (!is_scalar($value)) {
+ throw new ApiException("{$provider} response did not contain {$key}.");
+ }
+
+ return (int) $value;
+ }
+
+ /**
+ * @param array $response
+ *
+ * @return array
+ */
+ protected function responseRates(array $response, string $key, string $provider): array
+ {
+ $rates = $response[$key] ?? null;
+ if (!is_array($rates)) {
+ throw new ApiException("{$provider} response did not contain {$key}.");
+ }
+
+ $normalised = [];
+ foreach ($rates as $currency => $rate) {
+ if (!$rate instanceof BigDecimal && !is_float($rate) && !is_int($rate) && !is_string($rate)) {
+ throw new ApiException("{$provider} response did not contain a numeric rate for {$currency}.");
+ }
+
+ $normalised[(string) $currency] = $rate;
+ }
+
+ return $normalised;
+ }
}
diff --git a/src/Drivers/CurrencyApi.php b/src/Drivers/CurrencyApi.php
new file mode 100644
index 0000000..e72dd0e
--- /dev/null
+++ b/src/Drivers/CurrencyApi.php
@@ -0,0 +1,185 @@
+httpHeaders['apikey'] = $accessKey;
+
+ return $this;
+ }
+
+ #[Override]
+ public function get(string|Currency|array $forCurrency = []): ConversionResult
+ {
+ if ($forCurrency !== []) {
+ $this->currencies($forCurrency);
+ }
+
+ $response = $this->apiRequest('v3/latest', $this->buildRateParams());
+
+ return new ConversionResult(
+ $this->getBaseCurrency(),
+ $this->responseDate($response),
+ $this->ratesFromData($response['data'] ?? []),
+ );
+ }
+
+ #[Override]
+ public function historical(
+ ?DateTimeInterface $date = null,
+ string|Currency|array $forCurrency = [],
+ ): ConversionResult {
+ if ($date instanceof DateTimeInterface) {
+ $this->date($date);
+ }
+
+ if ($forCurrency !== []) {
+ $this->currencies($forCurrency);
+ }
+
+ if ($this->getDate() === null) {
+ throw new ApiException('Date needs to be set!');
+ }
+
+ $response = $this->apiRequest('v3/historical', [
+ ...$this->buildRateParams(),
+ 'date' => $this->getDate(),
+ ]);
+
+ return new ConversionResult(
+ $this->getBaseCurrency(),
+ $this->getDate(),
+ $this->ratesFromData($response['data'] ?? []),
+ );
+ }
+
+ #[Override]
+ public function convert(
+ ?float $amount = null,
+ string|Currency|null $fromCurrency = null,
+ string|Currency|null $toCurrency = null,
+ ?DateTimeInterface $date = null,
+ ): ConversionResult {
+ if ($date instanceof DateTimeInterface) {
+ $this->date($date);
+ }
+
+ if ($amount !== null) {
+ $this->amount = $amount;
+ }
+
+ if ($fromCurrency !== null) {
+ $this->baseCurrency = Currency::code($fromCurrency);
+ }
+
+ if ($toCurrency !== null) {
+ $this->currencies = [Currency::code($toCurrency)];
+ }
+
+ $target = $this->currencies[0] ?? null;
+ if ($target === null) {
+ throw new ApiException('A target currency is required for convert().');
+ }
+ if ($this->amount === null) {
+ throw new ApiException('An amount is required for convert().');
+ }
+
+ if ($this->getDate() !== null) {
+ return $this->historical();
+ }
+
+ $response = $this->apiRequest('v3/convert', [
+ 'value' => $this->amount,
+ 'base_currency' => $this->getBaseCurrency(),
+ 'currencies' => $target,
+ ]);
+
+ $data = $response['data'] ?? [];
+ $converted = is_array($data) ? ($data['value'] ?? null) : null;
+ if (!is_scalar($converted)) {
+ throw new ApiException('CurrencyAPI response did not contain a converted value.');
+ }
+
+ $rate = BigDecimal::of((string) $converted)
+ ->dividedBy(BigDecimal::of((string) $this->amount), ConversionResult::DEFAULT_SCALE, RoundingMode::HALF_UP);
+
+ return new ConversionResult(
+ $this->getBaseCurrency(),
+ $this->responseDate($response),
+ [$target => $rate],
+ );
+ }
+
+ /**
+ * @return array
+ */
+ private function buildRateParams(): array
+ {
+ $params = [
+ 'base_currency' => $this->getBaseCurrency(),
+ ];
+
+ if ($this->getSymbols() !== []) {
+ $params['currencies'] = implode(',', $this->getSymbols());
+ }
+
+ return $params;
+ }
+
+ /**
+ * @param mixed $data
+ *
+ * @return array
+ */
+ private function ratesFromData(mixed $data): array
+ {
+ if (!is_array($data)) {
+ throw new ApiException('CurrencyAPI response did not contain rate data.');
+ }
+
+ $rates = [];
+ foreach ($data as $currency => $rateData) {
+ $value = is_array($rateData) ? ($rateData['value'] ?? null) : null;
+ if (!$value instanceof BigDecimal && !is_float($value) && !is_int($value) && !is_string($value)) {
+ throw new ApiException('CurrencyAPI response did not contain a rate for ' . (string) $currency . '.');
+ }
+
+ $rates[(string) $currency] = $value;
+ }
+
+ return $rates;
+ }
+
+ /**
+ * @param array $response
+ */
+ private function responseDate(array $response): ?string
+ {
+ $meta = $response['meta'] ?? [];
+ $timestamp = is_array($meta) ? ($meta['last_updated_at'] ?? null) : null;
+ if (!is_string($timestamp) || $timestamp === '') {
+ return null;
+ }
+
+ return DateHelper::format(new DateTimeImmutable($timestamp));
+ }
+}
diff --git a/src/Drivers/CurrencyDriverContract.php b/src/Drivers/CurrencyDriverContract.php
index 04ea2fa..d4b89e1 100644
--- a/src/Drivers/CurrencyDriverContract.php
+++ b/src/Drivers/CurrencyDriverContract.php
@@ -1,138 +1,83 @@
- $symbols
+ */
+ public function currencies(string|Currency|array $symbols = []): static;
+
+ /**
+ * @param string|Currency|array $symbols
+ */
+ public function to(string|Currency|array $symbols = []): static;
+
+ public function amount(?float $amount): static;
+
+ public function date(?DateTimeInterface $date): static;
+
+ /**
+ * Returns the date in 'YYYY-mm-dd' format or null if not set.
+ */
+ public function getDate(): ?string;
+
+ /**
+ * @return list
+ */
+ public function getSymbols(): array;
+
+ /**
+ * @param string|Currency|array $forCurrency
+ */
+ public function get(string|Currency|array $forCurrency = []): ConversionResult;
+
+ /**
+ * Converts an amount of `$fromCurrency` into `$toCurrency`, optionally for a given date.
+ */
+ public function convert(
+ ?float $amount = null,
+ string|Currency|null $fromCurrency = null,
+ string|Currency|null $toCurrency = null,
+ ?DateTimeInterface $date = null,
+ ): ConversionResult;
+
+ /**
+ * @param string|Currency|array $forCurrency
+ */
+ public function historical(
+ ?DateTimeInterface $date = null,
+ string|Currency|array $forCurrency = [],
+ ): ConversionResult;
+
+ public function getBaseCurrency(): string;
+
+ public function config(string $key, string $value): static;
+
+ /**
+ * Sets the API key to use.
+ *
+ * Shortcut for config('access_key', $accessKey).
+ */
+ public function accessKey(string $accessKey): static;
+
+ /**
+ * Switches all HTTP requests to HTTPS.
+ *
+ * Drivers default to HTTPS in 2.0; this exists for explicit toggling.
+ */
+ public function secure(): static;
+
+ public function getProtocol(): string;
}
diff --git a/src/Drivers/CurrencyLayer.php b/src/Drivers/CurrencyLayer.php
index c92f18c..c720dfd 100644
--- a/src/Drivers/CurrencyLayer.php
+++ b/src/Drivers/CurrencyLayer.php
@@ -1,160 +1,118 @@
- 0,
- ];
-
- /**
- * @param string|array $forCurrency
- *
- * @return ConversionResult
- *
- * @throws CurrencyException
- */
- public function get($forCurrency = []): ConversionResult
- {
- if (!empty((array)$forCurrency)) {
- $this->currencies((array)$forCurrency);
- }
+declare(strict_types=1);
- // Get API response
- $response = $this->apiRequest('live', [
- 'source' => $this->getBaseCurrency(),
- 'currencies' => join(',', $this->getSymbols()),
- ]);
+namespace Otherguy\Currency\Drivers;
- // Transform rates response
- $rates = [];
- foreach ($response['quotes'] as $currency => $rate) {
- $rates[substr($currency, 3, 3)] = $rate;
- }
+use Brick\Math\BigDecimal;
+use DateTimeImmutable;
+use DateTimeInterface;
+use Otherguy\Currency\Currency;
+use Otherguy\Currency\Exceptions\ApiException;
+use Otherguy\Currency\Helpers\DateHelper;
+use Otherguy\Currency\Results\ConversionResult;
+use Override;
- return new ConversionResult($response['source'], $response['timestamp'], $rates);
- }
-
- /**
- * @param int|string|DateTime $date
- * @param string|array $forCurrency
- *
- * @return ConversionResult
- *
- * @throws CurrencyException
- */
- public function historical($date = null, $forCurrency = []): ConversionResult
- {
- // Set date
- $this->date($date);
-
- if (!empty((array)$forCurrency)) {
- $this->currencies((array)$forCurrency);
- }
+class CurrencyLayer extends BaseCurrencyDriver
+{
+ protected string $apiURL = 'apilayer.net/api';
+ protected string $baseCurrency = 'USD';
- if (null === $this->getDate()) {
- throw new ApiException('Date needs to be set!');
- }
+ /** @var array */
+ protected array $httpParams = [
+ 'format' => 0,
+ ];
- // Get API response
- $response = $this->apiRequest('historical', [
- 'date' => $this->getDate(),
- 'source' => $this->getBaseCurrency(),
- 'currencies' => join(',', $this->getSymbols()),
- ]);
-
- // Transform rates response
- $rates = [];
- foreach ($response['quotes'] as $currency => $rate) {
- $rates[substr($currency, 3, 3)] = $rate;
+ #[Override]
+ public function get(string|Currency|array $forCurrency = []): ConversionResult
+ {
+ if ($forCurrency !== []) {
+ $this->currencies($forCurrency);
+ }
+
+ $response = $this->apiRequest('live', [
+ 'source' => $this->getBaseCurrency(),
+ 'currencies' => implode(',', $this->getSymbols()),
+ ]);
+
+ return new ConversionResult(
+ $this->responseString($response, 'source', 'CurrencyLayer'),
+ $this->timestampToDate($this->responseInt($response, 'timestamp', 'CurrencyLayer')),
+ $this->stripQuotes($this->responseRates($response, 'quotes', 'CurrencyLayer')),
+ );
}
- return new ConversionResult($response['source'], $response['timestamp'], $rates);
- }
-
- /**
- * Converts any amount in a given currency to another currency.
- *
- * @param float $amount The amount to convert.
- * @param string $fromCurrency The base currency.
- * @param string $toCurrency The target currency.
- * @param int|string|DateTime $date The date to get the conversion rate for.
- *
- * @return float The conversion result.
- *
- * @throws ApiException
- */
- public function convert(float $amount = null, string $fromCurrency = null, string $toCurrency = null, $date = null): float
- {
- $this->date($date);
-
- // Overwrite/set params
- if ($amount !== null) {
- $this->amount = $amount;
+ #[Override]
+ public function historical(
+ ?DateTimeInterface $date = null,
+ string|Currency|array $forCurrency = [],
+ ): ConversionResult {
+ if ($date instanceof DateTimeInterface) {
+ $this->date($date);
+ }
+
+ if ($forCurrency !== []) {
+ $this->currencies($forCurrency);
+ }
+
+ if ($this->getDate() === null) {
+ throw new ApiException('Date needs to be set!');
+ }
+
+ $response = $this->apiRequest('historical', [
+ 'date' => $this->getDate(),
+ 'source' => $this->getBaseCurrency(),
+ 'currencies' => implode(',', $this->getSymbols()),
+ ]);
+
+ return new ConversionResult(
+ $this->responseString($response, 'source', 'CurrencyLayer'),
+ $this->timestampToDate($this->responseInt($response, 'timestamp', 'CurrencyLayer')),
+ $this->stripQuotes($this->responseRates($response, 'quotes', 'CurrencyLayer')),
+ );
}
- if ($fromCurrency !== null) {
- $this->baseCurrency = $fromCurrency;
+ /**
+ * @param array $params
+ *
+ * @return array
+ */
+ #[Override]
+ protected function apiRequest(string $endpoint, array $params = []): array
+ {
+ $response = parent::apiRequest($endpoint, $params);
+
+ if (!($response['success'] ?? false)) {
+ throw new ApiException(
+ (string) ($response['error']['info'] ?? 'CurrencyLayer API error'),
+ (int) ($response['error']['code'] ?? 0),
+ );
+ }
+
+ return $response;
}
- if ($toCurrency !== null) {
- $this->currencies = [$toCurrency];
+ /**
+ * @param array $quotes
+ *
+ * @return array
+ */
+ private function stripQuotes(array $quotes): array
+ {
+ $rates = [];
+ foreach ($quotes as $currency => $rate) {
+ $rates[substr((string) $currency, 3, 3)] = $rate;
+ }
+
+ return $rates;
}
- $params = [
- 'from' => $this->getBaseCurrency(),
- 'to' => reset($this->currencies),
- 'amount' => $this->amount,
- ];
+ private function timestampToDate(int|string|null $timestamp): ?string
+ {
+ if ($timestamp === null) {
+ return null;
+ }
- if (null !== $this->getDate()) {
- $params['date'] = $this->getDate();
+ return DateHelper::format(new DateTimeImmutable('@' . $timestamp));
}
-
- // Get API response
- $response = $this->apiRequest('convert', $params);
-
- // Return the rate as a float
- return floatval($response['result']);
- }
-
- /**
- * Performs an HTTP request.
- *
- * @param string $endpoint The API endpoint.
- * @param array $params The query parameters for this request.
- * @param string $method The HTTP method (defaults to 'GET').
- *
- * @return array|bool The response as decoded JSON.
- *
- * @throws ApiException
- */
- function apiRequest(string $endpoint, array $params = [], string $method = 'GET')
- {
- // Perform actual API request.
- $response = parent::apiRequest($endpoint, $params, $method);
-
- // Handle response exceptions.
- if ($response['success'] == false) {
- throw new ApiException($response['error']['info'], $response['error']['code']);
- }
-
- return $response;
- }
}
diff --git a/src/Drivers/ExchangeRatesApi.php b/src/Drivers/ExchangeRatesApi.php
index 8893a87..b65eb79 100644
--- a/src/Drivers/ExchangeRatesApi.php
+++ b/src/Drivers/ExchangeRatesApi.php
@@ -1,128 +1,40 @@
-currencies((array)$forCurrency);
- }
-
- // Get API response
- $response = $this->apiRequest('latest', [
- 'base' => $this->getBaseCurrency(),
- 'symbols' => join(',', $this->getSymbols()),
- ]);
-
- return new ConversionResult($response['base'], $response['date'], $response['rates']);
- }
-
- /**
- * @param int|string|DateTime $date
- * @param string|array $forCurrency
- *
- * @return ConversionResult
- *
- * @throws CurrencyException
- */
- public function historical($date = null, $forCurrency = []): ConversionResult
- {
- // Set date
- $this->date($date);
+use Otherguy\Currency\Exceptions\ApiException;
+use Override;
- if (!empty((array)$forCurrency)) {
- $this->currencies((array)$forCurrency);
- }
+class ExchangeRatesApi extends BaseCurrencyDriver
+{
+ protected string $apiURL = 'api.apilayer.com/exchangerates_data';
+ protected string $baseCurrency = 'EUR';
- if (null === $this->getDate()) {
- throw new ApiException('Date needs to be set!');
+ #[Override]
+ public function accessKey(string $accessKey): static
+ {
+ return $this->config('apikey', $accessKey);
}
- // Get API response
- $response = $this->apiRequest($this->getDate(), [
- 'base' => $this->getBaseCurrency(),
- 'symbols' => join(',', $this->getSymbols()),
- ]);
-
- return new ConversionResult($response['base'], $response['date'], $response['rates']);
- }
-
- /**
- * Converts any amount in a given currency to another currency.
- *
- * @param float $amount The amount to convert.
- * @param string $fromCurrency The base currency.
- * @param string $toCurrency The target currency.
- * @param int|string|DateTime $date The date to get the conversion rate for.
- *
- * @return float The conversion result.
- *
- * @throws ApiException
- */
- public function convert(float $amount = null, string $fromCurrency = null, string $toCurrency = null, $date = null): float
- {
- throw new ApiException("Endpoint 'convert' is not supported for this driver!", 404);
- }
-
- /**
- * Performs an HTTP request.
- *
- * @param string $endpoint The API endpoint.
- * @param array $params The query parameters for this request.
- * @param string $method The HTTP method (defaults to 'GET').
- *
- * @return array|bool The response as decoded JSON.
- *
- * @throws ApiException
- */
- function apiRequest(string $endpoint, array $params = [], string $method = 'GET')
- {
- // Perform actual API request.
- $response = parent::apiRequest($endpoint, $params, $method);
-
-
- // Handle response exceptions.
- if (isset($response['error'])) {
- throw new ApiException((string)$response['error'], 500);
+ /**
+ * @param array $params
+ *
+ * @return array
+ */
+ #[Override]
+ protected function apiRequest(string $endpoint, array $params = []): array
+ {
+ $response = parent::apiRequest($endpoint, $params);
+
+ if (!($response['success'] ?? false)) {
+ throw new ApiException(
+ (string) ($response['error']['info'] ?? $response['message'] ?? 'ExchangeRatesApi error'),
+ (int) ($response['error']['code'] ?? 0),
+ );
+ }
+
+ return $response;
}
-
- return $response;
- }
}
diff --git a/src/Drivers/FastForex.php b/src/Drivers/FastForex.php
new file mode 100644
index 0000000..c693aae
--- /dev/null
+++ b/src/Drivers/FastForex.php
@@ -0,0 +1,207 @@
+config('api_key', $accessKey);
+ }
+
+ #[Override]
+ public function get(string|Currency|array $forCurrency = []): ConversionResult
+ {
+ if ($forCurrency !== []) {
+ $this->currencies($forCurrency);
+ }
+
+ $endpoint = match (count($this->getSymbols())) {
+ 0 => 'fetch-all',
+ 1 => 'fetch-one',
+ default => 'fetch-multi',
+ };
+
+ $response = $this->apiRequest($endpoint, $this->buildRateParams());
+
+ return new ConversionResult(
+ $this->optionalResponseString($response, 'base') ?? $this->getBaseCurrency(),
+ $this->responseDate($response),
+ $this->ratesFromResponse($response),
+ );
+ }
+
+ #[Override]
+ public function historical(
+ ?DateTimeInterface $date = null,
+ string|Currency|array $forCurrency = [],
+ ): ConversionResult {
+ if ($date instanceof DateTimeInterface) {
+ $this->date($date);
+ }
+
+ if ($forCurrency !== []) {
+ $this->currencies($forCurrency);
+ }
+
+ if ($this->getDate() === null) {
+ throw new ApiException('Date needs to be set!');
+ }
+
+ $response = $this->apiRequest('historical', [
+ ...$this->buildRateParams(),
+ 'date' => $this->getDate(),
+ ]);
+
+ return new ConversionResult(
+ $this->optionalResponseString($response, 'base') ?? $this->getBaseCurrency(),
+ $this->optionalResponseString($response, 'date') ?? $this->getDate(),
+ $this->ratesFromResponse($response),
+ );
+ }
+
+ #[Override]
+ public function convert(
+ ?float $amount = null,
+ string|Currency|null $fromCurrency = null,
+ string|Currency|null $toCurrency = null,
+ ?DateTimeInterface $date = null,
+ ): ConversionResult {
+ if ($date instanceof DateTimeInterface) {
+ $this->date($date);
+ }
+
+ if ($amount !== null) {
+ $this->amount = $amount;
+ }
+
+ if ($fromCurrency !== null) {
+ $this->baseCurrency = Currency::code($fromCurrency);
+ }
+
+ if ($toCurrency !== null) {
+ $this->currencies = [Currency::code($toCurrency)];
+ }
+
+ $target = $this->currencies[0] ?? null;
+ if ($target === null) {
+ throw new ApiException('A target currency is required for convert().');
+ }
+ if ($this->amount === null) {
+ throw new ApiException('An amount is required for convert().');
+ }
+
+ if ($this->getDate() !== null) {
+ return $this->historical();
+ }
+
+ $response = $this->apiRequest('convert', [
+ 'from' => $this->getBaseCurrency(),
+ 'to' => $target,
+ 'amount' => $this->amount,
+ ]);
+
+ $rates = $this->ratesFromResponse($response);
+ if (!isset($rates[$target])) {
+ throw new ApiException("fastFOREX response did not contain a conversion for {$target}.");
+ }
+
+ $rate = BigDecimal::of((string) $rates[$target])
+ ->dividedBy(BigDecimal::of((string) $this->amount), ConversionResult::DEFAULT_SCALE, RoundingMode::HALF_UP);
+
+ return new ConversionResult(
+ $this->optionalResponseString($response, 'base') ?? $this->getBaseCurrency(),
+ $this->responseDate($response),
+ [$target => $rate],
+ );
+ }
+
+ /**
+ * @param array $params
+ *
+ * @return array
+ */
+ #[Override]
+ protected function apiRequest(string $endpoint, array $params = []): array
+ {
+ $response = parent::apiRequest($endpoint, $params);
+
+ if (isset($response['error'])) {
+ throw new ApiException(is_scalar($response['error']) ? (string) $response['error'] : 'fastFOREX API error');
+ }
+
+ return $response;
+ }
+
+ /**
+ * @return array
+ */
+ private function buildRateParams(): array
+ {
+ $params = [
+ 'from' => $this->getBaseCurrency(),
+ ];
+
+ if ($this->getSymbols() !== []) {
+ $params['to'] = implode(',', $this->getSymbols());
+ }
+
+ return $params;
+ }
+
+ /**
+ * @param array $response
+ *
+ * @return array
+ */
+ private function ratesFromResponse(array $response): array
+ {
+ $rates = $response['result'] ?? $response['results'] ?? null;
+ if (!is_array($rates)) {
+ throw new ApiException('fastFOREX response did not contain rate data.');
+ }
+
+ $normalised = [];
+ foreach ($rates as $currency => $rate) {
+ if (!$rate instanceof BigDecimal && !is_float($rate) && !is_int($rate) && !is_string($rate)) {
+ throw new ApiException('fastFOREX response did not contain a rate for ' . (string) $currency . '.');
+ }
+
+ $normalised[(string) $currency] = $rate;
+ }
+
+ return $normalised;
+ }
+
+ /**
+ * @param array $response
+ */
+ private function responseDate(array $response): ?string
+ {
+ if (isset($response['date']) && is_scalar($response['date'])) {
+ return (string) $response['date'];
+ }
+
+ if (isset($response['updated']) && is_string($response['updated']) && $response['updated'] !== '') {
+ return DateHelper::format(new DateTimeImmutable(strtok($response['updated'], ' ') ?: $response['updated']));
+ }
+
+ return null;
+ }
+}
diff --git a/src/Drivers/FixerIo.php b/src/Drivers/FixerIo.php
index b33acb9..cb31730 100644
--- a/src/Drivers/FixerIo.php
+++ b/src/Drivers/FixerIo.php
@@ -1,152 +1,39 @@
-currencies((array)$forCurrency);
- }
-
- // Get API response
- $response = $this->apiRequest('latest', [
- 'base' => $this->getBaseCurrency(),
- 'symbols' => join(',', $this->getSymbols()),
- ]);
-
- return new ConversionResult($response['base'], $response['date'], $response['rates']);
- }
-
- /**
- * @param int|string|DateTime $date
- * @param string|array $forCurrency
- *
- * @return ConversionResult
- *
- * @throws CurrencyException
- */
- public function historical($date = null, $forCurrency = []): ConversionResult
- {
- // Set date
- $this->date($date);
-
- if (!empty((array)$forCurrency)) {
- $this->currencies((array)$forCurrency);
- }
-
- if (null === $this->getDate()) {
- throw new ApiException('Date needs to be set!');
- }
-
- // Get API response
- $response = $this->apiRequest($this->getDate(), [
- 'base' => $this->getBaseCurrency(),
- 'symbols' => join(',', $this->getSymbols()),
- ]);
+declare(strict_types=1);
- return new ConversionResult($response['base'], $response['date'], $response['rates']);
- }
+namespace Otherguy\Currency\Drivers;
- /**
- * Converts any amount in a given currency to another currency.
- *
- * @param float $amount The amount to convert.
- * @param string $fromCurrency The base currency.
- * @param string $toCurrency The target currency.
- * @param int|string|DateTime $date The date to get the conversion rate for.
- *
- * @return float The conversion result.
- *
- * @throws ApiException
- */
- public function convert(float $amount = null, string $fromCurrency = null, string $toCurrency = null, $date = null): float
- {
- // Set date
- $this->date($date);
-
- // Overwrite/set params
- if ($amount !== null) {
- $this->amount = $amount;
- }
-
- if ($fromCurrency !== null) {
- $this->baseCurrency = $fromCurrency;
- }
-
- if ($toCurrency !== null) {
- $this->currencies = [$toCurrency];
- }
-
- $params = [
- 'from' => $this->getBaseCurrency(),
- 'to' => reset($this->currencies),
- 'amount' => $this->amount,
- ];
-
- if (null !== $this->getDate()) {
- $params['date'] = $this->getDate();
- }
-
- // Get API response
- $response = $this->apiRequest('convert', $params);
-
- // Return the rate as a float
- return floatval($response['result']);
- }
-
- /**
- * Performs an HTTP request.
- *
- * @param string $endpoint The API endpoint.
- * @param array $params The query parameters for this request.
- * @param string $method The HTTP method (defaults to 'GET').
- *
- * @return array|bool The response as decoded JSON.
- *
- * @throws ApiException
- */
- function apiRequest(string $endpoint, array $params = [], string $method = 'GET')
- {
- // Perform actual API request.
- $response = parent::apiRequest($endpoint, $params, $method);
+use Otherguy\Currency\Exceptions\ApiException;
+use Override;
- // Handle response exceptions.
- if ($response['success'] == false) {
- $message = '';
- if (isset($response['error']['type'])) {
- $message = "[{$response['error']['type']}]";
- }
- if (isset($response['error']['info'])) {
- $message .= ' ' . $response['error']['info'];
- }
- throw new ApiException(trim($message), $response['error']['code']);
+class FixerIo extends BaseCurrencyDriver
+{
+ protected string $apiURL = 'data.fixer.io/api';
+ protected string $baseCurrency = 'EUR';
+
+ /**
+ * @param array $params
+ *
+ * @return array
+ */
+ #[Override]
+ protected function apiRequest(string $endpoint, array $params = []): array
+ {
+ $response = parent::apiRequest($endpoint, $params);
+
+ if (!($response['success'] ?? false)) {
+ $message = '';
+ if (isset($response['error']['type'])) {
+ $message = "[{$response['error']['type']}]";
+ }
+ if (isset($response['error']['info'])) {
+ $message .= ' ' . $response['error']['info'];
+ }
+
+ throw new ApiException(trim($message), (int) ($response['error']['code'] ?? 0));
+ }
+
+ return $response;
}
-
- return $response;
- }
}
diff --git a/src/Drivers/Frankfurter.php b/src/Drivers/Frankfurter.php
new file mode 100644
index 0000000..eaafd11
--- /dev/null
+++ b/src/Drivers/Frankfurter.php
@@ -0,0 +1,114 @@
+currencies($forCurrency);
+ }
+
+ $response = $this->apiRequest('latest', $this->buildSymbolsParams());
+
+ return new ConversionResult(
+ $this->responseString($response, 'base', 'Frankfurter'),
+ $this->responseString($response, 'date', 'Frankfurter'),
+ $this->responseRates($response, 'rates', 'Frankfurter'),
+ );
+ }
+
+ #[Override]
+ public function historical(
+ ?DateTimeInterface $date = null,
+ string|Currency|array $forCurrency = [],
+ ): ConversionResult {
+ if ($date instanceof DateTimeInterface) {
+ $this->date($date);
+ }
+
+ if ($forCurrency !== []) {
+ $this->currencies($forCurrency);
+ }
+
+ if ($this->getDate() === null) {
+ throw new ApiException('Date needs to be set!');
+ }
+
+ $response = $this->apiRequest($this->getDate(), $this->buildSymbolsParams());
+
+ return new ConversionResult(
+ $this->responseString($response, 'base', 'Frankfurter'),
+ $this->responseString($response, 'date', 'Frankfurter'),
+ $this->responseRates($response, 'rates', 'Frankfurter'),
+ );
+ }
+
+ #[Override]
+ public function convert(
+ ?float $amount = null,
+ string|Currency|null $fromCurrency = null,
+ string|Currency|null $toCurrency = null,
+ ?DateTimeInterface $date = null,
+ ): ConversionResult {
+ if ($date instanceof DateTimeInterface) {
+ $this->date($date);
+ }
+
+ if ($amount !== null) {
+ $this->amount = $amount;
+ }
+
+ if ($fromCurrency !== null) {
+ $this->baseCurrency = Currency::code($fromCurrency);
+ }
+
+ if ($toCurrency !== null) {
+ $this->currencies = [Currency::code($toCurrency)];
+ }
+
+ if ($this->currencies === []) {
+ throw new ApiException('A target currency is required for convert().');
+ }
+ if ($this->amount === null) {
+ throw new ApiException('An amount is required for convert().');
+ }
+
+ return $this->getDate() === null ? $this->get() : $this->historical();
+ }
+
+ /**
+ * @return array
+ */
+ private function buildSymbolsParams(): array
+ {
+ $params = [
+ 'base' => $this->getBaseCurrency(),
+ ];
+
+ if ($this->getSymbols() !== []) {
+ $params['symbols'] = implode(',', $this->getSymbols());
+ }
+
+ return $params;
+ }
+}
diff --git a/src/Drivers/MockCurrencyDriver.php b/src/Drivers/MockCurrencyDriver.php
index f7484f2..23d4fbb 100644
--- a/src/Drivers/MockCurrencyDriver.php
+++ b/src/Drivers/MockCurrencyDriver.php
@@ -1,53 +1,97 @@
-getBaseCurrency(), time(), []);
- }
-
- /**
- * Converts any amount in a given currency to another currency.
- *
- * @param float $amount The amount to convert.
- * @param string $fromCurrency The base currency.
- * @param string $toCurrency The target currency.
- * @param int|string|DateTime $date The date to get the conversion rate for.
- *
- * @return float The conversion result.
- */
- public function convert(float $amount = null, string $fromCurrency = null, string $toCurrency = null, $date = null): float
- {
- return 12.34;
- }
-
- /**
- * @param int|string|DateTime $date
- * @param string|array $forCurrency
- *
- * @return ConversionResult
- */
- function historical($date = null, $forCurrency = []): ConversionResult
- {
- return new ConversionResult($this->getBaseCurrency(), time(), []);
- }
+ protected string $apiURL = 'localhost';
+ protected string $baseCurrency = 'USD';
+
+ /** @var array */
+ private array $rates = [];
+
+ /**
+ * @param array $rates
+ */
+ public function withRates(array $rates): self
+ {
+ $this->rates = $rates;
+
+ return $this;
+ }
+
+ #[Override]
+ public function get(string|Currency|array $forCurrency = []): ConversionResult
+ {
+ if ($forCurrency !== []) {
+ $this->currencies($forCurrency);
+ }
+
+ return new ConversionResult(
+ $this->getBaseCurrency(),
+ DateHelper::format(DateHelper::today()),
+ $this->rates,
+ );
+ }
+
+ #[Override]
+ public function historical(
+ ?DateTimeInterface $date = null,
+ string|Currency|array $forCurrency = [],
+ ): ConversionResult {
+ if ($date instanceof DateTimeInterface) {
+ $this->date($date);
+ }
+
+ if ($forCurrency !== []) {
+ $this->currencies($forCurrency);
+ }
+
+ return new ConversionResult(
+ $this->getBaseCurrency(),
+ $this->getDate() ?? DateHelper::format(DateHelper::today()),
+ $this->rates,
+ );
+ }
+
+ #[Override]
+ public function convert(
+ ?float $amount = null,
+ string|Currency|null $fromCurrency = null,
+ string|Currency|null $toCurrency = null,
+ ?DateTimeInterface $date = null,
+ ): ConversionResult {
+ if ($date instanceof DateTimeInterface) {
+ $this->date($date);
+ }
+
+ if ($amount !== null) {
+ $this->amount = $amount;
+ }
+
+ if ($fromCurrency !== null) {
+ $this->baseCurrency = Currency::code($fromCurrency);
+ }
+
+ if ($toCurrency !== null) {
+ $this->currencies = [Currency::code($toCurrency)];
+ }
+
+ $target = $this->currencies[0] ?? 'EUR';
+
+ return new ConversionResult(
+ $this->getBaseCurrency(),
+ $this->getDate() ?? DateHelper::format(DateHelper::today()),
+ [$target => BigDecimal::of('12.34')],
+ );
+ }
}
diff --git a/src/Drivers/OpenExchangeRates.php b/src/Drivers/OpenExchangeRates.php
index 571c07f..c9d857c 100644
--- a/src/Drivers/OpenExchangeRates.php
+++ b/src/Drivers/OpenExchangeRates.php
@@ -1,162 +1,112 @@
- 'false',
- 'show_alternative' => 'true',
- ];
-
- /**
- * Sets the API key to use. OpenExchangeRates uses app_id instead of access_key
- *
- * Shortcut for config('app_id', $accessKey)
- *
- * @param string $accessKey Your API key.
- *
- * @return self
- * @see CurrencyDriverContract::config()
- *
- */
- public function accessKey(string $accessKey): CurrencyDriverContract
- {
- $this->config('app_id', $accessKey);
- return $this;
- }
-
- /**
- * @param string|array $forCurrency
- *
- * @return ConversionResult
- *
- * @throws CurrencyException
- */
- public function get($forCurrency = []): ConversionResult
- {
- if (!empty((array)$forCurrency)) {
- $this->currencies((array)$forCurrency);
- }
+use Override;
- // Get API response
- $response = $this->apiRequest('latest.json', [
- 'base' => $this->getBaseCurrency(),
- 'symbols' => join(',', $this->getSymbols()),
- ]);
-
- return new ConversionResult($response['base'], $response['timestamp'], $response['rates']);
- }
-
- /**
- * @param int|string|DateTime $date
- * @param string|array $forCurrency
- *
- * @return ConversionResult
- *
- * @throws CurrencyException
- */
- public function historical($date = null, $forCurrency = []): ConversionResult
- {
- // Set date
- $this->date($date);
-
- if (!empty((array)$forCurrency)) {
- $this->currencies((array)$forCurrency);
- }
-
- if (null === $this->getDate()) {
- throw new ApiException('Date needs to be set!');
+class OpenExchangeRates extends BaseCurrencyDriver
+{
+ protected string $apiURL = 'openexchangerates.org/api';
+ protected string $baseCurrency = 'USD';
+
+ /** @var array */
+ protected array $httpParams = [
+ 'prettyprint' => 'false',
+ 'show_alternative' => 'true',
+ ];
+
+ #[Override]
+ public function accessKey(string $accessKey): static
+ {
+ return $this->config('app_id', $accessKey);
}
- // Get API response
- $response = $this->apiRequest("historical/{$this->getDate()}.json", [
- 'base' => $this->getBaseCurrency(),
- 'symbols' => join(',', $this->getSymbols()),
- ]);
-
- return new ConversionResult($response['base'], $response['timestamp'], $response['rates']);
- }
-
- /**
- * Converts any amount in a given currency to another currency.
- *
- * @param float $amount The amount to convert.
- * @param string $fromCurrency The base currency.
- * @param string $toCurrency The target currency.
- * @param int|string|DateTime $date The date to get the conversion rate for.
- *
- * @return float The conversion result.
- *
- * @throws ApiException
- */
- public function convert(float $amount = null, string $fromCurrency = null, string $toCurrency = null, $date = null): float
- {
- // Set date
- $this->date($date);
-
- // Overwrite/set params
- if ($amount !== null) {
- $this->amount = $amount;
+ #[Override]
+ public function get(string|Currency|array $forCurrency = []): ConversionResult
+ {
+ if ($forCurrency !== []) {
+ $this->currencies($forCurrency);
+ }
+
+ $response = $this->apiRequest('latest.json', [
+ 'base' => $this->getBaseCurrency(),
+ 'symbols' => implode(',', $this->getSymbols()),
+ ]);
+
+ return new ConversionResult(
+ $this->responseString($response, 'base', 'OpenExchangeRates'),
+ $this->timestampToDate($this->responseInt($response, 'timestamp', 'OpenExchangeRates')),
+ $this->responseRates($response, 'rates', 'OpenExchangeRates'),
+ );
}
- if ($fromCurrency !== null) {
- $this->baseCurrency = $fromCurrency;
+ #[Override]
+ public function historical(
+ ?DateTimeInterface $date = null,
+ string|Currency|array $forCurrency = [],
+ ): ConversionResult {
+ if ($date instanceof DateTimeInterface) {
+ $this->date($date);
+ }
+
+ if ($forCurrency !== []) {
+ $this->currencies($forCurrency);
+ }
+
+ if ($this->getDate() === null) {
+ throw new ApiException('Date needs to be set!');
+ }
+
+ $response = $this->apiRequest("historical/{$this->getDate()}.json", [
+ 'base' => $this->getBaseCurrency(),
+ 'symbols' => implode(',', $this->getSymbols()),
+ ]);
+
+ return new ConversionResult(
+ $this->responseString($response, 'base', 'OpenExchangeRates'),
+ $this->timestampToDate($this->responseInt($response, 'timestamp', 'OpenExchangeRates')),
+ $this->responseRates($response, 'rates', 'OpenExchangeRates'),
+ );
}
- if ($toCurrency !== null) {
- $this->currencies = [$toCurrency];
+ /**
+ * @param array $params
+ *
+ * @return array
+ */
+ #[Override]
+ protected function apiRequest(string $endpoint, array $params = []): array
+ {
+ $response = parent::apiRequest($endpoint, $params);
+
+ if (($response['error'] ?? false) === true) {
+ throw new ApiException(
+ sprintf(
+ '[%s] %s',
+ (string) ($response['message'] ?? ''),
+ (string) ($response['description'] ?? ''),
+ ),
+ (int) ($response['status'] ?? 0),
+ );
+ }
+
+ return $response;
}
- if (null !== $this->getDate()) {
- $params['date'] = $this->getDate();
- }
+ private function timestampToDate(int|string|null $timestamp): ?string
+ {
+ if ($timestamp === null) {
+ return null;
+ }
- $targetCurrency = reset($this->currencies);
-
- // Get API response
- $response = $this->apiRequest("convert/{$this->amount}/{$this->getBaseCurrency()}/{$targetCurrency}");
-
- // Return the rate as a float
- return floatval($response['response']);
- }
-
- /**
- * Performs an HTTP request.
- *
- * @param string $endpoint The API endpoint.
- * @param array $params The query parameters for this request.
- * @param string $method The HTTP method (defaults to 'GET').
- *
- * @return array|bool The response as decoded JSON.
- *
- * @throws ApiException
- */
- function apiRequest(string $endpoint, array $params = [], string $method = 'GET')
- {
- // Perform actual API request.
- $response = parent::apiRequest($endpoint, $params, $method);
-
- // Handle response exceptions.
- if (isset($response['error']) && $response['error'] == true) {
- throw new ApiException("[{$response['message']}] {$response['description']}", $response['status']);
+ return DateHelper::format(new DateTimeImmutable('@' . $timestamp));
}
-
- return $response;
- }
}
diff --git a/src/Exceptions/ApiException.php b/src/Exceptions/ApiException.php
index 0d3cfb6..8171982 100644
--- a/src/Exceptions/ApiException.php
+++ b/src/Exceptions/ApiException.php
@@ -1,11 +1,9 @@
-format($format);
- } else if ($date === 'now') {
- return date($format);
- } else if (is_string($date)) {
- return (new DateTime($date))->format($format);
- } else {
- $timestamp = (integer)$date;
- return date($format, $timestamp);
+ public static function format(?DateTimeInterface $date, string $format = 'Y-m-d'): ?string
+ {
+ return $date?->format($format);
}
- }
- /**
- * Get a date object by given date or time format
- *
- * Examples::
- *
- * Date.create('2018-12-04')
- * Date.create('first day of next year')
- *
- * @param String $time A date/time string. For valid formats see http://php.net/manual/en/datetime.formats.php
- *
- * @return DateTime
- *
- * @throws Exception
- */
- public static function create(string $time): DateTime
- {
- return new DateTime($time);
- }
-
- /**
- * Get the current date and time
- *
- * Examples::
- *
- * Date.now().timestamp
- *
- * @return DateTime
- *
- * @throws Exception
- */
- public static function now(): DateTime
- {
- return new DateTime('now');
- }
+ public static function now(): DateTimeImmutable
+ {
+ return new DateTimeImmutable('now');
+ }
- /**
- * Get the current date
- *
- * @return DateTime
- *
- * @throws Exception
- */
- public static function today(): DateTime
- {
- return new DateTime('today');
- }
+ public static function today(): DateTimeImmutable
+ {
+ return new DateTimeImmutable('today');
+ }
}
diff --git a/src/Results/ConversionResult.php b/src/Results/ConversionResult.php
index d9f45c2..2398f7d 100644
--- a/src/Results/ConversionResult.php
+++ b/src/Results/ConversionResult.php
@@ -1,141 +1,173 @@
-originalBaseCurrency = $baseCurrency;
- $this->baseCurrency = $baseCurrency;
-
- $this->date = DateHelper::format($date, 'Y-m-d');
-
- $rates[$baseCurrency] = 1.0;
-
- $this->originalConversionRates = $rates;
- $this->conversionRates = $rates;
- }
-
- /**
- * Get base currency.
- *
- * @return string
- */
- public function getBaseCurrency(): string
- {
- return $this->baseCurrency;
- }
-
- /**
- * Set new base currency.
- *
- * @param string $baseCurrency The new base currency.
- *
- * @return self
- *
- * @throws CurrencyException
- */
- public function setBaseCurrency(string $baseCurrency): ConversionResult
- {
- if (!isset($this->conversionRates[$baseCurrency])) {
- throw new CurrencyException("No conversion result for '$baseCurrency'!");
+ public const int DEFAULT_SCALE = 8;
+
+ /**
+ * @var array
+ */
+ public readonly array $originalConversionRates;
+
+ public readonly string $originalBaseCurrency;
+
+ /**
+ * @var array
+ */
+ private array $conversionRates;
+
+ private string $baseCurrency;
+
+ /**
+ * @param array $rates
+ *
+ * @throws MathException If a rate value is not a valid numeric.
+ */
+ public function __construct(
+ string|Currency $baseCurrency,
+ public readonly ?string $date = null,
+ array $rates = [],
+ public readonly int $scale = self::DEFAULT_SCALE,
+ ) {
+ $code = Currency::code($baseCurrency);
+
+ $this->originalBaseCurrency = $code;
+ $this->baseCurrency = $code;
+
+ $normalised = [];
+ foreach ($rates as $currency => $rate) {
+ $normalised[(string) $currency] = $this->toBigDecimal($rate);
+ }
+ $normalised[$code] = BigDecimal::one();
+
+ $this->originalConversionRates = $normalised;
+ $this->conversionRates = $normalised;
}
- if ($baseCurrency == $this->originalBaseCurrency) {
- $this->conversionRates = $this->originalConversionRates;
- return $this;
+ public function getBaseCurrency(): string
+ {
+ return $this->baseCurrency;
}
- // Calculate new conversion rates.
- foreach ($this->originalConversionRates as $currency => $rate) {
- $this->conversionRates[$currency] = (float)$rate / (float)$this->originalConversionRates[$baseCurrency];
+ public function getDate(): ?string
+ {
+ return $this->date;
}
- // Set new base currency.
- $this->baseCurrency = $baseCurrency;
- $this->conversionRates[$baseCurrency] = 1.0;
-
- // Return self
- return $this;
- }
-
- /**
- * Get date.
- */
- public function getDate()
- {
- return $this->date;
- }
-
- /**
- * @param string $currency
- *
- * @return float
- *
- * @throws CurrencyException
- */
- public function rate(string $currency): float
- {
- if (!isset($this->conversionRates[$currency])) {
- throw new CurrencyException("No conversion result for $currency!");
+ /**
+ * @throws CurrencyException
+ */
+ public function setBaseCurrency(string|Currency $baseCurrency): self
+ {
+ $code = Currency::code($baseCurrency);
+
+ if (!isset($this->originalConversionRates[$code])) {
+ throw new CurrencyException("No conversion result for '{$code}'!");
+ }
+
+ if ($code === $this->originalBaseCurrency) {
+ $this->conversionRates = $this->originalConversionRates;
+ $this->baseCurrency = $code;
+
+ return $this;
+ }
+
+ $divisor = $this->originalConversionRates[$code];
+
+ $rebased = [];
+ foreach ($this->originalConversionRates as $currency => $rate) {
+ $rebased[$currency] = $rate->dividedBy($divisor, $this->scale, RoundingMode::HALF_UP);
+ }
+ $rebased[$code] = BigDecimal::one();
+
+ $this->conversionRates = $rebased;
+ $this->baseCurrency = $code;
+
+ return $this;
}
- return $this->conversionRates[$currency];
- }
-
- /**
- * @param float $amount
- * @param string $fromCurrency
- * @param string $toCurrency
- *
- * @return float
- *
- * @throws CurrencyException
- */
- function convert(float $amount, string $fromCurrency, string $toCurrency): float
- {
- if (!isset($this->conversionRates[$toCurrency])) {
- throw new CurrencyException("No conversion result for '$toCurrency'!");
+ /**
+ * @throws CurrencyException
+ */
+ public function rate(string|Currency $currency): BigDecimal
+ {
+ $code = Currency::code($currency);
+
+ if (!isset($this->conversionRates[$code])) {
+ throw new CurrencyException("No conversion result for {$code}!");
+ }
+
+ return $this->conversionRates[$code];
+ }
+
+ /**
+ * @throws CurrencyException
+ */
+ public function rateAsFloat(string|Currency $currency): float
+ {
+ return $this->rate($currency)->toFloat();
}
- if (!isset($this->conversionRates[$fromCurrency])) {
- throw new CurrencyException("No conversion result for '$fromCurrency'!");
+ /**
+ * @throws CurrencyException
+ */
+ public function convert(
+ BigDecimal|float|int|string $amount,
+ string|Currency $fromCurrency,
+ string|Currency $toCurrency,
+ ): BigDecimal {
+ $from = Currency::code($fromCurrency);
+ $to = Currency::code($toCurrency);
+
+ if (!isset($this->originalConversionRates[$to])) {
+ throw new CurrencyException("No conversion result for '{$to}'!");
+ }
+
+ if (!isset($this->originalConversionRates[$from])) {
+ throw new CurrencyException("No conversion result for '{$from}'!");
+ }
+
+ return $this->toBigDecimal($amount)
+ ->multipliedBy($this->originalConversionRates[$to])
+ ->dividedBy($this->originalConversionRates[$from], $this->scale, RoundingMode::HALF_UP);
}
- return $amount * (float)$this->originalConversionRates[$toCurrency] / (float)$this->originalConversionRates[$fromCurrency];
- }
+ /**
+ * @return array
+ */
+ public function all(): array
+ {
+ return $this->conversionRates;
+ }
+
+ /**
+ * @return array
+ */
+ public function allAsFloats(): array
+ {
+ $floats = [];
+ foreach ($this->conversionRates as $code => $rate) {
+ $floats[$code] = $rate->toFloat();
+ }
+
+ return $floats;
+ }
- /**
- * @return array
- */
- public function all(): array
- {
- return $this->conversionRates;
- }
+ /**
+ * @throws MathException
+ */
+ private function toBigDecimal(BigDecimal|float|int|string $value): BigDecimal
+ {
+ return $value instanceof BigDecimal ? $value : BigDecimal::of($value);
+ }
}
diff --git a/src/Symbol.php b/src/Symbol.php
index e168f1b..9679ba9 100644
--- a/src/Symbol.php
+++ b/src/Symbol.php
@@ -1,382 +1,241 @@
-
+ *
+ * @deprecated since 2.0, use Otherguy\Currency\Currency::cases() and ->value
+ */
+ public static function all(): array
+ {
+ self::noticeDeprecation();
+
+ return array_map(static fn (Currency $c): string => $c->value, Currency::cases());
+ }
+
+ /**
+ * @deprecated since 2.0, use Otherguy\Currency\Currency::tryFrom($currency)?->displayName()
+ */
+ public static function name(string $currency): string
+ {
+ self::noticeDeprecation();
+
+ return Currency::tryFrom($currency)?->displayName() ?? '';
+ }
+
+ /**
+ * @return array
+ *
+ * @deprecated since 2.0, build via array_combine of Currency::cases()
+ */
+ public static function names(): array
+ {
+ self::noticeDeprecation();
+
+ $names = [];
+ foreach (Currency::cases() as $case) {
+ $names[$case->value] = $case->displayName();
+ }
- protected static $currencyNames = [
- 'AED' => 'United Arab Emirates Dirham',
- 'AFN' => 'Afghan Afghani',
- 'ALL' => 'Albanian Lek',
- 'AMD' => 'Armenian Dram',
- 'ANG' => 'Netherlands Antillean Guilder',
- 'AOA' => 'Angolan Kwanza',
- 'ARS' => 'Argentine Peso',
- 'AUD' => 'Australian Dollar',
- 'AWG' => 'Aruban Florin',
- 'AZN' => 'Azerbaijani Manat',
- 'BAM' => 'Bosnia Herzegovina Convertible Mark',
- 'BBD' => 'Barbadian Dollar',
- 'BDT' => 'Bangladeshi Taka',
- 'BGN' => 'Bulgarian Lev',
- 'BHD' => 'Bahraini Dinar',
- 'BIF' => 'Burundian Franc',
- 'BMD' => 'Bermudan Dollar',
- 'BND' => 'Brunei Dollar',
- 'BOB' => 'Bolivian Boliviano',
- 'BRL' => 'Brazilian Real',
- 'BSD' => 'Bahamian Dollar',
- 'BTC' => 'Bitcoin',
- 'BTN' => 'Bhutanese Ngultrum',
- 'BWP' => 'Botswanan Pula',
- 'BYR' => 'Belarusian Ruble',
- 'BYN' => 'New Belarusian Ruble',
- 'BZD' => 'Belize Dollar',
- 'CAD' => 'Canadian Dollar',
- 'CDF' => 'Congolese Franc',
- 'CHF' => 'Swiss Franc',
- 'CLF' => 'Chilean Unit of Account',
- 'CLP' => 'Chilean Peso',
- 'CNY' => 'Chinese Yuan',
- 'COP' => 'Colombian Peso',
- 'CRC' => 'Costa Rican Colón',
- 'CUC' => 'Cuban Convertible Peso',
- 'CUP' => 'Cuban Peso',
- 'CVE' => 'Cape Verdean Escudo',
- 'CZK' => 'Czech Republic Koruna',
- 'DJF' => 'Djiboutian Franc',
- 'DKK' => 'Danish Krone',
- 'DOP' => 'Dominican Peso',
- 'DZD' => 'Algerian Dinar',
- 'EGP' => 'Egyptian Pound',
- 'ERN' => 'Eritrean Nakfa',
- 'ETB' => 'Ethiopian Birr',
- 'EUR' => 'Euro',
- 'FJD' => 'Fijian Dollar',
- 'FKP' => 'Falkland Islands Pound',
- 'GBP' => 'British Pound Sterling',
- 'GEL' => 'Georgian Lari',
- 'GGP' => 'Guernsey Pound',
- 'GHS' => 'Ghanaian Cedi',
- 'GIP' => 'Gibraltar Pound',
- 'GMD' => 'Gambian Dalasi',
- 'GNF' => 'Guinean Franc',
- 'GTQ' => 'Guatemalan Quetzal',
- 'GYD' => 'Guyanaese Dollar',
- 'HKD' => 'Hong Kong Dollar',
- 'HNL' => 'Honduran Lempira',
- 'HRK' => 'Croatian Kuna',
- 'HTG' => 'Haitian Gourde',
- 'HUF' => 'Hungarian Forint',
- 'IDR' => 'Indonesian Rupiah',
- 'ILS' => 'Israeli New Sheqel',
- 'IMP' => 'Manx pound',
- 'INR' => 'Indian Rupee',
- 'IQD' => 'Iraqi Dinar',
- 'IRR' => 'Iranian Rial',
- 'ISK' => 'Icelandic Króna',
- 'JEP' => 'Jersey Pound',
- 'JMD' => 'Jamaican Dollar',
- 'JOD' => 'Jordanian Dinar',
- 'JPY' => 'Japanese Yen',
- 'KES' => 'Kenyan Shilling',
- 'KGS' => 'Kyrgystani Som',
- 'KHR' => 'Cambodian Riel',
- 'KMF' => 'Comorian Franc',
- 'KPW' => 'North Korean Won',
- 'KRW' => 'South Korean Won',
- 'KWD' => 'Kuwaiti Dinar',
- 'KYD' => 'Cayman Islands Dollar',
- 'KZT' => 'Kazakhstani Tenge',
- 'LAK' => 'Laotian Kip',
- 'LBP' => 'Lebanese Pound',
- 'LKR' => 'Sri Lankan Rupee',
- 'LRD' => 'Liberian Dollar',
- 'LSL' => 'Lesotho Loti',
- 'LTL' => 'Lithuanian Litas',
- 'LVL' => 'Latvian Lats',
- 'LYD' => 'Libyan Dinar',
- 'MAD' => 'Moroccan Dirham',
- 'MDL' => 'Moldovan Leu',
- 'MGA' => 'Malagasy Ariary',
- 'MKD' => 'Macedonian Denar',
- 'MMK' => 'Myanma Kyat',
- 'MNT' => 'Mongolian Tugrik',
- 'MOP' => 'Macanese Pataca',
- 'MRO' => 'Mauritanian Ouguiya',
- 'MUR' => 'Mauritian Rupee',
- 'MVR' => 'Maldivian Rufiyaa',
- 'MWK' => 'Malawian Kwacha',
- 'MXN' => 'Mexican Peso',
- 'MYR' => 'Malaysian Ringgit',
- 'MZN' => 'Mozambican Metical',
- 'NAD' => 'Namibian Dollar',
- 'NGN' => 'Nigerian Naira',
- 'NIO' => 'Nicaraguan Córdoba',
- 'NOK' => 'Norwegian Krone',
- 'NPR' => 'Nepalese Rupee',
- 'NZD' => 'New Zealand Dollar',
- 'OMR' => 'Omani Rial',
- 'PAB' => 'Panamanian Balboa',
- 'PEN' => 'Peruvian Nuevo Sol',
- 'PGK' => 'Papua New Guinean Kina',
- 'PHP' => 'Philippine Peso',
- 'PKR' => 'Pakistani Rupee',
- 'PLN' => 'Polish Zloty',
- 'PYG' => 'Paraguayan Guarani',
- 'QAR' => 'Qatari Rial',
- 'RON' => 'Romanian Leu',
- 'RSD' => 'Serbian Dinar',
- 'RUB' => 'Russian Ruble',
- 'RWF' => 'Rwandan Franc',
- 'SAR' => 'Saudi Riyal',
- 'SBD' => 'Solomon Islands Dollar',
- 'SCR' => 'Seychellois Rupee',
- 'SDG' => 'Sudanese Pound',
- 'SEK' => 'Swedish Krona',
- 'SGD' => 'Singapore Dollar',
- 'SHP' => 'Saint Helena Pound',
- 'SLL' => 'Sierra Leonean Leone',
- 'SOS' => 'Somali Shilling',
- 'SRD' => 'Surinamese Dollar',
- 'STD' => 'São Tomé and Príncipe Dobra',
- 'SVC' => 'Salvadoran Colón',
- 'SYP' => 'Syrian Pound',
- 'SZL' => 'Swazi Lilangeni',
- 'THB' => 'Thai Baht',
- 'TJS' => 'Tajikistani Somoni',
- 'TMT' => 'Turkmenistani Manat',
- 'TND' => 'Tunisian Dinar',
- 'TOP' => 'Tongan Paʻanga',
- 'TRY' => 'Turkish Lira',
- 'TTD' => 'Trinidad and Tobago Dollar',
- 'TWD' => 'New Taiwan Dollar',
- 'TZS' => 'Tanzanian Shilling',
- 'UAH' => 'Ukrainian Hryvnia',
- 'UGX' => 'Ugandan Shilling',
- 'USD' => 'United States Dollar',
- 'UYU' => 'Uruguayan Peso',
- 'UZS' => 'Uzbekistan Som',
- 'VEF' => 'Venezuelan Bolívar Fuerte',
- 'VND' => 'Vietnamese Dong',
- 'VUV' => 'Vanuatu Vatu',
- 'WST' => 'Samoan Tala',
- 'XAF' => 'CFA Franc BEAC',
- 'XAG' => 'Silver',
- 'XAU' => 'Gold',
- 'XCD' => 'East Caribbean Dollar',
- 'XDR' => 'Special Drawing Rights',
- 'XOF' => 'CFA Franc BCEAO',
- 'XPF' => 'CFP Franc',
- 'YER' => 'Yemeni Rial',
- 'ZAR' => 'South African Rand',
- 'ZMW' => 'Zambian Kwacha',
- 'ZWL' => 'Zimbabwean Dollar',
- ];
+ return $names;
+ }
- /**
- * @return array
- *
- * @throws ReflectionException
- */
- public static function all(): array
- {
- return array_values((new ReflectionClass(static::class))->getConstants());
- }
+ /**
+ * @internal Test-only helper to re-arm the once-per-process deprecation notice.
+ */
+ public static function resetDeprecationNotice(): void
+ {
+ self::$deprecationNoticed = false;
+ }
- /**
- * Retrieve the name of a currency by its symbol.
- *
- * @param mixed $currency The currency symbol.
- *
- * @return string
- */
- public static function name(string $currency): string
- {
- return isset(static::$currencyNames[$currency]) ? static::$currencyNames[$currency] : null;
- }
+ private static function noticeDeprecation(): void
+ {
+ if (self::$deprecationNoticed) {
+ return;
+ }
- /**
- * Retrieve an array of all currencies with their names.
- *
- * @return array
- */
- public static function names(): array
- {
- return static::$currencyNames;
- }
+ self::$deprecationNoticed = true;
+ trigger_error(
+ 'Otherguy\\Currency\\Symbol is deprecated since 2.0. Use Otherguy\\Currency\\Currency instead.',
+ E_USER_DEPRECATED,
+ );
+ }
}
diff --git a/tests/CurrencyTest.php b/tests/CurrencyTest.php
new file mode 100644
index 0000000..f69fd2d
--- /dev/null
+++ b/tests/CurrencyTest.php
@@ -0,0 +1,48 @@
+assertCount(167, Currency::cases());
+ }
+
+ #[Test]
+ public function backed_value_matches_iso_code(): void
+ {
+ $this->assertSame('USD', Currency::USD->value);
+ $this->assertSame('EUR', Currency::EUR->value);
+ $this->assertSame('BTC', Currency::BTC->value);
+ }
+
+ #[Test]
+ public function display_name_resolves_human_readable_label(): void
+ {
+ $this->assertSame('Bitcoin', Currency::BTC->displayName());
+ $this->assertSame('Lithuanian Litas', Currency::LTL->displayName());
+ $this->assertSame('United States Dollar', Currency::USD->displayName());
+ }
+
+ #[Test]
+ public function try_from_code_returns_matching_case(): void
+ {
+ $this->assertSame(Currency::USD, Currency::tryFromCode('USD'));
+ $this->assertNull(Currency::tryFromCode('XYZ'));
+ }
+
+ #[Test]
+ public function code_helper_coerces_strings_and_enum_values(): void
+ {
+ $this->assertSame('USD', Currency::code('USD'));
+ $this->assertSame('USD', Currency::code(Currency::USD));
+ }
+}
diff --git a/tests/DriverFactoryTest.php b/tests/DriverFactoryTest.php
index a85e1f6..994c7eb 100644
--- a/tests/DriverFactoryTest.php
+++ b/tests/DriverFactoryTest.php
@@ -1,33 +1,110 @@
assertInstanceOf(CurrencyDriverContract::class, DriverFactory::make('mock'));
- }
-
- /** @test */
- public function will_throw_exception_if_using_invalid_driver()
- {
- $this->expectException(DriverNotFoundException::class);
- DriverFactory::make('nonexistent-currency-api-driver');
- }
-
- /** @test */
- public function can_get_list_of_drivers()
- {
-
- $this->assertIsArray(DriverFactory::getDrivers());
- $this->assertArrayHasKey('mock', DriverFactory::getDrivers());
- }
+ protected function tearDown(): void
+ {
+ DriverFactory::setDefault(null);
+ }
+
+ #[Test]
+ public function default_factory_can_build_a_driver(): void
+ {
+ $this->assertInstanceOf(CurrencyDriverContract::class, DriverFactory::make('mock'));
+ }
+
+ #[Test]
+ public function unknown_driver_throws_driver_not_found_exception(): void
+ {
+ $this->expectException(DriverNotFoundException::class);
+ DriverFactory::make('nonexistent-currency-api-driver');
+ }
+
+ #[Test]
+ public function default_drivers_include_built_in_set(): void
+ {
+ $drivers = DriverFactory::getDrivers();
+
+ foreach (['mock', 'fixerio', 'currencylayer', 'openexchangerates', 'exchangeratesapi', 'frankfurter', 'currencyapi', 'fastforex'] as $name) {
+ $this->assertArrayHasKey($name, $drivers);
+ }
+ }
+
+ #[Test]
+ public function default_factory_can_build_triptally_provider_drivers(): void
+ {
+ $factory = new DriverFactory();
+ $http = new MockHttpClient();
+
+ $this->assertInstanceOf(
+ CurrencyApi::class,
+ $factory->build('currencyapi', $http, new RequestFactory()),
+ );
+ $this->assertInstanceOf(
+ FastForex::class,
+ $factory->build('fastforex', $http, new RequestFactory()),
+ );
+ }
+
+ #[Test]
+ public function build_accepts_custom_psr18_client_and_psr17_factory(): void
+ {
+ $factory = new DriverFactory();
+ $http = new MockHttpClient();
+
+ $driver = $factory->build('frankfurter', $http, new RequestFactory());
+
+ $this->assertInstanceOf(Frankfurter::class, $driver);
+ }
+
+ #[Test]
+ public function register_adds_a_custom_driver_class(): void
+ {
+ $factory = new DriverFactory();
+ $factory->register('custom-mock', MockCurrencyDriver::class);
+
+ $this->assertArrayHasKey('custom-mock', $factory->drivers());
+ $this->assertInstanceOf(
+ MockCurrencyDriver::class,
+ $factory->build('custom-mock', new MockHttpClient(), new RequestFactory()),
+ );
+ }
+
+ #[Test]
+ public function unregister_removes_a_driver(): void
+ {
+ $factory = new DriverFactory();
+ $factory->register('temp', MockCurrencyDriver::class);
+ $factory->unregister('temp');
+
+ $this->assertArrayNotHasKey('temp', $factory->drivers());
+
+ $this->expectException(DriverNotFoundException::class);
+ $factory->build('temp', new MockHttpClient(), new RequestFactory());
+ }
+
+ #[Test]
+ public function set_default_replaces_static_singleton(): void
+ {
+ $custom = new DriverFactory(['only' => MockCurrencyDriver::class]);
+ DriverFactory::setDefault($custom);
+
+ $this->assertSame(['only' => MockCurrencyDriver::class], DriverFactory::getDrivers());
+ }
}
diff --git a/tests/Drivers/BaseCurrencyDriverTest.php b/tests/Drivers/BaseCurrencyDriverTest.php
index fa8eb36..0ad9b57 100644
--- a/tests/Drivers/BaseCurrencyDriverTest.php
+++ b/tests/Drivers/BaseCurrencyDriverTest.php
@@ -1,113 +1,135 @@
valid_json_response),
- new Response(404, [], '404 - Not Found'),
- new Response(200, [], 'Cannot reach upstream currency data server!'),
- ]);
-
- $this->baseCurrencyDriver = DriverFactory::make('mock', new Client(['handler' => HandlerStack::create($mock)]));
- }
-
- /** @test */
- public function init_will_properly_set_parameters()
- {
- $this->baseCurrencyDriver->source(Symbol::ANG)->currencies([Symbol::DKK, Symbol::USD]);
- $this->assertEquals([Symbol::DKK, Symbol::USD], $this->baseCurrencyDriver->getSymbols());
- $this->assertEquals(Symbol::ANG, $this->baseCurrencyDriver->getBaseCurrency());
- }
-
- /** @test */
- public function will_properly_switch_to_https()
- {
- $this->assertEquals('http', $this->baseCurrencyDriver->getProtocol());
- $this->assertEquals('https', $this->baseCurrencyDriver->secure()->getProtocol());
- }
-
- /** @test */
- public function from_sets_base_currency()
- {
- $this->assertNotEquals(Symbol::BTC, $this->baseCurrencyDriver->getBaseCurrency());
- $this->assertEquals(Symbol::BTC, $this->baseCurrencyDriver->from(Symbol::BTC)->getBaseCurrency());
- }
-
- /** @test */
- public function to_sets_target_currency()
- {
- $this->assertIsArray($this->baseCurrencyDriver->getSymbols());
- $this->assertCount(2, $this->baseCurrencyDriver->to([Symbol::BTC, Symbol::LTL])->getSymbols());
- $this->assertEquals([Symbol::BTC, Symbol::LTL], $this->baseCurrencyDriver->to([Symbol::BTC, Symbol::LTL])->getSymbols());
- }
-
- /** @test */
- public function setters_are_fluent()
- {
- $this->assertInstanceOf(MockCurrencyDriver::class, $this->baseCurrencyDriver->source(Symbol::BTC));
- $this->assertInstanceOf(MockCurrencyDriver::class, $this->baseCurrencyDriver->from(Symbol::BTC));
- $this->assertInstanceOf(MockCurrencyDriver::class, $this->baseCurrencyDriver->amount(12));
- $this->assertInstanceOf(MockCurrencyDriver::class, $this->baseCurrencyDriver->to(Symbol::LTL));
- $this->assertInstanceOf(MockCurrencyDriver::class, $this->baseCurrencyDriver->currencies(Symbol::LTL));
- $this->assertInstanceOf(MockCurrencyDriver::class, $this->baseCurrencyDriver->secure());
- $this->assertInstanceOf(MockCurrencyDriver::class, $this->baseCurrencyDriver->config('test', 'value'));
- $this->assertInstanceOf(MockCurrencyDriver::class, $this->baseCurrencyDriver->accessKey('access key'));
- $this->assertInstanceOf(MockCurrencyDriver::class, $this->baseCurrencyDriver->date(time()));
- }
-
- /** @test */
- public function can_set_and_retrieve_date()
- {
- $this->assertEquals('2019-06-11', $this->baseCurrencyDriver->date(1560293762)->getDate());
- $this->assertEquals('2019-06-11', $this->baseCurrencyDriver->date('2019-06-11')->getDate());
- $this->assertEquals('2019-06-11', $this->baseCurrencyDriver->date(DateTime::createFromFormat('d.m.Y', '11.6.2019'))->getDate());
- }
-
- /** @test */
- public function can_perform_api_requests()
- {
- $response = $this->baseCurrencyDriver->apiRequest('test');
-
- $this->assertIsArray($response);
- $this->assertIsArray($response['rates']);
- $this->assertEquals('2019-06-11', $response['date']);
- $this->assertEquals(Symbol::USD, $response['base']);
-
- try {
- $this->baseCurrencyDriver->apiRequest('fail');
- } catch (ApiException $exception) {
- $this->assertInstanceOf(ApiException::class, $exception);
- $this->assertEquals(404, $exception->getCode());
- $this->assertInstanceOf(MockCurrencyDriver::class, $this->baseCurrencyDriver);
+ private MockHttpClient $http;
+ private TestableDriver $driver;
+
+ protected function setUp(): void
+ {
+ $this->http = new MockHttpClient();
+ $this->driver = new TestableDriver($this->http, new RequestFactory());
+ }
+
+ #[Test]
+ public function init_will_properly_set_parameters(): void
+ {
+ $this->driver->source(Currency::ANG)->currencies([Currency::DKK, Currency::USD]);
+
+ $this->assertSame(['DKK', 'USD'], $this->driver->getSymbols());
+ $this->assertSame('ANG', $this->driver->getBaseCurrency());
+ }
+
+ #[Test]
+ public function defaults_to_https_protocol(): void
+ {
+ $this->assertSame('https', $this->driver->getProtocol());
+ $this->assertSame('https', $this->driver->secure()->getProtocol());
+ }
+
+ #[Test]
+ public function from_sets_base_currency(): void
+ {
+ $this->assertNotSame('BTC', $this->driver->getBaseCurrency());
+ $this->assertSame('BTC', $this->driver->from(Currency::BTC)->getBaseCurrency());
+ }
+
+ #[Test]
+ public function to_sets_target_currencies(): void
+ {
+ $this->assertSame(
+ ['BTC', 'LTL'],
+ $this->driver->to([Currency::BTC, Currency::LTL])->getSymbols(),
+ );
+ }
+
+ #[Test]
+ public function setters_are_fluent(): void
+ {
+ $factory = new RequestFactory();
+ $base = new MockCurrencyDriver($this->http, $factory);
+
+ $this->assertInstanceOf(MockCurrencyDriver::class, $base->source(Currency::BTC));
+ $this->assertInstanceOf(MockCurrencyDriver::class, $base->from(Currency::BTC));
+ $this->assertInstanceOf(MockCurrencyDriver::class, $base->amount(12.0));
+ $this->assertInstanceOf(MockCurrencyDriver::class, $base->to(Currency::LTL));
+ $this->assertInstanceOf(MockCurrencyDriver::class, $base->currencies(Currency::LTL));
+ $this->assertInstanceOf(MockCurrencyDriver::class, $base->secure());
+ $this->assertInstanceOf(MockCurrencyDriver::class, $base->config('test', 'value'));
+ $this->assertInstanceOf(MockCurrencyDriver::class, $base->accessKey('access key'));
+ $this->assertInstanceOf(MockCurrencyDriver::class, $base->date(new DateTimeImmutable('2019-06-11')));
+ }
+
+ #[Test]
+ public function date_setter_formats_to_iso_date(): void
+ {
+ $this->driver->date(new DateTimeImmutable('@1560293762'));
+ $this->assertSame('2019-06-11', $this->driver->getDate());
+
+ $this->driver->date(new DateTimeImmutable('2019-06-11'));
+ $this->assertSame('2019-06-11', $this->driver->getDate());
+
+ $this->driver->date(null);
+ $this->assertNull($this->driver->getDate());
+ }
+
+ #[Test]
+ public function api_request_decodes_json_response_body(): void
+ {
+ $this->http->enqueue(new Response(200, [], '{"success":true,"base":"USD","date":"2019-06-11","rates":{"JPY":107.346001,"EUR":0.813399}}'));
+
+ /** @var array{base: string, date: string, rates: array} $response */
+ $response = $this->driver->callApi('latest');
+
+ $this->assertSame('USD', $response['base']);
+ $this->assertSame('2019-06-11', $response['date']);
+ $this->assertSame(107.346001, $response['rates']['JPY']);
+ }
+
+ #[Test]
+ public function api_request_wraps_invalid_json_in_api_exception(): void
+ {
+ $this->http->enqueue(new Response(200, [], 'Cannot reach upstream currency data server!'));
+
+ $this->expectException(ApiException::class);
+ $this->driver->callApi('nojson');
+ }
+
+ #[Test]
+ public function api_request_includes_http_params_in_query_string(): void
+ {
+ $this->http->enqueue(new Response(200, [], '{"ok":true}'));
+
+ $this->driver->config('access_key', 'secret');
+ $this->driver->callApi('latest', ['base' => 'USD']);
+
+ $uri = (string) $this->http->lastRequest()?->getUri();
+ $this->assertStringContainsString('access_key=secret', $uri);
+ $this->assertStringContainsString('base=USD', $uri);
+ $this->assertStringStartsWith('https://example.test/latest?', $uri);
}
- try {
- $this->baseCurrencyDriver->apiRequest('nojson');
- } catch (ApiException $exception) {
- $this->assertInstanceOf(ApiException::class, $exception);
- $this->assertEquals(JSON_ERROR_SYNTAX, $exception->getCode());
- $this->assertEquals('Syntax error', $exception->getMessage());
+ #[Test]
+ public function api_request_omits_query_string_when_no_params(): void
+ {
+ $this->http->enqueue(new Response(200, [], '{"ok":true}'));
+
+ $this->driver->callApi('ping');
+
+ $this->assertSame('https://example.test/ping', (string) $this->http->lastRequest()?->getUri());
}
- }
}
diff --git a/tests/Drivers/CurrencyApiTest.php b/tests/Drivers/CurrencyApiTest.php
new file mode 100644
index 0000000..9cc579a
--- /dev/null
+++ b/tests/Drivers/CurrencyApiTest.php
@@ -0,0 +1,126 @@
+harness = new DriverHarness();
+ $driver = $this->harness->make('currencyapi');
+ $this->assertInstanceOf(CurrencyApi::class, $driver);
+ $this->driver = $driver;
+ }
+
+ #[Test]
+ public function access_key_is_sent_as_apikey_header(): void
+ {
+ $this->harness->http->enqueue(new Response(200, [], '{"meta":{"last_updated_at":"2025-10-11T10:15:59Z"},"data":{"EUR":{"code":"EUR","value":0.8601}}}'));
+
+ $this->driver->accessKey('currencyapi-token')->from(Currency::USD)->get([Currency::EUR]);
+
+ $request = $this->harness->http->lastRequest();
+ $this->assertNotNull($request);
+ $this->assertSame(['currencyapi-token'], $request->getHeader('apikey'));
+ $this->assertStringNotContainsString('apikey=', (string) $request->getUri());
+ }
+
+ #[Test]
+ public function can_get_latest_rates(): void
+ {
+ $this->harness->http->enqueue(new Response(200, [], '{"meta":{"last_updated_at":"2025-10-11T10:15:59Z"},"data":{"EUR":{"code":"EUR","value":0.8601},"GBP":{"code":"GBP","value":0.7462}}}'));
+
+ $result = $this->driver->from(Currency::USD)->get([Currency::EUR, Currency::GBP]);
+
+ $this->assertInstanceOf(ConversionResult::class, $result);
+ $this->assertSame('USD', $result->getBaseCurrency());
+ $this->assertSame('2025-10-11', $result->getDate());
+ $this->assertSame('0.8601', (string) $result->rate(Currency::EUR));
+ $this->assertSame('0.7462', (string) $result->rate(Currency::GBP));
+
+ $uri = (string) $this->harness->http->lastRequest()?->getUri();
+ $this->assertStringContainsString('https://api.currencyapi.com/v3/latest', $uri);
+ $this->assertStringContainsString('base_currency=USD', $uri);
+ $this->assertStringContainsString('currencies=EUR%2CGBP', $uri);
+ }
+
+ #[Test]
+ public function can_get_historical_rates(): void
+ {
+ $this->harness->http->enqueue(new Response(200, [], '{"meta":{"last_updated_at":"2025-10-10T23:59:59Z"},"data":{"EUR":{"code":"EUR","value":0.8601}}}'));
+
+ $result = $this->driver->from(Currency::USD)->historical(new DateTimeImmutable('2025-10-10'), [Currency::EUR]);
+
+ $this->assertSame('USD', $result->getBaseCurrency());
+ $this->assertSame('2025-10-10', $result->getDate());
+ $this->assertSame('0.8601', (string) $result->rate(Currency::EUR));
+
+ $uri = (string) $this->harness->http->lastRequest()?->getUri();
+ $this->assertStringContainsString('https://api.currencyapi.com/v3/historical', $uri);
+ $this->assertStringContainsString('date=2025-10-10', $uri);
+ }
+
+ #[Test]
+ public function can_convert_currency_amounts(): void
+ {
+ $this->harness->http->enqueue(new Response(200, [], '{"meta":{"last_updated_at":"2025-10-11T10:15:59Z"},"data":{"value":86.01}}'));
+
+ $result = $this->driver->convert(100.0, Currency::USD, Currency::EUR);
+
+ $this->assertInstanceOf(ConversionResult::class, $result);
+ $this->assertSame('USD', $result->getBaseCurrency());
+ $this->assertSame('2025-10-11', $result->getDate());
+ $this->assertEqualsWithDelta(0.8601, $result->rateAsFloat(Currency::EUR), 0.000001);
+
+ $uri = (string) $this->harness->http->lastRequest()?->getUri();
+ $this->assertStringContainsString('https://api.currencyapi.com/v3/convert', $uri);
+ $this->assertStringContainsString('value=100', $uri);
+ $this->assertStringContainsString('base_currency=USD', $uri);
+ $this->assertStringContainsString('currencies=EUR', $uri);
+ }
+
+ #[Test]
+ public function convert_uses_historical_rates_when_date_given(): void
+ {
+ $this->harness->http->enqueue(new Response(200, [], '{"meta":{"last_updated_at":"2025-10-10T23:59:59Z"},"data":{"EUR":{"code":"EUR","value":0.8601}}}'));
+
+ $result = $this->driver->convert(100.0, Currency::USD, Currency::EUR, new DateTimeImmutable('2025-10-10'));
+
+ $this->assertSame('2025-10-10', $result->getDate());
+ $this->assertSame('0.8601', (string) $result->rate(Currency::EUR));
+
+ $uri = (string) $this->harness->http->lastRequest()?->getUri();
+ $this->assertStringContainsString('/v3/historical', $uri);
+ }
+
+ #[Test]
+ public function fails_to_get_historical_rates_if_date_not_set(): void
+ {
+ $this->expectException(ApiException::class);
+ $this->driver->from(Currency::USD)->to(Currency::EUR)->historical();
+ }
+
+ #[Test]
+ public function can_handle_response_failures(): void
+ {
+ $this->harness->http->enqueue(new Response(422, [], '{"message":"Validation error","errors":{"currencies":["The selected currencies is invalid."]}}'));
+
+ $this->expectException(ApiException::class);
+ $this->driver->from(Currency::USD)->to('ZZZ')->get();
+ }
+}
diff --git a/tests/Drivers/CurrencyLayerTest.php b/tests/Drivers/CurrencyLayerTest.php
index db3b792..a133589 100644
--- a/tests/Drivers/CurrencyLayerTest.php
+++ b/tests/Drivers/CurrencyLayerTest.php
@@ -1,96 +1,102 @@
mockHandler = new MockHandler();
- $this->currencyLayer = DriverFactory::make('currencylayer', new Client(['handler' => $this->mockHandler]));
- }
-
- /** @test */
- public function can_get_latest_rates()
- {
- // Response from https://currencylayer.com/documentation
- $this->mockHandler->append(new Response(200, [], '{"success":true,"terms":"https://currencylayer.com/terms","privacy":"https://currencylayer.com/privacy","timestamp":1432400348,"source":"USD","quotes":{"USDAUD":1.278342,"USDEUR":1.278342,"USDGBP":0.908019,"USDPLN":3.731504}}'));
-
- $result = $this->currencyLayer->from(Symbol::USD)->get([Symbol::AUD, Symbol::EUR, Symbol::GBP, Symbol::PLN]);
-
- $this->assertInstanceOf(ConversionResult::class, $result);
-
- $this->assertEquals(Symbol::USD, $result->getBaseCurrency());
- $this->assertEquals('2015-05-23', $result->getDate());
-
- $this->assertEquals(1.278342, $result->rate(Symbol::AUD));
- $this->assertEquals(1.278342, $result->rate(Symbol::EUR));
- $this->assertEquals(0.908019, $result->rate(Symbol::GBP));
- $this->assertEquals(3.731504, $result->rate(Symbol::PLN));
- }
-
-
- /** @test */
- public function can_get_historical_rates()
- {
- // Response from https://currencylayer.com/documentation
- $this->mockHandler->append(new Response(200, [], '{"success":true,"terms":"https://currencylayer.com/terms","privacy":"https://currencylayer.com/privacy","historical":true,"date":"2005-02-01","timestamp":1107302399,"source":"USD","quotes":{"USDAED":3.67266,"USDALL":96.848753,"USDAMD":475.798297,"USDANG":1.790403,"USDARS":2.918969,"USDAUD":1.293878}}'));
-
- $result = $this->currencyLayer->from(Symbol::USD)->historical('2005-02-01', [Symbol::AED, Symbol::ALL, Symbol::AMD, Symbol::ANG, Symbol::ARS, Symbol::AUD]);
-
- $this->assertInstanceOf(ConversionResult::class, $result);
-
- $this->assertEquals(Symbol::USD, $result->getBaseCurrency());
- $this->assertEquals('2005-02-01', $result->getDate());
-
- $this->assertEquals(3.67266, $result->rate(Symbol::AED));
- $this->assertEquals(96.848753, $result->rate(Symbol::ALL));
- $this->assertEquals(475.798297, $result->rate(Symbol::AMD));
- $this->assertEquals(1.790403, $result->rate(Symbol::ANG));
- $this->assertEquals(2.918969, $result->rate(Symbol::ARS));
- $this->assertEquals(1.293878, $result->rate(Symbol::AUD));
- }
-
- /** @test */
- public function fails_to_get_historical_rates_if_date_not_set()
- {
- $this->expectException(ApiException::class);
- $this->currencyLayer->from(Symbol::USD)->to(Symbol::EUR)->historical();
- }
-
- /** @test */
- public function can_convert_currency_amounts()
- {
- // Response from https://currencylayer.com/documentation
- $this->mockHandler->append(new Response(200, [], '{"success":true,"terms":"https://currencylayer.com/terms","privacy":"https://currencylayer.com/privacy","query":{"from":"USD","to":"GBP","amount":10},"info":{"timestamp":1430068515,"quote":0.658443},"result":6.58443}'));
-
- $result = $this->currencyLayer->convert(10, Symbol::USD, Symbol::GBP, 1430068515);
- $this->assertEquals(6.58443, $result);
- }
-
- /** @test */
- public function can_handle_response_failures()
- {
- // Response from https://currencylayer.com/documentation
- $this->mockHandler->append(new Response(200, [], '{"success":false,"error":{"code":104,"info":"Your monthly usage limit has been reached. Please upgrade your subscription plan."}}'));
-
- $this->expectException(ApiException::class);
- $this->currencyLayer->from(Symbol::USD)->to(Symbol::LTL)->get();
- }
+ private DriverHarness $harness;
+ private CurrencyLayer $driver;
+
+ protected function setUp(): void
+ {
+ $this->harness = new DriverHarness();
+ $driver = $this->harness->make('currencylayer');
+ $this->assertInstanceOf(CurrencyLayer::class, $driver);
+ $this->driver = $driver;
+ }
+
+ #[Test]
+ public function can_get_latest_rates(): void
+ {
+ $this->harness->http->enqueue(new Response(200, [], '{"success":true,"timestamp":1432400348,"source":"USD","quotes":{"USDAUD":1.278342,"USDEUR":1.278342,"USDGBP":0.908019,"USDPLN":3.731504}}'));
+
+ $result = $this->driver->from(Currency::USD)->get([Currency::AUD, Currency::EUR, Currency::GBP, Currency::PLN]);
+
+ $this->assertInstanceOf(ConversionResult::class, $result);
+ $this->assertSame('USD', $result->getBaseCurrency());
+ $this->assertSame('2015-05-23', $result->getDate());
+
+ $this->assertSame('1.278342', (string) $result->rate(Currency::AUD));
+ $this->assertSame('1.278342', (string) $result->rate(Currency::EUR));
+ $this->assertSame('0.908019', (string) $result->rate(Currency::GBP));
+ $this->assertSame('3.731504', (string) $result->rate(Currency::PLN));
+ }
+
+ #[Test]
+ public function can_get_historical_rates(): void
+ {
+ $this->harness->http->enqueue(new Response(200, [], '{"success":true,"historical":true,"date":"2005-02-01","timestamp":1107302399,"source":"USD","quotes":{"USDAED":3.67266,"USDAUD":1.293878}}'));
+
+ $result = $this->driver->from(Currency::USD)->historical(
+ new DateTimeImmutable('2005-02-01'),
+ [Currency::AED, Currency::AUD],
+ );
+
+ $this->assertSame('USD', $result->getBaseCurrency());
+ $this->assertSame('2005-02-01', $result->getDate());
+ $this->assertSame('3.67266', (string) $result->rate(Currency::AED));
+ $this->assertSame('1.293878', (string) $result->rate(Currency::AUD));
+ }
+
+ #[Test]
+ public function fails_to_get_historical_rates_if_date_not_set(): void
+ {
+ $this->expectException(ApiException::class);
+ $this->driver->from(Currency::USD)->to(Currency::EUR)->historical();
+ }
+
+ #[Test]
+ public function can_convert_currency_amounts(): void
+ {
+ $this->harness->http->enqueue(new Response(200, [], '{"success":true,"query":{"from":"USD","to":"GBP","amount":10},"info":{"timestamp":1430068515,"quote":0.658443},"result":6.58443}'));
+
+ $result = $this->driver->convert(10.0, Currency::USD, Currency::GBP);
+
+ $this->assertInstanceOf(ConversionResult::class, $result);
+ $this->assertEqualsWithDelta(0.658443, $result->rateAsFloat(Currency::GBP), 0.000001);
+ }
+
+ #[Test]
+ public function can_handle_response_failures(): void
+ {
+ $this->harness->http->enqueue(new Response(200, [], '{"success":false,"error":{"code":104,"info":"Your monthly usage limit has been reached. Please upgrade your subscription plan."}}'));
+
+ $this->expectException(ApiException::class);
+ $this->driver->from(Currency::USD)->to(Currency::EUR)->get();
+ }
+
+ #[Test]
+ public function access_key_is_added_to_request_query_string(): void
+ {
+ $this->harness->http->enqueue(new Response(200, [], '{"success":true,"timestamp":1700000000,"source":"USD","quotes":{"USDEUR":0.9}}'));
+
+ $this->driver->accessKey('cl-key')->from(Currency::USD)->get([Currency::EUR]);
+
+ $uri = (string) $this->harness->http->lastRequest()?->getUri();
+ $this->assertStringContainsString('access_key=cl-key', $uri);
+ $this->assertStringContainsString('source=USD', $uri);
+ $this->assertStringContainsString('currencies=EUR', $uri);
+ }
}
diff --git a/tests/Drivers/ExchangeRatesApiTest.php b/tests/Drivers/ExchangeRatesApiTest.php
index f5180a2..ee2d343 100644
--- a/tests/Drivers/ExchangeRatesApiTest.php
+++ b/tests/Drivers/ExchangeRatesApiTest.php
@@ -1,104 +1,98 @@
mockHandler = new MockHandler();
- $this->exchangeRatesApi = DriverFactory::make('exchangeratesapi', new Client(['handler' => $this->mockHandler]));
- }
-
- /** @test */
- public function fails_to_set_api_key()
- {
- $this->expectException(ApiException::class);
- $this->expectExceptionMessage('No Access Key is required for this driver!');
- $this->expectExceptionCode(400);
- $this->exchangeRatesApi->accessKey('test-access-key');
- }
-
- /** @test */
- public function can_get_latest_rates()
- {
- // Response from https://exchangeratesapi.io
- $this->mockHandler->append(new Response(200, [], '{"base":"EUR","rates":{"NOK":9.772,"USD":1.1289,"JPY":122.44},"date":"2019-06-13"}'));
-
- $result = $this->exchangeRatesApi->from(Symbol::EUR)->get([Symbol::NOK, Symbol::JPY, Symbol::USD]);
-
- $this->assertInstanceOf(ConversionResult::class, $result);
-
- $this->assertEquals(Symbol::EUR, $result->getBaseCurrency());
- $this->assertEquals('2019-06-13', $result->getDate());
- $this->assertEquals(9.772, $result->rate(Symbol::NOK));
- $this->assertEquals(1.1289, $result->rate(Symbol::USD));
- $this->assertEquals(122.44, $result->rate(Symbol::JPY));
- }
-
-
- /** @test */
- public function can_get_historical_rates()
- {
- // Response from https://exchangeratesapi.io
- $this->mockHandler->append(new Response(200, [], '{"base":"GBP","rates":{"NOK":10.088752796,"CAD":1.7366601677,"USD":1.636783369,"JPY":170.6398095762,"EUR":1.1961293255},"date":"2013-12-24"}'));
-
- $result = $this->exchangeRatesApi->from(Symbol::GBP)->historical('2013-12-24', [Symbol::NOK, Symbol::CAD, Symbol::USD, Symbol::JPY, Symbol::EUR]);
-
- $this->assertInstanceOf(ConversionResult::class, $result);
-
- $this->assertEquals(Symbol::GBP, $result->getBaseCurrency());
- $this->assertEquals('2013-12-24', $result->getDate());
-
- $this->assertEquals(1.636783369, $result->rate(Symbol::USD));
- $this->assertEquals(1.1961293255, $result->rate(Symbol::EUR));
- $this->assertEquals(1.7366601677, $result->rate(Symbol::CAD));
- $this->assertEquals(10.088752796, $result->rate(Symbol::NOK));
- $this->assertEquals(170.6398095762, $result->rate(Symbol::JPY));
- }
-
- /** @test */
- public function fails_to_get_historical_rates_if_date_not_set()
- {
- $this->expectException(ApiException::class);
- $this->exchangeRatesApi->from(Symbol::USD)->to(Symbol::EUR)->historical();
- }
-
- /** @test */
- public function fails_to_convert_currency_amounts()
- {
- $this->expectException(ApiException::class);
- $this->expectExceptionMessage("Endpoint 'convert' is not supported for this driver!");
- $this->expectExceptionCode(404);
-
- $result = $this->exchangeRatesApi->convert(25, Symbol::GBP, Symbol::JPY, '2018-02-22');
- }
-
- /** @test */
- public function can_handle_response_failures()
- {
- // Response from https://exchangeratesapi.io
- $this->mockHandler->append(new Response(200, [], '{"error":"Symbols \'USD,CAD,EUR,JPY,NOK,CDP\' are invalid for date 2019-06-14."}'));
-
- $this->expectException(ApiException::class);
- $this->expectExceptionCode(500);
- $this->exchangeRatesApi->from(Symbol::USD)->to(Symbol::LTL)->get();
- }
+ private DriverHarness $harness;
+ private ExchangeRatesApi $driver;
+
+ protected function setUp(): void
+ {
+ $this->harness = new DriverHarness();
+ $driver = $this->harness->make('exchangeratesapi');
+ $this->assertInstanceOf(ExchangeRatesApi::class, $driver);
+ $this->driver = $driver;
+ }
+
+ #[Test]
+ public function access_key_is_sent_as_apikey_query_param(): void
+ {
+ $this->harness->http->enqueue(new Response(200, [], '{"success":true,"base":"EUR","date":"2024-01-01","rates":{"USD":1.1}}'));
+
+ $this->driver->accessKey('apilayer-token')->get([Currency::USD]);
+
+ $uri = (string) $this->harness->http->lastRequest()?->getUri();
+ $this->assertStringContainsString('apikey=apilayer-token', $uri);
+ }
+
+ #[Test]
+ public function can_get_latest_rates(): void
+ {
+ $this->harness->http->enqueue(new Response(200, [], '{"success":true,"base":"EUR","rates":{"NOK":9.772,"USD":1.1289,"JPY":122.44},"date":"2019-06-13"}'));
+
+ $result = $this->driver->from(Currency::EUR)->get([Currency::NOK, Currency::JPY, Currency::USD]);
+
+ $this->assertInstanceOf(ConversionResult::class, $result);
+ $this->assertSame('EUR', $result->getBaseCurrency());
+ $this->assertSame('2019-06-13', $result->getDate());
+ $this->assertSame('9.772', (string) $result->rate(Currency::NOK));
+ $this->assertSame('1.1289', (string) $result->rate(Currency::USD));
+ $this->assertSame('122.44', (string) $result->rate(Currency::JPY));
+ }
+
+ #[Test]
+ public function can_get_historical_rates(): void
+ {
+ $this->harness->http->enqueue(new Response(200, [], '{"success":true,"base":"GBP","rates":{"NOK":10.088752796,"CAD":1.7366601677,"USD":1.636783369,"JPY":170.6398095762,"EUR":1.1961293255},"date":"2013-12-24"}'));
+
+ $result = $this->driver->from(Currency::GBP)->historical(
+ new DateTimeImmutable('2013-12-24'),
+ [Currency::USD, Currency::EUR, Currency::CAD, Currency::JPY, Currency::NOK],
+ );
+
+ $this->assertSame('GBP', $result->getBaseCurrency());
+ $this->assertSame('2013-12-24', $result->getDate());
+ $this->assertSame('1.636783369', (string) $result->rate(Currency::USD));
+ $this->assertSame('1.1961293255', (string) $result->rate(Currency::EUR));
+ }
+
+ #[Test]
+ public function fails_to_get_historical_rates_if_date_not_set(): void
+ {
+ $this->expectException(ApiException::class);
+ $this->driver->from(Currency::USD)->to(Currency::EUR)->historical();
+ }
+
+ #[Test]
+ public function can_convert_currency_amounts(): void
+ {
+ $this->harness->http->enqueue(new Response(200, [], '{"success":true,"query":{"from":"GBP","to":"JPY","amount":25},"info":{"timestamp":1519328414,"rate":148.972231},"date":"2018-02-22","result":3724.305775}'));
+
+ $result = $this->driver->convert(25.0, Currency::GBP, Currency::JPY, new DateTimeImmutable('2018-02-22'));
+
+ $this->assertInstanceOf(ConversionResult::class, $result);
+ $this->assertEqualsWithDelta(148.972231, $result->rateAsFloat(Currency::JPY), 0.000001);
+ }
+
+ #[Test]
+ public function can_handle_response_failures(): void
+ {
+ $this->harness->http->enqueue(new Response(200, [], '{"success":false,"error":{"code":101,"info":"Invalid API key"}}'));
+
+ $this->expectException(ApiException::class);
+ $this->driver->from(Currency::USD)->to(Currency::EUR)->get();
+ }
}
diff --git a/tests/Drivers/FastForexTest.php b/tests/Drivers/FastForexTest.php
new file mode 100644
index 0000000..d038e2b
--- /dev/null
+++ b/tests/Drivers/FastForexTest.php
@@ -0,0 +1,152 @@
+harness = new DriverHarness();
+ $driver = $this->harness->make('fastforex');
+ $this->assertInstanceOf(FastForex::class, $driver);
+ $this->driver = $driver;
+ }
+
+ #[Test]
+ public function access_key_is_added_to_request_query_string(): void
+ {
+ $this->harness->http->enqueue(new Response(200, [], '{"base":"USD","result":{"EUR":0.8601},"updated":"2025-10-10 23:59:07","ms":13}'));
+
+ $this->driver->accessKey('fastforex-token')->from(Currency::USD)->get(Currency::EUR);
+
+ $uri = (string) $this->harness->http->lastRequest()?->getUri();
+ $this->assertStringContainsString('api_key=fastforex-token', $uri);
+ }
+
+ #[Test]
+ public function can_get_latest_rate_for_one_currency(): void
+ {
+ $this->harness->http->enqueue(new Response(200, [], '{"base":"USD","result":{"EUR":0.8601},"updated":"2025-10-10 23:59:07","ms":13}'));
+
+ $result = $this->driver->from(Currency::USD)->get(Currency::EUR);
+
+ $this->assertInstanceOf(ConversionResult::class, $result);
+ $this->assertSame('USD', $result->getBaseCurrency());
+ $this->assertSame('2025-10-10', $result->getDate());
+ $this->assertSame('0.8601', (string) $result->rate(Currency::EUR));
+
+ $uri = (string) $this->harness->http->lastRequest()?->getUri();
+ $this->assertStringContainsString('https://api.fastforex.io/fetch-one', $uri);
+ $this->assertStringContainsString('from=USD', $uri);
+ $this->assertStringContainsString('to=EUR', $uri);
+ }
+
+ #[Test]
+ public function can_get_latest_rates_for_multiple_currencies(): void
+ {
+ $this->harness->http->enqueue(new Response(200, [], '{"base":"USD","results":{"EUR":0.8601,"GBP":0.7462},"updated":"2025-10-10 23:59:07","ms":13}'));
+
+ $result = $this->driver->from(Currency::USD)->get([Currency::EUR, Currency::GBP]);
+
+ $this->assertSame('0.8601', (string) $result->rate(Currency::EUR));
+ $this->assertSame('0.7462', (string) $result->rate(Currency::GBP));
+
+ $uri = (string) $this->harness->http->lastRequest()?->getUri();
+ $this->assertStringContainsString('https://api.fastforex.io/fetch-multi', $uri);
+ $this->assertStringContainsString('to=EUR%2CGBP', $uri);
+ }
+
+ #[Test]
+ public function can_get_latest_rates_without_symbols(): void
+ {
+ $this->harness->http->enqueue(new Response(200, [], '{"base":"USD","results":{"EUR":0.8601},"updated":"2025-10-10 23:59:07","ms":13}'));
+
+ $result = $this->driver->from(Currency::USD)->get();
+
+ $this->assertSame('0.8601', (string) $result->rate(Currency::EUR));
+
+ $uri = (string) $this->harness->http->lastRequest()?->getUri();
+ $this->assertStringContainsString('https://api.fastforex.io/fetch-all', $uri);
+ $this->assertStringContainsString('from=USD', $uri);
+ $this->assertStringNotContainsString('to=', $uri);
+ }
+
+ #[Test]
+ public function can_get_historical_rates(): void
+ {
+ $this->harness->http->enqueue(new Response(200, [], '{"date":"2025-10-10","base":"USD","results":{"EUR":0.8601},"ms":2}'));
+
+ $result = $this->driver->from(Currency::USD)->historical(new DateTimeImmutable('2025-10-10'), Currency::EUR);
+
+ $this->assertSame('USD', $result->getBaseCurrency());
+ $this->assertSame('2025-10-10', $result->getDate());
+ $this->assertSame('0.8601', (string) $result->rate(Currency::EUR));
+
+ $uri = (string) $this->harness->http->lastRequest()?->getUri();
+ $this->assertStringContainsString('https://api.fastforex.io/historical', $uri);
+ $this->assertStringContainsString('date=2025-10-10', $uri);
+ }
+
+ #[Test]
+ public function can_convert_currency_amounts(): void
+ {
+ $this->harness->http->enqueue(new Response(200, [], '{"base":"USD","amount":100,"result":{"EUR":86.01},"ms":13}'));
+
+ $result = $this->driver->convert(100.0, Currency::USD, Currency::EUR);
+
+ $this->assertInstanceOf(ConversionResult::class, $result);
+ $this->assertSame('USD', $result->getBaseCurrency());
+ $this->assertEqualsWithDelta(0.8601, $result->rateAsFloat(Currency::EUR), 0.000001);
+
+ $uri = (string) $this->harness->http->lastRequest()?->getUri();
+ $this->assertStringContainsString('https://api.fastforex.io/convert', $uri);
+ $this->assertStringContainsString('from=USD', $uri);
+ $this->assertStringContainsString('to=EUR', $uri);
+ $this->assertStringContainsString('amount=100', $uri);
+ }
+
+ #[Test]
+ public function convert_uses_historical_rates_when_date_given(): void
+ {
+ $this->harness->http->enqueue(new Response(200, [], '{"date":"2025-10-10","base":"USD","results":{"EUR":0.8601},"ms":2}'));
+
+ $result = $this->driver->convert(100.0, Currency::USD, Currency::EUR, new DateTimeImmutable('2025-10-10'));
+
+ $this->assertSame('2025-10-10', $result->getDate());
+ $this->assertSame('0.8601', (string) $result->rate(Currency::EUR));
+
+ $uri = (string) $this->harness->http->lastRequest()?->getUri();
+ $this->assertStringContainsString('/historical', $uri);
+ }
+
+ #[Test]
+ public function fails_to_get_historical_rates_if_date_not_set(): void
+ {
+ $this->expectException(ApiException::class);
+ $this->driver->from(Currency::USD)->to(Currency::EUR)->historical();
+ }
+
+ #[Test]
+ public function can_handle_response_failures(): void
+ {
+ $this->harness->http->enqueue(new Response(401, [], '{"error":"Invalid API key"}'));
+
+ $this->expectException(ApiException::class);
+ $this->driver->from(Currency::USD)->to(Currency::EUR)->get();
+ }
+}
diff --git a/tests/Drivers/FixerIoTest.php b/tests/Drivers/FixerIoTest.php
index 9633e30..ec54ac9 100644
--- a/tests/Drivers/FixerIoTest.php
+++ b/tests/Drivers/FixerIoTest.php
@@ -1,91 +1,118 @@
mockHandler = new MockHandler();
- $this->fixerIo = DriverFactory::make('fixerio', new Client(['handler' => $this->mockHandler]));
- }
-
- /** @test */
- public function can_get_latest_rates()
- {
- // Response from https://fixer.io/documentation
- $this->mockHandler->append(new Response(200, [], '{ "success": true, "timestamp": 1519296206, "base": "USD", "date": "2018-02-22", "rates": { "GBP": 0.72007, "JPY": 107.346001, "EUR": 0.813399 } }'));
-
- $result = $this->fixerIo->from(Symbol::USD)->get([Symbol::GBP, Symbol::JPY, Symbol::EUR]);
-
- $this->assertInstanceOf(ConversionResult::class, $result);
-
- $this->assertEquals(Symbol::USD, $result->getBaseCurrency());
- $this->assertEquals('2018-02-22', $result->getDate());
- $this->assertEquals(0.72007, $result->rate(Symbol::GBP));
- $this->assertEquals(107.346001, $result->rate(Symbol::JPY));
- $this->assertEquals(0.813399, $result->rate(Symbol::EUR));
- }
-
-
- /** @test */
- public function can_get_historical_rates()
- {
- // Response from https://fixer.io/documentation
- $this->mockHandler->append(new Response(200, [], '{ "success": true, "historical": true, "date": "2013-12-24", "timestamp": 1387929599, "base": "GBP", "rates": { "USD": 1.636492, "EUR": 1.196476, "CAD": 1.739516 } }'));
-
- $result = $this->fixerIo->from(Symbol::GBP)->historical('2013-12-24', [Symbol::USD, Symbol::EUR, Symbol::CAD]);
-
- $this->assertInstanceOf(ConversionResult::class, $result);
-
- $this->assertEquals(Symbol::GBP, $result->getBaseCurrency());
- $this->assertEquals('2013-12-24', $result->getDate());
-
- $this->assertEquals(1.636492, $result->rate(Symbol::USD));
- $this->assertEquals(1.196476, $result->rate(Symbol::EUR));
- $this->assertEquals(1.739516, $result->rate(Symbol::CAD));
- }
-
- /** @test */
- public function fails_to_get_historical_rates_if_date_not_set()
- {
- $this->expectException(ApiException::class);
- $this->fixerIo->from(Symbol::USD)->to(Symbol::EUR)->historical();
- }
-
- /** @test */
- public function can_convert_currency_amounts()
- {
- // Response from https://fixer.io/documentation
- $this->mockHandler->append(new Response(200, [], '{ "success": true, "query": { "from": "GBP", "to": "JPY", "amount": 25 }, "info": { "timestamp": 1519328414, "rate": 148.972231 }, "historical": "true", "date": "2018-02-22", "result": 3724.305775 }'));
-
- $result = $this->fixerIo->convert(25, Symbol::GBP, Symbol::JPY, '2018-02-22');
- $this->assertEquals(3724.305775, $result);
- }
-
- /** @test */
- public function can_handle_response_failures()
- {
- // Response from https://fixer.io/documentation
- $this->mockHandler->append(new Response(200, [], '{ "success": false, "error": { "code": 104, "type": "api_volume_reached", "info": "Your monthly API request volume has been reached. Please upgrade your plan." } }'));
-
- $this->expectException(ApiException::class);
- $this->fixerIo->from(Symbol::USD)->to(Symbol::LTL)->get();
- }
+ private DriverHarness $harness;
+ private FixerIo $driver;
+
+ protected function setUp(): void
+ {
+ $this->harness = new DriverHarness();
+ $driver = $this->harness->make('fixerio');
+ $this->assertInstanceOf(FixerIo::class, $driver);
+ $this->driver = $driver;
+ }
+
+ #[Test]
+ public function can_get_latest_rates(): void
+ {
+ $this->harness->http->enqueue(new Response(200, [], '{"success":true,"timestamp":1519296206,"base":"USD","date":"2018-02-22","rates":{"GBP":0.72007,"JPY":107.346001,"EUR":0.813399}}'));
+
+ $result = $this->driver->from(Currency::USD)->get([Currency::GBP, Currency::JPY, Currency::EUR]);
+
+ $this->assertInstanceOf(ConversionResult::class, $result);
+ $this->assertSame('USD', $result->getBaseCurrency());
+ $this->assertSame('2018-02-22', $result->getDate());
+ $this->assertSame('0.72007', (string) $result->rate(Currency::GBP));
+ $this->assertSame('107.346001', (string) $result->rate(Currency::JPY));
+ $this->assertSame('0.813399', (string) $result->rate(Currency::EUR));
+ }
+
+ #[Test]
+ public function can_get_historical_rates(): void
+ {
+ $this->harness->http->enqueue(new Response(200, [], '{"success":true,"historical":true,"date":"2013-12-24","timestamp":1387929599,"base":"GBP","rates":{"USD":1.636492,"EUR":1.196476,"CAD":1.739516}}'));
+
+
+ $result = $this->driver->from(Currency::GBP)->historical(
+ new DateTimeImmutable('2013-12-24'),
+ [Currency::USD, Currency::EUR, Currency::CAD],
+ );
+
+ $this->assertSame('GBP', $result->getBaseCurrency());
+ $this->assertSame('2013-12-24', $result->getDate());
+ $this->assertSame('1.636492', (string) $result->rate(Currency::USD));
+ $this->assertSame('1.196476', (string) $result->rate(Currency::EUR));
+ $this->assertSame('1.739516', (string) $result->rate(Currency::CAD));
+ }
+
+ #[Test]
+ public function fails_to_get_historical_rates_if_date_not_set(): void
+ {
+ $this->expectException(ApiException::class);
+ $this->driver->from(Currency::USD)->to(Currency::EUR)->historical();
+ }
+
+ #[Test]
+ public function can_convert_currency_amounts(): void
+ {
+ $this->harness->http->enqueue(new Response(200, [], '{"success":true,"query":{"from":"GBP","to":"JPY","amount":25},"info":{"timestamp":1519328414,"rate":148.972231},"historical":true,"date":"2018-02-22","result":3724.305775}'));
+
+ $result = $this->driver->convert(25.0, Currency::GBP, Currency::JPY, new DateTimeImmutable('2018-02-22'));
+
+ $this->assertInstanceOf(ConversionResult::class, $result);
+ $this->assertSame('GBP', $result->getBaseCurrency());
+ $this->assertSame('2018-02-22', $result->getDate());
+ $this->assertEqualsWithDelta(148.972231, $result->rateAsFloat(Currency::JPY), 0.000001);
+ }
+
+ #[Test]
+ public function convert_requires_target_currency(): void
+ {
+ $this->expectException(ApiException::class);
+ $this->driver->amount(10.0)->from(Currency::USD)->convert();
+ }
+
+ #[Test]
+ public function convert_requires_amount(): void
+ {
+ $this->expectException(ApiException::class);
+ $this->driver->from(Currency::USD)->to(Currency::EUR)->convert();
+ }
+
+ #[Test]
+ public function can_handle_response_failures(): void
+ {
+ $this->harness->http->enqueue(new Response(200, [], '{"success":false,"error":{"code":104,"type":"api_volume_reached","info":"Your monthly API request volume has been reached. Please upgrade your plan."}}'));
+
+ $this->expectException(ApiException::class);
+ $this->driver->from(Currency::USD)->to(Currency::EUR)->get();
+ }
+
+ #[Test]
+ public function access_key_is_added_to_request_query_string(): void
+ {
+ $this->harness->http->enqueue(new Response(200, [], '{"success":true,"base":"USD","date":"2024-01-01","rates":{"EUR":0.9}}'));
+
+ $this->driver->accessKey('my-fixerio-key')->from(Currency::USD)->get([Currency::EUR]);
+
+ $uri = (string) $this->harness->http->lastRequest()?->getUri();
+ $this->assertStringContainsString('access_key=my-fixerio-key', $uri);
+ $this->assertStringContainsString('base=USD', $uri);
+ $this->assertStringContainsString('symbols=EUR', $uri);
+ }
}
diff --git a/tests/Drivers/FrankfurterTest.php b/tests/Drivers/FrankfurterTest.php
new file mode 100644
index 0000000..7b3d7b8
--- /dev/null
+++ b/tests/Drivers/FrankfurterTest.php
@@ -0,0 +1,127 @@
+harness = new DriverHarness();
+ $driver = $this->harness->make('frankfurter');
+ $this->assertInstanceOf(Frankfurter::class, $driver);
+ $this->driver = $driver;
+ }
+
+ #[Test]
+ public function access_key_is_rejected(): void
+ {
+ $this->expectException(ApiException::class);
+ $this->expectExceptionMessage('Frankfurter does not require an API key.');
+ $this->driver->accessKey('any');
+ }
+
+ #[Test]
+ public function can_get_latest_rates(): void
+ {
+ $this->harness->http->enqueue(new Response(200, [], '{"amount":1.0,"base":"EUR","date":"2024-01-02","rates":{"USD":1.1,"GBP":0.86}}'));
+
+ $result = $this->driver->get([Currency::USD, Currency::GBP]);
+
+ $this->assertInstanceOf(ConversionResult::class, $result);
+ $this->assertSame('EUR', $result->getBaseCurrency());
+ $this->assertSame('2024-01-02', $result->getDate());
+ $this->assertSame('1.1', (string) $result->rate(Currency::USD));
+ $this->assertSame('0.86', (string) $result->rate(Currency::GBP));
+
+ $uri = (string) $this->harness->http->lastRequest()?->getUri();
+ $this->assertStringContainsString('https://api.frankfurter.dev/v1/latest', $uri);
+ $this->assertStringContainsString('base=EUR', $uri);
+ $this->assertStringContainsString('symbols=USD%2CGBP', $uri);
+ }
+
+ #[Test]
+ public function can_get_latest_rates_without_symbols(): void
+ {
+ $this->harness->http->enqueue(new Response(200, [], '{"amount":1.0,"base":"EUR","date":"2024-01-02","rates":{"USD":1.1}}'));
+
+ $this->driver->get();
+
+ $uri = (string) $this->harness->http->lastRequest()?->getUri();
+ $this->assertStringContainsString('base=EUR', $uri);
+ $this->assertStringNotContainsString('symbols=', $uri);
+ }
+
+ #[Test]
+ public function can_get_historical_rates(): void
+ {
+ $this->harness->http->enqueue(new Response(200, [], '{"amount":1.0,"base":"EUR","date":"2020-04-01","rates":{"USD":1.0934}}'));
+
+ $result = $this->driver->historical(new DateTimeImmutable('2020-04-01'), [Currency::USD]);
+
+ $this->assertSame('2020-04-01', $result->getDate());
+ $this->assertSame('1.0934', (string) $result->rate(Currency::USD));
+
+ $uri = (string) $this->harness->http->lastRequest()?->getUri();
+ $this->assertStringContainsString('https://api.frankfurter.dev/v1/2020-04-01', $uri);
+ }
+
+ #[Test]
+ public function fails_to_get_historical_rates_if_date_not_set(): void
+ {
+ $this->expectException(ApiException::class);
+ $this->driver->to(Currency::USD)->historical();
+ }
+
+ #[Test]
+ public function convert_uses_latest_rates_when_no_date_given(): void
+ {
+ $this->harness->http->enqueue(new Response(200, [], '{"amount":1.0,"base":"EUR","date":"2024-01-02","rates":{"USD":1.1}}'));
+
+ $result = $this->driver->convert(10.0, Currency::EUR, Currency::USD);
+
+ $this->assertSame('1.1', (string) $result->rate(Currency::USD));
+ }
+
+ #[Test]
+ public function convert_uses_historical_rates_when_date_given(): void
+ {
+ $this->harness->http->enqueue(new Response(200, [], '{"amount":1.0,"base":"EUR","date":"2020-04-01","rates":{"USD":1.0934}}'));
+
+ $result = $this->driver->convert(10.0, Currency::EUR, Currency::USD, new DateTimeImmutable('2020-04-01'));
+
+ $this->assertSame('2020-04-01', $result->getDate());
+ $this->assertSame('1.0934', (string) $result->rate(Currency::USD));
+
+ $uri = (string) $this->harness->http->lastRequest()?->getUri();
+ $this->assertStringContainsString('/2020-04-01', $uri);
+ }
+
+ #[Test]
+ public function convert_requires_target_currency(): void
+ {
+ $this->expectException(ApiException::class);
+ $this->driver->amount(10.0)->convert();
+ }
+
+ #[Test]
+ public function convert_requires_amount(): void
+ {
+ $this->expectException(ApiException::class);
+ $this->driver->to(Currency::USD)->convert();
+ }
+}
diff --git a/tests/Drivers/MockCurrencyDriverTest.php b/tests/Drivers/MockCurrencyDriverTest.php
index 954ff08..eb622b3 100644
--- a/tests/Drivers/MockCurrencyDriverTest.php
+++ b/tests/Drivers/MockCurrencyDriverTest.php
@@ -1,40 +1,62 @@
make('mock');
+ $this->assertInstanceOf(MockCurrencyDriver::class, $driver);
+ $this->driver = $driver;
+ }
+
+ #[Test]
+ public function returns_conversion_result_for_get(): void
+ {
+ $this->assertInstanceOf(ConversionResult::class, $this->driver->get());
+ }
+
+ #[Test]
+ public function returns_conversion_result_for_historical(): void
+ {
+ $result = $this->driver->historical(new DateTimeImmutable('2015-01-01'));
+
+ $this->assertInstanceOf(ConversionResult::class, $result);
+ $this->assertSame('2015-01-01', $result->getDate());
+ }
+
+ #[Test]
+ public function convert_returns_conversion_result_with_target_rate(): void
+ {
+ $result = $this->driver->convert(1.0, Currency::USD, Currency::EUR);
+
+ $this->assertInstanceOf(ConversionResult::class, $result);
+ $this->assertSame('USD', $result->getBaseCurrency());
+ $this->assertTrue(BigDecimal::of('12.34')->isEqualTo($result->rate(Currency::EUR)));
+ }
+
+ #[Test]
+ public function with_rates_seeds_get_response(): void
+ {
+ $this->driver->withRates(['EUR' => '0.92', 'GBP' => '0.79']);
+
+ $result = $this->driver->get();
- /** @var BaseCurrencyDriver */
- private $mockCurrencyDriver;
-
- protected function setUp(): void
- {
- $this->mockCurrencyDriver = DriverFactory::make('mock');
- }
-
- /** @test */
- public function can_get_latest_rates()
- {
- $this->assertInstanceOf(ConversionResult::class, $this->mockCurrencyDriver->get());
- }
-
- /** @test */
- public function can_get_historical_rates()
- {
- $this->assertInstanceOf(ConversionResult::class, $this->mockCurrencyDriver->historical('2015-01-01'));
- }
-
- /** @test */
- public function can_convert_currencies()
- {
- $this->assertEquals(12.34, $this->mockCurrencyDriver->convert(1, Symbol::USD, Symbol::EUR));
- }
+ $this->assertSame('0.92', (string) $result->rate(Currency::EUR));
+ $this->assertSame('0.79', (string) $result->rate(Currency::GBP));
+ }
}
diff --git a/tests/Drivers/OpenExchangeRatesTest.php b/tests/Drivers/OpenExchangeRatesTest.php
index dcb1c3b..3bc8724 100644
--- a/tests/Drivers/OpenExchangeRatesTest.php
+++ b/tests/Drivers/OpenExchangeRatesTest.php
@@ -1,99 +1,102 @@
mockHandler = new MockHandler();
- $this->openExchangeRates = DriverFactory::make('openexchangerates', new Client(['handler' => $this->mockHandler]));
- }
-
- /** @test */
- public function can_set_app_id()
- {
- $this->assertInstanceOf(OpenExchangeRates::class, $this->openExchangeRates->accessKey('7b23e3e4706c074e2665caf25e823e88'));
- }
-
- /** @test */
- public function can_get_latest_rates()
- {
- // Response from https://docs.openexchangerates.org
- $this->mockHandler->append(new Response(200, [], '{"disclaimer":"https://openexchangerates.org/terms/","license":"https://openexchangerates.org/license/","timestamp":1449877801,"base":"USD","rates":{"AED":3.672538,"AFN":66.809999,"ALL":125.716501,"AMD":484.902502,"ANG":1.788575}}'));
-
- $result = $this->openExchangeRates->from(Symbol::USD)->get([Symbol::AED, Symbol::AFN, Symbol::ALL, Symbol::AMD, Symbol::ANG]);
-
- $this->assertInstanceOf(ConversionResult::class, $result);
-
- $this->assertEquals(Symbol::USD, $result->getBaseCurrency());
- $this->assertEquals('2015-12-11', $result->getDate());
- $this->assertEquals(3.672538, $result->rate(Symbol::AED));
- $this->assertEquals(66.809999, $result->rate(Symbol::AFN));
- $this->assertEquals(125.716501, $result->rate(Symbol::ALL));
- $this->assertEquals(484.902502, $result->rate(Symbol::AMD));
- $this->assertEquals(1.788575, $result->rate(Symbol::ANG));
- }
-
-
- /** @test */
- public function can_get_historical_rates()
- {
- // Response from https://docs.openexchangerates.org
- $this->mockHandler->append(new Response(200, [], '{"disclaimer":"https://openexchangerates.org/terms/","license":"https://openexchangerates.org/license/","timestamp":982342800,"base":"USD","rates":{"AED":3.67246,"ALL":144.529793,"ANG":1.79}}'));
-
- $result = $this->openExchangeRates->from(Symbol::USD)->historical('2001-02-16', [Symbol::AED, Symbol::AED, Symbol::ANG]);
-
- $this->assertInstanceOf(ConversionResult::class, $result);
-
- $this->assertEquals(Symbol::USD, $result->getBaseCurrency());
- $this->assertEquals('2001-02-16', $result->getDate());
-
- $this->assertEquals(3.67246, $result->rate(Symbol::AED));
- $this->assertEquals(144.529793, $result->rate(Symbol::ALL));
- $this->assertEquals(1.79, $result->rate(Symbol::ANG));
- }
-
- /** @test */
- public function fails_to_get_historical_rates_if_date_not_set()
- {
- $this->expectException(ApiException::class);
- $this->openExchangeRates->from(Symbol::USD)->to(Symbol::EUR)->historical();
- }
-
- /** @test */
- public function can_convert_currency_amounts()
- {
- // Response from https://docs.openexchangerates.org
- $this->mockHandler->append(new Response(200, [], '{"disclaimer":"https://openexchangerates.org/terms/","license":"https://openexchangerates.org/license/","request":{"query":"/convert/19999.95/GBP/EUR","amount":19999.95,"from":"GBP","to":"EUR"},"meta":{"timestamp":1449885661,"rate":1.383702},"response":27673.975864}'));
-
- $result = $this->openExchangeRates->convert(19999.95, Symbol::GBP, Symbol::EUR, '2015-12-12');
- $this->assertEquals(27673.975864, $result);
- }
-
- /** @test */
- public function can_handle_response_failures()
- {
- // Response from https://docs.openexchangerates.org
- $this->mockHandler->append(new Response(200, [], '{"error":true,"status":401,"message":"invalid_app_id","description":"Invalid App ID provided - please sign up at https://openexchangerates.org/signup, or contact support@openexchangerates.org."}'));
-
- $this->expectException(ApiException::class);
- $this->openExchangeRates->from(Symbol::USD)->to(Symbol::LTL)->get();
- }
+ private DriverHarness $harness;
+ private OpenExchangeRates $driver;
+
+ protected function setUp(): void
+ {
+ $this->harness = new DriverHarness();
+ $driver = $this->harness->make('openexchangerates');
+ $this->assertInstanceOf(OpenExchangeRates::class, $driver);
+ $this->driver = $driver;
+ }
+
+ #[Test]
+ public function access_key_is_sent_as_app_id_query_param(): void
+ {
+ $this->harness->http->enqueue(new Response(200, [], '{"base":"USD","timestamp":1700000000,"rates":{"EUR":0.9}}'));
+
+ $this->driver->accessKey('app-id-token')->from(Currency::USD)->get([Currency::EUR]);
+
+ $uri = (string) $this->harness->http->lastRequest()?->getUri();
+ $this->assertStringContainsString('app_id=app-id-token', $uri);
+ $this->assertStringNotContainsString('access_key=', $uri);
+ }
+
+ #[Test]
+ public function can_get_latest_rates(): void
+ {
+ $this->harness->http->enqueue(new Response(200, [], '{"timestamp":1449877801,"base":"USD","rates":{"AED":3.672538,"AFN":66.809999,"ALL":125.716501,"AMD":484.902502,"ANG":1.788575}}'));
+
+ $result = $this->driver->from(Currency::USD)->get([Currency::AED, Currency::AFN, Currency::ALL, Currency::AMD, Currency::ANG]);
+
+ $this->assertInstanceOf(ConversionResult::class, $result);
+ $this->assertSame('USD', $result->getBaseCurrency());
+ $this->assertSame('2015-12-11', $result->getDate());
+ $this->assertSame('3.672538', (string) $result->rate(Currency::AED));
+ $this->assertSame('66.809999', (string) $result->rate(Currency::AFN));
+ $this->assertSame('125.716501', (string) $result->rate(Currency::ALL));
+ $this->assertSame('484.902502', (string) $result->rate(Currency::AMD));
+ $this->assertSame('1.788575', (string) $result->rate(Currency::ANG));
+ }
+
+ #[Test]
+ public function can_get_historical_rates(): void
+ {
+ $this->harness->http->enqueue(new Response(200, [], '{"timestamp":982342800,"base":"USD","rates":{"AED":3.67246,"ALL":144.529793,"ANG":1.79}}'));
+
+ $result = $this->driver->from(Currency::USD)->historical(
+ new DateTimeImmutable('2001-02-16'),
+ [Currency::AED, Currency::ALL, Currency::ANG],
+ );
+
+ $this->assertSame('USD', $result->getBaseCurrency());
+ $this->assertSame('2001-02-16', $result->getDate());
+ $this->assertSame('3.67246', (string) $result->rate(Currency::AED));
+ $this->assertSame('144.529793', (string) $result->rate(Currency::ALL));
+ $this->assertSame('1.79', (string) $result->rate(Currency::ANG));
+ }
+
+ #[Test]
+ public function fails_to_get_historical_rates_if_date_not_set(): void
+ {
+ $this->expectException(ApiException::class);
+ $this->driver->from(Currency::USD)->to(Currency::EUR)->historical();
+ }
+
+ #[Test]
+ public function can_convert_currency_amounts(): void
+ {
+ $this->harness->http->enqueue(new Response(200, [], '{"request":{"query":"/convert/19999.95/GBP/EUR","amount":19999.95,"from":"GBP","to":"EUR"},"meta":{"timestamp":1449885661,"rate":1.383702},"response":27673.975864,"result":27673.975864}'));
+
+ $result = $this->driver->convert(19999.95, Currency::GBP, Currency::EUR);
+
+ $this->assertInstanceOf(ConversionResult::class, $result);
+ $this->assertEqualsWithDelta(1.383702, $result->rateAsFloat(Currency::EUR), 0.000001);
+ }
+
+ #[Test]
+ public function can_handle_response_failures(): void
+ {
+ $this->harness->http->enqueue(new Response(200, [], '{"error":true,"status":401,"message":"invalid_app_id","description":"Invalid App ID provided"}'));
+
+ $this->expectException(ApiException::class);
+ $this->driver->from(Currency::USD)->to(Currency::EUR)->get();
+ }
}
diff --git a/tests/Drivers/TestableDriver.php b/tests/Drivers/TestableDriver.php
new file mode 100644
index 0000000..84b6f6f
--- /dev/null
+++ b/tests/Drivers/TestableDriver.php
@@ -0,0 +1,51 @@
+getBaseCurrency());
+ }
+
+ #[Override]
+ public function historical(
+ ?DateTimeInterface $date = null,
+ string|Currency|array $forCurrency = [],
+ ): ConversionResult {
+ return new ConversionResult($this->getBaseCurrency());
+ }
+
+ #[Override]
+ public function convert(
+ ?float $amount = null,
+ string|Currency|null $fromCurrency = null,
+ string|Currency|null $toCurrency = null,
+ ?DateTimeInterface $date = null,
+ ): ConversionResult {
+ return new ConversionResult($this->getBaseCurrency());
+ }
+
+ /**
+ * @param array $params
+ *
+ * @return array
+ */
+ public function callApi(string $endpoint, array $params = []): array
+ {
+ return $this->apiRequest($endpoint, $params);
+ }
+}
diff --git a/tests/Helpers/DateHelperTest.php b/tests/Helpers/DateHelperTest.php
index 949d65d..b62ef02 100644
--- a/tests/Helpers/DateHelperTest.php
+++ b/tests/Helpers/DateHelperTest.php
@@ -1,44 +1,48 @@
assertNull(DateHelper::format(null));
+ }
+
+ #[Test]
+ public function format_uses_iso_date_by_default(): void
+ {
+ $date = new DateTimeImmutable('2019-01-01 12:34:56');
+ $this->assertSame('2019-01-01', DateHelper::format($date));
+ }
+
+ #[Test]
+ public function format_accepts_custom_format_string(): void
+ {
+ $date = new DateTimeImmutable('2019-01-01 12:34:56');
+ $this->assertSame('01.01.2019 12:34', DateHelper::format($date, 'd.m.Y H:i'));
+ }
+
+ #[Test]
+ public function now_returns_current_timestamp(): void
+ {
+ $delta = DateHelper::now()->getTimestamp() - time();
+ $this->assertLessThanOrEqual(1, abs($delta));
+ }
- /** @test */
- public function can_parse_a_date()
- {
- $this->assertEquals('23:15:03', DateHelper::parse('23h 15m 03s', 'H\h i\m s\s')->format('H:i:s'));
- }
-
- /** @test */
- public function can_format_a_date()
- {
- $this->assertEqualsWithDelta((new DateTime())->format('Y-m-d'), DateHelper::format('now', 'Y-m-d'), 0.1);
- $this->assertEquals('2019-01-01', DateHelper::format(1546300800, 'Y-m-d'));
- $this->assertEquals('2019-01-01', DateHelper::format('2019-01-01', 'Y-m-d'));
- $this->assertEquals(DateHelper::today()->format('Y-m-d'), DateHelper::format(DateHelper::today(), 'Y-m-d'));
- }
-
- /** @test */
- public function can_create_a_date()
- {
- $this->assertEquals(1546300800, DateHelper::create('1.1.2019')->getTimestamp());
- }
-
- /** @test */
- public function can_get_current_date_and_time()
- {
- $this->assertEqualsWithDelta(new DateTime(), DateHelper::now(), 0.1);
- }
-
- /** @test */
- public function can_get_current_date()
- {
- $this->assertEquals(new DateTime('today'), DateHelper::today());
- }
+ #[Test]
+ public function today_returns_midnight_today(): void
+ {
+ $today = DateHelper::today();
+ $this->assertSame('00:00:00', $today->format('H:i:s'));
+ $this->assertSame((new DateTimeImmutable('today'))->format('Y-m-d'), $today->format('Y-m-d'));
+ }
}
diff --git a/tests/Results/ConversionResultTest.php b/tests/Results/ConversionResultTest.php
index bb81994..bf3b2c7 100644
--- a/tests/Results/ConversionResultTest.php
+++ b/tests/Results/ConversionResultTest.php
@@ -1,118 +1,172 @@
classUnderTest = new ConversionResult(Symbol::USD, 1560293762, [
- 'EUR' => 0.88,
- 'THB' => 31.27,
- ]);
- }
-
- /** @test */
- public function construct_will_properly_set_parameters()
- {
- $this->assertEquals(Symbol::USD, $this->classUnderTest->getBaseCurrency());
- $this->assertEquals('2019-06-11', $this->classUnderTest->getDate());
-
- $result = new ConversionResult(Symbol::USD, '1936-07-21', [
- 'CNY' => 1.12,
- ]);
-
- $this->assertEquals('1936-07-21', $result->getDate());
-
- $result = new ConversionResult(Symbol::EUR, DateTime::createFromFormat('d.m.Y', '5.10.1990'), [
- 'LTL' => 3.45280,
- ]);
-
- $this->assertEquals('1990-10-05', $result->getDate());
- $this->assertEquals(Symbol::EUR, $result->getBaseCurrency());
- }
-
- /** @test */
- public function returns_all_conversion_rates()
- {
- $this->assertCount(3, $this->classUnderTest->all());
- $this->assertArrayHasKey(Symbol::USD, $this->classUnderTest->all());
- $this->assertArrayHasKey(Symbol::EUR, $this->classUnderTest->all());
- $this->assertArrayHasKey(Symbol::THB, $this->classUnderTest->all());
- $this->assertEquals(1, $this->classUnderTest->all()[Symbol::USD]);
- $this->assertEquals(31.27, $this->classUnderTest->all()[Symbol::THB]);
- }
-
- /** @test */
- public function fails_to_convert_if_target_currency_does_not_exist()
- {
- $this->expectException(CurrencyException::class);
- $this->classUnderTest->convert(2, Symbol::EUR, Symbol::BTC);
- }
-
- /** @test */
- public function fails_to_convert_if_source_currency_does_not_exist()
- {
- $this->expectException(CurrencyException::class);
- $this->classUnderTest->convert(2, Symbol::BTC, Symbol::EUR);
- }
-
- /** @test */
- public function can_convert_between_currencies()
- {
- $result = $this->classUnderTest->convert(2, Symbol::EUR, Symbol::THB);
- $this->assertEqualsWithDelta(71.06, $result, 0.1);
- }
-
- /** @test */
- public function fails_to_retrieve_rate_if_currency_does_not_exist()
- {
- $this->expectException(CurrencyException::class);
- $this->classUnderTest->rate(Symbol::BTC);
- }
-
- /** @test */
- public function retrieves_currency_conversion_rate()
- {
- $this->assertEquals(31.27, $this->classUnderTest->rate(Symbol::THB));
- $this->assertEquals(0.88, $this->classUnderTest->rate(Symbol::EUR));
- }
-
- /** @test */
- public function fails_to_change_base_currency_if_currency_does_not_exist()
- {
- $this->expectException(CurrencyException::class);
- $this->classUnderTest->setBaseCurrency(Symbol::BTC);
- }
-
- /** @test */
- public function can_reset_base_currency()
- {
- $this->classUnderTest->setBaseCurrency(Symbol::USD);
- $this->assertEquals(0.88, $this->classUnderTest->rate(Symbol::EUR));
- $this->assertEquals(1, $this->classUnderTest->rate(Symbol::USD));
- }
-
- /** @test */
- public function can_change_base_currency()
- {
- $this->classUnderTest->setBaseCurrency(Symbol::EUR);
- $this->assertEqualsWithDelta(1.14, $this->classUnderTest->rate(Symbol::USD), 0.1);
- $this->assertEquals(1, $this->classUnderTest->rate(Symbol::EUR));
-
- $this->assertEquals(1, $this->classUnderTest->convert(0.88, Symbol::EUR, Symbol::USD));
- $this->assertEquals(1, $this->classUnderTest->convert(31.27, Symbol::THB, Symbol::USD));
- }
+ private ConversionResult $result;
+
+ protected function setUp(): void
+ {
+ $this->result = new ConversionResult(Currency::USD, '2019-06-11', [
+ 'EUR' => 0.88,
+ 'THB' => 31.27,
+ ]);
+ }
+
+ #[Test]
+ public function construct_will_properly_set_parameters(): void
+ {
+ $this->assertSame('USD', $this->result->getBaseCurrency());
+ $this->assertSame('2019-06-11', $this->result->getDate());
+
+ $other = new ConversionResult(Currency::USD, '1936-07-21', ['CNY' => 1.12]);
+ $this->assertSame('1936-07-21', $other->getDate());
+
+ $third = new ConversionResult(Currency::EUR, '1990-10-05', ['LTL' => 3.45280]);
+ $this->assertSame('1990-10-05', $third->getDate());
+ $this->assertSame('EUR', $third->getBaseCurrency());
+ }
+
+ #[Test]
+ public function returns_all_conversion_rates_including_base(): void
+ {
+ $rates = $this->result->all();
+
+ $this->assertCount(3, $rates);
+ $this->assertArrayHasKey('USD', $rates);
+ $this->assertArrayHasKey('EUR', $rates);
+ $this->assertArrayHasKey('THB', $rates);
+
+ $this->assertTrue(BigDecimal::one()->isEqualTo($rates['USD']));
+ $this->assertSame('31.27', (string) $rates['THB']);
+ }
+
+ #[Test]
+ public function all_as_floats_returns_native_floats(): void
+ {
+ $rates = $this->result->allAsFloats();
+
+ $this->assertSame(1.0, $rates['USD']);
+ $this->assertSame(0.88, $rates['EUR']);
+ $this->assertSame(31.27, $rates['THB']);
+ }
+
+ #[Test]
+ public function fails_to_convert_if_target_currency_does_not_exist(): void
+ {
+ $this->expectException(CurrencyException::class);
+ $this->result->convert(2, Currency::EUR, Currency::BTC);
+ }
+
+ #[Test]
+ public function fails_to_convert_if_source_currency_does_not_exist(): void
+ {
+ $this->expectException(CurrencyException::class);
+ $this->result->convert(2, Currency::BTC, Currency::EUR);
+ }
+
+ #[Test]
+ public function can_convert_between_currencies(): void
+ {
+ $converted = $this->result->convert(2, Currency::EUR, Currency::THB);
+
+ $this->assertEqualsWithDelta(71.06, $converted->toFloat(), 0.01);
+ }
+
+ #[Test]
+ public function fails_to_retrieve_rate_if_currency_does_not_exist(): void
+ {
+ $this->expectException(CurrencyException::class);
+ $this->result->rate(Currency::BTC);
+ }
+
+ #[Test]
+ public function retrieves_currency_conversion_rate(): void
+ {
+ $this->assertSame('31.27', (string) $this->result->rate(Currency::THB));
+ $this->assertSame('0.88', (string) $this->result->rate(Currency::EUR));
+ $this->assertSame(31.27, $this->result->rateAsFloat(Currency::THB));
+ }
+
+ #[Test]
+ public function fails_to_change_base_currency_if_currency_does_not_exist(): void
+ {
+ $this->expectException(CurrencyException::class);
+ $this->result->setBaseCurrency(Currency::BTC);
+ }
+
+ #[Test]
+ public function reset_to_original_base_currency_restores_original_rates(): void
+ {
+ $this->result->setBaseCurrency(Currency::EUR);
+ $this->result->setBaseCurrency(Currency::USD);
+
+ $this->assertSame('0.88', (string) $this->result->rate(Currency::EUR));
+ $this->assertTrue(BigDecimal::one()->isEqualTo($this->result->rate(Currency::USD)));
+ }
+
+ #[Test]
+ public function can_change_base_currency_and_convert_back_losslessly(): void
+ {
+ $this->result->setBaseCurrency(Currency::EUR);
+
+ $this->assertEqualsWithDelta(1.1363, $this->result->rateAsFloat(Currency::USD), 0.001);
+ $this->assertTrue(BigDecimal::one()->isEqualTo($this->result->rate(Currency::EUR)));
+
+ $this->assertSame('EUR', $this->result->getBaseCurrency());
+ $this->assertSame('USD', $this->result->originalBaseCurrency);
+ }
+
+ #[Test]
+ public function convert_round_trips_via_original_base(): void
+ {
+ $this->assertEqualsWithDelta(
+ 1.0,
+ $this->result->convert(0.88, Currency::EUR, Currency::USD)->toFloat(),
+ 0.0001,
+ );
+
+ $this->assertEqualsWithDelta(
+ 1.0,
+ $this->result->convert(31.27, Currency::THB, Currency::USD)->toFloat(),
+ 0.0001,
+ );
+ }
+
+ #[Test]
+ public function setting_base_to_self_returns_self(): void
+ {
+ $this->result->setBaseCurrency(Currency::USD);
+ $this->assertSame('USD', $this->result->getBaseCurrency());
+ $this->assertTrue(BigDecimal::one()->isEqualTo($this->result->rate(Currency::USD)));
+ }
+
+ #[Test]
+ public function constructor_accepts_string_base_currency(): void
+ {
+ $result = new ConversionResult('JPY', '2024-01-01', ['EUR' => '0.0061']);
+
+ $this->assertSame('JPY', $result->getBaseCurrency());
+ $this->assertSame('JPY', $result->originalBaseCurrency);
+ }
+
+ #[Test]
+ public function rates_can_be_passed_as_big_decimal_already(): void
+ {
+ $result = new ConversionResult(Currency::USD, '2024-01-01', [
+ 'EUR' => BigDecimal::of('0.92'),
+ ]);
+
+ $this->assertSame('0.92', (string) $result->rate(Currency::EUR));
+ }
}
diff --git a/tests/Support/DriverHarness.php b/tests/Support/DriverHarness.php
new file mode 100644
index 0000000..851eac8
--- /dev/null
+++ b/tests/Support/DriverHarness.php
@@ -0,0 +1,24 @@
+http = new MockHttpClient();
+ }
+
+ public function make(string $name): CurrencyDriverContract
+ {
+ return (new DriverFactory())->build($name, $this->http, new RequestFactory());
+ }
+}
diff --git a/tests/Support/JsonResponse.php b/tests/Support/JsonResponse.php
new file mode 100644
index 0000000..bd655b3
--- /dev/null
+++ b/tests/Support/JsonResponse.php
@@ -0,0 +1,24 @@
+ 'application/json'], $body);
+ }
+
+ /**
+ * @param array $headers
+ */
+ public static function with(int $status, string $body, array $headers = []): ResponseInterface
+ {
+ return new Response($status, $headers + ['Content-Type' => 'application/json'], $body);
+ }
+}
diff --git a/tests/Support/MockHttpClient.php b/tests/Support/MockHttpClient.php
new file mode 100644
index 0000000..b62452e
--- /dev/null
+++ b/tests/Support/MockHttpClient.php
@@ -0,0 +1,57 @@
+ */
+ private array $queue = [];
+
+ /** @var list */
+ private array $sentRequests = [];
+
+ public function enqueue(ResponseInterface|ClientExceptionInterface $item): self
+ {
+ $this->queue[] = $item;
+
+ return $this;
+ }
+
+ public function sendRequest(RequestInterface $request): ResponseInterface
+ {
+ $this->sentRequests[] = $request;
+
+ if ($this->queue === []) {
+ throw new MockHttpClientException('MockHttpClient queue is empty.');
+ }
+
+ $next = array_shift($this->queue);
+
+ if ($next instanceof ClientExceptionInterface) {
+ throw $next;
+ }
+
+ return $next;
+ }
+
+ public function lastRequest(): ?RequestInterface
+ {
+ return $this->sentRequests[count($this->sentRequests) - 1] ?? null;
+ }
+
+ /**
+ * @return list
+ */
+ public function sentRequests(): array
+ {
+ return $this->sentRequests;
+ }
+}
diff --git a/tests/SymbolTest.php b/tests/SymbolTest.php
index f1fd445..0be7ddf 100644
--- a/tests/SymbolTest.php
+++ b/tests/SymbolTest.php
@@ -1,31 +1,68 @@
assertCount(167, Symbol::all());
- }
-
- /** @test */
- public function can_get_a_symbol_name()
- {
- $this->assertEquals('Lithuanian Litas', Symbol::name(Symbol::LTL));
- $this->assertEquals('Bitcoin', Symbol::name(Symbol::BTC));
- }
-
- /** @test */
- public function can_get_a_list_of_all_symbols()
- {
- $this->assertCount(167, Symbol::names());
- $this->assertEquals('Lithuanian Litas', Symbol::names()[Symbol::LTL]);
- $this->assertEquals('Bitcoin', Symbol::names()[Symbol::BTC]);
- }
+ #[Test]
+ public function can_get_all_symbols(): void
+ {
+ $this->assertCount(167, @Symbol::all());
+ }
+
+ #[Test]
+ public function can_get_a_symbol_name(): void
+ {
+ $this->assertSame('Lithuanian Litas', @Symbol::name(Symbol::LTL));
+ $this->assertSame('Bitcoin', @Symbol::name(Symbol::BTC));
+ }
+
+ #[Test]
+ public function can_get_a_list_of_all_symbols(): void
+ {
+ $names = @Symbol::names();
+
+ $this->assertCount(167, $names);
+ $this->assertSame('Lithuanian Litas', $names[Symbol::LTL]);
+ $this->assertSame('Bitcoin', $names[Symbol::BTC]);
+ }
+
+ #[Test]
+ public function symbol_constants_resolve_to_currency_codes(): void
+ {
+ $this->assertSame('USD', Symbol::USD);
+ $this->assertSame('EUR', Symbol::EUR);
+ }
+
+ #[Test]
+ public function deprecated_methods_trigger_user_deprecated_notice(): void
+ {
+ Symbol::resetDeprecationNotice();
+ $messages = [];
+ set_error_handler(static function (int $errno, string $errstr) use (&$messages): bool {
+ if ($errno === E_USER_DEPRECATED) {
+ $messages[] = $errstr;
+
+ return true;
+ }
+
+ return false;
+ });
+
+ try {
+ Symbol::all();
+ } finally {
+ restore_error_handler();
+ }
+
+ $this->assertNotEmpty($messages);
+ $this->assertStringContainsString(Currency::class, $messages[0]);
+ }
}