Demo Symfony Currency Converter
Tags: symfony, php, tutorial, laravel
Published: 2022-07-25 09:44:00

I recently did a test task on Symfony - currency converter with direct and cross conversion. So I want to share the result with the community as an example of a simple console application according to Symfony's rules: DI, autowiring, service tagging, flexible configuration, that's all. I hope this will be useful for beginners.

[Source code](https://github.com/vladimirmartsul/symfony-exchange-demo)

The application calculates "currency exchange" at direct rates (for example, USD -> EUR), as well as through "intermediate" currencies (for example, BTC -> USD -> EUR). There are also fake rates for tests.

Rates are taken from [ecb.europa.eu](https://ecb.europa.eu) (major world currencies against EUR) and [coindesk.com](https://coindesk.com) (BTC against USD).

Triangulation is based on the principles from http://www.dpxo.net/articles/fx_rate_triangulation_sql.html.

The data storing in the SQLite database.

The application can be used through local PHP or in Docker.
PHP requirements: version 8.1+, bcmath, ctype, iconv, intl, pdo_sqlite, simplexml, sqlite3 extensions.

I had little experience with Symfony (I worked with Laravel mostly), so there may be some flaws.
In addition, SQLite imposed some limitations due to the lack of real decimal and numeric formats, and INSERT IGNORE, the calculation accuracy of 16.8 had to be hardcoded.
There was trouble with the ECB rates' dates, so the application uses the last available day from each source.

## Highlights

### Commands

The application has two console commands: "_currency:update_" - updating exchange rates (`\App\Command\CurrencyUpdateCommand`) and "_currency:exchange_" - exchangу currencies (`\App\Command\CurrencyExchangeCommand`).

The commands accept parameters, validate data, pass them to services, catch exceptions and beautifully print the result to the console with the appropriate exit status.

All services and providers are passed through constructor injection. Rate providers have been tagged with the "_app.rates_provider_" tag in config/services.yaml and passed through an iterator to `\App\Services\RatesUpdater` by this tag. Very convenient, I think.

```php
App\Providers\CoinDeskRatesProvider:
    tags: [ 'app.rates_provider' ]

App\Providers\EcbRatesProvider:
    tags: [ 'app.rates_provider' ]

App\Services\RatesUpdater:
    arguments:
        - !tagged_iterator app.rates_provider
```
```php
class RatesUpdater
{
    public function __construct(private readonly iterable $ratesProviders, ...)
    {
    }
...
}
```

### Data exchange and validation

Data for currency exchange and saving rates are sent via DTO: `\App\Dto\Exchange` and `\App\Dto\Rate`, respectively.
Validation of "_AmountRequirements_" - quantity requirements and "_ExchangeCurrencyRequirements_" - currency requirements are imposed on DTO for currency exchange.
In addition, validation applies to the `\App\Entity\Pair` and `\App\Entity\Rate` entities.

All validators are custom to hide unnecessary details from consumers. Validators locate in the `src/Validator/` classes. Most of them are compounds of simple rules. For example, the quantity requirements are "_Non-empty string_", "_Numeric type_", and "_Positive value_".
```php
class AmountRequirements extends Compound
{
    protected function getConstraints(array $options): array
    {
        return [
            new Assert\NotBlank(),
            new Assert\Type(type: 'numeric', message: 'The value {{ value }} is not a valid {{ type }}'),
            new Assert\Positive(),
        ];
    }
}
```

There is also a more complex currency existence validator `\App\Validator\PairCurrencyExistValidator`. It accesses the currency pair repository and checks the database for `SELECT COUNT(1) FROM pair WHERE base = <passed currency ticker>`. Its realized via Doctrine Query Builder.

### Exchange rates update

Everything is quite simple here: `\App\Services\RatesUpdater` receives an iterator of currency rate providers in the constructor and calls them one by one (via ___invoke_, so you don't need to invent a method name). All providers inherit the `\App\Providers\RatesProvider` abstract class and implement their data transformation methods in the `\App\Dto\Rate` DTO.

The abstract provider asks for rates at the address specified in the configuration and _.env_, which is embedded in the constructor and the base currency's name. Then the provider parses rates from JSON or XML into a simple array and passes them to the provider-specific transformer.
Parsers locate in `src/Parsers/`.

For tests, `\App\Providers\FakeRatesProvider` is used with an overridden fetch method and a couple of rates wired into it.

The rates received in the form of DTO are stored in the database in direct and reverse form, after which the triangulator `\App\Services\RatesTriangulator` is put into operation. It creates all possible combinations of rates through intermediate currencies (so-called cross rates) and records them in the `\App\Entity\Pair` entity.

Triangulation is based on the principles of http://www.dpxo.net/articles/fx_rate_triangulation_sql.html. It is much easier to get one pair of currencies for conversion from a separate table with currency pairs than calculate rates for each conversion.

If something goes wrong, then the providers or the triangulator throw exceptions.

### Using

If you have PHP installed locally, you need to clone the repository, install packages, create a database, perform migrations and update exchange rates.

```bash
git clone https://github.com/vladimirmartsul/symfony-exchange-demo.git
cd symfony-exchange-demo
composer install --no-dev --no-interaction
php bin/console doctrine:database:create
php bin/console doctrine:migrations:migrate --no-interaction
php bin/console currency:update
```

#### Calculate exchange
`php bin/console currency:exchange <amount> <from> <to>`

For example
`php bin/console currency:exchange 2 EUR BTC`
should output
`[OK] 2 EUR is 0.00005254 BTC`


You can also build and run the application in Docker
```bash
git clone https://github.com/vladimirmartsul/symfony-exchange-demo.git
cd symfony-exchange-demo
docker compose up --build
```

The exchange rates are loaded during the build.

#### Calculate exchange
`docker compose run symfony-exchange-demo currency:exchange <amount> <from> <to>`

For example
`docker compose run symfony-exchange-demo currency:exchange 2 EUR BTC`
Should output the same result as the local PHP run.

### Testing

A couple of tests have been written for the application to make sure that the main functionality works correctly. The tests use a mocked provider of exchange rates.

`\App\Tests\Command\CurrencyUpdateCommandTest` - a simple check for messages about successful download, triangulation and update of courses.

`\App\Tests\Command\CurrencyExchangeCommandTest` - a little more complicated: checking the real conversion using a dataProvider with several currency pairs and the expected result. Each time the test is run, the exchange rates are updated.

You can run tests locally by installing additional dev packages.

```bash
cd symfony-exchange-demo
echo APP_ENV=test > .env.local
composer install --no-interaction
php bin/console doctrine:database:create
php bin/console doctrine:migrations:migrate --no-interaction
php bin/phpunit
```

Or similar with the Docker.

```bash
cd symfony-exchange-demo
echo APP_ENV=test > .env.local
docker compose run symfony-exchange-demo composer install --no-interaction
docker compose run symfony-exchange-demo doctrine:database:create
docker compose run symfony-exchange-demo doctrine:migrations:migrate --no-interaction
docker compose run symfony-exchange-demo bin/phpunit
```

Welcome to comments and pull requests :-)