11. Application Exercise – Version 4
The tax calculation application will now implement the following layered structure:

We’ll reuse the components from version 3, modifying them to adapt to the application’s new architecture. This is called “refactoring” (reworking the code). Here, we’ll assume that the data needed by the application is stored in text files. The [dao] layer will handle interactions with these files.
11.1. Script Directory Structure
Version 4 is located in the [scripts-console/tax/version-04] folder:
The [Tests] folder contains the automated tests for version 4, which we will present at the end of this chapter.
11.2. Objects Exchanged Between Layers
We are retaining certain elements from Version 3 exactly as they were:
- The [TaxException] exception is the exception that the [dao] layer will throw when it encounters a problem either with data access or with the nature of the data (incorrect data);
- the [Utilities] class contains methods useful for managing text files (in this case, a single method, [cutNewLineChar]);
- The [readonly] class [TaxAdminData] encapsulates tax administration data. It is accessed via the static method [TaxAdminData::fromJsonFile($taxAdminDataFilename)] (or [TaxAdminData::fromArray($data)]), which validates the data and throws a [TaxException] exception if the data is invalid.
We are adding a new class, [TaxPayerData], which encapsulates a taxpayer’s data and the results of their tax calculation ([TaxPayerData.php]):
Comments
- line 9: Like [TaxAdminData], the [TaxPayerData] class is immutable: its objects pass from one layer to another without risk of being modified along the way;
- Lines 13–15: the taxpayer’s data needed to calculate their tax;
- Lines 17–21: The results of the tax calculation. These are nullable fields ([?int], [?float]) with the default value [null]: A [TaxPayerData] object is first created by the [dao] layer containing only the taxpayer’s data from [new TaxPayerData("yes", 2, 55555)], since the tax has not yet been calculated. The attribute names are those of the JSON keys in the Version 3 results file: the [results.txt] file will thus retain the same format;
- Lines 27–36: Since the object is immutable, the calculation results cannot be added to it. The method [withResults] returns a copy of the object supplemented with the results. It uses the [clone] operator from PHP 8.5, which accepts as its second parameter an array of attributes to be modified in the copy (see the chapter on classes). The PHP 7 course used a modifiable object with eight getters and eight setters;
- lines 39–42: The [__toString] method returns the JSON string from the object. It will be used to write the results file.
11.3. The [dao] layer
Here we are focusing on the [1] layer of our application:

