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 thesrc
directory in our project.
Virtual namespaceApp\
+ path in src directoryCore/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 /../
.
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:
<?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 theFileDecoder
class has a different namespace, we need to add the use lineuse 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.