Building My First Application with Zend, Part 7 (Finale)


Whoa boy do we have a lot to cover this round! I got my input filters in place, created my first hydrator strategy, built my first custom validator, and dealt with uploading and manipulating images! In short, I finished off the basics of the add/edit movie model! Yay!! Also, as this thing is probably getting a bit complicated and hard to follow, feel free to grab the zip file of my modules! 🙂

So first, the file. I wanted to be able to upload a cover of a movie to display, oh and take that cover and make both thumbnails (for browsing displays) and a fuller size for the info pages available. To that end, I added a new field to my form, a file upload field just before our save button.

$element = new Element\File('moviecover');
$element->setLabel('Cover Image')
	->setAttributes(
		array(
			'id' => 'moviecover',
			'accept' => 'image/*'
			)
		);

$this->add($element);

I use the HTML 5 attribute of "accept" to tell the browser to only allow images to be accepted. We will actually only accept JPG or PNG images, but this is just intended to be a basic image. I then added a new attribute to my Movie Entity for the new moviecover for use in our hydrations. A quick snipped up version just to show the three spots it needed to go.

<?php
	namespace MovieShelves\Movies;

	class MovieEntity {
		...
		protected $hasCover;
		protected $moviecover;
		
		public function __construct() {
		}
		
		public function exchangeArray($data) {
			...
			$this->sethasCover((isset($data['hasCover'])) ? $data['hasCover'] : false);
			$this->setMovieCover((isset($data['moviecover'])) ? $data['moviecover'] : false);

		}
		
		...
		public function setHasCover($hasCover) {
			$this->hasCover = $hasCover;	
		}
		public function getMovieCover() {
			return $this->moviecover;	
		}
		public function setMovieCover($moviecover) {
			$this->moviecover = $moviecover;	
		}
		
	}
?>

With that part added, now we need to deal with input. By default, if you don't declare an input filter, Zend will make presumptions based on the attributes and/or options of your fields. For example, all fields will be presumed to required and it would do basic checks for any fields declared with HTML 5 types, like date or digits. To exercise more control, and improve our filtration, we need to make an input filter. As with the form, this is a big chunk of code.

