Showing posts with label PHP. Show all posts
Showing posts with label PHP. Show all posts

Thursday, 3 July 2025

Generating llms.txt files with PHP

To make your website or project site provide LLM-friendly content, there's a new standard on the horizon named /llms.txt. It suggests adding a llms.txt containing human and LLM readable content in a fixed Markdown format.

Using llms-txt-php

In the PHP ecosystem, there's a relatively fresh PHP 📦 for writing, reading, and validating llms.txt Markdown files. The following snippet shows how to programmatically model such a file.
use Stolt\LlmsTxt\LlmsTxt;
use Stolt\LlmsTxt\Section;
use Stolt\LlmsTxt\Section\Link;

$section1 = (new Section())->name('Section name')
    ->addLink((new Link())->urlTitle('Link title')
        ->url('https://link_url')->urlDetails('Optional link details')
    );
$section2 = (new Section())->name('Optional')
    ->addLink((new Link())->urlTitle('Link title')
        ->url('https://link_url')
    );

$llmsTxt = (new LlmsTxt())->title('Test title')
  ->description('Test description')
  ->details('Test details')
  ->addSections([$section1, $section2])
  ->addSection($section2)
  ->toString();
Reading a llms.txt file and its parts are done the following way.
use Stolt\LlmsTxt\LlmsTxt;

$llmsText = (new LlmsTxt())->parse('/path/to/llmsTxt.md');

if ($llmsText->validate()) {
    $title = $llmsText->getTitle();
    $description = $llmsText->getDescription();
    $details = $llmsText->getDetails();
    $sections = $llmsText->getSections();
}
The llms-txt-php package also provides a minimalistic CLI to interact with an existing llms.txt file or boostrap it's creation. The following console output lists the currently available commands.
php bin/llms-txt list

llms-txt-php 1.6.1

Usage:
  command [options] [arguments]

Options:
  -h, --help            Display help for the given command. When no command is given display help for the list command
      --silent          Do not output any message
  -q, --quiet           Only errors are displayed. All other output is suppressed
  -V, --version         Display this application version
      --ansi|--no-ansi  Force (or disable --no-ansi) ANSI output
  -n, --no-interaction  Do not ask any interactive question
  -v|vv|vvv, --verbose  Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug

Available commands:
  check-links  Check links of a llms txt file
  completion   Dump the shell completion script
  help         Display help for a command
  info         Get metadata info of a llms txt file
  init         Create an initial llms txt file
  list         List commands
  validate     Validate the given llms txt file

Friday, 17 January 2025

Developing a PHP package, 2025 edition

Since my blog post about the anatomy of a dope PHP package repository a lot has changed in the PHP ecosystem and it's surrounding tools. Time to reflect these changes and provide, yet another, guide to develop a PHP package in 2025.

Idea to implementation

The hardest part to get started is finding an idea worth implementing. Sources of inspiration can be visiting conferences, visiting local user groups, or the good old blog post reading. Also, daily business might generate an idea worth investing time. The idea might be very niche at first but that shouldn't stop you from going for it; the learnings might advance your self-confidence and career.

Getting started

Template repositories are a GitHub feature released in June 2019, which allows you and others to generate new Git repositories with the same directory structure and files based on the given template repository. Some templates to look into are spatie/package-skeleton-php and ergebnis/php-package-template. If you're developing a dedicated Laravel package spatie/package-skeleton-laravel is a good starting point. For checking if a package is PDS conform, there are for one the command-line tools of the pds/skeleton project or the package analyser which is a very opinionated validator/analyser.

Must have development tools

Some of the above-mentioned package templates come with preconfigured development tools like PHPUnit or the PHP Coding Standards Fixer. Other tools you might have to add or switch yourself. For example when you're developing a Laravel package you might want to replace PHPUnit with the traction gaining Pest testing framework and the PHP Coding Standards Fixer with Pint. For aiding you in development, tools like PHPStan and Rector have become quite mandatory. Also, worth checking out is their thriving extension ecosystem e.g. Larastan and rector-laravel. For the CI part, GitHub Actions have replaced former CI environments like Travis CI. So it might be a good invest to look into the usage and definition of GitHub Actions.

The automation of dependency updates for dependant Composer packages can currently be handled via Dependabot but might soon be replaced by Conductor.

Identifying wording or grammar mistakes in repositories might become easier once we can add Peck, a wrapper around GNU Aspell, into the mix.

To practice Datensparsamkeit you can add the lean-package-validator, which ensures that no unnecessary package artifacts end up in the distributed dist archives. For some additional development tools, it's also worth checking out Tomas Votruba's tools selection.

Filling the gaps with polyfills

In case you're stuck with an older PHP or PHPUnit version, there are polyfills (polyfill-php83, polyfill-php84, and PHPUnit-Polyfills) available for filling the gaps; which eases later migrations to current PHP or PHPUnit versions.

Registering your package

After having finished the implementation of the package you need to Git tag it, ideally following semantic versioning, and submit it at Packagist, the main PHP package repository.

Package promotion

To promote your package start with a care- and heartful crafted README.md file. If you want to rise the visibility of your package invest some time to create a catching logo or delegate its creation to your bubble. Other channels to promote your work are X, the awesome PHP list, and giving a presentation about it at your local user group.

Thursday, 23 November 2023

Automating the backslash prefixing for native PHP function calls

After reading the blog post Why does a backslash prefix improve PHP function call performance by Jeroen Deviaene I was looking for a way to automate it for the codebase of the Lean Package Validator, to shave off some miliseconds for it's CLI. The PHP Coding Standards Fixer has a rule named native_function_invocation which does the very exact task.

Configuring the PHP Coding Standards Fixer

.php-cs-fixer.php
<?php

use PhpCsFixer\Config;
use PhpCsFixer\Finder;

$finder = Finder::create()
    ->in([__DIR__, __DIR__ . DIRECTORY_SEPARATOR . 'tests']);

$rules = [
    'psr_autoloading' => false,
    '@PSR2' => true,
    'phpdoc_order' => true,
    'ordered_imports' => true,
    'native_function_invocation' => [
        'include' => ['@internal'],
        'exclude' => ['file_put_contents']
    ]
];

$cacheDir = \getenv('HOME') ? \getenv('HOME') : __DIR__;

$config = new Config();

return $config->setRules($rules)
    ->setFinder($finder)
    ->setCacheFile($cacheDir . '/.php-cs-fixer.cache');
To make this rule executeable I needed to add the --allow-risky=yes option to the PHP Coding Standards Fixer calls in the two dedicated Composer scripts shown next.

composer.json
"scripts": {
    "lpv:test": "phpunit",
    "lpv:test-with-coverage": "export XDEBUG_MODE=coverage && phpunit --coverage-html coverage-reports",
    "lpv:cs-fix": "php-cs-fixer --allow-risky=yes fix . -vv || true",
    "lpv:cs-lint": "php-cs-fixer fix --diff --stop-on-violation --verbose --dry-run --allow-risky=yes",
    "lpv:configure-commit-template": "git config --add commit.template .gitmessage",
    "lpv:application-version-guard": "php bin/application-version --verify-tag-match=bin",
    "lpv:application-phar-version-guard": "php bin/application-version --verify-tag-match=phar",
    "lpv:static-analyse": "phpstan analyse --configuration phpstan.neon.dist", 
    "lpv:validate-gitattributes": "bin/lean-package-validator validate"
},
After running the lpv:cs-fix Composer script the first time the tests of the system under test started failing due to file_put_contents being prefixed with a backslash when using phpmock\MockBuilder's setName method, so I had to exclude it as shown in the PHP Coding Standards Fixer configuration above.

Monday, 15 January 2018

Documenting Composer scripts

For open source projects I'm involved with, I developed the habit to define, and document the steady growing amount of repository and build utilities via Composer scripts. Having Composer scripts available makes it trivial to define aliases or shortcuts for complex and hard to remember CLI calls. It also lowers the barrier for contributors to start using these tools while helping out with fixing bugs or providing new features. Finally they're also simplifying build scripts by stashing away complexity.

Defining Composer scripts

If you've already defined or worked with Composer scripts or even their npm equivalents you can skip this section, otherwise the next code snippet allows you to study how to define these. The here defined Composer scripts range from simple CLI commands with set options (e.g. the test-with-coverage script) to more complex build utility tools (i.e. the application-version-guard script) which are extracted into specific CLI commands to avoid cluttering up the composer.json or even the .travis.yml.

composer.json
{
  "scripts": {
    "test": "phpunit",
    "test-with-coverage": "phpunit --coverage-html coverage-reports",
    "cs-fix": "php-cs-fixer fix . -vv || true",
    "cs-lint": "php-cs-fixer fix --diff --stop-on-violation --verbose --dry-run",
    "configure-commit-template": "git config --add commit.template .gitmessage",
    "application-version-guard": "php bin/application-version --verify-tag-match"
  }
}

Describing Composer scripts

Since Composer 1.6.0 it's possible to set custom script descriptions via the scripts-descriptions element like shown next. It's to point out here that the name of a description has to match the name of a defined custom Composer script to be recognised at runtime. On another note it's to mention that the description should be worded in simple present to align with the other Composer command descriptions.

composer.json
{
  "scripts-descriptions": {
    "test": "Runs all tests.",
    "test-with-coverage": "Runs all tests and measures code coverage.",
    "cs-fix": "Fixes coding standard violations.",
    "cs-lint": "Checks for coding standard violations.",
    "configure-commit-template": "Configures a local commit message template.",
    "application-version-guard": "Checks that the application version matches the given Git tag."
  },
  "scripts": {
    "test": "phpunit",
    "test-with-coverage": "phpunit --coverage-html coverage-reports",
    "cs-fix": "php-cs-fixer fix . -vv || true",
    "cs-lint": "php-cs-fixer fix --diff --stop-on-violation --verbose --dry-run",
    "configure-commit-template": "git config --add commit.template .gitmessage",
    "application-version-guard": "php bin/application-version --verify-tag-match"
  }
}
Now when running $ composer via the terminal the descriptions of defined custom scripts will show up sorted in into the list of available commands, which makes it very hard to spot the Composer scripts of the package at hand. Luckily Composer scripts can also be namespaced.

