Upgrade Slimframework v3 to v4, how I did it

Here is an article that details my path to upgrade to Slimframework v3 to v4.

I'm developing on my freetime an application that manage the fundraising of the French Red Cross, and I'm updating a lot of frameworks to their last version (AngularJS to Angular 8/9 is my big next move).

You can find the project sources here : https://github.com/dev-mansonthomas/RedCrossQuest

There’s quite a lot of breaking changes involved, feature removed in profit to external frameworks, the upgrade documentation is quite minimal, but you must read it to understand what's changing :


This article is not well redacted, it’s just a log of what I did to achieve the upgrade, but I feel it may help some, so here we are ;)

What I've read to perform the upgrade

I’ve read theses article to help the transition to the 4.x 

The documentation : 

And also this slimV4 skeleton project which helped a lot

The idea of moving form callback to callable class came from here, and it’s quite a good move for the projet.

Here is a list of middleware that may help you :

The document of PHP DI (dependency injection), helped as well.

In addition to upgrading I've made other changes


I’ve also changed my route to handle specific actions.
For exemple, I had multiple way to research a list of object and used a query param “action” to specify with kind of search.

Instead I’ve appened the value of the action to the URI

Ex:

This will allow to properly document the REST api with swagger.

Surprisingly, it went quite well, I’ve changed quite a lot of code, made a few tries, fixed some errors and it worked.

Here are the details

Update your composer file


#Upgrade to latest version of slim
composer require slim/slim "^4.3.0"
#add the now missing PSR7 (Provide Request & Response object in routes)
#I use the slim one hoping to have a better compatibility 
composer require slim/psr7

# add the now missing dependency injection feature
composer require php-di/php-di

Dependency Injection initialisation and application settings


#Add the Dependencies injection & initialise settings
/public/rest/index.php

use DI\ContainerBuilder;

// Instantiate PHP-DI ContainerBuilder
$containerBuilder = new ContainerBuilder();

if (false) { // Should be set to true in production
 //TODO: GAE check which dir is writable
 $containerBuilder->enableCompilation(__DIR__ . '/../var/cache');
}

// Set up settings
$settings = require __DIR__ . '/../../src/settings.php';
$settings($containerBuilder);


Then convert the settings.php file to integrate with the container.

From : 
return [ 
'settings' => [ … ]
];

To 

<?php
declare(strict_types=1);

use DI\ContainerBuilder;
use Monolog\Logger;

return function (ContainerBuilder $containerBuilder) {
    // Global Settings Object
    $containerBuilder->addDefinitions([
        'settings' => [ … ],
    ]);
};

Copy the code above and replace [ … ] with your settings.

Convert the dependencies



Back to index.php, 

Setup Dependencies : 

// Set up dependencies
$dependencies = require __DIR__ . '/../../src/dependencies.php';
$dependencies($containerBuilder);

Convert dependencies.php to integrate with the container:

Add this to the beginning of the file
declare(strict_types=1);

Then these :

use DI\ContainerBuilder;
use Psr\Container\ContainerInterface;


The file must return a function that takes the container in parameter, and it’s being called in index.php with this code: $dependencies($containerBuilder);

return function (ContainerBuilder $containerBuilder)
{
 $containerBuilder->addDefinitions([
]);
};

For each dependency you have, you must add in the array passed in parameter to addDefinitions(), a function definition as follows: 

 ReturnObjectClass::class => function (ContainerInterface $c) {
   $settings = $c->get('settings');

   //Instanciate your dependency

   return $instanceOfDependency;
 },

It’s built as an associative array, you can pass either the type of the object or a string. 
The left part must be unique in the array
As it’s a function (callback function), the code is executed only when it’s needed.

Normally, you can copy/paste your code with little modification.
Keep the name from your original code so that you can refactor the rest of you code more easily.

Example : 

/**
*
* pecl install grpc
* pecl install protobuf
*
* @property PsrLogger    $logger
* @param \Slim\Container $c
* @return PsrLogger
*/
$container['googleLogger'] = function (\Slim\Container $c)
{
 $settings = $c->get('settings')['logger'];

 $logger = LoggingClient::psrBatchLogger(
   $settings['name'], [
   'resource'=>[
     'type'=>'gae_app'
   ],
   'labels'  =>null
 ]);


 return $logger;
};

To this : 

return function (ContainerBuilder $containerBuilder)
{
 $containerBuilder->addDefinitions([

   /**
    * Logger 'googleLogger'
    * pecl install grpc
    * pecl install protobuf
    *
    */

   LoggerInterface::class => function (ContainerInterface $c) {
     $settings = $c->get('settings')['logger'];

     $logger = LoggingClient::psrBatchLogger(
       $settings['name'], [
       'resource'=>[
         'type'=>'gae_app'
       ],
       'labels'  =>null
     ]);

     return $logger;
   },
  
   //Next dependency
  
 ]);
};

For the return type, try to get the interface of the returned class if it exists.
Highlighted in yellow, the name of the dependency, keep it in the comments so that it’s easier to refactor

Instantiate Slim App



After having converted your dependencies files, you can now instantiate the Slim App

// Set up dependencies
$dependencies = require __DIR__ . '/../../src/dependencies.php';
$dependencies($containerBuilder);

// Build PHP-DI Container instance
$container = $containerBuilder->build();

// Instantiate the app
AppFactory::setContainer($container);
$app = AppFactory::create();


Update the Middleware

My Middleware class is the AuthenticationMiddleware that manage authentication  and Authorisation with a JWT

