Modern PHP without frameworks

Original author: Kevin Smith
  • Transfer
  • Tutorial


I have a difficult task for you. The next time you start a new project, try to do without a PHP framework. I am not going to list the flaws of the frameworks, and this is not a manifestation of the syndrome of rejection of someone else’s development : in this guide we will use packages written by the developers of several frameworks. I fully respect innovation in this area.


But this article is not about them. She is about you. About the opportunity to become better as a developer.


Perhaps the main advantage of abandoning the framework will be knowing how everything works under the hood. You will see what happens without relying on a framework that cares about you so much that you cannot debug something or fully understand it.


Perhaps your next job will not allow you to enjoy the launch of a new project without a framework. Many important business-critical PHP tasks involve using existing applications. It doesn’t matter if this application is built on a modern framework like Laravel or Symfony, on one of the old platforms like CodeIgniter or FuelPHP - or it’s a depressingly widespread Legacy PHP application with an “include-oriented architecture” : if you are developing without a framework now , then you will be better prepared for any future PHP project.


They used to try to create without frameworks because some systems are forced to interpret and route HTTP requests, send HTTP responses and manage dependencies. Lack of standards inevitably led to the fact that at least these components of the frameworks were closely interlinked. So if you started developing a project without a framework, then in the end you came to the creation of your own framework.


But today, thanks to the efforts of PHP-FIG in the field of startup and mutual compatibility, you can develop without a framework, without creating it along the way. There are many great, mutually compatible packages written by numerous developers. And to assemble them into a single system is much easier than you think!


How does PHP work?


First of all, it’s important to understand how PHP applications interact with the outside world.


PHP executes server applications in a request / response cycle. All interaction with the application - from the browser, command line or REST API - comes into it as requests. Upon receipt of the request, the application loads, processes the request and generates a response that is sent back to the client, and the application closes. And this happens with every treatment.


Query controller


Armed with this knowledge, let's start with the front controller. It is a PHP file that processes all requests to your application. That is, this is the first PHP file into which the request falls, and (in fact) the last PHP file through which the application response passes.


Let's use the classic Hello, world ! Example served by the PHP built-in web server to see if everything is configured correctly. If you have not already done so, then make sure that PHP 7.1 or higher is installed in your environment.


Let's create a project directory, in it we will make a nested directory public , and inside it is a file index.php with the following code:


<?php
declare(strict_types=1);

echo 'Hello, world!';

Please note, here we declare strict typing - this should be done at the beginning of each PHP file of your application - because type hinting is important for debugging and clear understanding by those who will deal with the code after you.


Next, using the command line tool (like Terminal on MacOS), we will go to the project directory and launch the web server built into PHP.


php -S localhost:8080 -t public/

Now open the browser address http: // localhost: 8080 / . Showing Hello, world ! no mistakes?


Fine. Go to the next step!


Startup and third-party packages


Когда вы впервые начали работать с the PHP , то, вероятно, использовали выражения include или require для получения функциональности или конфигураций из других PHP-файлов. In general, this is best avoided, because then it will be much more difficult for other people to understand the code and understand where the dependencies are. This makes debugging a nightmare .


Exit - autoload. This means that when your application needs to use some class, PHP knows where to find it and automatically loads at the time of the call. This feature has existed since the days of PHP 5, but it began to be actively used only with the advent of PSR-0 (standard startup, today replaced PSR-4 ).


It would be possible to go through the burden of writing your own autoloader, but since we chose Composer to manage third-party dependencies, and it already has a very convenient autoloader, we will use it.


Verify that you have Composer installed . Then configure it for your project.


composer init

After that, go through the online guide for generating the configuration file composer.json . Then open it in the editor and add a field autoload to get it as shown below (then the autoloader will know where to look for your classes).


{
    "name": "kevinsmith/no-framework",
    "description": "An example of a modern PHP application bootstrapped without a framework.",
    "type": "project",
    "require": {},
    "autoload": {
        "psr-4": {
            "ExampleApp\\": "src/"
        }
    }
}

Now install Composer for this project, which will pull up all the dependencies (if they already exist) and configure the autoloader for us.


composer install

Update public/index.php to start the autoloader. Ideally, this is one of several include expressions that you use in your application.


<?php
declare(strict_types=1);

require_once dirname(__DIR__) . '/vendor/autoload.php';

echo 'Hello, world!';

If you reload the application in the browser, you will not see any difference. However, the autoloader works, it just does not do anything heavy. Let's take an example from Hello, world! in an auto-loading class to check how everything works.


In the root of the project, create a folder src and insert a file HelloWorld.php with the following code src into it HelloWorld.php :


<?php
declare(strict_types=1);

namespace ExampleApp;