Namespacing Composer scripts

To namespace (i.e. some-namespace) the custom Composer scripts for any given package define the script names with a namespace prefix as shown next. As the chances are very high that you will be using the one or other Composer script several times, while working on the package, it's recommended to use a short namespace like in the range from two to four characters.

composer.json
{
  "scripts": {
    "some-namespace:test": "phpunit",
    "some-namespace:test-with-coverage": "phpunit --coverage-html coverage-reports",
    "some-namespace:cs-fix": "php-cs-fixer fix . -vv || true",
    "some-namespace:cs-lint": "php-cs-fixer fix --diff --stop-on-violation --verbose --dry-run",
    "some-namespace:configure-commit-template": "git config --add commit.template .gitmessage",
    "some-namespace:application-version-guard": "php bin/application-version --verify-tag-match"
  }
}
Now this time when running $ composer via the terminal the defined custom scripts will show up in the list of available commands in a namespaced manner giving an immediate overview of the available Composer script of the package at hand.

$ composer
 ... ommitted content
Available commands:
  ... ommitted content
 some-namespace
  some-namespace:application-version-guard  Checks that the application version matches the given Git tag.
  some-namespace:configure-commit-template  Configures a local commit message template.
  some-namespace:cs-fix                     Fixes coding standard violations.
  some-namespace:cs-lint                    Checks for coding standard violations.
  some-namespace:test                       Runs all tests.
  some-namespace:test-with-coverage         Runs all tests and measures code coverage.
To use any namespaced Composer script, e.g. to fix coding standard violations after a substantial refactoring, it has to be called with its namespace e.g.$ composer some-namespace:cs-fix, which is the one disadavantage of Composer script namespacing.

Saturday, 25 March 2017

Keeping your CLI integration tests green on Windows

Lately on a Windows system, some failing integration tests for CLI commands utilising the Symfony Console component caused me some blip headaches by PHPUnit insisting that two strings are not identical due to different line endings. The following post documents the small steps I took to overcome these headaches.

First the assertion message produced by the failing test, see the console output below, got me thinking it might be caused by different encodings and line endings; though the project was utilising an .editorconfig from the early start and the related files were all encoded correctly and had the configured line endings. The Git configuration e.g. core.autocrlf=input also was as it should be.

1) Stolt\LeanPackage\Tests\Commands\InitCommandTest::createsExpectedDefaultLpvFile
Failed asserting that two strings are identical.
--- Expected
+++ Actual
@@ @@
 #Warning: Strings contain different line endings!
-Created default 'C:\Users\stolt\AppData\Local\Temp\lpv\.lpv' file.
+Created default 'C:\Users\stolt\AppData\Local\Temp\lpv\.lpv' file.
Another deeper look at the CommandTester class yielded that it’s possible to disable the command output decoration and also to normalise the command output. So a change of the SUT preparation and a normalisation of the console output, visualised via a git diff -U10, brought the solution for this particular test.

diff --git a/tests/Commands/InitCommandTest.php b/tests/Commands/InitCommandTest.php
index 58e7114..fb406f3 100644
--- a/tests/Commands/InitCommandTest.php
+++ b/tests/Commands/InitCommandTest.php
@@ -48,21 +48,21 @@ class InitCommandTest extends TestCase
     /**
      * @test
      */
     public function createsExpectedDefaultLpvFile()
     {
         $command = $this->application->find('init');
         $commandTester = new CommandTester($command);
         $commandTester->execute([
             'command' => $command->getName(),
             'directory' => WORKING_DIRECTORY,
-        ]);
+        ], ['decorated' => false]);

// ommitted  code

-        $this->assertSame($expectedDisplay, $commandTester->getDisplay());
+        $this->assertSame($expectedDisplay, $commandTester->getDisplay(true));
         $this->assertTrue($commandTester->getStatusCode() == 0);
         $this->assertFileExists($expectedDefaultLpvFile);
Since the SUT had a lot of integration test for its CLI commands, the lazy me took the shortcut to extend the CommandTester and using it, with desired defaults set, instead of changing all of the related command instantiations.

<?php

namespace SUT\Tests;

use Symfony\Component\Console\Tester\CommandTester as ConsoleCommandTester;

class CommandTester extends ConsoleCommandTester
{
    /**
     * Gets the display returned by the last execution of the command.
     *
     * @param bool $normalize Whether to normalize end of lines to \n or not
     *
     * @return string The display
     */
    public function getDisplay($normalize = true)
    {
        return parent::getDisplay($normalize);
    }

    /**
     * Executes the command.
     *
     * Available execution options:
     *
     *  * interactive: Sets the input interactive flag
     *  * decorated:   Sets the output decorated flag
     *  * verbosity:   Sets the output verbosity flag
     *
     * @param array $input   An array of command arguments and options
     * @param array $options An array of execution options
     *
     * @return int The command exit code
     */
    public function execute(
        array $input,
        array $options = ['decorated' => false]
    ) {
        return parent::execute($input, $options);
    }
}
So it's a yay for green CLI command integration tests on Windows from here on. Another measure for the SUT would be to enable Continuous Integration on a Windows system via AppVeyor, but that’s a task for another commit 85bdf22.

Monday, 10 October 2016

Eight knobs to adjust and improve your Travis CI builds

After having refactored several Travis CI configuration files over the last weeks, this post will provide eight adjustments or patterns immediately applicable for faster, changeable, and economic builds.

1. Reduce git clone depth

The first one is a simple configuration addition with a positive impact on the time and disk space consumption, which should be quite noticeable on larger code bases. Having this configured will enable shallow clones of the Git repository and reduce the clone depth from 50 to 2.

.travis.yml
git:
  depth: 2

2. Enable caching

The second one is also a simple configuration addition for caching the Composer dependencies of the system under build (SUB) or its result of static code analysis. Generally have a look if your used tools allow caching and if so cache away. This one deserves a shout out to @localheinz for teaching me about this one.

The next shown configuration excerpt assumes that you lint coding standard compliance with the PHP Coding Standards Fixer in version 2.0.0-alpha and have enable caching in its .php_cs configuration.

.travis.yml
cache:
  directories:
    - $HOME/.composer/cache
    - $HOME/.php-cs-fixer

3. Enforce contribution standards

This one might be a tad controversial, but after having had the joys of merging GitHub pull requests from a master branch I started to fail builds not coming from feature or topic branch with the next shown bash script. It's residing in an external bash script to avoid the risk of terminating the build process.

./bin/travis/fail-non-feature-topic-branch-pull-request
#!/bin/bash
set -e
if [[ $TRAVIS_PULL_REQUEST_BRANCH = master ]]; then
  echo "Please open pull request from a feature / topic branch.";
  exit 1;
fi

.travis.yml
script:
  - ./bin/travis/fail-non-feature-topic-branch-pull-request
The bash script could be extended to fail pull requests not following a branch naming scheme, e.g. feature- for feature additions or fix- for bug fixes, by evaluating the branch name. If this is a requirement for your builds you should also look into the blocklisting branches feature of Travis CI.

4. Configure PHP versions in an include

With configuring the PHP versions to build against in a matrix include it's much easier to inject enviroment variables and therewith configure the version specific build steps. You can even set multiple enviroment variables like done on the 7.0 version.

.travis.yml
env:
  global:
    - OPCODE_CACHE=apc

matrix:
  include:
    - php: hhvm
    - php: nightly
    - php: 7.1
    - php: 7.0
      env: DISABLE_XDEBUG=true LINT=true
    - php: 5.6
      env: 
      - DISABLE_XDEBUG=true

before_script:
  - if [[ $DISABLE_XDEBUG = true ]]; then
      phpenv config-rm xdebug.ini;
    fi
I don't know if enviroment variable injection is also possible with the minimalistic way to define the PHP versions list, so you should take that adjustment with a grain of salt.

It also seems like I stumbled upon a Travis CI bug where the global enviroment variable OPCODE_CACHE is lost, so add another grain of salt. To work around that possible bug the relevant configuration has to look like this, which sadly adds some duplication and might be unsuitable when dealing with a large amount of environment variables.

.travis.yml
matrix:
  include:
    - php: hhvm
      env: 
      - OPCODE_CACHE=apc
    - php: nightly
      env: 
      - OPCODE_CACHE=apc
    - php: 7.1
      env: 
      - OPCODE_CACHE=apc
    - php: 7.0
      env: OPCODE_CACHE=apc DISABLE_XDEBUG=true LINT=true
    - php: 5.6
      env: OPCODE_CACHE=apc DISABLE_XDEBUG=true

before_script:
  - if [[ $DISABLE_XDEBUG = true ]]; then
      phpenv config-rm xdebug.ini;
    fi

5. Only do static code analysis or code coverage measurement once

This one is for reducing the build duration and load by avoiding unnecessary build step repetition. It's achived by linting against coding standard violations or generating the code coverage for just a single PHP version per build, in most cases it will be the same for 5.6 or 7.0.

.travis.yml
matrix:
  include:
    - php: hhvm
    - php: nightly
    - php: 7.1
    - php: 7.0
      env: DISABLE_XDEBUG=true LINT=true
    - php: 5.6
      env: 
      - DISABLE_XDEBUG=true

script:
  - if [[ $LINT=true ]]; then
      composer cs-lint;
      composer test-test-with-coverage;
    fi

6. Only do release releated analysis and checks on tagged builds

This one is also for reducing the build duration and load by targeting the analysis and checks on tagged release builds. For example if you want to ensure that your CLI binary version, the one produced via the --version option, matches the Git repository version tag run this check only on tagged builds.

.travis.yml
script:
  - if [[ ! -z "$TRAVIS_TAG" ]]; then
      composer application-version-guard;
    fi

