Laminas: Part 3 – Advanced LmcUser Customizations

In the tutorial Laminas: Part 2 – Customizing LmcUser, we learned how to make some simple customization to LmcUser like simple extensions to the User entity, forms and views.

What if we wanted to make more complex extensions to the User entity?

In this tutorial, we will learn:

  • How to extend the User entity and customize adapters to support more complex data structures
  • How to customize the User Mapper to support different database abstraction layers

Getting started

This tutorial is based on the example application developed in the Laminas: Part 2 – Customizing LmcUser.

It is not mandatory to have completed that tutorial in order to follow this tutorial. A complete version of the LmcUser tutorial application is available in the https://github.com/visto9259/lmc-user-tutorial/tree/lmcuser-tutorial-part2 repository. To install, just clone it and run the PHP server locally:

$ git clone --branch lmcuser-tutorial-part2 https://github.com/visto9259/lmc-user-tutorial
$ cd lmc-user-tutorial
$ composer install
$ composer development-enable
$ composer serve

The application will start at localhost:8080.

This example application has two users already defined. You can log in using john@example.com or with jane@example.com. The password is the same for each user: 123456.

Settings things up

Advanced User entities

LmcUser is flexible and can be extended to suit your application needs. As we saw in the Laminas: Part 2 – Customizing LmcUser tutorial, the User entity that is used by LmcUser can be extended to support more properties.

The property that we added in the tutorial was a simple string which could be added without too many customizations. But what if we wanted to add more complex properties or if the database structure required more tables with more complex queries? It is all possible with extra work.

Data Layer Architecture

LmcUser has a multi-layer architecture to handle user data requests. All requests are handled by a mapper that provides the services required by LmcUser to interact with a given user entity strorage:

  • findByEmail($email): get from storage the user entity identified by email
  • findByUsername($username): get from storage the user entity identified by username
  • findById($id): get from storage the user entity identified by id
  • insert($user); insert the new $user entity into storage
  • update($user): update the $user entity in storage

These services are defined by the interface \LmcUser\Mapper\UserInterface.

LmcUser provides a mapper (\LmcUser\Mapper\User) implementing this interface. This mapper uses Laminas DB SQL to perform queries into the database and uses an \Laminas\Db\ResultSet\HydratingResultSet to transform datasets into user entity objects and vice-versa. The HydratringResultSet is configured with an hydrator and a user entity prototype.

We already saw in the Laminas: Part 2 – Customizing LmcUser tutorial that the user entity prototype can be extended.

LmcUser provides a high-level hydrator \LmcUser\Mapper\UserHydrator class that uses a base hydrator to perform most of the hydration work. By default, the base hydrator is a \Laminas\Hydrator\ClassMethodsHydrator class.

LmcUser uses Dependency Injection (DI) to build its mapper and hydrator using Service Manager aliases and factories.

That’s a lot of information to digest and may seem complicated but this architecture provides for a lot of flexibility. Let’s look into it in more depth via some real use cases.

Adding roles to the User entity

A first use case would be to add roles to the user entity where the roles property would be an array of strings. Adding roles would open the door to implement role-based access controls. So what do we need to do?

  • Update the database user table to add a roles column. We will use a simple varchar column and store the roles as a string delimited by ‘;’
  • Modify the user entity to add the roles property and the setter/getter methods
  • Modify the hydrator to transform the roles string into an array and vice-versa

Because this is still a relatively simple customization, there is no need at this point to modify the mapper.

Modify the user table to add a roles column and to populate existing rows with some values:

$ sqlite3 data/laminastutorial.db
sqlite> ALTER TABLE user ADD COLUMN 'roles' VARCHAR(100);
sqlite> UPDATE user SET 'roles'='admin;user' WHERE email='john@example.com';
sqlite> SELECT * FROM user;
1||john@example.com|John|$2y$14$yl5wHFF8oxzOvw53DMxOCu.QRSUnC91ZPgvGyPWnUAnX0N5xhCEcS||I love the Beatles|admin;user
2||jane@example.com|Jane|$2y$14$y6X167xdSgtKp.eevz//yObkduOAWjVH.v9TKQ1KZibi0A0M8Rui2||I prefer the Stones|
sqlite> .quit

