Skip to content

18. Practical Exercise – Version 8

We’ll revisit the sample application—version 5 (and its version 7 configured by the file JSON)—and turn it into a client/server application. This will allow us to put into practice what we learned in the previous chapter about web services.

18.1. Introduction

The architecture of version 5 was as follows:

Image

  • the layer named [dao] (Data Access Objects) handles interactions with the database MySQL and the local file system;
  • the layer named [business] performs the tax calculation;
  • The main script acts as the orchestrator: it instantiates the layers [dao] and [business], then communicates with the layer [business] to perform the necessary tasks.

We are going to migrate this architecture to the following client/server architecture:

Image

  • In [2], we will reuse the [dao] layer from version 5, removing the methods that access the local file system. These methods will be migrated to the [dao] layer of the [6, 7] client;
  • in [3], the [business] layer will remain the same as in version 5, minus its [executeBatchTaxes] method, which is migrated to the [business] and [8] layers of the client;
  • In [4], the server script must be written; it will need to:
    • create the layers [business], [dao], and [3, 2];
    • communicate with the client script [5, 7];
  • In [7], the client layer [dao] must be written:
    • it will be a client HTTP of the server script [4, 5];
    • It will adopt the methods for accessing the local file system from the [dao] layer of Version 5;
  • In [8], the client’s [business] layer will provide the same methods as the [InterfaceBusiness] interface from Version 5. However, its implementation will be different. In version 5, the [business] layer performed the tax calculation. Here, the server’s [business] layer performs this calculation. The client-side layer [business] will therefore call upon the layers [dao] and [7] to communicate with the server and request that it calculate the tax;
  • In [9], the console script will need to instantiate the client layers [dao, business] and launch their execution.

The server is a web script: it is located in the [scripts-web/tax/version-08] folder and executed by the Apache server in [Laragon]. The client is a console script: it is located in the [scripts-console/tax/version-08] folder and executed in a terminal.

18.2. The Server

We are focusing on the server-side of the application.

Image

This architecture will be implemented by the following files:

scripts-web/tax/version-08
├── Business
│   ├── InterfaceServerBusiness.php
│   └── ServerBusiness.php
├── Dao
│   ├── InterfaceServerDao.php
│   └── ServerDao.php
├── Data
│   ├── .htaccess
│   ├── config-server.json
│   ├── database-mysql.json
│   └── database-pgsql.json
├── Tests
│   ├── ServerBusinessTest.php
│   └── ServerDaoTest.php
└── tax-server.php

18.2.1. Entities Exchanged Between Layers

The entities exchanged between the layers are those from version 5: the [TaxException] exception, the tax administration data [TaxAdminData], and the database description [Database]. The server also uses the Version 5 class [Utilities], whose method [readJsonFile] is used by the class [Database]. The PHP 7 course used to copy these files to the server folder. We now load them directly from the [scripts-console/tax/version-05] folder: this ensures that each class exists as a single instance, and any correction made in version 5 benefits all versions that use it.

18.2.2. The [dao] layer

The [dao] layer implements the following [InterfaceServerDao] interface:

<?php

declare(strict_types=1);

// namespace
namespace Application;

// the interface of the server's [dao] layer
interface InterfaceServerDao
{
    // reading the tax administration data
    // throws a [TaxException] exception in case of error
    public function getTaxAdminData(): TaxAdminData;
}
  • Line 13: The [getTaxAdminData] method retrieves data from the tax authority’s database.

The [InterfaceServerDao] interface is implemented by the following [ServerDao] class:

<?php

declare(strict_types=1);

// namespace
namespace Application;

// the server's [dao] layer: it finds the tax administration data in a database
// it is the [DaoTaxWithTaxAdminDataInDatabase] class of version 5 without the methods
// that work with the taxpayers' JSON files
class ServerDao implements InterfaceServerDao
{
    // the description of the database
    private readonly Database $database;
    // the [TaxAdminData] object that contains the tax administration data
    // [protected]: derived classes will be able to initialize it
    protected readonly TaxAdminData $taxAdminData;

    // the constructor
    public function __construct(string $databaseFilename)
    {
        // the description of the database - may throw a [TaxException] exception
        $this->database = Database::fromJsonFile($databaseFilename);
        $database = $this->database;
        try {
            // we open the connection to the database
            // the rows read will be returned as associative arrays [column => value]
            $connection = \PDO::connect(
                $database->dsn,
                $database->id,
                $database->pwd,
                [\PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC]
            );
            // we start a transaction
            $connection->beginTransaction();
            // we read the tax brackets table
            $brackets = $this->getBrackets($connection);
            // we read the constants table
            $constants = $this->getConstants($connection);
            // we end the transaction with a success
            $connection->commit();
        } catch (\PDOException $ex) {
            // is there a transaction in progress?
            if (isset($connection) && $connection->inTransaction()) {
                // we end the transaction with a failure
                $connection->rollBack();
            }
            // we pass the error up to the calling code as a [TaxException] exception
            throw new TaxException("Database access error ({$ex->getMessage()})", 30, $ex);
        }
        // building the [TaxAdminData] object: the data read are checked
        $this->taxAdminData = TaxAdminData::fromArray([...$brackets, ...$constants]);
    }

This is the code for the [DaoTaxWithTaxAdminDataInDatabase] class from version 5, presented in Chapter 13, without the [TraitDao] feature that provided the methods for reading and writing taxpayers’ JSON files. The private methods [getBrackets] and [getConstants] that follow—not reproduced here—remain unchanged. There is only one difference:

  • line 17: the attribute [taxAdminData] is declared as [protected] instead of [private]. Version 9 will derive the [ServerDao] class, and the derived class must be able to initialize this attribute. Starting with PHP 8.4, a derived class can initialize an inherited [readonly] attribute, provided that it is not private.

18.2.3. The [business] layer

The [business] layer implements the following [InterfaceServerBusiness] interface:

<?php

declare(strict_types=1);

// namespace
namespace Application;

// the interface of the server's [business] layer
interface InterfaceServerBusiness
{
    // calculating a taxpayer's tax
    // returns the array [tax, surcharge, discount, reduction, rate]
    public function calculateTax(string $married, int $children, int $salary): array;
}

The [InterfaceServerBusiness] interface is implemented by the following [ServerBusiness] class:

<?php

declare(strict_types=1);

// namespace
namespace Application;

// the server's [business] layer: the [Business] class of version 5 without the [executeBatchTax] method
class ServerBusiness implements InterfaceServerBusiness
{
    // tax administration data
    private readonly TaxAdminData $taxAdminData;

