Skip to content

8. Practical Exercise – Version 3

We’ll revisit the exercise we covered earlier (versions 1 and 2) and solve it using PHP code that employs classes. We’ll put into practice what we’ve learned in the previous three chapters:

  • an (immutable) [readonly] class to represent tax administration data;
  • an interface, an abstract class that partially implements it, and a derived class that completes it;
  • a single exception type, [TaxException], to report all application errors;
  • the [#[\Override]] attribute on all methods that implement an interface method or override a parent method.

8.1. The script directory structure

Version 3 is located in the [scripts-console/tax/version-03] folder:

version-03
├── AbstractBaseTax.php
├── errors.txt
├── InterfaceTax.php
├── main.php
├── results.txt
├── taxadmindata.json
├── TaxAdminData.php
├── TaxException.php
├── taxpayersdata.txt
├── TaxWithTaxAdminDataInJsonFile.php
├── test-taxadmindata.php
└── Utilities.php

Each class or interface is in a file that bears its name. This is a convention universally followed in PHP (it is even mandated by the automatic loading standard PSR-4, which we will encounter with [Composer]).

8.2. The [TaxException] Exception

In version 3, when a class constructor or method encounters an error, it will throw a [TaxException] exception:

<?php

declare(strict_types=1);

// namespace
namespace Application;

// the exception thrown by the classes of the tax calculation application
class TaxException extends \RuntimeException
{
    // no code: the constructor inherited from [\RuntimeException] is enough
    // __construct(string $message = "", int $code = 0, ?\Throwable $previous = null)
}

Comments

  • Line 6: The class [TaxException] is in the [Application] namespace. All classes and interfaces in the tax calculation application will be in this namespace;
  • line 9: The class [TaxException] extends the predefined PHP class [RuntimeException]. As we saw in the chapter on exceptions, an exception is primarily defined by its type: it is the type that allows it to be caught with a [catch (TaxException $ex)] clause;
  • Lines 11–12: The PHP 7 course defined a constructor [__construct(string $message, int $code = 0)] that simply called the parent class’s constructor. It is unnecessary: the class inherits the constructor from [RuntimeException], which accepts a message, an error code, and any previous exception. We’ll use this last parameter to encapsulate a low-level exception (a [JsonException], for example) within a [TaxException].

8.3. The [TaxAdminData] class

In version 2, the tax administration data was consolidated:

  • first in a JSON file;
  • then from this JSON file into an associative array.

We had noted a flaw in this solution: there is no guarantee that the associative array contains all the expected keys or that the associated values are numbers. In version 3, the tax authority data is still in the file [taxadmindata.json] but with different attribute names, written according to the camelCase convention for class attribute names:

{
    "limits": [9964, 27519, 73779, 156244, 0],
    "coeffR": [0, 0.14, 0.3, 0.41, 0.45],
    "coeffN": [0, 1394.96, 5798, 13913.69, 20163.45],
    "halfShareQfCeiling": 1551,
    "singleIncomeCeilingForReduction": 21037,
    "coupleIncomeCeilingForReduction": 42074,
    "halfShareReductionValue": 3797,
    "singleDiscountCeiling": 1196,
    "coupleDiscountCeiling": 1970,
    "coupleTaxCeilingForDiscount": 2627,
    "singleTaxCeilingForDiscount": 1595,
    "tenPercentAllowanceMax": 12502,
    "tenPercentAllowanceMin": 437
}

In version 2, this file was used to initialize an associative array. In version 3, it will initialize an object of the following class: [TaxAdminData]:

<?php

declare(strict_types=1);

// namespace
namespace Application;

// the tax administration data: an immutable object
final readonly class TaxAdminData
{
    // the names of the arrays and constants expected in the data
    private const array ARRAYS = ["limits", "coeffR", "coeffN"];
    private const array CONSTANTS = [
        "halfShareQfCeiling",
        "singleIncomeCeilingForReduction",
        "coupleIncomeCeilingForReduction",
        "halfShareReductionValue",
        "singleDiscountCeiling",
        "coupleDiscountCeiling",
        "coupleTaxCeilingForDiscount",
        "singleTaxCeilingForDiscount",
        "tenPercentAllowanceMax",
        "tenPercentAllowanceMin",
    ];

    // private constructor: we must go through the [from...] methods
    private function __construct(
        // tax brackets
        public array $limits,
        public array $coeffR,
        public array $coeffN,
        // tax calculation constants
        public float $halfShareQfCeiling,
        public float $singleIncomeCeilingForReduction,
        public float $coupleIncomeCeilingForReduction,
        public float $halfShareReductionValue,
        public float $singleDiscountCeiling,
        public float $coupleDiscountCeiling,
        public float $coupleTaxCeilingForDiscount,
        public float $singleTaxCeilingForDiscount,
        public float $tenPercentAllowanceMax,
        public float $tenPercentAllowanceMin,
    ) {
    }

    // construction from a JSON file
    public static function fromJsonFile(string $taxAdminDataFilename): self
    {
        // we retrieve the content of the tax data file
        $fileContents = is_readable($taxAdminDataFilename) ? file_get_contents($taxAdminDataFilename) : false;
        // error?
        if ($fileContents === false) {
            throw new TaxException("The data file [$taxAdminDataFilename] does not exist", 1);
        }
        // we decode the JSON into an associative array
        try {
            $data = json_decode($fileContents, true, flags: JSON_THROW_ON_ERROR);
        } catch (\JsonException $ex) {
            throw new TaxException("The JSON data file [$taxAdminDataFilename] could not be processed correctly", 2, $ex);
        }
        // the JSON must be an object {...}
        if (!is_array($data)) {
            throw new TaxException("The JSON data file [$taxAdminDataFilename] does not contain a JSON object", 3);
        }
        // building the object from the associative array
        return self::fromArray($data);
    }

    // construction from an associative array [name => value]
    public static function fromArray(array $data): self
    {
        // the keys present must be exactly the expected ones
        $expected = [...self::ARRAYS, ...self::CONSTANTS];
        $missing = array_first(array_diff($expected, array_keys($data)));
        if ($missing !== null) {
            throw new TaxException("The attribute [$missing] of [TaxAdminData] has not been initialized", 4);
        }
        $unknown = array_first(array_diff(array_keys($data), $expected));
        if ($unknown !== null) {
            throw new TaxException("The attribute [$unknown] does not exist in [TaxAdminData]", 5);
        }
        // we check and convert each value
        $values = [];
        foreach (self::ARRAYS as $key) {
            // a non-empty array of reals >= 0
            if (!is_array($data[$key]) || !array_is_list($data[$key]) || count($data[$key]) === 0) {
                throw new TaxException("The value of attribute [$key] is invalid", 6);
            }
            $values[$key] = array_map(fn(mixed $value): float => self::check($key, $value), $data[$key]);
        }
        // the three arrays must have the same size
        $size = count($values["limits"]);
        if (count($values["coeffR"]) !== $size || count($values["coeffN"]) !== $size) {
            throw new TaxException("The arrays [limits, coeffR, coeffN] do not have the same size", 7);
        }
        foreach (self::CONSTANTS as $key) {
            // a real >= 0
            $values[$key] = self::check($key, $data[$key]);
        }
        // building the object: the keys of the array become named arguments
        return new self(...$values);
    }

    // checks that a value is a positive or zero real number and returns it as a [float]
    private static function check(string $key, mixed $value): float
    {
        // we accept 1551, 1551.78, "1551" but not "xx", true, null, -3...
        if ((is_int($value) || is_float($value) || (is_string($value) && is_numeric($value)))
            && (float) $value >= 0) {
            return (float) $value;
        }
        throw new TaxException("The value of attribute [$key] is invalid", 6);
    }

    // JSON string of the object
    public function __toString(): string
    {
        return json_encode(get_object_vars($this), JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
    }
}

Comments

  • Line 9: The class is [readonly]: All of its attributes are read-only. Once created, a [TaxAdminData] object can no longer be modified: it can be safely shared among all parts of the application. It is also [final]: it cannot be derived;
  • lines 12–24: two private class constants list the names of the attributes expected in the data. Starting with PHP 8.3, a class constant can be typed (here, [array]);
  • lines 27–43: The constructor uses parameter promotion: each parameter becomes a public attribute with the same name and type. The attributes have the same names as the keys in the JSON file. This is an important point that greatly simplifies code writing, as we will see. The tax brackets are arrays ([array]), and the calculation constants are real numbers ([float]). In PHP 7, attributes could not be typed (as noted in the PHP 7 course); they can be typed starting with PHP 7.4;
  • Line 27: The constructor is private; [new TaxAdminData(...)] cannot be written outside the class. A [TaxAdminData] object can only be obtained through the static methods [fromJsonFile] and [fromArray], which verify the validity of the data. This ensures that every [TaxAdminData] object contains valid data. In PHP, a class can have only one constructor. Static methods that construct an object—sometimes called “named constructors”—allow for multiple ways to construct an object. The PHP 7 course used instance methods [setFromJsonFile] called on an empty object: [(new TaxAdminData())->setFromJsonFile(...)]. This is impossible with an immutable object;
  • Lines 47–67: The [fromJsonFile] method constructs a [TaxAdminData] object from the JSON file, whose name is passed to it. Its return type, [self], refers to the class itself;
  • lines 50–53: The file’s contents are read. If this is not possible, a [TaxException] exception is thrown. This exception will propagate up to the main script;
  • lines 56–60: The file’s content, JSON, is decoded. With the option [JSON_THROW_ON_ERROR] (PHP 7.3), the [json_decode] function throws a [JsonException] exception when the JSON is invalid, instead of silently returning [null]. Note the named argument [flags:], which avoids having to assign a value to the third parameter [depth] of [json_decode]. The exception [JsonException] is encapsulated within a [TaxException]: the calling code only has to handle one exception type but can determine the original cause using [$ex->getPrevious()];
  • lines 62–64: a file containing, for example, only the number 12 is a valid JSON; you must verify that an array has indeed been obtained;
  • line 66: the rest of the work is delegated to the [fromArray] method. This method is public: it may be used in a future version where tax administration data comes from a database;
  • Lines 73–81: We verify that the keys in the array [$data] are exactly the expected names. [[...self::ARRAYS, ...self::CONSTANTS]] creates an array containing the elements from both arrays. The function [array_diff] returns the elements of the first array that are absent from the second, and the function [array_first] (PHP 8.5) returns the first element of an array or [null] if the array is empty. This detects a missing key followed by an unknown key. This is the most common error: a typo in an attribute name in the [taxadmindata.json] file. In the PHP 7 course, the loop [$this->$key = $value] silently created a dynamic attribute with the incorrect name. Since PHP 8.2, the creation of dynamic attributes has been deprecated, and it is prohibited within a [readonly] class;
  • lines 84–90: Each of the three arrays must be a non-empty list (function [array_is_list], PHP 8.1) whose elements are all positive or zero real numbers. The function [array_map] applies the method [check] to each element;
  • lines 92–95: The three tax bracket arrays must be the same size;
  • lines 96–99: Each of the calculation constants must be a positive real number or zero;
  • Line 101: The array [$values] uses the names of the constructor’s parameters as keys. The operator [...], when applied to an array with string keys, converts each element into a named argument (PHP 8.1): this line is equivalent to [new self(limits: $values["limits"], coeffR: $values["coeffR"], ...)]. This is where choosing identical names for the JSON keys and the class attributes simplifies the code;
  • Lines 105–113: The method [check] checks whether a value [$value] of unknown type ([mixed]) is a positive number or zero. According to the information in the JSON file, the value read can be an integer (1551), a floating-point number (1551.78), a string convertible to a number ("1551"), a non-convertible string ("xx"), a Boolean... The function [is_numeric] returns [true] for a string representing a number. The PHP 7 course performed this check using a regular expression and returned a [stdClass] object indicating whether an error had occurred. Here, an error triggers an exception, and the method simply returns the number in the form of a [float];
  • Line 116: The [__toString] method returns the JSON string containing the object’s attributes. The [get_object_vars] function returns a [attribute name => value] associative array.

8.3.1. Why are there no longer any getters and setters?

The PHP 7 course declared the attributes as private and wrote a getter and a setter for each one (26 methods), automatically generated by the NetBeans IDE. The setters were “fluent”: they returned the [$this] object, which allowed for chained calls to [$data->setLimits($limits)->setCoeffR($coeffR)].

With PHP 8, this code is no longer necessary:

  • the getters were used to read private attributes. The attributes [readonly] can safely be made public since they cannot be modified: we write [$taxAdminData->halfShareQfCeiling] instead of [$taxAdminData->getHalfShareQfCeiling()];
  • the setters were used to initialize the object and allowed for value validation. An immutable object is initialized once and for all by its constructor, and validation is performed by the methods [fromJsonFile] and [fromArray];
  • Typed attributes guarantee the type of the values. The PHP 7 course noted that the [$this->halfShareQfCeiling = 'abcd'] statement was possible: with attributes [float] and [declare(strict_types=1)], it causes an error [TypeError].

The script [test-taxadmindata.php] verifies the functionality of the class [TaxAdminData]:

<?php

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

// namespace
namespace Application;

// inclusion of the classes
require_once __DIR__ . "/TaxException.php";
require_once __DIR__ . "/TaxAdminData.php";

// a valid object
$taxAdminData = TaxAdminData::fromJsonFile("taxadmindata.json");
print "$taxAdminData\n";
print "halfShareQfCeiling=" . $taxAdminData->halfShareQfCeiling . "\n";
// the object is immutable
try {
    $taxAdminData->halfShareQfCeiling = 2000;
} catch (\Error $ex) {
    print $ex::class . ": " . $ex->getMessage() . "\n";
}
// the constructor is private
try {
    $taxAdminData = new TaxAdminData([], [], []);
} catch (\Error $ex) {
    print $ex::class . ": " . $ex->getMessage() . "\n";
}

// incorrect data
$valid = json_decode(file_get_contents("taxadmindata.json"), true, flags: JSON_THROW_ON_ERROR);
$tests = [
    // non-existent file
    fn() => TaxAdminData::fromJsonFile("xx.json"),
    // file that is not JSON
    fn() => TaxAdminData::fromJsonFile("taxpayersdata.txt"),
    // missing key
    fn() => TaxAdminData::fromArray(array_diff_key($valid, ["halfShareQfCeiling" => 0])),
    // unknown key (typo)
    fn() => TaxAdminData::fromArray(["halfShareQFCeiling" => 1551] + $valid),
    // non-numeric value
    fn() => TaxAdminData::fromArray(["halfShareQfCeiling" => "xx"] + $valid),
    // negative value in an array
    fn() => TaxAdminData::fromArray(["coeffR" => [0, 0.14, -0.3, 0.41, 0.45]] + $valid),
    // arrays of different sizes
    fn() => TaxAdminData::fromArray(["limits" => [9964, 27519, 73779, 0]] + $valid),
];
foreach ($tests as $test) {
    try {
        $test();
    } catch (TaxException $ex) {
        print "TaxException no. {$ex->getCode()}: {$ex->getMessage()}\n";
    }
}
// a value "1551" is accepted and converted to a real
$taxAdminData = TaxAdminData::fromArray(["halfShareQfCeiling" => "1551"] + $valid);
var_dump($taxAdminData->halfShareQfCeiling);

Comments

  • line 14: construction of a valid [TaxAdminData] object from the [taxadmindata.json] file;
  • line 15: the object is inserted into a string: its [__toString] method is called;
  • line 19: attempt to modify a read-only attribute;
  • line 25: attempt to use the private constructor;
  • line 31: the associative array of valid data;
  • lines 32–47: an array of arrow functions, each constructing a [TaxAdminData] object from invalid data. The invalid data is obtained by modifying a copy of the [$valid] array. When applying the [+] operator to two arrays, the elements of the left array take precedence in the event of a common key. The function [array_diff_key] removes from the first array the keys present in the second;
  • lines 48–54: Each function is executed, and the expected exception is displayed;
  • line 56: a numeric string is accepted and converted to a real number.

Results:

> php test-taxadmindata.php
{"limits":[9964,27519,73779,156244,0],"coeffR":[0,0.14,0.3,0.41,0.45],"coeffN":[0,1394.96,5798,13913.69,20163.45],"halfShareQfCeiling":1551,"singleIncomeCeilingForReduction":21037,"coupleIncomeCeilingForReduction":42074,"halfShareReductionValue":3797,"singleDiscountCeiling":1196,"coupleDiscountCeiling":1970,"coupleTaxCeilingForDiscount":2627,"singleTaxCeilingForDiscount":1595,"tenPercentAllowanceMax":12502,"tenPercentAllowanceMin":437}
halfShareQfCeiling=1551
Error: Cannot modify readonly property Application\TaxAdminData::$halfShareQfCeiling
Error: Call to private Application\TaxAdminData::__construct() from global scope
TaxException no. 1: The data file [xx.json] does not exist
TaxException no. 2: The JSON data file [taxpayersdata.txt] could not be processed correctly
TaxException no. 4: The attribute [halfShareQfCeiling] of [TaxAdminData] has not been initialized
TaxException no. 5: The attribute [halfShareQFCeiling] does not exist in [TaxAdminData]
TaxException no. 6: The value of attribute [halfShareQfCeiling] is invalid
TaxException no. 6: The value of attribute [coeffR] is invalid
TaxException no. 7: The arrays [limits, coeffR, coeffN] do not have the same size
float(1551)
  • line 2: the object’s JSON string. Note that real numbers without a decimal part (1551.0) are written without it (1551) by the [json_encode] function;
  • Lines 4–5: The two [Error] errors caused by modifying a [readonly] attribute and by calling a private constructor. These are programming errors, not data errors: they are not converted to [TaxException];
  • Lines 6–12: All incorrect data is detected, each with a different message and error code;
  • line 13: the string "1551" has been converted to a floating-point number.

8.4. The [InterfaceTax] interface

We will now define the following [InterfaceTax] interface: [InterfaceTax.php]:

<?php

declare(strict_types=1);

// namespace
namespace Application;

interface InterfaceTax
{
    // retrieve the data of the tax administration used to calculate the tax
    // may throw the TaxException exception
    public function getTaxAdminData(): TaxAdminData;

    // the interface knows how to calculate a tax
    public function calculateTax(string $married, int $children, int $salary): array;

    // the interface knows how to process data in text files
    // $taxPayersFileName: taxpayers data file in the form marital status, number of children, annual salary
    // $resultsFileName: results file in the form marital status, number of children, annual salary, tax amount...
    // $errorsFileName: file of the errors encountered
    // may throw the TaxException exception
    public function executeBatchTaxes(string $taxPayersFileName, string $resultsFileName, string $errorsFileName): void;
}

Comments

  • line 8: the interface for calculating taxes. It is also in the [Application] namespace;
  • line 12: The method [getTaxAdminData] will retrieve data from the tax authority into an object of type [TaxAdminData], which we just introduced. Since this data may be stored in a file, a database, or even on the network, the method [getTaxAdminData] may fail to retrieve the data. In this case, it will throw an exception of type [TaxException]. This is the standard method in object-oriented programming for signaling an error encountered in a method or a constructor;
  • line 15: The [calculateTax] method will calculate a taxpayer’s tax;
  • line 22: The method [executeBatchTaxes] calculates the tax for multiple taxpayers;
    • [$taxPayersFileName] is the name of the text file containing the taxpayers’ data;
    • [$resultsFileName] is the name of the text file containing the tax amounts for these taxpayers;
    • [$errorsFileName] is the name of the text file containing the errors encountered while processing these files.

The contents of the taxpayer text file [taxpayersdata.txt] are as follows:

yes,2,55555
yes,2,50000
yes,3,50000
no,2,100000
no,3x,100000
no,3,100000
yes,3,100000
yes,5,100000x
yes,5,100000
no,0,100000
yes,2,30000
no,0,200000
yes,3,200000

Note that lines 5 and 8 contain incorrect entries. The other eleven lines correspond to the eleven taxpayers listed in the table of official figures.

8.5. The class [Utilities]

We also define a class named [Utilities] in a file named [Utilities.php]:

<?php

declare(strict_types=1);

// namespace
namespace Application;

// a class of utility functions: it can be neither instantiated nor derived
final class Utilities
{
    // private constructor: the class cannot be instantiated
    private function __construct()
    {
    }

    public static function cutNewLineChar(string $line): string
    {
        // we remove the end-of-line mark of $line if it exists
        // (\r\n on Windows, \n on Unix)
        return rtrim($line, "\r\n");
    }
}

Comments

  • Line 9: The class [Utilities] is also placed in the [Application] namespace. It contains only static methods: there is no reason to instantiate or derive from it. The PHP 7 course declared it abstract to prevent its instantiation, regretting that PHP refused to declare it as both [final] and [abstract]. This is still the case. The current practice is to declare it as [final] (non-derivable) and give it a private constructor (which cannot be instantiated from outside);
  • line 16: the method [cutNewLineChar] removes any line-end characters from the text passed to it as a parameter. It is a static method, meaning it will be called in the form [Utilities::cutNewLineChar($line)]. Its code is the same as that of the function of the same name in version 2.

8.6. The abstract class [AbstractBaseTax]

The [InterfaceTax] interface will be implemented by the following abstract class [AbstractBaseTax] [AbstractBaseTax.php]:

<?php

declare(strict_types=1);

// namespace
namespace Application;

// an abstract class that implements the [InterfaceTax] interface
abstract class AbstractBaseTax implements InterfaceTax
{
    // the data of the tax administration
    private ?TaxAdminData $taxAdminData = null;

    // data needed to calculate the tax: the derived classes know where to find them
    #[\Override]
    abstract public function getTaxAdminData(): TaxAdminData;

    // tax calculation
    // --------------------------------------------------------------------------
    #[\Override]
    public function calculateTax(string $married, int $children, int $salary): array
    {
        // $married: yes, no
        // $children: number of children
        // $salary: annual salary
        // $this->taxAdminData: data of the tax administration
        //
        // we retrieve the data of the tax administration if it is not already done
        $this->taxAdminData ??= $this->getTaxAdminData();
        // calculation of the tax with children
        $result1 = $this->calculateTax2($married, $children, $salary);
        $tax1 = $result1["tax"];
        // calculation of the tax without the children
        if ($children !== 0) {
            $result2 = $this->calculateTax2($married, 0, $salary);
            $tax2 = $result2["tax"];
            // application of the family quotient ceiling
            $halfShareCeiling = $this->taxAdminData->halfShareQfCeiling;
            if ($children < 3) {
                // halfShareQfCeiling euros for the first 2 children
                $tax2 = $tax2 - $children * $halfShareCeiling;
            } else {
                // halfShareQfCeiling euros for the first 2 children, double for the following ones
                $tax2 = $tax2 - 2 * $halfShareCeiling - ($children - 2) * 2 * $halfShareCeiling;
            }
        } else {
            $tax2 = $tax1;
            $result2 = $result1;
        }
        // we take the highest tax with the rate and the surcharge that go with it
        if ($tax1 > $tax2) {
            $tax = $tax1;
            $rate = $result1["rate"];
            $surcharge = $result1["surcharge"];
        } else {
            $surcharge = $tax2 - $tax1 + $result2["surcharge"];
            $tax = $tax2;
            $rate = $result2["rate"];
        }
        // calculation of a possible discount
        $discount = $this->getDiscount($married, $salary, $tax);
        $tax -= $discount;
        // calculation of a possible tax reduction
        $reduction = $this->getReduction($married, $salary, $children, $tax);
        $tax -= $reduction;
        // result
        return [
            "tax" => (int) floor($tax),
            "surcharge" => (int) $surcharge,
            "discount" => (int) $discount,
            "reduction" => (int) $reduction,
            "rate" => $rate,
        ];
    }

Comments

  • line 9: The class [AbstractBaseTax] implements the interface [InterfaceTax];
  • Line 12: Data from the tax authority will be stored in the private attribute [$taxAdminData]. Its type, [?TaxAdminData], means “a [TaxAdminData] object or the value [null].” Its initial value is [null];
  • line 16: the [getTaxAdminData] method of the interface. We do not yet know how to define it: we have seen a case where data from the tax authority was retrieved from a JSON file (version 2). We will see another case where the data will be retrieved from a database. It will be up to the derived classes to define the content of the [getTaxAdminData] method. The method is therefore declared abstract, which requires the class itself to be declared abstract. This declaration is optional: an abstract class that implements an interface is not required to redeclare the methods it does not implement. However, it makes the code more readable. The attribute [#[\Override]] (PHP 8.3) indicates that the method belongs to an interface or a parent class: if the name is incorrect, PHP signals the error;
  • Lines 21–73: The tax calculation function already encountered in versions 1 and 2. Its parameters have changed: Version 2 took the associative array [$taxAdminData] as its first parameter, while Version 3 uses the attribute [$this->taxAdminData]. The first difference between these two solutions is a difference in the visibility of tax data:
    • in version 2, the associative array [$taxAdminData] did not have global visibility. It was therefore passed as a parameter to all tax calculation functions;
    • in version 3, the attribute [$this->taxAdminData] is visible in all methods of the class. It is therefore no longer passed as a parameter;
  • A second difference stems from the fact that version 3 replaces functions with class methods. Each method call is now made using the expression [$this->method(...)] (line 31, line 61);
  • Line 29: A third difference is that when the [calculateTax] method begins its work, it does not know whether the [$taxAdminData] attribute it needs has been initialized. In fact, the class has no constructor that would initialize it. It is therefore up to the [calculateTax] method to do so using the [getTaxAdminData] method. The operator [??=] (PHP 7.4) assigns the value on the right to the variable on the left only if the variable is equal to [null]. The line is equivalent to [if ($this->taxAdminData === null) { $this->taxAdminData = $this->getTaxAdminData(); }];
  • Line 38: Data from the tax authority is read directly from the read-only public attributes of the object [TaxAdminData];
  • Apart from these differences, the tax calculation method remains the same as in previous versions.

The private methods for calculating tax are as follows:

    // --------------------------------------------------------------------------
    private function calculateTax2(string $married, int $children, float $salary): array
    {
        // number of shares
        $nbShares = match ($married) {
            "yes" => $children / 2 + 2,
            "no" => $children / 2 + 1,
        };
        // 1 share per child from the 3rd one
        if ($children >= 3) {
            // one more half-share for each child from the 3rd one
            $nbShares += 0.5 * ($children - 2);
        }
        // taxable income
        $taxableIncome = $this->getTaxableIncome($salary);
        // surcharge
        $surcharge = floor($taxableIncome - 0.9 * $salary);
        // for rounding problems
        if ($surcharge < 0) {
            $surcharge = 0;
        }
        // family quotient
        $quotient = $taxableIncome / $nbShares;
        // the tax brackets (copies of the arrays of the object)
        $limits = $this->taxAdminData->limits;
        $coeffR = $this->taxAdminData->coeffR;
        $coeffN = $this->taxAdminData->coeffN;
        // is put at the end of the limits array to stop the following loop
        $limits[count($limits) - 1] = $quotient;
        // calculation of the tax
        $i = 0;
        while ($quotient > $limits[$i]) {
            $i++;
        }
        // now we can calculate the tax
        $tax = floor($taxableIncome * $coeffR[$i] - $nbShares * $coeffN[$i]);
        // result
        return ["tax" => $tax, "surcharge" => $surcharge, "rate" => $coeffR[$i]];
    }

    // taxableIncome=annualSalary-allowance
    // the allowance has a min and a max
    private function getTaxableIncome(float $salary): float
    {
        // allowance of 10% of the salary, bounded by a minimum and a maximum
        $allowance = 0.1 * $salary;
        $allowance = min($allowance, $this->taxAdminData->tenPercentAllowanceMax);
        $allowance = max($allowance, $this->taxAdminData->tenPercentAllowanceMin);
        // taxable income
        $taxableIncome = $salary - $allowance;
        // result
        return floor($taxableIncome);
    }

    // calculates a possible discount
    private function getDiscount(string $married, float $salary, float $taxes): float
    {
        // at the start, a zero discount
        $discount = 0;
        // maximum amount of tax to get the discount
        $taxCeilingForDiscount = $married === "yes"
            ? $this->taxAdminData->coupleTaxCeilingForDiscount
            : $this->taxAdminData->singleTaxCeilingForDiscount;
        if ($taxes < $taxCeilingForDiscount) {
            // maximum amount of the discount
            $discountCeiling = $married === "yes"
                ? $this->taxAdminData->coupleDiscountCeiling
                : $this->taxAdminData->singleDiscountCeiling;
            // theoretical discount
            $discount = $discountCeiling - 0.75 * $taxes;
            // the discount cannot exceed the amount of the tax
            if ($discount > $taxes) {
                $discount = $taxes;
            }
            // no discount < 0
            if ($discount < 0) {
                $discount = 0;
            }
        }
        // result
        return ceil($discount);
    }

    // calculates a possible reduction
    private function getReduction(string $married, float $salary, int $children, float $taxes): float
    {
        // the income ceiling to be entitled to the 20% reduction
        $incomeCeilingForReduction = $married === "yes"
            ? $this->taxAdminData->coupleIncomeCeilingForReduction
            : $this->taxAdminData->singleIncomeCeilingForReduction;
        $incomeCeilingForReduction += $children * $this->taxAdminData->halfShareReductionValue;
        if ($children > 2) {
            $incomeCeilingForReduction += ($children - 2) * $this->taxAdminData->halfShareReductionValue;
        }
        // taxable income
        $taxableIncome = $this->getTaxableIncome($salary);
        // reduction
        $reduction = 0;
        if ($taxableIncome < $incomeCeilingForReduction) {
            // 20% reduction
            $reduction = 0.2 * $taxes;
        }
        // result
        return ceil($reduction);
    }

Comments

  • Lines 80–83: The expression [match] (PHP 8.0) is a better replacement for [if / else] from version 2. It compares the value [$married] to the values of its branches using the equality operator [===] and returns the value of the matching branch. If no branch matches, it raises an error [UnhandledMatchError]: a marital status other than “yes” or “no” will not go unnoticed;
  • lines 100–102: the tax bracket tables are copied into local variables. In PHP, assigning a table creates a copy: the modification to [$limits] on line 104 does not affect the [readonly] attribute of the [TaxAdminData] object (which would, incidentally, be an error);
  • Lines 122–123: The functions [min] and [max] replace the two [if] functions from version 2: the deduction cannot exceed [tenPercentAllowanceMax] or be less than [tenPercentAllowanceMin];
  • the rest of the code is the same as in version 2, where the expression [$taxAdminData["COUPLE_DISCOUNT_CEILING"]] has become [$this->taxAdminData->coupleDiscountCeiling]. A typo in an attribute name is now flagged by the [VS Code] editor ([PHP Intelephense] extension) as soon as the code is written, which was not the case for a typo in an array key.

The [executeBatchTaxes] method is as follows:

    // tax calculation in batch mode
    // --------------------------------------------------------------------------
    #[\Override]
    public function executeBatchTaxes(string $taxPayersFileName, string $resultsFileName, string $errorsFileName): void
    {
        // the three files are not opened yet
        $errors = $results = $data = false;
        // quite a few errors can occur as soon as files are handled
        try {
            // opening the errors file
            $errors = fopen($errorsFileName, "w");
            if ($errors === false) {
                throw new TaxException("Unable to create the errors file [$errorsFileName]", 10);
            }
            // opening the results file
            $results = fopen($resultsFileName, "w");
            if ($results === false) {
                throw new TaxException("Unable to create the results file [$resultsFileName]", 11);
            }
            // opening the taxpayers data file
            $data = is_readable($taxPayersFileName) ? fopen($taxPayersFileName, "r") : false;
            if ($data === false) {
                throw new TaxException("Unable to open the taxpayers' declarations [$taxPayersFileName] for reading", 12);
            }
            // we process the current line of the taxpayers data file
            // which has the form marital status, number of children, annual salary
            $num = 0;         // no. of the current line
            $nbErrors = 0;    // number of errors encountered
            while (($line = fgets($data, 100)) !== false) {
                // one more line
                $num++;
                // we remove the possible end-of-line mark
                $line = Utilities::cutNewLineChar($line);
                // we ignore the empty lines
                if (trim($line) === "") {
                    continue;
                }
                // the line must have 3 fields married,children,salary
                $fields = array_map(trim(...), explode(",", $line));
                $error = count($fields) !== 3;
                if (!$error) {
                    [$married, $children, $salary] = $fields;
                    // the marital status must be yes or no
                    $married = strtolower($married);
                    // the number of children and the salary must be positive or zero integers
                    $error = !in_array($married, ["yes", "no"], true)
                        || !preg_match('/^\d+$/', $children)
                        || !preg_match('/^\d+$/', $salary);
                }
                // error?
                if ($error) {
                    fputs($errors, "line [$num] of the file [$taxPayersFileName] is incorrect\n");
                    $nbErrors++;
                    continue;
                }
                // we calculate the tax
                $result = $this->calculateTax($married, (int) $children, (int) $salary);
                // we write the result into the results file
                $result = ["married" => $married, "children" => (int) $children, "salary" => (int) $salary] + $result;
                fputs($results, json_encode($result, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR) . "\n");
            }
            // errors?
            if ($nbErrors > 0) {
                throw new TaxException("There were $nbErrors incorrect line(s): see the file [$errorsFileName]", 15);
            }
        } finally {
            // we close the files that have been opened
            foreach ([$data, $results, $errors] as $file) {
                if ($file !== false) {
                    fclose($file);
                }
            }
        }
    }

Code comments

  • Line 185: The method takes three parameters:
    • [$taxPayersFileName]: the name of the text file containing taxpayer data. Each line of text contains data for one taxpayer in the following format: marital status (yes/no), number of children, annual salary; for example, [yes,2,55555];
    • [$resultsFileName]: the name of the text file that will contain the results. Each line of text will be the JSON string representing a taxpayer’s results, for example, [{"married":"yes","children":2,"salary":50000,"tax":1384,"surcharge":0,"discount":384,"reduction":347,"rate":0.14}];
    • [$errorsFileName]: the name of the text file containing errors, for example, [line [5] of the file [taxpayersdata.txt] is incorrect];
  • line 188: the variables for the three files are initialized to [false] (file not open). This will make it possible to determine, in the [finally] clause, which files have been opened;
  • line 190: Because a number of operations can throw an exception, a [try / finally] structure encloses the entire method code;
  • lines 192–204: The three files are opened. An exception is thrown as soon as an open operation fails, with a different error code for each case;
  • Line 210: The lines in the [$data] file are read one by one in blocks of up to 100 characters (all lines are less than 100 characters long);
  • Line 214: The static method [Utilities::cutNewLineChar] is used to remove any end-of-line characters;
  • Lines 216–218: Empty lines (for example, at the end of the file) are ignored;
  • line 220: the line is split at the comma, and the spaces surrounding each field are removed. [trim(...)] is the syntax for “first-class callables” (PHP 8.1): it refers to the [trim] function itself, without calling it;
  • line 221: the line must have exactly three fields. The PHP 7 course wrote [list($married, $children, $salary) = explode(",", $line)] directly: with a line having only two fields, PHP 8 issues a “Undefined array key 2” warning;
  • Lines 227–229: The validity of the three elements is checked: marital status must be “yes” or “no,” and the number of children and salary must be positive integers or zero (a sequence of digits, regular expression [/^\d+$/]). The third parameter [true] of [in_array] requires a strict comparison [===];
  • lines 232–236: no exception is thrown if the line is invalid; instead, an error message is written to the text file [$errors], and the program moves on to the next line;
  • line 238: if the line read is valid, the tax is calculated. The result is returned as an associative array [tax, surcharge, discount, reduction, rate];
  • line 240: The keys [married, children, salary] are added to the beginning of the result;
  • line 241: the result is written to the text file [$results] in the form of its JSON string. The option [JSON_THROW_ON_ERROR] causes the function [json_encode] to throw an exception [JsonException] rather than returning [false] if the encoding fails;
  • lines 244–246: At the end of processing the [$data] file, the number of erroneous lines encountered is checked. If there is at least one, an exception is thrown;
  • lines 247–254: Regardless of how the method’s execution ends (normally or via an exception), the clause [finally] is executed: any files that were opened are closed. The PHP 7 course used a structure [try / catch / finally] whose clause [catch] simply rethrew the exception. This is unnecessary: a [try / finally] structure without [catch] allows the exception to propagate after the [finally] clause has been executed. Furthermore, the PHP 7 course closed the three files without verifying that they had been opened: in PHP 8, [fclose(false)] causes a [TypeError] error.

8.7. The [TaxWithTaxAdminDataInJsonFile] class

The abstract class [AbstractBaseTax] does not implement the [getTaxAdminData] method of the [InterfaceTax] interface. We must therefore define it in a derived class. We do this in the following derived class, [TaxWithTaxAdminDataInJsonFile]:

<?php

declare(strict_types=1);

// namespace
namespace Application;

// a class that takes the data of the tax administration from a JSON file
class TaxWithTaxAdminDataInJsonFile extends AbstractBaseTax
{
    // the data of the tax administration
    private readonly TaxAdminData $taxAdminData;

    // the constructor
    public function __construct(string $jsonFileName)
    {
        // we initialize $this->taxAdminData from the JSON file
        // may throw a TaxException exception
        $this->taxAdminData = TaxAdminData::fromJsonFile($jsonFileName);
    }

    // returns the data used to calculate the tax
    #[\Override]
    public function getTaxAdminData(): TaxAdminData
    {
        return $this->taxAdminData;
    }
}

Comments

  • Line 9: The [TaxWithTaxAdminDataInJsonFile] class extends the abstract class [AbstractBaseTax]. It must define the [getTaxAdminData] method, which its parent class has not defined;
  • Line 12: The attribute [$taxAdminData] will contain data from the tax authority. It is read-only: it is initialized once and for all by the constructor. Note that the parent class also has a private attribute [$taxAdminData]: the two attributes are distinct, as a private attribute is visible only within the class that declares it;
  • lines 15–20: The constructor receives as its only parameter the name of the JSON file containing the tax data;
  • line 19: An object of type [TaxAdminData] is created from the JSON file. This operation may throw an exception of type [TaxException]. This exception will propagate up to the main script [main.php];
  • Lines 23–27: A body is provided for the [getTaxAdminData] method, which the parent class had not defined. Here, it is sufficient to ensure that the [$this->taxAdminData] attribute is initialized by the constructor.

8.8. The script [main.php]

These classes and interfaces are used by the following script, [main.php]:

<?php

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

// namespace
namespace Application;

// inclusion of the interfaces and classes
require_once __DIR__ . "/TaxException.php";
require_once __DIR__ . "/TaxAdminData.php";
require_once __DIR__ . "/Utilities.php";
require_once __DIR__ . "/InterfaceTax.php";
require_once __DIR__ . "/AbstractBaseTax.php";
require_once __DIR__ . "/TaxWithTaxAdminDataInJsonFile.php";

// definition of the constants
const TAXPAYERSDATA_FILENAME = "taxpayersdata.txt";
const RESULTS_FILENAME = "results.txt";
const ERRORS_FILENAME = "errors.txt";
const TAXADMINDATA_FILENAME = "taxadmindata.json";

try {
    // we create a [TaxWithTaxAdminDataInJsonFile] object
    $tax = new TaxWithTaxAdminDataInJsonFile(TAXADMINDATA_FILENAME);
    // we execute the tax batch
    $tax->executeBatchTaxes(TAXPAYERSDATA_FILENAME, RESULTS_FILENAME, ERRORS_FILENAME);
} catch (TaxException $ex) {
    // we display the error
    print "Error no. {$ex->getCode()}: {$ex->getMessage()}\n";
}
// end
print "Done\n";

Comments

  • Line 4: Strict adherence to function parameter types is enforced;
  • Line 7: The script [main.php] is also placed in the [Application] namespace;
  • Lines 10–15: The class and interface files used by the script are loaded. Note that here we did not use a [use] statement to declare the full names of the classes used by the script. This is unnecessary because the script and the classes are in the same namespace, [Application]. Also note the loading order: a parent class or interface must be loaded before the class that derives from or implements it;
  • lines 18–21: the names of the files used in the script. These names are relative: the files are searched for in the current directory, the one from which the script is executed;
  • line 25: A [TaxWithTaxAdminDataInJsonFile] object is created. This may throw an exception if the JSON file is missing or incorrect;
  • line 27: the [executeBatchTaxes] method is called, which will calculate taxes for all taxpayers in the [TAXPAYERSDATA_FILENAME] file. The results will be written to the file [RESULTS_FILENAME], and any errors will be written to the file [ERRORS_FILENAME];
  • Lines 28–31: If an error occurs, the error code and message are displayed. The expression [{$ex->getCode()}] allows the result of a method call to be inserted into a string.

8.9. Results

Run the script in a terminal opened in the [version-03] folder:

1
2
3
> php main.php
Error no. 15: There were 2 incorrect line(s): see the file [errors.txt]
Done

Using the taxpayer file [taxpayersdata.txt] shown above, the following error file [errors.txt] is generated:

line [5] of the file [taxpayersdata.txt] is incorrect
line [8] of the file [taxpayersdata.txt] is incorrect

and the following results file, [results.txt]:

{"married":"yes","children":2,"salary":55555,"tax":2814,"surcharge":0,"discount":0,"reduction":0,"rate":0.14}
{"married":"yes","children":2,"salary":50000,"tax":1384,"surcharge":0,"discount":384,"reduction":347,"rate":0.14}
{"married":"yes","children":3,"salary":50000,"tax":0,"surcharge":0,"discount":720,"reduction":0,"rate":0.14}
{"married":"no","children":2,"salary":100000,"tax":19884,"surcharge":4480,"discount":0,"reduction":0,"rate":0.41}
{"married":"no","children":3,"salary":100000,"tax":16782,"surcharge":7176,"discount":0,"reduction":0,"rate":0.41}
{"married":"yes","children":3,"salary":100000,"tax":9200,"surcharge":2180,"discount":0,"reduction":0,"rate":0.3}
{"married":"yes","children":5,"salary":100000,"tax":4230,"surcharge":0,"discount":0,"reduction":0,"rate":0.14}
{"married":"no","children":0,"salary":100000,"tax":22986,"surcharge":0,"discount":0,"reduction":0,"rate":0.41}
{"married":"yes","children":2,"salary":30000,"tax":0,"surcharge":0,"discount":0,"reduction":0,"rate":0}
{"married":"no","children":0,"salary":200000,"tax":64210,"surcharge":7498,"discount":0,"reduction":0,"rate":0.45}
{"married":"yes","children":3,"salary":200000,"tax":42842,"surcharge":17283,"discount":0,"reduction":0,"rate":0.41}

The taxes calculated for the eleven correct lines (2814, 1384, 0, 19884, 16782, 9200, 4230, 22986, 0, 64210, 42842) are those from versions 1 and 2.

8.10. Conclusion

Version 3 has the same functionality as version 2 but is better equipped to handle errors: incomplete or incorrect tax data, erroneous lines in the taxpayer file, or missing files. Each error is flagged by a [TaxException] exception that includes a message and a specific code.

However, the [AbstractBaseTax] class combines two types of codes: tax calculation on the one hand, and reading and writing files on the other. In the following chapters, we will see how to separate these two concerns into different layers.