7. Run integration tests on very xth build

For catching breaking changes in interfaces or API's beyond your control it makes sense do run integration tests against them once in a while, but not on every single build, like shown in the next Travis CI configuration excerpt which runs the integration tests on every 50th build.

.travis.yml
script:
  - if [[ $(( $TRAVIS_BUILD_NUMBER % 50 )) = 0 ]]; then
      composer test-all;
    else
      composer test;
    fi

8. Utilise Composer scripts

The last one is all about improving the readability of the Travis CI configuration by extracting command configurations i.e. options into dedicated Composer scripts. This way the commands are also available during your development activitives and not hidden away in the .travis.yml file.

composer.json
{
    "__comment": "omitted other configuration",
    "scripts": {
        "test": "phpunit",
        "test-with-coverage": "phpunit --coverage-html coverage-reports",
        "cs-fix": "php-cs-fixer fix . -vv || true",
        "cs-lint": "php-cs-fixer fix --diff --verbose --dry-run"
    }
}
To ensure you don't end up with an invalid Travis CI configuration, which might be accidently committed, you can use composer-travis-lint a simple Composer script linting the .travis.yml with the help of the Travis CI API.

Happy refactoring.

Tuesday, 20 September 2016

Anatomy of a dope PHP package repository

While contributing to Construct, maintained by Jonathan Torres, I gathered some insights and learnings on the characteristics of a dope PHP package repository. This post summarises and illustrates these, so that PHP package develeopers have a complementary guideline to improve existing or imminent package repositories. Jonathan Reinink did a good job in putting the PHP package checklist out there which provides an incomplete, but solid quality checklist for open-source PHP packages.

I'll distill the characteristics of a dope PHP package repository by looking at the repository artifacts Construct can generate for you when starting the development of a new PHP project or micro-package. The following tree command output shows most of the elements this post will touch upon. The artifacts in parenthese are optional and configurable from Construct but can nonetheless have an import impact on the overall package quality.

├── <package-name>
│   ├── CHANGELOG.md
│   ├── (CONDUCT.md)
│   ├── composer.json
│   ├── composer.lock
│   ├── CONTRIBUTING.md
│   ├── (.editorconfig)
│   ├── (.env)
│   ├── (.env.example)
│   ├── (.git)
│   │   └── ...
│   ├── .gitattributes
│   ├── (.github)
│   │   ├── CONTRIBUTING.md
│   │   ├── ISSUE_TEMPLATE.md
│   │   └── PULL_REQUEST_TEMPLATE.md
│   ├── .gitmessage
│   ├── .gitignore
│   ├── (.lgtm)
│   ├── LICENSE.md
│   ├── (MAINTAINERS)
│   ├── (.php_cs)
│   ├── (phpunit.xml.dist)
│   ├── README.md
│   ├── (docs)
│   │   └── index.md
│   ├── src
│   │   └── Logger.php
│   ├── tests
│   │   └── LoggerTest.php
│   ├── .travis.yml
│   ├── (Vagrantfile)
│   └── vendor
│           └── ...

Definition of a dope PHP package repository

Before jumping into the details, let's define what could be considered as a dope package repository. Therefor, being lazy, I'm going to simply reword this classic quote from Michael Feathers
> Clean code is code that is written by someone who cares.
to
> A dope PHP package repository is one that is created and maintained by someone who cares.

Artifact categories

The next shown pyramid illustrates the three main categories the artifacts of a package repository will fall into.
First and most important there's the main sourcecode, it's tests or specs, and the documentation which could be dependent on it's size reside in a README.md section or inside a dedicated docs directory. Using a docs directory also allows publishing the documentation via GitHub pages. Other aspects of a package which should be documented are the chosen license, how to contribute to the package, possibly a code of conduct to comply with, and the changes made over the lifespan of the package.

Second there's the configuration for a myriad of tools like Git, GitHub, EditorConfig, Composer, the preferred testing framework, the preferred continuous inspection / integration platform such like Scrutinizer or Travis CI, and so forth.

The final category includes tools which ease the life of maintainers and potential contributors equally. These tools can be helpful for releasing new versions, enforcing coding standard compliance, or commit message quality and consistency.

Consistency

Sourcecode

All sourcecode and accompanying tests or specs should follow a coding standard (PSR-2) and have a consistent formatting style, there's nothing new here. The perfect place to communicate such requirements is the CONTRIBUTING.md file.

Tools like PHP Coding Standards Fixer or PHP_CodeSniffer in combination with a present configuration .php_cs|ruleset.xml.dist and a command wrapping Composer script are an ideal match to ease compliance. The Composer script cs-fix shown next will be available for maintainers and contributors alike.

composer.json
{
    "__comment": "omitted other configuration",
    "scripts": {
        "cs-fix": "php-cs-fixer fix . -vv || true"
    }
}
Consistent formatting styles like line endings, indentation style, and file encoding can be configured via an EditorConfig configuration residing in .editorconfig which will be used when supported by the IDE or text editor of choice.

Artifact naming and casing

Like sourcecode formatting and naming, repository artifacts should also follow a predictable naming scheme. All documentation files should have a consistent extension like .md or .rst and the casing should be consistent throughout the package repository. Comparing
├── <package-name>
│   ├── changelog.md
│   ├── code_of_conduct.md
│   ├── ...
│   ├── .github
│   │   └── ...
│   ├── LICENSE
│   ├── Readme.md
│   ├── roadmap.rst
to
├── <package-name>
│   ├── CHANGELOG.md
│   ├── CODE_OF_CONDUCT.md
│   ├── ...
│   ├── .github
│   │   └── ...
│   ├── LICENSE.md
│   ├── README.md
│   ├── ROADMAP.md
I would favour the later one anytime for it's much easier reading flow and pattern matchableness. The easier reading flow is achieved by the upper casing of the *.md files which also clearly communicates their documentation character.

The configuration files for tools which except the .dist file extension per default should all have such one like shown next.
├── <package-name>
│   ├── build.xml.dist
│   ├── phpunit.xml.dist
│   ├── ruleset.xml.dist
│   ├── ...

Commit message format

Next to the package's changelog, incrementally growing in the CHANGELOG.md file, the Git commit messages are an important source of change communication. Therefor they should also follow a consistent format which improves the reading flow while also leaving a professional impression. This format can be documented once again in the CONTRIBUTING.md file or even better be provided via a .gitmessage file residing in the package's Git repository.

Once more a Composer script, named configure-commit-template here, can ease configuration and if configured Git will use it's content when committing without the -m|--message and -F|--file option.

composer.json
{
    "__comment": "omitted other configuration",
    "scripts": {
        "configure-commit-template": "git config --add commit.template .gitmessage"
    }
}
To enforce commit message formatting adherence to the rules described by Chris Beams on a Git hook level, the git-lint-validators utility by Billie Thompson can be helpful.

Versioning

Release versions should follow the semantic versioning specification aka SemVer, once again there's nothing new here. When using version numbers in the sourcecode or CLI binaries, these should be in sync with the set Git tag. Tools like RMT or self-written tools should be utilised for this mundane task.

The next shown code illustrates such a simple self-written tool named application-version. It's main purpose is to set the provided version number in the CLI application's binary and avoid an application version and Git tag mismatch.

bin/application-version
#!/usr/bin/env php
<?php
$binApplicationName = '<bin-application-name>';
$binFile = __DIR__ . DIRECTORY_SEPARATOR . $binApplicationName;
list($void, $binFileRelative) = explode($binApplicationName, $binFile, 2);
$shortBinFilePath = $binApplicationName . $binFileRelative;

$options = getopt('v:ch', ['version:', 'current', 'verify-tag-match', 'help', 'current-raw']);

$help = <<<HELP
This command sets the version number in the {$shortBinFilePath} file:
Usage:
  application-version [options]
Options:
  -c, --current, --current-raw   The current version number
  --verify-tag-match             Verify application version and Git tag match
  -v, --version                  The version number to set
  -h, --help                     Display this help message
HELP;

if (array_key_exists('h', $options) || array_key_exists('help', $options)) {
    echo $help;
    exit(0);
}

/**
 * Return the application version.
 *
 * @param  string $binFile File holding the application version.
 * @return string
 */
function get_application_version($binFile) {
    $matches = [];
    $match = preg_match(
        '/(\d+\.)?(\d+\.)?(\*|\d+)/',
        file_get_contents($binFile),
        $matches
    );
    return trim($matches[0]);
}
/**
 * Return latest tagged version.
 *
 * @return string
 */
function get_latest_tagged_version() {
    exec('git describe --tags --abbrev=0', $output);
    return trim($output[0]);
}

if (array_key_exists('verify-tag-match', $options)) {
    $applicationVersion = 'v' . get_application_version($binFile);
    $latestGitTag = get_latest_tagged_version();
    if ($applicationVersion === $latestGitTag) {
        echo "The application version and Git tag match on {$latestGitTag}." . PHP_EOL;
        exit(0);
    }
    echo "The application version {$applicationVersion} and Git tag {$latestGitTag} don't match." . PHP_EOL;
    exit(1);
}

if (array_key_exists('current-raw', $options)) {
    echo get_application_version($binFile) . PHP_EOL;
    exit(0);
}

if (array_key_exists('c', $options) || array_key_exists('current', $options)) {
    $applicationVersion = 'v' . get_application_version($binFile);
    $latestGitTag = get_latest_tagged_version();
    echo "Current version set in {$shortBinFilePath} is {$applicationVersion}." . PHP_EOL;
    echo "Current tagged version {$latestGitTag}." . PHP_EOL;
    exit(0);
}

if ($options === []) {
    echo 'No options set.' . PHP_EOL;
    exit(1);
}