class HelloWorld
{
    public function announce(): void
    {
        echo 'Hello, autoloaded world!';
    }
}

Now, public/index.php replace the echo expression by calling the announce method in the class HelloWorld .


// ...

require_once dirname(__DIR__) . '/vendor/autoload.php';

$helloWorld = new \ExampleApp\HelloWorld();
$helloWorld->announce();

Restart the application in your browser and see a new message!


What is dependency injection?


Dependency injection is a technique in which each dependency is provided to the object that needs it, instead of the object turning outside for some kind of information or functionality.


Suppose a class method needs to be read from a database. To do this, you need to connect to it. Typically, a new connection is created with credentials obtained from global space.


class AwesomeClass
{
    public function doSomethingAwesome()
    {
        $dbConnection = return new \PDO(
            "{$_ENV['type']}:host={$_ENV['host']};dbname={$_ENV['name']}",
            $_ENV['user'],
            $_ENV['pass']
        );

        // Make magic happen with $dbConnection
    }
}

But this is not the best solution. The alien method is responsible for creating an object for a new connection to the database, obtaining credentials, and processing any problems in the event of a connection failure. As a result, the code is duplicated in the application. And if you try to run this class through unit testing, you cannot. The class is closely interconnected with the application environment and the database.


Let's not complicate the work from what the class needs from the very beginning. We just first require that the PDO be embedded in the class.


class AwesomeClass
{
    private $dbConnection;

    public function __construct(\PDO $dbConnection)
    {
        $this->dbConnection = $dbConnection;
    }

    public function doSomethingAwesome()
    {        
        // Make magic happen with $this->dbConnection
    }
}

It turned out much cleaner and easier to understand, less likely to make mistakes. Thanks to the type hint and dependency injection, the method announces exactly what it needs to complete the task, and gets what it needs without invoking an external dependency. And when it comes to unit testing, we will be ready to model a connection to the database and calmly pass the test.


A dependency injection container is a tool in which you wrap your entire application in order to create and implement these dependencies. A container is not necessary, but it makes life much easier as your application grows and becomes more complex.


We will use the most popular DI container for PHP with the inventive name PHP-DI . (It should be noted that in his documentation the implementation of dependencies is described differently, and to someone it will be more clear.)


Dependency Injection Container


Since we set up Composer, installing PHP-DI will be almost painless. To do this, we again turn to the command line:


composer require php-di/php-di

Upgrade public/index.php to configure and build the container.


// ...

require_once dirname(__DIR__) . '/vendor/autoload.php';

$containerBuilder = new \DI\ContainerBuilder();
$containerBuilder->useAutowiring(false);
$containerBuilder->useAnnotations(false);
$containerBuilder->addDefinitions([
    \ExampleApp\HelloWorld::class => \DI\create(\ExampleApp\HelloWorld::class)
]);

$container = $containerBuilder->build();

$helloWorld = $container->get(\ExampleApp\HelloWorld::class);
$helloWorld->announce();

Nothing special has happened yet. This is just a simple example where everything you need is placed in one file for easy observation.


We configure the container , so we need to explicitly declare the dependencies (and not use automatic injection or annotations ) and extract the object from the container HelloWorld .


Marginal note: the automatic implementation of dependencies can be a useful feature at the beginning of the creation of the application, but in the future it complicates the maintenance, since the dependencies remain relatively hidden. In addition, it is possible that in a few years another developer will connect some library, and as a result several libraries will implement one interface. This will break the automatic dependency injection and lead to an unpredictable stream of bugs. The developer who made the change may not notice them at all.


Let's simplify things even further by importing namespaces where possible.


<?php
declare(strict_types=1);

use DI\ContainerBuilder;
use ExampleApp\HelloWorld;
use function DI\create;

require_once dirname(__DIR__) . '/vendor/autoload.php';

$containerBuilder = new ContainerBuilder();
$containerBuilder->useAutowiring(false);
$containerBuilder->useAnnotations(false);
$containerBuilder->addDefinitions([
    HelloWorld::class => create(HelloWorld::class)
]);

$container = $containerBuilder->build();

$helloWorld = $container->get(HelloWorld::class);
$helloWorld->announce();

So far, everything looks as if we made a fuss for the sake of fulfilling what we have already done before.


Don’t worry, the container will come in handy when we add a few other tools to help transfer requests directly through the application. These tools will use the container to load the correct classes as needed.


https://kevinsmith.io/modern-php-without-a-framework-middleware


Middleware


If you imagine an application in the form of a bulb in which requests go outside to the center, and the answers are in the opposite direction, then middleware is each layer of the bulb that receives requests, probably does something with the answers and passes them to the bottom layer or generates an answer and sends to the top layer. This happens if the middle layer checks requests for compliance with some conditions like requesting a nonexistent path.


