Understanding Classes and Objects in PHP

In this tutorial I want to introduce you into the world of OOP using PHP as a programming language.

Let's create a directory structure where we'll keep some files that we'll want to parse in our project:

mkdir -p files/new-books/

Create file file/book.json with the following content:

{
  "title": "my first book",
  "description": "my first book description",
  "ISDN": "1231243423452542345"
}

Create file file/book.yaml with the following content:

title: my second book
description: my second book description
ISDN: 1233432523454646
store: Bucharest001

Create file file/new-books/book.json with the following content:

{
  "title": "my third book",
  "description": "my third book description",
  "ISDN": "1231243423452542335"
}

Create file file/new-books/book.yaml with the following content:

title: my forth book
description: my forth book description
ISDN: 1233432522454646
store: Berlin001

Before we deep dive into OOP, we need to create our environment that will be used to support autoloading of our code. For this, we'll use composer as a dependency manager.

Let's create the composer.json file with the following content:

{
  "autoload": {
    "psr-4": {
      "App\\": "src/"
    }
  }
}

For the moment, we specify in our composer file that we'll use PSR-4 as a standard convention for our autoloading logic for the php classes. As you can see in the file, we register the src/ directory with the App\ namespace. This will be only the beginning, while the rest of the namespaces will follow the directory structure.

Let me give you a few examples of usage with our current setup:

php file path namespace use statement
src/Core/Database/Connect.php App\Core\Database use App\Core\Database\Connect;
src/Core/Database/Driver/Mysql.php App\Core\Driver use App\Core\Database\Driver\Mysql;
src/Core/Database/Driver/Postgres.php App\Core\Driver use App\Core\Database\Driver\Postgres;

As you have already noticed, the namespace is build by replacing src directory with the namespace App that we have defined in our composer.json file.

App\ is the virtual part of the namespace, while everything else after it is the relative path to the src directory in our project.
Virtual namespace App\ + path in src directory Core/Database = App\Core\Database

If you want to learn more, I recommend you to look into PHP documentation and Symfony casts, namespaces under 5 minutes.

Before we run any composer command, I want to show you how to include the autoloader into our files.

Let's create a file cmd/run.php with the following content:

<?php

You have just created an empty PHP file where you have added the php starting tag <?php but ommited the ending tag ?>. Please note and keep in mind that is BEST PRACTICE to NOT add the php ending tag at the end of the file.

Now, if you run php cmd/run.php you won't see any output, because, obviously, our new file is empty.

At this moment, you should have this structure into your project directory:

.
├── cmd
│   └── run.php
└── composer.json

Another think that I want to make it clear from the beginning is that every command you'll run in the terminal will be executed from the root directory of the project we are working on. We'll make sure that we use the right paths to load the required files.

Now is the time to generate the vendor directory by running the following command in your terminal:

composer install

Once the command finishes, you'll notice that we'll have a new directory called vendor/ in our project and a new file composer.lock.

If you use git, you must ignore the vendor directory so that git will not record any change of any file inside it. For this, run the following command in your terminal:

echo "vendor/" >> .gitignore

In our run.php file, we need to require the file autoload.php from vendor/ directory.

If you add the following line into your cmd/run.php file:

<?php

require __DIR__ . '/../vendor/autoload.php';

If you run the script again php cmd/run.php, you won't have an output this time either. But, now we are loading the autoloader file from the composer (actually, vendor directory). Please note that the DIR will write the path for the file where it stands and from there (cmd) we have to go back one directory, this is why we have to use /../.

Diving into OOP

In this tutorial, we'll build a console command that will read some files and extract the products information from them. For the moment we'll focus only on json and yaml files to read.

Our first class that we'll build will be responsible to manage the process of reading the files from the directory and will save the content into an array (list). All data will have the same structure in the final stage.

Let's build our class. Create an empty php file

touch src/FilesManager.php

Then, we'll create the php class in the file.

<?php

namespace App;

class FilesManager
{

}

In every php class file inside the src directory you must add the namespace line, and the class name must be the same as the file name, except the php extension.

Next step is to add the three properties of the class:

  • decoders -> will be a list of decoders objects that will have the responsibility to read and transform the file content into php array.
  • registry -> here we'll store our formatted data that we read from the files
  • errors -> this property will keep a list of errors that may occur during our transformation
<?php

namespace App;