$version = isset($options['version']) ? trim($options['version']) : trim($options['v']);
$fileContent = file_get_contents($binFile);
$fileContent = preg_replace(
    '/(.*define.*VERSION.*)/',
    "define('VERSION', '$version');",
    $fileContent
);
file_put_contents($binFile, $fileContent);
echo "Set version in {$shortBinFilePath} to {$version}." . PHP_EOL;
exit(0);
The application-version tool could further be utilised in Travis CI builds, to avoid the earlier mentioned version differences, like shown in the next .travis.yml diggest. On an application version and Git tag mismatch the shown build script will break the build early.

.travis.yml
language: php

# omitted other configuration

script:
  # Verify application version and Git tag match
  - php bin/application-version --verify-tag-match
  # omitted other scripts

Lean builds

To speed up continuous integration builds, resource and time consuming extensions like Xdebug should be disabled when not required for measuring code coverage. The next shown before_script, tailored for Travis CI, is generated by Construct per default and might shave off a few build seconds and thereby provide a faster feedback.

.travis.yml
language: php

# omitted other configuration

before_script:
  - phpenv config-rm xdebug.ini || true
  # omitted other before_scripts
To reduce email traffic, the email notifications send by Travis CI should be reduced to a minimum like shown next, or dependent on your workflow the could be disabled at all.

.travis.yml
language: php

# omitted other configuration

notifications:
  email:
    on_success: never
Something I really would love to be supported by Travis CI is a feature to ignore a set of definable artifacts which could be configured in a .buildignore file or the like. This way wording or spelling changes on non build relevant artifacts like the README.md wouldn't trigger a build and misspend resources and energy. There's a related GitHub issue and here's hope it will be revisited in the near future.

Lean releases

To keep releases (or dists in Composer lingo) of PHP projects or micro-packages as lean as possible, their repositories should contain a complete and valid .gitattributes file. With such a file present all export-ignored files will be excluded from release archives and thereby save a significant amount of bandwith and energy.

The next code shows the content of such a .gitattributes file excluding non release relevant files like internal tools, configuration, and documentation artifacts. If for some reasons you require the complete source of a PHP project or micro-package you can bypass the default by using Composer's --prefer-source option.

.gitattributes
* text=auto eol=lf

.editorconfig export-ignore
.gitattributes export-ignore
.github/ export-ignore
.gitignore export-ignore
.gitmessage export-ignore
.php_cs export-ignore
.travis.yml export-ignore
bin/application-version export-ignore
bin/release-version export-ignore
bin/start-watchman export-ignore
CHANGELOG.md export-ignore
LICENSE.md export-ignore
phpunit.xml.dist export-ignore
README.md export-ignore
tests/ export-ignore
To validate the .gitattributes file of a PHP project or micro-package on the repository, Git HEAD, or build level the LeanPackageValidator CLI can be helpful.

Avoid badge posing

Badges, if used sparsely, are a handy tool for visualising some of the repository properties. It's definitely nice to immediately see the required PHP version, the current build status, or the latest version of the package as they save you manual look ups.

Badges showing the amount of downloads, code coverage, or the chosen license are in my opinion kind of poserish, they cause unnecessary requests to the badge service, and are in case of the license even obsolete.

Why should you care about the dopeness of a PHP package repository?

Creating and maintaining a dope PHP package repository might have a positive impact on several levels. It can earn you some valuable Brownie points or even provide a conversation gambit when doing job interviews, simply because you showcase professionalism.

Furthermore it's more likely to get valuable and high quality contributions from your second target audience when supportive documentation and tooling for things like issue creation, coding standards compliance, or Git commit message consistency are available.

It also might convince an end user, your main target audience, in using your package over a competitive one.

Le fini

So these were my recent insights and learnings on the anatomy of a dope PHP package repository. If you'r lucky to be attending this year's ZendCon, I recommend to catch Matthew Weier O'Phinney's session about Creating PHPantastic packages. I definitely be waiting for the related slides.

Happy packaging.

Friday, 22 April 2011

Enforcing target descriptions within build files with a Git hook

When automating mundane tasks of a project or development environment with a build tool like Phing or Ant, the driving build file will naturally accumulate several targets and tasks over time. To ease the build file acceptance within a team and at a later stage also the contribution rate by team members, it's crucial that all build targets have a description attribute to provide at least a rough outline of the build features at hand. When these attributes are in place the (potential) build file user will get such an outline by executing the build tool's list command (phing -l or ant -p). To get a better picture of the problem at hand imagine a project poorly covered with tests and your personal attitude towards extending it or just take a peek at the screenshot below showing a very poorly documented build file.

A poorly documented build file in Phing's list view

To overcome this accumulation of some sort of technical debt (i.e. poorly documented targets) there are various options at hand. The first one, not covered in this blog post, would be to add a pursuant test which verifies the existence of a description for every target/task of the build file under test. As it's very uncommon, at least from what I've heard, to have your build files covered by tests; the next thinkable approach would be to use a Git pre-commit hook to guard your repository/ies against the creeping in of such poorly documented build files.

The next listing shows such a Git hook (also available via GitHub) scribbled away in PHP, which detects any build file(s) following a common build file naming schema (i.e. build.xml|build.xml.dist|personal-build.xml|…) , prior to the actual commit. For every target element in the detected build file(s) it's then verified that it has a description attribute and that it's actual content is long enough to carry some meaning. If one of those two requirements aren't met, the commit is rejected while revealing the build file smells to the committer, so she can fix it, as shown in the outro screenshot. Happy build file sniffing.

#!/usr/bin/php
<?php
define('DEPENDENT_EXTENSION', 'SimpleXML');

if (!extension_loaded(DEPENDENT_EXTENSION)) {
    $consoleMessage = sprintf(
        "Skipping build file checks as the '%s' extension isn't available.", 
        DEPENDENT_EXTENSION
    );
    echo $consoleMessage . PHP_EOL;
    exit(0);
}

define('MIN_TARGET_DESCRIPTION_LENGTH', 10);
define('TARGET_DESCRIPTION_ATTRIBUTE', 'description');
define('TARGET_NAME_ATTRIBUTE', 'name');
define('CHECK_DESCRIPTION_LENGTH', true);

$possibleBuildFileNames = array(
    'build.xml.dist',
    'build.xml-dist',
    'build-dist.xml',
    'build.xml',
    'personal-build.xml'
);

$violations = getAllBuildFileViolationsOfCommit($possibleBuildFileNames);
fireBackPossibleViolationsAndExitAccordingly($violations);

function getAllBuildFileViolationsOfCommit(array $possibleBuildFileNames)
{
    $filesOfCommit = array();
    $gitCommand = 'git diff --cached --name-only';
    
    exec($gitCommand, $filesOfCommit, $commandReturnCode);
    
    $allViolations = array();
    foreach ($filesOfCommit as $file) {
      if (in_array(basename($file), $possibleBuildFileNames)) {
          $violations = checkBuildFileForViolations($file);
          if (count($violations) > 0) {
            $allViolations[$file] = $violations;
          }
      }
    }

    return $allViolations;
}

/**
 *  @param  array $allViolations
 *  @return void
 */
function fireBackPossibleViolationsAndExitAccordingly(array $allViolations)
{
    if (count($allViolations) > 0) {
        foreach ($allViolations as $buildFile => $violations) {

            $buildFileConsoleMessageHeader = sprintf("Build file '%s':", $buildFile);
            echo $buildFileConsoleMessageHeader . PHP_EOL;

            foreach ($violations as $violationMessage) {
                $buildFileConsoleMessageLine = sprintf(" + %s", $violationMessage);
                echo $buildFileConsoleMessageLine . PHP_EOL;
            }
        }
        if (count($allViolations) > 1) {
            $rejectCommitConsoleMessage = sprintf(
                "Therefore rejecting the commit of build files [ %s ].", 
                implode(', ', array_keys($allViolations))
            );
        } else {
            $rejectCommitConsoleMessage = sprintf(
                "Therefore rejecting the commit of build file [ %s ].", 
                implode(', ', array_keys($allViolations))
            );
        }

        echo $rejectCommitConsoleMessage . PHP_EOL;
        exit(1);
    }
    exit(0);
}
/**
 *  @param  string $buildfile
 *  @return array
 */
function checkBuildFileForViolations($buildFile) {
    if (!file_exists($buildFile)) {
        return array();
    }

    $buildfileXml = file_get_contents($buildFile);
    $buildXml = new SimpleXMLElement($buildfileXml);
    $allBuildTargets = $buildXml->xpath("//target");
    $violations = array();

    if (count($allBuildTargets) > 0) {

        $targetsWithNoDescription = $targetsWithTooShortDescription = array();

        foreach ($allBuildTargets as $buildTarget) {

            $actualTragetAttributes = $buildTarget->attributes();
            $allUsedTragetAttributes = array();
            $actualTargetName = null;

            foreach ($actualTragetAttributes as $attribute => $value) {
                $allUsedTragetAttributes[] = $attribute;

                if ($attribute === TARGET_NAME_ATTRIBUTE) {
                    $actualTargetName = $value;
                }

                if (CHECK_DESCRIPTION_LENGTH === true && 
                    $attribute === TARGET_DESCRIPTION_ATTRIBUTE && 
                    strlen($value) < MIN_TARGET_DESCRIPTION_LENGTH) 
                {
                    $targetsWithTooShortDescription[] = $actualTargetName;
                }
            }   

            if (!in_array(TARGET_DESCRIPTION_ATTRIBUTE, $allUsedTragetAttributes)) {
                $targetsWithNoDescription[] = $actualTargetName;
            }
        }
        if (count($targetsWithNoDescription) > 0) {
            if (count($targetsWithNoDescription) > 1) {
                $violations[] = sprintf(
                    "Build targets [ %s ] don't have mandatory descriptions.", 
                    implode(', ', $targetsWithNoDescription)
                );
            } else {
                $violations[] = sprintf(
                    "Build target [ %s ] doesn't have a mandatory description.", 
                    implode(', ', $targetsWithNoDescription)
                );
            }
        }

        if (count($targetsWithTooShortDescription) > 0) {
            if (count($targetsWithTooShortDescription) > 1) {
                $violations[] = sprintf(
                    "Build targets [ %s ] don't have an adequate target description length.", 
                    implode(', ', $targetsWithTooShortDescription),
                    MIN_TARGET_DESCRIPTION_LENGTH
                );
            } else {
                $violations[] = sprintf(
                    "Build target [ %s ] doesn't have an adequate target description length.", 
                    implode(', ', $targetsWithTooShortDescription),
                    MIN_TARGET_DESCRIPTION_LENGTH
                );
            }
        }
    }
    return $violations;
}
Non-Descriptive Phing build files rejected by a Git hook

