Skip to content

Instantly share code, notes, and snippets.

@juriansluiman
Created November 8, 2011 14:35
Show Gist options
  • Save juriansluiman/1347881 to your computer and use it in GitHub Desktop.
Save juriansluiman/1347881 to your computer and use it in GitHub Desktop.
Modular application system for ZF2

Application system

This document describes how a system can be formed to set up modules in combination with a tree of pages stored in the database. The general idea is to have pages stored within a database in a tree structure, where every page is coupled to a module (1:n). Pages are queried and parsed to routes as a combination of the page route and the modules route(s).

Goal

Webapplications exist often with a frontend (for normal visitors) and backend (to manage the whole bunch). Also some pages might want to utilize the same module (two simple text pages as basic example). Thirdly, it should be easy for end users to create new pages, modify them or delete pages. To accomplish this, a robust system ("content management framework") should be made on top of zf2 to accomodate this.

Configuration

A module registers the routes to the Application\Router\Listener, aliased as "router":

'di' => array(
    'instance' => array(
        'router' => array(
            'parameters' => array(
                'routes' => array(
                    'blog' => array(
                        'type' => 'literal',
                        'options' => array(
                            'defaults' => array(
                                'controller' => 'blog-article',
                                'action'     => 'index',
                            ),
                        ),
                    ),
                ),
            ),
        ),
    ),
),

The key for the routes is the name under which the module is registered for the page. In other words, a $page->getModule() would return "blog" to load above route configuration. The route is in this case a single route, but child routes can be added too.

Of course it is possible to have aliased controllers or FQCN as a controller, this is completely up to the module.

Routing

An early listener is registered for the "route" event to load all pages, parse the routes based on the page route and the module's route(s) and inject those routes into the router. As an example, where $page->getRoute() would return "/blog", $page->getId() is 1 and $page->getModule() is "blog" and a module "blog" is registered with above configuration into the router, the result is this:

array (
1 => 
  array (
    'type' => 'literal',
    'options' => 
    array (
      'route' => '/blog',
      'defaults' => 
      array (
        'controller' => 'blog-article',
        'action' => 'index',
        'page-id' => 1,
      ),
    ),
  ),
),

At this point, all controllers from the modules can be dispatched and follow the general zf2 path.

Pages

Of course you might have two pages ("products" and "services") both be for the module "Text". How does this text module know to show for /products the products texts and for /services the services texts? This happens through the same Application\Router\Listener which has also an early "dispatch" listener. In that callback, the Application\Model\Page, the model representing a single page, is queried for the current routeMatch.

As shown above, every route has a parameter page-id, which is the id for the page currently requested. The database is simply queried for that primary key. This page is injected as "page" parameter in the MvcEvent.

Every controller can attach a listener to it's dispatch event and grab the page from there. If you want to keep the code DRY, you can also use Application\Controller\ActionController as base class instead of Zend\Mvc\Controller\ActionController. The application's ActionController has a listener and sets a property $page.

Now you have the page, you want to know whether it's the "products" or "services" page. To accomplish this, Application\Model\Page has not only a method getModule() but also a getModuleId(). It allows a module to place an identifier into the page to, upon request, know which typical page is requested. In the most simple case, the "Text" module stores the primary key for the texts "products" and "services" as module identifier inside the page.

Events

This system is set up with the EventManager to provide hooks for other modules. Currently one additional event is triggered. The listener for the early route event triggers an route.init. For the route.init event, two listeners are attached by default:

  1. Load pages from database, parse all pages to routes and set the list of routes as param in the event;
  2. Load routes from event and inject them into the router.

This makes it possible to have events for caching the routes or modifying the routes. An example of the latter is i18n, where the i18n modules takes all routes and transforms them somehow in i18n versions. The second listener will use these new routes to set them into the router.

Admin system

With above application setup, an Admin module is required to manage the pages and their contents. This module is a kind of "proxy" to other modules. If you request the admin module to open page #34, the module looks for the module connected to page #34 and dispatches a controller from that module based on the matched route.

As an example, the blog module has a controller BlogAdmin\Controller\ArticleController registered to the admin module under "article". The route to open pages is admin/page/open/:id[/:subcontroller[/:subaction]]. This means, if you request the url admin/page/open/34/article/new, the BlogAdmin\Controller\ArticleController::newAction() will be called.

Note I use here a seperate namespace for the admin controllers: Blog\Controller\ArticleController versus BlogAdmin\Controller\ArticleController. I think this makes a clear distinction between a front end and back end part of a module. However, you're free to choose any kind of namespace.

Architecture

Every module working under the content management framework system, should inject an "Admin" class into a service class of the Admin module. The Admin module is then aware this content module is part of the system and this Admin class provides all information to the Admin module.

The admin class implements an interface Admin\Admin\AdminAware with the following methods:

  1. Controllers for dispatching. In above example, the Blog module would return at least 'article' => 'BlogAdmin\Controller\ArticleController'.
  2. Manifest for information. A module is not only a namespace, but for users it might provide a display name, a description, a website and a link to documentation. The manifest returned contains (parts of) this information.
  3. Creating and deleting pages. When a new Application\Model\Page is created, this Admin class is notified to insert for example a module identifier into the page. The same holds for delete: if a page is deleted, the module is notified to remove the contents for that page.