If the request passes to the end, the application will process it and turn it into a response. After that, each intermediate layer will receive a response in the reverse order, possibly modify it and pass it on to the next layer.


Options for using intermediate layers:


  • Debugging development issues.
  • Gradual exception handling in production.
  • Limiting the frequency of incoming requests.
  • Responses to requests from unsupported media types.
  • CORS processing.
  • Routing requests to appropriate processing classes.

Is an intermediate layer the only way to implement tools to handle all of these situations? Not at all. But middleware implementations make the request / response cycle much clearer, which greatly simplifies debugging and speeds up development.


We will use an intermediate layer for the last scenario: routing.


Routing


The router uses the information from the query to see which class should handle it (for example, the URI /products/purple-dress/medium should be handled by a class ProductDetails::class with passed as arguments purple-dress and medium ).


Our application will use the popular FastRoute router through the implementation of an intermediate layer compatible with the PSR-15 .


Middleware manager


For our application to work with some kind of intermediate layer, we need a dispatcher.


PSR-15 is a standard that defines interfaces for middleware and dispatchers (they are called “request handlers” in the specification), ensuring interoperability between a wide range of solutions. We just need to choose a manager compatible with the PSR-15, and it will work with any compatible middleware.


As a manager, install Relay .


composer require relay/relay:2.x@dev

And since the PSR-15 specification implies that the middleware implementation sends HTTP messages that are compatible with the PSR-7 , we will use Zend Diactoros .


composer require zendframework/zend-diactoros

Prepare Relay to receive intermediate layers.


// ...

use DI\ContainerBuilder;
use ExampleApp\HelloWorld;
use Relay\Relay;
use Zend\Diactoros\ServerRequestFactory;
use function DI\create;

// ...

$container = $containerBuilder->build();

$middlewareQueue = [];

$requestHandler = new Relay($middlewareQueue);
$requestHandler->handle(ServerRequestFactory::fromGlobals());

In line 16, we ServerRequestFactory::fromGlobals() will use the help ServerRequestFactory::fromGlobals() to collect all the information necessary to create a new request and transfer it Relay . Here the request is pushed onto the stack of intermediate layers.


Now add FastRoute a request handler (it FastRoute determines whether the request is valid and whether it can be processed by our application, and the request handler passes the request to the handler that is configured for this route).


composer require middlewares/fast-route middlewares/request-handler

Now, let's define a route for the handler class Hello, world ! .. Here we use a route /hello to demonstrate the ability to use a route that is different from the base URI.


// ...

use DI\ContainerBuilder;
use ExampleApp\HelloWorld;
use FastRoute\RouteCollector;
use Middlewares\FastRoute;
use Middlewares\RequestHandler;
use Relay\Relay;
use Zend\Diactoros\ServerRequestFactory;
use function DI\create;
use function FastRoute\simpleDispatcher;

// ...

$container = $containerBuilder->build();

$routes = simpleDispatcher(function (RouteCollector $r) {
    $r->get('/hello', HelloWorld::class);
});

$middlewareQueue[] = new FastRoute($routes);
$middlewareQueue[] = new RequestHandler();

$requestHandler = new Relay($middlewareQueue);
$requestHandler->handle(ServerRequestFactory::fromGlobals());

In order for everything to work, you need to update HelloWorld it by making it a callable class, that is, so that this class can be called as a function .


// ...

class HelloWorld
{
    public function __invoke(): void
    {
        echo 'Hello, autoloaded world!';
        exit;
    }
}

Pay attention to what is added exit; in the magic method __invoke() . Soon you will understand what it is for.


Now open http: // localhost: 8080 / hello and enjoy your success!


The glue that holds everything together


An astute reader will notice that the DI container, in spite of all the difficulties of its configuration and assembly, actually does nothing. Dispatcher and middleware can work without a container.


So why is it needed?


But what if - as it almost always happens in real applications - does the class HelloWorld have a dependency?


Let's add it and see what happens.


// ...

class HelloWorld
{
    private $foo;

    public function __construct(string $foo)
    {
        $this->foo = $foo;
    }

    public function __invoke(): void
    {
        echo "Hello, {$this->foo} world!";
        exit;
    }
}

Reboot the browser, and ...


Oh.


We see ArgumentCountError .


This is because for functioning HelloWorld it is required to introduce a string value during its creation, and in our country it hung in the air. And here the container comes to the rescue.


Let's define the dependency in the container and pass it in RequestHandler for permission .


// ...

use Zend\Diactoros\ServerRequestFactory;
use function DI\create;
use function DI\get;
use function FastRoute\simpleDispatcher;