Saturday, 20 November 2010

Measuring & displaying Phing build times with buildhawk

Recently I installed a Ruby gem called buildhawk which allows to measure and display the build times of Rake driven builds. As I like the idea behind this tool a lot but mostly use Phing for build orchestration, it was time to explore the possibility to interconnect them both. In this blog post I'll show an implementation of an apposite Phing Logger gathering the buildhawk compatible build times via git note(s) and how to put the interplay between those two tools to work.

Logging on

As mentioned above the build time of each build is stored as a git note and associated to the repository's HEAD, reflecting the current state of the system under build (SUB), which assumes that the SUB is versioned via Git. The next shown Phing Logger (i.e. BuildhawkLogger) grabs the overall build time by hooking into the buildFinished method of the extended DefaultLogger class, transforms it into a buildhawk specific format and finally adds it as a git note.
<?php

require_once 'phing/listener/DefaultLogger.php';

/**
 *  Writes a build event to the console and store the build time as a git notes in the   
 *  project's repository HEAD.
 *
 *  @author    Raphael Stolt <raphael.stolt@gmail.com>
 *  @see       BuildEvent
 *  @link      https://github.com/xaviershay/buildhawk Buildhawk on GitHub
 *  @package   phing.listener
 */
class BuildhawkLogger extends DefaultLogger {
    
    /**
     *  @var string
     */
    private $_gitNotesCommandResponse = null;

    /**
     *  Behaves like the original DefaultLogger, plus adds the total build time 
     *  as a git note to current repository HEAD.
     *
     *  @param  BuildEvent $event
     *  @see    BuildEvent::getException()
     *  @see    DefaultLogger::buildFinished
     *  @link   http://www.kernel.org/pub/software/scm/git/docs/git-notes.html
     */
    public function buildFinished(BuildEvent $event) {
        parent::buildFinished($event);
        if ($this->_isProjectGitDriven($event)) {
            $error = $event->getException();
            if ($error === null) {
                $buildtimeForBuildhawk = $this->_formatBuildhawkTime(
                    Phing::currentTimeMillis() - $this->startTime
                );
                if (!$this->_addBuildTimeAsGitNote($buildtimeForBuildhawk)) {
                    $message = sprintf(
                        "Failed to add git note due to '%s'",
                        $this->_gitNotesCommandResponse
                    );
                    $this->printMessage($message, $this->err, Project::MSG_ERR);
                }
            }
        }
    }
    
    /**
     * Checks (rudimentary) if the project is Git driven
     *
     *  @param  BuildEvent $event
     *  @return boolean
     */
    private function _isProjectGitDriven(BuildEvent $event)
    {
        $project = $event->getProject();
        $projectRelativeGitDir = sprintf(
            '%s/.git', $project->getBasedir()->getPath()
        );
        return file_exists($projectRelativeGitDir) && is_dir($projectRelativeGitDir);
    }
    
    /**
     *  Formats a time micro integer to buildhawk readable format.
     *
     *  @param  integer The time stamp
     */
    private function _formatBuildhawkTime($micros) {
        return sprintf("%0.3f", $micros);
    }
    
    /**
     *  Adds the build time as a git note to the current repository HEAD
     *
     *  @param  string  $buildTime The build time of the build
     *  @return mixed   True on sucess otherwise the command failure response
     */
    private function _addBuildTimeAsGitNote($buildTime) {
        $gitNotesCommand = sprintf(
            "git notes --ref=buildtime add -f -m '%s' HEAD 2>&1",
            $buildTime
        );
        $gitNotesCommandResponse = exec($gitNotesCommand, $output, $return);
        if ($return !== 0) {
            $this->_gitNotesCommandResponse = $gitNotesCommandResponse;
            return false;
        }
        return true;
    }
}

Putting the Logger to work

As the buildhawk logger is available via GitHub you can easily grab it by issuing sudo curl -s http://gist.github.com/raw/707868/BuildhawkLogger.php -o $PHING_HOME/listener/BuildhawkLogger.php. The next step, making the build times loggable, is achieved by using the -logger command line argument of the Phing Cli and specifying the buildhawk logger name or the path to it. In case you want the buildhawk logger to be used per default (it behaves like the default logger if the SUB isn't Git driven/managed) you can also add it to the Phing shell script.

The next console command issued in the directory of the SUB shows a Phing call utilizing the BuildhawkLogger, assumed it has been installed at $PHING_HOME/listener/BuildhawkLogger.php and not been made the default logger.
phing -logger phing.listener.BuildhawkLogger

Looking at them Phing build times

Now it's time to switch to buildhawk and let it finally perform it's designated task, rendering an with the commit SHAs, commit messages, and build times fed Erb template into an informative, viewable HTML page. To install it you simply have to run sudo gem install buildhawk and you're good to go.

The next console command shows the buildhawk call issued in the SUB's directory to produce it's build time report page.
buildhawk --title 'Examplr' > examplr-build-times.html
The outro screenshot below gives you a peek at a rendered build time report.Buildhawk report for a Phing driven build

Thursday, 3 June 2010

Growling PHPUnit's test status

PHPUnit Growl TestListenerTwo years ago I blogged about a Xinc (R.I.P?) plugin that growls each build status for any via Xinc continuously integrated project. Since I'm using PHPUnit more and more lately, especially in continuous testing sessions (sprints without hitting the continuous integration server), my dependence on a fast and more visual feedback loop rose. In this post I'll provide an easy solution that meets these requirements by utilizing PHPUnit's test listener feature.

What's the motivation, yo?

While doing story or feature sprints embedded in a continuous testing approach I first used a combination of stakeout.rb and PHPUnit's --colors option to radiate the tests status, but soon wasn't that satisfied with the chosen route as it happened that the console window got superimposed with other opened windows (e.g. API Browser, TextMate etc.) especially on my 13,3" MacBook.

To overcome this misery I decided to utilize PHPUnit's ability to write custom test listeners and to implement one that radiates the test status in a more prominent and sticky spot via Growl.

Implementing the Growl test listener

Similar to the ticket listener plugin mechanism I blogged about earlier PHPUnit also provides one for test listeners. This extension mechanism allows to bend the test result formatting and output to the given needs and scenarios a developer might face and therefore is a perfect match.

To customize the test feedback and visualization the test listener has to implement the provided PHPUnit_Framework_Testlistener interface. A few keystrokes later I ended up with the next shown implementation, which is also available via a GitHub gist, supporting the previous stated requirements.
<?php