<?php
	namespace MovieShelves\Movies;
	
	use Zend\InputFilter\InputFilter;
	
	class MovieFilter extends InputFilter {
		public function __construct() {
			$this->add(array(
				'name' => 'title',
				'required' => true,
				'filters' => array(
					array('name' => 'StripTags'),
					array('name' => 'StringTrim'),
				),
				'validators' => array(
					array(
						'name' => 'not_empty',
						'messages' => array(
							'isEmpty' => 'Movie title is required', 
						),
					),
				),
			));
			
			$this->add(array(
				'name' => 'runtime_minutes',
				'required' => true,
				'filters' => array(
					array('name' => 'StripTags'),
					array('name' => 'StringTrim'),
					array('name' => 'Null'),
				),
				'validators' => array(
					array(
						'name' => 'digits',
						'options' => array(
							'min' => 5,
							'messages' => array(
								'notDigits' => 'Runtime must be in number of minutes', 
								'digitsStringEmpty' => 'Runtime is required' 
							),
						),
					),
				),
			));
			
			$this->add(array(
				'name' => 'dateacquired',
				'required' => true,
				'validators' => array(
					array(
						'name' => 'not_empty',
						'messages' => array(
							'isEmpty' => 'Date aquired is required is required', 
						),
					),
					array(
						'name' => 'date',
						'options' => array(
							'format' => 'm/d/Y',
							'messages' => array(
								'dateInvalidDate' => 'Enter a valid date acquired',
								'dateFalseFormat' => 'Date acquired must be in format of mm/dd/yyyy',
							),
						),
					),
				),
			));
			
			$this->add(array(
				'name' => 'num_discs',
				'required' => true,
				'filters' => array(
					array('name' => 'StripTags'),
					array('name' => 'StringTrim'),
				),
				'validators' => array(
					array(
						'name' => 'digits',
						'options' => array(
							'min' => 1,
							'messages' => array(
								'digitsStringEmpty' => 'Enter the number of discs', 
								'notDigits' => 'Number of discs must be a valid integer' 
							),
						),
					),
				),
			));
			
			$this->add(array(
				'name' => 'num_episodes',
				'required' => false,
				'filters' => array(
					array('name' => 'StripTags'),
					array('name' => 'StringTrim'),
					array('name' => 'Null'),
				),
				'validators' => array(
					array(
						'name' => 'digits',
						'options' => array(
							'messages' => array(
								'notDigits' => 'Number of episodes must be a valid integer' 
							),
						),
					),
				),
			));
			
			$this->add(array(
				'name' => 'thinpaked',
				'required' => true,
				'filters' => array(
					array('name' => 'StripTags'),
					array('name' => 'StringTrim'),
				),
				'validators' => array(
					array(
						'name' => 'digits',
						'options' => array(
							'messages' => array(
								'notDigits' => 'Indicate if title is in a thin/slim case or not' 
							),
						),
					),
				),
			));
			
			$this->add(array(
				'name' => 'mpaa_rating',
				'required' => true,
				'filters' => array(
					array('name' => 'StripTags'),
					array('name' => 'StringTrim'),
				),
				'validators' => array(
					array(
						'name' => 'not_empty',
						'messages' => array(
							'isEmpty' => 'MPAA Rating is required - if no rating, use N/A', 
						),
					),
				),
			));
			
			$this->add(array(
				'name' => 'issubtitled',
				'required' => true,
				'filters' => array(
					array('name' => 'StripTags'),
					array('name' => 'StringTrim'),
				),
				'validators' => array(
					array(
						'name' => 'digits',
						'options' => array(
							'messages' => array(
								'notDigits' => 'Indicate if title is subtitled or not' 
							),
						),
					),
				),
			));
			
			$this->add(array(
				'name' => 'formats_formatid',
				'required' => true,
				'filters' => array(
					array('name' => 'StripTags'),
					array('name' => 'StringTrim'),
				),
				'validators' => array(
					array(
						'name' => 'not_empty',
						'messages' => array(
							'isEmpty' => 'Format is required', 
						),
					),
					array(
						'name' => 'digits',
						'options' => array(
							'messages' => array(
								'notDigits' => 'Format is required' 
							),
						),
					),
				),
			));
			
			$this->add(array(
				'name' => 'genres_genreid',
				'required' => true,
				'filters' => array(
					array('name' => 'StripTags'),
					array('name' => 'StringTrim'),
				),
				'validators' => array(
					array(
						'name' => 'not_empty',
						'messages' => array(
							'isEmpty' => 'Genre is required', 
						),
					),
					array(
						'name' => 'digits',
						'options' => array(
							'messages' => array(
								'notDigits' => 'Genre is required' 
							),
						),
					),
				),
			));
			
			$this->add(array(
				'name' => 'series_seriesid',
				'required' => false,
				'filters' => array(
					array('name' => 'StripTags'),
					array('name' => 'StringTrim'),
					array('name' => 'Null'),
				),
				'validators' => array(
					array(
						'name' => 'digits',
						'options' => array(
							'messages' => array(
								'notDigits' => 'Invalid series selection received', 
							),
						),
					),
				),
			));
			
			$this->add(array(
				'name' => 'series_position',
				'required' => false,
				'filters' => array(
					array('name' => 'StripTags'),
					array('name' => 'StringTrim'),
					array('name' => 'Null'),
				),
				'validators' => array(
					array(
						'name' => 'digits',
						'options' => array(
							'messages' => array(
								'notDigits' => 'Invalid series position received', 
							),
						),
					),
				),
			));
			
			$this->add(array(
				'name' => 'studios_studioid',
				'required' => true,
				'filters' => array(
					array('name' => 'StripTags'),
					array('name' => 'StringTrim'),
				),
				'validators' => array(
					array(
						'name' => 'not_empty',
						'messages' => array(
							'isEmpty' => 'Studio is required', 
						),
					),
					array(
						'name' => 'digits',
						'options' => array(
							'messages' => array(
								'notDigits' => 'Studio is required' 
							),
						),
					),
				),
			));
			
			$this->add(array(
				'name' => 'upcode',
				'required' => true,
				'filters' => array(
					array('name' => 'StripTags'),
					array('name' => 'StringTrim'),
					array('name' => 'Null'),
				),
				'validators' => array(
					array(
						'name' => 'not_empty',
						'messages' => array(
							'isEmpty' => 'UPC is required', 
						),
					),
					array(
						'name' => 'string_length',
						'options' => array(
							'min' => 5,
							'max' => 12,
							'messages' => array(
								'stringLengthTooShort' => 'UPC should be at least 5 digits long', 
								'stringLengthTooLong' => 'UPC should be no more than 12 digits long' 
							),
						),
					),
					array(
						'name' => 'digits',
						'options' => array(
							'messages' => array(
								'notDigits' => 'UPC should be only digits' 
							),
						),
					),
				),
			));
			
			$this->add(array(
				'name' => 'amazon_asin',
				'required' => false,
				'filters' => array(
					array('name' => 'StripTags'),
					array('name' => 'StringTrim'),
				),
				'validators' => array(
					array(
						'name' => 'string_length',
						'options' => array(
							'min' => 10,
							'max' => 10,
							'messages' => array(
								'stringLengthTooShort' => 'Amazon ASIN should be exactly 10 characters long', 
								'stringLengthTooLong' => 'Amazon ASIN should be exactly 10 characters long' 
							),
						),
					),
					array(
						'name' => 'alnum',
						'options' => array(
							'messages' => array(
								'notAlnum' => 'ASIN should be digits and letters only' 
							),
						),
					),
				),
			));
			
			$this->add(array(
				'name' => 'plotsummary',
				'required' => false,
				'filters' => array(
					array('name' => 'StripTags'),
					array('name' => 'StringTrim'),
					array('name' => 'Null'),
				),
			));
			
			$this->add(array(
				'name' => 'moviecover',
				'required' => false,
				'filters' => array(
					array(
						'name' => 'filerenameupload',
						'options' => array(
							'use_upload_extension' => true,
							'target' => './data/tmpuploads/newcover',
							'randomize' => true,
						)
					),
				),
				'validators' => array(
					array( 
						'name' => 'Application\Validator\CoverFile',
					),		
				),
			));
		}
	}
