Blog Posts

PHP talking to Magnolia CMS

Since we started working on Jackalope, we always claimed it would also provide an integration point with other enterprise systems. Last week, we set out to proof this idea. Grégory Joseph of the Java based Magnolia CMS came to help us on the Magnolia side of things. Magnolia is using the JCR reference implementation Jackrabbit for storing its content. After an interesting exchange on their design decisions and our ideas, we started to hack.

Trying to import an XML export from Magnolia in the JCR standard format showed that the Jackalope importer is not yet perfect. In the end Greg was faster in building a Magnolia module that exposes the Jackrabbit Davex binding that Jackalope uses to communicate with the server.

After that, things really started flowing. We managed to not only read data, but also write using the frontend editing feature of the CMF. Additionally, we managed to configure PHPCR-ODM to determine document classes based on the MetaData child every Magnolia page node has. And all of that using several parallel connections to the repositories, so that the normal pages and navigation can still be loaded from our standalone jackrabbit backend (you could just as well use another PHPCR implementation like jackalope-doctrine).

All in all a very successful hackday. We integrated the demos into the cmf-sandbox (branch magnolia_integration). See the MagnoliaController in src/Sandbox/MagnoliaBundle/Controller.

Screencapture showing the edit functionality

Installing the Sandbox with Magnolia

If you want to try things out for yourself, you need to:

  • Download Magnolia Community Edition
  • unpack it into a folder
  • Download the Magnolia Jackrabbit-Davex Module and drop all .jar files from this archive into apache-tomcat-6.0.32/webapps/magnoliaAuthor/WEB-INF/lib/
  • edit apache-tomcat-6.0.32/conf/server.xml to change the port from the default 8080 to 8888, so it looks something like <connector port="8888" protocol="HTTP/1.1">...
  • remove open file limitations. in bash, this will be ulimit -n 5000
  • launch magnolia with something like this: apache-tomcat-6.0.32/bin/magnolia_control.sh start tail -f apache-tomcat-6.0.32/logs/catalina.out
  • visit localhost:8888 and follow the Magnolia installation (login with superuser/superuser)

If you followed the above steps, you should be able to access Magnolia data with Jackalope-Jackrabbit at http://localhost:8888/magnoliaAuthor/.davex/ (Note that there is no web browser access, as the servlet does not issue a basic auth challenge but just expect the credentials to be set from the beginning).

Now try the urls /magnolia_edit, /magnolia/about/subsection-articles/article and /magnolia_odm and read the code to see what we do.

Related Entries:
- Progress on PHPCR with a hackday
- Doctrine PHPCR-ODM now handles versioning
- PHPCR workshop in zurich 8th of May 2011
- A (simple) PHPCR browser
- Touring North America

Comments [1]

Wsunit - the first release

It's always the same issue... you write unit tests for a web-service, giving you a feeling of confidence about the correctness of your code. Then the service provider changes the response without notifying you. Suddenly your implementation doesn't work anymore, but even your continuous integration server says it's "all green".

So what happened?

Surely the fixture files you used are outdated, and since they won't get updated automatically your tests don't stand a chance to detect the change.

Wsunit to the rescue

Implemented as a PHPUnit test listener, once configured wsunit will request a new response every time you run your test suite. It saves the response to your test's fixture file to verify the correct behavior of your implementation. Therefore you probably want to run this test suite as a separate job on your continuous integration server so you don't disturb the developers when doing their job.
Furthermore, wsunit keeps every 'old' responses (aka fixture files) enabling you to find the differences between the current response and an old response.

How does it work?