class PHPUnit_Extensions_TestListener_GrowlTestListener 
    implements PHPUnit_Framework_Testlistener
{
    const TEST_RESULT_COLOR_RED = 'red';
    const TEST_RESULT_COLOR_YELLOW = 'yellow';
    const TEST_RESULT_COLOR_GREEN = 'green';
    
    private $_errors = array();
    private $_failures = array();
    private $_incompletes = array();
    private $_skips = array();
    private $_tests = array();
    private $_suites = array();
    private $_endedSuites = 0;
    private $_assertionCount = 0;
    private $_startTime = 0;

    private $_successPicturePath = null;
    private $_incompletePicturePath = null;
    private $_failurePicturePath = null;

    /**
     * @param string $successPicturePath
     * @param string $incompletePicturePath
     * @param string $failurePicturePath
     */
    public function __construct($successPicturePath, $incompletePicturePath, 
        $failurePicturePath)
    {
        $this->_successPicturePath = $successPicturePath;
        $this->_incompletePicturePath = $incompletePicturePath;
        $this->_failurePicturePath = $failurePicturePath;
    }

    public function addError(PHPUnit_Framework_Test $test, Exception $e, $time)
    {
        $this->_errors[] = $test->getName();
    }
    
    public function addFailure(PHPUnit_Framework_Test $test, 
        PHPUnit_Framework_AssertionFailedError $e, $time) 
    {     
        $this->_failures[] = $test->getName();
    }
    
    public function addIncompleteTest(PHPUnit_Framework_Test $test, 
        Exception $e, $time)
    {
        $this->_incompletes[] = $test->getName();
    }
    
    public function addSkippedTest(PHPUnit_Framework_Test $test, 
        Exception $e, $time) 
    {
        $this->_skips[] = $test->getName();
    }
    
    public function startTest(PHPUnit_Framework_Test $test)
    {
    
    }
    
    public function endTest(PHPUnit_Framework_Test $test, $time) 
    { 
        $this->_tests[] = array('name' => $test->getName(), 
            'assertions' => $test->getNumAssertions()
        );
        $this->_assertionCount+= $test->getNumAssertions();
    }
    
    public function startTestSuite(PHPUnit_Framework_TestSuite $suite)
    {
        if (count($this->_suites) === 0) {
            PHP_Timer::start();
        }
        $this->_suites[] = $suite->getName();
    }
    
    public function endTestSuite(PHPUnit_Framework_TestSuite $suite)
    {
        $this->_endedSuites++;
        
        if (count($this->_suites) <= $this->_endedSuites)
        {
            $testTime = PHP_Timer::secondsToTimeString(
                PHP_Timer::stop());

            if ($this->_isGreenTestResult()) {
                $resultColor = self::TEST_RESULT_COLOR_GREEN;
            }
            if ($this->_isRedTestResult()) {
                $resultColor = self::TEST_RESULT_COLOR_RED;
            }
            if ($this->_isYellowTestResult()) {
                $resultColor = self::TEST_RESULT_COLOR_YELLOW;
            }

            $suiteCount = count($this->_suites);
            $testCount = count($this->_tests);
            $failureCount = count($this->_failures);
            $errorCount = count($this->_errors);
            $incompleteCount = count($this->_incompletes);
            $skipCount = count($this->_skips);

            $resultMessage = '';

            if ($suiteCount > 1) {
                $resultMessage.= "Suites: {$suiteCount}, ";
            }
            $resultMessage.= "Tests: {$testCount}, ";
            $resultMessage.= "Assertions: {$this->_assertionCount}";

            if ($failureCount > 0) {
                $resultMessage.= ", Failures: {$failureCount}";
            } 

            if ($errorCount > 0) {
                $resultMessage.= ", Errors: {$errorCount}";
            }

            if ($incompleteCount > 0) {
                $resultMessage.= ", Incompletes: {$incompleteCount}";
            }

            if ($skipCount > 0) {
                $resultMessage.= ", Skips: {$skipCount}";
            }
            $resultMessage.= " in {$testTime}.";
            $this->_growlnotify($resultColor, $resultMessage);
        }
    }

    /**
     * @param string $resultColor
     * @param string $message
     * @param string $sender The name of the application that sends the notification
     * @throws RuntimeException When growlnotify is not available
     */
    private function _growlnotify($resultColor, $message = null, $sender = 'PHPUnit')
    {
        if ($this->_isGrowlnotifyAvailable() === false) {
            throw new RuntimeException('The growlnotify tool is not available');
        }
        $notificationImage = $this->_getNotificationImageByResultColor(
            $resultColor);
        $command = "growlnotify -w -s -m '{$message}' "
                 . "-n '{$sender}' "
                 . "-p 2 --image {$notificationImage}";
        exec($command, $response, $return);
    }

    /**
     * @return boolean
     */
    private function _isGrowlnotifyAvailable()
    {
        exec('growlnotify -v', $reponse, $status);
        return ($status === 0);
    }

    /**
     * @param string $color 
     * @return string
     */
    private function _getNotificationImageByResultColor($color)
    {
        switch ($color) {
            case self::TEST_RESULT_COLOR_RED:
                return $this->_failurePicturePath;
                break;
            case self::TEST_RESULT_COLOR_GREEN:
                return $this->_successPicturePath;
                break;
            default:
                return $this->_incompletePicturePath;
        }
    }

    /**
     * @return boolean
     */
    private function _isGreenTestResult()
    {
        return count($this->_errors) === 0 && 
               count($this->_failures) === 0 &&
               count($this->_incompletes) === 0 &&
               count($this->_skips) === 0;
    }

    /**
     * @return boolean
     */
    private function _isRedTestResult()
    {
        return count($this->_errors) > 0 ||
               count($this->_failures) > 0;
    }

    /**
     * @return boolean
     */
    private function _isYellowTestResult()
    {
        return count($this->_errors) === 0 &&
               count($this->_failures) === 0 &&
               (count($this->_incompletes) > 0 ||
                count($this->_skips) > 0);
    }
}

Hooking the Growl test listener into the PHPUnit ecosystem

To make use of the just outlined test listener it's necessary to add an entry to PHPUnit's XML configuration file telling PHPUnit which test listener class to utilize and where it's located in the file system. In a next step the images for the three possible Growl notifications have to be added to the local file system, and as the Growl test listener constructor takes these as arguments they have also to be injected in the PHPUnit XML configuration file (i.e. phpunit-offline.xml). Take a peek yourself how this is done in the next listing.
<phpunit backupGlobals="false"
         backupStaticAttributes="true"
         bootstrap="bootstrap.php"
         colors="true"
         convertErrorsToExceptions="true"
         convertNoticesToExceptions="true"
         convertWarningsToExceptions="true"
         processIsolation="true"
         stopOnFailure="true"
         syntaxCheck="true"
         testSuiteLoaderClass="PHPUnit_Runner_StandardTestSuiteLoader">
  <testsuites> 
    <testsuite name="Zend_Service_GitHub Offline Testsuite">
      <directory>Zend/Service/GitHub</directory>
      <directory>Zend/Service</directory>
    </testsuite>
  </testsuites>
  <groups>
    <include>
      <group>offline</group>
    </include>
  </groups>
  <listeners>
    <listener class="PHPUnit_Extensions_TestListener_GrowlTestListener" 
              file="/Users/stolt/Work/GrowlTestListener.php">
     <arguments>
       <string>$HOME/Pictures/pass.png</string>
       <string>$HOME/Pictures/pending.png</string>
       <string>$HOME/Pictures/fail.png</string>
     </arguments>
    </listener>
  </listeners>
</phpunit>

Putting the Growl test listener to work

Attention shameless plug! As an example application for a continuous testing session I chose a Zend Framework Service component I'm currently working on. To set up the continuously testing workflow, stakeout.rb is still my #1 choice, but in a recent blog post Andy Stanberry shows another tool dubbed Kicker which seems to be coequal. The following console snippet shows in a concrete scenario how to utilize stakeout.rb to watch for any changes on the Zend_Service_GitHub component or it's backing tests which immediately trigger the test suite execution if one is detected.
stakeout.rb 'phpunit --configuration phpunit-offline.xml' **/*.{php} ../Zend/**/*.php ../Zend/Service/**/*.php
In the classic TDD cycle we start with a failing test. Creating the test, adding the assertions that the system under test (SUT) has to fulfill and saving the according test class automatically triggers the test suite execution which ends up in the next shown Growl notification.

Growl notice for failed tests

Nest a très important client call comes in and since we are clever, a quick TextMate shortcut marks the currently worked on test as incomplete. This step might be a bit controversy as it's also suggested to leave the last worked on test broken, but I got to show you the pending/incomplete Growl notification ;D

Growl notice for incomplete tests

After finishing the 'interruptive' client call aka context switch we can continue to work on the feature of the SUT until it fulfills the expected behavior which will be radiated via the next shown Growl notification. Happy Growl flavored testing!

Growl notice for successful tests

* As you might notice in the shown Growl notification images there's a test suite count of 9 while we are only operating on a single one, this seems to be a possible PHPUnit bug, or just a misconfiguration of my testing environment.

In case you got a solution for this problem feel free to add an illuminating comment.

Saturday, 15 May 2010

Installing the PHP redis extension on Mac OS X

Recently I took a look at Redis, a popular and advanced key-value store. Peeking at the supported languages section of the project's website you'll notice a lot of client libraries available for PHP. Two out of them caught my particular attention: Rediska due to it's impressive Zend Framework integration and phpredis as it's a native PHP extension written in C and therefore supposed to be blazingly faster than vanilla PHP client libraries. The following blog post will show how to install and configure the aforementioned, native PHP extension on a Mac OS X system.

The next steps assume that you've installed redis on your machine. In case you are using MacPorts and haven't installed the key-value store yet, all it takes are the following two commands and you're good to go. In case you prefer Homebrew for managing your package/software installations, there's also a Formula for redis available that allows you to install it via brew install redis.

sudo port install redis
sudo launchctl load -w /Library/LaunchDaemons/org.macports.redis.plist
The very first step for building the native PHP redis extension is to get the source code by cloning the GitHub repository of the extension without it's history revisions.
mkdir phpredis-build
cd phpredis-build
git clone --depth 1 git://github.com/nicolasff/phpredis.git
cd phpredis
The next task is to compile the extension with the following batch of commands.
phpize
./configure
make
sudo make install
The next to last step is to alternate your php.ini, use php --ini | grep 'Loaded' to get the location of it on your system, so that the redis module/extension is available to your PHP ecosystem. Therefor simply add extension=redis.so in the Dynamic Extensions section of your php.ini. Afterwards you can verify that the redis module is loaded and available via one of the following commands.
php -m | grep redis
php -i | grep 'Redis Support'
To make the extension also available to the running Apache PHP module you'll need to restart the Apache server. Looking at phpinfo()'s output in a browser you should see the entry shown in the next image.

Enabled redis extension

For testing the communication between the just installed redis extension and the running Redis server, I further created a simple test script called redis-glue-test.php you can fetch from GitHub and run via the next commands.
curl -s http://gist.github.com/raw/402018/redis-glue-test.php -o redis-glue-test.php
php redis-glue-test.php
When you see the following shown console output you're good to go. Happy Redising!

Output of redis-glue-test.php

Tuesday, 16 March 2010

Using MongoHq in Zend Framework based applications

MongoHq logoAs the name slightly foreshadows MongoHq is a currently bit pricey cloud-based hosting solution for MongoDb databases provided by CommonThread. Since they went live a few weeks ago I signed up for the small plan and started to successfully re-thinker with it in an exploratory Zend Framework based application.

Therefore the following post will show how to bootstrap such an instance into a Zend Framework based application and how to use it from there in some simple scenarios like storing data coming from a Zend_Form into a designated collection and vice versa fetching it from there.

Bootstrapping a MongoHq enabled connection

To establish and make the MongoDb connection application-wide available the almighty Zend_Application component came to the rescue again. After reading Matthew Weier O'Phinney's enlightening blog post about creating re-usable Zend_Application resource plugins and deciding to use MongoDb in some more exploratory projects, I figured it would be best to create such a plugin and ditch the also possible resource method approach.

The next code listing shows a possible implementation of the MongoDb resource plugin initializing a Mongo instance for the given APPLICATION_ENV (i.e. production) mode.

For the other application environment modes (development | testing | staging) it's currently assumed that no database authentication is enabled, which is also the default when using MongoDb, so you might need to adapt the plugin to your differing needs; and since I'm currently only rolling on the small plan the support for multiple databases is also not accounted for.

library/Recordshelf/Resource/MongoDb.php
<?php

