Posted on 2 Comments

Overloading constructors and functions in PHP

PHP Logo

Since I was recently asked whether it’s possible to overload constructors in PHP, or functions in general, here is the quick answer: No, not in the common sense of “overloading”, i.e. creating multiple versions of the same function name with different implementations and arguments. Thus, the following is not possible by default in PHP:

class MyClass {
  /**
   * Default constructor.
   */
  public function __construct() {
    ...
  }

  /**
   * NOT POSSIBLE: Overloaded constructor with additional argument(s).
   */
  public function __construct($someArgument) {
    ...
  }
}

Pattern-based Overloading in PHP

So how is it possible to achieve overloading of constructors and functions in general? Well, we can make use of the factory pattern and add fluent interfaces:

class MyClass {
  /**
   * Hide default constructor.
   */
  private function __construct() {
    ...
  }

  /**
   * Instead of overloaded constructor use factory pattern and fluent interfaces.
   */
  public static function create($someArg) {
    $instance = new self();
    ...
    return $instance; // keep it fluent and return instance
  }
}

func_get_args to the rescue

Ok, but this is not really overloading functions per-se. So, are there any alternatives to this approach? Yes, there are some, e.g. use func_get_args in the default constructor:

public function __construct()  {
   $arguments = func_get_args(); // get variable number of arguments
   ...

But using this approach chances are that you’ll end up with spaghetti code in order to check the arguments specified (amount, type, etc.). Thus, using the factory pattern in combination with fluent interfaces will keep your code clean and easily documentable.

Posted on 5 Comments

Enabling Cross-Origin Resource Sharing CORS for PHP

Source Code Icon

This post is an addition to Enabling Cross-Origin Resource Sharing CORS for Apache to show you how to enable Cross-Origin Resource Sharing CORS for PHP. Thus, in case you don’t have access to the .htaccess you can simply enable CORS for PHP using the following steps.

Setting required headers using PHP

As explained in Enabling Cross-Origin Resource Sharing CORS for Apache you need to make sure that responses to cross-domain requests to your server (e.g. through Ajax requests using jQuery) need to include a set of required headers to be accepted by the client browser. These are

  1. Access-Control-Allow-Origin
  2. Access-Control-Allow-Methods
  3. Access-Control-Max-Age
  4. Access-Control-Allow-Headers

Make sure that Access-Control-Allow-Origin is set a domain value actually allowed by your server. In theory you could use ‘*‘ as well, but some browsers (e.g. Firefox) will simply ignore it and CORS will not work.

PHP code to enable CORS

The following snippet should give you a quick overview about the required HTTP headers to set for CORS to work.

First, it defines a list of allowed origin domains based on regular expressions. This list will be checked against $_SERVER[‘HTTP_ORIGIN’], i.e. the Origin header specified in the client request. If one origin entry from the list matches the required CORS headers will be set. This setup also takes care of the CORS pre-flight request.

// array holding allowed Origin domains
$allowedOrigins = array(
  '(http(s)://)?(www\.)?my\-domain\.com'
);

if (isset($_SERVER['HTTP_ORIGIN']) && $_SERVER['HTTP_ORIGIN'] != '') {
  foreach ($allowedOrigins as $allowedOrigin) {
    if (preg_match('#' . $allowedOrigin . '#', $_SERVER['HTTP_ORIGIN'])) {
      header('Access-Control-Allow-Origin: ' . $_SERVER['HTTP_ORIGIN']);
      header('Access-Control-Allow-Methods: GET, PUT, POST, DELETE, OPTIONS');
      header('Access-Control-Max-Age: 1000');
      header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With');
      break;
    }
  }
}
Posted on Leave a comment

Magento Error ESI processing not enabled

Magento Logo

In a recent Magento setup with a Varnish caching server an undefined PHP variable in a template resulted in the error message “ESI processing not enabled“.

Due to the undefined PHP variable the underlying Varnish caching server’s Edge-side includes (ESI) capabilities, allowing content assembly by HTTP surrogates through in-markup XML-based language failed. After a quick code review of a just installed Magento extension there existed a newly introduced undefined PHP variable that caused Varnish to fail.

Thus, when using Varnish double check for undefined variables in Magento code 😉

Posted on 6 Comments

Web-based KeePass(X) Management Tool

KeePass and KeePassX are almost perfect tools when it comes to storing passwords and other sensitive information. Almost, since these tools currently only work offline and do not expose command line options out of the box. Since I want to access certain information on the road I was searching for ways to access KeePass and KeePassX via web interfaces. Unfortunately, I was unable to find suitable solutions that provided ways to directly interact with the underlying .kdb(x) databases.

Consequently, one of my next projects will be the create a web-based management tool to access KeePass and KeePassX databases. You can track the current status via GitHub. Feel free to leave suggestions for possible features, etc.

Posted on Leave a comment

PHP Continuous Integration ServerPHP Continuous Integration Server

PHP Logo

Continuous integration for PHP projects oftentimes exist of merely running your latest test cases against PHPUnit and on success uploading your new file versions to your staging (or even production) server. Although Jenkins is able to handle all those tasks (easily) the way along numerous configuration steps can be quite painful.

With PHPCI a new potential big player is rising that was specifically designed for PHP based projects. Instead of manually setting up PHPUnit and PHPMD for instance all required testing and deployment tools are already built-in as first class citizens.

For more information you might want to read Dan Cryer’s interview in the latest Web and PHP Magazine issue and visit the project’s homepage at http://www.phptesting.org/.

Posted on Leave a comment

Multilanguage support in Silverstripe

Silverstripe Logo

Operating interational websites and portals naturally requires multilanguage support and localization. Especially more prominent frameworks provide i18n support right out of the box. One of these is Silverstripe which recently has been released in version 3. This brief article describes the steps required to activate multilanguage support in Silverstripe.

First of all, you need to install the Translatable module, that builds on top of Silverstripe’s i18n core. Install this module as usual by placing the downloaded archive into the root folder of your project.

Before running a dev/build be sure to set default_locale to an existing locale, i.e. do not change the default locale in _config.php to a locale that does not yet exists (doing so has caused problems in a recent project).

Afterwards, as usual when altering core code in SilverStripe run a /dev/build?flush=all.

Now you should be able to add translations to existing pages by simply selecting the desired language from the dropdown menu.

Important: Always make sure to create translations in the context of an existing page, i.e. do not create a translation page manually. See the official module documentation on this matter.

Optionally, you can limit the translation languages available to content authors via _config.php.

Posted on 23 Comments

Shortening Strings (URLs) using Base 62 Encoding

Source Code Icon

Today, there already exist numerous URL shortening services, such as tinyurl, bit.ly, qr.cx and many more. Although they are very handy when it comes to shortening URLs in blog posts and articles oftentimes they can’t be used in proprietary software due to certain copyright and usage restrictions. Thus, a vast range of projects have evolved aiming to implement custom string (URL) shortening solutions.

This article presents a simple but effective template for creating a custom string shortening service, based a on proven and widely used algorithm – Base 62 encoding. Prior to discussing the source code let’s first break down the theory behind the shortening algorithm using Base 62 encoding.

Theory

What we want to achieve is a unique bi-directional mapping of a given string (e.g. URL) and a hash:

string <=> hash

Furthermore, we want this unique hash to be (much) shorter than the original string. Let’s have a look at the following example string and the (possible) shortened outcome:

This is an examplary long string to be shortened <=> T4Xza

Of course to achieve this bi-directional property, we need to be able to retrieve the original string based on the hash calculated and additionally, we must be given the same hash for the same string on every attempt. Thus, there must not exist more than one hash for the same original string and the encoding operation must be deterministic. This leads us to certain filtering operations for the input string prior to calculating the hash, but that’s just a matter of programmatic effort that we can handle later on.

Base 62

The encoding schema to be used by this shortening algorithm will be Base 62 encoding, as we are going to use 62 letters: [a-zA-Z0-9]. As always, the indexing begins at 0.

Database

In order to manage our list of shortened strings and their corresponding hashes we are going to use a database. In this example I’ve decided to use a MySQL database with the following schema: id : INT (primary key, auto increment) hash: VARCHAR (unique) url: VARCHAR Each encoded url (i.e. hash) entry will be identified by a unique id. Of course, whenever an entry should be added it needs to be checked if the same hash already exists, i.e. if the same url already exists in our database. Once this check is done and no same url exists we have to add a new entry and based on the last id generated we are going to calculate our hash. Following you find the corresponding SQL code for the schema:

CREATE TABLE IF NOT EXISTS `phpss` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `hash` varchar(100) COLLATE utf8_unicode_ci NOT NULL,
  `url` varchar(255) COLLATE utf8_unicode_ci NOT NULL,
  PRIMARY KEY (`id`),
  KEY `hash` (`hash`)
) ENGINE=InnoDB  DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;

