Ioannis Bekiaris

Building a CLI PHP application without a framework

I have worked with many PHP frameworks—good and bad ones. With “security” (of course, no framework itself guarantees security), efficiency, and community support as their main pros, frameworks are really useful. However, no matter how good a framework is, it comes with its downsides and limitations such as steep learning curve, predefined behaviors, and coding practices, redundant functionality, possible performance issues, maintenance toil...

Even if I still use frameworks, there are cases when I prefer to combine several PHP libraries instead. As the PHP community matures, we have plenty of tools out to help us in this direction. For example, Composer has been out there since 2012, and PHP Standards Recommendations help PHP devs work together in a unified way.

In this article, I would like to share my thoughts with you and show how easy it is to build your own single purpose framework using Composer. We will build a simple but fully extendable CLI application, with a command that logs the current date and time.

All the code used for this article, is published on my github account

Are you ready? Let’s start!

Step 1: Init your project

Before we get started, I assume that you already have Composer installed. If not, then follow the instructions here.

In your project’s root directory, run the following command:

composer install

This command will guide you through creating your `composer.json` config, following the instructions, a `composer.json` file in your project’s root directory.

For the sake of simplicity, we skipped all the instructions by just pressing enter. However, in a real project, you can make your own selections as described here.

Let’s also tell our Composer what version of PHP we are about to use on production. Enabling sorting of packages in the `composer.json` file is a good thing as well:

composer config platform.php 7.4.8
composer config sort-packages true

If you did everything correctly you will get a `composer.json` like the following:


{
    "name": "ioannis/cli-app",
    "authors": [
        {
            "name": "Ioannis Bekiaris",
            "email": "info@ibekiaris.me"
        }
    ],
    "require": {},
    "config": {
        "sort-packages": true,
        "platform": {
            "php": "7.4.8"
        }
    }
}

Step 2: Select your application’s packages

Now that we have Composer in place, we have to select all the packages we will need to build that CLI application.

First, we will need a package that eases the creation of handy and testable command line interfaces. For that reason, we will install `Symfony Console`:

composer require symfony/console

We will also need a PSR-11 compatible service container that allows us to standardize and centralize the way objects are constructed, and for that, we will use `Symfony DI`:

composer require symfony/dependency-injection
composer require symfony/config

Symfony Configuration component will help us with loading DI configuration from files

Step 3: Application structure and PSR-4 autoloading

Application structure is always important. For this tutorial, we will put all our application code under `./src` directory and we will set up PSR-4 autoload in Composer by adding the following in our `composer.json` file:


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

Step 4: Build your main application

Now that we have all the main libraries in place and decided on our project’s structure, it is time to start coding.

Let’s create the `./app.php` file. This will be the entry point of our application. The first thing that we have to do is to require the Composer’s autoloader.


<?php

declare(strict_types=1);

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

Next, we have to put the `Service Container` in action:


<?php

declare(strict_types=1);

use Psr\Container\ContainerInterface;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;

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

$serviceContainer = serviceContainer();

/**
 * @throws Exception
 */
function serviceContainer(): ContainerInterface
{
    $containerBuilder = new ContainerBuilder();
    $loader = new PhpFileLoader($containerBuilder, new FileLocator(__DIR__));
    $loader->load('./di.php');
    $containerBuilder->compile();
    return $containerBuilder;
}

Don’t forget that this code is “simplified” for the sake of this tutorial. In a production application, this Service Container setup is not that performant. Someone has to implement a caching mechanism to avoid the Container’s compilation when it is not needed.

Service Container is loading its configuration from a PHP file which exists under the root directory as well (./di.php)


<?php

declare(strict_types=1);

use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;

return function(ContainerConfigurator $configurator) {
    $services = $configurator->services();
};

Step 4: Add Logger Service

Our tutorial application is about logs. So, we will need a Logger Service and, of course, a popular package for application logs.

For that I ll use Monolog:

composer require monolog/monolog

Now let's create our `./src/LoggerFactory.php` class:


<?php

declare(strict_types=1);

namespace App;

use Monolog\Handler\ErrorLogHandler;
use Monolog\Logger;
use Psr\Log\LoggerInterface;

final class LoggerFactory
{
    public static function create(): LoggerInterface
    {
        $logger = new Logger('cli-app');
        $logger->pushHandler(new ErrorLogHandler());
        return $logger;
    }
}

and then add it as a service in our `./di.php`:


<?php

declare(strict_types=1);

use App\LoggerFactory;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;

return function(ContainerConfigurator $configurator) {
    $services = $configurator->services();
    $services->set(LoggerInterface::class, LoggerInterface::class)
        ->factory([LoggerFactory::class, 'create']);
};

Step 5: Add you first Console Command

First we will create the `./src/LogDateTimeCommand.php`


<?php

declare(strict_types=1);

namespace App;

use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

final class LogDateTimeCommand extends Command
{
    private LoggerInterface $logger;

    public function __construct(LoggerInterface  $logger, string $name = null)
    {
        parent::__construct('app:log-time');
        $this->logger = $logger;
    }

    protected function configure()
    {
        $this
            ->setDescription('Log date and time')
            ->setHelp('This command allows you to log date and time.')
        ;
    }

    public function execute(InputInterface $input, OutputInterface $output): int
    {
        $this->logger->info((new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s'));
        return 0;
    }
}

and we will set it as a Service, changing our `./di.php` as follows:


<?php

declare(strict_types=1);

use App\LogDateTimeCommand;
use App\LoggerFactory;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;

return function(ContainerConfigurator $configurator) {
    $services = $configurator->services();
    $services->set(LoggerInterface::class, LoggerInterface::class)
        ->factory([LoggerFactory::class, 'create']);

    $services->set(LogDateTimeCommand::class, LogDateTimeCommand::class)->autowire()->public();
};

It is time to put them all together in action by modifying our `./app.php` accordingly:


<?php

declare(strict_types=1);

use App\LogDateTimeCommand;
use Psr\Container\ContainerInterface;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\Console\Application;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;

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

$serviceContainer = serviceContainer();

$application = new Application();
$application->setCatchExceptions(false);
$application->addCommands([
    $serviceContainer->get(LogDateTimeCommand::class)
]);

$application->run();

/**
 * @throws Exception
 */
function serviceContainer(): ContainerInterface
{
    $containerBuilder = new ContainerBuilder();
    $loader = new PhpFileLoader($containerBuilder, new FileLocator(__DIR__));
    $loader->load('./di.php');
    $containerBuilder->compile();
    return $containerBuilder;
}

Running `php app.php` you should get the following outcome:

Available commands:
  help          Displays help for a command
  list          Lists commands
 app
  app:log-time  Log date and time

and if you run `php app.php app:log-time`, you will get the date-time log in your standard error stream!

Conclusion

This tutorial is not so much about a production-ready application or about how to stop working with frameworks. It aims at showing the power of Composer, and, most importantly,about helping you think a bit out of the box.

Frameworks are helpful, and there is a very good reason why professionals and companies are using them; They are saving us time!

However, there are cases when you don’t need any framework at all. With Composer and some good coding practices, there is no limit in what you can achieve.

So, next time you will start working on a new project, think about it ;)