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!
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"
}
}
}
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
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/"
}
}
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();
};
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']);
};
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!
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 ;)