class Recordshelf_Resource_MongoDb
extends Zend_Application_Resource_ResourceAbstract
{
/**
* Definable Mongo options.
*
* @var array
*/
protected $_options = array(
'hostname' => '127.0.0.1',
'port' => '27017',
'username' => null,
'password' => null,
'databasename' => null,
'connect' => true
);
/**
* Initalizes a Mongo instance.
*
* @return Mongo
* @throws Zend_Exception
*/
public function init()
{
$options = $this->getOptions();

if (null !== $options['username'] &&
null !== $options['password'] &&
null !== $options['databasename'] &&
'production' === APPLICATION_ENV) {
// Database Dns with MongoHq credentials
$mongoDns = sprintf('mongodb://%s:%s@%s:%s/%s',
$options['username'],
$options['password'],
$options['hostname'],
$options['port'],
$options['databasename']
);
} elseif ('production' !== APPLICATION_ENV) {
$mongoDns = sprintf('mongodb://%s:%s/%s',
$options['hostname'],
$options['port'],
$options['databasename']
);
} else {
$exceptionMessage = sprintf(
'Recource %s is not configured correctly',
__CLASS__
);
throw new Zend_Exception($exceptionMessage);
}
try {
return new Mongo($mongoDns, array('connect' => $options['connect']));
} catch (MongoConnectionException $e) {
throw new Zend_Exception($e->getMessage());
}
}
}
With the MongoDb resource plugin in the place to be, it's time to make it known to the boostrapping mechanism which is done by registering the resource plugin in the application.ini.

Further the MongoHq credentials, which are available in the MongoHq > My Database section, and the main database name are added to the configuration file which will be used to set the definable resource plugin ($_)options and to connect to the hosted database.

application/configs/application.ini

[production]
pluginPaths.Recordshelf_Resource = "Recordshelf/Resource"
resources.mongodb.username = __MONGOHQ_USERNAME__
resources.mongodb.password = __MONGOHQ_PASSWORD__
resources.mongodb.hostname = __MONGOHQ_HOSTNAME__
resources.mongodb.port = __MONGOHQ_PORT__
resources.mongodb.databasename = __MONGOHQ_DATABASENAME__

...

Cloudifying documents into collections

Having the MongoHq enabled connection in the bootstrapping mechanism it can now be picked up from there and used in any Zend Framework application context.

The example action method (i.e. proposeAction) assumes data (i.e. a tech talk proposal to revive the example domain from my last blog post) coming from a Zend_Form which will be stored in a collection named proposals, a table in old relational database think.

The next code listings states the action method innards to do so by injecting the valid form values into a model class which provides accessors and mutators for the domain model's properties and can transform them into a proposal document aka an array structure.

application/controllers/ProposalController.php
<?php

class ProposalController extends Zend_Controller_Action
{
public function indexAction()
{
$this->view->form = new Recordshelf_Form_Proposal();
}
public function thanksAction()
{
}
public function proposeAction()
{
$this->_helper->viewRenderer->setNoRender();
$form = new Recordshelf_Form_Proposal();

$request = $this->getRequest();

if ($this->getRequest()->isPost()) {
if ($form->isValid($request->getPost())) {
$model = new Recordshelf_Model_Proposal($form->getValues());
$mapper = new Recordshelf_Model_ProposalMapper();
if ($mapper->insert($model)) {
return $this->_helper->redirector('thanks');
}
$this->view->form = $form;
return $this->render('index');
} else {
$this->view->form = $form;
return $this->render('index');
}
}
}
}
Next the model/data mappper is initialized, which triggers the picking of the MongoHq enabled Mongo connection instance and the auto-determination of the collection name to use based on the mapper's class name. Subsequently the populated model instance is passed into the mappper's insert method which is pulling the document (array structure) and doing the actual insert into the proposals collection.

To give you an idea of the actual document structure it's shown in the next listing, followed by the model/data mapper implementation.
Array
(
[state] => new
[created] => MongoDate Object
(
[sec] => 1268774242
[usec] => 360831
)

[submitee] => Array
(
[title] => Mr
[firstname] => John
[familyname] => Doe
[email] => john.doe@gmail.com
[twitter] => johndoe
)

[title] => How to get a real name
[description] => Some descriptive text...
[topictags] => Array
(
[0] => John
[1] => Doe
[2] => Anonymous
)

)


application/models/ProposalMapper.php
<?php

class Recordshelf_Model_ProposalMapper
{
private $_mongo;
private $_collection;
private $_databaseName;
private $_collectionName;

public function __construct()
{
$frontController = Zend_Controller_Front::getInstance();
$this->_mongo = $frontController->getParam('bootstrap')
->getResource('mongoDb');
$config = $frontController->getParam('bootstrap')
->getResource('config');

$this->_databaseName = $config->resources->mongodb->get('databasename');

$replaceableClassNameparts = array(
'recordshelf_model_',
'mapper'
);
$this->_collectionName = str_replace($replaceableClassNameparts, '',
strtolower(__CLASS__) . 's');

$this->_collection = $this->_mongo->selectCollection(
$this->_databaseName,
$this->_collectionName
);
}
/**
* Inserts a proposal document/model into the proposals collection.
*
* @param Recordshelf_Model_Proposal $proposal The proposal document/model.
* @return MongoId
* @throws Zend_Exception
*/
public function insert(Recordshelf_Model_Proposal $proposal)
{
$proposalDocument = $proposal->getValues();
try {
if ($this->_collection->insert($proposalDocument, true)) {
return $proposalDocument['_id'];
}
} catch (MongoCursorException $mce) {
throw new Zend_Exception($mce->getMessage());
}
}
}

Querying and retrieving the cloudified data

As what comes in must come out, the next interaction with the Document Database Management System (DocDBMS) is about retrieving some afore-stored talk proposal documents from the collection so they can be rendered to the application's user. This isn't really MongoHq specific anymore, like most of the previous model parts, and is just here to round up this blog post and use some more of that MongoDb goodness. Looks like I have to look for an anonymous self-help group that stuff is highly addictive.

Anyway the next listing shows the action method fetching all stored documents available in the proposals collection. To save some CO2 on this blog post all documents are fetched, which ends up in the most trivial query but as you can figure the example domain provides a bunch of query examples like only proposals for a given topic tag, specific talk title or a given proposal state which can be easily created via passed-through Http request parameters.

application/controllers/ProposalController.php
<?php

class ProposalController extends Zend_Controller_Action
{
...

public function listAction()
{
$mapper = new Recordshelf_Model_ProposalMapper();
$proposals = $mapper->fetchAll();
// For iterating the Recordshelf_Model_Proposal's in the view
$this->view->proposals = $proposals;
}
}
The last code listing shows the above used fetchAll method of the data mapper class returning an array of stored proposal documents mapped to their domain model (i.e. Recordshelf_Model_Proposal) in the application.

application/models/ProposalMapper.php
<?php

class Recordshelf_Model_ProposalMapper
{
...

/**
* Fetches all stored talk proposals.
*
* @return array
*/
public function fetchAll()
{
$cursor = $this->_collection->find();
$proposals = array();

foreach ($cursor as $documents) {
$proposal = new Recordshelf_Model_Proposal();
foreach ($documents as $property => $value) {
if ('submitee' === $property) {
$proposal->submitee = new Recordshelf_Model_Submitee($value);
} else {
$proposal->$property = $value;
}
}
$proposals[] = $proposal;
}
return $proposals;
}
}

Friday, 5 February 2010

Utilizing Twitter lists with Zend_Service_Twitter

Twitter lists with the Zend FrameworkSeveral months ago Twitter added the list feature to it's public API. While debating some use cases for an event registration application I stumbled upon an interesting feature, which adds participants automatically to a Twitter list upon registration. This way registered and interested users can discover like-minded individuals and get in touch prior to any pre-social event activities. This post will show how this feature can be implemented by utilizing the Zend_Service_Twitter component, and how it then can be used in a Zend Framework based application.

Implementing the common list features

Looking at the three relevant parts of the Twitter list API some common features emerged and had to be supported to get the feature out of the door. These are namely the creation, deletion of new lists and the addition, removal of list members (i.e. event participants). Since the current Twitter component doesn't support these list operations out of the box it was time to put that develeoper hat on and get loose; which was actually a joy due to the elegance of the extended Zend_Service_Twitter component laying all the groundwork.

A non-feature-complete implementation is shown in the next code listing and can alternatively be pulled from GitHub. Currently it only supports the above stated common operations plus the ability to get the lists of a Twitter account and it's associated members; but feel free to fork it or even turn it into an official proposal.
<?php

require_once 'Zend/Service/Twitter.php';
require_once 'Zend/Service/Twitter/Exception.php';