?>

It starts off the same as our previous filters, setting the name space and extending Zend's filter for our class and having a __construct() function with our filter. The first filter is a good example of what is used for most of the form's text elements, just make it required and clean the content.

<?php
	namespace MovieShelves\Movies;
	
	use Zend\InputFilter\InputFilter;
	
	class MovieFilter extends InputFilter {
		public function __construct() {
			$this->add(array(
				'name' => 'title',
				'required' => true,
				'filters' => array(
					array('name' => 'StripTags'),
					array('name' => 'StringTrim'),
				),
				'validators' => array(
					array(
						'name' => 'not_empty',
						'messages' => array(
							'isEmpty' => 'Movie title is required', 
						),
					),
				),
			));
...
?>

The 'required' => true tells Zend that this field must contain data. It's default value is true, so you could leave it off for any required fields, but I prefer to include it for more explicit code. This default required has a rather bleh error message though of "Value is required" or the like, so to give our users better feedback we use the validators attribute to set a custom message if the field wasn't filled in. The "filters" attribute let's us have Zend strip any HTML tags from our content and trim the result.

Figuring out what variable name to list for the messages array can be a bit tricky. I recommend just going straight to Zend's github (or your local installation), opening the appropriate validator, and checking the CONST list to find the desired alias.

As the runtime field is for the minutes of runtime, we use the digits validator, which can continue our numbers between a minimum and maximum value. I also set the custom messages for having not answered the required question (which actually makes the NULL pointless) and if runtime is less than 5 minutes.

...
			
$this->add(array(
	'name' => 'runtime_minutes',
	'required' => true,
	'filters' => array(
		array('name' => 'StripTags'),
		array('name' => 'StringTrim'),
	),
	'validators' => array(
		array(
			'name' => 'digits',
			'options' => array(
				'min' => 5,
				'messages' => array(
					'notDigits' => 'Runtime must be in number of minutes', 
					'digitsStringEmpty' => 'Runtime is required' 
				),
			),
		),
	),
));
...

Date acquired lets us try the date filter. The date was a bit of a pain in the arse because for nice usability, the form displays and accepts mm/dd/yyyy while MySQL only wants yyyy-mm-dd for the actual save, and I had to "convert" my CF-based thinking on date formats to PHP. The filter, though, worked…mostly (more on that later). We again made it required, with custom message, and set the requirements for the date being in our preferred format and a valid date.

...
$this->add(array(
	'name' => 'dateacquired',
	'required' => true,
	'validators' => array(
		array(
			'name' => 'not_empty',
			'messages' => array(
				'isEmpty' => 'Date aquired is required is required', 
			),
		),
		array(
			'name' => 'date',
			'options' => array(
				'format' => 'm/d/Y',
				'messages' => array(
					'dateInvalidDate' => 'Enter a valid date acquired',
					'dateFalseFormat' => 'Date acquired must be in format of mm/dd/yyyy',
				),
			),
		),
	),
));
...

Speaking of that date field, I discovered after my last post that when editing a record, the date was populating the field in the yyyy-mm-dd format, despite the format options declared in our input and here. So I had to add a populateValues function to my form class to help guide the bind. It simply checks to see if we have anything in date acquired, and if so, try to change it to the needed display format. I also had to add use DateTime at the top and removed the setHydrator and setObject from the form construct.

public function populateValues($data) {
	// Fix to date formats properly
	if (!empty($data['dateacquired'])){
		$checkDate = DateTime::createFromFormat('Y-m-d', $data['dateacquired']);
	
		if ($checkDate && DateTime::getLastErrors()["warning_count"] == 0 && DateTime::getLastErrors()["error_count"] == 0) {
			$data['dateacquired'] = date_format($checkDate, 'm/d/Y');
		}
	}
	
	parent::populateValues($data);		
}

Back to our filters. Number of episodes is similar to runtime in that it is a digits field, except we add one more filter, the Null filter. This tells Zend to actually return NULL not blank for an empty field, so when it goes to the DB it properly saves as a NULL. 

...
$this->add(array(
	'name' => 'num_episodes',
	'required' => false,
	'filters' => array(
		array('name' => 'StripTags'),
		array('name' => 'StringTrim'),
		array('name' => 'Null'),
	),
	'validators' => array(
		array(
			'name' => 'digits',
			'options' => array(
				'messages' => array(
					'notDigits' => 'Number of episodes must be a valid integer' 
				),
			),
		),
	),
));
...

Most of the rest follow along that line, with a few not being required allowing the not_empty to be dropped. For upcode, I continue to use the digits and not_empty validators, but I also added in the string_length to ensure the code is within the guidelines for that kind of number, i.e. 5-12 characters in length. I also did this for the Amazon ASIN, but instead of all digits, it can have letters. So I switched from the digits validator to the Alnum, which accepts any alphanumeric character (i.e. letter or number).

...		
$this->add(array(
	'name' => 'amazon_asin',
	'required' => false,
	'filters' => array(
		array('name' => 'StripTags'),
		array('name' => 'StringTrim'),
	),
	'validators' => array(
		array(
			'name' => 'string_length',
			'options' => array(
				'min' => 10,
				'max' => 10,
				'messages' => array(
					'stringLengthTooShort' => 'Amazon ASIN should be exactly 10 characters long', 
					'stringLengthTooLong' => 'Amazon ASIN should be exactly 10 characters long' 
				),
			),
		),
		array(
			'name' => 'alnum',
			'options' => array(
				'messages' => array(
					'notAlnum' => 'ASIN should be digits and letters only' 
				),
			),
		),
	),
));
...

And finally, our file field filter. First, we use the built in filerenameupload filter to save our file in our own temp directory, versus off in the PHP tmp land. But what about validation…

...
$this->add(array(
	'name' => 'moviecover',
	'required' => false,
	'filters' => array(
		array(
			'name' => 'filerenameupload',
			'options' => array(
				'use_upload_extension' => true,
				'target' => './data/tmpuploads/newcover',
				'randomize' => true,
			)
		),
	),
	'validators' => array(
		array( 
			'name' => 'Application\Validator\CoverFile',
		),		
	),
));
...

Rather than stacking a bunch of small Zend validations on each other, and wrestling with figuring out each ones oddities, I decided to make my own custom validator! We want to ensure the image uploaded is either a JPG or PNG, less than 1 MB in size, and at least 500 pixels tall. I decided to put the Validator in the Application module, because if I were going to completely build this out, multiple other modules would use it. Oh, I also moved the custom form stuff there for the same reason.

<?php
namespace Application\Validator;

use Zend\Validator\AbstractValidator;

class CoverFile extends AbstractValidator{
	
	// Set the constants that give our error messages user friendly aliases - if you plan to use messageTemplates, this is NOT optional!!  Can be whatever you want
	const NOTUPLOADED = 'notuploaded';
	const TYPE = 'type';
	const SIZE  = 'size';
	const TOOSHORT  = 'short';
	const TOONARROW  = 'narrow';
	
	// Set up our message templates
	protected $messageTemplates = array(
		self::NOTUPLOADED => "Error occurred trying to upload the file",
		self::TYPE => "Invalid file type: '%value%'; covers must be JPG or PNG",
		self::SIZE => "File is too big, please reduce to less than 1 MB in size",
		self::TOOSHORT => "Cover must be at least %value% pixels wide",
		self::TOONARROW => "Cover must be at least %value% pixels tall"
	);

	// set any private variables needed, in this case the default presumption that the file is valid, what types we accepted, and our minimum allowed dimentions
	private $isValid = true;
	private $acceptableTypes = array('image/jpg','image/jpeg','image/png');
	private $minWidth = 300;
	private $minHeight = 500;
	
	// set up the construction - since this function actually takes no options, it is purely a place holder. 
	public function __construct(array $options = array()){
		parent::__construct($options);
	}
	
	// The actual isValid function - it MUST be called this for the validation to work.  $fileToCheck is the fileField array contents passed in automatically by Zend
	public function isValid($fileToCheck, $context = null) {
		$newCover = $fileToCheck['tmp_name'];
		$this->isValid = true;
		
		// file checks - return false if fails
		
		// did Zend already block the upload?
		if (empty($newCover)) {
			$this->isValid = false;
		}
		
		// right file type? 
		if (!in_array($fileToCheck['type'], $this->acceptableTypes, true)) {
			$this->setValue($fileToCheck['type']);
			$this->error(self::TYPE);
			$this->isValid = false;
		}
		
		// file size okay?
		if ($fileToCheck['size'] >= 1048576) {
			$this->error(self::SIZE);
			$this->isValid = false;
		}
		
		try {
			// big enough to do both images?  500 px tall, at least 300 wide for larger cover
			$coverDimensions = getimagesize($newCover);
			$width = $coverDimensions[0];
			$height  = $coverDimensions[1];
			
			if ($width < $this->minWidth) {
				$this->setValue($this->minWidth);
				$this->error(self::TOOSHORT);
				$this->isValid = false;
			}
			
			if ($height < $this->minHeight) {
				$this->setValue($this->minHeight);
				$this->error(self::TOONARROW);
				$this->isValid = false;
			}
			
		} catch (Exception $e) {
			$this->error(self::NOTUPLOADED);
			$this->isValid = false;
		}
		
		// if anything failed, delete our temp file; don't clutter the server!!!
		if (!$this->isValid && !empty($newCover)) {
			unlink($newCover);
		}
		
		return $this->isValid;
	}
}
?>

I think most of the comments within in make it fairly understandable. The $fileToCheck variable that isValid expects is basically the file upload results array that Zend will automatically pass in when it goes to validate a file field. The return is always the same, though if the file upload filed, everything but error will be blank.

Anytime the image checks fail, I use $this-error to indicate which error message template to use and set my return variable to false. I decided to do that versus doing a bunch of individual returns for cleaner code and for that last bit that cleans up our unneeded file if the validations fail.

Now that our input is getting nice and clean, we can actually process this stuff, yeah. So in our Movies Controller, we add our functions for processing adds and edits.

...
public function addAction() {
	// instantites the MovieForm and an empty instance of the entity that is bound to the form
	$form = $this->getServiceLocator()->get('MoviesForm');
	$movie = new MovieEntity();
	$form->bind($movie);
	
	$request = $this->getRequest();
	
	// if the form has been submitted, get the data and validate it
	if ($request->isPost()) {
		$form->setData(
			array_merge_recursive(
				$request->getPost()->toArray(),
				$request->getFiles()->toArray()
			)
		);
		
		if ($form->isValid()) {
			// if the data is valid, send to the DAO to be saved
			$data = $form->getData();
			
			$this->getMoviesDAO()->saveMovie($movie);
			
			$sMovieCover = $data->getMovieCover();
			
			if (!empty($sMovieCover['tmp_name'])) {
				$sCoverResults = $this->processCover($data);
				
				if ($sCoverResults['ResultCode'] != 200) {
					$this->flashMessenger()->setNamespace("error")->addMessage($sCoverResults['ResultMessage']);
				}
			}
			
			// give success mesage and redirect
			$this->flashMessenger()->setNamespace("success")->addMessage('Movie added successfully!');
			return $this->redirect()->toRoute('movies');
		}
	}
	return array('form' => $form);
}

public function editAction() {
	// for edit, we check for a movie id; if there isn't one, send to add instead; otherwise populate the movie object with the movie then bind the form
	$movieid = (int)$this->params('movieid');
	
	if (!$movieid) {
		return $this->redirect()->toRoute('movies', array('action'=>'add'));
	}
	$movie = $this->getMoviesDAO()->getMovie($movieid);
	
	$form = $this->getServiceLocator()->get('MoviesForm');
	$form->bind($movie);
	
	$request = $this->getRequest();
	
	// if the form has been submitted, get the data and validate it
	if ($request->isPost()) {
		$form->setData(
			array_merge_recursive(
				$request->getPost()->toArray(),
				$request->getFiles()->toArray()
			)
		);
		
		if ($form->isValid()) {
			$data = $form->getData();
			
			// if the data is valid, send to the DAO to be saved
			$this->getMoviesDAO()->saveMovie($movie);
			$sMovieCover = $data->getMovieCover();
			
			if (!empty($sMovieCover['tmp_name'])) {
				$sCoverResults = $this->processCover($data);
				
				if ($sCoverResults['ResultCode'] != 200) {
					$this->flashMessenger()->setNamespace("error")->addMessage($sCoverResults['ResultMessage']);
				}
			}
			
			// give success mesage and redirect
			$this->flashMessenger()->setNamespace("success")->addMessage('Movie updated successfully!');
			return $this->redirect()->toRoute('movies');
		}
	}
	return array(
		'movieid' => $movieid,
		'form' => $form
	);
}
...

For the most part, the two sections are the same – they create our movie entity, bind it to the form, and then if the form has been posted, injects the form data into our object, validates it, then saves it if it is good. For the data setting, though, we now are using an array_merge_recursive to combine the regular form field data with our file upload data, so all of it is properly pushed into our object.

The editAction adds a little extra code at the top to confirm we have a valid movie id (and if not, presumes we're doing an add). I'd probably flesh this out some more in a real app to make sure the requested movie to be edited exists and to throw an error rather than presume having no movie id means it is an add.

Saving of the actual data is handled in our DAO through our new save function.

public function saveMovie(MovieEntity $movie) {
	$hydrator = new ClassMethods(false);
	$hydrator->addStrategy('dateAcquired', new DateStrategy);
	$hydrator
		->addFilter(
			"array_copy",
			new MethodMatchFilter("getArrayCopy"),
			FilterComposite::CONDITION_AND
		)
		->addFilter(
			"formatlabel",
			new MethodMatchFilter("getFormatLabel"),
			FilterComposite::CONDITION_AND
		)
		->addFilter(
			"genre",
			new MethodMatchFilter("getGenre"),
			FilterComposite::CONDITION_AND
		)
		->addFilter(
			"seriesname",
			new MethodMatchFilter("getSeriesName"),
			FilterComposite::CONDITION_AND
		)
		->addFilter(
			"studio",
			new MethodMatchFilter("getStudio"),
			FilterComposite::CONDITION_AND
		)
		->addFilter(
			"hascover",
			new MethodMatchFilter("getHasCover"),
			FilterComposite::CONDITION_AND
		)
		->addFilter(
			"movieCover",
			new MethodMatchFilter("getMovieCover"),
			FilterComposite::CONDITION_AND
		)
	;
	
	$data = $hydrator->extract($movie);
	
	if ($movie->getMovieID()) {
		$action = $this->sql->update();
		$action->set($data);
		$action->where(array('movieid' => $movie->getMovieID()));
	} else {
		$action = $this->sql->insert();
		unset($data['movieid']);
		$action->values($data);
	}
	
	$statement = $this->sql->prepareStatementForSqlObject($action);
	$result = $statement->execute();
	
	if(!$movie->getMovieID()) {
		$movie->setMovieID($result->getGeneratedValue());
	}
	
	return $result;
}

This save function has a few new things versus the others I did. First, you'll probably notice my hydrator statement is way way longer because it adds a custom strategy for our date field and it adds multiple filters. The filters are used to keep Zend from trying to "write" to non-existent fields in our database, namely the names we pulled up for display purposes in our getMovie function and our movie cover. I also quickly discovered that the date wasn't saving right anymore. Why? Because of that aforementioned nicety of having the form display the date as mm/dd/yyyy but MySQL wanting yyyy-mm-dd. Since Zend was not automatically formatting the date correctly, it was giving the date to MySQL's Insert/updates with that format and MySQL just declined to save it.

So to fix that, I made my custom DateStrategy which converts the date back to a MySQL acceptable format for our update/insert statements. Like the rest, it is in our Application module.

<?php
	namespace Application\Hydrator;

	use DateTime;
	use Zend\Stdlib\Hydrator\Strategy\DefaultStrategy;
	
	class DateStrategy extends DefaultStrategy {
		public function hydrate($value){
			// Fix date for SQL injection
			$checkDate = DateTime::createFromFormat('m/d/Y', $value);
			
			if ($checkDate && DateTime::getLastErrors()["warning_count"] == 0 && DateTime::getLastErrors()["error_count"] == 0) {
				$value = date_format($checkDate, 'Y-m-d');
			}

			return $value;
		}
		public function extract($value){
			// Fix date for SQL injection
			$checkDate = DateTime::createFromFormat('m/d/Y', $value);
			
			if ($checkDate && DateTime::getLastErrors()["warning_count"] == 0 && DateTime::getLastErrors()["error_count"] == 0) {
				$value = date_format($checkDate, 'Y-m-d');
			}

			return $value;
		}
	}
?>

Once I finally understood what the heck strategies for, making one ended up being pretty easy. You extend the default Strategy, then add our hydrate and extract functions, if needed. In this case, we took the date coming in (if any) and switched it to a MySQL acceptable format using PHP's basic DateTime functions.

Beyond that custom strategy and the new filters, the save is fairly standard. Back in the controller code, you may have noticed a few extra lines after our save call versus the other saves we did.

$data = $form->getData();

$this->getMoviesDAO()->saveMovie($movie);
$sMovieCover = $data->getMovieCover();

if (!empty($sMovieCover['tmp_name'])) {
	$sCoverResults = $this->processCover($data);
	
	if ($sCoverResults['ResultCode'] != 200) {
		$this->flashMessenger()->setNamespace("error")->addMessage($sCoverResults['ResultMessage']);
	}
}

Once our movie is saved, we still need to deal with the cover file, if any, that was uploaded. For this, I made a function called processCover into which I pass the same data object used to save the movie (this is so we have both the file data and the movie data). I added the processCover code to the end of my Movies Controller class.

// takes new uploaded file and makes our thumbnails
private function processCover($data) {
	$sMovieCover = $data->getMovieCover();
	$sCoverResults = array(
		"ResultCode" => 200,
		"ResultMessage" => "",
	);
	$newCover = $sMovieCover['tmp_name'];
	$movieid = $data->getMovieID();
	$thumbnailWidth = 0;
	$thumbnailHeight = 200;
	$fullCoverWidth = 0;
	$fullCoverHeight = 500;
	$newThumbNailFile = "./data/covers/movieshelves/thumbnails/movie_" . $movieid . ".png";
	$newCoverFile = "./data/covers/movieshelves/fullcovers/movie_" . $movieid . ".png";
	
	// take our original image and make two covers for display, a thumbnail and a regular cover
	$coverDimensions = getimagesize($newCover);
	$width = $coverDimensions[0];
	$height  = $coverDimensions[1];
	$ratio = $width/$height;
	
	if (strpos($newCover, ".jp") !== false) {
		$oCover = imagecreatefromjpeg($newCover);
	}
	else {
		$oCover = imagecreatefrompng($newCover);
	}
	
	// THUMBNAIL
	// get thumbs dimensions based on ratio of original so we don't distory; max height is 200, width can vary as needed
	$thumbnailWidth = $thumbnailHeight * $ratio;
	
	$iThumbnail = imagecreatetruecolor($thumbnailWidth, $thumbnailHeight);
	
	imagecopyresampled($iThumbnail, $oCover, 0, 0, 0, 0,
		    $thumbnailWidth, $thumbnailHeight, $width, $height); 
	
	imagepng($iThumbnail, $newThumbNailFile);
	
	// FULL COVER
	$fullCoverWidth = $fullCoverHeight * $ratio;
	
	$iFullCover = imagecreatetruecolor($fullCoverWidth, $fullCoverHeight);
	
	imagecopyresampled($iFullCover, $oCover, 0, 0, 0, 0,
		    $fullCoverWidth, $fullCoverHeight, $width, $height); 
	
	imagepng($iFullCover, $newCoverFile);
	
	unlink($newCover);
	
	return $sCoverResults;
}

Since this function will only be used in this class, I made it private rather than public.  It basically takes our prevalidated file and makes a 200 pixel tall thumbnail and a 500 pixel tall full cover image, then saves to our desired locations.  I must admit, I found the entire of PHP's image functions insanely frustrating while writing this function (and these are PHP's not Zends).  ColdFusion would have been like four lines to do this, not this insane "make a blank image, calculate the ratio of the original image, then copy the old to the while resizing.  Ugh.  But I got it and it works.   Regardless of whether it was a JPG or PNG, the final image will be a PNG.

Anyway, with that, the last bit we need to have this model pretty functional is the delete function.  Again, it's pretty standard versus our previous ones, except that we also delete any associated cover files at the same time.  In our controller:

public function deleteAction() {
	$movieid = (int)$this->params('movieid');
	
	if (!$movieid) {
		return $this->redirect()->toRoute('movies', array('action'=>'add'));
	}
	
	try {
		$movie = $this->getMoviesDAO()->getMovie($movieid);
	}
	catch (\Exception $ex) {
		$this->flashMessenger()->setNamespace("error")->addMessage('Movie not found; was it already deleted?!');
		return $this->redirect()->toRoute('movies');
	}
	
	$request = $this->getRequest();
	
	if ($request->isGet()) {
		$this->getMoviesDAO()->deleteMovie($movie);
		
		// don't forget to delete any covers!
		$ThumbNailFile = "./data/covers/movieshelves/thumbnails/movie_" . $movie->getMovieID() . ".png";
		unlink($ThumbNailFile);

		$CoverFile = "./data/covers/movieshelves/fullcovers/movie_" . $movie->getMovieID() . ".png";
		unlink($CoverFile);
		
		$this->flashMessenger()->setNamespace("success")->addMessage('Movie deleted successfully!');
		return $this->redirect()->toRoute('movies');
	}
	
	return array(
		'movieid' => $movieid,
		'movies' => $this->getMoviesDAO()->getMovie($movieid)
	);
}

And then in the DAO:

public function deleteMovie(MovieEntity $movie) {
	if ($movie->getMovieID()) {
		$action = $this->sql->delete();
		$action->where(array('movieid' => $movie->getMovieID()));
	}
	
	$statement = $this->sql->prepareStatementForSqlObject($action);
	$result = $statement->execute();
	
	return $result;
}

And that's it.  We can now list, add, edit, and delete movies!  Yay!  Not that this module is fully complete or anything.  After all, these are all admin functions – what about just browsing and searching for users?  And we have covers we display no where?  Also, I'm lazy, so I don't want to type so much! At this point though this covers the basics.  I'm sure there are other ways to do a lot of this (heck, I know there are), and maybe some of you guys know of some.  Feel free to share, lord knows we need more good info out there! 

My next article about this app will be a new series – kicking this all up a notch!  My plan is to add navigation, user authorization to separate all these admin functions from what regular people can see, and utilize Zend's Amazon functions to make an easier add function that can just snatch our basic data and cover, then let us review/correct as desired 🙂

Oh, and yes, I did finally realize that this whole app was getting pretty complicated towards the ends, so if you'd like, you can download this Zip file with my Modules folder – it has the both the MovieShelves and Application modules so you can see the helpers and what not as well.