lexa-tools framework for PHP 5.3

NOTE. This project is no longer maintained.

Table of Contents

Requirements and Installation

You need Apache web server with enabled mod_rewrite and installed PHP 5.3. To check your PHP configuration, please run the requirements.php script and ensure that all conditions are met. Otherwise, tweak settings in the php.ini file.

Download the library or check it out from the Subversion repository:

svn export http://lexa-tools.googlecode.com/svn/trunk /lexa-tools-path

Say Hello to the World

Create a folder named lexa-tools-sample under the Apache web root directory and place two files there:

.htaccess

DirectoryIndex index.php

RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . index.php [L]

index.php

<?
    # Include the library
    require "/lexa-tools-path/lib/all.php";

    # Register a route
    lexa_route("/", function() {
        print "Hello world!";
    });

    # Process current request
    lexa_dispatch();

The first file consists of a pretty standard rewrite ruleset. The second is an entry script, it contains the simpliest possible web application code.

Point your browser at http://localhost/lexa-tools-sample. Greetings to the World should appear.

Now try navigating to an unknown location, for example to http://localhost/lexa-tools-sample/foo.

If you see a standard web server 404 error page, then check if the AllowOverride All option is enabled for your web directory (without it .htaccess has no effect). If you see a single line «Not Found», then everything is ok, but you’d better turn on the display_errors option by adding the following line:

ini_set("display_errors", 1)

at the beginning of index.php.

Rationale

lexa-tools library is not as much a framework as a set of utililies (tools). You are free to organize the site as you’d like. No special folder structure is required. No need to declare any classes. No config files and no magic conventions. The library just arms you with a handy procedural API.

Most ideas were borrowed from Ruby on Rails when I was porting an application from Rails to PHP. If you have had experience with Rails then you would notice a lot of familiar concepts and names.

I wanted that simple things would be simple to implement. In the previous section I gave an example of the most basic application — it took only three lines of PHP code.

Is it MVC?

Yes and No.

The library provides all three components of MVC, but doesn’t force you to use them at all. For trivial cases, simple routing is enough (like in the Sinatra micro-framework). If you need more sophisticated views, then you use View features like layouts, partials, named contents and HTML helpers. If you need models, then the bundled ORM and the Model class are at your service. Finally, you can combine several route handlers into a Controller. M, V and C can be used in any combination, and it’s a great level of flexibility!

Performance

Author of any framework will assure you that his creature is fast and lightweight. Can you believe him?

I decided to repeat a benchmark used by the Yii framework team. They measured the amount of minimal overhead that frameworks bring. This page explains the approach in detail.

I used the same technique, and below are the results:

Chart shows performance indicators of various PHP frameworks

As you can clearly see, the speed of lexa-tools matches its simplicity.

Routing and Dispatcher

With lexa-tools, all requests that don’t hit a physical file or a directory are processed against registered routes. That’s why you need mod_rewrite and the .htaccess file.

A route is a mechanism that associates an incoming URL with a piece of code to generate a response.

lexa_route

Routes are registered via the lexa_route function. This function accepts from 2 to 5 arguments:

lexa_route($pattern, $handler)
lexa_route($name, $pattern, $handler)    
lexa_route($name, $pattern, $defaults, $handler)
lexa_route($name, $pattern, $defaults, $constraints, $handler)

The most important argument is URL $pattern. The pattern consists of segments joined by separators. Two characters can act as separators: «/» (slash) and «.» (dot).

Route segments whose first symbol is «:» (colon) are dynamic segments, others are static segments.

Dynamic segments can have default values ($defaults) and regular expression constraints ($constraints). Dynamic segments on the right side of an URL are optional if they have default values.

Routes can be named ($name) or anonymous ($name = null or empty).

The route handler ($handler) can be either a callback taking a single argument (an array of dynamic route segment values) or a Controller class name (Controllers are described below).

Routes are case-sensitive.

Examples:

# simple case
lexa_route("hello", function() {
    print "Hello!";
});