    // the [dao] layer is injected by the constructor
    public function __construct(private readonly InterfaceServerDao $dao)
    {
        // we retrieve the data used to calculate the tax
        // the [getTaxAdminData] method may throw a [TaxException] exception
        // we then let it propagate up to the calling code
        $this->taxAdminData = $this->dao->getTaxAdminData();
    }

    // tax calculation
    // --------------------------------------------------------------------------
    #[\Override]
    public function calculateTax(string $married, int $children, int $salary): array
  • Line 9: The class [ServerBusiness] is the class [Business] from version 5 without its method [executeBatchTaxes];
  • line 15: The constructor receives a reference to a [dao] layer of type [InterfaceServerDao];
  • line 26: the [calculateTax] method and the private methods it uses remain unchanged. The algorithm was discussed and explained starting in Version 1, and its object-oriented version is covered in Chapter 13.

18.2.4. The server script

The server script [tax-server.php] implements the [web] and [4] layers. It is configured by the following JSON and [Data/config-server.json] files:

{
    "databaseFilename": "Data/database-mysql.json",
    "dependencies": [
        "../../../scripts-console/tax/version-05/Entities/TaxException.php",
        "../../../scripts-console/tax/version-05/Entities/TaxAdminData.php",
        "../../../scripts-console/tax/version-05/Entities/Database.php",
        "../../../scripts-console/tax/version-05/Utilities/Utilities.php",
        "Dao/InterfaceServerDao.php",
        "Dao/ServerDao.php",
        "Business/InterfaceServerBusiness.php",
        "Business/ServerBusiness.php"
    ],
    "users": [
        {
            "login": "admin",
            "passwd": "admin"
        }
    ]
}
  • Line 2: the JSON configuration file for the MySQL database. This is the [database-mysql.json] file from version 7. The [Data] folder also contains the [database-pgsql.json] file for the PostgreSQL database: simply change this line so that the server works with PostgreSQL;
  • Lines 3–12: the PHP files for the application, whose paths are relative to the [version-08] folder on the server. The first four are from version 5;
  • lines 13–18: the list of users authorized to use the application.

As in version 7, the [rootDirectory] key no longer exists: the script calculates its own root folder based on the constant [__DIR__]. There is also no longer a [absoluteDependencies] key, which provided the absolute path to the [vendor/autoload.php] file from [Composer]: it is now determined based on [__DIR__].

The file [config-server.json] is located in the [Data] folder on the server, and thus within the website’s directory structure: without taking precautions, anyone could download it along with URL and [http://localhost/php8-oct-2026/scripts-web/tax/version-08/Data/config-server.json] and read the user credentials. The same applies to the file [database-mysql.json], which contains the database password. The [Data] folder therefore contains a file named [.htaccess]:

# the [Data] folder contains passwords: Apache refuses to serve its files
Require all denied

A file named [.htaccess] contains Apache server configuration directives that apply to the folder containing it and its subfolders. The [Require all denied] directive denies access to the folder: Apache responds with [403 Forbidden] to any request for a file in the [Data] folder. The PHP script, on the other hand, reads these files from the disk without going through Apache and is not affected.

The script [tax-server.php] implements the [web] layer as follows:

<?php

// strict compliance with the declared types of function parameters and results
declare(strict_types=1);

// namespace
namespace Application;

// Symfony dependencies
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

// the server's root folder: the paths of the configuration file are relative to this folder
const ROOT_DIRECTORY = __DIR__;
// path of the configuration file
const CONFIG_FILENAME = ROOT_DIRECTORY . "/Data/config-server.json";

// we retrieve the configuration
$config = json_decode(file_get_contents(CONFIG_FILENAME), true, flags: JSON_THROW_ON_ERROR);

// we include the dependencies needed by the script
require_once ROOT_DIRECTORY . "/../../../vendor/autoload.php";
foreach ($config["dependencies"] as $dependency) {
    require_once ROOT_DIRECTORY . "/$dependency";
}

// preparing the server's JSON response
$response = new JsonResponse();
// accented characters and / not escaped, floats always written with a decimal point (0.0)
$response->setEncodingOptions(JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION);

// we retrieve the current request
$request = Request::createFromGlobals();
// authentication: the credentials sent by the client (null if it did not send any)
$requestUser = $request->getUser();
$requestPassword = $request->getPassword();
// is the user one of the authorized users?
$found = $requestUser !== null && $requestPassword !== null
    && array_any(
        $config["users"],
        fn(array $user): bool => $user["login"] === $requestUser && hash_equals($user["passwd"], $requestPassword)
    );
if (!$found) {
    // not found - code 401
    $response->setStatusCode(Response::HTTP_UNAUTHORIZED);
    $response->headers->set("WWW-Authenticate", 'Basic realm="Tax calculation server"');
    // error message
    $response->setData(["response" => ["error" => "Authentication failed [$requestUser]"]]);
    $response->send();
    // end
    exit;
}

// we have a valid user - we check the parameters received
$errors = [];
// we must have a GET request with three parameters
if ($request->getMethod() !== "GET" || $request->query->count() !== 3) {
    $errors[] = "GET method required with only the parameters [married, children, salary]";
}
// the marital status
if (!$request->query->has("married")) {
    $errors[] = "missing married parameter";
} else {
    $married = strtolower(trim($request->query->getString("married")));
    if (!in_array($married, ["yes", "no"], true)) {
        $errors[] = "invalid married parameter [$married]";
    }
}
// the number of children and the salary: integers >= 0
$values = [];
foreach (["children", "salary"] as $parameter) {
    if (!$request->query->has($parameter)) {
        $errors[] = "missing $parameter parameter";
        continue;
    }
    $value = trim($request->query->getString($parameter));
    if (preg_match('/^\d+$/', $value) !== 1) {
        $errors[] = "invalid $parameter parameter [$value]";
    } else {
        $values[$parameter] = (int) $value;
    }
}
// other parameters in the request?
foreach (array_diff(array_keys($request->query->all()), ["married", "children", "salary"]) as $parameter) {
    $errors[] = "invalid parameter [$parameter]";
}
// errors?
if ($errors !== []) {
    // we send a 400 error code to the client
    $response->setStatusCode(Response::HTTP_BAD_REQUEST);
    $response->setData(["response" => ["errors" => $errors]]);
    $response->send();
    exit;
}

// we have everything we need to work
// building the server's architecture
try {
    // creating the [dao] layer
    $dao = new ServerDao(ROOT_DIRECTORY . "/" . $config["databaseFilename"]);
    // creating the [business] layer
    $business = new ServerBusiness($dao);
} catch (TaxException $ex) {
    // the error message of a DBMS driver may not be UTF-8 encoded (Windows-1252 on Windows)
    $message = $ex->getMessage();
    if (!mb_check_encoding($message, "UTF-8")) {
        $message = mb_convert_encoding($message, "UTF-8", "Windows-1252");
    }
    // we send a 500 error code to the client
    $response->setStatusCode(Response::HTTP_INTERNAL_SERVER_ERROR);
    $response->setData(["response" => ["error" => $message]]);
    $response->send();
    exit;
}
// tax calculation
$result = $business->calculateTax($married, $values["children"], $values["salary"]);
// we return the response
$response->setData(["response" => $result]);
$response->send();

Comments

  • lines 10–12: the classes from the [HttpFoundation] library that will be used are declared;
  • Line 15: The server’s root directory is the script’s directory;
  • line 20: the configuration file is loaded;
  • Lines 23–26: We load the class loader from [Composer], then all the application files;
  • line 29: we prepare a JSON response. The [JsonResponse] class is a subclass of [Response]: its [setData] method encodes the array passed to it into JSON and uses it as the response body. It also sets the header HTTP [Content-Type: application/json];
  • Line 31: The options for function [json_encode], which is used by [setData]. With [JSON_UNESCAPED_UNICODE], accented characters are not encoded in [\uXXXX]. With [JSON_PRESERVE_ZERO_FRACTION], the actual tax rate [0.0] is written as [0.0] and not [0]: the client will decode it as a floating-point number (float) and not as an integer;
  • lines 36–37: We retrieve the identifiers sent by the client, as in the previous chapter;
  • lines 39–43: We verify that the user is indeed one of the authorized users. The function [array_any] (PHP 8.4) returns [true] if at least one element of the array [$config["users"]] satisfies the condition expressed by the arrow function passed to it. It replaces the [while] loop in the PHP 7 script. Passwords are compared using [hash_equals];
  • lines 44–52: if this is not the case, the code HTTP [401 Unauthorized] is sent, indicating that access has been denied. Upon receiving this code and the header HTTP [WWW-Authenticate: Basic realm=…], browsers display an authentication window prompting the user to authenticate;
  • Line 49: The server’s response, JSON, explains the cause of the error. All server responses will be the string JSON from an array [["response" => something]]. The script PHP 7 returned the rejected username and password to the client. A password must never be returned or written to logs: we only return the username;
  • Lines 56–88: We verify the validity of the request:
    • a GET request with exactly three parameters;
    • a parameter [married] whose value must be [yes] or [no];
    • a parameter [children], whose value must be an integer >= 0;
    • a parameter [salary], whose value must be an integer greater than or equal to 0;
    • no other parameters;
  • line 59: each time an error is detected, an error message is added to the array [$errors];
  • line 65: the method [getString] of the object [$request->query] returns the parameter value as a character string;
  • line 72: The parameters [children] and [salary] follow the same rule: they are checked in the same loop. Their values, converted to integers, are placed in the array [$values];
  • line 85: The function [array_diff] returns the names of the received parameters that are not in the list [married, children, salary];
  • line 89: if an error occurs, the code HTTP [400 Bad Request] is sent to the client. The response JSON contains the list of errors;
  • Lines 99–104: From this point on, the query has been validated. The [dao, business] layers can be instantiated. This instantiation incurs a cost (reading the database) and should only be performed if you are certain the query is valid. Constructing the [dao] layer may throw a [TaxException]-type exception;
  • lines 106–108: The error message is often that of the PDO driver of the DBMS. On Windows, it may sometimes be encoded in Windows-1252 rather than UTF-8. However, the [json_encode] function only accepts UTF-8 strings: [JsonResponse] would then throw an exception. The [mb_check_encoding] function checks the string’s encoding, and the [mb_convert_encoding] function converts it if necessary. The script PHP 7 used the function [utf8_encode], which has been deprecated since PHP 8.2;
  • line 111: if an exception occurred, then the code HTTP [500 Internal Server Error] is sent to the client. This code indicates that the server was unable to process a valid request. The response explains the cause of the error;
  • line 117: the tax calculation is delegated to the [business] layer;
  • Lines 119–120: Send the response, with the default status code [200 OK].

Let’s test this script. In a browser, request URL and [http://localhost/php8-oct-2026/scripts-web/tax/version-08/tax-server.php?married=yes&children=2&salary=55555]. The browser displays its authentication window, where you enter [admin] and [admin]. It then displays the server’s response: JSON. If the server rejects these credentials, see the section “When the server always responds with 401” in the previous chapter.

18.2.4.1. Why [http] and not [https]?

The client sends its credentials: strictly speaking, the URL should be secured ([https]), as we saw in the previous chapter. But modern browsers ([Chrome], [Edge], [Firefox]) will only accept a HTTPS page if the server certificate is issued by a certificate authority they trust. The certificate that [Laragon] generates for [localhost] is self-signed: it is not guaranteed by anyone. Depending on the browser version and the state of its cache, the browser either displays a warning with a link ([Proceed to localhost (unsafe)]) or refuses to load the page without offering the option to proceed. This happens when the browser has recorded that [localhost] must always be served with a valid certificate (HSTS mechanism), or when it deems the certificate non-compliant. The solutions described in the previous chapter (adding the [Laragon] certificate to the [Windows] certificate store, deleting the HSTS rule) are not always sufficient.

We therefore use the unsecured URL [http://localhost/...] in the browser. This is not a security lapse here: a request to [localhost] never leaves the machine (it passes through the internal network interface, known as the “loopback” interface), and no spyware can intercept it on the network. Web browsers are aware of this, by the way: they treat [http://localhost] as a trusted origin. On the other hand, as soon as the server is accessible from the network—and especially in a production environment—the HTTPS protocol is essential, along with a valid certificate issued by a recognized authority (for example, free of charge, by [Let's Encrypt]).

The clients we write, however, are not browsers: the [curl] command (option [-k]) and the Symfony client [HttpClient] (options [verify_peer] through [false]) can accept the self-signed certificate from [Laragon]. However, this requires that the HTTPS protocol have been enabled in [Laragon] ([Apache / SSL / Enabled] menu), which is not the case by default, and that this certificate be usable on the machine. To ensure the examples work on any [Laragon] installation, we will therefore use URL and [http://localhost/...] throughout this chapter and the following ones: in the browser, in the [curl] commands, and in the client configuration files ([urlServer] key). To switch to HTTPS, simply replace [http] with [https] in these URL and add the [-k] option to the [curl] commands: The client configuration files already contain the necessary options [verify_peer] and [verify_host], which have no effect when used with [http]. The results are the same with both schemes.

Let’s run the same query with [curl]. The non-ASCII characters in a URL must be encoded: for example, the character [é] is encoded as [%C3%A9] (its two bytes as UTF-8). A browser performs this encoding itself:

> curl -i -u admin:admin "http://localhost/php8-oct-2026/scripts-web/tax/version-08/tax-server.php?married=yes&children=2&salary=55555"
HTTP/1.0 200 OK
Date: Fri, 02 Oct 2026 12:29:49 GMT
Server: Apache/2.4.58 (Ubuntu)
X-Powered-By: PHP/8.5.11
Cache-Control: no-cache, private
Connection: close
Content-Type: application/json

{"response":{"tax":2814,"surcharge":0,"discount":0,"reduction":0,"rate":0.14}}
  • line 2: the code [200 OK];
  • line 8: the header HTTP [Content-Type: application/json] defined by the class [JsonResponse];
  • Line 10: The server's response JSON.

Without credentials (no [-u] option), the code [401 Unauthorized] is returned:

> curl -i "http://localhost/php8-oct-2026/scripts-web/tax/version-08/tax-server.php?married=yes&children=2&salary=55555"
HTTP/1.0 401 Unauthorized
Date: Fri, 02 Oct 2026 12:29:49 GMT
Server: Apache/2.4.58 (Ubuntu)
X-Powered-By: PHP/8.5.11
Cache-Control: no-cache, private
WWW-Authenticate: Basic realm="Tax calculation server"
Connection: close
Content-Type: application/json

{"response":{"error":"Authentication failed []"}}

Let’s run a few error tests. We request URL and [http://localhost/php8-oct-2026/scripts-web/tax/version-08/tax-server.php?married=x&children=x&salary=x&w=x]:

> curl -i -u admin:admin "http://localhost/php8-oct-2026/scripts-web/tax/version-08/tax-server.php?married=x&children=x&salary=x&w=x"
HTTP/1.0 400 Bad Request
Date: Fri, 02 Oct 2026 12:29:49 GMT
Server: Apache/2.4.58 (Ubuntu)
X-Powered-By: PHP/8.5.11
Cache-Control: no-cache, private
Connection: close
Content-Type: application/json

{"response":{"errors":["GET method required with only the parameters [married, children, salary]","invalid married parameter [x]","invalid children parameter [x]","invalid salary parameter [x]","invalid parameter [w]"]}}

A POST request without parameters (option [-X POST] from [curl]):

> curl -i -u admin:admin -X POST "http://localhost/php8-oct-2026/scripts-web/tax/version-08/tax-server.php"
HTTP/1.0 400 Bad Request
Date: Fri, 02 Oct 2026 12:29:49 GMT
Server: Apache/2.4.58 (Ubuntu)
X-Powered-By: PHP/8.5.11
Cache-Control: no-cache, private
Connection: close
Content-Type: application/json

{"response":{"errors":["GET method required with only the parameters [married, children, salary]","missing married parameter","missing children parameter","missing salary parameter"]}}

Now let’s simulate a failure of the DBMS. We can stop MySQL from the [Laragon] window. Instead, we changed the DSN entry in the [database-mysql.json] file to [mysql:host=127.0.0.1;port=3307;dbname=dbtaxes-2019;charset=utf8mb4]: no DBMS is listening on port 3307, and the connection is refused, just as it would be if MySQL were stopped:

> curl -i -u admin:admin "http://localhost/php8-oct-2026/scripts-web/tax/version-08/tax-server.php?married=yes&children=3&salary=60000"
HTTP/1.0 500 Internal Server Error
Date: Fri, 02 Oct 2026 12:29:50 GMT
Server: Apache/2.4.58 (Ubuntu)
X-Powered-By: PHP/8.5.11
Cache-Control: no-cache, private
Connection: close
Content-Type: application/json

{"response":{"error":"Database access error (SQLSTATE[HY000] [2002] Connection refused)"}}
  • line 2: the code [500 Internal Server Error];
  • line 10: the exception message [TaxException] thrown by the [dao] layer. On Windows, the driver message is in French: [Aucune connexion n'a pu être établie car l'ordinateur cible l'a expressément refusée].

Then restore the original DSN.

18.2.5. [PHPUnit] Tests

Each time we build a new version of the server, we will test the layers [dao] and [business], as has been done since version 4. These tests do not go through the web server: they build the [dao] and [business] layers just as the server script does and test them directly. They are located in the [Tests] folder on the server. The PHP 7 course used the [Codeception] testing framework; we are using [PHPUnit], as in the previous chapters.

18.2.5.1. [dao] Layer Tests

The [Tests/ServerDaoTest.php] test is as follows:

<?php

// strict compliance with the declared types of function parameters and results
declare(strict_types=1);

// namespace of the tests
namespace Application\Tests;

// the tested class
use Application\ServerDao;
// the PHPUnit classes
use PHPUnit\Framework\TestCase;

final class ServerDaoTest extends TestCase
{
    // the server's root folder
    private const string ROOT_DIRECTORY = __DIR__ . "/..";
    // the server's configuration
    private static array $config;

    // executed once, before the first test of the class
    #[\Override]
    public static function setUpBeforeClass(): void
    {
        // we retrieve the server's configuration
        self::$config = json_decode(file_get_contents(self::ROOT_DIRECTORY . "/Data/config-server.json"), true, flags: JSON_THROW_ON_ERROR);
        // we include the server's dependencies
        foreach (self::$config["dependencies"] as $dependency) {
            require_once self::ROOT_DIRECTORY . "/$dependency";
        }
    }

    // the tax administration data read from the database
    public function testTaxAdminData(): void
    {
        // creating the [dao] layer as in the server script
        $dao = new ServerDao(self::ROOT_DIRECTORY . "/" . self::$config["databaseFilename"]);
        $taxAdminData = $dao->getTaxAdminData();
        // calculation constants
        $this->assertSame(1551.0, $taxAdminData->halfShareQfCeiling);
        $this->assertSame(21037.0, $taxAdminData->singleIncomeCeilingForReduction);

Comments

  • Lines 23–31: We set up the same working environment as in the server script [tax-server.php]: reading the configuration file and loading the application files. The class loader for [Composer] does not need to be loaded: [PHPUnit] has already done so;
  • Line 37: We create an instance of the [dao] layer to be tested, just as is done in the server script;
  • From this point on, the conditions are the same as in the server script: the remainder of the [testTaxAdminData] method, not reproduced here, is the same as the [DaoTest] test for versions 5 through 7.

18.2.5.2. Tests for layer [business]

The [Tests/ServerBusinessTest.php] test is as follows:

<?php

// strict compliance with the declared types of function parameters and results
declare(strict_types=1);

// namespace of the tests
namespace Application\Tests;

// the tested classes
use Application\InterfaceServerBusiness;
use Application\ServerDao;
use Application\ServerBusiness;
// the PHPUnit classes
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

final class ServerBusinessTest extends TestCase
{
    // the server's root folder
    private const string ROOT_DIRECTORY = __DIR__ . "/..";
    // the server's configuration
    private static array $config;
    // the tested [business] layer
    private InterfaceServerBusiness $business;

    // executed once, before the first test of the class
    #[\Override]
    public static function setUpBeforeClass(): void
    {
        // we retrieve the server's configuration
        self::$config = json_decode(file_get_contents(self::ROOT_DIRECTORY . "/Data/config-server.json"), true, flags: JSON_THROW_ON_ERROR);
        // we include the server's dependencies
        foreach (self::$config["dependencies"] as $dependency) {
            require_once self::ROOT_DIRECTORY . "/$dependency";
        }
    }

    // executed before each test
    #[\Override]
    protected function setUp(): void
    {
        // creating the [dao] and [business] layers as in the server script
        $dao = new ServerDao(self::ROOT_DIRECTORY . "/" . self::$config["databaseFilename"]);
        $this->business = new ServerBusiness($dao);
    }

    // the taxpayers of the table of official figures
    public static function taxpayers(): array

Comments

  • lines 28–36: We set up the same working environment as in the server script;
  • Lines 40–44: Before each test, an instance of the [business] layer to be tested is created, as done in the server script;
  • The remainder of the test, not reproduced here, is the same as the [BusinessTest] test for versions 4 through 7: a [taxpayers] data provider containing the 11 taxpayers from the official figures table and the [testCalculateTax] method.

The test results are as follows (the MySQL DBMS must be running):

> php vendor/bin/phpunit scripts-web/tax/version-08/Tests --testdox
PHPUnit 12.5.37 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.5.11
Configuration: .../php8-oct-2026/phpunit.xml

............                                                      12 / 12 (100%)

Time: 00:00.014, Memory: 16.00 MB

Server Business (Application\Tests\ServerBusiness)
 ✔ Calculate tax with data set "couple, 2 children, 55555"
 ✔ Calculate tax with data set "couple, 2 children, 50000"
 ✔ Calculate tax with data set "couple, 3 children, 50000"
 ✔ Calculate tax with data set "single, 2 children, 100000"
 ✔ Calculate tax with data set "single, 3 children, 100000"
 ✔ Calculate tax with data set "couple, 3 children, 100000"
 ✔ Calculate tax with data set "couple, 5 children, 100000"
 ✔ Calculate tax with data set "single, 0 children, 100000"
 ✔ Calculate tax with data set "couple, 2 children, 30000"
 ✔ Calculate tax with data set "single, 0 children, 200000"
 ✔ Calculate tax with data set "couple, 3 children, 200000"

Server Dao (Application\Tests\ServerDao)
 ✔ Tax admin data

OK (12 tests, 35 assertions)

18.3. The client

We are focusing on the client-side of the application.

Image

This architecture will be implemented by the following files:

scripts-console/tax/version-08
├── Business
│   ├── ClientBusiness.php
│   └── InterfaceClientBusiness.php
├── Dao
│   ├── ClientDao.php
│   └── InterfaceClientDao.php
├── Data
│   ├── config-client-ko.json
│   ├── config-client-unreachable.json
│   ├── config-client.json
│   ├── errors.json
│   ├── results.json
│   └── taxpayersdata.json
├── Main
│   └── MainTaxClient.php
└── Tests
    └── ClientBusinessTest.php

18.3.1. Entities Exchanged Between Layers

The client entities have all been described and are already used in version 5:

  • the exception [TaxException];
  • the data for taxpayer [TaxPayerData].

Like the server, the client loads them from the Version 5 folder, along with the class [Utilities] and the trait [TraitDao].

18.3.2. The [dao] layer

The [dao] layer implements the following [InterfaceClientDao] interface:

<?php

declare(strict_types=1);

// namespace
namespace Application;

// the interface of the [dao] layer of the client
// all the methods throw a [TaxException] exception in case of error
interface InterfaceClientDao
{
    // reading the taxpayers data
    // the incorrect data are reported in the JSON file [$errorsFilename]
    /** @return TaxPayerData[] */
    public function getTaxPayersData(string $taxPayersFilename, string $errorsFilename): array;

    // calculation of the tax of a taxpayer by the server
    // returns the array [tax, surcharge, discount, reduction, rate]
    public function calculateTax(string $married, int $children, int $salary): array;

    // saving the results
    /** @param TaxPayerData[] $taxPayersData */
    public function saveResults(string $resultsFilename, array $taxPayersData): void;
}
  • Line 15: The [getTaxPayersData] method loads taxpayer data from the JSON and [$taxPayersFilename] files into memory. If there are any errors, they are logged in the JSON and [$errorsFilename] files;
  • line 19: the [calculateTax] method calculates a taxpayer’s tax;
  • line 23: the method [saveResults] saves the data from table [$taxPayersData]—which represents the results of several tax calculations—to file [$resultsFilename].

The methods [getTaxPayersData] and [saveResults] are part of the [InterfaceDao] interface in version 5. The [InterfaceClientDao] interface is implemented by the following [ClientDao] class:

<?php

declare(strict_types=1);

// namespace
namespace Application;

// Symfony dependencies
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

// the [dao] layer of the client: an HTTP client of the tax calculation server
class ClientDao implements InterfaceClientDao
{
    // the trait of version 5 implements the [getTaxPayersData] and [saveResults] methods of the interface
    use TraitDao;

    // the HTTP client
    private readonly HttpClientInterface $httpClient;

    // constructor
    // $urlServer: the URL of the tax calculation server
    // $user: the user who makes the requests, array [login, passwd]
    // $httpClientOptions: additional options of the HTTP client (verify_peer, verify_host...)
    public function __construct(
        private readonly string $urlServer,
        #[\SensitiveParameter] array $user,
        array $httpClientOptions = [],
    ) {
        // the HTTP client: the credentials of the user will be sent with each request
        $this->httpClient = HttpClient::create([
            ...$httpClientOptions,
            "auth_basic" => [$user["login"], $user["passwd"]],
        ]);
    }

    // tax calculation by the server
    #[\Override]
    public function calculateTax(string $married, int $children, int $salary): array
    {
        try {
            // we make the request to the server
            $response = $this->httpClient->request("GET", $this->urlServer, [
                "query" => [
                    "married" => $married,
                    "children" => $children,
                    "salary" => $salary,
                ],
            ]);
            // the status of the response
            $statusCode = $response->getStatusCode();
            // the JSON document of the response, even if the status is an error code (parameter false)
            $json = $response->getContent(false);
        } catch (ExceptionInterface $ex) {
            // the server could not be reached
            throw new TaxException("Error communicating with the server ({$ex->getMessage()})", 40, $ex);
        }
        // logs
        // print "$json\n";
        // the server sends the JSON string of an array ["response" => something]
        try {
            $serverResponse = json_decode($json, true, flags: JSON_THROW_ON_ERROR)["response"] ?? null;
        } catch (\JsonException $ex) {
            $serverResponse = null;
        }
        if (!is_array($serverResponse)) {
            throw new TaxException("Invalid server response [$json]", 41);
        }
        // error?
        if ($statusCode !== 200) {
            // we throw an exception whose message is the server's response completed by its HTTP status
            $message = json_encode(
                ["HTTP status" => $statusCode] + $serverResponse,
                JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR
            );
            throw new TaxException($message, 42);
        }
        // we return the response: the array [tax, surcharge, discount, reduction, rate]
        return $serverResponse;
    }
}

Comments

  • line 17: We insert the [TraitDao] class from version 5, which implements the [getTaxPayersData] and [saveResults] methods. This leaves only the [calculateTax] method to be implemented;
  • line 20: the client HTTP of [Symfony], of the type of the interface [HttpClientInterface];
  • lines 26–30: The constructor of the [ClientDao] class takes three parameters:
    • the URL [$urlServer] from the tax calculation server. This is a parameter promoted to an attribute (constructor property promotion);
    • the array [$user] of keys [login] and [passwd], which defines the user making the requests. The attribute [#[\SensitiveParameter]] (PHP 8.2) instructs PHP to hide the value of this parameter in the stack traces displayed in the event of an error: the password will not appear there;
    • additional options for the HTTP client;
  • lines 32–35: the client HTTP is created once and for all, with the received options, which are supplemented by the option [auth_basic]: the user’s credentials will be sent with each request. The operator [...] copies the elements from the array [$httpClientOptions] into the options array. The client PHP 7 created a new client HTTP for each request;
  • line 40: The [calculateTax] method receives the three parameters to be sent to the tax calculation server;
  • lines 44–50: The server is queried with the three parameters it expects. The [query] option adds these parameters to URL after encoding them;
  • Line 54: The response JSON is retrieved from the server. If the parameter [false] is not passed to the method [getContent], then, if the server’s response status indicates an error (3xx–5xx codes), the HTTP client throws an exception as soon as an attempt is made to retrieve the response content. Here, regardless of the HTTP status of the response, we want to access its content, which explains the error;
  • Lines 55–57: If the server could not be reached, the HTTP client throws a [ExceptionInterface] exception. We convert it to a [TaxException] exception: this way, the client’s upper layers only have to handle one exception type;
  • line 60: In development mode, the server’s JSON response can be displayed;
  • Lines 63–68: The server's response is the string JSON from an array [["response" => something]]. We retrieve the [something]. If the received document is not JSON (for example, an Apache error page HTML), we throw a [TaxException] exception;
  • lines 71–77: if the status code HTTP is not 200, then the server has encountered a problem. An exception of type [TaxException] is then thrown, with a message consisting of the server’s response JSON appended with the response code HTTP. The operator [+] combines two associative arrays;
  • line 80: the result is returned as an associative array with keys [tax, surcharge, discount, reduction, rate].

18.3.3. The [business] layer

The [business] [8] layer implements the following [InterfaceClientBusiness] interface:

<?php

declare(strict_types=1);

// namespace
namespace Application;

// the interface of the [business] layer of the client
interface InterfaceClientBusiness
{
    // calculation of the tax of a taxpayer
    // returns the array [tax, surcharge, discount, reduction, rate]
    public function calculateTax(string $married, int $children, int $salary): array;

    // calculation of the taxes in batch mode
    // may throw a [TaxException] exception coming up from the [dao] layer
    public function executeBatchTaxes(string $taxPayersFileName, string $resultsFileName, string $errorsFileName): void;
}
  • Line 13: The [calculateTax] method calculates the tax;
  • line 17: the [executeBatchTaxes] method calculates the tax for taxpayers whose data is in the [$taxPayersFileName] file, writes the results to the [$resultsFileName] file, and writes any errors encountered to the [$errorsFileName] file.

These are the two methods of the [InterfaceBusiness] interface in version 5. The [InterfaceClientBusiness] interface is implemented by the following [ClientBusiness] class:

<?php

declare(strict_types=1);

// namespace
namespace Application;

// the [business] layer of the client: the tax calculation is done by the server
class ClientBusiness implements InterfaceClientBusiness
{
    // the [dao] layer is injected by the constructor
    public function __construct(private readonly InterfaceClientDao $clientDao)
    {
    }

    // tax calculation: it is delegated to the [dao] layer that queries the server
    #[\Override]
    public function calculateTax(string $married, int $children, int $salary): array
    {
        return $this->clientDao->calculateTax($married, $children, $salary);
    }

    // calculation of the taxes in batch mode
    #[\Override]
    public function executeBatchTaxes(string $taxPayersFileName, string $resultsFileName, string $errorsFileName): void
    {
        // we let the exceptions coming from the [dao] layer go up
        // we retrieve the taxpayers data
        $taxPayersData = $this->clientDao->getTaxPayersData($taxPayersFileName, $errorsFileName);
        // we calculate the tax of each taxpayer
        $results = array_map(
            fn(TaxPayerData $taxPayerData): TaxPayerData => $taxPayerData->withResults(
                $this->calculateTax($taxPayerData->married, $taxPayerData->children, $taxPayerData->salary)
            ),
            $taxPayersData
        );
        // saving the results
        $this->clientDao->saveResults($resultsFileName, $results);
    }
}

Comments

  • line 12: The constructor of the [ClientBusiness] class receives a reference to the [dao] layer as a parameter;
  • Lines 18–20: The tax calculation is delegated to the [dao] layer;
  • lines 25–38: The method [executeBatchTaxes] is that of the [Business] class in version 5.

18.3.4. The main script

The client script [Main/MainTaxClient.php] implements the layers [console] and [9]. It is configured by the following file: JSON and [Data/config-client.json]:

{
    "urlServer": "http://localhost/php8-oct-2026/scripts-web/tax/version-08/tax-server.php",
    "user": {
        "login": "admin",
        "passwd": "admin"
    },
    "httpClientOptions": {
        "verify_peer": false,
        "verify_host": false
    },
    "taxPayersDataFilename": "Data/taxpayersdata.json",
    "resultsFilename": "Data/results.json",
    "errorsFilename": "Data/errors.json",
    "dependencies": [
        "../version-05/Entities/TaxException.php",
        "../version-05/Entities/TaxPayerData.php",
        "../version-05/Utilities/Utilities.php",
        "../version-05/Dao/TraitDao.php",
        "Dao/InterfaceClientDao.php",
        "Dao/ClientDao.php",
        "Business/InterfaceClientBusiness.php",
        "Business/ClientBusiness.php"
    ]
}
  • Line 2: the URL from the tax calculation server. It uses the [http] schema: we saw why earlier;
  • Lines 3–6: the user sending requests to the tax calculation server;
  • lines 7–9: the HTTP client options. They are only used with a URL or [https]: as in the previous chapter, they instruct the client not to verify the server’s self-signed certificate. When used with a URL or [http], these options are ignored;
  • lines 11–13: the JSON files containing taxpayer data, results, and errors;
  • lines 14–23: the various PHP files from the client. The first four are those for version 5.

The code for the [MainTaxClient.php] script is as follows:

<?php

// strict respect of the declared types of function parameters and results
declare(strict_types=1);

// namespace
namespace Application;

// the root folder of the client: the paths of the configuration file are relative to this folder
const ROOT_DIRECTORY = __DIR__ . "/..";
// the configuration file of the [Data] folder: 1st argument of the command line if it exists
$configFilename = ROOT_DIRECTORY . "/Data/" . ($argv[1] ?? "config-client.json");

// we retrieve the configuration
$config = json_decode(file_get_contents($configFilename), true, flags: JSON_THROW_ON_ERROR);

// we include the dependencies needed by the script: third-party libraries (Symfony) then application files
require_once ROOT_DIRECTORY . "/../../../vendor/autoload.php";
foreach ($config["dependencies"] as $dependency) {
    require_once ROOT_DIRECTORY . "/$dependency";
}

// the paths of the data files
$taxPayersDataFilename = ROOT_DIRECTORY . "/" . $config["taxPayersDataFilename"];
$resultsFilename = ROOT_DIRECTORY . "/" . $config["resultsFilename"];
$errorsFilename = ROOT_DIRECTORY . "/" . $config["errorsFilename"];

// creation of the [dao] layer
$clientDao = new ClientDao($config["urlServer"], $config["user"], $config["httpClientOptions"]);
// creation of the [business] layer
$clientBusiness = new ClientBusiness($clientDao);

// tax calculation in batch mode
try {
    $clientBusiness->executeBatchTaxes($taxPayersDataFilename, $resultsFilename, $errorsFilename);
    print "The results have been saved in the file [{$config["resultsFilename"]}]\n";
} catch (TaxException $ex) {
    // we display the error
    print "Error no. {$ex->getCode()}: {$ex->getMessage()}\n";
}
// end
print "Done\n";

Comments

  • line 10: the client’s root folder is the parent folder of the [Main] folder;
  • line 12: the configuration file, located in the [Data] folder. Its name can be passed as an argument to the script ([php MainTaxClient.php config-client-ko.json]). Otherwise, the default is the [config-client.json] file. We will use this option for error testing;
  • Lines 18–21: Loading dependencies: the [Composer] class loader for the Symfony [HttpClient] library, followed by the client files;
  • line 29: creating the [dao] layer. We pass the information the layer constructor expects to it:
    • the URL from the tax calculation server;
    • the credentials of the user who will make the requests;
    • the options for the HTTP client;
  • Line 31: Creation of the [business] layer. A reference to the [dao] layer that was just created is passed to the layer constructor;
  • line 35: the [business] layer is instructed to:
    • calculate the taxes for all taxpayers in the [taxpayersdata.json] file;
    • write the results to the file [results.json];
    • write the errors to the file [errors.json];
  • Lines 37–39: All client errors, including communication errors with the server, occur as a [TaxException] exception, for which the code and message are displayed.

With the Apache server and MySQL DBMS running, let’s run the client in a terminal opened in the [Main] folder:

1
2
3
> php MainTaxClient.php
The results have been saved in the file [Data/results.json]
Done

Running the client produces the same results as in previous versions. Check the following files:

  • [Data/taxpayersdata.json]: data for taxpayers for whom the tax amount is calculated;
  • [Data/results.json]: results for the various taxpayers in the [Data/taxpayersdata.json] file. This file is identical to the [results.json] file from version 7;
  • [Data/errors.json]: errors that may have occurred while processing the [Data/taxpayersdata.json] file.

Let’s look at the possible error scenarios. First, an unreachable server. You can stop the Apache server for [Laragon]. Instead, we used the configuration file [config-client-unreachable.json], which is identical to the file [config-client.json] except for URL: this one specifies port 4443 on the [localhost] machine, on which no server is listening for requests ([http://localhost:4443/php8-oct-2026/...]). The results in the client console are then as follows:

1
2
3
> php MainTaxClient.php config-client-unreachable.json
Error no. 40: Error communicating with the server (Failed to connect to localhost port 4443 after 0 ms: Couldn't connect to server for "http://localhost:4443/php8-oct-2026/scripts-web/tax/version-08/tax-server.php?married=yes&children=2&salary=55555".)
Done

The exception thrown by the Symfony client HTTP was converted by the [dao] layer into a [TaxException] exception with code 40.

Now, let’s simulate a failure of the MySQL DBMS, in the same way as we did previously with [curl]. The results in the client console are as follows:

1
2
3
> php MainTaxClient.php
Error no. 42: {"HTTP status":500,"error":"Database access error (SQLSTATE[HY000] [2002] Connection refused)"}
Done

Let’s restore the DBMS, then use the configuration file [config-client-ko.json], in which the logging-in user is unknown to the server:

1
2
3
4
    "user": {
        "login": "x",
        "passwd": "x"
    },

The results in the client console are then as follows:

1
2
3
> php MainTaxClient.php config-client-ko.json
Error no. 42: {"HTTP status":401,"error":"Authentication failed [x]"}
Done

18.3.5. [PHPUnit] Tests

As we did for previous versions, we will write a test named [PHPUnit] for the client’s [business] layer. The [Tests/ClientBusinessTest.php] test is as follows:

<?php

// strict respect of the declared types of function parameters and results
declare(strict_types=1);

// namespace of the tests
namespace Application\Tests;

// the tested classes
use Application\ClientDao;
use Application\ClientBusiness;
use Application\InterfaceClientBusiness;
// the PHPUnit classes
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

// test of the [business] layer of the client: the server must be running
final class ClientBusinessTest extends TestCase
{
    // the root folder of the client
    private const string ROOT_DIRECTORY = __DIR__ . "/..";
    // the configuration of the client
    private static array $config;
    // the tested [business] layer
    private InterfaceClientBusiness $business;

    // executed once, before the first test of the class
    #[\Override]
    public static function setUpBeforeClass(): void
    {
        // we retrieve the configuration of the client
        self::$config = json_decode(file_get_contents(self::ROOT_DIRECTORY . "/Data/config-client.json"), true, flags: JSON_THROW_ON_ERROR);
        // we include the dependencies of the client (the Composer autoloader is already loaded by PHPUnit)
        foreach (self::$config["dependencies"] as $dependency) {
            require_once self::ROOT_DIRECTORY . "/$dependency";
        }
    }

    // executed before each test
    #[\Override]
    protected function setUp(): void
    {
        // creation of the [dao] and [business] layers as in the main script
        $clientDao = new ClientDao(self::$config["urlServer"], self::$config["user"], self::$config["httpClientOptions"]);
        $this->business = new ClientBusiness($clientDao);
    }

Comments

  • lines 29–37: definition of the test environment. We use the same one as in the main script [MainTaxClient.php];
  • Lines 41–45: Creation of the layers [dao] and [business] before each test. The attribute [$this->business] references the layer [business];
  • The rest of the test, not reproduced here, is the same as in previous versions: the 11 taxpayers from the table of official figures and the [testCalculateTax] method. Note that the [assertSame] validation requires that the values returned by the server have exactly the expected types: integers for tax, surcharge, discount, and reduction; a real number for the rate. This is ensured by the server’s [JSON_PRESERVE_ZERO_FRACTION] option.

This test checks the client, but also, through it, the server: the Apache server and the MySQL DBMS must be running. The test results are as follows:

> php vendor/bin/phpunit scripts-console/tax/version-08/Tests --testdox
PHPUnit 12.5.37 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.5.11
Configuration: .../php8-oct-2026/phpunit.xml

...........                                                       11 / 11 (100%)

Time: 00:00.070, Memory: 16.00 MB

Client Business (Application\Tests\ClientBusiness)
 ✔ Calculate tax with data set "couple, 2 children, 55555"
 ✔ Calculate tax with data set "couple, 2 children, 50000"
 ✔ Calculate tax with data set "couple, 3 children, 50000"
 ✔ Calculate tax with data set "single, 2 children, 100000"
 ✔ Calculate tax with data set "single, 3 children, 100000"
 ✔ Calculate tax with data set "couple, 3 children, 100000"
 ✔ Calculate tax with data set "couple, 5 children, 100000"
 ✔ Calculate tax with data set "single, 0 children, 100000"
 ✔ Calculate tax with data set "couple, 2 children, 30000"
 ✔ Calculate tax with data set "single, 0 children, 200000"
 ✔ Calculate tax with data set "couple, 3 children, 200000"

OK (11 tests, 22 assertions)

We now have a client/server application. In the next version, we will improve the server: it will use a session, log its operations, and notify the administrator via email in case of a problem.