class FilesManager
{
    protected $decoders = [];
    protected $registry = [];
    protected $errors = [];
}
Please note that our properties are protected. That means they will be accessible only inside the class and inside the classes that extends this class. But for the moment, we'll not extend this class.

Before we create our first method in the FilesManager class, let's create our first interface.

Object interfaces allow you to create code which specifies which methods a class must implement, without having to define how these methods are implemented. Interfaces share a namespace with classes and traits, so they may not use the same name.

Before we create the interface file, we need to create a directory inside src to store it

mkdir src/Books/

Now, let's create the interface file

touch src/Books/DecoderInterface;

Open the file in your editor and add the following code:

<?php

namespace App\Books;

interface DecoderInterface
{
    /**
     * @param string $filePath
     * @return array
     */
    public function parseFile($filePath);
}

Interfaces that are declared just like normal classes with the exception that there is no executable code inside them. The classes that will implement this interface will have to declare the methods defined in the interfaces, in our case, they'll have to define method parseFile

Now, let's go back to our src/FilesManager.php file and create a new method inside the class.

<?php

namespace App;

use App\Decoders\DecoderInterface;

class FilesManager
{
    protected $decoders = [];
    protected $registry = [];
    protected $errors = [];
    
    /**
     * @param string $extension
     * @param DecoderInterface $decoder
     */
    public function addDecoder($extension, DecoderInterface $decoder)
    {
        $this->decoders[$extension] = $decoder;
    }
}

In this method, we register a new extension and store it in an array with the extension as a key for the array:

Since we cannot instantiate the interface, we'll need a class that will implement our interface.

But, we'll create another class that cannot be instantiated, but it can have executable code. We'll create an abstract class that will have the responsibility to format our data from the files into a format that we want.

Let's create the file first:

touch src/Books/FileDecoder.php

And add the following code in the file:

<?php

namespace App\Books;

abstract class FileDecoder implements DecoderInterface
{
    public function processFile($filePath)
    {
        $payload = $this->parseFile($filePath);

        return $this->filterFields($payload);
    }

    protected function filterFields(array $payload)
    {
        return [
            'title' => $payload['title'],
            'description' => $payload['description'],
        ];
    }
}

The purpose of this class is to format the data that we read from files to a specific format that we can use. The key feature here is that no matter what file we read, the output array will be the same all the time. This class will not know anything about the files that we read.

The responsibility of the file read and decoding, will be held by the classes that will extend our abstract class.

Let's focus for now on the json decoder class.

Create the directory where we'll keep our decoders

mkdir src/Books/Decoders

Create the JsonDecoder file

touch src/Books/Decoders/JsonDecoder;

And then, create the class inside the newly created file:

<?php

namespace App\Books\Decoders;

use App\Books\FileDecoder;

class JsonDecoder extends FileDecoder
{

}

Since we extend the abstract class FileDecoder which implements the DecoderInterface, we'll have to create in this class the method specified in the interface.

Add the parseFile as a public method inside the JsonDecoder class. The method will have a parameter $filePath which will be a string with the path to the json file. Inside the method we'll read the content of the file and then transform the content into a php array using the function json_decode()

In the end, our JsonDecoder class will look like this:

<?php

namespace App\Books\Decoders;

use App\Books\FileDecoder;

class JsonDecoder extends FileDecoder
{
    public function parseFile($filePath)
    {
        $fileContent = file_get_contents($filePath);

        return json_decode($fileContent, true);
    }
}
Since the FileDecoder class has a different namespace, we need to add the use line use App\Books\FileDecoder; below the namespace definition so that the php interpreter will know which class to use.

Now, that we have our classes at this stage, let's go to our cmd/run.php file and make use of them:

<?php

use App\Books\Decoders\JsonDecoder;

require_once __DIR__ . '/../vendor/autoload.php';

$manager = new \App\FilesManager();
$manager->addDecoder('json', new JsonDecoder());

If you run the php script it not output anything, which is exactly what we want at this stage.

php cmd/run.php

But for the moment I want to show in the terminal the structure of our $manager object.

Let's add the following line at the end of the cmd/run.php file:

dump($manager);

and then run again the php file

php cmd/run.php

When you run the script you should have an error that the dump function is undefined

PHP Fatal error:  Uncaught Error: Call to undefined function dump() in cmd/run.php:10

That is because there is no dump function in php language, but there is a package that adds it, and we can use it.

To add the package, we'll use the power of composer and the only thing we need to do is to run the following command:

composer require --dev symfony/var-dumper

If you run again php cmd/run.php you should have the following output:

App\FilesManager {#3
  #decoders: array:1 [
    "json" => App\Books\Decoders\JsonDecoder {#2}
  ]
  #registry: []
  #errors: []
}

Now is time to go back to our src/FilesManager.php file and add a new method in the class. We will create a method that will be responsible to read all files from a directory.

Add the following method in the body of the FilesManager class:

public function readDirectory($directoryPath)
{
    foreach (new \DirectoryIterator($directoryPath) as $file) {
        if ($file->isDot()) {
            continue;
        }
        if ($file->isDir()) {
            continue;
        }

        $extension = $file->getExtension();
        if (array_key_exists($extension, $this->decoders) === false) {
            $this->logError(sprintf('there is no support for `%s` extension', $extension));
            continue;
        }
         
        $this->processFile($file);
    }
}

This method will receive a $directoryPath as a parameter. It will contain a foreach loop statement that will iterate each file and directory from the path we provide as parameter.

The first two if statements will skip the loop when it detects a directory, or a special directory dot (. or ..). For the moment we don't do anything with the directories, we'll come back to them later.

Then, we create a variable where we'll store the extension of the file, and then we check if the extension exist in the array keys of $this->decoders. If it does not exist, we log a message into the $error property of the class.

In the end, we'll call another method of the class, and we'll pass the file object to it.

Add the following line into the cmd/run.php right above the dump function call:

$manager->readDirectory('files');

If you run again the cmd/run.php file, you will see another error that method processFile does not exist. Let's add the protected method into our src/FileManager.php:

protected function processFile(\DirectoryIterator $file)
{
    /** @var DecoderInterface $decoder */
    $decoder = $this->decoders[$file->getExtension()];

    $this->registry[] = $decoder->parseFile($file->getPathname());
}

Then, add another protected method to the same file:

protected function logError($message)
{
    $this->errors[] = $message;
}

In the end, our src/FilesManager.php file should have the following content:

<?php

namespace App;

use App\Books\DecoderInterface;

class FilesManager
{
    protected $decoders = [];
    protected $registry = [];
    protected $errors = [];

    /**
     * @param string $extension
     * @param DecoderInterface $decoder
     */
    public function addDecoder($extension, DecoderInterface $decoder)
    {
        $this->decoders[$extension] = $decoder;
    }

    public function readDirectory($directoryPath)
    {
        foreach (new \DirectoryIterator($directoryPath) as $file) {
            if ($file->isDot()) {
                continue;
            }
            if ($file->isDir()) {
                continue;
            }

            $extension = $file->getExtension();
            if (array_key_exists($extension, $this->decoders) === false) {
                $this->logError(sprintf('there is no support for `%s` extension', $extension));
                continue;
            }

            $this->processFile($file);
        }
    }

    protected function processFile(\DirectoryIterator $file)
    {
        /** @var FileDecoder $decoder */
        $decoder = $this->decoders[$file->getExtension()];

        $this->registry[] = $decoder->processFile($file->getPathname());
    }

    protected function logError($message)
    {
        $this->errors[] = $message;
    }
}

Now, if you run again the run file:

php cmd/run.php

You should see the following output:

App\FilesManager {#3
  #decoders: array:1 [
    "json" => App\Books\Decoders\JsonDecoder {#2}
  ]
  #registry: array:1 [
    0 => array:3 [
      "title" => "my first book"
      "description" => "my first book description"
    ]
  ]
  #errors: array:1 [
    0 => "there is no support for `yaml` extension"
  ]
}

Here, we can see that we have only one decoder registered to our script logic, we have parsed one json file, since we have the json decoder, and we have another file type yaml which is not supported yet, because we don't have a decoder for it, yet. But we'll add one right now.

By default, PHP doesn't have a default integration for yaml code, as it has for json. In order to make this work, we'll need to use another external dependency called symfony/yaml component.

To do this, run the following command in your terminal:

composer require symfony/yaml

Now, let's create a new decoder file:

touch src/Books/Decoders/YamlDecoder.php

with the following code inside:

<?php

namespace App\Books\Decoders;

use App\Books\FileDecoder;
use Symfony\Component\Yaml\Yaml;

class YamlDecoder extends FileDecoder
{
    public function parseFile($filePath)
    {
        return Yaml::parseFile($filePath);
    }
}

As you can see, in the parseFile method of this class, we return the parsed file content by the external Yaml class.

Let's open the cmd/run.php file and add the new decoder to it. Our file will look like this:

<?php

use App\Books\Decoders\JsonDecoder;
use App\Books\Decoders\YamlDecoder;

require_once __DIR__ . '/../vendor/autoload.php';

$manager = new \App\FilesManager();
$manager->addDecoder('json', new JsonDecoder());
$manager->addDecoder('yaml', new YamlDecoder());

$manager->readDirectory('files');

dump($manager);

If you run again the php script

php cmd/run.php

we will see an output similar to this one:

App\FilesManager {#3
  #decoders: array:2 [
    "json" => App\Books\Decoders\JsonDecoder {#2}
    "yaml" => App\Books\Decoders\YamlDecoder {#4}
  ]
  #registry: array:2 [
    0 => array:3 [
      "title" => "my first book"
      "description" => "my first book description"
    ]
    1 => array:4 [
      "title" => "my second book"
      "description" => "my second book description"
    ]
  ]
  #errors: []
}

At this moment, we have two decoders, two elements in the registry property and no error registered in out class.

But, in the files directory we have actually 4 files, which 2 are in a subdirectory. But our code, doesn't do anything with the subdirectories that are found in the path. Remember that we have added this if statement in our readDirectory method in the FilesManager class?

if ($file->isDir()) {
    continue;
}

This is where we tell the program to skip every directory that it finds in our provided path. But that was only while we were building the scripts, and now that we have finished it, we need to access all files inside the original path, no matter how deep they are in the directory structure.

Open the src/FilesManager.php file and inside readDirectory method, add a new line inside the if ($file->isDir()) { statement, but above the continue line. The if statement should look like this now:

if ($file->isDir()) {
    $this->readDirectory($file->getPathname());
    continue;
}

And the whole method will look like this:

public function readDirectory($directoryPath)
{
    foreach (new \DirectoryIterator($directoryPath) as $file) {
        if ($file->isDot()) {
            continue;
        }
        if ($file->isDir()) {
            $this->readDirectory($file->getPathname());
            continue;
        }

        $extension = $file->getExtension();
        if (array_key_exists($extension, $this->decoders) === false) {
            $this->logError(sprintf('there is no support for `%s` extension', $extension));
            continue;
        }

        $this->processFile($file);
    }
}

What you are doing here, is to call the same method recursively for each directory it finds. With other words, the function will call itself every time it finds a directory and will pass the full path as a parameter.

Now, if you run again the php file

php cmd/run.php

you should see that you have 4 elements in the registry property of the class, and each element has the title and description taken from the parsed file.

But what can we do if we want to add the filename of the parsed file for each element ?

This is actually pretty simple and for this we'll have to make a small change in our abstract FileDecoder class:

Open the src/Books/FileDecoder.php file and make change the both methods to have the following content:

<?php

namespace App\Books;

abstract class FileDecoder implements DecoderInterface
{
    public function processFile($filePath)
    {
        $payload = $this->parseFile($filePath);

        return $this->filterFields($payload, $filePath);
    }

    protected function filterFields(array $payload, $filePath)
    {
        return [
            'title' => $payload['title'],
            'description' => $payload['description'],
            'source' => $filePath,
        ];
    }
}

What we've done here, was to add the filePath into the returned array of the method filterFields and add $filePath as a second parameter to the method.

Since our filterFields method doesn't know anything about the filename, it will receive the path for the file which is being processed as a parameter. This file path is known in the processFile method and from this method we call the filterFields method and we pass two parameters this time: $payload and $filePath.

Now, if you run again the cmd/run.php file, you should see in the list the source file that was parsed.

php cmd/run.php

# Output
App\FilesManager {#3
  #decoders: array:2 [
    "json" => App\Books\Decoders\JsonDecoder {#2}
    "yaml" => App\Books\Decoders\YamlDecoder {#4}
  ]
  #registry: array:4 [
    0 => array:3 [
      "title" => "my first book"
      "description" => "my first book description"
      "source" => "files/book.json"
    ]
    1 => array:3 [
      "title" => "my second book"
      "description" => "my second book description"
      "source" => "files/book.yaml"
    ]
    2 => array:3 [
      "title" => "my third book"
      "description" => "my third book description"
      "source" => "files/new-books/book.json"
    ]
    3 => array:3 [
      "title" => "my forth book"
      "description" => "my forth book description"
      "source" => "files/new-books/book.yaml"
    ]
  ]
  #errors: []
}

The next step is to add unittests with phpunit. Feel free to follow the next toturial as well.