<?php
require '../../vendor/autoload.php';
use \RedCrossQuest\Middleware\AuthorisationMiddleware;
$app->add( new AuthorisationMiddleware($app) );

To

<?php
declare(strict_types=1);
require '../../vendor/autoload.php';

use Slim\App;
use \RedCrossQuest\Middleware\AuthorisationMiddleware;

return function (App $app) {
 $app->add(AuthorisationMiddleware::class);
};

The middleware must implement  Psr\Http\Server\MiddlewareInterface
And the signature of the main method changed from

public function __invoke(ServerRequestInterface $request, ResponseInterface $response, callable $next)

To
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface



On your middleware class :

Add this : 

use Psr\Http\Server\MiddlewareInterface;

class AuthorisationMiddleware implements MiddlewareInterface


Update the __invoke methode to the “process” signature

return $next($request, $response);

Should be change to

return $handler->handle($request)

For denying a request : 


$response500 = $response->withStatus(500);
return $response500;

Change it to : 

$response500 = (new Response())->withStatus(500);
return $response500;

I used to pass the $app to the constructor of the Middleware.
What you set as constructor parameter must be resolvable by the DI Container.
I replace $app by the container itself and then lookup the objects I needed (logger & settings) (likely I can pass directly the logger and setting in the constructor, but I’ll this see later)

Update the Routes


Add this after the middleware and replace your existing route import

// Register routes
$routes = require __DIR__ . '/../app/routes.php';
$routes($app);


In my routes.php, I’ve multiple includes

include_once("../../src/routes/00-authentication.php");
include_once("../../src/routes/01-troncs.php");
include_once("../../src/routes/02-troncs-queteurs.php");

In each files, I’ve routes like this, not enclosed in any function or classes.

$app->get(getPrefix().'/{role-id:[1-9]}/ul/{ul-id}/troncs', function ($request, $response, $args)

I’m just adding around routes:

return function (App $app) {

include_once("../../src/routes/00-authentication.php");
include_once("../../src/routes/01-troncs.php");
include_once("../../src/routes/02-troncs-queteurs.php");

};


Add the following at the end of your index.php, you’ll put the Exception handler just after:

// Add Routing Middleware
$app->addRoutingMiddleware();
$app->addBodyParsingMiddleware(); //otherwise I found that $request->getBody() is null

Routes : Rework from callback to callable classe

That's where I spent most of my time migrating my code, but for the benefit of the code maintainability.
I was using PHP closure to set the code that will execute for each actions :

$app->get(getPrefix().'/{role-id:[1-9]}/ul/{ul-id}/troncs', function ($request, $response, $args)

The problem is that you can’t properly use the Dependency Injection mechanism. ($di->get(‘’) is an anti-pattern). Also closure perform less than the following solution.


So instead of passing a callback function ($request, $response, $args)
I’ll pass a Callable class, which is a class that implement an __invoke() method.

In our case, the signature must be : 
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;

public function __invoke(Request $request, Response $response, $args): Response

The good thing with a Callable class is that you can factorise more code, less boiler plate code etc…

  1. Extracting data from the request
  2. Validate input data and set it as an object property
  3. Better error logging with full log + input data + action class name

This done by extending an Abstract class named “Action” : 

The __invoke() method will extract usual data and set it as object property, call the abstract “action()” method within a try catch block with a proper error handling.

In my code I was already using the Symphony Validator to check input data to prevent hacking attemtps.
I’ve improved the code so that I pass a list of validation specifications, call the “validateSentData“ method that execute all the validations and set the validated data in the $this->validatedData object property.
If a validation fails, an exception is thrown, and logged by the __invoke method.
If the code within the action fails (ex: DB constraints fails), the __invoke method logs the input data.
This way the logging is consistent accross all the code, and easier to filter within StackDriver (or Elastic).

On the logging perspective, I also implemented a LoggerInterface class on top of Google StackDriver logger

It adds to every logging the following informations : 
  • Backend version & Environment
  • Info from the JWT token which include userId,username, internal entity id, roleId.
  • Extra information

This is added with a call to this function at the beginning of each route action method :
Logger::dataForLogging(new LoggingEntity($this->decodedToken), … ExtraInfo here ...);

This way I can filter on a specific user or entity and see only those logs. It’s useful to debug an issue in production with a user that complains about some error.




Exception Handler

I’ve added the following to the end of the index.php file :

$errorMiddleware = $app->addErrorMiddleware(true, true, true);
$errorMiddleware->setDefaultErrorHandler($customErrorHandler);

And I moved the errorHandler in my dependencies.php to a specific file
(the code should be improved to take into account the parameters)


Now, load the app and start debugging.
Use Chrome developer tools or firebug to capture http response;

Here are some of the thing I had to deal with : 
  • Add setBasePath(“/rest”); in index.php and review how I was dealing with path (with a difficulty on top of that, is that local dev and Google App Engine Standard do not behave the same for some reason)

Comments

Unknown said…
Was it worth upgrading.- I built a small app with slim 3 and loved how easy it was to build, Slim 4 seems to add a lot of complication on the surface so is it worth it?
Manson Thomas said…
Yes, this brings some new capabilities, and you get security updates that are a must have!

It's not that difficult to upgrade, just branch your code (or make a copy) and work on that branch/copy. If you're stuck, your app still works.

Popular posts from this blog

Reset Bacula database and files

Limit the upload bandwidth of your apache webserver with mod_bw