Modify the User entity class that we created in the previous tutorial (/module/User/src/Entity/User.php):

<?php

namespace User\Entity;

class User extends \LmcUser\Entity\User
{

    protected ?string $tagline = null;

    protected array $roles = [];

    public function getRoles(): array
    {
        return $this->roles;
    }

    public function setRoles(array $roles): User
    {
        $this->roles = $roles;
        return $this;
    }

    /* the rest of the file */

}

Let’s tackle the hydrator. We need to modify the user hydrator to convert between a ‘;’ delimited string and an array. As described above LmcUser uses a high-level hydrator \LmcUser\Mapper\UserHydrator that implements the Laminas\Hydrator\HydratorInterface, i.e the extract and hydrate methods. This hydrator uses a base hydrator to do most the hydration and only performs some mapping for the id field. The base hydrator is by default a ClassMethodsHydrator which can hydrates an obejct using setter methods.

One option would be replace this hydrator by our own which would implement the HydratorInterface but we would pretty much redo the entire code just to handle one field. It would be a better solution to provide our own base hydrator that would covert strings to arrays and vice-versa.

The User Hydrator is instantiated by the Service Manager using the name 'lmcuser_user_hydrator' which maps to the \LmcUser\Factory\UserHydrator::class. The factory instantiates the base hydrator using the name 'lmcuser_base_hydrator' which is an alias the ClassMethodsHydrator::class.