Algorithm

Obviously, the underlying algorithm represents the main part of this solution. As previously mentioned, each shortened string will be represented by a unique id (and hash) in the database. We will make use of this unique ID to create hashes, using Base62 encoding. So, let’s say our next unique ID for the string to be shortened is 100. In this case 100 is Base10 encoded (100×10^0). Consequently, the next step would be to convert it to Base62 encoding. This can be done quite easily using modulo operation:

hashDigits = []
dividend = ID
remainder = 0

while(dividend > 0)
  remainder = modulo(dividend, 62)
  dividend = divide(dividend, 62)
  hashDigits.prepend(remainder)
endwhile

Thus, 100 would lead to 1×62^1 + 38×62^0 using Base62 encoding (hashDigits = [1, 38]). The next step is to convert these hashDigits to their corresponding Base62 representation, resulting in a unique hash string:

base62Alphabet = [a,b,c,...,A,B,C,...,0,1,2,...]
hashDigitsCount = hashDigits.count()
hashString = ""
i = 0

while(hashDigitsCount > i)
  hashString += base62Alphabet[hashDigits[i]]
  i++
endwhile

This would lead to the hash bM: b = base62Alphabet[1] M = base62Alphabet[38]

Improvements

This implementation is for reference only, since it (currently) lacks important checking functionality. At the moment it only checks the database for identical strings. While this is perfectly fine for normal text, when using URLs there are oftentimes multiple versions possible, e.g. http://google.com vs. http://www.google.com vs. http://www.google.com/. The algorithm itself seems pretty stable. If you find any bugs and have suggestions for improving the code please let me know.

I will add the code to GitHub in the near future, so please stand by for updates.

Source Code

Below you find the source code of the php string shortener.

phpStringShortener.php

/**
 * PHPStringShortener
 * 
 * A simple string shortener class to demonstrate how to shorten strings, such
 * as URLs using Base62 encoding.
 * 
 * @author Matthias Kerstner <matthias@kerstner.at>
 * @uses PDO 
 * @link https://www.kerstner.at/phpStringShortener
 */
require_once './config.php';

class PhpStringShortener {

    /**
     * @var PDO
     */
    private $DB_HANDLE = null;

    /**
     * The Base62 alphabet to be used.
     * @var array
     */
    private $BASE62_ALPHABET = array(
        'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z',
        'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z',
        '0', '1', '2', '3', '4', '5', '6', '7', '8', '9');

    //potential additional characters allowed unencoded in URLs include:
    //'$', '-', '_', '.', '+', '!', '*', '(', ')', ','
    //@see http://www.faqs.org/rfcs/rfc1738.html

    /**
     * Returns DB handle, implemented as singleton.
     * @return PDO
     * @see config.php
     */
    private function getDbHandle() {

        if ($this->DB_HANDLE !== null) {
            return $this->DB_HANDLE;
        }

        try {
            $this->DB_HANDLE = new PDO('mysql:host='
                            . PHPSS_DBHOST . ';dbname='
                            . PHPSS_DBDB, PHPSS_DBUSER, PHPSS_DBPASS, array(
                        PDO::ATTR_PERSISTENT => true
                    ));
            $this->DB_HANDLE->exec("SET CHARACTER SET utf8");

            return $this->DB_HANDLE;
        } catch (PDOException $e) {
            throw new Exception('Error: ' . $e->getMessage());
        }
    }

    /**
     * Closes the DB handle. 
     */
    private function closeDbHandle() {
        if ($this->DB_HANDLE !== null)
            $this->DB_HANDLE = null;
    }

    /**
     * Generates hash for $id specified using Base62 encoding.
     * @param int $id
     * @return string|null
     */
    private function generateHashForId($id) {
        $hash = '';
        $hashDigits = array();
        $dividend = (int) $id;
        $remainder = 0;

        while ($dividend > 0) {
            $remainder = floor($dividend % 62);
            $dividend = floor($dividend / 62);
            array_unshift($hashDigits, $remainder);
        }

        foreach ($hashDigits as $v) {
            $hash .= $this->BASE62_ALPHABET[$v];
        }

        return $hash;
    }

    /**
     * Closes DB handle.
     */
    public function __destruct() {
        $this->closeDbHandle();
    }

    /**
     * Returns string identified by $hash.
     * @param string $hash
     * @return string|null 
     */
    public function getStringByHash($hash) {
        $dbHandle = $this->getDbHandle();
        $sql = 'SELECT id, hash, url FROM phpss WHERE hash = :hash';
        $sth = $dbHandle->prepare($sql, array(PDO::ATTR_CURSOR => PDO::CURSOR_FWDONLY));
        $sth->execute(array(':hash' => $hash));
        $entry = $sth->fetch(PDO::FETCH_ASSOC);

        if (!$entry || count($entry) < 1 || !isset($entry['url'])) {
            return null;
        }

        return $entry['url'];
    }

    /**
     * Returns hash identified by $string.
     * @param string $string
     * @return string|null 
     */
    public function getHashByString($string) {
        $dbHandle = $this->getDbHandle();
        $sql = 'SELECT hash, url FROM phpss WHERE url = :url';
        $sth = $dbHandle->prepare($sql, array(PDO::ATTR_CURSOR => PDO::CURSOR_FWDONLY));
        $sth->execute(array(':url' => $string));
        $entry = $sth->fetch(PDO::FETCH_ASSOC);

        if (count($entry) > 0 && isset($entry['hash'])) { //hash already exists
            return $entry['hash'];
        }

        return null;
    }

    /**
     * Adds hash identified by $string if it does not already exist.
     * @param string $string 
     * @return string
     */
    public function addHashByString($string) {
        $hash = $this->getHashByString($string);

        if ($hash !== null) { //hash already exists
            return $hash;
        }

        $dbHandle = $this->getDbHandle();
        $sql = 'insert into phpss (id, hash, url) values(0, :hash, :url)';
        $sth = $dbHandle->prepare($sql, array(PDO::ATTR_CURSOR => PDO::CURSOR_FWDONLY));
        if (!$sth->execute(array(':hash' => '', ':url' => $string))) {
            throw new Exception('Error: failed to add entry (1)');
        }

        $lastInsertId = $dbHandle->lastInsertId();

        if (!$lastInsertId) {
            throw new Exception('Error: failed to add entry (2)');
        }

        $hash = $this->generateHashForId($lastInsertId);

        $sql = 'update phpss set hash = :hash where id = :id';
        $sth = $dbHandle->prepare($sql, array(PDO::ATTR_CURSOR => PDO::CURSOR_FWDONLY));
        if (!$sth->execute(array(':hash' => $hash, ':id' => $lastInsertId))) {
            throw new Exception('Error: failed to add entry (3)');
        }

        if ($hash !== null) { //hash already exists
            return $hash;
        }
    }

}

config.php

/**
 * PHPStringShortener
 *
 * A simple string shortener class to demonstrate how to shorten strings, such
 * as URLs using Base62 encoding.
 *
 * @author Matthias Kerstner <matthias@kerstner.at>
 * @uses PDO
 * @link https://www.kerstner.at/phpStringShortener
 */
define('PHPSS_DBHOST', 'localhost');
define('PHPSS_DBUSER', 'root');
define('PHPSS_DBPASS', '');
define('PHPSS_DBDB', 'phpss');

index.php

/**
 * PHPStringShortener
 *
 * A simple string shortener class to demonstrate how to shorten strings, such
 * as URLs using Base62 encoding.
 *
 * @author Matthias Kerstner <matthias@kerstner.at>
 * @uses PDO
 * @link https://www.kerstner.at/phpStringShortener
 */

require_once './phpStringShortener.php';

$cmd = isset($_GET['cmd']) ? $_GET['cmd'] : null;

