PHP, Zend Framework and Other Crazy Stuff
Example Zend Framework Blog Application Tutorial: Part 8: Creating and Editing Blog Entries with a dash of HTMLPurifier
There’s nothing quite like having a functioning application emerge out of the controlled chaos we know as The Development Process. In Part 8 of the ongoing saga describing how to build a real world blog application using the Zend Framework we finally reach the point at which we concentrate on blog entries. At the end of this Part, we will be able to create and edit entries in preparation for Part 9 when we will explore displaying them to the world!
Previously: Example Zend Framework Blog Application Tutorial – Part 7: Authorisation with Zend_Acl and Revised Styling
The reason displaying entries is not addressed here is simple. Display requires a lot of Zend_View work which is deserving of an article to itself before we go too far. A few of you have already noted in comments about our suspicious lack of View Helper usage
. By now a few uncomfortable flaws should be becoming apparent in how some template content is becoming duplicated.
So, on with the show already!
Step 1: Adding an Entry Controller and Add Action Template
The first step to creating new entries will be writing an Admin_EntryController class (the prefix is needed since it’s situated in the Admin Module) with an addAction() method and matching template.
The template will utilise a new Zend_Form object for gathering the input used to create a new entry, as well as offering an editing View.
We’ll start with the Controller, added at /application/admin/controllers/EntryController.php:
[geshi lang=php]
class Admin_EntryController extends Zend_Controller_Action
{
public function addAction()
{}
public function listAction()
{}
public function editAction()
{}
public function deleteAction()
{}
}[/geshi]
The EntryController needs four basic methods. We intend creating, editing and deleting entries, as well as listing all entries to an Author to select those options.
We'll concentrate on the addAction() method first, so let's add an appropriate template at /application/admin/views/scripts/entry/add.phtml which has an old friend referred to:
[geshi lang=php]
Where I am not understood, it shall be concluded that something very useful and profound is couched underneath.
- Jonathan Swift
failedValidation): ?>
Some problems were detected with the submitted form.
entryForm ?>[/geshi]
Let's accompany our blog entry forms with a little Jonathan Swift for inspiration
. We once again meet a validation problem message identical to the one in our login form. As we're duplicating this message we're going to have to consider a means later on of isolating such commonly used feedback messages for reuse somewhere in Part 9.
To keep our styling synchronised with our intended form output, add the following to /public/css/style.css:
[geshi lang=css]textarea {
width: 76%;
height: 30em;
}[/geshi]
This should provide a decent styling for textareas for most browsers. Perhaps even Safari which I noticed recently is displaying some of my text fields poorly.
Step 2: Assembling the Entry Form with Zend_Form
We've already had a fairly detailed look at Zend_Form back in Part 6 when we wrote a subclass to contain some standard and specific decorator arrays for form elements, and created our Login Form example. The same principles used there also apply here with very few changes. Once again we are using the shorter array-based syntax over multiple method calls. One of the differences to take note of is that I've used two new options attribs and value which no form developer could live without!
We'll start with a new class called ZFBlog_Form_EntryAdd located at /library/ZFBlog/Form/EntryAdd.php:
[geshi lang=php]
class ZFBlog_Form_EntryAdd extends ZFBlog_Form
{
public function init()
{
$this->setAction('/admin/entry/add');
// Display Group #1 : Entry Data
$this->addElement('text', 'title', array(
'decorators' => $this->_standardElementDecorator,
'label' => 'Title:',
'attribs' => array(
'maxlength' => 200,
'size' => 80
),
'validators' => array(
array('StringLength', false, array(3,200))
),
'required' => true
));
$this->addElement('text', 'date', array(
'decorators' => $this->_standardElementDecorator,
'label' => 'Date:',
'attribs' => array(
'maxlength' => 16,
'size' => 16
),
'value' => Zend_Date::now()->toString('yyyy-MM-dd HH:mm'),
'validators' => array(
array('Date', false, array('yyyy-MM-dd HH:mm', 'en'))
),
'required' => true
));
$this->addElement('textarea', 'entrybody', array(
'decorators' => $this->_standardElementDecorator,
'label' => 'Entry Body:',
'required' => true
));
$this->addElement('textarea', 'entrybodyextended', array(
'decorators' => $this->_standardElementDecorator,
'label' => 'Extended Body:'
));
$this->addDisplayGroup(
array('title','date','entrybody','entrybodyextended'), 'entrydata',
array(
'disableLoadDefaultDecorators' => true,
'decorators' => $this->_standardGroupDecorator,
'legend' => 'Entry'
)
);
// Display Group #2 : Submit
$this->addElement('submit', 'submit', array(
'decorators' => $this->_buttonElementDecorator,
'label' => 'Save'
));
$this->addDisplayGroup(
array('submit'), 'entrydatasubmit',
array(
'disableLoadDefaultDecorators' => true,
'decorators' => $this->_buttonGroupDecorator,
'class' => 'submit'
)
);
}
}[/geshi]
I threw in a sprinkling of Zend_Date above to format the current date to a format compatible with a MySQL database. Zend_Date is also used in the background by the Zend_Validator_Date class which is why the date formatting is identical to both. Note that the formatting is not the PHP regular style used by the date() function, but instead uses ISO format specifiers. This offers a great deal of flexibility if you want something other than MySQL formatted dates.
Finally note that there is no "required" flag for the "entrybodyextended" element since an extended body is optional.
We're not quite done yet. Our two textareas, as discussed way back in Part 1, are being designed to accept HTML input since I really can't be bothered to play with custom formatting tags and such. This obviously raises the risk that I, another author, or someone who's guessed my password may, either by mistake or intent, add some Cross-Site Scripting (XSS) into the mix and introduce a devastating security exploit. We can't have that now!
Step 3: Filtering Entries using a HTMLPurifier based Custom Filter
What we will do now is attach a custom filter to the textareas containing our Entry data that cleans up any HTML input, removes XSS, and as a bonus converts any non-HTML input into formatted HTML. This, for example, covers our text paragraphs by wrapping them in <p> tags.
My favourite library for achieving this is HTMLPurifier which I consider one of those much under utilised libraries in PHP. To my knowledge, there is nothing out there to beat its comprehensive feature list. Its finest feature is that it actually understands HTML across multiple standards. Input is tokenised, parsed, passed through a whitelist (the opposite of a detection blacklist), reformed as perfectly correct valid output, and then it can optionally wrap stuff like plain text in paragraphs. All for your preferred DTD. Either you're using HTMLPurifier, or you are using something second or third rate, and I have no qualms whatsoever in stating that as a fact. I cannot praise this library more highly.
To start, you'll need to download a copy of HTMLPurifer 3.1.0rc1 (which is the latest release at the time of writing) and copy the contents of the package's /library directory into our blog application's /library. Since HTMLPurifer follows the PEAR Convention for class naming and file location, we need make no changes to our include_path. If you prefer, you can also install HTMLPurifer from its PEAR channel as described on the download page. Although it's a release candidate I haven't run into any problems using it other than my unexplainable habit of mispelling HTMLPurifier as HTMLPurifer which has led to a few frustrating hair pulling moments!
HTMLPurifier's perfection is not without a performance cost. To improve performance it will utilise a HTML definition cache. Add a new base directory at /cache/htmlpurifier and grant permissions sufficient to let the webserver write files there. Don't get too caught up over performance as security isn't an area you want to needlessly play Scrooge with
. The performance cost is really only noticeable if using it for post-processing of output being sent to visitors. The cache coupled with the fact HTMLPurifier is used only in the backend Administration eliminates any such cost for the purposes of our application.
Let's introduce our custom filter classes. I'm saving them using a mirror directory structure of the Zend Framework as usual. Here a standard one I usually use with HTMLPurifier saved to /library/ZFBlog/Filter/HTMLPurifier.php:
[geshi lang=php]
class ZFBlog_Filter_HTMLPurifier implements Zend_Filter_Interface
{
protected $_htmlPurifier = null;
public function __construct($options = null)
{
$config = null;
if (!is_null($options)) {
$config = HTMLPurifier_Config::createDefault();
foreach ($options as $option) {
$config->set($option[0], $option[1], $option[2]);
}
}
$this->_htmlPurifier = new HTMLPurifier($config);
}
public function filter($value)
{
return $this->_htmlPurifier->purify($value);
}
}[/geshi]
HTMLPurifier options have three distinct elements we can pass to this filter as an array. We could stop here, passing filter options for every form, but this is a very general filter class whose sole purpose is to pass options into HTMLPurifier, so let's add a subclass of this specifically for HTML textual input with predefined options at /library/ZFBlog/Filter/HtmlBody.php:
[geshi lang=php]
class ZFBlog_Filter_HtmlBody extends ZFBlog_Filter_HTMLPurifier
{
public function __construct($newOptions = null)
{
$options = array(
array('Cache', 'SerializerPath',
Bootstrap::$root . '/cache/htmlpurifier'
),
array('HTML', 'Doctype', 'XHTML 1.0 Strict'),
array('HTML', 'Allowed',
'p,em,h1,h2,h3,h4,h5,strong,a[href],ul,ol,li,code,pre,'
.'blockquote,img[src|alt|height|width],sub,sup'
),
array('AutoFormat', 'Linkify', 'true'),
array('AutoFormat', 'AutoParagraph', 'true')
);
if (!is_null($newOptions)) {
// I'll let HTMLPurifier overwrite original options
// with new ones rather than filter them myself
$options = array_merge($options, $newOptions);
}
parent::__construct($options);
}
}[/geshi]
This new subclass passes specific options to HTMLPurifier. We provide the path to the cache directory created previously, inform the library our output should conform to XHTML 1.0 Strict, add a whitelist of allowed tags and attributes, and finally enable two optional formatting helpers to auto paragraph output (wrapped with <p> tags) and transform URLs into hyperlinks. If ZFBlog_Filter_HtmlBody needs further adjustment we can pass it options when attaching this filter to our form elements.
This really is how HTMLPurifer works. By using sensible defaults, configuration before use is extremely simple.
With our two custom filters in tow, we now need to make sure the Zend Framework can actually find them! We've done this previously actually when registering a custom decorator path with Zend_Form. Let's repeat the process. Here's an updated ZFBlog_Form class from /library/ZFBlog/Form.php:
[geshi lang=php]
class ZFBlog_Form extends Zend_Form
{
protected $_standardElementDecorator = array(
'ViewHelper',
array('LabelError', array('escape'=>false)),
array('HtmlTag', array('tag'=>'li'))
);
protected $_buttonElementDecorator = array(
'ViewHelper'
);
protected $_standardGroupDecorator = array(
'FormElements',
array('HtmlTag', array('tag'=>'ol')),
'Fieldset'
);
protected $_buttonGroupDecorator = array(
'FormElements',
'Fieldset'
);
protected $_noElementDecorator = array(
'ViewHelper'
);
public function __construct($options = null)
{
// Path setting for custom classes MUST ALWAYS be first!
$this->addElementPrefixPath('ZFBlog_Form_Decorator', 'ZFBlog/Form/Decorator/', 'decorator');
$this->addElementPrefixPath('ZFBlog_Filter', 'ZFBlog/Filter/', 'filter');
$this->_setupTranslation();
parent::__construct($options);
$this->setAttrib('accept-charset', 'UTF-8');
$this->setDecorators(array(
'FormElements',
'Form'
));
}
protected function _setupTranslation()
{
if (self::getDefaultTranslator()) {
return;
}
$path = Bootstrap::$root . '/translate/forms.php';
$translate = new Zend_Translate('array', $path, 'en');
self::setDefaultTranslator($translate);
}
}[/geshi]
Now Zend_Form can use our custom filters. The last thing we do is attach our new HtmlBody custom filter to our new form along with a few other filters for good measure:
[geshi lang=php]
class ZFBlog_Form_EntryAdd extends ZFBlog_Form
{
public function init()
{
$this->setAction('/admin/entry/add');
// Display Group #1 : Entry Data
$this->addElement('text', 'title', array(
'decorators' => $this->_standardElementDecorator,
'label' => 'Title:',
'attribs' => array(
'maxlength' => 200,
'size' => 80
),
'validators' => array(
array('StringLength', false, array(3,200))
),
'filters' => array('StringTrim'),
'required' => true
));
$this->addElement('text', 'date', array(
'decorators' => $this->_standardElementDecorator,
'label' => 'Date:',
'attribs' => array(
'maxlength' => 16,
'size' => 16
),
'value' => Zend_Date::now()->toString('yyyy-MM-dd HH:mm'),
'validators' => array(
array('Date', false, array('yyyy-MM-dd HH:mm', 'en'))
),
'required' => true
));
$this->addElement('textarea', 'entrybody', array(
'decorators' => $this->_standardElementDecorator,
'label' => 'Entry Body:',
'filters' => array('HtmlBody'),
'required' => true
));
$this->addElement('textarea', 'entrybodyextended', array(
'decorators' => $this->_standardElementDecorator,
'label' => 'Extended Body:',
'filters' => array('HtmlBody')
));
$this->addDisplayGroup(
array('title','date','entrybody','entrybodyextended'), 'entrydata',
array(
'disableLoadDefaultDecorators' => true,
'decorators' => $this->_standardGroupDecorator,
'legend' => 'New Entry'
)
);
// Display Group #2 : Submit
$this->addElement('submit', 'submit', array(
'decorators' => $this->_buttonElementDecorator,
'label' => 'Save'
));
$this->addDisplayGroup(
array('submit'), 'entrydatasubmit',
array(
'disableLoadDefaultDecorators' => true,
'decorators' => $this->_buttonGroupDecorator,
'class' => 'submit'
)
);
}
}[/geshi]
Let's get this form attached to a View now.
Step 4: Output Form from Add Action Template
Let's revise our EntryController to pass our sparkling new form to the addAction() method's associated template.
[geshi lang=php]
class Admin_EntryController extends Zend_Controller_Action
{
public function addAction()
{
$form = new ZFBlog_Form_EntryAdd;
if (!$this->getRequest()->isPost()) {
$this->view->entryForm = $form;
return;
} elseif (!$form->isValid($_POST)) {
$this->view->failedValidation = true;
$this->view->entryForm = $form;
return;
}
}
}[/geshi]
Now, keeping in mind we should keep track of what error messages need to be cleaned up into a shorter message (as I noted previously, I like short error messages attached to element labels), let's add the Zend_Validate_Date default errors for replacement in our translation array in /translate/forms.php:
[geshi lang=php]
return array(
Zend_Validate_NotEmpty::IS_EMPTY => 'Required',
Zend_Validate_StringLength::TOO_SHORT => 'Minimum Length of %min%',
Zend_Validate_StringLength::TOO_LONG => 'Maximum Length of %max%',
Zend_Validate_Date::NOT_YYYY_MM_DD => 'Must use YYYY-MM-DD format',
Zend_Validate_Date::INVALID => 'Not valid date',
Zend_Validate_Date::FALSEFORMAT => 'Invalid date format',
);[/geshi]
Let's give it a whirl! Open up http://zfblog/admin/entry/add in your browser (you may need to login as Joe Bloggs first with the username "joebloggs" and password "password" setup earlier) and marvel at the new form.
We're still not done. If a valid form is submitted we're going to need to store it to the database!
Step 5: Storing New Enties to the Database
We've already put in place an Entries Model when we were setting up our database, so saving entries is just a simple addition to our EntryController.
[geshi lang=php]
class Admin_EntryController extends Zend_Controller_Action
{
public function addAction()
{
$form = new ZFBlog_Form_EntryAdd;
if (!$this->getRequest()->isPost()) {
$this->view->entryForm = $form;
return;
} elseif (!$form->isValid($_POST)) {
$this->view->failedValidation = true;
$this->view->entryForm = $form;
return;
}
$values = $form->getValues();
$table = new Entries;
$data = array(
'title' => $values['title'],
'date' => $values['date'],
'author' => Zend_Auth::getInstance()->getIdentity()->username,
'author_id' => Zend_Auth::getInstance()->getIdentity()->id,
'body' => $values['entrybody'],
'extended_body' => $values['entrybodyextended']
);
$table->insert($data);
$this->view->entrySaved = true;
}
}[/geshi]
I've left the class as is, but at some point I'll refactor this. One of the few worthwhile aesthetic goals of any source code is to keep methods as short as possible. Almost half the above method is mapping form names to database field names. If used a more direct convention, we could have saved reading space and had a shorter method.
We now just need to add another of those persistently untidy confirmation messages to be cleaned up later to our Add Action's template
.
[geshi lang=php]
Where I am not understood, it shall be concluded that something very useful and profound is couched underneath.
- Jonathan Swift
failedValidation): ?>
Some problems were detected with the submitted form.
entrySaved): ?>
New entry has been saved.
entryForm ?>
[/geshi]
Go ahead and try out the new entry addition. As noted in the introduction we'll cover the actual display of Entries in the next Part, so for now check out the results on the database using PhpMyAdmin or similar. You notice paragraphs are correctly wrapped with <p> and links linkified as HTMLPurifier makes it's presence felt. Tags and attributes that were not added to our whitelist will also be stripped.
Step 6: Editing Entries
Editing entries is a fairly simple addition to our zoo. In fact, it's necessary. We already have in place a form class for inputting entries and it's only a small step from there to add in the old values for editing.
The only hoop to jump through is reversing the two default formatting steps that HTMLPurifier performs before entries are saved to the database. This can be a confusing problem because many people, I suspect, assume populating Zend_Form with values will output those values exactly. Not so. If you have added Filters to a form object you have actually told Zend_Form that only values passing this filter are entirely valid, so guess what? Yes, populating data is filtered when added to a form object. Since here we filter all entry body data through HTMLPurifier, while the actual input from the user is not, we need to disable this filter before displaying the populated edit form.
There is another strategy you could also use. Instead of applying the filter to the form, apply it instead within the Model using overridden save() and update() methods. I've elected not to do this since I quite like having the filtering as part of the form object itself. It doesn't change the fact that that hoop exists, but locating it in the Model could aid in reuse since it's now decoupled from a form object and managed closer to the database. But anyway, that's what Refactoring is for! So let's forge ahead.
We'll start with implementing the listAction() for our Entry Controller so we can view a list of all entries for editing/deletion. For the moment this does not include paging.
[geshi lang=php]
class Admin_EntryController extends Zend_Controller_Action
{
public function addAction()
{
$form = new ZFBlog_Form_EntryAdd;
if (!$this->getRequest()->isPost()) {
$this->view->entryForm = $form;
return;
} elseif (!$form->isValid($_POST)) {
$this->view->failedValidation = true;
$this->view->entryForm = $form;
return;
}
$values = $form->getValues();
$table = new Entries;
$data = array(
'title' => $values['title'],
'date' => $values['date'],
'author' => Zend_Auth::getInstance()->getIdentity()->username,
'author_id' => Zend_Auth::getInstance()->getIdentity()->id,
'body' => $values['entrybody'],
'extended_body' => $values['entrybodyextended']
);
$table->insert($data);
$this->view->entrySaved = true;
}
public function listAction()
{
$table = new Entries;
// SELECT title, date, id FROM entries
$rows = $table->fetchAll(
$table->select()->from($table, array('title','date','id'))
);
$this->view->entries = $rows->toArray();
}
public function editAction()
{}
public function deleteAction()
{}
}[/geshi]
The associated view simply iterates the array of entries over a foreach construct to replicate an entry list.
/application/admin/views/scripts/entry/list.phtml
[geshi lang=php]entries) > 0): ?>
There are no entries for this blog.
[/geshi]
It looks pretty darn ugly, but will fit for now. Logged in as an Author, you can view the list of existing entries assuming you've added a few test ones while testing our new add entry functionality. Each will have a link to edit or delete that entry. We'll cover the URL form I used shortly.
Let's create a new form for editing entries. Luckily we have the Entry Add form, so all we need do is subclass this to make a few small adjustments! Add this form as /library/ZFBlog/Form/EntryEdit.php:
[geshi lang=php]
class ZFBlog_Form_EntryEdit extends ZFBlog_Form_EntryAdd
{
public function init()
{
parent::init();
$this->setAction('/admin/entry/edit');
// What entry id are we editing?!
$this->addElement('hidden', 'id', array(
'decorators' => $this->_noElementDecorator,
'validators' => array(
'Digits'
),
'required' => true
));
$this->getDisplayGroup('entrydata')->setLegend('Edit Entry');
$this->getDisplayGroup('entrydata')->addElement(
$this->getElement('id')
);
}
}[/geshi]
Isn't a little reuse worthwhile?
Before displaying our Edit Form we need to populate it with the data we're editing. This involves capturing the entry from the database, reversing any HTMLPurifier filtering, disabling the Form's filter chain, and only then re-populating the form. We need to be careful the workflow only disables filters for form display - they should remain in force for any submitted edits.
[geshi lang=php]
class Admin_EntryController extends Zend_Controller_Action
{
public function addAction()
{
$form = new ZFBlog_Form_EntryAdd;
if (!$this->getRequest()->isPost()) {
$this->view->entryForm = $form;
return;
} elseif (!$form->isValid($_POST)) {
$this->view->failedValidation = true;
$this->view->entryForm = $form;
return;
}
$values = $form->getValues();
$table = new Entries;
$data = array(
'title' => $values['title'],
'date' => $values['date'],
'author' => Zend_Auth::getInstance()->getIdentity()->username,
'author_id' => Zend_Auth::getInstance()->getIdentity()->id,
'body' => $values['entrybody'],
'extended_body' => $values['entrybodyextended']
);
$table->insert($data);
$this->view->entrySaved = true;
}
public function listAction()
{
$table = new Entries;
$rows = $table->fetchAll(
$table->select()->from($table, array('title','date','id'))
);
$this->view->entries = $rows->toArray();
}
public function editAction()
{
$form = new ZFBlog_Form_EntryEdit;
if (!$this->getRequest()->isPost()) {
$table = new Entries;
$rowset = $table->find( $this->_getParam('id') );
if (count($rowset) == 0) {
$this->view->failedFind = true;
return;
}
$form->setElementFilters(array()); // disable all element filters
$this->_repopulateForm($form, $rowset->current());
$this->view->entryForm = $form;
return;
} elseif (!$form->isValid($_POST)) {
$this->view->failedValidation = true;
$this->view->entryForm = $form;
return;
}
$values = $form->getValues();
$table = new Entries;
$data = array(
'title' => $values['title'],
'date' => $values['date'],
'body' => $values['entrybody'],
'extended_body' => $values['entrybodyextended']
);
$table->update($data,
$table->getAdapter()->quoteInto('id = ?', $values['id'])
);
$this->view->entrySaved = true;
}
public function deleteAction()
{
}
protected function _repopulateForm($form, $entry)
{
$values = array(
'title' => $this->_reverseAutoFormat($entry->title),
'date' => $entry->date,
'entrybody' => $this->_reverseAutoFormat($entry->body),
'entrybodyextended' =>
$this->_reverseAutoFormat($entry->extended_body),
'id' => $this->_getParam('id')
);
$form->populate($values);
}
protected function _reverseAutoFormat($string)
{
$string = preg_replace("/\
/", '', $string);
$string = preg_replace("/\<\/p\>/", "\n\n", $string);
$string = preg_replace("/\]*\>/", '', $string);
$string = preg_replace("/\<\/a\>/", '', $string);
$string = html_entity_decode($string, ENT_QUOTES, 'UTF-8');
return $string;
}
}[/geshi]
We're knee deep in complexity now... To keep the editAction() method cleaner, I've refactored a few steps into protected helper methods.
The workflow is quite straightforward. If no form is submitted, we populate a new form with reverse-filtered data so it's back in the same form we expect the author had originally inputted it. If we are dealing with a form submission, we simply check validity, and update the older entry on the database.
The main tricky bit is that _reverseAutoFormat() method. I'm certainly not comfortable with it since it makes a lot of assumptions - the worst one being to entity decode the data. It's a reasonable default, but chances are there will be some input the user expressly encoded for a reason. The content between <code> tags springs immediately to mind. In the future I would seriously consider a class expressly for reverse filtering that uses something more flexible like the DOM.
Here's the respective view template for our editAction():
[geshi lang=php]failedValidation): ?>
Some problems were detected with the submitted form.
entrySaved): ?>
Entry has been saved.
failedFind): ?>
The entry could not be found in the database.
entryForm ?>
[/geshi]
The ever mounting feedback messages are becoming intolerable I hope?
Fire ahead, and try out adding and editing entries.
Conclusion
Blog entries. You can't live without them, and despite their apparent simplicity they can be complex to program for. We're only at the start of a chain of work that will see us building a layer of entry data processing possibilities. The main ones covered here is creating, cleaning and editing. At some point I'll want to parse out coloured PHP syntax, perhaps insert some Microformatting. Maybe throw in more autoformatting. The strategies governing these and converting between presentation forms and user editable forms are well worth a careful examination.
In the next Part to this series we'll select the simplest strategy of them all. We'll retain original input on the database, and simply post-process the entry for any such filtering. This will add heavily to page view performance but we do have options to limit that!
Note: The source code for this entry is available to browse, or checkout with subversion, from http://svn.astrumfutura.org/zfblog/tags/Part8. The full source code for the entire application (as it exists thus far) from http://svn.astrumfutura.org/zfblog.
Related posts:
- An Example Zend Framework Blog Application – Part 5: Creating Models with Zend_Db and adding an Administration Module
- Example Zend Framework Blog Application Tutorial – Part 6: Introduction to Zend_Form and Authentication with Zend_Auth
- Example Zend Framework Blog Application Tutorial – Part 7: Authorisation with Zend_Acl and Revised Styling
- An Example Zend Framework Blog Application – Part 3: A Simple Hello World Tutorial
- An Example Zend Framework Blog Application – Part 4: Setting the Design Stage with Blueprint CSS Framework and Zend_Layout
| Print article | This entry was posted by Pádraic Brady on May 14, 2008 at 11:39 am, and is filed under PHP General, PHP Security, Zend Framework. Follow any responses to this post through RSS 2.0. You can leave a response or trackback from your own site. |
-
Skyblaze
-
http://blog.astrumfutura.com Pádraic Brady
-
Jérôme Poskin
-
http://blog.astrumfutura.com Pádraic Brady
-
Jérôme Poskin
-
Jérôme Poskin
-
http://blog.astrumfutura.com Pádraic Brady
-
Jérôme Poskin
-
http://blog.astrumfutura.com Pádraic Brady
-
http://panepucci.com.br Luciano Panepucci
-
http://panepucci.com.br Luciano Panepucci
-
http://blog.astrumfutura.com Pádraic Brady
-
http://bbn.by Codeator
-
http://blog.astrumfutura.com Pádraic Brady
-
http://projectgeo.hamera.com Travis H
-
dan
-
Navdeep Singh
-
ph1864
-
virtualme123
-
http://blog.astrumfutura.com Pádraic Brady
-
virtualme123
-
alistairh
-
Marc
-
Matt
