MVC with one to many relationship

Hello.
I am new to Laminas, I installed the framework and I followed up the MVC tutorial, creating a music album.
I understood how things work, but I cannot understand how to implement a more advanced thing, like to add Genre to the music database and have one to many relationship.
One Album can be of one Genre (Rock, Pop, Dance). One Genre can apply to multiple Albums.
Do you have an example, please on how to add Genre to this music database, so I could also have in my Add/Edit form a dropdown list with Genre to choose from?
Thank you

Welcome to the Laminas forum.

Can you go into a bit more detail about exactly which tutorial you used? Maybe there is a link? It’s a bit difficult to address your problem if it’s not clear which tutorial you used. It might also make sense if you share some of your code with us. What have you done so far to implement the one-to-many relation?

Hello,
I have used this tutorial: Overview - tutorials - Laminas Docs
There is only one table in the database, called Album.
I need to add a second table, named Genre.
So, in my Album table, I will have an extra column, named GenreID, a foreign key to Genre table.
I think it is clear enough.
Now, I do not have any code yet, because I don’t know what direction to take.
I guess I need to add a model Genre…
The problem is the following.

Create and Edit forms.
I need a dropdown list filled with Genre from Genre table.
When I click on Update or Create button, the album will be created, or modified, with corresponding GenreID, chosen from the dropdown,

Here I am kind of stuck…
Thank you

Hi @codexknight7,

I hope the below code might be useful to you. I’ve not used Laminas DB for some time now, therefore if the code doesn’t work fully my apologies in advance. I’m omitting the form for the Gener table and its model because you can copy-paste the album code to produce the genre form and model by changing the name. The difference will be below.

  1. Change in album form to accept the genre for an album.
    1.1 Changing the form code a little bit and that would be to move the code from form constructor to the init method.
<?php
...
class AlbumForm extends Form{
    public function __construct($name = null){
        parent:::_contruct('album');
    }

    public function init(array $allGenerData = []){
        $this->add([
            'name' => 'id',
            'type' => 'hidden',
        ]);
        $this->add([
            'name' => 'title',
            'type' => 'text',
            'options' => [
                'label' => 'Title',
            ],
        ]);
        $this->add([
            'name' => 'artist',
            'type' => 'text',
            'options' => [
                'label' => 'Artist',
            ],
        ]);
        $this->add([
            'name' => 'gener_id',
            'type' => \Laminas\Form\Element\Select::class,
            'options' => [
                'label' => 'Gener',
                'value_options' => $allGenerData,
            ],
        ]);
        $this->add([
            'name' => 'submit',
            'type' => 'submit',
            'attributes' => [
                'value' => 'Go',
                'id'    => 'submitbutton',
            ],
        ]);
    }
}

// Change in Module class

class Module implements ConfigProviderInterface
{
    // getConfig() and getServiceConfig() methods are here

    // Add this method:
    public function getControllerConfig()
    {
        return [
            'factories' => [
                Controller\AlbumController::class => function($container) {
                    return new Controller\AlbumController(
                        $container->get(Model\AlbumTable::class),
                        $container->get(Model\GenerTable::class)
                    );
                },
            ],
        ];
    }
}

// Change in Album Controller Class

class AlbumController extends AbstractActionController
{
    // Add gener property:
    private $generTable;

    // Add this constructor:
    public function __construct(AlbumTable $table, GenerTable $gener)
    {
        $this->table = $table;
        $this->generTable = $gener;
    }

    public function addAction(){
        ...
        $generData = $this->generTable->fetchAll();
        $from->init($generData);
        ...
    }
}

// Change in Album Model

class Album implements InputFilterAwareInterface
{
    public $id;
    public $artist;
    public $title;
    public $gener_id;

    // Add this property:
    private $inputFilter;

    public function exchangeArray(array $data)
    {
        $this->id     = !empty($data['id']) ? $data['id'] : null;
        $this->artist = !empty($data['artist']) ? $data['artist'] : null;
        $this->title  = !empty($data['title']) ? $data['title'] : null;
        $this->title  = !empty($data['gener_id']) ? $data['gener_id'] : null;
    }
}