if ($cmd !== null) {
    try {
        if ($cmd == 'get') {
            $hash = isset($_GET['hash']) ? $_GET['hash'] : null;

            if (!$hash) {
                die('No hash specified');
            }

            $phpSS = new PhpStringShortener();
            $string = $phpSS->getStringByHash($hash);

            //we expect strings to be URLs, so redirect now
            echo 'Redirecting to ' . $string;
            header('refresh:5;url=' . $string);
            exit;
        } else if ($cmd == 'add') {
            $string = isset($_GET['string']) ? $_GET['string'] : null;

            if (!$string) {
                die('No string specified');
            }

            $phpSS = new PhpStringShortener();
            $hash = $phpSS->addHashByString($string);

            die('Your hash for ' . $string . ' is: ' . $hash);
        }
    } catch (Exception $e) {
        die('Error: ' . $e->getMessage());
    }
}
Posted on 40 Comments

Mehrseitige Formulare mittels Zend Framework umsetzenImplementing multi-page forms using the Zend Framework

Unfortunately, the Zend Framework (ZF) version 1.x currently does not provide a standard way or class to implement multi-page forms based on Zend_Form. Due to the fact that this is a very common use case I will try to explain a working solution to overcome this problem.

Basically, the goal is to provide means to connect multiple sub-forms (Zend_Form_SubForm) together that can be browsed (“paged”) by users. Ideally, we want some sort of breadcrumb navigation too to enable users to jump directly to specific sub-forms, or steps. Data submitted for these steps will be kept inside a specific session namespace (Zend_Session_Namespace) to maintain states inbetween step changes.

UPDATE: Due to numerous requests I’ve decided to finally create a sample Zend project that incorporates the multi-page functionality described in this post. Please check the GitHub repository for the latest version.

Below you find the skeleton code required for multi-page form using ZF.

The Base Form

First of all, we need a basic form (Zend_Form) that incorporates our sub-forms, or steps. Since we want to reuse this base form we will create a base multi-page form class called Form_MultiPage, as shown in the following code snippet:

class Form_MultiPage extends Zend_Form {

 /**
  * Prepare a sub form for display.
  * @param  string|Zend_Form_SubForm $spec
  * @param bool? $isLastSubForm
  * @param bool? $showSubmitButton
  * @return Zend_Form_SubForm
  */
  public function prepareSubForm($spec, $isLastSubForm=false, $showSubmitButton=true) {}

 /**
  * Sets default form decorators to sub form unless custom decorators have 
  * been set previously.
  * @param  Zend_Form_SubForm $subForm
  * @return Form_MultiPage
  */
  public function setSubFormDecoratorsT(Zend_Form_SubForm $subForm) {}

 /**
  * Adds a submit button to @subForm.
  * @param  Zend_Form_SubForm $subForm
  * @param bool? $isLastSubForm
  * @param string? $continueLabel
  * @param string? $finishLabel
  * @return Zend_Form
  */
  public function addSubmitButton(Zend_Form_SubForm $subForm, $isLastSubForm=false, $continueLabel = 'Save and Continue', $finishLabel='Finish') {}

 /**
  * Adds action and method to sub form.
  * @param Zend_Form_SubForm $subForm
  * @return Zend_Form_SubForm
  */
  public function addSubFormAction(Zend_Form_SubForm $subForm) {}
}

As you can see Form_MultiPage already provides us with a limited set of functionality, most important of all prepareSubForm, which prepares the current sub-form to display.

The Concrete Base Form

Based on Form_MultiPage we then can derive the concrete base forms. For instance, let’s say we want to create a multi-page user registration form called Register_Form consisting of three steps: Account-, personal- and profile data.

class Register_Form extends Form_MultiPage {

  /**
  * Setup the sub-forms for this multi-page form.
  */
  public function init() {
    parent::init();

    $step1SubForm = new Register_Form_StepOne();
    $step2SubForm = new Register_Form_StepTwo();
    $step3SubForm = new Register_Form_StepThree();

    $this->addSubForms(array(
      'step1' => $step1SubForm,
      'step2' => $step2SubForm,
      'step3' => $step3SubForm
     ));
    }
}

Note that the sub forms Register_Form_StepXXX represent the actual forms that will be displayed to the users.

Based on Register_Form we are ready to start building the business logic for our multi-page forms.

The Controller

In order to process form requests we need a Zend_Controller that is capable of dealing with our multi-page form requests. Consequently, we introduce the Controller_MultiPage, which incorporates the basic functionality needed to handle multi-page form submissions:

class Controller_MultiPage extends Zend_Controller_Action {

    protected $_formClass;
    protected $_formClassName;
    protected $_form;
    protected $_verificationViewScript;
    protected $_namespace;
    protected $_session;

    /**
     * Determines which sub-forms should not have CSRF-protection
     * @var array 
     */
    protected $_excludeCsrfSubForms = array();

    /**
     * Checks step (subform) based on session data.
     * @param Zend_Form_SubForm $form 
     */
    protected function checkSessionStep(&$form) {}

    /**
     * Default populate function. You can override this function in your concrete
     * controller.
     * @param Zend_Form_SubForm $form
     * @param array $data
     */
    protected function populateForm($form, $data) {}

    /**
     * Custom sub form pre checks/actions.
     * @param Zend_Form_SubForm $subForm
     * @param array $rawPostData
     * @return Zend_Form_SubForm
     */
    protected function preSubFormActions($subForm, $rawPostData) {}

    /**
     * Checks whether to attach submit button.
     * @param string $formName
     * @return bool 
     */
    protected function checkAttachSubmitButton($formName) {}

    /**
     * Checks subform identified by $step.
     * @param string $step, e.g. 'step'[NUM], e.g. "step1"
     * @param Zend_Form_SubForm $subForm
     * @param array $cleanedPostData
     * @param array $rawPostData 
     * @return bool|array
     */
    protected function checkStep($step, Zend_Form_SubForm &$subForm, array $cleanedPostData, array $rawPostData) {}

    /**
     * Returns current form, i.e. form to be displayed to user based on previous action.
     * @return Zend_Form_SubForm
     */
    public function getForm() {}

    /**
     * Get the session namespace we're using.
     * @return Zend_Session_Namespace
     */
    public function getSessionNamespace() {}

    /**
     * Returns session namespace data as associative array.
     * @return array
     */
    public function getSessionNamespaceData() {}

    /**
     * Get a list of forms already stored in the session, i.e. those steps that have been verified.
     * @return array
     */
    public function getStoredForms() {}

    /**
     * Get list of all subforms available, i.e. the "steps".
     * @return array
     */
    public function getPotentialForms() {}

    /**
     * Determines which subform was submitted.
     * @return false|Zend_Form_SubForm
     */
    public function getCurrentSubForm() {}

    /**
     * Returns the "next" sub form to display. If current request is not POST, 
     * i.e. the page was requested directly via GET the user is redirected to
     * the first sub form. If the current subform is the last potential form it
     * will be returned again, otherwise the next sub form will be returned.
     * @return Zend_Form_SubForm|false
     */
    public function getNextSubForm() {}

    /**
     * Returns sub form specified by name. Returns getNextSubForm() if an invalid
     * name or a form has been specified that is not valid and its order is 
     * greater than current sub form + 1.
     * @param string $subFormName
     * @return Zend_Form_SubForm
     */
    public function getSubForm($subFormName) {}

    /**
     * Checks if form is the last one of our base form.
     * @param string $subFormName
     * @return bool 
     */
    public function isLastSubForm($subFormName) {}

    /**
     * Returns amount of remaining sub forms not yet verified/completed.
     * @return int
     */
    public function getRemainingFormCount() {}

    /**
     * Checks if sub form is valid, i.e. valid data has been POSTed.
     * @param  Zend_Form_SubForm $subForm
     * @param  array $postData
     * @return bool
     */
    public function subFormIsValid(Zend_Form_SubForm $subForm, array $postData) {}

    /**
     * Checks if the *entire* form is valid, i.e. if all sub form have been validated.
     * @return bool
     */
    public function formIsValid($form, $postData) {}

    /**
     * Produces a breadcrumb trail based on the form's current status and attaches
     * it to the subform specified.
     * Sets the following meta-fields for each breadcrumb:
     * - form: name of the subform referenced
     * - label: label to display
     * - valid: if form is valid and complete
     * - enabled: if form is enabled to be processed (i.e. previous step is valid)
     * - active: if sub form specified is currently active (i.e. is current sub form)
     * @return Zend_Form_SubForm
     */
    public function addFormBreadCrumbs($subForm) {}

    /**
     * Adds CSRF protection to subform.
     * @param Zend_Form_SubForm $subForm
     * @return Zend_Form_SubForm 
     */
    protected function addCsrfProtection($form) {}

    /**
     * The last sub-form is to verify the entire form -> display form data submitted once more. 
     * This comes in handy if you want to users to present their data entered in all sub forms
     * once more to let them verify their input.
     * @param Zend_Form_SubForm $form
     * @return Zend_Form_SubForm 
     */
    public function attachFinalFormVerification($form) {}

    /**
     * Display <form> to add private user account and POST to addAction to 
     * process data.
     */
    public function indexAction() {}

    /**
     * This is were the actual form processing happens. Each submit button of each sub form
     * should be linked to this action as it contains the business logic of this multi page controller.
     */
    public function processAction() {}

    /**
     * Submission is complete, i.e. entire form has been verified.
     */
    public function completedAction() {}
    
    /**
     * This method has to be implemented in the inheriting class.
     * It contains all actions required to finally process the completed/valid form.
     * Takes data from session namespace.
     */
    protected function processValidForm() {}
}

As you can see the controller provides a lot of functionality to handle multi-page form submissions. Most important to note is the fact that form submissions are handled by processAction.

Again, based on this base multi-page controller class Controller_MultiPage we are ready to implement our concrete registration controller, Registration_Controller:

class Register_Controller extends Controller_MultiPage {

  protected $_formClass = 'Register_Form';
  protected $_formClassName = 'Register';
  protected $_verificationViewScript = '_verification.phtml';
  protected $_subFormLabels = array(
    'step1' => 'Step1',
    'step2' => 'Step2',
    'step3' => 'Step3');
  protected $_namespace = 'RegisterController';

  public function indexAction() {
    parent::indexAction(); //explicitely delegate to parent
  }

  public function processAction() {
    parent::processAction(); //explicitely delegate to parent
  }

  protected function checkStep($step, Zend_Form_SubForm &$subForm, array $cleanedPostData, array $rawPostData) {
    $step = preg_replace('/[a-z]/i', '', $step);

    switch ($step) {
        default:
           return $cleanedPostData; //to be implemented, e.g. by checkStepX()
    }
  }

  /**
   * To be called when entire form has been validated.
   */
  protected function processValidForm() {
    $this->getSessionNamespace()->lock(); //lock against further editing
    $formData = $this->getSessionNamespaceData();
		
    //DO SOME PROCESSING...

    // remove session namespace
    $sessionNamespace = new Zend_Session_Namespace($this->_namespace);
    $sessionNamespace->unsetAll();
    Zend_Session::namespaceUnset($this->_namespace);
  }
}

Basically, Controller_MultiPage lacks the capability of checking sub form specific constraints during a submission. This functionality needs to be implemented by the concrete implementation’s checkStep, which again may delegate to other checking functions.

Once the entire form (i.e. all of its sub forms) have been submitted completely processValidForm should be called to do some final processing (i.e. persist the account to be registered). Additionally, it will first lock and then erase the session namespace after final submission.

Based on this architecture you are able to implement highly flexible multi-page forms using nothing more than Zend_Form and Zend_Form_SubForm.

Source files

Below you find the complete source code for this example consisting of the following files:

Form_MultiPage.php

/**
 * Base class for multipage forms.
 *
 * @author <matthias.kerstner>
 * @link https://www.kerstner.at
 */
class Form_MultiPage extends Zend_Form {

    /**
     * Prepare a sub form for display.
     *
     * @param  string|Zend_Form_SubForm $spec
     * @return Zend_Form_SubForm
     */
    public function prepareSubForm($spec, $isLastSubForm = false, $showSubmitButton = true) {
        $subForm = null;

        if (is_string($spec)) {
            $subForm = $this->{$spec};
        } elseif ($spec instanceof Zend_Form_SubForm) {
            $subForm = $spec;
        } else {
            throw new Exception('Invalid argument passed to ' .
                    __FUNCTION__ . '()');
        }

        $this->setSubFormDecoratorsT($subForm)
                ->addSubFormAction($subForm);

        if ($showSubmitButton) {
            $this->addSubmitButton($subForm, $isLastSubForm);
        }

        return $subForm;
    }

    /**
     * Sets default form decorators to sub form unless custom decorators have 
     * been set previously.
     *
     * @param  Zend_Form_SubForm $subForm
     * @return Form_MultiPage
     */
    public function setSubFormDecoratorsT(Zend_Form_SubForm $subForm) {
        $subForm->setDecorators(array('FormElements',
            array('HtmlTag', array('tag' => 'div',
                    'class' => 'mmsForm')),
            'Form'));

        return $this;
    }

    /**
     * Add a submit button to an individual sub form
     *
     * @param  Zend_Form_SubForm $subForm
     * @return Zend_Form
     */
    public function addSubmitButton(Zend_Form_SubForm $subForm, $isLastSubForm = false, $continueLabel = 'Save and Continue', $finishLabel = 'Finish') {

        $label = $isLastSubForm ? $finishLabel : $continueLabel;

        $subForm->addElement(new Form_Element_Submit(
                        'submit',
                        array(
                            'label' => $label,
                            'required' => false,
                            'ignore' => true
                        )
        ));

        return $this;
    }

    /**
     * Adds action and method to sub form.
     *
     * @param  Zend_Form_SubForm $subForm
     * @return My_Form_Registration
     */
    public function addSubFormAction(Zend_Form_SubForm $subForm) {
        $lang = Zend_Registry::get('Zend_Locale')->getLanguage();
        $view = Zend_Layout::getMvcInstance()->getView();
        $action = $view->url(array('language' => $lang, 'action' => 'process'), $view->multilanguage()->getCurrentRoute());

        $subForm->setAction($action)
                ->setAttrib('enctype', 'multipart/form-data')
                ->setMethod('post');
        return $this;
    }

}

Custom_Multipage_Controller.php

/**
* A base controller class for multipage forms.
*
* Only valid data will be stored in session (sub-)namespace corresponding to 
* subform and metadata field will be set accordingly.
*
* @author <matthias.kerstner>
* @link https://www.kerstner.at
*/
class Custom_Controller_MultiPage extends Zend_Controller_Action {
protected $_formClass;
protected $_formClassName;
protected $_form;
protected $_verificationViewScript;
protected $_namespace;
protected $_session;
/**
* determines which sub-forms should *not* have CSRF-protection
* @var array 
*/
protected $_excludeCsrfSubForms = array();
/**
* Redirects user to index action if needed.
* @param Zend_Form_SubForm $form 
*/
protected function checkSessionStep(&$form) {
$step = preg_replace('/[a-z]/i', '', $form->getName());
$session = $this->getSessionNamespaceData();
if ($step > 1 && !isset($session['step1'])) {
//redirect user to start if previous steps have not yet been completed
$this->_helper->redirector('index');
}
}
/**
* Default populate function. You can override this function in your concrete
* controller.
* @param type $form
* @param type $data
*/
protected function populateForm($form, $data) {
/**
* populate with data -> be aware that only fields set in 
* this array will be populated!
*/
return $form->populate($data);
}
/**
* Custom subform pre checks/actions.
* @param Zend_Form_SubForm $subForm
* @param type $rawPostData
* @return Zend_Form_SubForm
*/
protected function preSubFormActions($subForm, $rawPostData) {
return $subForm;
}
/**
*
* @param type $formName
* @return type 
*/
protected function checkAttachSubmitButton($formName) {
return true; //default action unless overriden by concrete implementation
}
/**
* Checks subform.
* @param type $step, e.g. 'step'[NUM], e.g. "step1"
* @param Zend_Form_SubForm $subForm
* @param array $cleanedPostData
* @param array $rawPostData 
* @return bool|array
*/
protected function checkStep($step, Zend_Form_SubForm &$subForm, array $cleanedPostData, array $rawPostData) {
$step = preg_replace('/[a-z]/i', '', $step);
return $cleanedPostData;  //TO BE IMPLEMENTED IN CONCRETE CONTROLLER
}
/**
* 
*/
public function getForm() {
if ($this->_form === null) {
if ($this->_formClass == '')
throw new Exception('No multipage form set');
$this->_form = new $this->_formClass;
if (!$this->_form)
throw new Exception('No multipage form set');
}
return $this->_form;
}
/**
* Get the session namespace we're using
*
* @return Zend_Session_Namespace
*/
public function getSessionNamespace() {
if (null === $this->_session) {
if (empty($this->_namespace))
throw new Exception('No namespace set for multipage form');
$this->_session = new Zend_Session_Namespace($this->_namespace);
}
return $this->_session;
}
/**
*
* @return type 
*/
public function getSessionNamespaceData() {
$data = array();
foreach ($this->getSessionNamespace() as $k => $v) {
if (!is_array($v))
continue;
foreach ($v as $kForm => $vForm) {
$data[$k] = $vForm;
}
}
return $data;
}
/**
* Get a list of forms already stored in the session
*
* @return array
*/
public function getStoredForms() {
$stored = array();
foreach ($this->getSessionNamespace() as $key => $value) {
$stored[] = $key;
}
return $stored;
}
/**
* Get list of all subforms available
*
* @return array
*/
public function getPotentialForms() {
return array_keys($this->getForm()->getSubForms());
}
/**
* What sub form was submitted?
*
* @return false|Zend_Form_SubForm
*/
public function getCurrentSubForm() {
$request = $this->getRequest();
if (!$request->isPost()) {
return false;
}
foreach ($this->getPotentialForms() as $name) {
$data = $request->getPost($name, false);
if ($data) {
if (is_array($data)) {
return $this->getForm()->getSubForm($name);
break;
}
}
}
return false;
}
/**
* Returns the "next" subform to display. If current request is not POST, 
* i.e. the page was requested directly via GET the user is redirected to
* the first subform. If the current subform is the last potential form it
* will be returned again, otherwise the next subform will be returned.
* @return Zend_Form_SubForm|false
*/
public function getNextSubForm() {
if ($this->getRemainingFormCount() < 1) {
return false; //no more subforms to process
}
$storedForms = $this->getStoredForms();
$potentialForms = $this->getPotentialForms();
$sessionData = $this->getSessionNamespaceData();
$currentSubForm = $this->getCurrentSubForm();
if (!$currentSubForm) { //return first subform since no form was submitted (no POST)
return $this->getForm()->getSubForm($potentialForms[0]);
}
$currentSubFormName = $currentSubForm->getName();
$currentSubFormIdx = array_search($currentSubFormName, $potentialForms);
if ($this->isLastSubForm($currentSubFormName)) {
return $this->getForm()->getSubForm(end($potentialForms));
}
return $this->getForm()->getSubForm($potentialForms[$currentSubFormIdx + 1]);
}
/**
* Returns subform specified by name. Returns getNextSubForm() if an invalid
* name or a form has been specified that is not valid and its order is 
* greater than current subform + 1.
* @param type $subFormName
* @return type 
*/
public function getSubForm($subFormName) {
$storedSubForms = $this->getStoredForms();
if (in_array($subFormName, $storedSubForms)) { //requested subform is active already
return $this->getForm()->getSubForm($subFormName);
}
return $this->getCurrentSubForm();
}
/**
* Checks if form is last.
* @param string $subFormName
* @return bool 
*/
public function isLastSubForm($subFormName) {
$potentialSubForms = $this->getPotentialForms();
return (array_search($subFormName, $potentialSubForms) >= (count($potentialSubForms) - 1));
}
/**
*
* @return type 
*/
public function getRemainingFormCount() {
$storedSubForms = $this->getStoredForms();
$potentialSubForms = $this->getPotentialForms();
$sessionData = $this->getSessionNamespaceData();
$completedSubForms = 0;
foreach ($storedSubForms as $name) {
if (isset($sessionData[$name]['metadata']['complete']) &&
($sessionData[$name]['metadata']['complete'])) {
$completedSubForms++;
}
}
return (count($this->getPotentialForms()) - $completedSubForms);
}
/**
* Is the sub form valid?
*
* @param  Zend_Form_SubForm $subForm
* @param  array $postData
* @return bool
*/
public function subFormIsValid(Zend_Form_SubForm $subForm, array $postData) {
if ($subForm->isValid($postData)) {
$subFormName = $subForm->getName();
$formData = $subForm->getValues();
// init metadata
$formData[$subFormName]['metadata'] = array();
// call custom checker function for subform
$checkedFormData = $this->checkStep($subFormName, $subForm, $formData, $postData);
if (!$checkedFormData) {
return false; // custom subform check failed
} else {
$formData = $checkedFormData;
}
$formData[$subFormName]['metadata']['complete'] = true;
$this->getSessionNamespace()->$subFormName = $formData; //overwrite existing values
// activate next subform to be selectable (if not already activated)
$potentialSubForms = $this->getPotentialForms();
$storedSubForms = $this->getStoredForms();
$currentSubFormIdx = array_search($subFormName, $potentialSubForms);
// "unlock" next subform
if ($currentSubFormIdx < (count($potentialSubForms) - 1)) {
$nextSubFormName = $potentialSubForms[$currentSubFormIdx + 1];
if (!isset($storedSubForms[array_search($nextSubFormName, $potentialSubForms)])) {
$this->getSessionNamespace()->$nextSubFormName = array();
}
}
return true; //subform is valid
}
//set subset of VALID fields in session namespace
$subSubForms = $subForm->getSubForms();
$subFormName = $subForm->getName();
$validSubFormFields = array();
$subFormNames = array_keys($subSubForms);
foreach ($subSubForms as $k => $v) {
$elements = $v->getElements();
$validSubFormFields[$k] = array();
foreach ($elements as $kEl => $vEl) {
//check if field has been posted and is valid
//be sure to specifiy context for isValid since it is required
//by certain validators, such as identical
if (isset($postData[$subFormName][$k][$kEl]) &&
$vEl->isValid($postData[$subFormName][$k][$kEl], $postData[$subFormName][$k])) {
$validSubFormFields[$k][$kEl] = $vEl->getValue();
}
}
}
//register (partial) valid data in session namespace for subform
$this->getSessionNamespace()->$subFormName = array($subFormName => $validSubFormFields);
//mark as invalid (again)
$formData[$subFormName]['metadata']['complete'] = false;
return false;
}
/**
* Is the full form valid?
*
* @return bool
*/
public function formIsValid($form, $postData) {
if ($this->getRemainingFormCount() >= 1)
return false;
//final submission only allowed using submit button and not breadcrumbs
return isset($postData[$form->getName()]['submit']);
}
/**
* Produces a breadcrumb list based on the form's current status and attaches
* it to the subform specified.
* Sets the following meta-fields for each breadcrumb:
* - form: name of the subform referenced
* - label: label to display
* - valid: if form is valid and complete
* - enabled: if form is enabled to be processed (i.e. previous step is valid)
* - active: if subform specified is currently active (i.e. is current subform)
* @return Zend_Form_SubForm
*/
public function addFormBreadCrumbs($subForm) {
$formLabels = $this->_subFormLabels;
$storedForms = $this->getStoredForms();
$potentialForms = $this->getPotentialForms();
$subFormName = $subForm->getName();
$sessionData = $this->getSessionNamespaceData();
$breadCrumbs = array();
foreach ($storedForms as $k => $v) { //mark enabled/valid subforms
$breadCrumbs[] = array(
'form' => $v,
'label' => $formLabels[$v],
'enabled' => true,
'active' => false,
'valid' => (isset($sessionData[$v]['metadata']['complete']) &&
$sessionData[$v]['metadata']['complete']));
}
foreach ($potentialForms as $v) { //add incomplete/missing subforms
if (!in_array($v, $storedForms)) {
$breadCrumbs[] = array(
'form' => $v,
'label' => $formLabels[$v],
'enabled' => false,
'active' => false,
'valid' => false);
}
}
//set current form-breadcrumb active
foreach ($breadCrumbs as $k => $breadCrumb) {
if ($breadCrumb['form'] == $subFormName) {
$breadCrumbs[$k]['active'] = true;
}
}
// sort breadcrumbs ascending
function valSort($a, $b) {
return strtolower($a['form']) > strtolower($b['form']);
}
usort($breadCrumbs, 'valSort');
//activate first button by default
$breadCrumbs[0]['enabled'] = true;
$breadCrumbSubForm = new Form_BreadCrumbs();
$subForm->addSubForm($breadCrumbSubForm->addBreadCrumbs($breadCrumbs), 'breadCrumbs', 1000); //1000=p at the very end of the form
return $subForm;
}
/**
*
* @param type $subForm
* @return type 
*/
protected function addCsrfProtection($form) {
//TODO: add your CSRF protection here
return $form;
}
/**
* The last sub-form is verification -> display form data submitted once more
* @param Zend_Form_SubForm $form
* @return Zend_Form_SubForm 
*/
public function attachFinalFormVerification($form) {
$translate = Zend_Registry::get('Zend_Translate');
$potentialSubForms = $this->getPotentialForms();
if ($form->getName() != end($potentialSubForms)) {
return $form; // form submitted is !last subform
}
$invalidForms = '';
$sessionData = $this->getSessionNamespaceData();
$moduleName = $this->getRequest()->getModuleName();
foreach ($potentialSubForms as $formName) {
if ($formName != end($potentialSubForms) &&
(!isset($sessionData[$formName]) ||
!isset($sessionData[$formName]['metadata']['complete']) ||
!$sessionData[$formName]['metadata']['complete'])) {
$invalidForms .= "<li>" . $translate->_(mb_strtoupper($moduleName) . '.' .
mb_strtoupper($this->_formClassName) . '.FORMSTEP.' . $formName) . "</li>";
}
}
$html = '';
if ($invalidForms) {
$html .= '<div id="VerificationInvalidFormsWarningContainer">';
$html .= $translate->_('FORM.VERIFICATION.ERRORMSG');
$html .= '<ul>' . $invalidForms . '</ul>';
$html .= '</div>';
}
//TODO: attach $html to $form
return $form;
}
/**
* Display <form> to add private user account and POST to addAction to 
* process data.
*/
public function indexAction() {
// Either re-display the current page, or grab the "next"
// (first) sub form
if (!$form = $this->getCurrentSubForm()) {
$form = $this->getNextSubForm();
}
if (!$form) { //we are already done processing the form
return $this->render('done');
}
$formSessionData = $this->getSessionNamespaceData();
$form = $this->attachFinalFormVerification($form);
$form = $this->addFormBreadCrumbs($form);
$form = $this->addCsrfProtection($form);
$form = $this->getForm()->prepareSubForm($form, false, $this->checkAttachSubmitButton($form->getName()));
$form = $this->populateForm($form, $formSessionData);
$this->view->form = $form;
}
/**
*
* @return type 
*/
public function processAction() {
if (!$form = $this->getCurrentSubForm()) { // no active subform
return $this->_forward('index');
}
$postData = $this->getRequest()->getPost();
$nextSubFormToLoad = null;
// breadcrumb selected -> jump to this subform after saving current subform
if (isset($postData[$form->getName()]['breadCrumbs'])) {
$breadCrumbSubmit = array_keys($postData[$form->getName()]['breadCrumbs']);
$nextSubFormToLoad = $this->getSubForm($breadCrumbSubmit[0]);
}
// actions/checks to be carried out PRIOR to actual validation
$this->checkSessionStep($form);
$form = $this->preSubFormActions($form, $postData);
// check submitted subform
if (!$this->subFormIsValid($form, $postData)) {
// set next subform to load based on breadcrumb navigation
if ($nextSubFormToLoad != null) {
$form = $nextSubFormToLoad;
}
$formSessionData = $this->getSessionNamespaceData();
$form = $this->populateForm($form, $formSessionData);
if ($nextSubFormToLoad != null) { // validate to show errors when navigating via breadcrumbs
$form->isValid($formSessionData);
}
$form = $this->preSubFormActions($form, $postData);
$form = $this->addFormBreadCrumbs($form);
$form = $this->attachFinalFormVerification($form);
$form = $this->addCsrfProtection($form);
$this->view->form = $this->getForm()->prepareSubForm($form, $this->isLastSubForm($form->getName()), $this->checkAttachSubmitButton($form->getName()));
return $this->render('index');
}
// check entire form
if (!$this->formIsValid($form, $postData)) {
// set next subform to load
if ($nextSubFormToLoad != null) {
$form = $nextSubFormToLoad; // breadcrumb navigation
} else {
$form = $this->getNextSubForm(); // next step 
}
$formSessionData = $this->getSessionNamespaceData();
$form = $this->populateForm($form, $formSessionData);
if ($nextSubFormToLoad != null) { // validate to show errors when navigating via breadcrumbs
$form->isValid($formSessionData);
}
$form = $this->preSubFormActions($form, $postData);
$form = $this->addFormBreadCrumbs($form);
$form = $this->attachFinalFormVerification($form);
$form = $this->addCsrfProtection($form);
$this->view->form = $this->getForm()
->prepareSubForm($form, $this->isLastSubForm($form->getName()), $this->checkAttachSubmitButton($form->getName()));
return $this->render('index');
}
try { // valid and complete form data received
// persist data
$this->processValidForm();
// unset session to prevent double submissions
$this->getSessionNamespace()->unsetAll();
Zend_Session::namespaceUnset($this->_namespace);
// redirect to prevent double posting
$this->_helper->redirector('completed', null, null, array('language' => $this->getCurrentLanguage()));
} catch (Exception $e) {
throw new Exception('Failed to add account: ' . $e->getMessage());
}
}
/**
* 
*/
public function completedAction() {
return $this->render('done');
}
/**
* This method has to be implemented in the inheriting class
* Here are all the actions done to finally process the complete and valid form.
* Takes data from session namespace.
*/
protected function processValidForm() {
throw new Exception('This method has to be implemented in the inheriting class');
}
}

Form_BreadCrumbs.php

Finally, if you are dealing with multiple subforms you want some sort of breadcrumb navigation:

/**
* @author matthias.kerstner
*/
class Form_BreadCrumbs extends Zend_Form_SubForm {
/**
*
* @param array $breadCrumbs 
*/
public function addBreadCrumbs($breadCrumbs) {
foreach ($breadCrumbs as $k => $v) {
$attribs = array('class' => 'button');
$breadCrumb = new Zend_Form_Element_Submit(
$v['form'],
array(
'label' => _($v['label']),
'required' => false,
'ignore' => true,
'order' => $k,
'decorators' => array(
'viewHelper',
'Errors'
)));
/**
* set attributes based on status
*/
if (!$v['enabled']) {
$attribs['disabled'] = 'disabled';
$attribs['class'] = ($attribs['class'] != "" ? $attribs['class'] . " " : "") . "disabled";
} else {
$attribs['class'] = ($attribs['class'] != "" ? $attribs['class'] . " " : "") . "enabled";
}
if ($v['active']) {
$attribs['class'] = ($attribs['class'] != "" ? $attribs['class'] . " " : "") . "active";
}
if ($v['valid']) {
$attribs['class'] = ($attribs['class'] != "" ? $attribs['class'] . " " : "") . "valid";
} else {
$attribs['class'] = ($attribs['class'] != "" ? $attribs['class'] . " " : "") . "invalid";
}
$breadCrumb->setAttribs($attribs);
$this->addElement($breadCrumb);
}
return $this;
}
public function addBreadCrumbsToMenu($breadCrumbs) {
$menulink = '<dt id="breadCrumbs-label">&#160;</dt><dd id="breadCrumbs-element"><fieldset id="fieldset-breadCrumbs"><dl>';
foreach ($breadCrumbs as $k => $v) {
$menulink .= '<input type="submit" name="step'.$k.'[breadCrumbs][step'.$k.']" id="step'.$k.'-breadCrumbs-step'.$k.'" value="'.$v['label'].'" class="';
$attribs = array('class' => 'button');
if (!$v['enabled']) {
$attribs['disabled'] = 'disabled';
$attribs['class'] = ($attribs['class'] != "" ? $attribs['class'] . " " : "") . "disabled";
} else {
$attribs['class'] = ($attribs['class'] != "" ? $attribs['class'] . " " : "") . "enabled";
}
if ($v['active']) {
$attribs['class'] = ($attribs['class'] != "" ? $attribs['class'] . " " : "") . "active";
}
if ($v['valid']) {
$attribs['class'] = ($attribs['class'] != "" ? $attribs['class'] . " " : "") . "valid";
} else {
$attribs['class'] = ($attribs['class'] != "" ? $attribs['class'] . " " : "") . "invalid";
}
$menulink .=  $attribs['class'];
$menulink .=  '" />';
}
echo $menulink;
return $this;
}
}

Posted on 4 Comments

PHP – Array to XML Conversion

PHP Logo

The following function presents a simple approach to convert PHP arrays to their XML counterpart. It supports nested arrays too. Internally, it uses SimpleXMLElement to do the actual work.

/**
* @param array $array the array to be converted
* @param string? $rootElement if specified will be taken as root element, otherwise defaults to 
*                <root>
* @param SimpleXMLElement? if specified content will be appended, used for recursion
* @return string XML version of $array
*/
function arrayToXml($array, $rootElement = null, $xml = null) {
$_xml = $xml;
if ($_xml === null) {
$_xml = new SimpleXMLElement($rootElement !== null ? $rootElement : '<root/>');
}
foreach ($array as $k => $v) {
if (is_array($v)) { //nested array
arrayToXml($v, $k, $_xml->addChild($k));
} else {
$_xml->addChild($k, $v);
}
}
return $_xml->asXML();
}

Examples are:

echo arrayToXml(array('testOne' => 'xml'));
echo arrayToXml(array('testOne' => array('testOneInner' => 'content')));
echo arrayToXml(array('testOne' => array('testOneInner' => 'content'), 'testTwo' => 'content'));
echo arrayToXml(array('testOne' => array('testOneInner' => array('testOneInnerInner' => 'content')), 'testTwo' => 'content'));
echo arrayToXml(array('testOne' => array('testOneInner' => array('testOneInnerInner' => 'content')), 'testTwo' => array('testTwoInner' => array('testTwoInnerInner' => 'content'))));

which produces:

<?xml version="1.0"?>
<root><testOne>xml</testOne></root>
<?xml version="1.0"?>
<root><testOne><testOneInner>content</testOneInner></testOne></root>
<?xml version="1.0"?>
<root><testOne><testOneInner>content</testOneInner></testOne><testTwo>content</testTwo></root>
<?xml version="1.0"?>
<root><testOne><testOneInner><testOneInnerInner>content</testOneInnerInner></testOneInner></testOne><testTwo>content</testTwo></root>
<?xml version="1.0"?>
<root><testOne><testOneInner><testOneInnerInner>content</testOneInnerInner></testOneInner></testOne><testTwo><testTwoInner><testTwoInnerInner>content</testTwoInnerInner></testTwoInner></testTwo></root>
Posted on 6 Comments

Delete/copy directories recursively using PHP

PHP Logo

As there currently does not exist a PHP function that allows developers to delete or copy entire (non-empty) directories at once one has to recursively loop through them to manually delete/copy their contents.

This post provides you with two easy to use functions for

  1. deleting entire (non-empty) directories
  2. copying entire (non-empty) directories

First, let’s start with the delete function:

/**
* Removes all files from $source, optionally ignoring SVN meta-data 
* folders (default).
* @param string $source
* @return boolean 
*/
public static function deleteDirectory($source, $excludeSvnFolders=true, $recusion=false) {
$dir_handle = opendir($source);
if (!$dir_handle)
return false;
while ($file = readdir($dir_handle)) {
if ($file == '.' || $file == '..')
continue;
if ($excludeSvnFolders && $file == '.svn')
continue;
if (!is_dir($source . '/' . $file)) {
unlink($source . '/' . $file);
} else {
self::deleteDirectory($source . '/' . $file, $excludeSvnFolders, true);
}
}
closedir($dir_handle);
if ($recusion) {
rmdir($source);
}
return true;
}

The second function handles copying entire directories:

/**
* Copies contents from $source to $dest, optionally ignoring SVN meta-data
* folders (default).
* @param string $source
* @param string $dest
* @param boolean $ignoreSvnFolders
* @return boolean true on success false otherwise
*/
public static function copyDirectory($source, $dest, $excludeSvnFolders=true) {
$sourceHandle = opendir($source);
if (!$sourceHandle) {
echo 'failed to copy directory: failed to open source ' . $source;
return false;
}
while ($file = readdir($sourceHandle)) {
if ($file == '.' || $file == '..')
continue;
if ($excludeSvnFolders && $file == '.svn')
continue;
if (is_dir($source . '/' . $file)) {
if (!file_exists($dest . '/' . $file)) {
mkdir($dest . '/' . $file, 0755);
}
self::copyDirectory($source . '/' . $file, $dest . '/' . $file, $excludeSvnFolders);
} else {
copy($source . '/' . $file, $dest . '/' . $file);
}
}
return true;
}

As you may have noticed both function are declared static. This is due to the fact that I am using them in a general purpose file management class.

Both functions offer the possibility to ignore SVN meta-data folders, i.e. .svn directories. Further improvements to these functions could be to extend them with an array of folders and files to exclude, instead of the boolean $excludeSvnFolders.

Posted on 1 Comment

Calculate next auto-increment value for MySQL tables

Sometimes it is necessary to determine the next auto-increment value that will be used by MySQL for inserting values into tables.

First off, there are several different approaches to determine it. Most people use the MAX() function and simply add 1 to the value. Although this is fine is most cases, you may run into problems when forced to retain data consistency after deleting rows.

Another approach is to determine the AUTO_INCREMENT value directly:

SHOW TABLE STATUS LIKE 'your_table'

In order to determine the next auto-increment value used by MySQL you simply have to get the value of the field called ‘Auto_increment’.

Using PHP, this can be achieved like so:

$result = mysql_query("SHOW TABLE STATUS LIKE 'your_table'");
$row = mysql_fetch_assoc($result);
return $row['Auto_increment'];

Posted on 2 Comments

Assigning PHP variables in shell

Google Logo

For a project I am currently working on it was necessary to read PHP define()’d-values from a configuration file using a shell script that did some further processing.

In order to be able to read define()’d-values you need to first include() (or better require_once()) the source configuration file(s) and then assign its values to shell variables.

  1. Determine path to PHP:
    PHP_PATH="/opt/lampp/bin/php"
    
  2. Include any required configuration files:
    PHP_REQUIRE="require_once('$(pwd)/../src/config/base.php');"
    
  3. Finally assign PHP variables to shell:
    RELEASE_NUMBER=`$PHP_PATH -r "$PHP_REQUIRE echo RELEASE_VERSION;"`
    

Hint: In case your PHP installation is included in PATH you can simply write PHP_PATH=”php”.

In this example the release version number is read from a base-configuration file, which will be used by the shell script for further processing.

Posted on 7 Comments

PHP Mailing List

A simple mailing list manager written in PHP.

Using PHP Mailing List you can easily set up mailing lists with unlimited number of users. Messages can be sent using the web interface and will be broadcasted by PHP Mailing List to all list members. Members can be added/removed from lists via the web interface too.

Also, PHP Mailing List offers the possibility to retrieve a list of members. Actions such as adding/removing and approving members is automatically handled by PHP Mailing List through confirmations emails. Furthermore, if an admin account email is set it will be notified of any changes made to the lists.

Spam protection is achieved through the use of a captcha widget included in the web interface. Moreover, member email addresses are shown only partially to avoid harvesting attacks.

Functionality Overview

PHP mailing list provides the following core functionality:

  1. subscribe: Add members to a mailing list via form.php
  2. unsubscribe: Remove subscribed members from a mailing list
  3. authorize: Authorize a subscription request
  4. send message: Send messages to all members of a mailing list

Download: phpMailingList on GitHub

UPDATE: Due to numerous requests I’ve moved the source code to GitHub. Please check the Github repository for the latest version.

Installation

  1. Edit config.ini to your needs.
  2. Edit form.php, members.php and error.php to your needs.
  3. Be sure to set correct permissions for the lists-directory set in config.ini.
  4. Test your installation by creating a list, e.g.: lists/mylist/
  5. Call index.php?list=mylist to use your shiny new mailing list. Hint: In case you get write permission errors be sure to set the correct permissions for the lists-directory (and any subdirectories andfiles).

Configuration

Everything you need (or can) configure can be found in config/config.ini. You are free to customize form.php and members.php to your needs. Make sure to leave the core input fields “as-is”.

Posted on Leave a comment

HTML Table Extractor

Source Code Icon

This PHP script parses “useful” information from HTML tables. It has been developed as an additional tool in the course of my master thesis at the Graz University of Technology.

Please refer to my master thesis for detailed information on this tool.

/**
* HTMLTableExtractor
*
* Parses HTML tables and extracts "useful" information from HTML table.
* Valid "keywords" can be identified by their markup: bold-face.
* Note that keywords are only taken into consideration until special delimiters
* are reached. Any further keywords markup will be ignored. Writes parsed
* output in (parser) optimized format to file specified for main().
*
* main():
* - Reads all files from source folder specified to main().
* - Writes output to file specified to main().
* - Writes log messages to log file specified to main().
*
* @filesource htmlTableExtractor.html
* @author Matthias Kerstner info@kerstner.at
* @version 1.6
*/
require_once 'simple_html_dom.php'; //http://sourceforge.net/projects/simplehtmldom/
set_time_limit(0); //remove timeout limit
ini_set('memory_limit', '5000M'); //increase buffer limit for simple_html_dom...
mb_internal_encoding('UTF-8');
mb_regex_encoding('UTF-8');
define('LANG_CNT', 2); //including source language
header('Content-type: text/html; charset=utf-8'); //for warnings, errors,...
/**
* Converts given @param{$filename} into UTF-8 encoding and returns content.
* @param <String> $filename
* @return <String>
*/
function file_get_contents_utf8($filename) {
$content = file_get_contents($filename);
return mb_convert_encoding($content, 'UTF-8',
mb_detect_encoding($content, 'UTF-8, ISO-8859-1', true));
}
/**
* Recursively parses @param{$baseDir} for files to be processed
* @param <String> $baseDir
* @return <array> all files found in @param{$baseDir}
*/
function gatherSrcFiles($baseDir) {
$list = array();
if(!is_dir($baseDir))
die('ERROR: baseDir "'.$baseDir.'"does not exist');
$handle = null;
if($handle = @opendir($baseDir)) {
while(false !== ($file = readdir($handle))) {
if($file != '.' && $file != '..')
$list[] = $baseDir."/".$file;
}
closedir($handle);
}
return $list;
}
/**
* Checks @param{$dstFile} for consistent translation entries. Errors
* will be printed to stdout. Calculates (simple) statistical output.
* @param string $dstFile
*/
function checkConsistency($dstFile, $logFile) {
$fp = null;
$fpLog = null;
if(false === ($fp = @fopen($dstFile, 'r')))
die("ERROR: Failed to open dstFile '".$dstFile."', aborting...");
if(false === ($fpLog = @fopen($logFile, 'a+')))
die("ERROR: Failed to open logFile '".$logFile."', aborting...");
$errBuffer = '';
$lineCnt = 0;
$inconsistentCnt = 0;
while(!feof($fp)) {
$line = fgets($fp, 4096);
if(!mb_ereg_match("[^\W]+ -> [^\W]+", $line)) { //check syntax
$errBuffer .= "\nWARNING: invalid line found: '".$line."'";
$inconsistentCnt += 1;
}
$lineCnt += 1;
}
fclose($fp);
$buff = "Consistency-check statistics:\n".$inconsistentCnt." / ".$lineCnt;
$percentage = 0;
if($lineCnt > 0) //calculate percentage
$percentage = round($inconsistentCnt / $lineCnt, 2) * 100;
$buff .= ", ".$percentage."% inconsistent\n".$errBuffer;
fwrite($fpLog, $buff);
fclose($fpLog);
}
/**
* Removes page header information from @param{$fileContent} and returns it.
* @param <String> $fileContent
* @return <String>
*/
function removePageHeader($fileContent) {
$pattern = '<SPAN ID="Frame\d+" DIR="LTR" STYLE="float: left; '.
'width: 7.74cm; height: 0.74cm; border: none; padding: 0cm; '.
'background: #ffffff">\s*<P LANG="de-DE" CLASS="western" '.
'ALIGN=CENTER( STYLE="margin-top: 0cm")*>\s*'.
'(<FONT FACE="Verdana, sans-serif">)*(<FONT SIZE=2>)*\s*'.
'[a-zA-ZäüöÄÜÖß]*\s*(–\s*[a-zA-ZäüöÄÜÖß]*)*\s*(</FONT>)*\s*(</P>)*'.
'\s*</SPAN>';
return mb_ereg_replace($pattern, '', $fileContent);
}
/**
* Runs the HTML table extractor.
* @param <String> $baseDir base folder containing files to be processed
* @param <String> $dstFile destination path where output will be written to
* @param <String> $logFile destination path where log output will be written to
*/
function main($baseDir, $dstFile, $logFile) {
$fpDst = null;
$fpLog = null;
$formatted_src_lang = "";
if(!defined('LANG_CNT'))
die("ERROR: LANG_CNT not defined");
if(false === ($fpDst = @fopen($dstFile, 'w')))
die("ERROR: Failed to open dstFile '".$dstFile."', aborting...");
if(false === ($fpLog = @fopen($logFile, 'w')))
die("ERROR: Failed to open logFile '".$logFile."', aborting...");
fwrite($fpDst, "<HTML><HEAD><META HTTP-EQUIV=\"CONTENT-TYPE\" ".
"CONTENT=\"text/html; charset=utf-8\"></HEAD><BODY>");
$files = gatherSrcFiles($baseDir);
$delimiters = array(":", ",");   //valid delimiters for delimiting keyword-search
foreach($files as $f) { //parse files in selection
$fileContent = removePageHeader(file_get_contents($f));
$html = str_get_html(file_get_contents($fileContent));
foreach($html->find('tr') as $tr) { //parse translations
$langs = $tr->getElementsByTagName('td');
$langCnt = 0;
foreach($langs as $td) { //parse translations
if($langCnt >= (int)LANG_CNT) {
fwrite($fpLog, "WARNING: invalid syntax detected ".
"(trying to continue...)\n");
break; //invalid td-count, break loop to retain data consistency
}
$paragraphs = $td->getElementsByTagName('p');
if(count($paragraphs) < 1) {
fwrite($fpLog, "WARNING: invalid p count detected ".
"(trying to continue...)\n");
break; //invalid td-count, break loop to retain data consistency
}
$p = $paragraphs[0]; //max 1 per td allowed (all others will be ignored)
$plainUntilDelim = mb_ereg_replace("[\t]", "",
html_entity_decode(trim($p->plaintext), ENT_QUOTES, 'UTF-8'));
$firstFound = mb_strlen($plainUntilDelim);
foreach($delimiters as $d) { //find first delimiter position
$pos = mb_strpos($plainUntilDelim, $d);
if($pos !== false && $pos < $firstFound)
$firstFound = $pos;
}
if($firstFound >= 1) {
//parse plaintext until first delimiter found (or to the end if none found)
$plainUntilDelim = mb_substr($plainUntilDelim, 0, $firstFound);
}
//echo "<br><br>plain until delim=".$plainUntilDelim;
$plainUntilDelimLen = mb_strlen($plainUntilDelim);
$keywordParts = $p->getElementsByTagName('b');
$keyword = '';
$searchOffset = 0;
$parsedKeywordParts = array();
foreach($keywordParts as $v) {
//parse keyword(s) (all bold characters until first delimiter is found)
//remove special characters except delimiters (!)
$stripped = mb_ereg_replace("[\t]", "",
html_entity_decode(trim($v->plaintext), ENT_QUOTES, 'UTF-8'));
$firstFoundStripped = mb_strlen($stripped);
foreach($delimiters as $d) { //find first delimiter position
$pos = mb_strpos($stripped, $d);
if($pos !== false && $pos < $firstFoundStripped)
$firstFoundStripped = $pos;
}
if($firstFound >= 1) //extract plaintext until first delimiter
$stripped = mb_substr($stripped, 0, $firstFoundStripped);
$stripped = mb_ereg_replace("[,:]", "", trim($stripped)); //do not remove spaces here
if(!empty($stripped) && !in_array($stripped, $parsedKeywordParts)) {
//ignore empty tags and duplicates
//echo "<br>searching for ".$stripped . "(".mb_strlen($stripped).") with offset=".$searchOffset.", langCnt=".$langCnt." %2=".($langCnt%2)."<br";
$pos = mb_strpos($plainUntilDelim, $stripped, $searchOffset);
if($pos === false)
break;  //not found inside delimited plaintext -> break loop
if($pos > $searchOffset+1 && ($langCnt % 2 != 0))
$keyword .= " ".$stripped; //translation-td, insert space between keyword-parts
else
$keyword .= $stripped;
$parsedKeywordParts[] = $stripped;
$searchOffset = $pos; //set next iteration offset
}
}
$keyword = mb_ereg_replace("[\t\r\n]", " ", trim($keyword)); //strip any unnecessary chars
if(empty($keyword))
fwrite($fpLog, "ERROR: could not determine keyword\n");
if($langCnt % 2 != 0) { //used for pretty formatting in dstFile
fwrite($fpDst, "[".$keyword."]<br>{".$formatted_src_lang."} => {".$p->innertext."}<br><br>");
} else { //src language
fwrite($fpDst, "[".$keyword."] => ");
$formatted_src_lang = $p->innertext;
}
$langCnt += 1;
} //end parse translations
} //end parse rows
fwrite($fpDst, "<br><br><br>"); //start next file
$html->clear(); //prevent memory-leak
unset($html);
} //end parse files
fwrite($fpDst, "</BODY></HTML>");
fclose($fpDst);
fclose($fpLog);
checkConsistency($dstFile, $logFile);
echo "DONE!";
}
// start processing
main('/source/path', '/dst/path/to/output.html', '/dst/path/to/out.log');