Configuration

Through DI it is possible to inject the modules which need to be accessible in the admin interface. The Admin\Service\Admin is aliased to "admin" and accepts an array of options:

'di' => array(
    'instance' => array(
        'admin' => array(
            'parameters' => array(
                'modules' => array(
                    'blog'      => 'BlogAdmin\Admin',
                    'portfolio' => 'MyPortfolioAdmin\Admin',
                    'shop'      => 'ExtendedEcommerceAdmin\Admin',
                ),
            ),
        ),
    ),
),

Again the key for the modules listing is the name under which the module is registered for the page. The value is a class implementing the Admin\Admin\AdminAware interface.

Controllers

The Admin\Admin\AdminAware interface defines a getControllers() method. The method should return an associative array similar to the aliases of the DI configuration: keys are the shortname and values are the FQCN.

Manifest

The manifest is to provide meta data about the module: what it's called, what is does, a link to documentation etc. A method getManifest() should return an instance of Admin\Admin\Manifest and contains several fields which might be filled be the module. The admin module can consume the manifests and utilize their data. Further architecture TBD.

Create and delete pages

When a page is created, the module should be notified of this. As example, the admin module notifies the blog module. As an argument, the page instance for that page is given. The module can read page properties and insert an identifier for itself when this page is requested (see Application system above).

namespace BlogAdmin;

use Admin\Admin\AdminAware,
    Application\Model\Page,
    Blog\Model\Blog;

class Admin implements AdminAware
{
    public function create (Page $page)
    {
        $blog = new Blog;
        $em   = $this->getLocator()->get('doctrine')->getEntityManager();
        $em->persist($blog);
        $em->flush();

        $page->setModuleId($blog->id);
    }

    public function delete (Page $page)
    {
        $em   = $this->getLocator()->get('doctrine')->getEntityManager();
        $blog = $em->find('Blog\Model\Blog', $page->getModuleId());
        $em->remove($blog);
        $em->flush();
    }

    // other methods here
}

Events

The admin module triggers at several moments events to provide hooks for other modules:

  1. page.create: create a page (at timing 0 the page is instantiated)
  2. page.open: open a page (at timing 0 the module controller is dispatched)
  3. page.delete: delete a page (at timing 0 the page is removed)
  4. content.create: modules should/might trigger this if any content is created
  5. content.update: modules should/might trigger this if any existing content is updated

Other ideas for the system, no concrete implementations ready

  1. Integrate Assetic to keep assets for modules inside the modules directory. Need an Assetic module which can be consumed by other content modules.
  2. Use Zend_Navigation to build a tree of pages for menu/sitemap/breadcrumbs. Easy to parse pages to navigation, what to do with "subpages": eg portfolio page has "subpages" determined by the portfolio module. Perhaps events can help in this case?
  3. Use ACL/RBAC to restrict access for anonymous visitors, users or other groups to specific parts
  4. Keep users in db, many-to-many with groups (also in db)
  5. Keep pages in db, one-to-many with domains (kind-of category, also in db)
  6. Find a way to give groups access to domains (view rights)
  7. Find a way to give groups access to actions: place a reaction at a blog article, create a new page in the CMS, mark orders in the eCommerce system as lapsed, upload documents to the server etc.
@lsmith77
Copy link

have you looked at http://phpcr.github.com ?

@juriansluiman
Copy link
Author

No, I have not looked in depth to content repositories. I heard from ocramius he spoke to you and that a CR was suggested. I'll have a look at it too. We've now at zf2 five people (me included) interested in (some parts) of this system, so we'll see where this is heading.

@lsmith77
Copy link

The idea behind a CR is to really focus on the CMS use case. With PHPCR you get a standardized API for which you will see many implementations which will support different persistence layers (RDBMS, NoSQL, file system), different deployment targets (shared hosts, multiple geo locations etc), as well as different advanced feature sets (versioning etc.). But the code and concepts will remain portable to a large extend.

The abstraction makes sense especially because the problem domain is limited. Of course you can mix and match .. like use an RDBMS for your store inventory and orders while using PHPCR for your product and category descriptions.

At the same time you get an API that is already prepared to handle key issues like tree traversal, full text search, referencing, versioning, content import/export (between different apps .. but also between staging and production).

@juriansluiman
Copy link
Author

That's what I also got from the slides of phpcr. I like the basic, initial idea for sure and everybody benefits from standardization in the community. There are some small rough edges which needs to be flattened first (but they're most of the type 'not knowing that B is possible too'), however this might save a lot of work if versioning and staging/production environments need to be implemented manually.

@lsmith77
Copy link

please come by #jackalope IRC to discuss or send us a mail to the mailing list ... we are very interested in collaboration on the PHPCR / PHPCR ODM level.

@Ocramius
Copy link

@lsmith77 coming back to this... Would it be possible to build it through Doctrine\Common\Persistence interfaces only? (just a weird idea)

@lsmith77
Copy link

didn't look at it in detail again .. but you might want to check out what we did for https://github.com/symfony-cmf/ChainRoutingBundle/blob/master/Routing/DoctrineRouter.php

@Ocramius
Copy link

@lsmith77 RouteRepositoryInterface looks generic enough :) Thx for the info!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment