TLDR? Get this little tool I rigged. Takes care of all this for you.
It's probably difficult to recall the "thing" that prompts us into crafting the best forms possible, or especially to neophytes, what a clean form might be. Personally, I'd seen a really clean example in a GitHub repo and then mentally compared it with what I'd coded...then...lightbulb! "Damn, that's how that's done!"
In a framework like ZF2 where there's a lot of boilerplate code at play, cleanliness is truly godliness. So can forms be beautiful? I think so.
I've struggled with forms and ZF2 for some time. In hindsight, I even documented my struggle along the way. I think I've had time to distill the outcome; and I'll propose a blanket approach in what follows. Its benefit is that if you need to do 'something', you'll know precisely where it needs to get done.
By adopting this, we on a handshake agree that: forms should be built by factories, that input filters should be separated from form construction logic, and that annotation builders are pure evil (had to squeeze that last one in!).
Some Code...
Consider a form that needs to store tax settings (TaxForm). The contract is:
- It has to be aware of country codes; good opportunity to show how to separate dependencies via Factory.
- I also use Doctrine, and need it to spit out a Tax object.
- When a country is selected, the 'state' select is populated.
- When a country is submitted (on Form post) I always want it to repopulate the "state" select.
Let's get started.
Factory and Form
Going stepwise, the basic skeleton for a factory is this:
namespace Application\Factory\Form;
use Zend\ServiceManager\FactoryInterface;
use Zend\ServiceManager\ServiceLocatorInterface;
class TaxFormFactory implements FactoryInterface{
public function createService(ServiceLocatorInterface $serviceLocator){
}
}
Passing config options to the factory is easy with `MutableCreationOptionsInterface`. We modify our skeleton slightly to accept 'options', so that it can receive 'country' information.
namespace Application\Factory\Form;
use Zend\ServiceManager\FactoryInterface;
use Zend\ServiceManager\ServiceLocatorInterface;
use Zend\ServiceManager\MutableCreationOptionsInterface;
class TaxFormFactory implements FactoryInterface, MutableCreationOptionsInterface{
protected $options;
public function setCreationOptions( array $options )
{
$this->options = $options;
}
public function createService(ServiceLocatorInterface $serviceLocator){
}
}
When we create the form through the service manager, we can now pass a second parameter that contains the country like so:
$form = $sm->get( 'FormElementManager' )->get( TaxForm::class, ['country' => $this->params()->fromPost('country')] );
Finished Factory
namespace Application\Factory\Form;
use Application\Entity\Tax;
use Application\Form\TaxForm;
use Application\InputFilter\TaxInputFilter;
use DoctrineModule\Stdlib\Hydrator\DoctrineObject;
use Zend\ServiceManager\FactoryInterface;
use Zend\ServiceManager\ServiceLocatorInterface;
use Phine\Country\Loader\Loader;
use Zend\ServiceManager\MutableCreationOptionsInterface;
class TaxFormFactory implements FactoryInterface, MutableCreationOptionsInterface{
protected $options;
public function setCreationOptions( array $options )
{
$this->options = $options;
}
public function createService(ServiceLocatorInterface $serviceLocator){
/**
* @var \Zend\Form\FormElementManager $serviceLocator
* @var \Zend\ServiceManager\ServiceManager $serviceManager
*/
$serviceManager = $serviceLocator->getServiceLocator();
try
{
$country_loader = new Loader();
$countries = $country_loader->loadCountries();
$countries = array_map( function( $a ){ return $a->getShortName(); }, $countries );
$states = [];
if( isset( $this->options['country']) )
{
$subdivisions = $country_loader->loadSubdivisions( $this->options['country'] );
foreach( $subdivisions as $s => $d )
{
list(, $code) = explode( '-', $s, 2 );
$states[$code] = $d->getName();
}
}
$form = new TaxForm( $countries, $states );
$form->setTranslator( $serviceManager->get('translator') );
$form->setHydrator( new DoctrineObject( $serviceManager->get('doctrine.entitymanager.orm_default'), false ) );
$form->setObject( new Tax() );
$form->setInputFilter( $serviceManager->get('InputFilterManager')->get( TaxInputFilter::class ) );
}
}
Reading through the code:
- I'm using Phine to figure out country subdivisions ("phine/country":"dev-master"), dealing with the whole country situation (building a $countries and $states array set in the process)
- I'm creating a new form (code for that form below)
- I'm setting a Doctrine hydrator for later extraction
- I'm setting an Input filter that I create separately (code also below)
Using ::class not only really cleans things up, you'll create fewer strings throughout, and it makes things hella easy to remember while you're coding when introspection kicks in.
Finished Form
I'll spare all the gory field details, only including country and state; important take-away is that the constructor and init, are separate. I needed the translator in here too, the TranslatorAwareTrait really comes in handy.
namespace Application\Form;
use Zend\Form\Element;
use Zend\Captcha;
use Zend\InputFilter;
use Zend\Form\Element\Text;
use Zend\Form\Form;
use Zend\Form\Element\Select;
use Zend\Form\Element\Radio;
use Zend\Form\Element\Hidden;
use Zend\I18n\Translator\TranslatorAwareTrait;
class TaxForm extends Form
{
use TranslatorAwareTrait;
private $countries;
private $states;
public function __construct( $countries = [], $states = [] )
{
$this->countries = $countries;
$this->states = $states;
parent::__construct('taxes', []);
}
public function init()
{
$label_col = 'col-sm-3';
$field_col = 'sm-9';
$this->add([
'name' => 'country',
'type' => Select::class,
'options' => [
'label' => _( "Country" ),
'value_options' => array_merge( [$this->translator->translate( 'All' )], $this->countries ),
'column-size' => $field_col,
'label_attributes' => ['class' => $label_col],
],
'attributes' => [
'maxlength' => 2,
],
]);
$this->add([
'name' => 'state',
'type' => Select::class,
'options' => [
'label' => _( "State" ),
'value_options' => array_merge( [$this->translator->translate( 'All' )], $this->states ),
'column-size' => $field_col,
'label_attributes' => ['class' => $label_col],
],
'attributes' => [
'maxlength' => 2,
],
]);
}
}
An aside, the column size stuff that's in there, is there to support a great Twitter Bootstrap form project I like to use.
That's it - the form is done! Mosey on over to your module config, and add this to your use statements, and form_elements configuration:
use Application\Form\TaxForm;
use Application\Factory\Form\TaxFormFactory;
return [
//...
'form_elements' => [
'factories' => [
TaxForm::class => TaxFormFactory::class,
],
],
//...
];
So, barring our completion of the InputFactory and Doctrine Entity, the "Form" side of things is done. Let's take a look at InputFilters, which are what you should be configuring to validate your forms.
Don't do getInputFilter right inside the form, you'll be thankful when you need your validator down the road (e.g., zf-content-validation, or Apigility). They're good outside of forms! You'll see!
InputFilter and Factory
Input filters are specifications for filtering and validating. Remember that filters run before validators - that'd been a source of hair loss for me when I was starting out.
The factory's need is arguable if your filter only uses baked in filters/validators. I have admittedly skipped factories time and again. Quite often though you'll need to do some Entity-based validation, for example, make sure that another Tax with a same name doesn't exist in the database (or another user with the same email, etc.). I'm using Doctrine, so I need the Factory specifically to inject the Entity Manager's Tax repository into my actual InputFilter. So:
namespace Application\Factory\InputFilter\TaxInputFilterFactory;
use Zend\ServiceManager\FactoryInterface;
use Zend\ServiceManager\MutableCreationOptionsInterface;
use Zend\ServiceManager\ServiceLocatorInterface;
use Application\Entity\Tax;
use Application\InputFilter\TaxInputFilter;
class TaxInputFilterFactory implements FactoryInterface
{
public function createService( ServiceLocatorInterface $serviceLocator )
{
$repo = $serviceLocator->getServiceLocator()
->get('Doctrine\ORM\EntityManager')
->getRepository( Tax::class );
return new TaxInputFilter( $repo );
}
}
Very basic, but the one big gotcha to note is that when you run an input filter factory, the service locator that you get is not the main service locator. I needed to run ->getServiceLocator() on the passed $serviceLocator to get the main SL (which can then, get me my repository).
The TaxInputFilter is equally simple:
use Zend\Filter\StringTrim;
use Zend\InputFilter\InputFilter;
use Zend\Form\Element;
use CirclicalUser\Form\Filter\ArrayBlock;
use HTMLPurifier;
use Zend\Validator\Hostname;
class TaxInputFilter extends InputFilter
{
private $repository;
public function __construct
public function init()
{
$this->add([
'name' => 'name',
'required' => true,
'filters' => [
['name' => ArrayBlock::class],
['name' => StringTrim::class],
['name' => HTMLPurifier::class],
],
]);
$this->add([
'name' => 'identification',
'required' => true,
'filters' => [
['name' => ArrayBlock::class],
['name' => StringTrim::class],
['name' => HTMLPurifier::class],
],
]);
//...
}
}
So, to tie that into your config, add these lines to your module.config.php:
use Application\Factory\InputFilter\TaxInputFilterFactory;
use Application\InputFilter\TaxInputFilter;
'input_filters' => [
'factories' => [
TaxInputFilter::class =>TaxInputFilterFactory::class,
],
],
Now! Here's where you and I probably reach the same conclusion:
Damn that's a lot of boilerplate!
I agree -- maybe I can convince you to consider this tool that does all the work for you?
Making It Work (..and Hydration)
We're not quite done, for all of this to work you have to invoke your Form properly.
Bad:
$form = $sm->get( TaxForm::class, $opts );
Good:
$form = $sm->get( 'FormElementManager' )->get( TaxForm::class, $opts );
I've also taken a lazy approach, and have developed a utility class that lets me process any such form with a mapper.
namespace Application\Service;
use Application\Mapper\AbstractDoctrineMapper;
use Zend\Form\Form;
use Zend\ServiceManager\ServiceLocatorAwareInterface;
use Zend\ServiceManager\ServiceLocatorAwareTrait;
class FormService implements ServiceLocatorAwareInterface {
use ServiceLocatorAwareTrait;
/**
* Utility function for all those cases where we process forms to store entities within their respective mappers.
* Psst. This happens often.
*
* @param $form
* @param $mapper
* @param $response
* @return null
*/
public function processFormWithMapper( Form $form, $data, AbstractDoctrineMapper $mapper, Array &$response )
{
$object = null;
$object = null;
try
{
/** @var Form $data */
$object = $form->getObject();
$form->setData( $data );
if( !empty($data['id']) )
if( $object = $mapper->getRepository()->findOneBy(['id' => $data['id']]) )
$form->bind( $object );
if( $form->isValid() )
{
if( $object->getId() )
$mapper->update( $form->getObject() );
else
$mapper->save( $form->getObject() );
$response['success'] = true;
$response['message'] = "The configuration was successfully saved!";
}
else
{
$response['message'] = "Hm. That didn't work - check the errors on the form.";
$response['form_errors'] = $form->getMessages();
}
}
catch( \Exception $x )
{
$response['messsage'] = $x->getMessage();
}
return $object;
}
}
My usage pattern ends up looking like this:
public function saveAction()
{
$response = ['success' => false];
$sm = $this->getServiceLocator();
$post = $this->params()->fromPost();
if( $this->getRequest()->isPost() )
{
$opts = null;
if( $country = $this->params()->fromPost( 'country' ) )
$opts = [ 'country' => $country ];
$form = $sm->get( 'FormElementManager' )->get( TaxForm::class, $opts );
/**
* @var FormService $form_service
* @var TaxMapper $tax_mapper
*/
$tax_mapper = $sm->get( TaxMapper::class );
$form_service = $sm->get( FormService::class );
$tax_object = $form_service->processFormWithMapper( $form, $post, $tax_mapper, $response );
$response['success'] = $tax_object != null;
$response['rows'] = $tax_mapper->getList();
}
return new JsonModel( $response );
}
Thanks for reading! Drop me a note if you find any fixes or have recommendations for the form tool!