class Recordshelf_Service_Twitter_List extends Zend_Service_Twitter
{
const LIST_MEMBER_LIMIT = 500;
const MAX_LIST_NAME_LENGTH = 25;
const MAX_LIST_DESCRIPTION_LENGTH = 100;

/**
* Initializes the service and adds the list to the method types
* of the parent service class.
*
* @param string $username The Twitter account name.
* @param string $password The Twitter account password.
* @see Zend_Service_Twitter::_methodTypes
*/
public function __construct($username = null, $password = null)
{
parent::__construct($username, $password);
$this->_methodTypes[] = 'list';
}
/**
* Creates a list associated to the current user.
*
* @param string $listname The listname to create.
* @param array $options The options to set whilst creating the list.
* Allows to set the list creation mode (public|private)
* and the list description.
* @return Zend_Rest_Client_Result
* @throws Zend_Service_Twitter_Exception
*/
public function create($listname, array $options = array())
{
$this->_init();

if ($this->_existsListAlready($listname)) {
$exceptionMessage = 'List with name %s exists already';
$exceptionMessage = sprintf($exceptionMessage, $listname);
throw new Zend_Service_Twitter_Exception($exceptionMessage);
}

$_options = array('name' => $this->_validListname($listname));
foreach ($options as $key => $value) {
switch (strtolower($key)) {
case 'mode':
$_options['mode'] = $this->_validMode($value);
break;
case 'description':
$_options['description'] = $this->_validDescription($value);
break;
default:
break;
}
}
$path = '/1/%s/lists.xml';
$path = sprintf($path, $this->getUsername());

$response = $this->_post($path, $_options);
return new Zend_Rest_Client_Result($response->getBody());
}
/**
* Deletes an owned list of the current user.
*
* @param string $listname The listname to delete.
* @return Zend_Rest_Client_Result
* @throws Zend_Service_Twitter_Exception
*/
public function delete($listname)
{
$this->_init();

if (!$this->_isListAssociatedWithUser($listname)) {
$exceptionMessage = 'List %s is not associate with user %s ';
$exceptionMessage = sprintf($exceptionMessage,
$listname,
$this->getUsername()
);
throw new Zend_Service_Twitter_Exception($exceptionMessage);
}
$_options['_method'] = 'DELETE';
$path = '/1/%s/lists/%s.xml';
$path = sprintf($path,
$this->getUsername(),
$this->_validListname($listname)
);
$response = $this->_post($path, $_options);
return new Zend_Rest_Client_Result($response->getBody());
}
/**
* Adds a member to a list of the current user.
*
* @param integer $userId The numeric user id of the member to add.
* @param string $listname The listname to add the member to.
* @return Zend_Rest_Client_Result
* @throws Zend_Service_Twitter_Exception
*/
public function addMember($userId, $listname)
{
$this->_init();

if (!$this->_isListAssociatedWithUser($listname)) {
$exceptionMessage = 'List %s is not associate with user %s ';
$exceptionMessage = sprintf($exceptionMessage,
$listname,
$this->getUsername()
);
throw new Zend_Service_Twitter_Exception($exceptionMessage);
}

$_options['id'] = $this->_validInteger($userId);
$path = '/1/%s/%s/members.xml';
$path = sprintf($path,
$this->getUsername(),
$this->_validListname($listname)
);

if ($this->_isListMemberLimitReached($listname)) {
$exceptionMessage = 'List can contain no more than %d members';
$exceptionMessage = sprintf($exceptionMessage,
self::LIST_MEMBER_LIMIT
);
throw new Zend_Service_Twitter_Exception($exceptionMessage);
}

$response = $this->_post($path, $_options);
return new Zend_Rest_Client_Result($response->getBody());
}
/**
* Removes a member from a list of the current user.
*
* @param integer $userId The numeric user id of the member to remove.
* @param string $listname The listname to remove the member from.
* @return Zend_Rest_Client_Result
* @throws Zend_Service_Twitter_Exception
*/
public function removeMember($userId, $listname)
{
$this->_init();

if (!$this->_isListAssociatedWithUser($listname)) {
$exceptionMessage = 'List %s is not associate with user %s ';
$exceptionMessage = sprintf($exceptionMessage,
$listname,
$this->getUsername()
);
throw new Zend_Service_Twitter_Exception($exceptionMessage);
}

$_options['_method'] = 'DELETE';
$_options['id'] = $this->_validInteger($userId);
$path = '/1/%s/%s/members.xml';
$path = sprintf($path,
$this->getUsername(),
$this->_validListname($listname)
);
$response = $this->_post($path, $_options);
return new Zend_Rest_Client_Result($response->getBody());
}
/**
* Fetches the list members of the current user.
*
* @param string $listname The listname to fetch members from.
* @return Zend_Rest_Client_Result
* @throws Zend_Service_Twitter_Exception
*/
public function getMembers($listname) {
$this->_init();
$path = '/1/%s/%s/members.xml';
$path = sprintf($path,
$this->getUsername(),
$this->_validListname($listname)
);
$response = $this->_get($path);
return new Zend_Rest_Client_Result($response->getBody());
}
/**
* Fetches the list of the current user or any given user.
*
* @param string $username The username of the list owner.
* @return Zend_Rest_Client_Result
*/
public function getLists($username = null)
{
$this->_init();
$path = '/1/%s/lists.xml';
if (is_null($username)) {
$path = sprintf($path, $this->getUsername());
} else {
$path = sprintf($path, $username);
}
$response = $this->_get($path);
return new Zend_Rest_Client_Result($response->getBody());
}
/**
* Checks if the list exists already to avoid number
* indexed recreations.
*
* @param string $listname The list name.
* @return boolean
* @throws Zend_Service_Twitter_Exception
*/
private function _existsListAlready($listname)
{
$_listname = $this->_validListname($listname);
$lists = $this->getLists();
$_lists = $lists->lists;
foreach ($_lists->list as $list) {
if ($list->name == $_listname) {
return true;
}
}
return false;
}
/**
* Checks if the list is associated with the current user.
*
* @param string $listname The list name.
* @return boolean
*/
private function _isListAssociatedWithUser($listname)
{
return $this->_existsListAlready($listname);
}
/**
* Checks if the list member limit is reached.
*
* @param string $listname The list name.
* @return boolean
*/
private function _isListMemberLimitReached($listname)
{
$members = $this->getMembers($listname);
return self::LIST_MEMBER_LIMIT < count($members->users->user);
}
/**
* Returns the list creation mode or returns the private mode when invalid.
* Valid values are private or public.
*
* @param string $creationMode The list creation mode.
* @return string
*/
private function _validMode($creationMode)
{
if (in_array($creationMode, array('private', 'public'))) {
return $creationMode;
}
return 'private';
}
/**
* Returns the list name or throws an Exception when invalid.
*
* @param string $listname The list name.
* @return string
* @throws Zend_Service_Twitter_Exception
*/
private function _validListname($listname)
{
$len = iconv_strlen(trim($listname), 'UTF-8');
if (0 == $len) {
$exceptionMessage = 'List name must contain at least one character';
throw new Zend_Service_Twitter_Exception($exceptionMessage);
} elseif (self::MAX_LIST_NAME_LENGTH < $len) {
$exceptionMessage = 'List name must contain no more than %d characters';
$exceptionMessage = sprintf($exceptionMessage,
self::MAX_LIST_NAME_LENGTH
);
throw new Zend_Service_Twitter_Exception($exceptionMessage);
}
return trim($listname);
}
/**
* Returns the list description or throws an Exception when invalid.
*
* @param string $description The list description.
* @return string
* @throws Zend_Service_Twitter_Exception
*/
private function _validDescription($description)
{
$len = iconv_strlen(trim($description), 'UTF-8');
if (0 == $len) {
return '';
} elseif (self::MAX_LIST_DESCRIPTION_LENGTH < $len) {
$exceptionMessage = 'List description must contain no more than %d characters';
$exceptionMessage = sprintf($exceptionMessage,
self::MAX_LIST_DESCRIPTION_LENGTH
);
throw new Zend_Service_Twitter_Exception($exceptionMessage);
}
return trim(strip_tags($description));
}
}

Adding the 'auto list' feature

For using the above implemented add member feature it's assumed that a participant has provided a valid and existing Twitter username, his approval of being added to the event list (i.e. zfweekend) and that he further has been registered effectively. To have the name of the Twitter list to act on and the account credentials available corresponding configuration entries are set as shown next.

application/configs/application.ini

[production]
twitter.username = __USERNAME__
twitter.password = __PASSWORD__
twitter.auto.listname = zfweekend
With the Twitter credentials and the list name available it's now possible to pull this feature into the register method of the register Action Controller, where it's applied as shown in the outro listing. As you will see, besides some bad practices due to demonstration purposes, the register Form makes use of a custom TwitterScreenName validator and filter which are also available via GitHub. Happy Twitter listing!
<?php

class RegisterController extends Zend_Controller_Action
{
/**
* @badpractice Push this into a specific Form class.
* @return Zend_Form
*/
private function _getForm()
{
$form = new Zend_Form();
$form->setAction('/register/register')
->setMethod('post');
$twitterScreenName = $form->createElement('text', 'twitter_screen_name',
array('label' => 'Twittername: ')
);
$twitterScreenName->addValidator(new Recordshelf_Validate_TwitterScreenName())
->setRequired(true)
->setAllowEmpty(false)
->addFilter(new Recordshelf_Filter_TwitterScreenName());

$autoListApproval = $form->createElement('checkbox', 'auto_list_approval',
array('label' => 'I approved to be added to the event Twitter list: ')
);

$form->addElement($twitterScreenName)
->addElement($autoListApproval)
->addElement('submit', 'register', array('label' => ' Register '));

return $form;
}
public function indexAction()
{
$this->view->form = $this->_getForm();
}
public function thanksAction()
{
}
/**
* @badpractice Handle possible Exception of
* Recordshelf_Service_Twitter_List::addMember.
* @return Recordshelf_Service_Twitter_List
*/
public function registerAction()
{
$this->_helper->viewRenderer->setNoRender();
$form = $this->_getForm();
$request = $this->getRequest();

if ($this->getRequest()->isPost()) {
if ($form->isValid($request->getPost())) {

$model = new Recordshelf_Model_Participant($form->getValues());
$model->save();

if ($form->getElement('auto_list_approval')->isChecked()) {
$twitterScreenName = $form->getValue('twitter_screen_name');
$twitter = $this->_getTwitterListService();
$response = $twitter->user->show($twitterScreenName);
$userId = (string) $response->id;
$response = $twitter->list->addMember($userId,
$this->_getTwitterListName());

$model->hasBeenAddedToTwitterList(true);
$model->update();

return $this->_helper->redirector('thanks');
}
} else {
return $this->_helper->redirector('index');
}
}
}
/**
* @badpractice Push this into a dedicated Helper or something similar.
* @return Recordshelf_Service_Twitter_List
*/
private function _getTwitterListService()
{
$config = Zend_Registry::get('config');
return new Recordshelf_Service_Twitter_List(
$config->twitter->get('username'),
$config->twitter->get('password')
);
}
/**
* @badpractice Push this into a dedicated Helper or something similar.
* @return string
*/
private function _getTwitterListName()
{
$config = Zend_Registry::get('config');
return $config->twitter->auto->get('listname');
}
}