BreadcrumbHomeResourcesBlog Using Mezzio On IBM I October 20, 2022 Using Mezzio on IBM iIBM iBy Yeshua HallUsing Mezzio on IBM i can be simple. This guide walks through how to build a PHP application on IBM i using Mezzio, LaminasDB, Mezzio-Hal, Bootstrap, DataTables, and SweetAlert 2Table of ContentsBefore You Get StartedGetting Started with Mezzio SkeletonTableGateway, the Cornerstone of Data-Driven ApplicationsBuilding a CLI to initiate example dataWriting Plant API RoutesExtending the App User InterfaceWrapping UpTable of Contents1 - Before You Get Started2 - Getting Started with Mezzio Skeleton3 - TableGateway, the Cornerstone of Data-Driven Applications4 - Building a CLI to initiate example data5 - Writing Plant API Routes6 - Extending the App User Interface7 - Wrapping UpBack to topBefore You Get StartedThis article uses the following technologies to explain building an example PHP application on IBM i:Mezzio, a backend PHP framework;Laminas-Db, a database layer to use with IBM i Db2;Mezzio-Hal for Hal-compliant RESTful responses;Bootstrap, DataTables, and SweetAlert2 for the frontend.The completed example has a RESTful API, a CLI for seeding Db2 with example data, a frontend built in Plates templates, and Javascript AJAX calls for a fast and interactive user experience.Server RequirementsIBM iIBM i Open Source Tooling w/ BASH & SSH working (optional, highly recommended)PHP v7.4+Composer for PHP package managementAll commands will be run on IBM i to ensure compatibility and communication with IBM i Db2 data.Completed ExampleThe application we build in this article already exists and can be found in the ibmi-oss-examples repository on GitHub.Back to topGetting Started with Mezzio SkeletonThe Mezzio Skeleton provides a convenient way of starting application development with Mezzio. Using composer, one may simply run:$ composer create-project mezzio/mezzio-skeleton <project-name>where <project-name> is the name of your application, to start a new Mezzio development project.The command above will prompt you to choose:Whether to install a minimal skeleton (no default middleware), a flat application structure (all code under src/), or a modular structure (directories under src/ are modules, each with source code and potentially templates, configuration, assets, etc.). In this example, we use the modular structure.A dependency injection container. We use the default, laminas-servicemanager, in this example.A router. We use the default, FastRoute, in this example.A template renderer. We use Plates in this example.An error handler. Whoops is a very nice option for development, as it gives you extensive, browseable information for exceptions and errors raised. We use Whoops in this example.Once the skeleton generation finishes successsfully, you should have a new directory named <project-name> in your current directory.You can go to this directory and start PHP's built-in web server to verify the installation:$ cd <project-name> $ composer serveand then browse to http://IBM-i-URI:8080 to view the skeleton's landing page.Development ModeTo disable caching while developing, ensure development-mode is enabled:$ composer development-enableRequire Packages With ComposerThis project requires a few extra packages, like laminas-db, mezzio-hal, and others. Install them using composer:$ composer require laminas/laminas-db laminas/laminas-hydrator laminas/laminas-inputfilter laminas/laminas-paginator laminas/laminas-paginator-adapter-laminasdb laminas/laminas-serializer mezzio/mezzio-hal mezzio/mezzio-problem-details sobored/mezzio-rest-helpers $ composer require --dev laminas/laminas-cliScaffoldingMezzio tooling and Laminas CLI come with convenient scaffolding commands for generating modules, handlers, and other components with ease. Once generated, one can fill in the contents with proper logic.We'll use this tooling to create a couple modules and a few handlers to get us started. Inside your project's directory, run:$ ./vendor/bin/laminas mezzio:module:create Rest $ ./vendor/bin/laminas mezzio:module:create Plant $ ./vendor/bin/laminas mezzio:handler:create "Plant\Handler\DeletePlantHandler" $ ./vendor/bin/laminas mezzio:handler:create "Plant\Handler\GetPlantHandler" $ ./vendor/bin/laminas mezzio:handler:create "Plant\Handler\ListPlantsHandler" $ ./vendor/bin/laminas mezzio:handler:create "Plant\Handler\SavePlantHandler" $ ./vendor/bin/laminas mezzio:handler:create "App\Handler\PlantPageHandler"These commands will generate modules and handlers for us under the src directory to use later, once we're ready to develop those features.The handler creation also creates a file, config/autoload/mezzio-tooling-factories.global.php, and fills it with our factory-handler relationships. These can be moved to our Plant module's ConfigProvider, but we'll leave them be for example.Configuring Our Db2 AdapterThrough composer, we've required laminas/laminas-db, which needs us to configure our database adapter. To do this, let's create a config file, config/autoload/db2.local.php, and define our Db2 connection:<?php declare(strict_types=1); return [ 'db' => [ 'database' => '*LOCAL', 'driver' => 'IbmDb2', 'driver_options' => [ ], 'username' => '<username>', 'password' => '<password>', 'platform' => 'IbmDb2', 'platform_options' => [ 'quote_identifiers' => false, ], ], ];Be sure to replace <username> and <password> with the proper username and password for your instance.Back to topTableGateway, the Cornerstone of Data-Driven ApplicationsUnless we want to spend a lot of time stubbing fake data, we should start with building our TableGateway for this application. TableGateways give us a convenient way to communicate with data from our database by abstracting the communication.This application uses one example table named Plants. Let's build our TableGateway for this table. We won't worry about it existing. In the next step, we'll build a CLI to initiate the table and fill it with example data.A TableGateway is simply a class, and it'll need a Factory for dependency injection. This is all we really need to know about a Factory; it provides a method for invoking our TableGateway with its dependencies, like the database adapter. We'll also need an Entity and a Collection.An Entity is an object-oriented representation of a single record; meaning: a PHP class which has properties that reflect the column names of a single record. A collection is even simpler. It's just a class to represent a collection of Entities, which gives us convenient methods to paginate through our Entities.With this in mind, let's create four new files:src/Plant/src/Entity/PlantEntity.phpsrc/Plant/src/Collection/PlantCollection.phpsrc/Plant/src/TableGateway/PlantTableGateway.phpsrc/Plant/src/TableGateway/PlantTableGatewayFactory.phpPlantEntityWe'll start the source code by creating our Entity, which will be our PHP object to represent our data:<?php // src/Plant/src/Entity/PlantEntity.php declare(strict_types=1); namespace Plant\Entity; class PlantEntity { public $id; public $name; public $nickname; public $wiki; public static function create( string $name, string $wiki, ?string $nickname = null, ?int $id = null ) : PlantEntity { $plant = new self(); $plant->exchangeArray([ 'id' => $id, 'name' => $name, 'nickname' => $nickname, 'wiki' => $wiki, ]); return $plant; } public static function createFromBody(array $body) : PlantEntity { $plant = new self(); $plant->exchangeArray($body); return $plant; } public function getArrayCopy() : array { return [ 'id' => $this->id, 'name' => $this->name, 'nickname' => $this->nickname, 'wiki' => $this->wiki, ]; } public function exchangeArray(array $plant) : void { $this->id = $plant['id'] ?? 0; $this->name = $plant['name']; $this->nickname = $plant['nickname'] ?? ''; $this->wiki = $plant['wiki'] ?? ''; } }Our Entity has two convenient Create methods for ease of initiating an Entity based on associated arrays or the body of an HTTP request. The methods getArrayCopy() and exchangeArray() are both required for hydration, which we'll cover later.PlantCollectionNext, we'll fill in our Collection, which is quick and simple:<?php // src/Plant/src/Collection/PlantCollection.php declare(strict_types=1); namespace Plant\Collection; use Laminas\Paginator\Paginator; class PlantCollection extends Paginator { }Not much to explain about the above. The Paginator class just gives us convenient methods for dynamically paginating our data later. Basically, one can essentially feed a select statement to a collection; define page parameters like current page, item count per page, and more; and get the relevant records back for the defined current pagePlantTableGatewayPlantTableGatewayFactoryNext, we should fill in our TableGateway's logic:<?php // src/Plant/src/TableGateway/PlantTableGateway.php declare(strict_types=1); namespace Plant\TableGateway; use Exception; use Laminas\Db\ResultSet\ResultSetInterface; use Laminas\Db\TableGateway\TableGateway; use Laminas\Paginator\Adapter\LaminasDb\DbSelect; use Plant\Entity\PlantEntity; class PlantTableGateway extends TableGateway { const SAVE_EXCEPTION_PREFIX = 'Save Plant Error: '; // Ensure column names are lowercase protected $columns = [ '"id"' => 'id', '"name"' => 'name', '"nickname"' => 'nickname', '"wiki"' => 'wiki', ]; public function get(int $id) : ResultSetInterface { return $this->select(['id' => $id]); } public function getForPagination(string $orderBy = '', string $order = '') : DbSelect { $select = $this->getSql()->select(); $select->columns($this->columns); $select->order("{$orderBy} {$order}"); return new DbSelect($select, $this->getSql(), $this->getResultSetPrototype()); } /** * @throws Exception */ public function save(PlantEntity $plant) : int { $id = $plant->id; $data = $plant->getArrayCopy(); if (empty($id)) { unset($data['id']); try { $this->insert($data); } catch (Exception $e) { throw new Exception(self::SAVE_EXCEPTION_PREFIX . $e->getMessage()); } return (int) $this->getLastInsertValue(); } if (! $this->get($id)->current() instanceof PlantEntity) { $message = self::SAVE_EXCEPTION_PREFIX . "Cannot update plant with identifier $id; does not exist"; throw new Exception($message); } try { $this->update($data, ['id' => $id]); } catch (Exception $e) { throw new Exception(self::SAVE_EXCEPTION_PREFIX . $e->getMessage()); } return $id; } }In the above class, we define a few key methods; one for getting a single record, one for many, and another for saving a plant record. Note that the columns are defined as a protected class property, which tells the TableGateway exactly which columns we want to return with defined column aliases, which we also ensure are lowercase by double-quoting them.The save method will either create a new record or update an existing one depending on whether the plant entered has an existing ID or not.PlantTableGatewayFactoryFinally, we need to fill in our Factory with dependency injection and define their relationship in our module's Config Provider:<?php // src/Plant/src/TableGateway/PlantTableGatewayFactory.php declare(strict_types=1); namespace Plant\TableGateway; use Laminas\Db\Adapter\AdapterInterface; use Laminas\Db\ResultSet\HydratingResultSet; use Laminas\Hydrator\ArraySerializableHydrator; use Plant\Entity\PlantEntity; use Psr\Container\ContainerInterface; class PlantTableGatewayFactory { public function __invoke(ContainerInterface $container) : PlantTableGateway { return new PlantTableGateway( 'plants', $container->get(AdapterInterface::class), null, $this->getResultSetPrototype($container) ); } private function getResultSetPrototype(ContainerInterface $container) : HydratingResultSet { $hydrators = $container->get('HydratorManager'); $hydrator = $hydrators->get(ArraySerializableHydrator::class); return new HydratingResultSet($hydrator, new PlantEntity()); } }In the above, we pass the table name, Db2 adapter, and a way to hydrate our data. Hydrating data basically means to return an object (PlantEntity in this case) instead of returning an associative array.This is the only place we define and use our table name. It uses our library in this case, and the table usually ends up in your user's personal schema, for example: bob.plants. If you'd like to tie it to a specific library, simply define it in this factory. Pass 'schema_name.plants' instead of just 'plants'.The only thing left is to define our Factory's relation in the Plant Config Provider:<?php // src/Plant/src/ConfigProvider.php declare(strict_types=1); namespace Plant; class ConfigProvider { // ... public function getDependencies() : array { return [ 'invokables' => [ ], 'factories' => [ TableGateway\PlantTableGateway::class => TableGateway\PlantTableGatewayFactory::class, ], ]; } // ... }In the next step, we'll be putting all of the above through a test by utilizing our TableGateway in a command line interface.Back to topBuilding a CLI to initiate example dataIn this example application, we're initiating an example table with data to ensure it can be demoed on any IBM i. For ultimate convenience, we'll build a command under our Plant module.To do this, we'll need a Command class and a factory for dependency injection:src/Plant/src/Command/SetupDataCommand.phpsrc/Plant/src/Command/SetupDataCommandFactory.phpSetupDataCommandFactoryThe logic for our command is pretty straightforward, especially using our Entity and TableGateway. The most complex part concerns input from the user:<?php // src/Plant/src/Command/SetupDataCommand.php declare(strict_types=1); namespace Plant\Command; use Exception; use Laminas\Db\Adapter\Driver\ConnectionInterface; use Plant\Entity\PlantEntity; use Plant\TableGateway\PlantTableGateway; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Question\ConfirmationQuestion; use Symfony\Component\Console\Style\SymfonyStyle; class SetupDataCommand extends Command { private $plantModel; protected static $defaultName = 'plant:setup-data'; protected static $defaultDescription = 'Setup example data for plant module.'; public function __construct(PlantTableGateway $plantModel, string $name = null) { $this->plantModel = $plantModel; parent::__construct($name); } protected function execute(InputInterface $input, OutputInterface $output) : int { $io = new SymfonyStyle($input, $output); $overwrite = $initialize = true; $data = []; $db2Conn = $this->plantModel->getAdapter()->getDriver()->getConnection(); $result = $db2Conn->execute("SELECT COUNT(*) as NUM FROM qsys2.tables WHERE table_name = 'PLANTS'"); $count = $result->current(); $tableExists = !empty($count) && !empty($count['NUM']); if ($tableExists) { $confirmOverwrite = new ConfirmationQuestion('The example table, PLANTS, already exists. Would you like to overwrite it?', false); $overwrite = $io->askQuestion($confirmOverwrite); if ($overwrite) { $dropPlants = "DROP TABLE plants"; $db2Conn->execute($dropPlants); } else { $confirmInitialize = new ConfirmationQuestion('Would you like to add example data to the existing table?', false); $initialize = $io->askQuestion($confirmInitialize); } } if (!$tableExists || $overwrite) { try { $this->createTable($db2Conn, $io); } catch (Exception $e) { return $this->error($io, 'Error while creating table: ' . $e->getMessage()); } } if ($initialize) { try { $data = $this->initializeData($io); } catch (Exception $e) { return $this->error($io, 'Error while initializing records: ' . $e->getMessage()); } } if (!empty($data)) { $this->showData($data, $io); } return 0; } private function createTable(ConnectionInterface $db2Conn, SymfonyStyle $io) : void { $createPlants = <<<SQL CREATE TABLE plants( id INTEGER GENERATED BY DEFAULT AS IDENTITY (START WITH 1) PRIMARY KEY, name varchar(256) not null, nickname varchar(256), wiki varchar(256) ) SQL; $io->info('Attempting to create PLANTS table.'); $db2Conn->execute($createPlants); $io->success('PLANTS table successfully created.'); } /** * @throws Exception */ private function initializeData(SymfonyStyle $io) : array { $io->info('Attempting to initialize PLANTS records.'); $plant1 = PlantEntity::create( 'Monstera deliciosa', 'https://en.wikipedia.org/wiki/Monstera_deliciosa' ); $plant2 = PlantEntity::create( 'Monstera adansonii', 'https://en.wikipedia.org/wiki/Monstera_adansonii', 'Swiss cheese plant' ); $plant3 = PlantEntity::create( 'Anthurium warocqueanum', 'https://en.wikipedia.org/wiki/Anthurium_warocqueanum', 'Queen anthurium' ); $plants = [$plant1, $plant2, $plant3]; $data = []; foreach ($plants as $plant) { $plantId = $this->plantModel->save($plant); $data[] = $this->plantModel->get($plantId)->current(); } $io->success('PLANTS records initialized successfully.'); return $data; } private function showData(array $data, SymfonyStyle $io) : void { $headers = ['ID', 'Name', 'Nickname', 'Wikipedia URL']; $io->table($headers, [ [$data[0]->id, $data[0]->name, $data[0]->nickname, $data[0]->wiki], [$data[1]->id, $data[1]->name, $data[1]->nickname, $data[1]->wiki], [$data[2]->id, $data[2]->name, $data[2]->nickname, $data[2]->wiki], ]); } private function error(SymfonyStyle $io, string $message) : int { $io->error($message); return 1; } }The above simply checks if the table exists yet, and depending on input from the user, creates teh Plants table and initiates it with three unique records.SetupDataCommand in ConfigProviderNext, we'll fill in our factory in order to pass dependencies to our command class:<?php // src/Plant/src/Command/SetupDataCommandFactory.php declare(strict_types=1); namespace Plant\Command; use Plant\TableGateway\PlantTableGateway; use Psr\Container\ContainerInterface; class SetupDataCommandFactory { public function __invoke(ContainerInterface $container) : SetupDataCommand { return new SetupDataCommand( $container->get(PlantTableGateway::class) ); } }Testing and Running the CLIOnce the above is done, the Laminas CLI will list a new command:$ cd /path/to/project $ ./vendor/bin/laminas list Available commands: completion Dump the shell completion script help Display help for a command list List commands mezzio mezzio:action:create Create an action class file. mezzio:factory:create Create a factory class file for the named class. mezzio:handler:create Create a PSR-15 request handler class file. mezzio:middleware:create Create a PSR-15 middleware class file. mezzio:middleware:migrate-from-interop Migrate http-interop middleware and delegators mezzio:middleware:to-request-handler Migrate PSR-15 middleware to request handlers mezzio:module:create Create and register a middleware module with the application mezzio:module:deregister Deregister a middleware module from the application mezzio:module:register Register a middleware module with the application plant plant:setup-data Setup example data for plant module. To run our command:$ ./vendor/bin/laminas plant:setup-dataThis will create our table and initiate it with example data.Back to topWriting Plant API RoutesThe bulk of our plant logic is done in the TableGateway with our get and save methods. Now we just need build a few Handlers to enable us to write our Plant API routes. These are already generated for us from our Mezzio CLI commands above:src/Plant/src/Handler/DeletePlantHandler.phpsrc/Plant/src/Handler/GetPlantHandler.phpsrc/Plant/src/Handler/ListPlantsHandler.phpsrc/Plant/src/Handler/SavePlantHandler.phpDeletePlantHandlerDelete is usually the most straight-forward Handler. It doesn't even require a fancy response. Simply make the generated DeletePlantHandler.php file match this:<?php // src/Plant/src/Handler/DeletePlantHandler.php declare(strict_types=1); namespace Plant\Handler; use Laminas\Diactoros\Response\EmptyResponse; use Plant\Entity\PlantEntity; use Plant\TableGateway\PlantTableGateway; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; use SoBoRed\Mezzio\Rest\Exception\NoResourceFoundException; class DeletePlantHandler implements RequestHandlerInterface { /** * @var PlantTableGateway */ private $plantTable; public function __construct(PlantTableGateway $plantModel) { $this->plantTable = $plantModel; } public function handle(ServerRequestInterface $request) : ResponseInterface { $id = $request->getAttribute('id', false); $plant = $this->plantTable->get((int)$id)->current(); if (! $plant instanceof PlantEntity) { throw NoResourceFoundException::create("Plant with id `{$id}` not found"); } $this->plantTable->delete(['id' => $id]); return new EmptyResponse(204); } } and pass the TableGateway in the DeletePlantHandlerFactory: <?php // src/Plant/src/Handler/DeletePlantHandlerFactory.php declare(strict_types=1); namespace Plant\Handler; use Plant\TableGateway\PlantTableGateway; use Psr\Container\ContainerInterface; class DeletePlantHandlerFactory { public function __invoke(ContainerInterface $container) : DeletePlantHandler { return new DeletePlantHandler($container->get(PlantTableGateway::class)); } }GetPlantHandlerGet is another simple handler. However, it does require us to return a Plant object in our response. We're also sending back HAL+json compliant responses. To ensure we do, we'll use the mezzio-rest-helpers RestDispatchTrait and Exceptions, which build HAL+json compliant responses for us.Open GetPlanHandler.php and replace the contents with:<?php // src/Plant/src/Handler/GetPlantHandler.php declare(strict_types=1); namespace Plant\Handler; use Mezzio\Hal\HalResponseFactory; use Mezzio\Hal\ResourceGenerator; use Plant\Entity\PlantEntity; use Plant\TableGateway\PlantTableGateway; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; use SoBoRed\Mezzio\Rest\Exception\NoResourceFoundException; use SoBoRed\Mezzio\Rest\RestDispatchTrait; class GetPlantHandler implements RequestHandlerInterface { /** * @var PlantTableGateway */ private $plantTable; use RestDispatchTrait; // from mezzio-rest-helpers public function __construct( PlantTableGateway $plantModel, ResourceGenerator $resourceGenerator, HalResponseFactory $responseFactory ) { $this->plantTable = $plantModel; $this->resourceGenerator = $resourceGenerator; // from RestDispatchTrait $this->responseFactory = $responseFactory; // from RestDispatchTrait } public function handle(ServerRequestInterface $request) : ResponseInterface { $id = $request->getAttribute('id', false); $plant = $this->plantTable->get((int)$id)->current(); if (! $plant instanceof PlantEntity) { // NoResourceFoundException is from mezzio-rest-helpers throw NoResourceFoundException::create("Plant with id `{$id}` not found"); } // createResponse() is from RestDispatchTrait return $this->createResponse($request, $plant); } } and pass the TableGateway, ResourceGenerator, and HalResponseFactory in the GetPlantHandlerFactory: <?php // src/Plant/src/Handler/GetPlantHandlerFactory.php declare(strict_types=1); namespace Plant\Handler; use Mezzio\Hal\HalResponseFactory; use Mezzio\Hal\ResourceGenerator; use Plant\TableGateway\PlantTableGateway; use Psr\Container\ContainerInterface; class GetPlantHandlerFactory { public function __invoke(ContainerInterface $container) : GetPlantHandler { return new GetPlantHandler( $container->get(PlantTableGateway::class), $container->get(ResourceGenerator::class), $container->get(HalResponseFactory::class) ); } }ListPlantsHandlerThe main difference between get and list is that we'll need to use our PlantCollection to allow for pagination of our results. This will also allow us to build a HAL+json compliant response via the RestDispatchTrait's createResponse() method.Replace the contents of ListPlantsHandler.php with:<?php // src/Plant/src/Handler/ListPlantsHandler.php declare(strict_types=1); namespace Plant\Handler; use Mezzio\Hal\HalResponseFactory; use Mezzio\Hal\ResourceGenerator; use Plant\Collection\PlantCollection; use Plant\TableGateway\PlantTableGateway; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; use SoBoRed\Mezzio\Rest\RestDispatchTrait; class ListPlantsHandler implements RequestHandlerInterface { /** * @var PlantTableGateway */ private $plantTable; use RestDispatchTrait; public function __construct( PlantTableGateway $plantTable, ResourceGenerator $resourceGenerator, HalResponseFactory $responseFactory ) { $this->plantTable = $plantTable; $this->resourceGenerator = $resourceGenerator; $this->responseFactory = $responseFactory; } public function handle(ServerRequestInterface $request) : ResponseInterface { // Get query parameters for pagination $page = $request->getQueryParams()['page'] ?? 1; $perPage = $request->getQueryParams()['perPage'] ?? 25; $all = isset($request->getQueryParams()['all']); $orderBy = $request->getQueryParams()['orderBy'] ?? 'name'; $order = $request->getQueryParams()['order'] ?? 'ASC'; // Get the select statement for pagination $plantsSelect = $this->plantTable->getForPagination($orderBy, $order); // Use the PlantCollection to initiate pagination and set the current page $plants = new PlantCollection($plantsSelect); $plants->setItemCountPerPage($all ? $plants->getTotalItemCount() : $perPage); $plants->setCurrentPageNumber($page); // Will use the PlantCollection to create a HAL+json compliant, paginated response return $this->createResponse($request, $plants); } } and pass the TableGateway, ResourceGenerator, and HalResponseFactory in the ListPlantsHandlerFactory: <?php // src/Plant/src/Handler/ListPlantsHandlerFactory.php declare(strict_types=1); namespace Plant\Handler; use Mezzio\Hal\HalResponseFactory; use Mezzio\Hal\ResourceGenerator; use Plant\TableGateway\PlantTableGateway; use Psr\Container\ContainerInterface; class ListPlantsHandlerFactory { public function __invoke(ContainerInterface $container) : ListPlantsHandler { return new ListPlantsHandler( $container->get(PlantTableGateway::class), $container->get(ResourceGenerator::class), $container->get(HalResponseFactory::class) ); } }PlantInputFilterBefore we can get to our SavePlantHandler, we should build an InputFilter for our Plant. (Laminas InputerFilters)[https://docs.laminas.dev/laminas-inputfilter/intro/) are flexible and can be used to filter and validate generic sets of input data.We will use ours to filter (convert, sanatize) and validate the input to our Save Plant route.Let's create the file src/Plant/src/Filter/PlantInputFilter.php and fill the contents with:<?php // src/Plant/src/Filter/PlantInputFilter.php declare(strict_types=1); namespace Plant\Filter; use Laminas\Filter\StringTrim; use Laminas\Filter\ToInt; use Laminas\InputFilter\InputFilter; use Laminas\Validator\Digits; class PlantInputFilter extends InputFilter { public function init() { $this->add([ 'name' => 'id', 'allow_empty' => true, 'fallback_value' => 0, 'filters' => [ [ 'name' => ToInt::class, ], ], 'validators' => [ [ 'name' => Digits::class, ], ], ]); $this->add([ 'name' => 'name', 'required' => true, 'filters' => [ [ 'name' => StringTrim::class, ], ], 'description' => 'Scientific name of plant', 'allow_empty' => false, 'continue_if_empty' => false, ]); $this->add([ 'name' => 'wiki', 'required' => true, 'filters' => [ [ 'name' => StringTrim::class, ], ], 'description' => 'URL to wikipedia article of plant', 'allow_empty' => false, 'continue_if_empty' => false, ]); $this->add([ 'name' => 'nickname', 'allow_empty' => true, 'fallback_value' => '', 'filters' => [ [ 'name' => StringTrim::class, ], ], 'description' => 'Nickname of plant', ]); } }With this input filter, error checking in our save handler will be much cleaner.SavePlantHandlerOur save handler could've been a jumbled mess of logic, but we've prepared well for it.Let's fill the contents of SavePlantHandler.php with:<?php // src/Plant/src/Handler/SavePlantHandler.php declare(strict_types=1); namespace Plant\Handler; use Exception; use Mezzio\Hal\HalResponseFactory; use Mezzio\Hal\ResourceGenerator; use Plant\Entity\PlantEntity; use Plant\Filter\PlantInputFilter; use Plant\TableGateway\PlantTableGateway; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; use SoBoRed\Mezzio\Rest\Exception\InvalidParameterException; use SoBoRed\Mezzio\Rest\Exception\RuntimeException; use SoBoRed\Mezzio\Rest\RestDispatchTrait; class SavePlantHandler implements RequestHandlerInterface { /** * @var PlantTableGateway */ private $plantTable; /** * @var PlantInputFilter */ private $plantFilter; use RestDispatchTrait; public function __construct( PlantTableGateway $plantTable, PlantInputFilter $plantFilter, ResourceGenerator $resourceGenerator, HalResponseFactory $responseFactory ) { $this->plantTable = $plantTable; $this->plantFilter = $plantFilter; $this->resourceGenerator = $resourceGenerator; $this->responseFactory = $responseFactory; } public function handle(ServerRequestInterface $request) : ResponseInterface { $body = $request->getParsedBody(); $id = $request->getAttribute('id'); $body['id'] = $body['id'] ?? $id; $this->plantFilter->setData($body); if (!$this->plantFilter->isValid()) { throw InvalidParameterException::create( 'Invalid parameter', $this->plantFilter->getMessages() ); } $status = empty($this->plantFilter->getValue('id')) ? 201 : 200; $plant = PlantEntity::createFromBody($this->plantFilter->getValues()); try { $plantId = $this->plantTable->save($plant); } catch (Exception $e) { throw RuntimeException::create('An error occurred while saving plant data: ' . $e->getCode() . ' - ' . $e->getMessage()); } return $this->createResponse($request, $this->plantTable->get($plantId)->current()) ->withStatus($status); } } and pass the TableGateway, InputFilter, ResourceGenerator, and HalResponseFactory in the ListPlantsHandlerFactory: <?php declare(strict_types=1); namespace Plant\Handler; use Laminas\InputFilter\InputFilterPluginManager; use Mezzio\Hal\HalResponseFactory; use Mezzio\Hal\ResourceGenerator; use Plant\Filter\PlantInputFilter; use Plant\TableGateway\PlantTableGateway; use Psr\Container\ContainerInterface; class SavePlantHandlerFactory { public function __invoke(ContainerInterface $container) : SavePlantHandler { $filters = $container->get(InputFilterPluginManager::class); return new SavePlantHandler( $container->get(PlantTableGateway::class), $filters->get(PlantInputFilter::class), $container->get(ResourceGenerator::class), $container->get(HalResponseFactory::class) ); } }API Routes and PipelineFinally, with all our Handlers defined, we can add our API routes to the config/routes.php file:<?php // config/routes.php declare (strict_types=1); use Mezzio\Application; use Mezzio\Helper\BodyParams\BodyParamsMiddleware; use Mezzio\MiddlewareFactory; Use Psr\Container\ContainerInterface; return static function (Application $app, MiddlewareFactory $factory, ContainerInterface $container): void { $app->get('/', App\Handler\HomePageHandler::class, 'home'); $app->get('/api/ping', App\Handler\PingHandler::class, 'api.ping'); $app->get('/api/plants', Plant\Handler\ListPlantsHandler::class, 'api.plants'); $app->get('/api/plants/{id:\d+}', Plant\Handler\GetPlantHandler::class, 'api.plant'); $app->post('/api/plants[/{id:\d+}]', [ BodyParamsMiddleware::class, Plant\Handler\SavePlantHandler::class ], 'api.plant.save'); $app->delete( '/api/plants/{id:\d+}', Plant\Handler\DeletePlantHandler::class, 'api.plant.delete' ); }; Be sure to add ProblemDetailsMiddleware for /api to our config/pipeline.php configuration file, to ensure it's run on all our API routes: <?php // config/pipeline.php // ... return function (Application $app, MiddlewareFactory $factory, ContainerInterface $container): void { // The error handler should be the first (most outer) middleware to catch // all Exceptions. $app->pipe(ErrorHandler::class); // Always use Problem Details format for calls to the API. $app->pipe('/api', ProblemDetailsMiddleware::class); $app->pipe(ServerUrlMiddleware::class); // ... };The API should be accessible now. You can run:$ cd /path/to/project $ composer serve on your IBM i, and then test the API: $ curl -v http://ibmi-ip:8080/api/plants/3 * Trying ibmi-ip:8080... * Connected to ibmi-ip (ibmi-ip) port 8080 (#0) > GET /api/plants/3 HTTP/1.1 > Host: ibmi-ip:8080 > User-Agent: curl/7.85.0 > Accept: */* > * Mark bundle as not supporting multiuse < HTTP/1.1 200 OK < Host: ibmi-ip:8080 < Date: Tue, 11 Oct 2022 20:14:18 GMT < Connection: close < X-Powered-By: PHP/7.3.15 ZendServer/2019.0.4 < Content-Type: application/hal+json < { "id": 44, "name": "Anthurium warocqueanum", "nickname": "Queen anthurium", "wiki": "https://en.wikipedia.org/wiki/Anthurium_warocqueanum", "_links": { "self": { "href": "http://ibmi-ip:8080/api/plants/3" } } * Closing connection 0 }This completes our Plant module, and now we can move on to extending our user interface around this API inside our App module.Back to topExtending the App User InterfaceWe've generated our PlantPageHandler earlier, and it's finally time to get it working with our API. In order to produce an interactive user experience, we'll rely heavily on Javascript and AJAX. Due to this, we will leave the PlantPageHandler as it was generated. We don't need to pass any data to the template, because we'll get all that from our API.Main.js and Plant.jsTo start, let's initialize two Javascript files for us to write our front-end logic:public/assets/scripts/main.jspublic/assets/scripts/plants.jsand fill them with:// public/assets/scripts/main.js app = window.app || {}; app.genericError = (title, message) => { Swal.fire({ icon: 'error', title, text: message }); }; app.genericSuccess = (title, message) => { Swal.fire({ icon: 'success', title, text: message }); };// public/assets/scripts/plants.js app = window.app || {}; app.plants = app.plants || {}; app.plants.init = () => { const dataTable = app.plants.startTable(); app.plants.setupModals(dataTable); }; app.plants.startTable = () => { const tablePlants = $('#TablePlants').DataTable({ ajax: { url: '/api/plants?all', dataSrc: '_embedded.plants' }, order: [[1, 'desc']], columns: [ { searchable: false, sortable: false, data: (plant) => { let actions = ''; if (plant.hasOwnProperty('id')) { actions += `<button class="btn btn-primary btn-edit-plant" data-plant-id="${plant.id}" data-toggle="modal" data-target="#ModalSavePlant"><i class="fa fa-edit"></i></button> ` + `<button class="btn btn-danger btn-delete-plant" data-plant-id="${plant.id}"><i class="fa fa-trash"></i></button> ` } return actions; } }, { data: 'id' }, { data: 'name' }, { data: 'nickname' }, { data: (plant) => { return plant.hasOwnProperty('wiki') ? `<a href="${plant.wiki}" target="_blank">${plant.wiki}</a>` : ''; } } ] }); tablePlants.on('draw.dt', () => { app.plants.bindTableActions(tablePlants); }); return tablePlants; }; app.plants.setupModals = (dataTable) => { const $modalSavePlant = $('#ModalSavePlant'); $modalSavePlant.on('show.bs.modal', (element) => { const $modal = $(element.currentTarget); const $formLoading = $modal.find('.form-inputs-loading'); const $formInputs = $modal.find('.form-inputs'); const $button = $(element.relatedTarget); const plantId = $button.data('plant-id'); if (plantId !== undefined) { // Edit mode $formLoading.removeClass('d-none'); $formInputs.addClass('d-none'); $modal.find('.modal-title').text(`Save Plant #${plantId}`); $.ajax({ url: `/api/plants/${plantId}`, type: 'GET', success: (res) => { $modal.find('#id').val(res.id); $modal.find('#name').val(res.name); $modal.find('#nickname').val(res.nickname); $modal.find('#wiki').val(res.wiki); }, complete: () => { $formLoading.addClass('d-none'); $formInputs.removeClass('d-none'); } }) } }); $modalSavePlant.on('hidden.bs.modal', (element) => { const $modal = $(element.currentTarget); $modal.find('.modal-title').text(`Save New Plant`); $modal.find('#id').val(0); $modal.find('#name').val(''); $modal.find('#nickname').val(''); $modal.find('#wiki').val(''); }); $('#FormSavePlant').on('submit', (element) => { element.preventDefault(); const $form = $(element.currentTarget); const inputValues = $form.serializeArray(); const plant = {}; let url = '/api/plants'; for (let inputValue of inputValues) { plant[inputValue.name] = inputValue.value; } plant.id = parseInt(plant.id); const isNew = plant.id === 0; if (!isNew) { url += `/${plant.id}`; } $.ajax({ url, type: 'POST', data: plant, success: (res) => { dataTable.ajax.reload(null, isNew); $modalSavePlant.modal('hide'); app.genericSuccess('Success!', 'Plant record successfully saved.'); }, error: (res) => { let title = 'Error!'; let message = 'Something went wrong while trying to save the plant record.'; if (res.hasOwnProperty('responseJSON')) { title = res.responseJSON.title; message = res.responseJSON.detail; } $modalSavePlant.modal('hide'); app.genericError(title, message); } }); }); }; app.plants.bindTableActions = (dataTable) => { $('.btn-delete-plant').on('click', (element) => { const $button = $(element.currentTarget); const plantId = $button.data('plant-id'); Swal.fire({ title: 'Are you sure?', text: 'Deleting a plant cannot be undone.', buttonsStyling: false, showCancelButton: true, confirmButtonText: 'Yes, delete', customClass: { confirmButton: 'btn btn-primary mr-3', cancelButton: 'btn btn-danger', }, }).then((res) => { if (res.isConfirmed) { $.ajax({ url: `/api/plants/${plantId}`, type: 'DELETE', success: () => { dataTable.ajax.reload(null, false); app.genericSuccess('Success!', 'Plant record successfully deleted.'); }, error: (res) => { let title = 'Error!'; let message = 'Something went wrong while trying to delete the plant record.'; if (res.hasOwnProperty('responseJSON')) { title = res.responseJSON.title; message = res.responseJSON.detail; } app.genericError(title, message); } }); } }); }); }; // Call init when document is done loading $(() => { app.plants.init(); });The above plants.js file seems complex, but it's just a handful of event handlers (button presses, form submits, etc.) that manipulate data via AJAX calls to our API routes.The most complex parts have to do with setting data on the DataTables action buttons and using that data to fill in the Save form if the edit button is pressed.Adding Dependencies and Plant-Page Link to Our LayoutLet's edit our default.phtml file, because we need to add some front-end dependencies as well as a link to our Plants page. We need to add css files, javascript files, and edit the navigation content some, so let's just replace the contents with:<!-- src/App/templates/layout/default.phtml --> <!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" /> <title><?=$this->e($title)?> - mezzio</title> <link rel="shortcut icon" href="https://getlaminas.org/images/favicon/favicon.ico" /> <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.0/css/bootstrap.min.css" /> <link href="https://use.fontawesome.com/releases/v5.13.0/css/all.css" rel="stylesheet" /> <link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/1.12.1/css/dataTables.bootstrap4.min.css"> <style> body { padding-top: 70px; } .app { min-height: 100vh; } .app-footer { padding-bottom: 1em; } .mezzio-green, h2 a, h2 a:hover { color: #009655; } .navbar-brand { padding: 0; } .navbar-brand img { margin: -.5rem 0; filter: brightness(0) invert(1); } </style> <?=$this->section('stylesheets')?> </head> <body class="app"> <header class="app-header"> <nav class="navbar navbar-expand-sm navbar-dark bg-dark fixed-top" role="navigation"> <div class="container"> <div class="navbar-header"> <button type="button" class="navbar-toggler" data-toggle="collapse" data-target="#navbarCollapse" aria-controls="#navbarCollapse" aria-expanded="false" aria-label="Toggle navigation"> <span class="navbar-toggler-icon"></span> </button> <!-- Brand --> <a class="navbar-brand" href="<?= $this->url('home') ?>"> <img src="https://docs.laminas.dev/img/laminas-mezzio-rgb.svg" alt="Laminas Mezzio" height="56" /> </a> </div> <!-- Links --> <div class="collapse navbar-collapse" id="navbarCollapse"> <ul class="navbar-nav mr-auto"> <li class="nav-item"> <a href="https://docs.mezzio.dev/mezzio" target="_blank" class="nav-link"> <i class="fa fa-book"></i> Docs </a> </li> <li class="nav-item"> <a href="https://github.com/mezzio/mezzio" target="_blank" class="nav-link"> <i class="fa fa-wrench"></i> Contribute </a> </li> <li class="nav-item"> <a href="<?= $this->url('api.ping') ?>" class="nav-link"> Ping Test </a> </li> <li class="nav-item"> <a href="<?= $this->url('plants') ?>" class="nav-link"> Plants Example </a> </li> </ul> </div> </div> </nav> </header> <div class="app-content"> <main class="container"> <?=$this->section('content')?> </main> </div> <footer class="app-footer"> <div class="container"> <hr /> <?php if ($this->section('footer')): ?> <?=$this->section('footer')?> <?php else: ?> <p> © <?=date('Y')?> <a href="https://getlaminas.org/">Laminas Project</a> a Series of LF Projects, LLC. </p> <?php endif ?> </div> </footer> <script src="https://code.jquery.com/jquery-3.5.1.min.js"></script> <script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.0/js/bootstrap.min.js"></script> <script src="https://cdn.datatables.net/1.12.1/js/jquery.dataTables.min.js"></script> <script src="https://cdn.datatables.net/1.12.1/js/dataTables.bootstrap4.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script> <script src="/assets/scripts/main.js"></script> <?=$this->section('javascript')?> </body> </html>This adds the front-end libraries: jQuery, DataTables, SweetAlert2, and a way for us to load specific javascript files per page ($this->section('javascript')).Plant Page TemplateFill in our plant-page.phtml template:<!-- src/App/templates/app/plant-page.phtml --> <?php $this->layout('layout::default', ['title' => 'Plants']) ?> <div class="mb-3"> <button class="btn btn-primary" data-toggle="modal" data-target="#ModalSavePlant"><i class="fa fa-plus"></i> Add plant</button> </div> <table id="TablePlants" class="table table-striped table-bordered" style="width:100%"> <thead> <tr> <th>Actions</th> <th>ID</th> <th>Name</th> <th>Nickname</th> <th>Wiki</th> </tr> </thead> <tbody> </tbody> </table> <div class="modal fade" id="ModalSavePlant" tabindex="-1" aria-labelledby="ModalSavePlantLabel" aria-hidden="true"> <div class="modal-dialog"> <form id="FormSavePlant"> <div class="modal-content"> <div class="modal-header"> <h5 class="modal-title" id="ModalSavePlantLabel">Save New Plant</h5> <button type="button" class="close" data-dismiss="modal" aria-label="Close"> <span aria-hidden="true">×</span> </button> </div> <div class="modal-body"> <div class="form-inputs-loading d-none"> <div class="spinner-border" role="status"> <span class="sr-only">Loading...</span> </div> </div> <div class="form-inputs"> <input type="hidden" value="0" id="id" name="id"> <div class="form-group"> <label for="name" class="col-form-label">Name:</label> <input type="text" class="form-control" id="name" name="name" required> </div> <div class="form-group"> <label for="nickname" class="col-form-label">Nickname:</label> <input type="text" class="form-control" id="nickname" name="nickname"> </div> <div class="form-group"> <label for="wiki" class="col-form-label">Wikipedia URL:</label> <input type="text" class="form-control" id="wiki" name="wiki" required> </div> </div> </div> <div class="modal-footer"> <button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button> <button type="submit" class="btn btn-primary">Save changes</button> </div> </div> </form> </div> </div> <?php $this->push('javascript') ?> <script src="/assets/scripts/plants.js"></script> <?php $this->end() ?>The above template is only a blank table (to be filled in with DataTables & AJAX on page load) and a blank save form in a bootstrap modal (to be initiated on open depending on create or update flag).Plant Page RouteThe only thing left to do is to define the route for the plant page. Add it to the config/routes.php file:<?php // config/routes.php // ... return static function (Application $app, MiddlewareFactory $factory, ContainerInterface $container): void { // ... $app->get('/plants', App\Handler\PlantPageHandler::class, 'plants'); // ... };Back to topWrapping UpEverything should be in place for us to visit our web page, which uses our API to communicate with the example data we created. Again, to serve our application:$ cd /path/to/project $ composer serveand now we can visit http://ibmi-ip:8080 to see a working DataTable with add, edit, and delete functionality.That was a lot, but we also accomplished a great deal. We have, with community-standard code, built a CLI to initialize example data, a REST API to communicate with the data, and a fast & interactive user experience centered around the API. Not only that, but our API is HAL+json compliant.Need Help Modernizing Your IBM i Application?Zend can help. Learn more about our custom consulting options by talking to an expert today.Talk to an ExpertAdditional ResourcesResource Collection - Zend IBM i BlogCase Study - AgVantage Software Modernizes Front-End System With ZendPHP on IBM iBlog - Exploring ZendHQ for IBM iBlog - IBM i Modernization: Modernizing Smartly and on a BudgetBlog - Using ZendPHP, PHP-FPM, and Nginx on IBM iBlog - Using MariaDB and PHP on IBM iBlog - IBM i Merlin and What it Means for PHP TeamsBlog - Installing Zend Server on IBM i Blog - Installing ZendPHP on IBM iBlog - IBM i 7.5 for PHP: Exploring Db2 ServicesBlog - A Developer's Guide to Building PHP APIsBack to top
Yeshua Hall Senior Solutions Architect, Zend by Perforce Yeshua Hall is the Senior Solutions Architect at Perforce Software. Yeshua is passionate about helping customers overcome complex technical challenges to achieve their team and business goals.