# fancy case
lexa_route("default", ":controller/:action/:id",
    array(
        "controller" => "home",
        "action" => "index",
        "id" => -1
    ),
    array(
        "controller" => "(home|about|shop)",
        "action" => "\\w+",
        "id" => "\\d+"
    ),
    function($values) {
        print_r($values);
    }
);

lexa_dispatch

This function finds a route for the current request and executes its handler. Call it when all routes are registered and all initializations are performed.

lexa_route_url

For a route name and optional array of dynamic route segment values, returns an URL:

lexa_route_url("static-route-1");

lexa_route_url("default", array(
    "controller" => "shop",
    "action" => "sell",
    "id" => 42
));

Returned URL is rooted. That is, it is prefixed with the path from the web server root to the entry script (index.php) location, and therefore can be safely used anywhere within the site.

lexa_root_url

Returns a part of the URL between the web server root and the entry script (index.php).

For example, if the site is located inside the folder site1 under the web-server root, then lexa_root_url() will return /site1/. Useful when setting cookies, and just a simple way to get the site root URL.

lexa_resolve_url

For a given relative URL, returns its rooted version. Technically speaking, if the argument doesn’t start with «/» and doesn’t contain «:», then it’s prefixed with the result of the lexa_root_url call.

This function is especially useful when generating links to static files.

lexa_redirect

Sends HTTP 301 / 302 redirect headers:

lexa_redirect($url, $permanent = false);

lexa_expires

By default, all output generated by lexa-tools is marked non-cacheable. The lexa_expires function allows to specify for how long the content can be cached on the client side. It emits Expires and Cache-Control HTTP headers:

# Let's cache for 5 min
lexa_expires(5 * 60);

# No, I changed my mind
lexa_expires(0);

lexa_send_file

Use this function to send a file:

lexa_send_file($location, $type = null, $attachment = null);
  • $location is the file path
  • $type is a desired MIME type, defauls to «application/octet-stream»
  • $attachment is either a boolean true or a string containing the desired attachment name

Please note that it’s always better to serve static files bypassing PHP. For controlled downloads of large files, consider using server-specific features like X-Accel-Redirect or X-Sendfile.

lexa_accept_verbs

Specifies which HTTP verbs are valid for the current request:

# only POST requests are allowed
lexa_accept_verbs("post");

# POST, PUT and DELETE are allowed
lexa_accept_verbs("post", "put", "delete");

It’s known that requests performing any side effects (like data modifications) should be non-GET. Use this function to enforce this recommendation for your sites. An exception will be thrown if the content of the REQUEST_METHOD server variable is not present among strings passed to lexa_accept_verbs:

lexa_route("save", function() {
    lexa_accept_verbs("post");

    # perform save
    # ...
 });     

lexa_http_error

This helper function throws a special type of exception which will result in setting a specified HTTP response status code:

if($maintenance)
    lexa_http_error(503);

if(!$article)
    lexa_http_error(404);

See also Error handling.

Views

PHP is a template engine by design. It’s one of the PHP strengths. That’s also one of the reasons why lexa-tools wants the short_open_tag option to be enabled, although grumpy nerds say it makes code less portable.

In lexa-tools, views are plain PHP files, but several functions add some power to them. These functions are described directly below.

lexa_view_path

Specifies the root directory for all view files. It’s a good idea to store views in a directory inaccessible from the Web. For example:

lexa_view_path("../views");

lexa_render

Returns output generated by a view. It accepts a view file name and optional hash of values that will be visible inside a view as local variables:

lexa_render("home.php");

lexa_render("messages/form", array(
    "name" => "Alex"        
));

$var1 = "a";
lexa_render("view1", compact("var1"));

View files are searched in the specified view path. The .php extension is not necessary.

Note, how the standard compact function is useful for transferring local variables from a route handler to a view.

Note also, that lexa_render doesn’t perform any output. It returns a string, and you should echo / print it.

lexa_layout

Specifies a layout, or a master view, for the current view. Call this function anywhere within a view to denote that you want it to be decorated with a layout (though I recommend to call it at the very first line). The contents of the current view will be captured and available in the layout via lexa_yield.

Example:

layout1.php

<h1>My site</h1>
<hr />
<?= lexa_yield() ?>
<hr />
Copyright (c) 2011 