After configuring the test listener in the PHPUnit configuration file, the central point of interest is the wsunit configuration file. Here you define which test (identified by it's name) will send a request to which location together with what query parameters.
If you now run the test suite the test listener detects that there is a request to be sent and fetches the requested data from the defined location. After a conversion to XML the response will be stored in a fixture file named after the executed test.
The test now just uses the fixture file to verify the correct implementation of your code.
Since the header and response body are converted into a XML structure it is fairly easy to check it's content by using PHPUnit's 'assertXml*' assertion methods.

Further reading

A complete description how to install and set up wsunit is written down in Bastian's blog.

Related Entries:
- Liip @ linuxwochen.at
- A workaround for the missing git submodule support in Bamboo 3.1
- Jackalope - JCR for PHP started
- whereami extension: now privacy enabled
- the whereami firefox extension

Comments [0]

Monitoring Symfony Applications

For one of our projects we wanted to be able to check the system health once we go live. Our idea was to have a system that let us see the status of the services like MySQL or Memcached from the point of view of the application itself. Since our project was developed in PHP using the Symfony framework we decided to create a new Symfony bundle where we could implement this functionality. The bundle is called LiipMonitorBundle and can be obtained here.

So one goal of our bundle was to do something more advanced than a simple "Ping" to a server –for that we can just use Nagios. We wanted that the bundle could do something like: "Let's write a session object to Redis, updating the user membership status, read it back and see that the data stored in the server is correct". So we can know more than just a simple "ping redis". Also when we get a report that the system is misbehaving we want to be able to run the health checks from the point of view of the app itself. For example we can ping Redis and see that is up and running but that doesn't mean our application is using it properly.

A second goal was that the health checks themselves should be easy to write and easy to run. Also they should be totally independent from the bundle itself. So the bundle should provide functionality for auto discovering health checks, for running them and for giving reports, but it should not provide any health checks. That means that the health checks should be implemented by the applications themselves.

In regard to how to run the checks we added two possibilities: via web using a REST API and by using the command line. Also since each health check has an id that identifies it, we can run the checks all together or individually. If the checks succeed you get an "OK" message for each of them. If they fail you get a "KO" plus the message from the Exception that made the test fail.

Now, what are health checks, how do you implement them? Health checks are simply a PHP class that implements the CheckInterface from the MonitorBundle. This interface has two methods: CheckInterface::check and CheckInterface::getName. The method getName just returns the Health Check name while the method check implements the actual logic for the health check. The bundle documentation explains these in detail.

Once we implement a health check we have to add it as a service to our Service Container and tag it as monitor.check. By doing just that our health check is ready and available to be picked up by the bundle's Health Check Runner.

Running health checks from the command line is quite easy, just execute the following command and you will see the results right away:

$ ./app/console monitor:health
Jackrabbit Health Check: OK
Redis Health Check: OK
Memcache Health Check: KO - No configuration set for session.save_path
PHP Extensions Health Check: OK

As you can see there the bundle ran four Health Checks of which only the Memcache one failed.

Health Checks are not only for your applications, you could also ship them inside your bundles and let the bundle auto-discover them. This means that for example if you create your own bundle that say connects to a RabbitMQ, your bundle could provide the health checks required to see that your connection with RabbitMQ is working as expected.

As for the technology the bundle has a couple of peculiarities. Since the bundle provides also a REST API that allows us to run health checks and get the replies as JSON objects we built a web app for it that's implemented using Ember.js. Ember.js is an MVC javascript framework that deserves a post on it's own. You can learn more about it here. The views for the Ember.js app are rendered by Symfony with a template engine agnostic solution. By doing that the bundle doesn't require for you to use Twig or the PHP templating engine from Symfony. We had to provide that feature since we have apps that use Twig for frontend views and PHP for backen views.

All in all we've got an easy to use bundle that was quite well received by the Symfony community, counting 49 followers on Github and 2 forks. From one of those forks we already merged pull request. Apart from that it spawned a new bundle called LiipMonitorExtraBundle that serves as a repository of reusable Health Checks for and by the community. Is time that you fork it and contribute your own health checks.

P. S.: As a side note a week after the bundle was released a new Java web framework came to life with exactly the same concept. That framework was created by @coda an engineer that works at Yammer.

Comments [1]

Table Inheritance with Doctrine

Introduction

Lately we had several projects where we had to store in a database very different items that shared a common state.

As an example take the RocketLab website you are reading: Events and BlogPosts are aggregated in the LabLog list as if they were similar items. And indeed they all have a Title, a Date and a Description.

But if you get the detail page of an Event or a BlogPost you can see that they actually don't contain the same information: a BlogPost contains essentially formatted text when an Event contains more structured information such as the place where the event will take place, the type of event it is, if people need to register to attend, etc..

Still we have to access those entities sometimes as similar items (in the LabLog list) or as different items (in the events list and in the blog posts list).

Naïve database model

Our first idea, and it was not that bad, Drupal does just the same, was to have a database table with the common fields, a field containing the type of item (it's either an event or a blog post) and a data field where we serialized the corresponding PHP object. This approach was ok until we had to filter or search LabLog items based on fields that were contained in the serialized data.

Indeed SQL does not know anything about PHP serialized data, thus you cannot use any of it's features on that data.

So how do you get all the LabLog items that are Events, happen in April 2012 and are "techtalks"? The only way is to go through all the Events records of April, unserialize the data and check if it's a techtalk event. In SQL you would normally only do a single request to find those items.

A better database model

There is a better way to model this in a database, it's called table inheritance. It exists in two forms: single table inheritance and multiple table inheritance.

Multiple table inheritance

Multiple table inheritance requires to use three tables instead of a single one. The idea is to keep the common data in a "parent" table, which will reference items either in the Event table or in the BlogPost table. The type column (called the discriminator) helps to find out if the related item should be searched in the Event table or in the BlogPost table. This is called multiple table inheritance because it tries to model the same problem as object inheritance using multiple database tables.

Multiple table inheritance

When you have a LabLogItem you check the type field to know in which table to find the related item, then you look for that item with the ID equals to related_id.

Single table inheritance

Alternatively the same can be modelled in a single table. All the fields are present for all the types of LabLogItem but the one that do not pertain to this particular type of item are left empty. This is called single table inheritance.

Single table inheritance

Single or multiple table inheritance

The difference is really only in how the data is stored in the database. On the PHP side this will not change anything. One may notice that single table inheritance will promote performance because everything is in a single table and there is no need to use joins to get all the information. On the other hand, multiple table inheritance will allow a cleaner separation of the data and will not introduce "dead data fields", i.e. fields that will remain NULL most of the time.

Table inheritance with Symfony and Doctrine

Symfony and Doctrine make it extremely easy to use table inheritance. All you need to do is to model your entities as PHP classes and then create the correct database mapping. Doctrine will take care of the hassle of implementing the inheritance in the database server.

Please note that the code I present here is not exactly what we use in RocketLab; we are developers and as such we always have to make things harder. But the idea is there...

The parent entity

In the case of RocketLab we created a parent (abstract) entity, called LabLogItem, that contains the common properties.

/**
 * This class represents a LabLog item, either a BlogPost or an Event.
 * It is abstract because we never have a LabLog entity, it's either an event or a blog post.
 * @ORM\Entity
 * @ORM\Table(name="lablog")
 * @ORM\InheritanceType("SINGLE_TABLE")
 * @ORM\DiscriminatorColumn(name="type", type="string")
 * @ORM\DiscriminatorMap( {"event" = "Event", "blogpost" = "BlogPost"} )
 */
abstract class LabLogItem
{
    /**
     * @ORM\Id
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    protected $id;

    /**
     * @ORM\Column(type="date")
     */
    protected $date;

    /**
     * @ORM\Column(type="string")
     */
    protected $title;

    /**
     * @ORM\Column(type="text")
     */
    protected $description;

    /***** Getters and setters *****/

    public function getId()
    {
        return $this->id;
    }

    public function setDate($date)
    {
        $this->date = $date;
    }

    public function getDate()
    {
        return $this->date;
    }

    // And so on...
}

There are several things to note about the mapping:

  • @ORM\InheritanceType: indicates that this entity is used as parent class in the table inheritance. This example uses single table inheritance, but using multiple tables inheritance is as easy as setting the parameter to "JOINED". Doctrine will create an manage the unique or multiple database tables for you !
  • @ORM\DiscriminatorColumn: indicates which column will be used as discriminator (i.e. to store the type of item). You don't have to define this column in the entity, it will be automagically created by Doctrine.
  • @ORM\DiscriminatorMap: this is used to define the possible values of the discriminator column as well as associating a specific entity class with each type of item. Here the discriminator columns may contain the string "event" or "blogpost". When its value is "event" the class Event will be used, when its value is "blogpost", the class BlogPost will be used.

Basically that's the only thing you need to use table inheritance, but let's have a look at the children entities.

The children entities

We have two regular entities to model the events and blog posts. Those entities extend LabLogItem.

/**
 * Represent a blog post item.
 * Note that this class extends LabLogItem
 */
class LabLogItemBlog extends LabLogItem
{
    /**
     * @ORM\Column(type="text")
     */
    protected $content;

    /***** Getters and setters *****/

    public function getContent()
    {
        return $this->content;
    }

    public function setContent($content)
    {
        $this->content = $content;
    }
}

/**
 * Represent an event item.
 * Note that this class extends LabLogItem
 */
class LabLogItemEvent extends LabLogItem
{
    /**
     * @ORM\Column(type="string")
     */
    protected $eventType;

    /**
     * @ORM\Column(type="string")
     */
    protected $location;

    /**
     * @ORM\Column(type="boolean")
     */
    protected $requiresRegistration;

    /***** Getters and setters *****/

    public function getEventType()
    {
        return $this->eventType;
    }

    public function setEventType($type)
    {
        $this->eventType = $type;
    }

    // And so on...
}

There is not much special in the children entities. An important thing to note is that the common fields defined in the parent entity LabLogItem SHOULD NOT be repeated here. Also you may notice that there is no annotations in the children such as @ORM\Entity to indicate that they are entities. Indeed they will inherit the annotations of LabLogItem and become entities.

From now on, when you create a PHP object of type Event and ask the entity manager to persist it, Doctrine will automatically do the complex work for you. From the developper point of view, Events and BlogPosts are just entities like any other.

It's easy to do operations on items which you don't know exactly the type:

$item = $entityManager->getRepository('RocketLabBundle:LabLogItem')->findOneByDate($someDate);

// Here we don't know exactly whether $item contains a blog post or an event...

if ($item instanceof Event) {
    // Then it's an Event
    echo $item->getEventType();
} else {
    // Otherwise it's a BlogPost
    echo $item->getContent();
}

But, if you know the type of item you are using you still can use them as regular entities:

$item = $entityManager->getRepository('RocketLabBundle:Event')->findOneByDate($someDate);

//  We have searched the Event entity repository so what we get in $item MUST BE an Event
echo $item->getEventType();

Conclusion

As you can see above using table inheritance with Symfony and Doctrine is very easy. It's just a matter of creating the parent class and the correct mapping. Furthermore you can switch from single to multiple table inheritance by modifying one line of code.

This technique should be used whenever you need to store items with a common state but that are very different in their nature.


Comments [5]

Behat hackday

A few days ago, we held an open hackday on doing Behaviour Driven Development (BDD)  with Behat at Liip Fribourg. It was the opportunity for the handful of participants to get a first grip on Behat and explore some aspects of BDD through it.

Introduction

BDD is an interesting practice for agile web developers, it ideally allows the formalization of the acceptance criteria of a story through scenarios written in Gherkin, a domain specific language that is human writable and machine readable. An archetypal Gherkin feature with one scenario (from Behat documentation):

Feature: My feature
      As an explicit system actor
      I want to gain some beneficial outcome which furthers the goal
      So that I realize a named business value
      @some_tag @some_other_tag
      Scenario: Some determinable business situation
          Given some precondition
              And some other precondition
          When some action by the actor
              And some other action
              And yet another action
          Then some testable outcome is achieved
              And something else we can check happens too

Gherkin Scenarios are composed of steps, and subordinated to features. Scenarios can be tagged, to later specify which subset to test, or to put constraints on their testing. The actual semantic of steps is implemented in Contexts (PHP Classes).

Explorations

Sylvain and Thomas started integrating Behat in one of their existing Symfony2 project. They appreciated the effectiveness and the ease of use of Behat+Mink to test form interaction, and the BehatBundle for Symfony2 which made integrating Behat into Symfony2 a breeze. They outlined that the first steps to implement are the ones specifying the role of the user: "As an editor ...". At the moment this is not used during the test but it could be interesting to use this feature to authenticate the user before running the test.

Donato mainly tested the Mink SahiDriver against liip.ch's project bundle and, outside Symfony, on a simple FuelPHP based application. In both cases the Behat+Mink+Sahi combo proved to be very handy and ran smoothly. The tests with Sahi were run successfully with both Firefox and Chrome.

Adi played with remoting sahi in order to start tests inside a virtual machine but have them executed in the browser on a remote system for testing with different browsers on different OS.

Lukas aimed at testing Ajax requests. He outlined the limits of testing asynchronous process through Behat.

Timo and Benoît focused on the product owner interests in the system. They went through some existing sprint review protocols (made of demo scenarios) and analyzed how much of them could actually be transformed into Behat features and scenarios. They found out that currently Behat, through Mink and the various browser drivers, allows to test only for content presence, and some browsing and navigation interactions. Still a lot more standard steps might need to be provided to test for the presence of document header elements, element attributes, element styling, element metrics, document validation, mouse hovering, scrolling, element visibility ...

Conclusions, open questions

BBD and Behat are certainly the easiest way to introduce test-driven development on a project.

One of true value of BDD lies in the ecosystem of scenarios steps provided out-of-the-box: only with a broad spectrum of steps covering most aspects of browsing will Behat be effectively usable for a PO.

BDD empowers the product owner, but he still has to closely collaborate with developers when writing acceptance criteria: the syntax has to be respected, the PO may not know how to test for an element presence, etc.

It is yet undecided how BDD concepts match agile concepts. A user story will be covered by many scenarios, but should a Gherkin feature represent only one user story? or should we rather use tags and match features to epic stories or even code parts (Symfony2 bundles)?

Despite the shortcomings or difficulties in the Javascript and XHR area we're planning to get started with BDD in an existing project as of mid-December. It will be a slow start and we will grant ourselves some learning time. Depending on how practical the approach will prove in our context, we will not limit ourselves to new user stories, but also describe a number of existing features and get them tested this way.


Comments [1]

How to manage cache permissions in Symfony2

I guess in the Symfony2 world, we all know the following problem: We have a running Symfony2 installation we already accessed in the browser. Now we want to use the Symfony2 console to run a command and *BANG* we get an exception. The problem is, that the cache directory is not writable by the command line user. Now the usual reaction is "let's just set the access rights to 777", which solves the problem for the moment. But of course it will return, once the command line user wants to access another file/directory that was created by the webserver.

Luckily there is a more sustainable solution, which is described here.

I hope this will save you some time - it for sure saved me some :)

Related Entries:
- Touring North America
- Doctrine PHPCR-ODM now handles versioning
- Paris PHPCR meetup
- Multilanguage support for Doctrine PHPCR-ODM
- How to manage patching a github hosted repo

Comments [15]

Packaging solution for (php-)projects

At Liip we have been relying on Debian and RedHat packages to deploy our web applications for some time now. For this we created or in some cases adapted some project/framework-specific solutions:

The existing solutions work very well but they're also very specific. Since we don't really want to reinvent the wheel for every new project/framework we decided to start looking for a more generic solution. The prerequisite was that it had to support Debian and Red Hat packages.

With fpm, by Jordan Sissel, we found a solution that can create either Debian or Red Hat packages. Basically, fpm just takes all the files inside a directory and creates a package from that.

Since the layout of the application in a development environment is usually different from the production system (on a dev environment you might have the project files in your home directory whereas in the production environment they should end up in /var/www/projectname) there was still some work involved beside running fpm. But we wanted something that was automated as much as possible: developers should only need to setup packaging once and after that they would only have to execute a simple command to build packages.

For this reason we decided to build a set of wrapper scripts around fpm. With these scripts, you just need to create a configuration file that defines the layout on the target system and some package parameters. Once you created the configuration file, a simple command creates a Makefile for you. After that, building your package is as easy as typing "make" in your project directory. What is really nice with this packaging solution it's that it's a) easy to use and b) platform-independent (for Debian packages at least) since you can use fpm on any platform you want.