11.3.1. The [InterfaceDao] interface
The interface for the [dao] layer will be as follows: [InterfaceDao.php]:
Comments
- The specifications are as follows:
- Taxpayer data is stored in a text file;
- the results of the tax calculation are saved in a text file;
- Any errors are saved in a text file;
- it is unknown in what format the tax authority’s data is available. For each new format, the [InterfaceDao] interface must be implemented by a new class;
- Methods of the interface that encounter a fatal error while accessing data must throw a [TaxException] exception;
- Line 15: the method that retrieves taxpayer data, [marital status, number of children, annual salary]:
- the first parameter is the name of the text file containing this data;
- the second parameter is the name of the text file in which any errors encountered are to be logged;
- Line 14: The type [array] does not specify what the array contains. The documentation comment for [/** @return TaxPayerData[] */] (syntax PHPDoc) specifies that it is an array of [TaxPayerData] objects. PHP ignores this, but the [PHP Intelephense] extension of [VS Code] uses it: it then knows the type of the array elements and can suggest their attributes during data entry;
- line 18: the method that retrieves data from the tax authority. No parameters are passed to it because we do not know how the data is stored;
- line 22: the method that saves the tax calculation results to a text file, whose name is passed as a parameter.
When writing the [InterfaceDao] interface, we know there will be different ways to implement the [getTaxAdminData] method depending on how the tax authority’s data is stored. The [InterfaceDao] interface will therefore be implemented by different classes, each handling a specific storage method for this data (JSON file, database, web service). These classes will, however, share common code: the implementation of the [getTaxPayersData] and [saveResults] methods. As we saw in the chapter on traits, this use case can be implemented in two ways:
- We create an abstract class C that contains the code common to the derived classes. Class C implements interface I, but certain methods—which must be defined in the derived classes—are declared abstract in class C, which is therefore itself abstract. We then create classes C1 and C2 derived from C, each of which implements the abstract methods of their parent class C in its own way;
- we create a trait T that is nearly identical to the abstract class C from the previous solution. This trait does not implement the interface I because, syntactically, it cannot. We then create the classes C1 and C2, which implement the interface I and use the trait T. All that remains for these classes is to implement the methods of the interface I that are not implemented by the trait T that they import.
For this example, we will use a trait [TraitDao]. Version 3 used an abstract class.
11.3.2. The trait [TraitDao]
The code for the trait [TraitDao] is as follows: [TraitDao.php]:
Comments
- Line 9: Here we are defining a trait, not a class;
- Line 12: a field separator constant (PHP 8.2), typed (PHP 8.3): the field separator for a line in the taxpayer file. It becomes a constant of the class that uses the trait and is used with the notation [self::SEPARATOR];
- Line 15: Starting with PHP 8.3, the attribute [#[\Override]] is accepted on a trait method. This is verified in the class that uses the trait: here, the method must correspond to a method of the [InterfaceDao] interface implemented by this class;
- Lines 16–74: The method [getTaxPayersData] implements the method of the same name from the [InterfaceDao] interface. It retrieves the data for taxpayers [marital status, number of children, annual salary] from the text file named [$taxPayersFilename]. It returns this data in the form of a [$taxPayersData] array containing elements of type [TaxPayerData] (line 58);
- The method [getTaxPayersData] is very similar to the method [AbstractBaseTax::executeBatchTaxes] in version 3, with the following differences:
- The [getTaxPayersData] method only retrieves taxpayer data. It does not perform any tax calculations: that is the role of the [business] layer;
- Like the [executeBatchTaxes] method, it flags erroneous rows. Here, the errors are first stored in an array named [$errors], which is written to a text file at the end of processing (line 71). Depending on the situation, this array may or may not be empty. An erroneous line no longer triggers an exception: it is up to the calling code to consult the error file;
- in the case of a fatal error, a [TaxException] exception is thrown (line 25, line 64);
- Lines 23–26: The file is opened before the [try / finally] structure. If opening the file fails, an exception is thrown and there is nothing to close. If it succeeds, the [finally] clause (lines 66–69) ensures that the file is closed, regardless of whether an exception occurs. This approach avoids the “Was the file opened?” check found in version 3;
- Lines 62–65: Note the processing performed upon exiting the read loop. The function [fgets] has the drawback of returning [false] both when the read operation reaches the end of the file and when it fails due to an error. To distinguish between the two cases, we check whether we have reached the end of the file using the function [feof]. If this is not the case, an error has occurred, and an exception is then thrown;
- lines 78–82: implementation of the [saveResults] method of the [InterfaceDao] interface. Each [TaxPayerData] element in the array is converted to a string by the function [strval], which calls the [__toString] method of the object: this yields its JSON string. [strval(...)] refers to the [strval] function itself (first-class callable, PHP 8.1);
- lines 85–92: the private method [saveLines] writes an array of lines to a text file, with each line terminated by the line-end character \n;
- line 89: the PHP function [file_put_contents] writes a string to a text file. It opens the file, writes the string to it, and closes the file. It returns [false] if an error occurs. In this case, PHP also issues a warning. The [@] operator suppresses this warning: the error is reported via the exception thrown on the next line, with a clearer message.
The trait [TraitDao] has implemented two of the methods from the [InterfaceDao] interface: [getTaxPayersData] and [saveResults]. We still need to implement the [getTaxAdminData] method, which retrieves data from the tax authority.
11.3.3. The [DaoTaxWithTaxAdminDataInJsonFile] class
The [DaoTaxWithTaxAdminDataInJsonFile] class implements the [InterfaceDao] interface as follows:
Comments
- Line 9: The class [DaoTaxWithTaxAdminDataInJsonFile] implements the interface [InterfaceDao];
- Line 12: The class uses the [TraitDao] trait, which, as we know, implements the [getTaxPayersData] and [saveResults] methods of the [InterfaceDao] interface. All that remains for the class to do is to implement the [getTaxAdminData] method, which retrieves data from the tax authority;
- line 15: the attribute of type [TaxAdminData] returned by the [getTaxAdminData] method. It is initialized by the constructor;
- Line 21: Data from the tax authority is read from the JSON file. If the file is missing or invalid, a [TaxException] exception is thrown to the calling code.
We are now done with the [dao] layer of our application: we have a class that fully implements the [InterfaceDao] interface we defined for ourselves. We can now move on to the [business] layer.
11.4. The [business] layer
We will now implement the [2] layer of our architecture:

11.4.1. The [InterfaceBusiness] interface
The interface for the [business] layer will be as follows: [InterfaceBusiness.php]:
Comments
- Line 13: The [business] layer can calculate a specific taxpayer’s tax amount provided it is given the following information: marital status, number of children, and annual salary. The [calculateTax] method is purely a “business” method: it does not concern itself with the source of the data it uses. It does not use the [dao] layer, so it does not throw an exception;
- Line 17: The [business] layer can also calculate the tax amount for a group of taxpayers whose data is collected in the text file named [$taxPayersFileName]. It writes the results to the text file named [$resultsFileName]. Non-fatal errors are logged in the text file named [$errorsFileName]. The method [executeBatchTaxes] must call the layer [dao], which handles file system access. Exceptions may then be propagated from the [dao] layer: the [executeBatchTaxes] method will not catch them; it will allow them to be propagated to the main script.
11.4.2. The [Business] class
The [Business] class implements the [InterfaceBusiness] interface as follows:
Comments
- line 9: The [Business] class implements the [InterfaceBusiness] interface, that is, the [calculateTax] and [executeBatchTaxes] methods;
- line 12: the object of type [TaxAdminData], which encapsulates the tax administration data. This data is required by the business method [calculateTax];
- Lines 15–21: The constructor initializes the class’s two attributes:
- the attribute [$dao], a reference to the layer [dao], is initialized by promoting the constructor parameter. This is required so that the layer [business] knows whom to contact when it needs external data. Note that the type of this parameter is that of the [InterfaceDao] interface, thus allowing the [Business] class to work with any class that implements this interface. The PHP 7 course also offered a [setDao] setter: this is no longer present, as the [dao] layer is injected once and for all by the constructor;
- The [$taxAdminData] attribute is initialized by calling the [getTaxAdminData] method of the [dao] layer;
- we conclude that when the methods [calculateTax] and [executeBatchTaxes] are executed, both attributes [$dao] and [$taxAdminData] are initialized. The [$this->taxAdminData ??= $this->getTaxAdminData()] test in version 3 is no longer necessary;
- lines 26–77: this code is from the [AbstractBaseTax::calculateTax] method in version 3. The same applies to the private methods [calculateTax2], [getTaxableIncome], [getDiscount], and [getReduction], which are not reproduced here.
The method [Business::executeBatchTaxes] is as follows:
Comments
- Line 188: The method must repeatedly call the [calculateTax] method for each taxpayer found in the text file named [$taxPayersFileName] and write the results to the text file named [$resultsFileName]. Any non-fatal errors encountered are logged in the text file named [$errorsFileName]. The method does not throw any exceptions itself but allows those thrown by the [dao] layer to propagate;
- Line 192: Taxpayer data is requested from the [dao] layer. This layer returns an array of elements of type [TaxPayerData]. If an exception occurs here, since it is not caught by a [catch], it is automatically propagated to the calling code, and the rest of the method is not executed;
- lines 194–199: The function [array_map] applies a function to each element of the array [$taxPayersData] and returns the array of results. The applied function is an arrow function that calculates the tax for taxpayer [$taxPayerData] and returns a copy of [$taxPayerData] populated with the results of the calculation (line 195). Note that the arrow function uses [$this]: an arrow function automatically captures variables from its context, including [$this];
- Line 201: Once the tax has been calculated for all taxpayers, the results are saved to a text file. The [dao] layer performs this task.
In general, the [business] layer is fairly simple to write because it communicates with the [dao] layer, which handles data access and the associated error handling.
11.5. The Main Script
Now let’s write the script for the [3] layer of our architecture:

The main script is as follows: [main.php]:
Comments
- Lines 10–18: Load the application’s classes, interfaces, and traits. A trait must be loaded before the class that uses it;
- line 21: the name of the taxpayer data file;
- line 22: the name of the results file;
- line 23: the name of the error file;
- line 24: the name of the JSON file containing data from the tax authority;
- line 28: creation of the [dao] layer;
- line 30: creation of the [business] layer based on the [dao] layer;
- line 32: execution of method [executeBatchTaxes] of layer [business];
- lines 34–37: As we saw, the layers [dao] and [business] can throw exceptions. These are intercepted here.
11.6. Visual Tests
11.6.1. Test #1
Run the script [main.php] in a terminal window opened in the [version-04] folder:
With the taxpayer file [taxpayersdata.txt] from version 3:
the following error file [errors.txt] is generated:
and the following results file [results.txt]:
These are the results for version 3.
11.6.2. Test #2
In the main script, we assign a filename that does not exist to the taxpayer file:
The results displayed in the console are as follows:
Only the exception message thrown by the [dao] layer is displayed. In the PHP 7 course, the function [fopen] was called on the nonexistent file: the PHP interpreter then displayed a warning before the exception message:
The PHP 7 course suppressed this message by instructing PHP not to display errors using the [ini_set("display_errors", "0")] statement. This is the correct configuration for a production server (errors are then written to a log file), but during development, it is necessary to see all messages from PHP. We therefore prefer not to trigger the warning: the [dao] layer uses the [is_readable] function to verify that the file can be read before opening it.
11.7. [PHPUnit] Tests
Visual tests are highly insufficient:
- we generally limit ourselves to just a few tests;
- we may not pay close attention during this visual check, and details may slip past us.
In real-world professional development, tests are written as code and run automatically, often after every code change. The goal is to make the tests as comprehensive as possible. To achieve this, we use testing frameworks.
Here, we will use [PHPUnit] [https://phpunit.de/], the standard testing framework in PHP. The PHP 7 course used the [Codeception] framework, which was chosen because it integrated with the NetBeans IDE—and which, incidentally, used PHPUnit behind the scenes. We will use only a small portion of the capabilities of PHPUnit. The idea is to have a quick way, after each new version of the application exercise, to verify that it works. Successful tests give the developer confidence in the code they have written. This is an important factor.
11.7.1. Installing [PHPUnit]
Like many PHP libraries, [PHPUnit] is installed with [Composer], the PHP dependency manager, which is included with [Laragon]. The [php8-oct-2026] folder containing the course code includes a file named [composer.json] that lists the libraries used by the course. PHPUnit appears there in the [require-dev] section, which contains libraries used solely for development:
If you haven’t already done so, simply open a terminal in the [php8-oct-2026] folder and type the following command:
[Composer] will then download all the libraries listed in [composer.json] into the [vendor] folder within [php8-oct-2026]. PHPUnit is installed there along with its executable, [vendor/bin/phpunit]. In a project that does not yet have PHPUnit, it would be added using the [composer require --dev phpunit/phpunit] command, which downloads it and registers it in the [require-dev] section of the [composer.json] file.
Version 12 of PHPUnit requires PHP 8.3 or later. Its documentation is available at [https://docs.phpunit.de/].
11.7.2. The configuration file [phpunit.xml]
The [php8-oct-2026] folder also contains the configuration file for PHPUnit, [phpunit.xml]. When PHPUnit is launched from this folder, it automatically uses this file:
Comments
- Line 5: The XML schema, which describes the file's syntax. [VS Code] uses it to validate the file and suggest possible attributes;
- Line 6: No colors are used in the results display. You can use [true] to display successful tests in green and failed tests in red in the terminal;
- Lines 7–10: A test that triggers a deprecation, a note, or a warning from PHP is considered a failure, even if its assertions are verified. This is a way to enforce clean PHP 8 code: for example, using a deprecated feature in PHP 8.5 would cause the tests to fail;
- lines 11–13: in these cases, PHPUnit displays the details of the message from PHP;
- line 14: the folder where PHPUnit stores information from one run to the next (for example, tests that failed, so they can be run first the next time);
- lines 15–20: the folders containing the tested source code. [ignoreSuppressionOfDeprecations] indicates that a deprecation is reported even if it was suppressed by the operator [@].
11.7.3. Tests for the [dao] layer
The tests for version 4 are in the [version-04/Tests] folder. A PHPUnit test class:
- extends the class [PHPUnit\Framework\TestCase];
- has a name ending in [Test], in a file of the same name: this is how PHPUnit finds the test classes in a folder;
- has public test methods whose names begin with [test];
- may have a method named [setUp], executed before each test method, and a method named [tearDown], executed after each test method. These are used to set up the test environment and then clean it up. Each test method thus runs in a fresh environment, independent of the others.
In a test method, results are verified using assertion methods inherited from [TestCase], for example:
Assertion | Verifies that |
[assertSame($expected, $actual)] | the two values are identical ([===]: same type and same value) |
[assertEquals($expected, $actual)] | the two values are equal ([==]) |
[assertEqualsWithDelta($expected, $actual, $delta)] | Both numbers are equal to within [$delta] |
[assertTrue($actual)], [assertFalse($actual)], [assertNull($actual)] | the value is [true], [false], [null] |
[assertCount($n, $array)] | The array has [$n] elements |
[assertStringContainsString($substring, $string)] | the string contains the substring |
[expectException($class)] | The following code throws an exception of the specified class |
To build the tests for a version of the application exercise, we use an environment identical to that of the main script [main.php] for that version: the same files are loaded, and the same layers are constructed in the same way.
The test class [DaoTest] is as follows: [Tests/DaoTest.php]:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 | |
Comments
- line 7: The test classes are in the [Application\Tests] namespace, which is distinct from that of the application;
- Lines 10–16: The classes used are imported with the statement [use]. The application classes are in the [Application] namespace, and those from PHPUnit are in [PHPUnit\Framework];
- Lines 19–25: Load the files from the [dao] layer, as in [main.php]. The paths are relative to the test file folder ([__DIR__]): the tests run regardless of the folder from which PHPUnit is launched and regardless of the location of the [php8-oct-2026] folder on the disk. The PHP 7 course used absolute paths ([C:/Data/...]) specific to the author’s machine. The classes in PHPUnit, on the other hand, are loaded automatically by the loader in [Composer], which is initialized by PHPUnit;
- line 27: the test class extends [TestCase];
- line 30: the version 4 folder, where the data files are located;
- Lines 38–44: Before each test, the [dao] layer is built as done by [main.php], and a temporary file is created for errors. The function [tempnam] creates a uniquely named file in the system’s temporary files folder, as returned by [sys_get_temp_dir]. The PHP 7 course performed this task in the test class constructor: the constructor for [TestCase] no longer needs to be redefined as of PHPUnit 10;
- lines 48–52: After each test, the temporary file is deleted;
- lines 55–73: testing the tax administration data. We verify that the contents of the [TaxAdminData] object returned by the [dao] layer match what is expected. The [assertSame] method is used: [assertSame(1551.0, ...)] verifies that the value is the real number 1551.0; [assertSame(1551, ...)] would fail because the integer 1551 is not identical to the real number 1551.0. For arrays, [assertSame] verifies that the two arrays have the same keys, in the same order, with identical values;
- lines 76–85: a data provider. This is a static public method that returns an array of datasets. Each dataset is an array of arguments to be passed to a test method. The key of each dataset (“non-existent file”...) gives it a name that will appear in the test results;
- line 87: the PHP 8 attribute [#[DataProvider]] indicates that the test method [testInvalidTaxAdminData] must be executed once for each dataset from the provider [invalidFiles]. Versions of PHPUnit prior to version 10 used an annotation in a comment instead: annotations are no longer recognized as of PHPUnit 12;
- Lines 88–95: Verify that a layer built with an incorrect tax data file throws an exception with the correct error code. The methods [expectException] and [expectExceptionCode] must be called before the code that throws the exception: the test passes if the exception is thrown; otherwise, it fails. The incorrect files are in the [Tests/data] folder: a malformed JSON file (missing comma), a file missing the [coupleDiscountCeiling] key, and a file with a negative value;
- Lines 98–112: the test for reading taxpayer data. The [taxpayersdata.txt] file from version 4 contains 11 correct lines and 2 erroneous lines. We verify the number of taxpayers retrieved, their type ([assertContainsOnlyInstancesOf]), the contents of the first and last records, and then the contents of the error file. To compare two [TaxPayerData] objects, we use [assertEquals]: two distinct objects are never identical ([===]), but they are equal ([==]) if their attributes are equal;
- Lines 115–120: A nonexistent taxpayer file must trigger a code 12 exception.
11.7.4. Running the tests
The tests are run in a terminal opened in the [php8-oct-2026] folder, by passing the test folder to PHPUnit:
- line 5: PHPUnit found and used the configuration file [phpunit.xml];
- line 7: one point per successful test. The 18 tests are those from the two test classes [DaoTest] and [BusinessTest] (the latter is described in the following paragraph): a test run with a data provider counts as many tests as there are datasets;
- line 11: the results: all tests passed.
The [--testdox] option provides a more descriptive display, where each test is described by a sentence composed of the name of its method and the name of its dataset:
Now let’s look at a test that fails. In the [BusinessTest] class, we introduce an error in the expected reduction for the second taxpayer (346 instead of 347). Running the tests then yields the following result:
- line 7: the letter F (Failure) indicates the test that failed;
- line 13: the test that failed, along with the name and contents of its dataset;
- lines 14–24: the assertion that failed ([assertSame]) and the difference between the expected (-) and actual (+) arrays;
- line 26: the file and line number of the assertion that failed;
- lines 28–29: the summary.
When a test does not even run (for example, because a file loaded by [require_once] cannot be found), PHPUnit displays the corresponding PHP error, just as running a standard PHP script would.
11.7.5. [business] Layer Tests
The test class [BusinessTest] follows the same construction rules as the class [DaoTest]: [Tests/BusinessTest.php]:
Comments
- lines 18–26: loading the files for layers [dao] and [business]. The test environment is the same as that of [main.php];
- lines 37–43: instantiation of layers [dao] and [business], as in [main.php];
- lines 46–73: the data provider: the eleven taxpayers from the table of official figures. Each dataset contains the taxpayer’s data, the tax amount generated by the tax authority’s official simulator [https://www3.impots.gouv.fr/simulateur/calcul_impot/2019/simplifie/index.htm], and the expected results of the algorithm described in the document;
- line 76: the method [testCalculateTax] will be executed eleven times, once per taxpayer. The PHP 7 course originally included eleven nearly identical test methods, from [test1] to [test11]; the data provider eliminates this duplication;
- line 80: the tax calculation is performed by the [business] layer;
- line 82: We verify that the calculated tax matches the official simulator’s result to within 1 euro. We have seen that rounding issues caused the algorithm in the document to yield results that differed from the official ones by up to 1 euro;
- line 84: We verify that the results table [tax, surcharge, discount, reduction, rate] is exactly as expected, including the data types: integers for the amounts, a real number for the rate. This is why the rate for the 9th taxpayer is written as 0.0 and not 0. This precise verification protects against any unintended changes to the algorithm in future versions.
The results of running these tests are those shown in the previous paragraph: all 11 tests in the [business] layer pass.
11.7.6. PHPUnit in VS Code
The easiest way to run the tests in [VS Code] is to use its built-in terminal (Terminal menu / New Terminal): it opens in the project’s [php8-oct-2026] folder, and you type the command [php vendor/bin/phpunit scripts-console/tax/version-04/Tests] there. The up arrow on the keyboard recalls the last command: rerunning the tests after making a code change takes just one keystroke.
Some useful options for the [phpunit] command:
- [--testdox]: displays tests as sentences;
- [--filter testCalculateTax]: runs only those tests whose names contain the specified text;
- [--stop-on-failure]: stops at the first failure.
There are also extensions for [VS Code] that integrate PHPUnit into the editor’s test explorer (for example, the “PHPUnit Test Explorer” extension): these allow you to run a test with a single click and display the results next to the code. They actually execute the same [phpunit] command that we type into the terminal.
11.7.7. Tests for Upcoming Versions
Going forward, the tests for the [dao] and [business] layers will be identical to those in version 4. The only thing that will change is the test environment: the files loaded and the way the [dao] and [business] layers are built. We will therefore present only this one and the test results.