view1.php

<? lexa_layout("layout1") ?>
Some contents here

Layouts can be nested: you may call lexa_layout from a layout.

All local variables passed to a view are also visible to all applied layouts.

lexa_begin_content and lexa_end_content

Within a view, any contents between lexa_begin_content and lexa_end_content calls will be cut and captured for later use in a layout via lexa_yield($name):

layout1.php

<h1>Hello!</h1>    
<?= lexa_yield() ?>
<hr />
<?= lexa_yield("footer") ?>

view1.php

<? lexa_layout("layout1") ?>

This will be available via lexa_yield()

<? lexa_begin_content("footer") ?>
This will be available via lexa_yield("footer")
<? lexa_end_content()  ?>

This will also be available via lexa_yield()

Named contents allow to customize certain parts of a layout such as footers, sidebars, etc.

lexa_yield

Returns the value of a named content or the last rendered view. The function should be used in layouts only.

# Returns the contents of the last rendered view
lexa_yield();

# Returns the named content
lexa_yield("sidebar");

The function doesn’t perform any output. It returns a string to be printed.

lexa_partial

Allows to break a view into manageable parts — partial views. Consider the following code within a view:

<? foreach($products as $p): ?>
    <?= lexa_partial("product", compact("p")) ?>
<? endforeach ?>

Note that a partial view uses its own set of local variables, and they must be passed explicitly if needed.

HTML helpers

There are a number of functions for programmatic generation of HTML markup.

lexa_tag, lexa_begin_tag and lexa_end_tag

# Renders a short tag
lexa_tag("br");

lexa_tag("a", array(
    "href" => "http://blog.amartynov.ru"
));

# Renders an opening tag
lexa_begin_tag("div", array(
    "id" => "div1",
    "style" => array(
        "background" => "lime"
    )
));

# Renders a closing tag for the last opened one
lexa_end_tag();

A hash of attributes can be optionally passed as a second argument. Attributes whose values are strictly equal to null don’t participate in the output. The style attribute can be specified as a hash.

Form HTML helpers

The following functions generate HTML markup for various form fields:

lexa_text_field_tag($name, $value, $attrs);

lexa_label_tag($input_name, $text, $attrs);

lexa_checkbox_tag($name, bool $checked, $attrs);

lexa_text_area_tag($name, $value, $attrs);

lexa_select_tag($name, array $options, $value, $attrs);    

lexa_hidden_field_tag($name, $value);

lexa_file_field_tag($name, $attrs);

lexa_submit_tag($text, $attrs);

# obj.person[name] -> obj_person_name
lexa_sanitize_to_id($name);

# Renders hidden input with CSRF validation token
lexa_csrf_tag();

lexa_link_to and lexa_button_to

# Renders a text anchor
lexa_link_to($text, $url, $attrs);

# Renders a separate form (POST by default) with a sumbit button
lexa_button_to($text, $url, $attrs);

Data Models

Previously, lexa-tools used the RedBean ORM library as a data layer. But later I decided to extract the most beautiful ideas of RedBean into a separate project named orange-bean. Its a very simple yet powerful and very fast ORM.

lexa-tools introduces the Lexa\Tools\Model class which provides validation and cascade deleting capabilities to your models.

Here is an example of how model classes may look like.

Attachment plugin

Uploading files and images is a very common task. That’s why I created a special plugin for the Model to simpify work with attachments. It was inspired by paperclip of Ruby on Rails and implemented using orange-bean observers.

The following line of code turns the avatar property of the person model into a sophisticated attachment object:

lexa_attachment("person", "avatar", "uploads/person/avatars");

Once it’s done, the avatar property becomes an instance of the Lexa\Tools\Attachment class:

class Attachment {

    # returns true if something has been uploaded
    function has_file();

    # returns rooted URL by style name
    function url($style = "");

    # returns filesystem path by style name
    function path($style = "");

    # schedules saving of a newly uploaded file 
    # Example: $person->avatar->schedule_upload($_FILES["file"]);
    function schedule_upload($file_data);

    # schedules deletion of uploaded files on next save
    function schedule_delete();

}

