Request cycle and code organization
InterpretersOffice uses the Model-View-Controller pattern and is built on the Laminas MVC framework, formerly know as Zend. Laminas/Zend now seems to be less popular than some other PHP frameworks around, notably Laravel and Symfony. The MVC pattern also seems to be becoming passé, having been overtaken by the middleware paradigm. Laminas MVC is nevertheless a reasonable choice – a quality framework with a strong user community where you can find support if you need it.
This document won’t attempt to describe the intricacies of the Laminas MVC request/response cycle. The point is to provide a general outline of the application flow and in particular, where in our application directory tree you will find the physical files involved in that flow.
As with most modern PHP frameworks, the entry point to the application is its main index.php file. The one in this application is straight out of the box from the Laminas skeleton application, there being no reason to modify it. Every http request is subject to URL-rewriting rules (also borrowed from Laminas without modification). If a URL matches an existing physical path, e.g., a Javascript or CSS resource, the request is served and we’re done; otherwise it is handled by index.php, and the application takes over.
routing
Many things happen under the hood during the application bootstrap phase, including reading configuration data, setting up the dependency injection container (a/k/a Service Manager), followed by routing the request. Routes – the paths in the URL – are mapped to controllers and their action methods via configuration. The framework figures out which controller class to instantiate, and then which of the controller’s action methods to invoke.
So, for example, when a user navigates to /admin/schedule,
the framework looks to its configuration for a matching route,
and finds one.
The framework then examines that configuration to see what controller to instantiate – in this case,
To continue with this example, once we get into scheduleAction() method, the flow of execution is typical of GET requests in this application. We fetch desired data from the database (in this case, an array of Event entities matching a particular date, and related data); we inject this data into a Laminas\View\Model\ViewModel object; and return it. The framework then takes care of rendering and outputting this to the browser as finished HTML, using a viewscript that we have written for this purpose. The viewscripts used by InterpretersOffice are standard PHP files with a .phtml extension.
You may be wondering how the Laminas framework figures out where to find these viewscript files. The short version is that a configuration file tells it where to look, and there is more than one way to configure it.
We should mention here that like many Laminas MVC apps, InterpretersOffice consists of multiple MVC modules. Technically, the only two essential modules are the ones in the InterpretersOffice and InterpretersOffice\Admin namespaces. (In practice, for the Southern District of New York where the app was developed, the InterpretersOffice\Requests module is indispensable.) Each module lives in its own subdirectory and has, among other things, its own configuration files. Among these is a module.config.php file which returns an array of configuration data. A common practice is to include here an array under the key view_manager that tells Laminas how to resolve viewscripts for the module.
'view_manager' => [
'template_map' => include(__DIR__.'/template_map.php'),
'template_path_stack' => [
__DIR__.'/../view',
],
],
The above configuration directive tells Laminas to look first at the configuration array returned by the file template_map.php, which maps controller actions to viewscripts. (The optional template map offers the advantage of performance at the cost of a bit more maintenance.) Then, if that fails, the fallback is to look in the module’s view subdirectory based on the convention module_name/controller_name/action_name.phtml. Thus, in this example, if no mapping were to be found in template_map.php, Laminas would then look for module/Admin/view/interpreters-office/admin/schedule.phtml. If that also fails, Laminas throws an Exception.
Now let’s go back and look at this same example again with a little more detail, and emphasize how directories and files are laid out.
the application root directory
The top level of the application directory looks like
david@lin-chi:/opt/www/court-interpreters-office$ tree -aL 1
.
├── bin
├── composer.json
├── composer.lock
├── config
├── data
├── docs
├── .eslintrc.json
├── .git
├── .gitignore
├── LICENSE
├── module
├── phpcs.xml
├── phpdoc.dist.xml
├── phpunit.xml
├── public
├── README.md
├── test
├── .travis.yml
└── vendor
bin contains some interesting utilities but none of them is required at runtime.
The composer.* files are related to the PHP dependency manager PHP Composer, which is required for installation and occasional maintenance, but not at runtime.
configuration files
config is, as you would expect, where configuration is stored. Its contents look like
david@lin-chi:/opt/www/court-interpreters-office$ tree -L 2 config
config
├── application.config.php
├── autoload
│ ├── config.local.php.dist
│ ├── doctrine.local.php
│ ├── doctrine.local.php.dist
│ ├── doctrine.test.php
│ ├── global.php
│ ├── local.development.php
│ ├── local.development.php.dist
│ ├── local.production.php
│ ├── local.production.php.dist
│ ├── local.staging.php
│ ├── local.testing.php
│ ├── mailgun.sdny.local.php
│ ├── navigation.global.php
│ ├── README.md
│ ├── rotation_module.local.php
│ └── vault.local.php
├── cli-config.php
├── development.config.php -> development.config.php.dist
├── development.config.php.dist
├── doctrine-bootstrap.php
└── modules.config.php
application.config.php contains settings that Laminas uses early in the bootstrapping phase, and which you will rarely if ever need to change.
In the autoload subdirectory, everything with a .dist extension is a sort of template that ships with the application. At installation time these are copied (manually, in the current version of InterpretersOffice) to their equivalents minus the .dist and edited according to the local environment. The purpose of “development,” “production”, etc., is to enable us to configure the application differently depending on the environment variable environment, which is is set in the vhost configuration, and can be read with the PHP getenv() function.
The framework consumes everything in autoload that matches the environment or that contains “local” in its name. (You can control this behavior by tweaking a setting in application.config.php but, again, in the normal course there will be no reason to do so.) It also reads *global.php but the local files take precedence.
The framework also reads in the configurations found in each individual module’s config subdirectory. Collecting and merging all this involves considerable overhead, which is why in production environments we enable configuration caching. If you change the config you will need to purge the cache. Forget this important detail, and you will go insane wondering why your update is having no effect.
Let’s jump back up to the top level and continue.
The data directory must be server-writeable. It contains, among other things, log files, a cache subdirectory and the filesystem cache used by Doctrine ORM.
docs contains documentation files, such as the one you are now reading. .eslintrc.json is just for configuring eslint, a tool for tidying up Javascript code. The files .git .gitignore are related to git. LICENSE is self-explanatory; the *.xml files are for configuring phpunit and phpcs, the executables for which are located in vendor/bin. At this writing test contains some experimental QA-type tests written in Javascript. The phpunit tests can be found in each modules test subdirectory. .travis.yml is required in order to use the incredibly helpful Travis CI service. None of these resources are required at runtime.
The vendor directory contains a copious amount of third-party libraries, including the Laminas framework itself. composer creates and manages this; no one else should modify any of its contents.
We’ve intentionally skipped a couple of items in order to save them for last: the public and module directories.
the public resources
public is the web document root. It has the application entry point, index.php, and all our Javascript and CSS assets are in js and css, respectively. Several third-party js and CSS libraries are located in here as well. Most of our CSS is provided by Bootstrap; the custom CSS is minimal.
The custom Javascript, however, is abundant. A naming convention strongly suggests which js files are combined with which controller actions, but the places to look, if you want to identify the js that is used on a given page, are in the main layout viewscript and in the individual action’s viewscript. The layout loads all the js that is nearly certain to be needed down the line – mostly libraries. The individual viewscripts load js code that is specific to the particular page/controller-action. Within those viewscripts, we load js resources by using a view helper that ships with Laminas, e.g.,
<?php /* module/Admin/view/interpreters-office/admin/schedule/index.phtml */
$this->headScript()->appendFile($this->basePath('js/lib/jquery-ui/jquery-ui.min.js'))
->appendFile($this->basePath('js/lib/moment/min/moment.min.js'))
->appendFile($this->basePath('js/admin/schedule.js'));
// etc
the modules
Virtually all InterpretersOffice’s server-side PHP resides in module. The standard modular structure of Laminas MVC is neatly organized:
david@lin-chi:/opt/www/court-interpreters-office$ tree -L 2 module
module
├── Admin
│ ├── config
│ ├── src
│ ├── test
│ └── view
├── InterpretersOffice
│ ├── config
│ ├── src
│ ├── test
│ └── view
├── Notes
│ ├── config
│ ├── src
│ ├── test
│ └── view
├── Requests
│ ├── config
│ ├── src
│ ├── test
│ └── view
├── Rotation
│ ├── config
│ ├── src
│ ├── test
│ └── view
└── Vault
├── config
├── src
└── test
The mapping of these physical paths to PHP namespaces is defined in composer.json. The various module subdirectories have short names but correspond to longer, fully qualified namespaces. The Admin module contains code in the InterpretersOffice\Admin namespace; the Requests maps to the InterpretersOffice\Requests namespace; and so on.
Modules are enabled or disabled by editing config/modules.config.php, and in practice you will rarely need to touch it. The order in which modules are loaded is significant. The first several entries are for the Laminas framework and Doctrine ORM, while the later entries refer to the modules that compose the actual InterpretersOffice application. The InterpretersOffice and InterpretersOffice\Admin modules must come first, in that order; the ones following depend on them.
All of these modules, with the exception of Vault, are under the InterpretersOffice namespace. Each module has its own config and other subdirectories. This helps to keep the modules separate and self-contained. Each module’s src subdirectory contains module-specific classes: service classes that do especially heavy lifting, controllers, factory classes, form classes derived from Laminas\Form, and more. The view subdirectory is for viewscripts, as you might have guessed; and similarly, the test subdirectory contains phpunit tests.
Each module’s src folder includes a class called Module.php, which can optionally define an onBootstrap() method, invoked early in the request cycle, where you can attach event listeners that may be triggered later in the cycle. On the plus side, it encourages separation of concerns, but it can get complicated. Internally, Laminas relies heavily on its event system and InterpretersOffice uses it as well. When trying to comprehend the flow of execution you may find it helpful either to use a debugger or recursively grep src folders for strings like “attach” and “trigger.”
All that said, let’s return to the example in which the user requests /admin/schedule.
authentication and authorization
The Admin module’s onBootstrap() method does some initialization work,
which includes attaching a listener to the MvcEvent::EVENT_ROUTE
event to enforce
authentication and authorization.
public function onBootstrap(EventInterface $event)
{
// ...
$eventManager = $event->getApplication()->getEventManager();
$eventManager->attach(MvcEvent::EVENT_ROUTE, [$this, 'enforceAuthentication']);
// ...
}
The above snippet tells the event manager to execute the enforceAuthentication() method of Admin\Module.php when the routing event has taken place – in other words, once we know what module, controller and action are going to serve the request.
enforceAuthentication() considers whether authentication is required (for a few routes, it is not). If the user is not authenticated, and is requesting a resource that requires authentication, we redirect to the login page. If the user is authenticated, we next consider whether this user is authorized access to the requested resource, using a service derived from the Laminas ACL implementation and an extensive configuration file located at module/Admin/config/acl.php. Note that other modules can contribute their own acl settings as well, by placing the appropriate configuration in their own config files. We check whether the role that the current user is authorized in relation to the requested resource (usually, a controller) and privilege (usually, an action).
If the authorization is denied, we redirect to the login page with a message saying access denied.
For this example, assume the user is logged in and has the role manager. Per our ACL rules, a manager is allowed to run the schedule action of the InterpretersOffice\Admin\ScheduleController, so we move on.
controller, action and view
We mentioned earlier that during bootstrapping, Laminas initializes a service manager. The framework uses this service manager to instantiate
controllers via factory classes, i.e., classes that implement Laminas\ServiceManager\Factory\FactoryInterface.
A standard practice is to write configuration in a module’s module.config.php mapping controllers to factories. Looking at the
ScheduleControllerFactory’s
invoke__ method you’ll notice that the first argument is an implementation
of Interop\Container\ContainerInterface. That $container
is
the service manager, which (if properly set up) provides access to all the dependencies and configuration we need.
/**
* implements Laminas\ServiceManager\FactoryInterface
*
* @param ContainerInterface $container
* @param string $requestedName
* @param null|array $options
* @return ScheduleController
*/
public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
{
$end_time_enabled = $this->getEndTimeEnabled();
$em = $container->get('entity-manager');
return new ScheduleController($em, ['end_time_enabled'=>$end_time_enabled]);
}
Line 11 is concerned with getting a configuration datum to help the ScheduleController configure the
view for purposes of presentation logic, but this detail is merely incidental to this discussion. The main point is that the controller
factories have access, via the service manager a/k/a $container
, to whatever dependencies
the controller requires.
This pattern recurs throughout the application. We usually don’t instantiate objects, but rather pull them from the container. By default, the container will re-use any already-existing instance, and again, the dependency injection is taken care of via factories that we write and register with the module configuration. Conventionally, you identify classes by their fully qualified class names, or other likewise unique identifiers, to avoid ambiguity and collisions. But you can also set aliases for convenience. In the above example, ‘entity-manager’ is an alias for the more verbose doctrine.entitymanager.orm_default
Controllers are found in each module’s src/Controllers folder. The factories in most cases are in src/Controller/Factory, to keep src/Controllers from becoming too cluttered (with the smaller modules I decided it was not necessary to dedicate a folder to the factory classes, so they sit alongside the controllers.)
With a ScheduleController having been instantiated by the framework, it now dispatches the action matched by routing, in this case scheduleAction():
public function scheduleAction()
{
$filters = $this->getFilters();
$date = new \DateTime($filters['date']);
$repo = $this->entityManager->getRepository(Entity\Event::class);
$data = $repo->getSchedule($filters);
$end_time_enabled = $this->config['end_time_enabled'];
$viewModel = new ViewModel(compact('data', 'date','end_time_enabled'));
$this->setPreviousAndNext($viewModel, $date)
->setVariable('language', $filters['language']);
if ($this->getRequest()->isXmlHttpRequest()) {
$viewModel
->setTemplate('interpreters-office/admin/schedule/partials/table')
->setTerminal(true);
}
return $viewModel;
}
This controller fetches interpreter scheduling data – an array of Event entities – for a particular date. It uses a helper method to apply filters to the database query, collects a few other details, assigns these variables to a ViewModel, and returns the ViewModel object.
The $this->entityManager
in the above excerpt is the Doctrine entity manager that was passed
to the controller’s constructor back at the factory. Nearly all of the communication with the data layer is done through this object.
In this example we use it for access to a custom repository class that knows how to fetch the schedule data. The bulk of our
entity models and repositories are in module/InterpretersOffice/src/Entity
Viewscripts are located in each module’s view subdirectory. The framework consults its configuration to determine what viewscript to use for rendering, as described above. In this example our viewscript is module/Admin/view/schedule/schedule.phtml, which loads some Javascript for things like a datepicker for navigating by date and other interactive controls, then handles display logic to show the user the events on the interpreters’ schedule for a given date.
<?php /** module/Admin/view/schedule/schedule.phtml */
$this->headScript()->appendFile($this->basePath('js/lib/jquery-ui/jquery-ui.min.js'))
->appendFile($this->basePath('js/lib/moment/min/moment.min.js'))
->appendFile($this->basePath('js/admin/schedule.js'));
$this->headTitle($this->date->format('D d M Y'));
$messenger = $this->flashMessenger();
if ($messenger->hasSuccessMessages()) :
echo $messenger->render('success', ['alert','alert-success',], false);
endif;
// etc
The $messenger
refers to a Laminas
controller plugin with which you can set a message to show the
user, then redirect to another page and display it. InterpretersOffice uses this
technique frequently for showing the user confirmation messages, sometimes with server-side redirection, sometimes client-side
with Javascript by saying document.location = some_other_url
The preceding walk-through is intended to provide a feel for how a typical request/response cycle works, and where the various files are found in the application directory tree. This example is for a GET request, where the user is reading data as opposed to changing it.
The pattern for pages for creating or editing entities is typical web application flow. We display an HTML form; the user enters data in the form and POSTs it; we validate the input; if validation fails we display error messages; otherwise we update the database using the Doctrine API, and then display a confirmation. For the most part, the forms are built by extending Laminas\Form components, which take an object-oriented approach for form elements, data validation and filtering.
For further information you can examine the source code, read the documentation sites for the framework and various libraries, or ask me a question: davidmintz@interpretersoffice.org.