We made the scripts available on github so other people can benefit from them. To help people get into it and demonstrate some of the possibilities of this solution, we created a demo project that you can try to package and deploy. If you just want to build Debian packages you can just install fpm and you should be good to go (it even works on OS X, so you don't need a Debian machine to create packages). For rpm packages you unfortunately still need rpmbuild.

Related Entries:
- How to manage patching a github hosted repo
- Integrating Magento into Symfony2
- First release of proxy-object
- Buzzing in Berlin
- Using the Symfony2 console

Comments [8]

PHP 5.4.0RC1 for OS X on php-osx.liip.ch

Just a quick note. I uploaded the first build of the Release Candidate 1 of PHP 5.4.0 to php-osx.liip.ch. It works on Mac OS X 10.6 and 10.7. See the beta instructions for some info about how to install this. And keep on testing (and filing issues, if you find any)

Tags:
Related Entries:
- Easily test PHP 5.4 beta on OS X with php-osx
- Automatically switching between different Time Machine disks
- Leopard native apache with custom (64bit) PHP
- Slick (on OS X) vs. Ugly (on XP) Software
- Universal Binary PHP

Comments [5]

Integrating Magento into Symfony2

So last week four developers sat together on a regular Hackday to see what’s needed to hook up Magento into Symfony. To make this short the outcome is a Magento bundle for Symfony2.

When we met in the morning we weren’t even sure what exactly to try out but soon agreed on implementing a Symfony authentication which uses the Magento customer database.

Autoloader and Login

Starting our hacking, the first problem appeared quickly with incompatible class loaders: Unfortunately, the Magento class loader isn’t designed for missing class files, so every time Symfony tries to check for existence of a class which doesn’t exist there will be a fatal error. As we knew this problem from other projects a patch was already at hand.

Everything went quite smoothly then as integrating Magento into another project is basically just including the file app/Mage.php and then calling Mage::app(). Done.

Implementing the authentication itself seemed easy too as there is this simple method call in Magento which we added to our bundle within minutes to try out the login:

Mage::getSingleton('customer/session')->login($username, $password);

Session issues

But that’s where things got ugly: The sessions didn’t sync between Magento and Symfony. Integrating Magento without synced sessions doesn’t really make any sense as the user will loose his cart as soon as he browses from Magento to Symfony.

After a few hours of debugging and digging into both session handlers of Magento and Symfony the problem was found inside the __destruct methods. So here’s what happens:

  1. Symfony stores everything you save in the session inside Symfony\Component\HttpFoundation\Session.

  2. During destruction phase inside Magento Mage_Core_Model_Resource_Session::__destruct() calls session_write_close() and saves the session content to the database (depending on your Magento configuration).

  3. Next Symfony\Component\HttpFoundation\Session::__destruct() writes its own session information with the key _symfony2 into the session storage - too late!

The solution was to add a kernel.response event listener and call Symfony\Component\HttpFoundation\Session::save() so the Symfony session content gets into the session storage before Magento saves it to the database.

Promising results

After solving the ugly session problem the only thing left was implementing an security provider for Symfony which authenticates the user against the Magento customer database. The result is a proof-of-concept Symfony Bundle which already provides some useful features to Symfony:

  • Login for Magento customers.
  • Synchronized session to access the shopping cart.
  • Full access to Magento products inside Symfony.

Integrating Magento into Symfony has been proven certainly possible and we had fun learning more about the session handlers of both Magento and Symfony.

At the moment there’s no specific project at Liip planning to use this bundle, so contribution is welcome on GitHub: https://github.com/liip/LiipMagentoBundle

Related Entries:
- How to manage patching a github hosted repo
- Touring North America
- Doctrine PHPCR-ODM now handles versioning
- Paris PHPCR meetup
- Multilanguage support for Doctrine PHPCR-ODM

Comments [7]

Easily test PHP 5.4 beta on OS X with php-osx

As you may have read on this blog, we (together with local.ch) provide a ready-to-run compiled PHP package for OS X (Snow Leopard and Lion) for easy installation over at php-osx.liip.ch.

And since the first beta of the next major release of PHP was released a few days ago, I thought we could provide packages for people who'd like to test it and see if there software is still running. And make adjustments or report bugs, if it doesn't. Complaining after the official stable release was made is usually too late, so testing your software against beta releases is very important. For your project and for PHP.

Installation is almost as easy as for the stable release. If you didn't install php-osx before, either install the stable release with

curl -s http://php-osx.liip.ch/install.sh | bash -

or just the installer with

curl -s -o /tmp/packager.tgz http://php-osx.liip.ch/packager/packager.tgz
sudo tar -C /usr/local  -xzf /tmp/packager.tgz

then just do

sudo /usr/local/packager/packager.py install beta-frontenddev 
and you're ready to go.

If you want to revert back to the stable PHP, just change the symlink of /usr/local/php5 to some older installation

We will regularly update that package so that it contains the latest bug fixes of the PHP 5.4 branch

Bugs about PHP should be reported at bugs.php.net, bugs or feature request about the php-osx package can be filed at github.com/liip/php-osx/issues. Pull Requests are of course also welcome.

Happy Testing!

Tags:
Related Entries:
- PHP 5.4.0RC1 for OS X on php-osx.liip.ch
- Automatically switching between different Time Machine disks
- Leopard native apache with custom (64bit) PHP
- Slick (on OS X) vs. Ugly (on XP) Software
- Universal Binary PHP

Comments [0]

Next1-10/250