There is a concept of attachment processors. They allow attachments to have different styles. One or more processors can be passed as the fourth argument to the lexa_attachment function.

The library comes with two of them. The DefaultAttachmentProcessor is used when no processor is passed explicitly, its style name is the empty string.

The ImageAttachmentProcessor is used for images, and there is a convenient factory method to create this kind of processors:

lexa_image_attachment_processor(
    $style, 
    $width, $height, 
    $crop = true, 
    $type = null
);

Consider the following example:

lexa_attachment("person", "avatar", "uploads/person/avatars", array(
    lexa_image_attachment_processor("icon", 64, 64),
    lexa_image_attachment_processor("full", 1024, 768, false, "jpg")
));

It says: the avatar property of the person bean is an attachment; for an uploaded file, two images will be saved:

  • cropped icon 64×64 to uploads/person/avatars/icon directory
  • large image not exceeding 1024×768 in JPEG format to uploads/person/avatars/full directory

Also, you can create your own attachment processor as a subclass of the base AttachmentProcessor. This is a point of extensibility. For example, with custom processors you can store files in the Amazon Cloud, or even upload images to TwitPic…

Controllers

As been said, lexa-tools doesn’t require following the MVC paradigm. But in many cases, it’s convenient to group several route handlers into a Controller. For this purpose, the Lexa\Tools\Controller class exists.

Here is a controller with two actions:

class MyController extends \Lexa\Tools\Controller {

    protected function action_hello() {
        print "Hello!";
    }

    protected function action_goodbye() {
        print lexa_render("goodbye");
    }

}

Action methods are not required to be public but must be prefixed with action_.

Action name is taken from the action dynamic route segment value:

# action name comes from URL
lexa_route("shop", "shop/:action", "ShopController");

# action name is defined in the default values hash
lexa_route("article", "atricle/:id", array("action" => "artice"), "ArticleController");

All route segment values are available via the $route_values property. And the action name — additionally via the $action_name property.

Controllers have two advantages:

before_action filters

The before_action method is called before any action. It’s the right place to check permissions and a chance to cancel the action:

class MyController ... {

    protected function before_action() {

        # check some conditions
        if($this->action_name != "auth" && !$this->authenticated() {

            # schedule a redirect
            lexa_redirect(lexa_route_url("auth"));

            # cancel execution of the action
            return false;
        }

        # otherwise, don't forget to call the base implementation
        return parent::before_action();
    }

}

Automatic CSRF validation

For all non-GET requests, controllers perform anti-forgery validation automatically.

However, there may be cases when you don’t want this validation to happen. Ok, it can be easily disabled by overriding the validate_request method:

class MyController ... {

    protected function validate_request() {
        if($this->action_name == "special-action")
            return;

        parent::validate_request();            
    }

}

Passing property values to a view

In Ruby on Rails, all instance variables of a controller are automatically accessible to a view. This behavior is convenient, and with lexa-tools it can be achieved in the following way:

Controller code:

class MyController ... {

    function action_hello() {
        $this->text = "Hello";
        $this->time = time();
        print lexa_render("hello", get_object_vars($this));
    }

}

View code:

<?= $text ?> said at <?= date("M d, Y H:i", $time) ?>

HTTP Authentication

lexa_auth_basic

This function performs Basic HTTP Authentication:

lexa_auth_basic($func, $realm = "");

lexa_auth_basic(function($login, $pw) { 
    return md5($login . $pw) = "....";
});

On success it returns true. On failure it sends necessary HTTP response headers and returns false.

A good place to use this function is a controller’s before_action filter:

class MyController ... {

    protected function before_action() {
        return lexa_auth_basic(...);
    }

}

Cross-Site Request Forgery protection

lexa-tools implements cookie-based anti-forgery protection.

To enable this protection for a form, you should:

  • add the CSRF hidden field to the form using the lexa_csrf_tag() call
  • in the form handler, invoke lexa_csrf_validate() prior to any further processing

Validation technical details: if the system session cookie value doesn’t match the value submitted with the form, the processing will be aborted with an exception.

Controllers perform anti-forgery validation automatically for all non-GET requests.

For Ajax requests and other cases of manual POST data generation, two functions exist:

# Returns CSRF form parameter name
lexa_csrf_param_name();

# Returns the value of CSRF token
lexa_csrf_token();

Example of passing CSRF token with jQuery Ajax request:

jQuery.ajax(
    type: "POST",
    url: <?= json_encode(lexa_route_url("some-form")) ?>, 
    data: {
        <?= lexa_csrf_param_name() ?>: <?= json_encode(lexa_csrf_token()) ?>
    }         
)

Flash notifications

lexa-tools supports Rails-style flash notifications. They are useful for displaying messages like «New post created», «Record added» or «Error occurred» in the next or current request:

# Schedules a notification for the next request
lexa_flash($key, $text);

# Schedules a notification for the curent request
lexa_flash_now($key, $text);

# Returns a scheduled notification by key
lexa_flash($key);

Example:

lexa_route("save", function() {
    try {
        // save it...
        lexa_flash("notice", "Saved successfully!");
        lexa_redirect(lexa_route_url("success"));
    } catch(Exception $x) {
        lexa_flash_now("error", $x->getMessage());
        print lexa_render("form");
    }
});

In a view:

<? if(lexa_flash("error")): ?>
    <div class="error">
        <?= htmlspecialchars(lexa_flash("error")) ?>
    </div>
<? endif ?>

To keep a flash message for the next request, use the lexa_flash_keep($key) function. To discard previously scheduled notification, call lexa_flash_discard($key).

Internationalization

lexa-tools uses JSON files to store localizable strings in the hierarchical manner:

{
    "my-site": {
        "title": "Super site",
        "footer": {
            "copyright": "Copyright (c) Super Webmaster"                
        }
    }
}

You register these files by one of the following functions:

# Registers a whole directory of locale files
lexa_locale_path("../locale");

# Registers a single locale file
lexa_locale_file("de", "../locale/de.json");

The path passed to the lexa_locale_path function should contain files like en.json and de-at.json. That is, their names should consist of a locale code and the .json extension.

It’s possible to register any number of files and paths. They will be loaded on demand. A couple of locale files is bundled into the library, for example this and this.

The active locale is specified and determined by the lexa_locale function:

# sets the Russian locale
lexa_locale("ru");

# prints "ru"
print lexa_locale();

The default locale is «en». It’s also possible to detect the client preferred language by analyzing request headers.

Registered strings are retrieved by the «translation function» lexa_t:

print lexa_t("my-site.title");
print lexa_t("my-site.footer.copyright");

The passed key is a dot-separated path to a string in the JSON file.

Fallbacks

You can define locale fallbacks using the lexa_locale_fallback function:

lexa_locale_fallback("es-mx", "es");
lexa_locale_fallback("es-ar", "es");
lexa_locale_fallback("es-pr", "es");

This way, you can create a main Spanish localization and some «patches» for various latin countries. The default fallback is «en».

If no translation is found (including all fallbacks) then the key as-is wrapped in square brackets is returned:

lexa_locale("martian");

# prints [my-site.title]
print lexa_t("my-site.title");

Choosing client preferred locale

Browsers pass a special HTTP header Accept-Language which contains lingual preferences of a client. lexa-tools has a nice capability to automatically choose appropriate locale by analyzing this header:

lexa_locale_from_request("en", "ru", "de");

lexa_locale_from_request("en-us", "en-gb", "en-ca");

You pass the list of supported languages, the function detects which one fits and makes it active.

Class auto-loading

lexa-tools has a universal class auto-loader, it is used internally and is also available for your usage. Typical auto-loading scenarios are discussed below:

Load classes from a file

# The simpliest case is to load a class from a file.
# This will autoload class A from the file ../classes/a.php
lexa_autoload("A", "../classes/a.php")

# To load several classes, pass names in an array
lexa_autoload(array("A", "B", "C"), "../classes.php")

Load classes by a prefix from a directory

Assume you have some data model classes and their names start with the Model_ prefix (Model_Person, Model_Product, etc). Then you can use the following code:

lexa_autoload("Model_*", "../models")

File naming conventions:

  • the prefix is stripped off
  • names in the CamelCase are dasherized (ClassName -> class-name)
  • names with the underscore character (_) are converted to the lower case

For example:

  • Model_Person will be loaded from ../models/person.php
  • Model_ProductCategory from ../models/product-category.php
  • Model_Article_Tag from ../models/article_tag.php

Load a whole namespace from a directory

lexa_autoload("My\Namespace\*", "../my-ns-classes")

The code above will load any class in the My\Namespace namespace. Same naming conventions are used:

  • My\Namespace\Class1 will be loaded from ../my-ns-classes/class1.php
  • My\Namespace\SubNamespace\Another_Class from
    ../my-ns-classes/sub-namespace/another_class.php

Combined usage

Above scenarios can be mixed:

# Load a namespace from a file
# e.g. on-demand loading of a library merged into a single file
lexa_autoload("Some\Library\*", "../lib/some-library.php");

# Load a part of a namespace by a prefix from a directory
lexa_autoload("App\Model_*", "../app/models")

# Load a namespace and a class from a file
# e.g. load orange-bean ORM on demand
lexa_autoload(array("Lexa\OrangeBean\*", "R"), "vendor/orange-bean.php")

Admin interface generator

The most exciting feature of lexa-tools is its admin interface generator (code name «Manage»).

Nothing can describe it better than a live working example. And I have it for you. For this sample model there is an XML configuration file. This XML file is created according to this XML Schema (thanks to lexa-xml-serialization library which is also bundled into lexa-tools).

A single line of code:

lexa_manage_register("manage", "../manage-config.xml");

makes this wonderful backend to appear at the /manage/ URL. Use login Sam and empty password to authenticate.

I hope you’ll enjoy it. The example is fully functional, but of course read-only. Its source code and the sample database come along with the library.

Error handling

lexa-tools converts all runtime errors into exceptions. Latter are handled in the following way. If the display_errors ini option is enabled, then the exception details are printed, otherwise only HTTP status message (like «Internal Server Error») appears.

To affect the default behavior, you may register your own exception handler:

set_exception_handler(function($e) {
    # perform some logging
    # ...        

    lexa_redirect("/error.html");
});

How about caching?

orange-bean ORM which is used as a data layer has its internal query cache.

Opcode cachers like APC are always recommended, and some internal parts of lexa-tools can take advantage of APC if it’s available.

There is no other caching mechanism.

You will have to profile your app before adding caching capabilities to avoid the trap of premature optimization effects. So I think you should decide yourself when to use memcached, when to use shared memory storages and when to send HTTP caching headers, depending on your specific scenarios.

How about Ajax?

lexa-tools is a server-side framework. When it handles requests, it doesn’t distinguish full-page updates and Ajax calls. It doesn’t introduce any client-side API.

I recommend that you use jQuery or another Javascript library to add client-side and Ajax niceness to your site.

How about logging?

Logging is another component that is absent from lexa-tools. There are dedicated logging frameworks like log4php, so I decided not to reinvent the wheel.

How about output compression?

Most frameworks boast how they automatically compress the output. lexa-tools doesn’t.

I think output compression is a task of your web-server. To make your site fast, you will almost certainly use Nginx or another frontend server, which can compress HTTP streams much more efficiently.

Anyway, even if you have to deploy on a shared hosting and have no access to the web-server configuration, output compression can be enabled by a single line of PHP code: use either ob_gzhandler or the zlib.output-compression option.

How about API for Twitter, YouTube, Akismet, etc?

Every mature API-provider offers client libraries for different languages. Obviously, it’s safer to use the official dedicated code. That’s why lexa-tools doesn’t implement these APIs.

Code Quality

The library was created using the TDD methodology. Almost everything that can be covered by unit tests is covered by them (check test code).

lexa-xml-serialization and orange-bean projects are also well tested.

Sample web site which I use to demonstrate the auto-admin generator uses up to 99% of the framework features. The working example is a clear demonstration of the working code.

Your Feedback

Should you have any questions or suggestions, feel free to contact me.

Thank you.

License

The library is released under the MIT license.