In-Depth Tutorial
Making Use of Forms and Fieldsets
So far all we have done is read data from the database. In a real-lifeapplication, this won't get us very far, as we'll often need to support the fullrange of fullCreate,Read,Update andDelete operations (CRUD).Typically, new data will arrive via web form submissions.
Form components
Thezend-form andzend-inputfilter componentsprovide us with the ability to create fully-featured forms and their validationrules. zend-form consumes zend-inputfilter internally, so let's take a look atthe elements of zend-form that we will use for our application.
Fieldsets
Zend\Form\Fieldset models a reusable set of elements. You will use aFieldset to create the various HTML inputs needed to map to your server-sideentities. It is considered good practice to have oneFieldset for every entityin your application.
TheFieldset component, however, is not a form, meaning you will not be ableto use aFieldset without attaching it to theZend\Form\Form instance. Theadvantage here is that you have one set of elements that you can re-use for asmany forms as you like.
Forms
Zend\Form\Form is a container for all elements of your HTML<form>. You areable to add both single elements or fieldsets (modeled asZend\Form\Fieldsetinstances).
Creating your first Fieldset
Explaining how zend-form works is best done by giving you realcode to work with. So let's jump right into it and create all the forms we needto finish ourBlog module. We start by creating aFieldset that contains allthe input elements that we need to work with our blog data:
- You will need one hidden input for the
idproperty, which is only needed for editting and deleting data. - You will need one text input for the
titleproperty. - You will need one textarea for the
textproperty.
Create the filemodule/Blog/src/Form/PostFieldset.php with the followingcontents:
<?phpnamespace Blog\Form;use Zend\Form\Fieldset;class PostFieldset extends Fieldset{ public function init() { $this->add([ 'type' => 'hidden', 'name' => 'id', ]); $this->add([ 'type' => 'text', 'name' => 'title', 'options' => [ 'label' => 'Post Title', ], ]); $this->add([ 'type' => 'textarea', 'name' => 'text', 'options' => [ 'label' => 'Post Text', ], ]); }}This new class creates an extension ofZend\Form\Fieldset that, in aninit()method (more on this later), adds elements for each aspect of our blog post. Wecan now re-use this fieldset in as many forms as we want. Let's create our firstform.
Creating the PostForm
Now that we have ourPostFieldset in place, we can use it inside aForm.The form will use thePostFieldset, and also include a submit button so thatthe user can submit the data.
Create the filemodule/Blog/src/Form/PostForm.php with the following contents:
<?phpnamespace Blog\Form;use Zend\Form\Form;class PostForm extends Form{ public function init() { $this->add([ 'name' => 'post', 'type' => PostFieldset::class, ]); $this->add([ 'type' => 'submit', 'name' => 'submit', 'attributes' => [ 'value' => 'Insert new Post', ], ]); }}And that's our form. Nothing special here, we add ourPostFieldset to theform, we add a submit button to the form, and nothing more.
Adding a new Post
Now that we have thePostForm written, it's time to use it. But there are afew more tasks left:
- We need to create a new controller
WriteControllerwhich accepts the following instances via its constructor: - a
PostCommandInterfaceinstance - a
PostForminstance - We need to create an
addAction()method in the newWriteControllerto handle displaying the form and processing it. - We need to create a new route,
blog/add, that routes to theWriteControllerand itsaddAction()method. - We need to create a new view script to display the form.
Creating the WriteController
While we could re-use our existing controller, it has a differentresponsibility: it will bewriting new blog posts. As such, it will need toemitcommands, and thus use thePostCommandInterface that we have definedpreviously.
To do that, it needs to accept and process user input, which we have modeledin ourPostForm in a previous section of this chapter.
Let's create this new class now. Open a new file,module/Blog/src/Controller/WriteController.php, and add the followingcontents:
<?phpnamespace Blog\Controller;use Blog\Form\PostForm;use Blog\Model\Post;use Blog\Model\PostCommandInterface;use Zend\Mvc\Controller\AbstractActionController;use Zend\View\Model\ViewModel;class WriteController extends AbstractActionController{ /** * @var PostCommandInterface */ private $command; /** * @var PostForm */ private $form; /** * @param PostCommandInterface $command * @param PostForm $form */ public function __construct(PostCommandInterface $command, PostForm $form) { $this->command = $command; $this->form = $form; } public function addAction() { }}We'll now create a factory for this new controller; create a new file,module/Blog/src/Factory/WriteControllerFactory.php, with the followingcontents:
<?phpnamespace Blog\Factory;use Blog\Controller\WriteController;use Blog\Form\PostForm;use Blog\Model\PostCommandInterface;use Interop\Container\ContainerInterface;use Zend\ServiceManager\Factory\FactoryInterface;class WriteControllerFactory implements FactoryInterface{ /** * @param ContainerInterface $container * @param string $requestedName * @param null|array $options * @return WriteController */ public function __invoke(ContainerInterface $container, $requestedName, array $options = null) { $formManager = $container->get('FormElementManager'); return new WriteController( $container->get(PostCommandInterface::class), $formManager->get(PostForm::class) ); }}The above factory introduces something new: theFormElementManager. This is aplugin manager implementation that is specifically for forms. We don'tnecessarily need to register our forms with it, as it will check to see if arequested instance is a form when attempting to pull one from it. However, itdoes provide a couple nice features:
- If the form or fieldset or element retrieved implements an
init()method, it invokes that method after instantiation. This is useful, as that way we're initializing after we have all our dependencies injected, such as input filters. Our form and fieldset define this method! - It ensures that the various plugin managers related to input validation are shared with the instance, a feature we'll be using later.
Finally, we need to configure the new factory; inmodule/Blog/config/module.config.php, add an entry in thecontrollersconfiguration section:
'controllers' => [ 'factories' => [ Controller\ListController::class => Factory\ListControllerFactory::class, // Add the following line: Controller\WriteController::class => Factory\WriteControllerFactory::class, ],],Now that we have the basics for our controller in place, we can create a routeto it:
<?php// In module/Blog/config/module.config.php:namespace Blog;use Zend\Router\Http\Literal;use Zend\Router\Http\Segment;use Zend\ServiceManager\Factory\InvokableFactory;return [ 'service_manager' => [ /* ... */ ], 'controllers' => [ /* ... */ ], 'router' => [ 'routes' => [ 'blog' => [ 'type' => Literal::class, 'options' => [ 'route' => '/blog', 'defaults' => [ 'controller' => Controller\ListController::class, 'action' => 'index', ], ], 'may_terminate' => true, 'child_routes' => [ 'detail' => [ 'type' => Segment::class, 'options' => [ 'route' => '/:id', 'defaults' => [ 'action' => 'detail', ], 'constraints' => [ 'id' => '\d+', ], ], ], // Add the following route: 'add' => [ 'type' => Literal::class, 'options' => [ 'route' => '/add', 'defaults' => [ 'controller' => Controller\WriteController::class, 'action' => 'add', ], ], ], ], ], ], ], 'view_manager' => [ /* ... */ ],];Finally, we'll create a dummy template:
<!-- Filename: module/Blog/view/blog/write/add.phtml --><h1>WriteController::addAction()</h1>Check-in
If you try to access the new routelocalhost:8080/blog/add you're supposed tosee the following error message:
An error occurredAn error occurred during execution; please try again later.Additional information:Zend\ServiceManager\Exception\ServiceNotFoundExceptionFile:{projectPath}/vendor/zendframework/zend-servicemanager/src/ServiceManager.php:{lineNumber}Message:Unable to resolve service "Blog\Model\PostCommandInterface" to a factory; are you certain you provided it during configuration?If this is not the case, be sure to follow the tutorial correctly and carefullycheck all your files.
The error is due to the fact that we have not yet defined animplementation ofourPostCommandInterface, much less wired the implementation into ourapplication!
Let's create a dummy implementation, as we did when we first started workingwith repositories. Create the filemodule/Blog/src/Model/PostCommand.php withthe following contents:
<?phpnamespace Blog\Model;class PostCommand implements PostCommandInterface{ /** * {@inheritDoc} */ public function insertPost(Post $post) { } /** * {@inheritDoc} */ public function updatePost(Post $post) { } /** * {@inheritDoc} */ public function deletePost(Post $post) { }}Now add service configuration inmodule/Blog/config/module.config.php:
'service_manager' => [ 'aliases' => [ /* ... */ // Add the following line: Model\PostCommandInterface::class => Model\PostCommand::class, ], 'factories' => [ /* ... */ // Add the following line: Model\PostCommand::class => InvokableFactory::class, ],],Reloading your application now will yield you the desired result.
Displaying the form
Now that we have new controller working, it's time to pass this form to the viewand render it. Change your controller so that the form is passed to the view:
// In /module/Blog/src/Controller/WriteController.php:public function addAction(){ return new ViewModel([ 'form' => $this->form, ]);}And then we need to modify our view to render the form:
<!-- Filename: module/Blog/view/blog/write/add.phtml --><h1>Add a blog post</h1><?php$form = $this->form;$form->setAttribute('action', $this->url());$form->prepare();echo $this->form()->openTag($form);echo $this->formCollection($form);echo $this->form()->closeTag();The above does the following:
- We set the
actionattribute of the form to the current URL. - We "prepare" the form; this ensures any data or error messages bound to the form or its various elements are injected and ready to use for display purposes.
- We render an opening tag for the form we are using.
- We render the contents of the form, using the
formCollection()view helper; this is a convenience method with some typically sane default markup. We'll be changing it momentarily. - We render a closing tag for the form.
Form method
HTML forms can be sent using
POSTandGET. zend-form defaults toPOST.If you want to switch toGET:$form->setAttribute('method', 'GET');
Refreshing the browser you will now see your form properly displayed. It's notpretty, though, as the default markup does not follow semantics for Bootstrap(which is used in the skeleton application by default). Let's update it a bit tomake it look better; we'll do that in the view script itself, as markup-relatedconcerns belong in the view layer:
<!-- Filename: module/Blog/view/blog/write/add.phtml --><h1>Add a blog post</h1><?php$form = $this->form;$form->setAttribute('action', $this->url());$fieldset = $form->get('post');$title = $fieldset->get('title');$title->setAttribute('class', 'form-control');$title->setAttribute('placeholder', 'Post title');$text = $fieldset->get('text');$text->setAttribute('class', 'form-control');$text->setAttribute('placeholder', 'Post content');$submit = $form->get('submit');$submit->setAttribute('class', 'btn btn-primary');$form->prepare();echo $this->form()->openTag($form);?><fieldset><div class="form-group"> <?= $this->formLabel($title) ?> <?= $this->formElement($title) ?> <?= $this->formElementErrors()->render($title, ['class' => 'help-block']) ?></div><div class="form-group"> <?= $this->formLabel($text) ?> <?= $this->formElement($text) ?> <?= $this->formElementErrors()->render($text, ['class' => 'help-block']) ?></div></fieldset><?phpecho $this->formSubmit($submit);echo $this->formHidden($fieldset->get('id'));echo $this->form()->closeTag();The above adds HTML attributes to a number of the elements we've defined, anduses more specific view helpers to allow us to render the exact markup we wantfor our form.
However, if we're submitting the form all we see is our form being displayedagain. And this is due to the simple fact that we didn't add any logic to thecontroller yet.
General form-handling logic for controllers
Writing a controller that handles a form workflow follows the same basic patternregardless of form and entities:
- You need to check if the HTTP request method is via
POST, meaning if the form has been sent. - If the form has been sent, you need to:
- pass the submitted data to your
Forminstance - validate the
Forminstance - If the form passes validation, you will:
- persist the form data
- redirect the user to either the detail page of the entered data, or to an overview page
- In all other cases, you need to display the form, potentially with error messages.
Modify yourWriteController:addAction() to read as follows:
public function addAction(){ $request = $this->getRequest(); $viewModel = new ViewModel(['form' => $this->form]); if (! $request->isPost()) { return $viewModel; } $this->form->setData($request->getPost()); if (! $this->form->isValid()) { return $viewModel; } $data = $this->form->getData()['post']; $post = new Post($data['title'], $data['text']); try { $post = $this->command->insertPost($post); } catch (\Exception $ex) { // An exception occurred; we may want to log this later and/or // report it to the user. For now, we'll just re-throw. throw $ex; } return $this->redirect()->toRoute( 'blog/detail', ['id' => $post->getId()] );}Stepping through the code:
- We retrieve the current request.
- We create a default view model containing the form.
- If we do not have a
POSTrequest, we return the default view model. - We populate the form with data from the request.
- If the form is not valid, we return the default view model; at this point, the form will also contain error messages.
- We create a
Postinstance from the validated data. - We attempt to insert the post.
- On success, we redirect to the post's detail page.
Child route names
When using the various
url()helpers provided in zend-mvc and zend-view,you need to provide the name of a route. When using child routes, the routename is of the form<parent>/<child>— i.e., the parent name and childname are separated with a slash.
Submitting the form right now will return into the following error
Fatal error: Call to a member function getId() on null in{projectPath}/module/Blog/src/Controller/WriteController.phpon line {lineNumber}This is because our stubPostCommand class does not return a newPostinstance, violating the contract!
Let's create a new implementation to work against zend-db. Create the filemodule/Blog/src/Model/ZendDbSqlCommand.php with the following contents:
<?phpnamespace Blog\Model;use RuntimeException;use Zend\Db\Adapter\AdapterInterface;use Zend\Db\Adapter\Driver\ResultInterface;use Zend\Db\Sql\Delete;use Zend\Db\Sql\Insert;use Zend\Db\Sql\Sql;use Zend\Db\Sql\Update;class ZendDbSqlCommand implements PostCommandInterface{ /** * @var AdapterInterface */ private $db; /** * @param AdapterInterface $db */ public function __construct(AdapterInterface $db) { $this->db = $db; } /** * {@inheritDoc} */ public function insertPost(Post $post) { $insert = new Insert('posts'); $insert->values([ 'title' => $post->getTitle(), 'text' => $post->getText(), ]); $sql = new Sql($this->db); $statement = $sql->prepareStatementForSqlObject($insert); $result = $statement->execute(); if (! $result instanceof ResultInterface) { throw new RuntimeException( 'Database error occurred during blog post insert operation' ); } $id = $result->getGeneratedValue(); return new Post( $post->getTitle(), $post->getText(), $id ); } /** * {@inheritDoc} */ public function updatePost(Post $post) { } /** * {@inheritDoc} */ public function deletePost(Post $post) { }}In theinsertPost() method, we do the following:
- We create a
Zend\Db\Sql\Insertinstance, providing it the table name. - We add values to the
Insertinstance. - We create a
Zend\Db\Sql\Sqlinstance with the database adapter, and prepare a statement from ourInsertinstance. - We execute the statement and check for a valid result.
- We marshal a return value.
Now that we have this in place, we'll create a factory for it; create the filemodule/Blog/src/Factory/ZendDbSqlCommandFactory.php with the followingcontents:
<?phpnamespace Blog\Factory;use Interop\Container\ContainerInterface;use Blog\Model\ZendDbSqlCommand;use Zend\Db\Adapter\AdapterInterface;use Zend\ServiceManager\Factory\FactoryInterface;class ZendDbSqlCommandFactory implements FactoryInterface{ public function __invoke(ContainerInterface $container, $requestedName, array $options = null) { return new ZendDbSqlCommand($container->get(AdapterInterface::class)); }}And finally, we'll wire it up in the configuration; update theservice_managersection ofmodule/Blog/config/module.config.php to read as follows:
'service_manager' => [ 'aliases' => [ Model\PostRepositoryInterface::class => Model\ZendDbSqlRepository::class, // Update the following alias: Model\PostCommandInterface::class => Model\ZendDbSqlCommand::class, ], 'factories' => [ Model\PostRepository::class => InvokableFactory::class, Model\ZendDbSqlRepository::class => Factory\ZendDbSqlRepositoryFactory::class, Model\PostCommand::class => InvokableFactory::class, // Add the following line: Model\ZendDbSqlCommand::class => Factory\ZendDbSqlCommandFactory::class, ],],Submitting your form again, it should process the form and redirect you to thedetail page for the new entry!
Let's see if we can improve this a bit.
Using zend-hydrator with zend-form
In our controller currently, we have the following:
$data = $this->form->getData()['post'];$post = new Post($data['title'], $data['text']);What if we could automate that, so we didn't need to worry about:
- Whether or not we're using a fieldset
- What the form fields are named
Fortunately, zend-form features integration with zend-hydrator. This will allowus to return aPost instance when we retrieve the validated values!
Let's udpate our fieldset to provide a hydrator and a prototype object.
First, add two import statements to the top of the class file:
// In module/Blog/src/Form/PostFieldset.php:use Blog\Model\Post;use Zend\Hydrator\Reflection as ReflectionHydrator;Next, update theinit() method to add the following two lines:
// In /module/Blog/src/Form/PostFieldset.php:public function init(){ $this->setHydrator(new ReflectionHydrator()); $this->setObject(new Post('', '')); /* ... */}When you grab the data from this fieldset, it will be returned as aPostinstance.
However, we grab datafrom the form; how can we simplify that interaction?
Since we only have the one fieldset, we'll set it as the form'sbase fieldset.This hints to the form that when we retrieve data from it, it should return thevalues from the specified fieldset instead; since our fieldset returns thePost instance, we'll have exactly what we need.
Modify yourPostForm class as follows:
// In /module/Blog/src/Form/PostForm.php:public function init(){ $this->add([ 'name' => 'post', 'type' => PostFieldset::class, 'options' => [ 'use_as_base_fieldset' => true, ], ]); /* ... */Let's update ourWriteController; modify theaddAction() method to replacethe following two lines:
$data = $this->form->getData()['post'];$post = new Post($data['title'], $data['text']);to:
$post = $this->form->getData();Everything should continue to work. The changes done serve the purpose ofde-coupling the details of how the form is structured from the controller,allowing us to work directly with our entities at all times!
Conclusion
In this chapter, we've learned the fundamentals of using zend-form, includingadding fieldsets and elements, rendering the form, validating input, and wiringforms and fieldsets to use entities.
In the next chapter we will finalize the CRUD functionality by creating theupdate and delete routines for the blog module.
Found a mistake or want to contribute to the documentation? Edit this page on GitHub!