// changes for phtml files
$gener_id = $form->get('gener_id');
$gener_id->setAttribute('class', 'form-control');
$gener_id->setAttribute('placeholder', 'Artist');
<div class="form-group">
    <?= $this->formLabel($gener_id) ?>
    <?= $this->formElement($gener_id ?>
    <?= $this->formElementErrors()->render($gener_id, ['class' => 'help-block']) ?>
</div>

CREATE TABLE gener (id INTEGER PRIMARY KEY AUTOINCREMENT, gener_name varchar(100) NOT NULL);
INSERT INTO gener (gener_name) VALUES ('Rock');
INSERT INTO gener (gener_name) VALUES ('Pop');
INSERT INTO gener (gener_name) VALUES ('Dance');

ALTER TABLE `album` ADD COLUMN `gener_id` INTEGER DEFAULT 1 NULL AFTER `artist`;
ALTER TABLE `album` ADD CONSTRAINT `album_gener_id` FOREIGN KEY (`gener_id`) REFERENCES `gener` (`id`);

I hope this helps you. Thanks!

Hello ALTAMASH80,
That’s very kind from you.
I will try to implement it.
Thank you very much for your time.

The Getting Started tutorial uses laminas-db but it can be replaced with any other packages you like or with which you have experience.
Like @ALTAMASH80, I do not use laminas-db because it does not support relationships out of the box.

The dropdown list can be handled with a custom element based on Laminas\Form\Select.

namespace ExampleModule\Form;

class ExampleSelectElement extends Laminas\Form\Element\Select
    implements Laminas\Db\Adapter\AdapterAwareInterface
{
    use Laminas\Db\Adapter\AdapterAwareTrait;

    public function init(): void
    {
        if (! $this->adapter) {
            return;
        }

        /** @var Laminas\Db\Adapter\Driver\StatementInterface $statement */
        $statement = $this->adapter->query('SELECT `id`, `name` FROM `artist`');
        $result    = $statement->execute();

        $options = [];
        /** @var array{id: int, name: string} $row */
        foreach ($result as $row) {
            $options[$row['id']] = $row['name'];
        }

        $this->setValueOptions($options);
    }
}

Add a delegator which adds the database adapter to the custom form element:

module/ExampleModule/config/module.config.php:

return [
    'form_elements' => [
        'delegators' => [
            ExampleModule\Form\ExampleSelectElement::class => [
                Laminas\Db\Adapter\AdapterServiceDelegator::class
            ],
        ],
    ],
    // …
];

Use the element in a form:

namespace ExampleModule\Form;

final class ExampleForm extends Laminas\Form\Form
{
    public function init(){
        $this->add([
            'name' => 'artist',
            'type' => ExampleModule\Form\ExampleSelectElement:class,
        ]);
    }
}

In your controller:

namespace ExampleModule\Controller;

use ExampleModule\Form\ExampleForm;
use Laminas\Form\FormElementManager;
use Laminas\Mvc\Controller\AbstractActionController;

final class HelloController extends AbstractActionController
{
    private FormElementManager $formElementManager;

    public function __construct(FormElementManager $formElementManager)
    {
        $this->formElementManager = $formElementManager;
    }

    public function worldAction()
    {
        $form = $this->formElementManager->get(ExampleForm::class);

        // …
    }
}

Register the controller in the configuration:

return [
    'controllers' => [
        'factories' => [
            ExampleModule\Controller\HelloController::class => 
                Laminas\ServiceManager\AbstractFactory\ReflectionBasedAbstractFactory::class,
        ],
    ],
    // …
];

This solution allows an easy reuse of the element, keeps the controller clean and makes it easier to test the form element.
Try to avoid assembling anything in the controller.

More on this topic can be found in the documentation:

1 Like

Hi @froschdesign,

I hope you would not have any problem using your code to make another repository and use it as a tutorial on my site. As usual, your answer is much more methodical and uses the correct and advanced features of Laminas MVC like traits and a Reflection-based controller to use. Thanks!

Thank you very much.
Your answer came just in time.Because I was trying to make the dropdown, work, as this control refused to accept the ResultSet, and asked for an array. And I could not convert it to an arrray properly.
I will try your solution, in hope it will finally work fine.

Thanks again everyone, for your precious help.

I tried to implement the latest solution.
But there is something wrong.
I am getting the following error:

An error occurred
An error occurred during execution; please try again later.
Additional information:
Laminas\ServiceManager\Exception\ServiceNotFoundException
File:
D:\xampp\htdocs\laminas-mvc-tutorial\vendor\laminas\laminas-servicemanager\src\ServiceManager.php:557
Message:
Unable to resolve service "Album\Form\GenreSelectElement" to a factory; are you certain you provided it during configuration?

Here is my Module.php

<?php

namespace Album;

// Add these import statements:
use Laminas\Db\Adapter\AdapterInterface;
use Laminas\Db\ResultSet\ResultSet;
use Laminas\Db\TableGateway\TableGateway;
use Laminas\ModuleManager\Feature\ConfigProviderInterface;

class Module implements ConfigProviderInterface {

    // getConfig() method is here
    public function getConfig() {
        return include __DIR__ . '/../config/module.config.php';
    }

    // Add this method:
    public function getServiceConfig() {
        return [
            'factories' => [
                Model\AlbumTable::class => function ($container) {
                    $tableGateway = $container->get(Model\AlbumTableGateway::class);
                    return new Model\AlbumTable($tableGateway);
                },
                Model\AlbumTableGateway::class => function ($container) {
                    $dbAdapter = $container->get(AdapterInterface::class);
                    $resultSetPrototype = new ResultSet();
                    $resultSetPrototype->setArrayObjectPrototype(new Model\Album());
                    return new TableGateway('album', $dbAdapter, null, $resultSetPrototype);
                },
                Model\GenreTable::class => function ($container) {
                    $tableGateway = $container->get(Model\GenreTableGateway::class);
                    return new Model\GenreTable($tableGateway);
                },
                Model\GenreTableGateway::class => function ($container) {
                    $dbAdapter = $container->get(AdapterInterface::class);
                    $resultSetPrototype = new ResultSet();
                    $resultSetPrototype->setArrayObjectPrototype(new Model\Genre());
                    return new TableGateway('genre', $dbAdapter, null, $resultSetPrototype);
                },
            ],
        ];
    }

    public function getControllerConfig() {
        return [
            'factories' => [
                Controller\AlbumController::class => function ($container) {
                    return new Controller\AlbumController(
                    $container->get(Model\AlbumTable::class),
                    $container->get(\Album\Form\GenreSelectElement::class)
                    );
                },
            ],
        ];
    }

}

Here is module.config.php

<?php

namespace Album;

use Laminas\Router\Http\Segment;
use Laminas\ServiceManager\Factory\InvokableFactory;

return [
    // The following section is new and should be added to your file:
    'router' => [
        'routes' => [
            'album' => [
                'type' => Segment::class,
                'options' => [
                    'route' => '/album[/:action[/:id]]',
                    'constraints' => [
                        'action' => '[a-zA-Z][a-zA-Z0-9_-]*',
                        'id' => '[0-9]+',
                    ],
                    'defaults' => [
                        'controller' => Controller\AlbumController::class,
                        'action' => 'index',
                    ],
                ],
            ],
        ],
    ],
    'view_manager' => [
        'template_path_stack' => [
            'album' => __DIR__ . '/../view',
        ],
    ],
    'navigation' => [
        'default' => [
            [
                'label' => 'Home',
                'route' => 'home',
            ],
            [
                'label' => 'Album',
                'route' => 'album',
                'pages' => [
                    [
                        'label' => 'Add',
                        'route' => 'album',
                        'action' => 'add',
                    ],
                    [
                        'label' => 'Edit',
                        'route' => 'album',
                        'action' => 'edit',
                    ],
                    [
                        'label' => 'Delete',
                        'route' => 'album',
                        'action' => 'delete',
                    ],
                ],
            ],
        ],
    ],
    'form_elements' => [
        'delegators' => [
            Album\Form\GenreSelectElement::class => [
                Laminas\Db\Adapter\AdapterServiceDelegator::class
            ],
        ],
    ],
    'controllers' => [
        'factories' => [
            Album\Controller\AlbumController::class =>
            Laminas\ServiceManager\AbstractFactory\ReflectionBasedAbstractFactory::class,
        ],
    ],
];

Here is GenreSelectElement.php

<?php

namespace Album\Form;

class GenreSelectElement extends Laminas\Form\Element\Select
{
    use Laminas\Db\Adapter\AdapterAwareTrait;

    public function init(): void
    {
        if (! $this->adapter) {
            return;
        }

        /** @var Laminas\Db\Adapter\Driver\StatementInterface $statement */
        $statement = $this->adapter->query('SELECT `id`, `genre_name` FROM `genre`');
        $result    = $statement->execute();

        $options = [];
        /** @var array{id: int, name: string} $row */
        foreach ($result as $row) {
            $options[$row['id']] = $row['genre_name'];
        }

        $this->setValueOptions($options);
    }
}

Finally, AlbumController.php

<?php
// in module/Album/src/Controller/AlbumController.php:
namespace Album\Controller;

use Album\Model\AlbumTable;
use Album\Model\GenreTable;
use Laminas\Form\FormElementManager;
use Laminas\Mvc\Controller\AbstractActionController;
use Laminas\View\Model\ViewModel;
use Album\Form\AlbumForm;
use Album\Model\Album;

class AlbumController extends AbstractActionController
{
  // Add this property:
   private $albumTable;
   private $genreTable;
   private FormElementManager $formElementManager;

   // Add this constructor:
   public function __construct(AlbumTable $albumTable, FormElementManager $formElementManager)
   {
       $this->albumTable = $albumTable;
       $this->formElementManager = $formElementManager;
   }

   public function indexAction()
   {
       // Grab the paginator from the AlbumTable:
       $paginator = $this->albumTable->fetchAll(true);

       // Set the current page to what has been passed in query string,
       // or to 1 if none is set, or the page is invalid:
       $page = (int) $this->params()->fromQuery('page', 1);
       $page = ($page < 1) ? 1 : $page;
       $paginator->setCurrentPageNumber($page);

       // Set the number of items per page to 10:
       $paginator->setItemCountPerPage(10);

       return new ViewModel(['paginator' => $paginator]);
   }

    public function addAction()
    {
        $form = $this->formElementManager->get(AlbumForm::class);

        $form->get('submit')->setValue('Add');

        $request = $this->getRequest();

        if (! $request->isPost()) {
            return ['form' => $form];
        }

        $album = new Album();
        $form->setInputFilter($album->getInputFilter());
        $form->setData($request->getPost());

        if (! $form->isValid()) {
            return ['form' => $form];
        }

        $album->exchangeArray($form->getData());
        $this->saveAlbum($album);
        return $this->redirect()->toRoute('album');
    }

    public function editAction()
    {
      $id = (int) $this->params()->fromRoute('id', 0);

       if (0 === $id) {
           return $this->redirect()->toRoute('album', ['action' => 'add']);
       }

       // Retrieve the album with the specified id. Doing so raises
       // an exception if the album is not found, which should result
       // in redirecting to the landing page.
       try {
           $album = $this->albumTable->getAlbum($id);
       } catch (\Exception $e) {
           return $this->redirect()->toRoute('album', ['action' => 'index']);
       }

       $form = new AlbumForm();
       $form->bind($album);
       $form->get('submit')->setAttribute('value', 'Edit');

       $request = $this->getRequest();
       $viewData = ['id' => $id, 'form' => $form];

       if (! $request->isPost()) {
           return $viewData;
       }

       $form->setInputFilter($album->getInputFilter());
       $form->setData($request->getPost());

       if (! $form->isValid()) {
           return $viewData;
       }

       try {
           $this->albumTable->saveAlbum($album);
       } catch (\Exception $e) {
       }

       // Redirect to album list
       return $this->redirect()->toRoute('album', ['action' => 'index']);
     }


    public function deleteAction()
    {
      $id = (int) $this->params()->fromRoute('id', 0);
        if (!$id) {
            return $this->redirect()->toRoute('album');
        }

        $request = $this->getRequest();
        if ($request->isPost()) {
            $del = $request->getPost('del', 'No');

            if ($del == 'Yes') {
                $id = (int) $request->getPost('id');
                $this->albumTable->deleteAlbum($id);
            }

            // Redirect to list of albums
            return $this->redirect()->toRoute('album');
        }

        return [
            'id'    => $id,
            'album' => $this->albumTable->getAlbum($id),
        ];
    }
}

Is there something I have missed?
Thank you

This is wrong:

You must use the form element manager also in the editAction method.

This is also wrong:

You must set the form element manager to the controller, not the form element!

$container->get(\Laminas\Form\FormElementManager::class)

But you can simplify the entire entry:

    public function getControllerConfig() {
        return [
            'factories' => [
                Controller\AlbumController::class => Laminas\ServiceManager\AbstractFactory\ReflectionBasedAbstractFactory::class,
            ],
        ];
    }

Sorry, once again, I am getting errors.

An error occurred
An error occurred during execution; please try again later.
Additional information:
Laminas\ServiceManager\Exception\ServiceNotFoundException
File:
D:\xampp\htdocs\laminas-mvc-tutorial\vendor\laminas\laminas-servicemanager\src\ServiceManager.php:557
Message:
Unable to resolve service "Album\Controller\AlbumController" to a factory; are you certain you provided it during configuration?

Module.php after latest change:

<?php

namespace Album;

// Add these import statements:
use Laminas\Db\Adapter\AdapterInterface;
use Laminas\Db\ResultSet\ResultSet;
use Laminas\Db\TableGateway\TableGateway;
use Laminas\ModuleManager\Feature\ConfigProviderInterface;


class Module implements ConfigProviderInterface {

    // getConfig() method is here
    public function getConfig() {
        return include __DIR__ . '/../config/module.config.php';
    }

    // Add this method:
    public function getServiceConfig() {
        return [
            'factories' => [
                Model\AlbumTable::class => function ($container) {
                    $tableGateway = $container->get(Model\AlbumTableGateway::class);
                    return new Model\AlbumTable($tableGateway);
                },
                Model\AlbumTableGateway::class => function ($container) {
                    $dbAdapter = $container->get(AdapterInterface::class);
                    $resultSetPrototype = new ResultSet();
                    $resultSetPrototype->setArrayObjectPrototype(new Model\Album());
                    return new TableGateway('album', $dbAdapter, null, $resultSetPrototype);
                },
                Model\GenreTable::class => function ($container) {
                    $tableGateway = $container->get(Model\GenreTableGateway::class);
                    return new Model\GenreTable($tableGateway);
                },
                Model\GenreTableGateway::class => function ($container) {
                    $dbAdapter = $container->get(AdapterInterface::class);
                    $resultSetPrototype = new ResultSet();
                    $resultSetPrototype->setArrayObjectPrototype(new Model\Genre());
                    return new TableGateway('genre', $dbAdapter, null, $resultSetPrototype);
                },
            ],
        ];
    }

    public function getControllerConfig() {
        return [
            'factories' => [
                Controller\AlbumController::class => Laminas\ServiceManager\AbstractFactory\ReflectionBasedAbstractFactory::class,
            ],
        ];
    }
}

AlbumController.php (Add and Edit methods):

   public function addAction()
    {
        $form = $this->formElementManager->get(AlbumForm::class);

        $form->get('submit')->setValue('Add');

        $request = $this->getRequest();

        if (! $request->isPost()) {
            return ['form' => $form];
        }

        $album = new Album();
        $form->setInputFilter($album->getInputFilter());
        $form->setData($request->getPost());

        if (! $form->isValid()) {
            return ['form' => $form];
        }

        $album->exchangeArray($form->getData());
        $this->saveAlbum($album);
        return $this->redirect()->toRoute('album');
    }

    public function editAction()
    {
      $id = (int) $this->params()->fromRoute('id', 0);

       if (0 === $id) {
           return $this->redirect()->toRoute('album', ['action' => 'add']);
       }

       // Retrieve the album with the specified id. Doing so raises
       // an exception if the album is not found, which should result
       // in redirecting to the landing page.
       try {
           $album = $this->albumTable->getAlbum($id);
       } catch (\Exception $e) {
           return $this->redirect()->toRoute('album', ['action' => 'index']);
       }

       $form = $this->formElementManager->get(AlbumForm::class);
       $form->bind($album);
       $form->get('submit')->setAttribute('value', 'Edit');

       $request = $this->getRequest();
       $viewData = ['id' => $id, 'form' => $form];

       if (! $request->isPost()) {
           return $viewData;
       }

       $form->setInputFilter($album->getInputFilter());
       $form->setData($request->getPost());

       if (! $form->isValid()) {
           return $viewData;
       }

       try {
           $this->albumTable->saveAlbum($album);
       } catch (\Exception $e) {
       }

       // Redirect to album list
       return $this->redirect()->toRoute('album', ['action' => 'index']);
     }

I don’t understand, what is going on…

You have a typical problem with the namespace. You have defined a single namespace for the file with Album and therefore PHP is searching for the class Album\Laminas\ServiceManager\AbstractFactory\ReflectionBasedAbstractFactory. But this class does not exists.

Add a backslash:

public function getControllerConfig() {
    return [
        'factories' => [
            Controller\AlbumController::class => \Laminas\ServiceManager\AbstractFactory\ReflectionBasedAbstractFactory::class,
        ],
    ];
}

… or import the namespace for the factory:

use Laminas\ServiceManager\AbstractFactory\ReflectionBasedAbstractFactory;

// …

public function getControllerConfig() {
    return [
        'factories' => [
            Controller\AlbumController::class => ReflectionBasedAbstractFactory::class,
        ],
    ];
}

You should use an IDE like PHPStorm from Jetbrains or something else which provides a feature like “Code Quality Analysis”. The IDE will then inform you immediately that the class cannot be found.
The IDE also helps with PHP’s namespaces and the related imports.

I have used NetBeans as IDE.
I have switched to VisualStudio Code, which is absolutely fantastic.
Now, I am at another level of error.
This time is the Form. My dropdown control is not rendering correctly.
At same time, in Edit mode, is it possible to get the returned genre_id selected in the dropdown?
There are 3 files invoked: add.phtml, edit.phtml, and Album.php
I will list the code below for them.
Please help me to set the correct attributes for the Genre dropdown(Select)

Album.php

<?php
// module/Album/src/Model/Album.php:
namespace Album\Model;

// Add the following import statements:
use DomainException;
use Laminas\Filter\StringTrim;
use Laminas\Filter\StripTags;
use Laminas\Filter\ToInt;
use Laminas\InputFilter\InputFilter;
use Laminas\InputFilter\InputFilterAwareInterface;
use Laminas\InputFilter\InputFilterInterface;
use Laminas\Validator\StringLength;

class Album implements InputFilterAwareInterface
{
    public $id;
    public $artist;
    public $title;
    public $genre_id;

    // Add this property:
    private $inputFilter;

    public function exchangeArray(array $data)
    {
        $this->id     = !empty($data['id']) ? $data['id'] : null;
        $this->artist = !empty($data['artist']) ? $data['artist'] : null;
        $this->title  = !empty($data['title']) ? $data['title'] : null;
        $this->genre_id  = !empty($data['genre_id']) ? $data['genre_id'] : null;
    }

    public function getArrayCopy()
    {
       return [
           'id'     => $this->id,
           'artist' => $this->artist,
           'title'  => $this->title,
           'genre_id'  => $this->genre_id,
       ];
   }

    /* Add the following methods: */

    public function setInputFilter(InputFilterInterface $inputFilter)
    {
        throw new DomainException(sprintf(
            '%s does not allow injection of an alternate input filter',
            __CLASS__
        ));
    }

    public function getInputFilter()
    {
        if ($this->inputFilter) {
            return $this->inputFilter;
        }

        $inputFilter = new InputFilter();

        $inputFilter->add([
            'name' => 'id',
            'required' => true,
            'filters' => [
                ['name' => ToInt::class],
            ],
        ]);

        $inputFilter->add([
            'name' => 'artist',
            'required' => true,
            'filters' => [
                ['name' => StripTags::class],
                ['name' => StringTrim::class],
            ],
            'validators' => [
                [
                    'name' => StringLength::class,
                    'options' => [
                        'encoding' => 'UTF-8',
                        'min' => 1,
                        'max' => 100,
                    ],
                ],
            ],
        ]);

        $inputFilter->add([
            'name' => 'title',
            'required' => true,
            'filters' => [
                ['name' => StripTags::class],
                ['name' => StringTrim::class],
            ],
            'validators' => [
                [
                    'name' => StringLength::class,
                    'options' => [
                        'encoding' => 'UTF-8',
                        'min' => 1,
                        'max' => 100,
                    ],
                ],
            ],
        ]);

        $inputFilter->add([
            'name' => 'genre_id',
            'required' => true,
            'filters' => [
                ['name' => ToInt::class],
            ],
        ]);


        $this->inputFilter = $inputFilter;
        return $this->inputFilter;
    }
}

add.phtml

<?php
// module/Album/view/album/album/add.phtml:


$title = 'Add new album';
$this->headTitle($title);
?>
<h1><?= $this->escapeHtml($title) ?></h1>
<?php
// This provides a default CSS class and placeholder text for the title element:
$album = $form->get('title');
$album->setAttribute('class', 'form-control');
$album->setAttribute('placeholder', 'Album title');

// This provides a default CSS class and placeholder text for the artist element:
$artist = $form->get('artist');
$artist->setAttribute('class', 'form-control');
$artist->setAttribute('placeholder', 'Artist');

$genre_id = $form->get('genre_id');
$genre_id->setAttribute('class', 'form-control');
$genre_id->setAttribute('placeholder', 'Genre');

// This provides CSS classes for the submit button:
$submit = $form->get('submit');
$submit->setAttribute('class', 'btn btn-primary');

$form->setAttribute('action', $this->url('album', ['action' => 'add']));
$form->prepare();

echo $this->form()->openTag($form);
?>
<?php // Wrap the elements in divs marked as form groups, and render the
      // label, element, and errors separately within ?>
<div class="form-group">
    <?= $this->formLabel($album) ?>
    <?= $this->formElement($album) ?>
    <?= $this->formElementErrors()->render($album, ['class' => 'help-block']) ?>
</div>

<div class="form-group">
    <?= $this->formLabel($artist) ?>
    <?= $this->formElement($artist) ?>
    <?= $this->formElementErrors()->render($artist, ['class' => 'help-block']) ?>
</div>

<div class="form-group">
    <?= $this->formLabel($genre_id) ?>
    <?= $this->formElement($genre_id) ?>
    <?= $this->formElementErrors()->render($genre_id, ['class' => 'help-block']) ?>
</div>

<?php
echo $this->formSubmit($submit);
echo $this->formHidden($form->get('id'));
echo $this->form()->closeTag();

edit.phtml

<?php
// module/Album/view/album/album/edit.phtml:

$title = 'Edit album';
$this->headTitle($title);
?>
<h1><?= $this->escapeHtml($title) ?></h1>
<?php
$album = $form->get('title');
$album->setAttribute('class', 'form-control');
$album->setAttribute('placeholder', 'Album title');

$artist = $form->get('artist');
$artist->setAttribute('class', 'form-control');
$artist->setAttribute('placeholder', 'Artist');

$genre_id = $form->get('genre_id');
$genre_id->setAttribute('class', 'form-control');
$genre_id->setAttribute('placeholder', 'Genre');

$submit = $form->get('submit');
$submit->setAttribute('class', 'btn btn-primary');

$form->setAttribute('action', $this->url('album', [
    'action' => 'edit',
    'id'     => $id,
]));
$form->prepare();

echo $this->form()->openTag($form);
?>
<div class="form-group">
    <?= $this->formLabel($album) ?>
    <?= $this->formElement($album) ?>
    <?= $this->formElementErrors()->render($album, ['class' => 'help-block']) ?>
</div>

<div class="form-group">
    <?= $this->formLabel($artist) ?>
    <?= $this->formElement($artist) ?>
    <?= $this->formElementErrors()->render($artist, ['class' => 'help-block']) ?>
</div>

<div class="form-group">
    <?= $this->formLabel($genre_id) ?>
    <?= $this->formElement($genre_id) ?>
    <?= $this->formElementErrors()->render($genre_id, ['class' => 'help-block']) ?>
</div>

<?php
echo $this->formSubmit($submit);
echo $this->formHidden($form->get('id'));
echo $this->form()->closeTag();

You bind your object to the form, which means that your model also needs the genre_id.
The form extracts the data from your Album model and uses this data to set the values for the form elements. In the case of the select element, it needs a datum called genre_id.
The extraction is done via the Album::getArrayCopy method, so the datum must also be included there.

(When this problem is solved, we can simplify the view scripts.)

Well, the classic logic would be to add a hidden control to the form with genre_id retrieved from the album table. When in Edit mode, to implement some loop, foreach option in select, if option.genre_id == hidden.genre_id then option.set_selected()


Album model already returns the genre_id field.
Still to implement the rest.

Is definitely not needed.

Thank you, but I didn’t get it.
I need some visual example, please.
Spent half a day, looking for examples on web and in Laminas documentation, and didn’t find anything.
I actually have my form rendered with an empty select for Genre. It is even not getting populated.
Second. not sure if I am doing this the proper way.

Laminas\Form\View\Helper\FormLabel::__invoke expects either label content as the second argument, or that the element provided has a label attribute; neither found

Do you mean that no option is present:

<select name="genre_id"></select>

Or do you mean that no option is selected:

<select name="genre_id">
   <option value="1">Pop</option>
   <option value="2">Jazz</option>
   <option value="3" selected>Rock</option> <!-- selected is missing -->
</select>