// ...

$containerBuilder->addDefinitions([
    HelloWorld::class => create(HelloWorld::class)
        ->constructor(get('Foo')),
    'Foo' => 'bar'
]);

$container = $containerBuilder->build();

// ...

$middlewareQueue[] = new FastRoute($routes);
$middlewareQueue[] = new RequestHandler($container);

$requestHandler = new Relay($middlewareQueue);
$requestHandler->handle(ServerRequestFactory::fromGlobals());

Voila! When you restart your browser, you should see Hello, bar world! .


Correct sending of answers


Remember I mentioned the expression exit in HelloWorld ?


This is an easy way to make sure we get a simple answer, but still, this is not the best way to send output to the browser. Such a rude approach forces HelloWorld you to HelloWorld do the extra work of reporting - and this should be done by another class - which makes sending headers and status codes HelloWorld too complicated and also leads to the application being closed, giving no chance of middleware coming after HelloWorld .


Remember that each intermediate layer has the ability to modify the request along the path to the application, as well as (in the reverse order) modify the response along the path from the application. In addition to the standard interface to Request PSR-7 determine the structure of another HTTP-message which will be useful for us to cycle on the back of the branch: Response . If you want, you can read more about HTTP messages and the good PSR-7 Request and Response standards .


Update HelloWorld for return Response .


// ...

namespace ExampleApp;

use Psr\Http\Message\ResponseInterface;

class HelloWorld
{
    private $foo;

    private $response;

    public function __construct(
        string $foo,
        ResponseInterface $response
    ) {
        $this->foo = $foo;
        $this->response = $response;
    }

    public function __invoke(): ResponseInterface
    {
        $response = $this->response->withHeader('Content-Type', 'text/html');
        $response->getBody()
            ->write("<html><head></head><body>Hello, {$this->foo} world!</body></html>");

        return $response;
    }
}

Update the container definition so that it is HelloWorld provided with a fresh object Response .


// ...

use Middlewares\RequestHandler;
use Relay\Relay;
use Zend\Diactoros\Response;
use Zend\Diactoros\ServerRequestFactory;
use function DI\create;

// ...

$containerBuilder->addDefinitions([
    HelloWorld::class => create(HelloWorld::class)
        ->constructor(get('Foo'), get('Response')),
    'Foo' => 'bar',
    'Response' => function() {
        return new Response();
    },
]);

$container = $containerBuilder->build();

// ...

If we refresh the page now, we get a blank screen. The application returns the correct object from the middleware manager Response , and then ... what?


It just doesn't do anything to him.


We need another tool: an emitter. It is located between the application and the web server (Apache, nginx, etc.) and sends your response to the client that generated the request. The emitter simply takes a Response object and translates it into instructions that are understandable by the server API .


Good news! The package Zend Diactoros we are already using to manage requests includes an emitter for PSR-7 responses.


For simplicity, we use a very simple emitter here. Although it can be much more complicated, in the case of large downloads, the real application must be configured to automatically use the streaming emitter. This is well described on the Zend blog .


Update public/index.php to receive Response from the dispatcher and transfer to the emitter.


// ...

use Relay\Relay;
use Zend\Diactoros\Response;
use Zend\Diactoros\Response\SapiEmitter;
use Zend\Diactoros\ServerRequestFactory;
use function DI\create;

// ...

$requestHandler = new Relay($middlewareQueue);
$response = $requestHandler->handle(ServerRequestFactory::fromGlobals());

$emitter = new SapiEmitter();
return $emitter->emit($response);

Reload the page - we're back in business! The time has come for more reliable response processing.


Line 15 ends the request / response cycle and the web server enters.


Completion


Using 44 lines of code and several widely used, thoroughly tested, reliable, interacting components, we implemented a program of a bootstrap modern PHP application. It is compatible with the PSR-4 , PSR-7 , PSR-11, and PSR-15 standards , so a wide range of implementations of HTTP messages, DI containers, middleware, and dispatchers are available to you.


We delved into some technologies and arguments, but I hope you can see the simplicity of the bootstrap program for a new application without the accompanying junk framework. I also hope that you are now better prepared to use these technologies in existing applications.


The application used in the article is in the repository , you can freely fork and download.


If you want to read about the quality of unrelated packages, then I highly recommend to get acquainted with the Aura , of The League of Extraordinary the Packages , components of the Symfony , the components of the Zend Framework , sharpened under the security libraries Paragon Initiative and a list of compatible with PSR-15 middlleware.


If you use this code in production , then you will probably need to put the routes and container definitions into separate files so that it is easier to accompany them as the project becomes more complex. I also recommend implementing EmitterStack for smart processing of file downloads and other great answers.