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
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
$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…
- Extracting data from the request
- Validate input data and set it as an object property
- 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
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.