This is set up by following the Service Manager configuration in the LmcUser Module::class:

     /* ... */
           'aliases' => [
                // ...
                'lmcuser_base_hydrator' => 'lmcuser_default_hydrator',
            ],
            'invokables' => [
                'lmcuser_default_hydrator' => ClassMethodsHydrator::class,
            ],

            'factories' => [
                // .... 
                'lmcuser_user_hydrator' => Factory\UserHydrator::class,

      /* the rest of the file */

Let’s create our own 'lmcuser_base_hydrator'. There is no real need to build and entirely new base hydrator. The ClassMethodsHydrator does most of the job. It just needs a specific strategy to handle roles. Luckily Laminas Hydrator provides an off-the-shelf strategy to do explode and implode on arrays. Where before the 'lmcuser_base_hydrator' was simply instantiated by an invokable factory, we now need to provide a simple factory for it. Create the /module/User/src/Mapper/BaseUserHydratorFactory.php:

<?php

namespace User\Mapper;

use Laminas\Hydrator\ClassMethodsHydrator;
use Laminas\Hydrator\Strategy\ExplodeStrategy;
use Laminas\ServiceManager\Factory\FactoryInterface;
use Psr\Container\ContainerInterface;

class BaseUserHydratorFactory implements FactoryInterface
{

    public function __invoke(ContainerInterface $container, $requestedName, ?array $options = null)
    {
        $hydrator = new ClassMethodsHydrator();
        $hydrator->addStrategy('roles', new ExplodeStrategy(';'));
        return $hydrator;
    }
}

What are we doing in this factory? We first instantiate a ClassMethodsHydrator and then we add a ExplodeStrategy for the 'roles' field.

We now have to configure the Service Manager to use our new factory to build the 'lmcuser_base_hydrator'. Modify the 'service_manager' configuration in module/User/config/module.config.php:

<?php

return [
    'service_manager' => [
        /* .... */

        'aliases' => [
            'lmcuser_base_hydrator' => 'custom_user_hydrator',
        ],
        'factories' => [
            'custom_user_hydrator' => \User\Mapper\BaseUserHydratorFactory::class,
        ],
    ],
 
  /* .... */
;

Now, whenever we have a User Entity object such as when using the View plugin $this->lmcUserIdentity(), we can use the getRoles methods to get the roles array. We will demonstrate this later.

Adding full name to the User Entity

This second use case will be to add the user’s full name to the User entity. To make things a little more complicated, let’s assume that the database has separate first and last name columns but we want a single full name in the User entity. So what do we need to do?

  • Update the database user table to add the firstname and lastname columns. We will use a simple varchar columns
  • Modify the User entity class to add a fullname property with setter/getter methods
  • Modify the user hydrator to transform the first and last name into a full name and vice-versa.

Let’s update the user table in the database to add the first and last name colums:

$ sqlite3 data/laminastutorial.db
sqlite> ALTER TABLE user ADD COLUMN 'first_name' VARCHAR(100);
sqlite> ALTER TABLE user ADD COLUMN 'last_name' VARCHAR(100);
sqlite> UPDATE user set first_name='John', last_name='Smith' WHERE email='john@example.com';
sqlite> UPDATE user set first_name='Jane', last_name='Doe' WHERE email='jane@example.com';
sqlite> SELECT * FROM user;
1||john@example.com|John|$2y$14$yl5wHFF8oxzOvw53DMxOCu.QRSUnC91ZPgvGyPWnUAnX0N5xhCEcS||I love the Beatles|admin;user|John|Smith
2||jane@example.com|Jane|$2y$14$y6X167xdSgtKp.eevz//yObkduOAWjVH.v9TKQ1KZibi0A0M8Rui2||I prefer the Stones||Jane|Doe
sqlite> .quit

Modify the User entity class (/module/User/src/Entity/User.php):

<?php

namespace User\Entity;

class User extends \LmcUser\Entity\User
{
    /* ... existing properties */
    
    protected ?string $fullName;

    public function getFullName(): ?string
    {
        return $this->fullName;
    }

    public function setFullName(?string $fullName): User
    {
        $this->fullName = $fullName;
        return $this;
    }

    /* ... existing methods */
}

For the hydrator, since we need to combine the data from the first_name and last_name columns, it will be best to modify the high-level User hydrator itself to manipulate the data before hydrating and after extracting using the base hydrator that we built previously.

Create a new User hydrator in module/User/src/Mapper/UserHydrator.php:

<?php

namespace User\Mapper;

use Laminas\Hydrator\HydratorInterface;
use LmcUser\Entity\UserInterface as UserEntityInterface;
use LmcUser\Mapper\Exception\InvalidArgumentException;

class UserHydrator implements HydratorInterface
{

    private HydratorInterface $hydrator;

    public function __construct(HydratorInterface $hydrator)
    {
        $this->hydrator = $hydrator;
    }

    public function extract(object $object): array
    {
        if (!$object instanceof UserEntityInterface) {
            throw new InvalidArgumentException('$object must be an instance of LmcUser\Entity\UserInterface');
        }

        /** @var \User\Entity\User $object */
        $data = $this->hydrator->extract($object);

        // get the firstname and lastname. Assume the pattern is 'first_name last_name'
        $array = explode(' ', $data['full_name']);
        $data['first_name'] = $array[0];
        $data['last_name'] = $array[1];
        unset($data['full_name']);

        // This part comes from the LmcUser User hydrator and we need to keep it
        if ($data['id'] !== null) {
            $data = $this->mapField('id', 'user_id', $data);
        } else {
            unset($data['id']);
        }

        return $data;
    }

    public function hydrate(array $data, object $object)
    {
        if (!$object instanceof UserEntityInterface) {
            throw new InvalidArgumentException('$object must be an instance of LmcUser\Entity\UserInterface');
        }

        // This part comes from the LmcUser User hydrator and we need to keep it
        $data = $this->mapField('user_id', 'id', $data);

        $data['fullname'] = $data['first_name'] . ' ' . $data['last_name'];
        unset($data['first_name']);
        unset($data['last_name']);

        return $this->hydrator->hydrate($data, $object);
    }

    protected function mapField(string $keyFrom, string$keyTo, array $array): array
    {
        $array[$keyTo] = $array[$keyFrom];
        unset($array[$keyFrom]);

        return $array;
    }
}

Create a simple factory for this new hydrator and add it to the Service Manager configuration:

/module/User/src/Mapper/UserHydrator/Factory.php:

<?php

namespace User\Mapper;

use Laminas\ServiceManager\Factory\FactoryInterface;
use Psr\Container\ContainerInterface;

class UserHydratorFactory implements FactoryInterface
{

    public function __invoke(ContainerInterface $container, $requestedName, ?array $options = null)
    {
        return new UserHydrator($container->get('lmcuser_base_hydrator'));
    }
}

/module/User/config/module.config.php:

<?php

return [
    'service_manager' => [
        'delegators' => [
            'lmcuser_register_form' => [
                \User\Form\RegisterFormDelegatorFactory::class,
            ],
        ],
        'aliases' => [
            'lmcuser_base_hydrator' => 'custom_user_hydrator',
        ],
        'factories' => [
            'custom_user_hydrator' => \User\Mapper\BaseUserHydratorFactory::class,
            'lmcuser_user_hydrator' => \User\Mapper\UserHydratorFactory::class,
        ],
    ],
    /* .... the rest */
];

Let’s modify the user profile view to display these new properties (/module/User/view/lmc-user/user/index.phtml):

<div class="row">
    <div class="col-1 m-1">
        <div><?php echo $this->gravatar($this->lmcUserIdentity()->getEmail()) ?></div>
    </div>
    <div class="col m-1">
        <h3><?php echo $this->translate('Hello'); ?>, <?php echo $this->escapeHtml($this->lmcUserDisplayName()) ?>!</h3>
        <p>Full name: <?=$this->lmcUserIdentity()->getFullName();?></p>
        <p><?= $this->lmcUserIdentity()->getTagline();?></p>
        <p>My roles:</p>
        <?php $roles = $this->lmcUserIdentity()->getRoles();?>
        <?php if (!empty($roles)): ?>
        <ul class="list-group">
            <?php foreach ($roles as $role): ?>
            <li class="list-group-item"><?= $role?></li>
            <?php endforeach;?>
        </ul>
        <?php else: ?>
        <p>No roles</p>
        <?php endif; ?>
        <a class="btn btn-primary" href="<?php echo $this->url('lmcuser/logout') ?>"><?php echo $this->translate('Sign Out'); ?></a>
    </div>
</div>

If you log in as john@example.com and navigate to localhost:8080/user, you should get the following page:

Advanced Mapper Customizations

So far, we have looked at how to customize the User entity to support more complex properties.

To recall the data layer architecture, LmcUser uses a mapper to query, insert and update user rows in the database. LmcUser provides a default mapper that uses Database Abstraction Layer (DBAL) provided by laminas-db and the hydrators that we worked on to map entity properties to row data.

What if the laminas-db DBAL was not suitable for our database? For example, we could want to use an Object Relational Mapper (ORM) such as Doctrine, something like a Web service to store user data.

Note: The component LM-Commons/LmcUserDoctrineORM provides an Doctrine ORM alternative to the default mapper provided by LmcUser.

For the purpose of this tutorial, let’s assume that we want to replace the Laminas DBAL used by the default mapper, by the TableGateway abstraction that was used in the Album tutorial. We can still the same hydrator that we developed above.

Let’s create a new mapper in module/User/src/Mapper/UserMapper.php. As described in the Data Layer Architecture section, our mapper must implement the \LmcUser\Mapper\UserInterface.

<?php

namespace User\Mapper;

use Laminas\Db\TableGateway\TableGateway;
use Laminas\Hydrator\HydratorInterface;
use LmcUser\Mapper\UserInterface;

class UserMapper implements UserInterface
{

    private TableGateway $tableGateway;

    private HydratorInterface $hydrator;

    public function __construct(TableGateway $tableGateway, HydratorInterface $hydrator)
    {
        $this->tableGateway = $tableGateway;
        $this->hydrator = $hydrator;
    }

    public function findByEmail($email)
    {
        $rowSet = $this->getTable()->select([
            'email' => $email,
        ]);
        return $rowSet->current();
    }

    public function findByUsername($username)
    {
        $rowSet = $this->getTable()->select([
            'username' => $username,
        ]);
        return $rowSet->current();
    }

    public function findById($id)
    {
        $rowSet = $this->getTable()->select([
            'user_id' => $id,
        ]);
        $user = $rowSet->current();
        return $user;
    }

    public function insert(\LmcUser\Entity\UserInterface $user)
    {
        $data = $this->getHydrator()->extract($user);
        $this->getTable()->insert($data);
        $id = $this->getTable()->lastInsertValue;
        return $this->findById($id);
    }

    public function update(\LmcUser\Entity\UserInterface $user)
    {
        $data = $this->getHydrator()->extract($user);
        $this->getTable()->update($data, [
            'user_id' => $user->getId(),
        ]);
        return $this->findById($user->getId());
    }

    private function getTable(): TableGateway
    {
        return $this->tableGateway;
    }

    private function getHydrator(): HydratorInterface
    {
        return $this->hydrator;
    }
}

Create a factory for the mapper module/User/src/Mapper/UserMapperFactory.php:

<?php

namespace User\Mapper;

use Laminas\Db\ResultSet\HydratingResultSet;
use Laminas\Db\TableGateway\TableGateway;
use Laminas\ServiceManager\Factory\FactoryInterface;
use Psr\Container\ContainerInterface;

class UserMapperFactory implements FactoryInterface
{

    public function __invoke(ContainerInterface $container, $requestedName, ?array $options = null)
    {
        $options = $container->get('lmcuser_module_options');
        $dbAdapter = $container->get('lmcuser_laminas_db_adapter');
        $hydrator = $container->get('lmcuser_user_hydrator');


        $entityClass = $options->getUserEntityClass();
        $hydratingResultSet = new HydratingResultSet($hydrator, new $entityClass());

        $tableName = $options->getTableName();
        $tableGateway = new TableGateway($tableName, $dbAdapter, [], $hydratingResultSet);

        return new UserMapper($tableGateway, $hydrator);
    }
}

The factory, inpsired from the default mapper factory provided with LmcUser, uses the user hydrator and User entity classes that we built previously and injects them into our UserMapper.

The 'lmcuser_module_options' service is a class representation of the LmcUser options found in the config/autoload/lmcuser_global.php.

Finally, let’s add our UserMapper service to the Service Manager configuration in module/config/module.config.php:

<?php

return [
    'service_manager' => [
        'delegators' => [
            'lmcuser_register_form' => [
                \User\Form\RegisterFormDelegatorFactory::class,
            ],
        ],
        'aliases' => [
            'lmcuser_base_hydrator' => 'custom_user_hydrator',
        ],
        'factories' => [
            'custom_user_hydrator' => \User\Mapper\BaseUserHydratorFactory::class,
            'lmcuser_user_hydrator' => \User\Mapper\UserHydratorFactory::class,
            'lmcuser_user_mapper' => \User\Mapper\UserMapperFactory::class,
        ],
    ],
    /* ... */
];

Conclusion

In this tutorial, we learned:

  • How to extend the User entity and customize adapters to support more complex data structures
  • How to customize the User Mapper to support different database abstraction layers

Next steps

These were more complex customizations to show that LmcUser can be adapted to your needs.

In the next tutorials, we will go deeper in customizations:

  • Laminas: Part 4 – Performing additonal actions on user actions (to come)

Previous tutorials:

Leave a Comment

Your email address will not be published. Required fields are marked *