decorative image for blog on using mezzio on ibm i
October 20, 2022

Using Mezzio on IBM i

IBM i

Using 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 2

Back to top

Before You Get Started

This article uses the following technologies to explain building an example PHP application on IBM i:

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 Requirements

  • IBM i
  • IBM i Open Source Tooling w/ BASH & SSH working (optional, highly recommended)
  • PHP v7.4+
  • Composer for PHP package management

All commands will be run on IBM i to ensure compatibility and communication with IBM i Db2 data.

Completed Example

The application we build in this article already exists and can be found in the ibmi-oss-examples repository on GitHub.

Back to top

Getting Started with Mezzio Skeleton

The 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 serve

and then browse to http://IBM-i-URI:8080 to view the skeleton's landing page.

Development Mode

To disable caching while developing, ensure development-mode is enabled:

$ composer development-enable

Require Packages With Composer

This 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-cli

Scaffolding

Mezzio 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 Adapter

Through 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 top

TableGateway, the Cornerstone of Data-Driven Applications

Unless 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.php
  • src/Plant/src/Collection/PlantCollection.php
  • src/Plant/src/TableGateway/PlantTableGateway.php
  • src/Plant/src/TableGateway/PlantTableGatewayFactory.php

PlantEntity

We'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.

PlantCollection

Next, 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 page
PlantTableGateway

PlantTableGatewayFactory

Next, 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.

PlantTableGatewayFactory

Finally, 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 top

Building a CLI to initiate example data

In 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.php
  • src/Plant/src/Command/SetupDataCommandFactory.php

SetupDataCommandFactory

The 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 ConfigProvider

Next, 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 CLI

Once 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-data

This will create our table and initiate it with example data.

Back to top

Writing Plant API Routes

The 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.php
  • src/Plant/src/Handler/GetPlantHandler.php
  • src/Plant/src/Handler/ListPlantsHandler.php
  • src/Plant/src/Handler/SavePlantHandler.php

DeletePlantHandler

Delete 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));
    }
}

GetPlantHandler

Get 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)
        );
    }
}

ListPlantsHandler

The 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)
        );
    }
}

PlantInputFilter

Before 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.

SavePlantHandler

Our 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 Pipeline

Finally, 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 top

Extending the App User Interface

We'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.js

To start, let's initialize two Javascript files for us to write our front-end logic:

  • public/assets/scripts/main.js
  • public/assets/scripts/plants.js

and 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 Layout

Let'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>
                    &copy; <?=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 Template

Fill 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">&times;</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 Route

The 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 top

Wrapping Up

Everything 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 serve

and 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 Expert

Additional Resources

Back to top