From b35a5ac4af3ec52d16d364cfa494c88ab9d18743 Mon Sep 17 00:00:00 2001
From: froschdesign
z3Q!?gs!(cgT?Z~EjofuJ5==h{nW1aw>KuFW2r=K_Uij&&r3f-^91d#v)9s8ABmdqX
zcyl+#*g*PX#2#1F_z6AcG4oV<`z$aykx%VjDreN{h{Ke!8?vvJe(O5GHjIlwqgmT)
z+qC<*6(pmqoUqRHVfF3X->FZ8hKc This adds a simple but functional breadcrumb to every page (we tell it to render
-from a depth of 0 so we see all page levels), but we can do better than that!
-Because Bootstrap has a styled breadcrumb as part of its base CSS, let's add
+from a depth of 0 so we see all page levels), but we can do better than that! Because Bootstrap has a styled breadcrumb as part of its base CSS, let's add
a partial that outputs the Let's create the partial Refreshing the page now gives us a styled set of breadcrumbs on each page. Refreshing the page now gives us a styled set of breadcrumbs on each page that should look
+like this:QzBR=A1MLIj+>^;
z5y#8uh8~r{ntU1arL)^aci*$8V})lmHerNEq<190gq*=27Y;72|9HR6@UB$RSe%#a
zG!86S>~^Nz+zp2FxjxNoO027^tpn?&_hWSHVmy5ty|6`WNuwl|f6k~`uT^j-
%oXhc11NSu;*eRxf9-2bK5+tA!Lq6ao(;(WaR5{`9>1Z~f5_
z6v4XweTMt7GpI%|6Vllzh{PBrCI9Q~)YdOhCb*1Fn-VMBn(xkCl{
zHHk#mZwBb{qoC5IFcCkav-Q1u#s=d%N#jq3^C$TcG_9T|G
ryn&lQd7AXfKEJbG(xW}0Ild{uUv;PBeEL1b~%qKrC);3oIM6w3p`;QaQ0iD
zaMn{Y)?lh1^=n6?_~uAIcq%_@sN`p*;T8{|*YxHBE|{~5@+8M5zx~3{c{yv
z%Rsr(>95vq!02wMg#Gu8Kzaw}oty(+OG3J0kr<-|3-Uj5oY7tRBcSHXiAO7EDg5@i
zYX!W!_n-!35Ex%?F|O
xiM9eKG1dp76)V+h@cd_tA7UrHg>;>Wv
z_r7dx&(kyVbcjAPYC-iFb5^vs9nZD8D0uQ9>NWg3E_0Ma8{{W_;@)y)Wc3xUSc6Bn
zvj47cr@3|^Vg8}_SNY=Y!a)2OmSearch
diff --git a/navigation/index.html b/navigation/index.html
index 2ca249e..9700163 100644
--- a/navigation/index.html
+++ b/navigation/index.html
@@ -653,11 +653,12 @@ Adding Breadcrumbs
<?= $this->content ?>
</div>
<ul>
using Bootstrap styles. We'll create it in the
view
directory of the Application
module (this partial is application wide,
-rather than album specific):module/Application/view/partial/breadcrumbs.phtml
:
-<?php // in module/Application/view/partial/breadcrumb.phtml: ?>
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
@@ -697,7 +698,9 @@
Adding Breadcrumbs
->setPartial('partial/breadcrumb') ?>
<?= $this->content ?>
</div>Using laminas-paginator in
one page is not a problem. However, how will the album list look when we have
100 albums or more in our database? The standard solution to this problem is to
split the data up into a number of pages, and allow the user to navigate around
-these pages using a pagination control. Just type "Laminas" into Google,
-and you can see their pagination control at the bottom of the page:
A typical paginator on a web page looks like this:
+As before, we are going to use sqlite, via PHP's PDO driver. Create a text file
data/album-fixtures.sql
with the following contents:
/album
, you'll see a huge long list of 150+ albums; it's ugly.
laminas-paginator is not installed or configured by default, so we will need to do -that. Run the following from the application root:
-$ composer require laminas/laminas-paginator
+that.
+laminas-paginator uses data source adapters to access data collections. In order to +access data into our database, we will need an adapter that uses laminas-db which is +provided by an additional component: laminas-paginator-adapter-laminasdb.
+Run the following from the application root:
+$ composer require laminas/laminas-paginator laminas-paginator-adapter-laminasdb
Assuming you followed the Getting Started tutorial,
you will be prompted by the laminas-component-installer
-plugin to inject Laminas\Paginator
; be sure to select the option for either
-config/application.config.php
or config/modules.config.php
; since it is the
-only package you are installing, you can answer either "y" or "n" to the "Remember this
-option for other packages of the same type" prompt.
Laminas\Paginator
and Laminas\Paginator\Adapter\LaminasDb
; be sure to select the option for either
+config/application.config.php
or config/modules.config.php
; since you are installing more than one
+package, you can answer "y" to the "Remember this
+option for other packages of the same type" prompt such that the same option is applied to both components.
-Manual configuration
If you are not using laminas-component-installer, you will need to setup configuration manually. You can do this in one of two ways:
-
- Register the
Laminas\Paginator
module in either +- Register the
@@ -727,16 +731,18 @@Laminas\Paginator
andLaminas\Paginator\Adapter\LaminasDb
modules in eitherconfig/application.config.php
orconfig/modules.config.php
. Make sure you put it towards the top of the module list, before any modules you have defined or third party modules you are using.Manual configuration
<?php use Laminas\Paginator\ConfigProvider; +use Laminas\Paginator\Adapter\LaminasDb\ConfigProvider as LaminasDbAdapterConfigProvider; return [ 'service_manager' => (new ConfigProvider())->getDependencyConfig(), + 'paginators' => (new LaminasDbAdapterConfigProvider())->getPaginatorConfig(), ];
Once installed, our application is now aware of laminas-paginator, and even has +
Once installed, our application is now aware of laminas-paginator
and its data source adapters, and even has
some default factories in place, which we will now make use of.
In order to let laminas-paginator handle our database queries automatically for us,
-we will be using the DbSelect pagination adapter
+we will be using the DbSelect pagination adapter
This will automatically manipulate and run a Laminas\Db\Sql\Select
object to
include the correct LIMIT
and WHERE
clauses so that it returns only the
configured amount of data for the given page. Let's modify the fetchAll
method
@@ -747,8 +753,9 @@
This will return a fully configured Paginator
instance. We've already told the
+
This will return a fully configured Paginator
instance using a DbSelect
adapter. We've already told the
DbSelect
adapter to use our created Select
object, to use the adapter that
the TableGateway
object uses, and also how to hydrate the result into a
Album
entity in the same fashion as the TableGateway
does. This means that
diff --git a/search/search_index.json b/search/search_index.json
index ad907e2..11c2c3c 100644
--- a/search/search_index.json
+++ b/search/search_index.json
@@ -1 +1 @@
-{"config":{"indexing":"full","lang":["en"],"min_search_length":3,"prebuild_index":false,"separator":"[\\s\\-]+"},"docs":[{"location":"","text":"MVC Tutorials The following tutorials will guide you through creating your first laminas-mvc application, testing it, and adding features to it. The \"In-Depth\" tutorial dives into some more advanced details of how the MVC works, along with strategies for developing models and achieving separation of concerns. Getting Started with Laminas Extensions for Album Module Unit Testing a laminas-mvc Application Adding laminas-navigation to the Album Module Adding laminas-paginator to the Album Module Internationalization and Configuration Internationalization Advanced Configuration Tricks In-Depth Tutorial The in-dept tutorial includes a more complex example. It combines best-practices and interesting features, like a repository, SQL abstraction and the form element manager. In-Depth Tutorial Application Integrations The following tutorials show possible integrations of components within laminas-mvc based applications. Adding laminas-cache to a laminas-mvc Application Adding laminas-eventmanager to a laminas-mvc Application Adding laminas-form to a laminas-mvc Application Adding laminas-inputfilter to a laminas-mvc Application Adding laminas-session to a laminas-mvc Application Component Tutorials The following are focused tutorials on specific components. Setting up a Database Adapter Using the EventManager Migrating to Version 3 Overview Components Applications","title":"Home"},{"location":"#mvc-tutorials","text":"The following tutorials will guide you through creating your first laminas-mvc application, testing it, and adding features to it. The \"In-Depth\" tutorial dives into some more advanced details of how the MVC works, along with strategies for developing models and achieving separation of concerns. Getting Started with Laminas","title":"MVC Tutorials"},{"location":"#component-tutorials","text":"The following are focused tutorials on specific components. Setting up a Database Adapter Using the EventManager","title":"Component Tutorials"},{"location":"#migrating-to-version-3","text":"Overview Components Applications","title":"Migrating to Version 3"},{"location":"advanced-config/","text":"Advanced Configuration Tricks Configuration of laminas-mvc applications happens in several steps: Initial configuration is passed to the Application instance and used to seed the ModuleManager and ServiceManager . In this tutorial, we will call this configuration system configuration . The ModuleManager 's ConfigListener aggregates configuration and merges it while modules are being loaded. In this tutorial, we will call this configuration application configuration . Once configuration is aggregated from all modules, the ConfigListener will also merge application configuration globbed in specified directories (typically config/autoload/ ). Finally, immediately prior to the merged application configuration being passed to the ServiceManager , it is passed to a special EVENT_MERGE_CONFIG event to allow further modification. In this tutorial, we'll look at the exact sequence, and how you can tie into it. System configuration To begin module loading, we have to tell the Application instance about the available modules and where they live, optionally provide some information to the default module listeners (e.g., where application configuration lives, and what files to load; whether to cache merged configuration, and where; etc.), and optionally seed the ServiceManager . For purposes of this tutorial we will call this the system configuration . When using the skeleton application, the system configuration is by default in config/application.config.php . The defaults look like this: return [ // Retrieve list of modules used in this application. 'modules' => require __DIR__ . '/modules.config.php', // These are various options for the listeners attached to the ModuleManager 'module_listener_options' => [ // use composer autoloader instead of laminas-loader 'use_laminas_loader' => false, // An array of paths from which to glob configuration files after // modules are loaded. These effectively override configuration // provided by modules themselves. Paths may use GLOB_BRACE notation. 'config_glob_paths' => [ realpath(__DIR__) . '/autoload/{{,*.}global,{,*.}local}.php', ], // Whether or not to enable a configuration cache. // If enabled, the merged configuration will be cached and used in // subsequent requests. 'config_cache_enabled' => true, // The key used to create the configuration cache file name. 'config_cache_key' => 'application.config.cache', // Whether or not to enable a module class map cache. // If enabled, creates a module class map cache which will be used // by in future requests, to reduce the autoloading process. 'module_map_cache_enabled' => true, // The key used to create the class map cache file name. 'module_map_cache_key' => 'application.module.cache', // The path in which to cache merged configuration. 'cache_dir' => 'data/cache/', // Whether or not to enable modules dependency checking. // Enabled by default, prevents usage of modules that depend on other modules // that weren't loaded. // 'check_dependencies' => true, ], // Used to create an own service manager. May contain one or more child arrays. // 'service_listener_options' => [ // [ // 'service_manager' => $stringServiceManagerName, // 'config_key' => $stringConfigKey, // 'interface' => $stringOptionalInterface, // 'method' => $stringRequiredMethodName, // ], // ], // Initial configuration with which to seed the ServiceManager. // Should be compatible with Laminas\\ServiceManager\\Config. // 'service_manager' => [], ]; The system configuration is for the bits and pieces related to the MVC that run before your application is ready. The configuration is usually brief, and quite minimal. Also, system configuration is used immediately , and is not merged with any other configuration — which means, with the exception of the values under the service_manager key, it cannot be overridden by a module. This leads us to our first trick: how do you provide environment-specific system configuration? Environment-specific system configuration What happens when you want to change the set of modules you use based on the environment? Or if the configuration caching should be enabled based on environment? It is for this reason that the default system configuration we provide in the skeleton application is in PHP; providing it in PHP means you can programmatically manipulate it. As an example, let's make the following requirements: We want to use the Laminas\\\\DeveloperTools module in development only. We want to have configuration caching on in production only. laminas/laminas-development-mode provides a concise and conventions-based approach to switching between specifically production and development. The package is installed by default with version 3+ skeletons, and can be installed with existing v2 skeletons using the following: $ composer require laminas/laminas-development-mode The approach it takes is as follows: The user provides production settings in config/application.config.php . The user provides development settings in config/development.config.php.dist to override bootstrap-level settings such as modules and configuration caching, and optionally also in config/autoload/development.local.php.dist (to override application settings). The bootstrap script ( public/index.php ) checks for config/development.config.php , and, if found, merges its configuration with the application configuration prior to configuring the Application instance. When you execute: $ ./vendor/bin/laminas-development-mode enable The .dist files are copied to versions removing the suffix; doing so ensures they will then be used when invoking the application. As such, to accomplish our goals, we will do the following: In config/development.config.php.dist , add Laminas\\\\DeveloperTools to the list of modules: 'modules' => [ 'LaminasDeveloperTools', ], Also in config/development.config.php.dist , we will disable config caching: 'config_cache_enable' => false, In config/application.config.php , we will enable config caching: 'config_cache_enable' => true, Enabling development mode now enables the selected module, and disables configuration caching; disabling development mode enables configuration caching. (Also, either operation clears the configuration cache.) If you require additional environments, you can extend laminas-development-mode to address them using the same workflow. Environment-specific application configuration Sometimes you want to change application configuration to load things such as database adapters, log writers, cache adapters, and more based on the environment. These are typically managed in the service manager, and may be defined by modules. You can override them at the application level via Laminas\\ModuleManager\\Listener\\ConfigListener , by specifying a glob path in the system configuration — the module_listener_options.config_glob_paths key from the previous examples. The default value for this is config/autoload/{{,*.}global,{,*.}local}.php . What this means is that it will look for application configuration files in the config/autoload directory, in the following order: global.php *.global.php local.php *.local.php This allows you to define application-level defaults in \"global\" configuration files, which you would then commit to your version control system, and environment-specific overrides in your \"local\" configuration files, which you would omit from version control. Additional glob patterns for development mode When using laminas-development-mode, as detailed in the previous section, the shipped config/development.config.php.dist file provides an additional glob pattern for specifying development configuration: config/autoload/{,*.}{global,local}-development.php This will match files such as: database.global-development.php database.local-development.php These will only be considered when development mode is enabled! This is a great solution for development, as it allows you to specify alternate configuration that's specific to your development environment without worrying about accidently deploying it. However, what if you have more environments — such as a \"testing\" or \"staging\" environment — and they each have their own specific overrides? To accomplish this, we'll provide an environment variable via our web server configuration, APP_ENV . In Apache, you'd put a directive like the following in either your system-wide apache.conf or httpd.conf, or in the definition for your virtual host; alternately, it can be placed in an .htaccess file. SetEnv \"APP_ENV\" \"development\" For other web servers, consult the web server documentation to determine how to set environment variables. To simplify matters, we'll assume the environment is \"production\" if no environment variable is present. With that in place, We can alter the glob path in the system configuration slightly: 'config_glob_paths' => [ realpath(__DIR__) . sprintf('/autoload/{,*.}{global,%s,local}.php', getenv('APP_ENV') ?: 'production') ], The above will allow you to define an additional set of application configuration files per environment; furthermore, these will be loaded only if that environment is detected! As an example, consider the following tree of configuration files: config/ autoload/ global.php local.php users.development.php users.testing.php users.local.php If $env evaluates to testing , then the following files will be merged, in the following order: global.php users.testing.php local.php users.local.php Note that users.development.php is not loaded — this is because it will not match the glob pattern! Also, because of the order in which they are loaded, you can predict which values will overwrite the others, allowing you to both selectively overwrite as well as debug later. Order of config merging The files under config/autoload/ are merged after your module configuration, detailed in next section. We have detailed it here, however, as setting up the application configuration glob path happens within the system configuration ( config/application.config.php ). Module Configuration One responsibility of modules is to provide their own configuration to the application. Modules have two general mechanisms for doing this. First , modules that either implement Laminas\\ModuleManager\\Feature\\ConfigProviderInterface and/or a getConfig() method can return their configuration. The default, recommended implementation of the getConfig() method is: public function getConfig() { return include __DIR__ . '/config/module.config.php'; } where module.config.php returns a PHP array. From that PHP array you can provide general configuration as well as configuration for all the available Manager classes provided by the ServiceManager. Please refer to the Configuration mapping table to see which configuration key is used for each specific Manager . Second , modules can implement a number of interfaces and/or methods related to specific service manager or plugin manager configuration. You will find an overview of all interfaces and their matching Module Configuration functions inside the Configuration mapping table . Most interfaces are in the Laminas\\ModuleManager\\Feature namespace (some have moved to the individual components), and each is expected to return an array of configuration for a service manager, as denoted in the section on default service configuration . Configuration mapping table Manager name Interface name Module method name Config key name ControllerPluginManager ControllerPluginProviderInterface getControllerPluginConfig() controller_plugins ControllerManager ControllerProviderInterface getControllerConfig() controllers FilterManager FilterProviderInterface getFilterConfig() filters FormElementManager FormElementProviderInterface getFormElementConfig() form_elements HydratorManager HydratorProviderInterface getHydratorConfig() hydrators InputFilterManager InputFilterProviderInterface getInputFilterConfig() input_filters RoutePluginManager RouteProviderInterface getRouteConfig() route_manager SerializerAdapterManager SerializerProviderInterface getSerializerConfig() serializers ServiceLocator ServiceProviderInterface getServiceConfig() service_manager ValidatorManager ValidatorProviderInterface getValidatorConfig() validators ViewHelperManager ViewHelperProviderInterface getViewHelperConfig() view_helpers LogProcessorManager LogProcessorProviderInterface getLogProcessorConfig log_processors LogWriterManager LogWriterProviderInterface getLogWriterConfig log_writers Configuration Priority Considering that you may have service configuration in your module configuration file, what has precedence? The order in which they are merged is: configuration returned by the various service configuration methods in a module class configuration returned by getConfig() In other words, your getConfig() wins over the various service configuration methods. Additionally, and of particular note: the configuration returned from those methods will not be cached. Use cases for service configuration methods Use the various service configuration methods when you need to define closures or instance callbacks for factories, abstract factories, and initializers. This prevents caching problems, and also allows you to write your configuration files in other markup formats. Manipulating merged configuration Occasionally you will want to not just override an application configuration key, but actually remove it. Since merging will not remove keys, how can you handle this? Laminas\\ModuleManager\\Listener\\ConfigListener triggers a special event, Laminas\\ModuleManager\\ModuleEvent::EVENT_MERGE_CONFIG , after merging all configuration, but prior to it being passed to the ServiceManager . By listening to this event, you can inspect the merged configuration and manipulate it. The ConfigListener itself listens to the event at priority 1000 (i.e., very high), which is when the configuration is merged. You can tie into this to modify the merged configuration from your module, via the init() method. namespace Foo; use Laminas\\ModuleManager\\ModuleEvent; use Laminas\\ModuleManager\\ModuleManager; class Module { public function init(ModuleManager $moduleManager) { $events = $moduleManager->getEventManager(); // Registering a listener at default priority, 1, which will trigger // after the ConfigListener merges config. $events->attach(ModuleEvent::EVENT_MERGE_CONFIG, [$this, 'onMergeConfig']); } public function onMergeConfig(ModuleEvent $e) { $configListener = $e->getConfigListener(); $config = $configListener->getMergedConfig(false); // Modify the configuration; here, we'll remove a specific key: if (isset($config['some_key'])) { unset($config['some_key']); } // Pass the changed configuration back to the listener: $configListener->setMergedConfig($config); } } At this point, the merged application configuration will no longer contain the key some_key . Cached configuration and merging If a cached config is used by the ModuleManager , the EVENT_MERGE_CONFIG event will not be triggered. However, typically that means that what is cached will be what was originally manipulated by your listener. Configuration merging workflow To cap off the tutorial, let's review how and when configuration is defined and merged. System configuration Defined in config/application.config.php No merging occurs Allows manipulation programmatically, which allows the ability to: Alter flags based on computed values Alter the configuration glob path based on computed values Configuration is passed to the Application instance, and then the ModuleManager in order to initialize the system. Application configuration The ModuleManager loops through each module class in the order defined in the system configuration Service configuration defined in Module class methods is aggregated Configuration returned by Module::getConfig() is aggregated Files detected from the service configuration config_glob_paths setting are merged, based on the order they resolve in the glob path. ConfigListener triggers EVENT_MERGE_CONFIG : ConfigListener merges configuration Any other event listeners manipulate the configuration Merged configuration is finally passed to the ServiceManager","title":"Advanced Configuration Tricks"},{"location":"advanced-config/#advanced-configuration-tricks","text":"Configuration of laminas-mvc applications happens in several steps: Initial configuration is passed to the Application instance and used to seed the ModuleManager and ServiceManager . In this tutorial, we will call this configuration system configuration . The ModuleManager 's ConfigListener aggregates configuration and merges it while modules are being loaded. In this tutorial, we will call this configuration application configuration . Once configuration is aggregated from all modules, the ConfigListener will also merge application configuration globbed in specified directories (typically config/autoload/ ). Finally, immediately prior to the merged application configuration being passed to the ServiceManager , it is passed to a special EVENT_MERGE_CONFIG event to allow further modification. In this tutorial, we'll look at the exact sequence, and how you can tie into it.","title":"Advanced Configuration Tricks"},{"location":"advanced-config/#system-configuration","text":"To begin module loading, we have to tell the Application instance about the available modules and where they live, optionally provide some information to the default module listeners (e.g., where application configuration lives, and what files to load; whether to cache merged configuration, and where; etc.), and optionally seed the ServiceManager . For purposes of this tutorial we will call this the system configuration . When using the skeleton application, the system configuration is by default in config/application.config.php . The defaults look like this: return [ // Retrieve list of modules used in this application. 'modules' => require __DIR__ . '/modules.config.php', // These are various options for the listeners attached to the ModuleManager 'module_listener_options' => [ // use composer autoloader instead of laminas-loader 'use_laminas_loader' => false, // An array of paths from which to glob configuration files after // modules are loaded. These effectively override configuration // provided by modules themselves. Paths may use GLOB_BRACE notation. 'config_glob_paths' => [ realpath(__DIR__) . '/autoload/{{,*.}global,{,*.}local}.php', ], // Whether or not to enable a configuration cache. // If enabled, the merged configuration will be cached and used in // subsequent requests. 'config_cache_enabled' => true, // The key used to create the configuration cache file name. 'config_cache_key' => 'application.config.cache', // Whether or not to enable a module class map cache. // If enabled, creates a module class map cache which will be used // by in future requests, to reduce the autoloading process. 'module_map_cache_enabled' => true, // The key used to create the class map cache file name. 'module_map_cache_key' => 'application.module.cache', // The path in which to cache merged configuration. 'cache_dir' => 'data/cache/', // Whether or not to enable modules dependency checking. // Enabled by default, prevents usage of modules that depend on other modules // that weren't loaded. // 'check_dependencies' => true, ], // Used to create an own service manager. May contain one or more child arrays. // 'service_listener_options' => [ // [ // 'service_manager' => $stringServiceManagerName, // 'config_key' => $stringConfigKey, // 'interface' => $stringOptionalInterface, // 'method' => $stringRequiredMethodName, // ], // ], // Initial configuration with which to seed the ServiceManager. // Should be compatible with Laminas\\ServiceManager\\Config. // 'service_manager' => [], ]; The system configuration is for the bits and pieces related to the MVC that run before your application is ready. The configuration is usually brief, and quite minimal. Also, system configuration is used immediately , and is not merged with any other configuration — which means, with the exception of the values under the service_manager key, it cannot be overridden by a module. This leads us to our first trick: how do you provide environment-specific system configuration?","title":"System configuration"},{"location":"advanced-config/#module-configuration","text":"One responsibility of modules is to provide their own configuration to the application. Modules have two general mechanisms for doing this. First , modules that either implement Laminas\\ModuleManager\\Feature\\ConfigProviderInterface and/or a getConfig() method can return their configuration. The default, recommended implementation of the getConfig() method is: public function getConfig() { return include __DIR__ . '/config/module.config.php'; } where module.config.php returns a PHP array. From that PHP array you can provide general configuration as well as configuration for all the available Manager classes provided by the ServiceManager. Please refer to the Configuration mapping table to see which configuration key is used for each specific Manager . Second , modules can implement a number of interfaces and/or methods related to specific service manager or plugin manager configuration. You will find an overview of all interfaces and their matching Module Configuration functions inside the Configuration mapping table . Most interfaces are in the Laminas\\ModuleManager\\Feature namespace (some have moved to the individual components), and each is expected to return an array of configuration for a service manager, as denoted in the section on default service configuration .","title":"Module Configuration"},{"location":"advanced-config/#configuration-mapping-table","text":"Manager name Interface name Module method name Config key name ControllerPluginManager ControllerPluginProviderInterface getControllerPluginConfig() controller_plugins ControllerManager ControllerProviderInterface getControllerConfig() controllers FilterManager FilterProviderInterface getFilterConfig() filters FormElementManager FormElementProviderInterface getFormElementConfig() form_elements HydratorManager HydratorProviderInterface getHydratorConfig() hydrators InputFilterManager InputFilterProviderInterface getInputFilterConfig() input_filters RoutePluginManager RouteProviderInterface getRouteConfig() route_manager SerializerAdapterManager SerializerProviderInterface getSerializerConfig() serializers ServiceLocator ServiceProviderInterface getServiceConfig() service_manager ValidatorManager ValidatorProviderInterface getValidatorConfig() validators ViewHelperManager ViewHelperProviderInterface getViewHelperConfig() view_helpers LogProcessorManager LogProcessorProviderInterface getLogProcessorConfig log_processors LogWriterManager LogWriterProviderInterface getLogWriterConfig log_writers","title":"Configuration mapping table"},{"location":"advanced-config/#configuration-priority","text":"Considering that you may have service configuration in your module configuration file, what has precedence? The order in which they are merged is: configuration returned by the various service configuration methods in a module class configuration returned by getConfig() In other words, your getConfig() wins over the various service configuration methods. Additionally, and of particular note: the configuration returned from those methods will not be cached.","title":"Configuration Priority"},{"location":"advanced-config/#manipulating-merged-configuration","text":"Occasionally you will want to not just override an application configuration key, but actually remove it. Since merging will not remove keys, how can you handle this? Laminas\\ModuleManager\\Listener\\ConfigListener triggers a special event, Laminas\\ModuleManager\\ModuleEvent::EVENT_MERGE_CONFIG , after merging all configuration, but prior to it being passed to the ServiceManager . By listening to this event, you can inspect the merged configuration and manipulate it. The ConfigListener itself listens to the event at priority 1000 (i.e., very high), which is when the configuration is merged. You can tie into this to modify the merged configuration from your module, via the init() method. namespace Foo; use Laminas\\ModuleManager\\ModuleEvent; use Laminas\\ModuleManager\\ModuleManager; class Module { public function init(ModuleManager $moduleManager) { $events = $moduleManager->getEventManager(); // Registering a listener at default priority, 1, which will trigger // after the ConfigListener merges config. $events->attach(ModuleEvent::EVENT_MERGE_CONFIG, [$this, 'onMergeConfig']); } public function onMergeConfig(ModuleEvent $e) { $configListener = $e->getConfigListener(); $config = $configListener->getMergedConfig(false); // Modify the configuration; here, we'll remove a specific key: if (isset($config['some_key'])) { unset($config['some_key']); } // Pass the changed configuration back to the listener: $configListener->setMergedConfig($config); } } At this point, the merged application configuration will no longer contain the key some_key .","title":"Manipulating merged configuration"},{"location":"advanced-config/#configuration-merging-workflow","text":"To cap off the tutorial, let's review how and when configuration is defined and merged. System configuration Defined in config/application.config.php No merging occurs Allows manipulation programmatically, which allows the ability to: Alter flags based on computed values Alter the configuration glob path based on computed values Configuration is passed to the Application instance, and then the ModuleManager in order to initialize the system. Application configuration The ModuleManager loops through each module class in the order defined in the system configuration Service configuration defined in Module class methods is aggregated Configuration returned by Module::getConfig() is aggregated Files detected from the service configuration config_glob_paths setting are merged, based on the order they resolve in the glob path. ConfigListener triggers EVENT_MERGE_CONFIG : ConfigListener merges configuration Any other event listeners manipulate the configuration Merged configuration is finally passed to the ServiceManager","title":"Configuration merging workflow"},{"location":"db-adapter/","text":"Setting up a Database Adapter laminas-db provides a general purpose database abstraction layer. At its heart is the Adapter , which abstracts common database operations across the variety of drivers we support. In this guide, we will document how to configure both a single, default adapter as well as multiple adapters (which may be useful in architectures that have a cluster of read-only replicated servers and a single writable server of record). Installing laminas-db First, install laminas-db using Composer: $ composer require laminas/laminas-db Installation and automated Configuration If you are using laminas-component-installer (installed by default with the skeleton application, and optionally for Mezzio applications), you will be prompted to install the package configuration. For laminas-mvc applications, choose either application.config.php or modules.config.php . For Mezzio applications, choose config/config.php . Installation and manual Configuration If you are not using the installer, you will need to manually configure add the component to your application. Configuration for a laminas-mvc-based Application For laminas-mvc applications, update your list of modules in either config/application.config.php or config/modules.config.php to add an entry for 'Laminas\\Db' at the top of the list: // In config/modules.config.php return [ 'Laminas\\Db', // <-- This line 'Laminas\\Form', /* ... */ ]; // OR in config/application.config.php return [ /* ... */ // Retrieve list of modules used in this application. 'modules' => [ 'Laminas\\Db', // <-- This line 'Laminas\\Form', /* ... */ ], /* ... */ ]; Configuration for a mezzio-based Application For Mezzio applications, create a new file, config/autoload/laminas-db.global.php , with the following contents: use Laminas\\Db\\ConfigProvider; return (new ConfigProvider())(); Configuring the default Adapter Within your service factories, you may retrieve the default adapter from your application container using the class name Laminas\\Db\\Adapter\\AdapterInterface : use Laminas\\Db\\Adapter\\AdapterInterface; function ($container) { return new SomeServiceObject($container->get(AdapterInterface::class)); } When installed and configured, the factory associated with AdapterInterface will look for a top-level db key in the configuration, and use it to create an adapter. As an example, the following would connect to a MySQL database using PDO, and the supplied PDO DSN: // In config/autoload/global.php return [ 'db' => [ 'driver' => 'Pdo', 'dsn' => 'mysql:dbname=laminastutorial;host=localhost;charset=utf8', ], ]; More information on adapter configuration can be found in the docs for Laminas\\Db\\Adapter . Configuring named Adapters Sometimes you may need multiple adapters. As an example, if you work with a cluster of databases, one may allow write operations, while another may be read-only. laminas-db provides an abstract factory , Laminas\\Db\\Adapter\\AdapterAbstractServiceFactory , for this purpose. To use it, you will need to create named configuration keys under db.adapters , each with configuration for an adapter: // In config/autoload/global.php return [ 'db' => [ 'adapters' => [ 'Application\\Db\\WriteAdapter' => [ 'driver' => 'Pdo', 'dsn' => 'mysql:dbname=application;host=canonical.example.com;charset=utf8', ], 'Application\\Db\\ReadOnlyAdapter' => [ 'driver' => 'Pdo', 'dsn' => 'mysql:dbname=application;host=replica.example.com;charset=utf8', ], ], ], ]; You retrieve the database adapters using the keys you define, so ensure they are unique to your application, and descriptive of their purpose! Retrieving named Adapters Retrieve named adapters in your service factories just as you would another service: function ($container) { return new SomeServiceObject($container->get('Application\\Db\\ReadOnlyAdapter')); } Using the AdapterAbstractServiceFactory as a Factory Depending on what application container you use, abstract factories may not be available. Alternately, you may want to reduce lookup time when retrieving an adapter from the container (abstract factories are consulted last!). laminas-servicemanager abstract factories work as factories in their own right, and are passed the service name as an argument, allowing them to vary their return value based on requested service name. As such, you can add the following service configuration as well: use Laminas\\Db\\Adapter\\AdapterAbstractServiceFactory; // If using laminas-mvc: // In module/YourModule/config/module.config.php 'service_manager' => [ 'factories' => [ 'Application\\Db\\WriteAdapter' => AdapterAbstractServiceFactory::class, ], ], // If using Mezzio 'dependencies' => [ 'factories' => [ 'Application\\Db\\WriteAdapter' => AdapterAbstractServiceFactory::class, ], ],","title":"Setting Up A Database Adapter"},{"location":"db-adapter/#setting-up-a-database-adapter","text":"laminas-db provides a general purpose database abstraction layer. At its heart is the Adapter , which abstracts common database operations across the variety of drivers we support. In this guide, we will document how to configure both a single, default adapter as well as multiple adapters (which may be useful in architectures that have a cluster of read-only replicated servers and a single writable server of record).","title":"Setting up a Database Adapter"},{"location":"db-adapter/#installing-laminas-db","text":"First, install laminas-db using Composer: $ composer require laminas/laminas-db","title":"Installing laminas-db"},{"location":"db-adapter/#configuring-the-default-adapter","text":"Within your service factories, you may retrieve the default adapter from your application container using the class name Laminas\\Db\\Adapter\\AdapterInterface : use Laminas\\Db\\Adapter\\AdapterInterface; function ($container) { return new SomeServiceObject($container->get(AdapterInterface::class)); } When installed and configured, the factory associated with AdapterInterface will look for a top-level db key in the configuration, and use it to create an adapter. As an example, the following would connect to a MySQL database using PDO, and the supplied PDO DSN: // In config/autoload/global.php return [ 'db' => [ 'driver' => 'Pdo', 'dsn' => 'mysql:dbname=laminastutorial;host=localhost;charset=utf8', ], ]; More information on adapter configuration can be found in the docs for Laminas\\Db\\Adapter .","title":"Configuring the default Adapter"},{"location":"db-adapter/#configuring-named-adapters","text":"Sometimes you may need multiple adapters. As an example, if you work with a cluster of databases, one may allow write operations, while another may be read-only. laminas-db provides an abstract factory , Laminas\\Db\\Adapter\\AdapterAbstractServiceFactory , for this purpose. To use it, you will need to create named configuration keys under db.adapters , each with configuration for an adapter: // In config/autoload/global.php return [ 'db' => [ 'adapters' => [ 'Application\\Db\\WriteAdapter' => [ 'driver' => 'Pdo', 'dsn' => 'mysql:dbname=application;host=canonical.example.com;charset=utf8', ], 'Application\\Db\\ReadOnlyAdapter' => [ 'driver' => 'Pdo', 'dsn' => 'mysql:dbname=application;host=replica.example.com;charset=utf8', ], ], ], ]; You retrieve the database adapters using the keys you define, so ensure they are unique to your application, and descriptive of their purpose!","title":"Configuring named Adapters"},{"location":"event-manager/","text":"Using the EventManager This tutorial explores the features of laminas-eventmanager in-depth. Terminology An Event is a named action. A Listener is any PHP callback that reacts to an event . An EventManager aggregates listeners for one or more named events, and triggers events. Typically, an event will be modeled as an object, containing metadata surrounding when and how it was triggered, including the event name, what object triggered the event (the \"target\"), and what parameters were provided. Events are named , which allows a single listener to branch logic based on the event. Getting started The minimal things necessary to start using events are: An EventManager instance One or more listeners on one or more events A call to trigger() an event A basic example looks something like this: use Laminas\\EventManager\\EventManager; $events = new EventManager(); $events->attach('do', function ($e) { $event = $e->getName(); $params = $e->getParams(); printf( 'Handled event \"%s\", with parameters %s', $event, json_encode($params) ); }); $params = ['foo' => 'bar', 'baz' => 'bat']; $events->trigger('do', null, $params); The above will result in the following: Handled event \"do\", with parameters {\"foo\":\"bar\",\"baz\":\"bat\"} Closures are not required Throughout this tutorial, we use closures as listeners. However, any valid PHP callback can be attached as a listeners: PHP function names, static class methods, object instance methods, function, or closures. We use closures within this post for illustration only. Event instances trigger() is useful as it will create a Laminas\\EventManager\\Event instance for you. You may want to create such an instance manually; for instance, you may want to re-use the same event instance to trigger multiple events, or you may want to use a custom instance. Laminas\\EventManager\\Event , which is the shipped event type and the one used by the EventManager by default has a constructor that accepts the same three arguments passed to trigger() : use Laminas\\EventManager\\Event; $event = new Event('do', null, $params); When you have an instance available, you will use a different EventManager method to trigger the event: triggerEvent() . As an example: $events->triggerEvent($event); Event targets If you were paying attention to the first example, you will have noted the null second argument both when calling trigger() as well as creating an Event instance. Why is it there? Typically, you will compose an EventManager within a class, to allow triggering actions within methods. The middle argument to trigger() is the \"target\", and in the case described, would be the current object instance. This gives event listeners access to the calling object, which can often be useful. use Laminas\\EventManager\\EventManager; use Laminas\\EventManager\\EventManagerAwareInterface; use Laminas\\EventManager\\EventManagerInterface; class Example implements EventManagerAwareInterface { protected $events; public function setEventManager(EventManagerInterface $events) { $events->setIdentifiers([ __CLASS__, get_class($this), ]); $this->events = $events; } public function getEventManager() { if (! $this->events) { $this->setEventManager(new EventManager()); } return $this->events; } public function doIt($foo, $baz) { $params = compact('foo', 'baz'); $this->getEventManager()->trigger(__FUNCTION__, $this, $params); } } $example = new Example(); $example->getEventManager()->attach('doIt', function($e) { $event = $e->getName(); $target = get_class($e->getTarget()); // \"Example\" $params = $e->getParams(); printf( 'Handled event \"%s\" on target \"%s\", with parameters %s', $event, $target, json_encode($params) ); }); $example->doIt('bar', 'bat'); The above is basically the same as the first example. The main difference is that we're now using that middle argument in order to pass the target, the instance of Example , on to the listeners. Our listener is now retrieving that ( $e->getTarget() ), and doing something with it. If you're reading this critically, you should have a new question: What is the call to setIdentifiers() for? Shared managers One aspect that the EventManager implementation provides is an ability to compose a SharedEventManagerInterface implementation. Laminas\\EventManager\\SharedEventManagerInterface describes an object that aggregates listeners for events attached to objects with specific identifiers . It does not trigger events itself. Instead, an EventManager instance that composes a SharedEventManager will query the SharedEventManager for listeners on identifiers it's interested in, and trigger those listeners as well. How does this work, exactly? Consider the following: use Laminas\\EventManager\\SharedEventManager; $sharedEvents = new SharedEventManager(); $sharedEvents->attach('Example', 'do', function ($e) { $event = $e->getName(); $target = get_class($e->getTarget()); // \"Example\" $params = $e->getParams(); printf( 'Handled event \"%s\" on target \"%s\", with parameters %s', $event, $target, json_encode($params) ); }); This looks almost identical to the previous example; the key difference is that there is an additional argument at the start of the list, 'Example' . This code is saying, \"Listen to the 'do' event of the 'Example' target, and, when notified, execute this callback.\" This is where the setIdentifiers() method of EventManager comes into play. The method allows passing an array of strings, defining the names of the context or targets the given instance will be interested in. If an array is given, then any listener on any of the targets given will be notified. So, getting back to our example, let's assume that the above shared listener is registered, and also that the Example class is defined as above. We can then execute the following: $example = new Example(); $example->getEventManager()->setSharedManager($sharedEvents); $example->do('bar', 'bat'); and expect the following to be echo 'd: Handled event \"do\" on target \"Example\", with parameters {\"foo\":\"bar\",\"baz\":\"bat\"} Now, let's say we extended Example as follows: class SubExample extends Example { } One interesting aspect of our setEventManager() method is that we defined it to listen both on __CLASS__ and get_class($this) . This means that calling do() on our SubExample class would also trigger the shared listener! It also means that, if desired, we could attach to specifically SubExample , and listeners attached to only the Example target would not be triggered. Finally, the names used as contexts or targets need not be class names; they can be some name that only has meaning in your application if desired. As an example, you could have a set of classes that respond to \"log\" or \"cache\" — and listeners on these would be notified by any of them. Use class names as identifiers We recommend using class names, interface names, and/or abstract class names for identifiers. This makes determining what events are available easier, as well as finding which listeners might be attaching to those events. Interfaces make a particularly good use case, as they allow attaching to a group of related classes a single operation. At any point, if you do not want to notify shared listeners, pass a null value to setSharedManager() : $events->setSharedManager(null); and they will be ignored. If at any point, you want to enable them again, pass the SharedEventManager instance: $events->setSharedManager($sharedEvents); Wildcards So far, with both a normal EventManager instance and with the SharedEventManager instance, we've seen the usage of string event and string target names to which we want to attach. What if you want to attach a listener to multiple events or targets? The answer is to supply an array of events or targets, or a wildcard, * . Consider the following examples: // Multiple named events: $events->attach( ['foo', 'bar', 'baz'], // events $listener ); // All events via wildcard: $events->attach( '*', // all events $listener ); // Multiple named targets: $sharedEvents->attach( ['Foo', 'Bar', 'Baz'], // targets 'doSomething', // named event $listener ); // All targets via wildcard $sharedEvents->attach( '*', // all targets 'doSomething', // named event $listener ); // Mix and match: multiple named events on multiple named targets: $sharedEvents->attach( ['Foo', 'Bar', 'Baz'], // targets ['foo', 'bar', 'baz'], // events $listener ); // Mix and match: all events on multiple named targets: $sharedEvents->attach( ['Foo', 'Bar', 'Baz'], // targets '*', // events $listener ); // Mix and match: multiple named events on all targets: $sharedEvents->attach( '*', // targets ['foo', 'bar', 'baz'], // events $listener ); // Mix and match: all events on all targets: $sharedEvents->attach( '*', // targets '*', // events $listener ); The ability to specify multiple targets and/or events when attaching can slim down your code immensely. Wildcards can cause problems Wildcards, while they simplify listener attachment, can cause some problems. First, the listener must either be able to accept any incoming event, or it must have logic to branch based on the type of event, the target, or the event parameters. This can quickly become difficult to manage. Additionally, there are performance considerations. Each time an event is triggered, it loops through all attached listeners; if your listener cannot actually handle the event, but was attached as a wildcard listener, you're introducing needless cycles both in aggregating the listeners to trigger, and by handling the event itself. We recommend being specific about what you attach a listener to, in order to prevent these problems. Listener aggregates Another approach to listening to multiple events is via a concept of listener aggregates, represented by Laminas\\EventManager\\ListenerAggregateInterface . Via this approach, a single class can listen to multiple events, attaching one or more instance methods as listeners. This interface defines two methods, attach(EventManagerInterface $events) and detach(EventManagerInterface $events) . You pass an EventManager instance to one and/or the other, and then it's up to the implementing class to determine what to do. The trait Laminas\\EventManager\\ListenerAggregateTrait defines a $listeners property and common logic for detaching an aggregate's listeners. We'll use that to demonstrate creating an aggregate logging listener: use Laminas\\EventManager\\EventInterface; use Laminas\\EventManager\\EventManagerInterface; use Laminas\\EventManager\\ListenerAggregateInterface; use Laminas\\EventManager\\ListenerAggregateTrait; use Laminas\\Log\\Logger; class LogEvents implements ListenerAggregateInterface { use ListenerAggregateTrait; private $log; public function __construct(Logger $log) { $this->log = $log; } public function attach(EventManagerInterface $events) { $this->listeners[] = $events->attach('do', [$this, 'log']); $this->listeners[] = $events->attach('doSomethingElse', [$this, 'log']); } public function log(EventInterface $e) { $event = $e->getName(); $params = $e->getParams(); $this->log->info(sprintf('%s: %s', $event, json_encode($params))); } } Attach the aggregate by passing it an event manager instance: $logListener = new LogEvents($logger); $logListener->attach($events); Any events the aggregate attaches to will then be notified when triggered. Why bother? For a couple of reasons: Aggregates allow you to have stateful listeners. The above example demonstrates this via the composition of the logger; another example would be tracking configuration options. Aggregates make detaching listeners easier, as you can detach all listeners a class defines at once. Introspecting results Sometimes you'll want to know what your listeners returned. One thing to remember is that you may have multiple listeners on the same event; the interface for results must be consistent regardless of the number of listeners. The EventManager implementation by default returns a Laminas\\EventManager\\ResponseCollection instance. This class extends PHP's SplStack , allowing you to loop through responses in reverse order (since the last one executed is likely the one you're most interested in). It also implements the following methods: first() will retrieve the first result received last() will retrieve the last result received contains($value) allows you to test all values to see if a given one was received, and returns a boolean true if found, and false if not. stopped() will return a boolean value indicating whether or not a short-circuit occured; more on this in the next section. Typically, you should not worry about the return values from events, as the object triggering the event shouldn't really have much insight into what listeners are attached. However, sometimes you may want to short-circuit execution if interesting results are obtained. (laminas-mvc uses this feature to check for listeners returning responses, which are then returned immediately.) Short-circuiting listener execution You may want to short-circuit execution if a particular result is obtained, or if a listener determines that something is wrong, or that it can return something quicker than the target. As examples, one rationale for adding an EventManager is as a caching mechanism. You can trigger one event early in the method, returning if a cache is found, and trigger another event late in the method, seeding the cache. The EventManager component offers two ways to handle this, depending on whether you have an event instance already, or want the event manager to create one for you. triggerEventUntil(callable $callback, EventInterface $event) triggerUntil(callable $callback, $eventName, $target = null, $argv = []) In each case, $callback will be any PHP callable, and will be passed the return value from the most recently executed listener. The $callback must then return a boolean value indicating whether or not to halt execution; boolean true indicates execution should halt. Your consuming code can then check to see if execution was short-circuited by using the stopped() method of the returned ResponseCollection . Here's an example: public function someExpensiveCall($criteria1, $criteria2) { $params = compact('criteria1', 'criteria2'); $results = $this->getEventManager()->triggerUntil( function ($r) { return ($r instanceof SomeResultClass); }, __FUNCTION__, $this, $params ); if ($results->stopped()) { return $results->last(); } // ... do some work ... } With this paradigm, we know that the likely reason of execution halting is due to the last result meeting the test callback criteria; as such, we return that last result. The other way to halt execution is within a listener, acting on the Event object it receives. In this case, the listener calls stopPropagation(true) , and the EventManager will then return without notifying any additional listeners. $events->attach('do', function ($e) { $e->stopPropagation(); return new SomeResultClass(); }); This, of course, raises some ambiguity when using the trigger paradigm, as you can no longer be certain that the last result meets the criteria it's searching on. As such, we recommend that you standardize on one approach or the other. Keeping it in order On occasion, you may be concerned about the order in which listeners execute. As an example, you may want to do any logging early, to ensure that if short-circuiting occurs, you've logged; if implementing a cache, you may want to return early if a cache hit is found, and execute late when saving to a cache. Each of EventManager::attach() and SharedEventManager::attach() accept one additional argument, a priority . By default, if this is omitted, listeners get a priority of 1, and are executed in the order in which they are attached. However, if you provide a priority value, you can influence order of execution. Higher priority values execute earlier . Lower (negative) priority values execute later . To borrow an example from earlier: $priority = 100; $events->attach('Example', 'do', function($e) { $event = $e->getName(); $target = get_class($e->getTarget()); // \"Example\" $params = $e->getParams(); printf( 'Handled event \"%s\" on target \"%s\", with parameters %s', $event, $target, json_encode($params) ); }, $priority); This would execute with high priority, meaning it would execute early. If we changed $priority to -100 , it would execute with low priority, executing late. While you can't necessarily know all the listeners attached, chances are you can make adequate guesses when necessary in order to set appropriate priority values. We advise avoiding setting a priority value unless absolutely necessary. Custom event objects As noted earlier, an Event instance is created when you call either trigger() or triggerUntil() , using the arguments passed to each; additionally, you can manually create an instance. Why would you do so, however? One thing that looks like a code smell is when you have code like this: $routeMatch = $e->getParam('route-match', false); if (! $routeMatch) { // Oh noes! we cannot do our work! whatever shall we do?!?!?! } The problems with this are several: Relying on string keys for event parameters is going to very quickly run into problems — typos when setting or retrieving the argument can lead to hard to debug situations. Second, we now have a documentation issue; how do we document expected arguments? how do we document what we're shoving into the event? Third, as a side effect, we can't use IDE or editor hinting support — string keys give these tools nothing to work with. Similarly, consider how you might represent a computational result of a method when triggering an event. As an example: // in the method: $params['__RESULT__'] = $computedResult; $events->trigger(__FUNCTION__ . '.post', $this, $params); // in the listener: $result = $e->getParam('__RESULT__'); if (! $result) { // Oh noes! we cannot do our work! whatever shall we do?!?!?! } Sure, that key may be unique, but it suffers from a lot of the same issues. The solution is to create custom event types . As an example, laminas-mvc defines a custom MvcEvent ; this event composes the application instance, the router, the route match, the request and response instances, the view model, and also a result. We end up with code like this in our listeners: $response = $e->getResponse(); $result = $e->getResult(); if (is_string($result)) { $content = $view->render('layout.phtml', ['content' => $result]); $response->setContent($content); } As noted earlier, if using a custom event, you will need to use the triggerEvent() and/or triggerEventUntil() methods instead of the normal trigger() and triggerUntil() . Putting it together: Implementing a caching system In previous sections, I indicated that short-circuiting is a way to potentially implement a caching solution. Let's create a full example. First, let's define a method that could use caching. You'll note that in most of the examples, we use __FUNCTION__ as the event name; this is a good practice, as it makes code completion simpler, maps event names directly to the method triggering the event, and typically keeps the event names unique. However, in the case of a caching example, this might lead to identical events being triggered, as we will be triggering multiple events from the same method. In such cases, we recommend adding a semantic suffix: __FUNCTION__ . 'pre' , __FUNCTION__ . 'post' , __FUNCTION__ . 'error' , etc. We will use this convention in the upcoming example. Additionally, you'll notice that the $params passed to the event are usually the parameters passed to the method. This is because those are often not stored in the object, and also to ensure the listeners have the exact same context as the calling method. In the upcoming example, however, we will be triggering an event using the results of execution , and will need a way of representing that. We have two possibilities: Use a \"magic\" key, such as __RESULT__ , and add that to our parameter list. Create a custom event that allows injecting the result. The latter is a more correct approach, as it introduces type safety, and prevents typographical errors. Let's create that event now: use Laminas\\EventManager\\Event; class ExpensiveCallEvent extends Event { private $criteria1; private $criteria2; private $result; public function __construct($target, $criteria1, $criteria2) { // Set the default event name: $this->setName('someExpensiveCall'); $this->setTarget($target); $this->criteria1 = $criteria1; $this->criteria2 = $criteria2; } public function getCriteria1() { return $this->criteria1; } public function getCriteria2() { return $this->criteria2; } public function setResult(SomeResultClass $result) { $this->result = $result; } public function getResult() { return $this->result; } } We can now create an instance of this within our class method, and use it to trigger listeners: public function someExpensiveCall($criteria1, $criteria2) { $event = new ExpensiveCallEvent($this, $criteria1, $criteria2); $event->setName(__FUNCTION__ . '.pre'); $results = $this->getEventManager()->triggerEventUntil( function ($r) { return ($r instanceof SomeResultClass); }, $event ); if ($results->stopped()) { return $results->last(); } // ... do some work ... $event->setName(__FUNCTION__ . '.post'); $event->setResult($calculatedResult); $this->events()->triggerEvent($event); return $calculatedResult; } Before triggering either event, we set the event name in the instance to ensure the correct listeners are notified. The first trigger checks to see if we get a result class returned, and, if so, we return it. The second trigger is a fire-and-forget; we don't care what is returned, and only want to notify listeners of the result. To provide some caching listeners, we'll need to attach to each of the someExpensiveCall.pre and someExpensiveCall.post events. In the former case, if a cache hit is detected, we return it. In the latter, we store the value in the cache. The following listeners attach to the .pre and .post events triggered by the above method. We'll assume $cache is defined, and is a laminas-cache storage adapter. The first listener will return a result when a cache hit occurs, and the second will store a result in the cache if one is provided. $events->attach('someExpensiveCall.pre', function (ExpensiveCallEvent $e) use ($cache) { $key = md5(json_encode([ 'criteria1' => $e->getCriteria1(), 'criteria2' => $e->getCriteria2(), ])); $result = $cache->getItem($key, $success); if (! $success) { return; } $result = new SomeResultClass($result); $e->setResult($result); return $result; }); $events->attach('someExpensiveCall.post', function (ExpensiveCallEvent $e) use ($cache) { $result = $e->getResult(); if (! $result instanceof SomeResultClass) { return; } $key = md5(json_encode([ 'criteria1' => $e->getCriteria1(), 'criteria2' => $e->getCriteria2(), ])); $cache->setItem($key, $result); }); ListenerAggregates allow stateful listeners The above could have been done within a ListenerAggregate , which would have allowed keeping the $cache instance as a stateful property, instead of importing it into closures. Another approach would be to move the body of the method to a listener as well, which would allow using the priority system in order to implement caching. If we did that, we'd modify the ExpensiveCallEvent to omit the .pre suffix on the default event name, and then implement the class that triggers the event as follows: public function setEventManager(EventManagerInterface $events) { $this->events = $events; $events->setIdentifiers([__CLASS__, get_class($this)]); $events->attach('someExpensiveCall', [$this, 'doSomeExpensiveCall']); } public function someExpensiveCall($criteria1, $criteria2) { $event = new ExpensiveCallEvent($this, $criteria1, $criteria2); $this->getEventManager()->triggerEventUntil( function ($r) { return $r instanceof SomeResultClass; }, $event ); return $event->getResult(); } public function doSomeExpensiveCall(ExpensiveCallEvent $e) { // ... do some work ... $e->setResult($calculatedResult); } Note that the doSomeExpensiveCall method does not return the result directly; this allows what was originally our .post listener to trigger. You'll also notice that we return the result from the Event instance; this is why the first listener passes the result into the event, as we can then use it from the calling method! We will need to change how we attach the listeners; they will now attach directly to the someExpensiveCall event, without any suffixes; they will also now use priority in order to intercept before and after the default listener registered by the class. The first listener will listen at priority 100 to ensure it executes before the default listener, and the second will listen at priority -100 to ensure it triggers after we already have a result: $events->attach('someExpensiveCall', function (ExpensiveCallEvent $e) use ($cache) { // listener for checking against the cache }, 100); $events->attach('someExpensiveCall', function (ExpensiveCallEvent $e) use ($cache) { // listener for injecting into the cache }, -100); The workflow ends up being approximately the same, but eliminates the conditional logic from the original version, and reduces the number of events to one. The alternative, of course, is to have the object compose a cache instance and use it directly. However, the event-based approach allows: Re-using the listeners with multiple events. Attaching multiple listeners to the event; as an example, to implement argument validation, or to add logging. The point is that if you design your object with events in mind, you can add flexibility and extension points without requiring decoration or class extension. Conclusion laminas-eventmanager is a powerful component. It drives the workflow of laminas-mvc, and is used in many Laminas components to provide hook points for developers to manipulate the workflow. It can be a powerful tool in your development toolbox.","title":"Using the EventManager"},{"location":"event-manager/#using-the-eventmanager","text":"This tutorial explores the features of laminas-eventmanager in-depth.","title":"Using the EventManager"},{"location":"event-manager/#terminology","text":"An Event is a named action. A Listener is any PHP callback that reacts to an event . An EventManager aggregates listeners for one or more named events, and triggers events. Typically, an event will be modeled as an object, containing metadata surrounding when and how it was triggered, including the event name, what object triggered the event (the \"target\"), and what parameters were provided. Events are named , which allows a single listener to branch logic based on the event.","title":"Terminology"},{"location":"event-manager/#getting-started","text":"The minimal things necessary to start using events are: An EventManager instance One or more listeners on one or more events A call to trigger() an event A basic example looks something like this: use Laminas\\EventManager\\EventManager; $events = new EventManager(); $events->attach('do', function ($e) { $event = $e->getName(); $params = $e->getParams(); printf( 'Handled event \"%s\", with parameters %s', $event, json_encode($params) ); }); $params = ['foo' => 'bar', 'baz' => 'bat']; $events->trigger('do', null, $params); The above will result in the following: Handled event \"do\", with parameters {\"foo\":\"bar\",\"baz\":\"bat\"}","title":"Getting started"},{"location":"event-manager/#shared-managers","text":"One aspect that the EventManager implementation provides is an ability to compose a SharedEventManagerInterface implementation. Laminas\\EventManager\\SharedEventManagerInterface describes an object that aggregates listeners for events attached to objects with specific identifiers . It does not trigger events itself. Instead, an EventManager instance that composes a SharedEventManager will query the SharedEventManager for listeners on identifiers it's interested in, and trigger those listeners as well. How does this work, exactly? Consider the following: use Laminas\\EventManager\\SharedEventManager; $sharedEvents = new SharedEventManager(); $sharedEvents->attach('Example', 'do', function ($e) { $event = $e->getName(); $target = get_class($e->getTarget()); // \"Example\" $params = $e->getParams(); printf( 'Handled event \"%s\" on target \"%s\", with parameters %s', $event, $target, json_encode($params) ); }); This looks almost identical to the previous example; the key difference is that there is an additional argument at the start of the list, 'Example' . This code is saying, \"Listen to the 'do' event of the 'Example' target, and, when notified, execute this callback.\" This is where the setIdentifiers() method of EventManager comes into play. The method allows passing an array of strings, defining the names of the context or targets the given instance will be interested in. If an array is given, then any listener on any of the targets given will be notified. So, getting back to our example, let's assume that the above shared listener is registered, and also that the Example class is defined as above. We can then execute the following: $example = new Example(); $example->getEventManager()->setSharedManager($sharedEvents); $example->do('bar', 'bat'); and expect the following to be echo 'd: Handled event \"do\" on target \"Example\", with parameters {\"foo\":\"bar\",\"baz\":\"bat\"} Now, let's say we extended Example as follows: class SubExample extends Example { } One interesting aspect of our setEventManager() method is that we defined it to listen both on __CLASS__ and get_class($this) . This means that calling do() on our SubExample class would also trigger the shared listener! It also means that, if desired, we could attach to specifically SubExample , and listeners attached to only the Example target would not be triggered. Finally, the names used as contexts or targets need not be class names; they can be some name that only has meaning in your application if desired. As an example, you could have a set of classes that respond to \"log\" or \"cache\" — and listeners on these would be notified by any of them.","title":"Shared managers"},{"location":"event-manager/#introspecting-results","text":"Sometimes you'll want to know what your listeners returned. One thing to remember is that you may have multiple listeners on the same event; the interface for results must be consistent regardless of the number of listeners. The EventManager implementation by default returns a Laminas\\EventManager\\ResponseCollection instance. This class extends PHP's SplStack , allowing you to loop through responses in reverse order (since the last one executed is likely the one you're most interested in). It also implements the following methods: first() will retrieve the first result received last() will retrieve the last result received contains($value) allows you to test all values to see if a given one was received, and returns a boolean true if found, and false if not. stopped() will return a boolean value indicating whether or not a short-circuit occured; more on this in the next section. Typically, you should not worry about the return values from events, as the object triggering the event shouldn't really have much insight into what listeners are attached. However, sometimes you may want to short-circuit execution if interesting results are obtained. (laminas-mvc uses this feature to check for listeners returning responses, which are then returned immediately.)","title":"Introspecting results"},{"location":"event-manager/#keeping-it-in-order","text":"On occasion, you may be concerned about the order in which listeners execute. As an example, you may want to do any logging early, to ensure that if short-circuiting occurs, you've logged; if implementing a cache, you may want to return early if a cache hit is found, and execute late when saving to a cache. Each of EventManager::attach() and SharedEventManager::attach() accept one additional argument, a priority . By default, if this is omitted, listeners get a priority of 1, and are executed in the order in which they are attached. However, if you provide a priority value, you can influence order of execution. Higher priority values execute earlier . Lower (negative) priority values execute later . To borrow an example from earlier: $priority = 100; $events->attach('Example', 'do', function($e) { $event = $e->getName(); $target = get_class($e->getTarget()); // \"Example\" $params = $e->getParams(); printf( 'Handled event \"%s\" on target \"%s\", with parameters %s', $event, $target, json_encode($params) ); }, $priority); This would execute with high priority, meaning it would execute early. If we changed $priority to -100 , it would execute with low priority, executing late. While you can't necessarily know all the listeners attached, chances are you can make adequate guesses when necessary in order to set appropriate priority values. We advise avoiding setting a priority value unless absolutely necessary.","title":"Keeping it in order"},{"location":"event-manager/#custom-event-objects","text":"As noted earlier, an Event instance is created when you call either trigger() or triggerUntil() , using the arguments passed to each; additionally, you can manually create an instance. Why would you do so, however? One thing that looks like a code smell is when you have code like this: $routeMatch = $e->getParam('route-match', false); if (! $routeMatch) { // Oh noes! we cannot do our work! whatever shall we do?!?!?! } The problems with this are several: Relying on string keys for event parameters is going to very quickly run into problems — typos when setting or retrieving the argument can lead to hard to debug situations. Second, we now have a documentation issue; how do we document expected arguments? how do we document what we're shoving into the event? Third, as a side effect, we can't use IDE or editor hinting support — string keys give these tools nothing to work with. Similarly, consider how you might represent a computational result of a method when triggering an event. As an example: // in the method: $params['__RESULT__'] = $computedResult; $events->trigger(__FUNCTION__ . '.post', $this, $params); // in the listener: $result = $e->getParam('__RESULT__'); if (! $result) { // Oh noes! we cannot do our work! whatever shall we do?!?!?! } Sure, that key may be unique, but it suffers from a lot of the same issues. The solution is to create custom event types . As an example, laminas-mvc defines a custom MvcEvent ; this event composes the application instance, the router, the route match, the request and response instances, the view model, and also a result. We end up with code like this in our listeners: $response = $e->getResponse(); $result = $e->getResult(); if (is_string($result)) { $content = $view->render('layout.phtml', ['content' => $result]); $response->setContent($content); } As noted earlier, if using a custom event, you will need to use the triggerEvent() and/or triggerEventUntil() methods instead of the normal trigger() and triggerUntil() .","title":"Custom event objects"},{"location":"event-manager/#putting-it-together-implementing-a-caching-system","text":"In previous sections, I indicated that short-circuiting is a way to potentially implement a caching solution. Let's create a full example. First, let's define a method that could use caching. You'll note that in most of the examples, we use __FUNCTION__ as the event name; this is a good practice, as it makes code completion simpler, maps event names directly to the method triggering the event, and typically keeps the event names unique. However, in the case of a caching example, this might lead to identical events being triggered, as we will be triggering multiple events from the same method. In such cases, we recommend adding a semantic suffix: __FUNCTION__ . 'pre' , __FUNCTION__ . 'post' , __FUNCTION__ . 'error' , etc. We will use this convention in the upcoming example. Additionally, you'll notice that the $params passed to the event are usually the parameters passed to the method. This is because those are often not stored in the object, and also to ensure the listeners have the exact same context as the calling method. In the upcoming example, however, we will be triggering an event using the results of execution , and will need a way of representing that. We have two possibilities: Use a \"magic\" key, such as __RESULT__ , and add that to our parameter list. Create a custom event that allows injecting the result. The latter is a more correct approach, as it introduces type safety, and prevents typographical errors. Let's create that event now: use Laminas\\EventManager\\Event; class ExpensiveCallEvent extends Event { private $criteria1; private $criteria2; private $result; public function __construct($target, $criteria1, $criteria2) { // Set the default event name: $this->setName('someExpensiveCall'); $this->setTarget($target); $this->criteria1 = $criteria1; $this->criteria2 = $criteria2; } public function getCriteria1() { return $this->criteria1; } public function getCriteria2() { return $this->criteria2; } public function setResult(SomeResultClass $result) { $this->result = $result; } public function getResult() { return $this->result; } } We can now create an instance of this within our class method, and use it to trigger listeners: public function someExpensiveCall($criteria1, $criteria2) { $event = new ExpensiveCallEvent($this, $criteria1, $criteria2); $event->setName(__FUNCTION__ . '.pre'); $results = $this->getEventManager()->triggerEventUntil( function ($r) { return ($r instanceof SomeResultClass); }, $event ); if ($results->stopped()) { return $results->last(); } // ... do some work ... $event->setName(__FUNCTION__ . '.post'); $event->setResult($calculatedResult); $this->events()->triggerEvent($event); return $calculatedResult; } Before triggering either event, we set the event name in the instance to ensure the correct listeners are notified. The first trigger checks to see if we get a result class returned, and, if so, we return it. The second trigger is a fire-and-forget; we don't care what is returned, and only want to notify listeners of the result. To provide some caching listeners, we'll need to attach to each of the someExpensiveCall.pre and someExpensiveCall.post events. In the former case, if a cache hit is detected, we return it. In the latter, we store the value in the cache. The following listeners attach to the .pre and .post events triggered by the above method. We'll assume $cache is defined, and is a laminas-cache storage adapter. The first listener will return a result when a cache hit occurs, and the second will store a result in the cache if one is provided. $events->attach('someExpensiveCall.pre', function (ExpensiveCallEvent $e) use ($cache) { $key = md5(json_encode([ 'criteria1' => $e->getCriteria1(), 'criteria2' => $e->getCriteria2(), ])); $result = $cache->getItem($key, $success); if (! $success) { return; } $result = new SomeResultClass($result); $e->setResult($result); return $result; }); $events->attach('someExpensiveCall.post', function (ExpensiveCallEvent $e) use ($cache) { $result = $e->getResult(); if (! $result instanceof SomeResultClass) { return; } $key = md5(json_encode([ 'criteria1' => $e->getCriteria1(), 'criteria2' => $e->getCriteria2(), ])); $cache->setItem($key, $result); });","title":"Putting it together: Implementing a caching system"},{"location":"event-manager/#conclusion","text":"laminas-eventmanager is a powerful component. It drives the workflow of laminas-mvc, and is used in many Laminas components to provide hook points for developers to manipulate the workflow. It can be a powerful tool in your development toolbox.","title":"Conclusion"},{"location":"i18n/","text":"Internationalization If you are building a site for an international audience, you will likely want to provide localized versions of common strings on your website, including menu items, form labels, button labels, and more. Additionally, some websites require that route path segments be localized. Laminas provides internationalization (i18n) tools via the laminas-i18n component, and integration with laminas-mvc via the laminas-mvc-i18n component. Installation Install laminas-mvc-i18n via Composer: $ composer require laminas/laminas-mvc-i18n Assuming you are using laminas-component-installer (which is installed by default with the skeleton application), this will prompt you to install the component as a module in your application; make sure you select either application.config.php or modules.config.php for the location. Once installed, this component exposes several services, including: MvcTranslator , which implements the laminas-i18n TranslatorInterface , as well as the version specific to laminas-validator, providing an instance that can be used for all application contexts. A \"translator aware\" router. By default, until you configure translations, installation has no practical effect. So the next step is creating translations to use in your application. Creating translations The laminas-i18n Translation chapter covers the details of adding translations to your application. You can use PHP arrays, INI files, or the popular gettext package (which allows you to use industry standard tools such as poedit to edit translations). Once you have some translation sources, you will need to put them somewhere your application can access them. Options include: In a subdirectory of the module that defines and/or consumes the translation strings. As an example, module/Application/language/ . In your application data directory; e.g., data/language/ . Make sure you follow the guidelines from the laminas-i18n documentation with regards to naming your files. Additionally, you may want to further segregate any such directory by text domain. From here, you need to configure the translator to use your files. This requires adding configuration in either your module or application configuration files that provides: The default locale if none is provided. Translation file patterns, which include: the translation source type (e.g., gettext , phparray , ini ) the base directory in which they are stored a file pattern for identifying the files to use As examples: // in a module's module.config.php: 'translator' => [ 'locale' => 'en_US', 'translation_file_patterns' => [ [ 'type' => 'gettext', 'base_dir' => __DIR__ . '/../language', 'pattern' => '%s.mo', ], ], ], // or in config/autoload/global.php: 'translator' => [ 'locale' => 'en_US', 'translation_file_patterns' => [ [ 'type' => 'gettext', 'base_dir' => getcwd() . '/data/language', 'pattern' => '%s.mo', ], ], ], Once the above configuration is in place, the translator will be active in your application, allowing you to use it. Translating strings in templates Once you have defined some strings to translate, and configured the application to use them, you can translate them in your application. The translate() and translatePlural() view helpers allow you to provide translations within your view scripts. As an example, you might want to translate the string \"All rights reserved\" in your footer. You could do the following in your layout script: <p>© 2016 by Examples Ltd. <?= $this->translate('All rights reserved') ?></p> Translating route segments In order to enable route translation, you need to do two things: Tell the router to use the translation-aware route class. Optionally, tell it which text domain to use (if not using the default text domain). To tell the application to use the translation-aware route class, we can update our routing configuration. Underneath the top-level router key, we'll add the router_class key: // In a module.config.php file, or config/autoload/global.php: 'router' => [ 'router_class' => Laminas\\Mvc\\I18n\\Router\\TranslatorAwareTreeRouteStack::class, 'routes' => [ /* ... */ ], ], If you want to use an alternate text domain, you can do so via the translator_text_domain key, also directly below the router key: // In a module.config.php file, or config/autoload/global.php: 'router' => [ 'router_class' => Laminas\\Mvc\\I18n\\Router\\TranslatorAwareTreeRouteStack::class, 'translator_text_domain' => 'router', 'routes' => [ /* ... */ ], ], Now that the router is aware of translations, we can use translatable strings in our routes. To do so, surround the string capable of translation with braces ( {} ). As an example: 'route' => '/{login}', specifies the word \"login\" as translatable.","title":"Internationalization"},{"location":"i18n/#internationalization","text":"If you are building a site for an international audience, you will likely want to provide localized versions of common strings on your website, including menu items, form labels, button labels, and more. Additionally, some websites require that route path segments be localized. Laminas provides internationalization (i18n) tools via the laminas-i18n component, and integration with laminas-mvc via the laminas-mvc-i18n component.","title":"Internationalization"},{"location":"i18n/#installation","text":"Install laminas-mvc-i18n via Composer: $ composer require laminas/laminas-mvc-i18n Assuming you are using laminas-component-installer (which is installed by default with the skeleton application), this will prompt you to install the component as a module in your application; make sure you select either application.config.php or modules.config.php for the location. Once installed, this component exposes several services, including: MvcTranslator , which implements the laminas-i18n TranslatorInterface , as well as the version specific to laminas-validator, providing an instance that can be used for all application contexts. A \"translator aware\" router. By default, until you configure translations, installation has no practical effect. So the next step is creating translations to use in your application.","title":"Installation"},{"location":"i18n/#creating-translations","text":"The laminas-i18n Translation chapter covers the details of adding translations to your application. You can use PHP arrays, INI files, or the popular gettext package (which allows you to use industry standard tools such as poedit to edit translations). Once you have some translation sources, you will need to put them somewhere your application can access them. Options include: In a subdirectory of the module that defines and/or consumes the translation strings. As an example, module/Application/language/ . In your application data directory; e.g., data/language/ . Make sure you follow the guidelines from the laminas-i18n documentation with regards to naming your files. Additionally, you may want to further segregate any such directory by text domain. From here, you need to configure the translator to use your files. This requires adding configuration in either your module or application configuration files that provides: The default locale if none is provided. Translation file patterns, which include: the translation source type (e.g., gettext , phparray , ini ) the base directory in which they are stored a file pattern for identifying the files to use As examples: // in a module's module.config.php: 'translator' => [ 'locale' => 'en_US', 'translation_file_patterns' => [ [ 'type' => 'gettext', 'base_dir' => __DIR__ . '/../language', 'pattern' => '%s.mo', ], ], ], // or in config/autoload/global.php: 'translator' => [ 'locale' => 'en_US', 'translation_file_patterns' => [ [ 'type' => 'gettext', 'base_dir' => getcwd() . '/data/language', 'pattern' => '%s.mo', ], ], ], Once the above configuration is in place, the translator will be active in your application, allowing you to use it.","title":"Creating translations"},{"location":"i18n/#translating-strings-in-templates","text":"Once you have defined some strings to translate, and configured the application to use them, you can translate them in your application. The translate() and translatePlural() view helpers allow you to provide translations within your view scripts. As an example, you might want to translate the string \"All rights reserved\" in your footer. You could do the following in your layout script: <p>© 2016 by Examples Ltd. <?= $this->translate('All rights reserved') ?></p>","title":"Translating strings in templates"},{"location":"i18n/#translating-route-segments","text":"In order to enable route translation, you need to do two things: Tell the router to use the translation-aware route class. Optionally, tell it which text domain to use (if not using the default text domain). To tell the application to use the translation-aware route class, we can update our routing configuration. Underneath the top-level router key, we'll add the router_class key: // In a module.config.php file, or config/autoload/global.php: 'router' => [ 'router_class' => Laminas\\Mvc\\I18n\\Router\\TranslatorAwareTreeRouteStack::class, 'routes' => [ /* ... */ ], ], If you want to use an alternate text domain, you can do so via the translator_text_domain key, also directly below the router key: // In a module.config.php file, or config/autoload/global.php: 'router' => [ 'router_class' => Laminas\\Mvc\\I18n\\Router\\TranslatorAwareTreeRouteStack::class, 'translator_text_domain' => 'router', 'routes' => [ /* ... */ ], ], Now that the router is aware of translations, we can use translatable strings in our routes. To do so, surround the string capable of translation with braces ( {} ). As an example: 'route' => '/{login}', specifies the word \"login\" as translatable.","title":"Translating route segments"},{"location":"migration-from-zendframework/","text":"document.addEventListener(\"DOMContentLoaded\", function (event) { window.location.pathname = '/migration/'; });","title":"_migration-from-zendframework"},{"location":"navigation/","text":"Using laminas-navigation in your Album Module In this tutorial we will use the laminas-navigation component to add a navigation menu to the black bar at the top of the screen, and add breadcrumbs above the main site content. Preparation In a real world application, the album browser would be only a portion of a working website. Usually the user would land on a homepage first, and be able to view albums by using a standard navigation menu. So that we have a site that is more realistic than just the albums feature, lets make the standard skeleton welcome page our homepage, with the /album route still showing our album module. In order to make this change, we need to undo some work we did earlier. Currently, navigating to the root of your app ( / ) routes you to the AlbumController 's default action. Let's undo this route change so we have two discrete entry points to the app, a home page, and an albums area. // In module/Application/config/module.config.php: 'home' => [ 'type' => Literal::class, 'options' => [ 'route' => '/', 'defaults' => [ 'controller' => Controller\\IndexController::class, // <-- change back here 'action' => 'index', ], ], ], (You can also now remove the import for the Album\\Controller\\AlbumController class.) This change means that if you go to the home page of your application ( http://localhost:8080/ or http://laminas-mvc-tutorial.localhost/ ), you see the default skeleton application introduction. Your list of albums is still available at the /album route. Setting Up laminas-navigation First, we need to install laminas-navigation. From your root directory, execute the following: $ composer require laminas/laminas-navigation Assuming you followed the Getting Started tutorial , you will be prompted by the laminas-component-installer plugin to inject Laminas\\Navigation ; be sure to select the option for either config/application.config.php or config/modules.config.php ; since it is the only package you are installing, you can answer either \"y\" or \"n\" to the \"Remember this option for other packages of the same type\" prompt. Manual configuration If you are not using laminas-component-installer, you will need to setup configuration manually. You can do this in one of two ways: Register the Laminas\\Navigation module in either config/application.config.php or config/modules.config.php . Make sure you put it towards the top of the module list, before any modules you have defined or third party modules you are using. Alternately, add a new file, config/autoload/navigation.global.php , with the following contents: <?php use Laminas\\Navigation\\ConfigProvider; return [ 'service_manager' => (new ConfigProvider())->getDependencyConfig(), ]; Once installed, our application is now aware of laminas-navigation, and even has some default factories in place, which we will now make use of. Configuring our Site Map Next up, we need laminas-navigation to understand the hierarchy of our site. To do this, we can add a navigation key to our configuration, with the site structure details. We'll do that in the Application module configuration: // in module/Application/config/module.config.php: return [ /* ... */ 'navigation' => [ 'default' => [ [ 'label' => 'Home', 'route' => 'home', ], [ 'label' => 'Album', 'route' => 'album', 'pages' => [ [ 'label' => 'Add', 'route' => 'album', 'action' => 'add', ], [ 'label' => 'Edit', 'route' => 'album', 'action' => 'edit', ], [ 'label' => 'Delete', 'route' => 'album', 'action' => 'delete', ], ], ], ], ], /* ... */ ]; This configuration maps out the pages we've defined in our Album module, with labels linking to the given route names and actions. You can define highly complex hierarchical sites here with pages and sub-pages linking to route names, controller/action pairs, or external uris. For more information, see the laminas-navigation quick start . Adding the Menu View Helper Now that we have the navigation helper configured by our service manager and merged config, we can add the menu to the title bar to our layout by using the menu view helper : <?php // in module/Application/view/layout/layout.phtml: ?> <div class=\"collapse navbar-collapse\"> <?php // add this: ?> <?= $this->navigation('navigation')->menu() ?> </div> The navigation helper is provided by default with laminas-view, and uses the service manager configuration we've already defined to configure itself automatically. Refreshing your application, you will see a working menu; with just a few tweaks however, we can make it look even better: <?php // in module/Application/view/layout/layout.phtml: ?> <div class=\"collapse navbar-collapse\"> <?php // update to: ?> <?= $this->navigation('navigation') ->menu() ->setMinDepth(0) ->setMaxDepth(0) ->setUlClass('nav navbar-nav') ?> </div> Here we tell the renderer to give the root <ul> the class of nav (so that Bootstrap styles the menu correctly), and only render the first level of any given page. If you view your application in your browser, you will now see a nicely styled menu appear in the title bar. The great thing about laminas-navigation is that it integrates with laminas-router in order to highlight the currently viewed page. Because of this, it sets the active page to have a class of active in the menu; Bootstrap uses this to highlight your current page accordingly. Adding Breadcrumbs Adding breadcrumbs follows the same process. In our layout.phtml we want to add breadcrumbs above the main content pane, so our users know exactly where they are in our website. Inside the container <div> , before we output the content from the view, let's add a breadcrumb by using the breadcrumbs view helper . <?php // module/Application/view/layout/layout.phtml: ?> <div class=\"container\"> <?php // add the following line: ?> <?= $this->navigation('navigation')->breadcrumbs()->setMinDepth(0) ?> <?= $this->content ?> </div> This adds a simple but functional breadcrumb to every page (we tell it to render from a depth of 0 so we see all page levels), but we can do better than that! Because Bootstrap has a styled breadcrumb as part of its base CSS, let's add a partial that outputs the <ul> using Bootstrap styles. We'll create it in the view directory of the Application module (this partial is application wide, rather than album specific): <?php // in module/Application/view/partial/breadcrumb.phtml: ?> <nav aria-label=\"breadcrumb\"> <ol class=\"breadcrumb\"> <?php // iterate through the pages foreach ($this->pages as $key => $page): ?> <?php // if this isn't the last page, add a link and the separator: if ($key < count($this->pages) - 1): ?> <li class=\"breadcrumb-item\"> <a href=\"<?= $page->getHref() ?>\"> <?= $page->getLabel() ?> </a> </li> <?php // otherwise, output the name only: else: ?> <li class=\"breadcrumb-item active\" aria-current=\"page\"> <?= $page->getLabel() ?> </li> <?php endif; ?> <?php endforeach; ?> </ol> </nav> Notice how the partial is passed a Laminas\\View\\Model\\ViewModel instance with the pages property set to an array of pages to render. Now we need to tell the breadcrumb helper to use the partial we have just written: <?php // in module/Application/view/layout/layout.phtml: ?> <div class=\"container\"> <?php // Update to: ?> <?= $this->navigation('navigation') ->breadcrumbs() ->setMinDepth(0) ->setPartial('partial/breadcrumb') ?> <?= $this->content ?> </div> Refreshing the page now gives us a styled set of breadcrumbs on each page.","title":"Adding laminas-navigation to the Album Module"},{"location":"navigation/#using-laminas-navigation-in-your-album-module","text":"In this tutorial we will use the laminas-navigation component to add a navigation menu to the black bar at the top of the screen, and add breadcrumbs above the main site content.","title":"Using laminas-navigation in your Album Module"},{"location":"navigation/#preparation","text":"In a real world application, the album browser would be only a portion of a working website. Usually the user would land on a homepage first, and be able to view albums by using a standard navigation menu. So that we have a site that is more realistic than just the albums feature, lets make the standard skeleton welcome page our homepage, with the /album route still showing our album module. In order to make this change, we need to undo some work we did earlier. Currently, navigating to the root of your app ( / ) routes you to the AlbumController 's default action. Let's undo this route change so we have two discrete entry points to the app, a home page, and an albums area. // In module/Application/config/module.config.php: 'home' => [ 'type' => Literal::class, 'options' => [ 'route' => '/', 'defaults' => [ 'controller' => Controller\\IndexController::class, // <-- change back here 'action' => 'index', ], ], ], (You can also now remove the import for the Album\\Controller\\AlbumController class.) This change means that if you go to the home page of your application ( http://localhost:8080/ or http://laminas-mvc-tutorial.localhost/ ), you see the default skeleton application introduction. Your list of albums is still available at the /album route.","title":"Preparation"},{"location":"navigation/#setting-up-laminas-navigation","text":"First, we need to install laminas-navigation. From your root directory, execute the following: $ composer require laminas/laminas-navigation Assuming you followed the Getting Started tutorial , you will be prompted by the laminas-component-installer plugin to inject Laminas\\Navigation ; be sure to select the option for either config/application.config.php or config/modules.config.php ; since it is the only package you are installing, you can answer either \"y\" or \"n\" to the \"Remember this option for other packages of the same type\" prompt.","title":"Setting Up laminas-navigation"},{"location":"navigation/#configuring-our-site-map","text":"Next up, we need laminas-navigation to understand the hierarchy of our site. To do this, we can add a navigation key to our configuration, with the site structure details. We'll do that in the Application module configuration: // in module/Application/config/module.config.php: return [ /* ... */ 'navigation' => [ 'default' => [ [ 'label' => 'Home', 'route' => 'home', ], [ 'label' => 'Album', 'route' => 'album', 'pages' => [ [ 'label' => 'Add', 'route' => 'album', 'action' => 'add', ], [ 'label' => 'Edit', 'route' => 'album', 'action' => 'edit', ], [ 'label' => 'Delete', 'route' => 'album', 'action' => 'delete', ], ], ], ], ], /* ... */ ]; This configuration maps out the pages we've defined in our Album module, with labels linking to the given route names and actions. You can define highly complex hierarchical sites here with pages and sub-pages linking to route names, controller/action pairs, or external uris. For more information, see the laminas-navigation quick start .","title":"Configuring our Site Map"},{"location":"navigation/#adding-the-menu-view-helper","text":"Now that we have the navigation helper configured by our service manager and merged config, we can add the menu to the title bar to our layout by using the menu view helper : <?php // in module/Application/view/layout/layout.phtml: ?> <div class=\"collapse navbar-collapse\"> <?php // add this: ?> <?= $this->navigation('navigation')->menu() ?> </div> The navigation helper is provided by default with laminas-view, and uses the service manager configuration we've already defined to configure itself automatically. Refreshing your application, you will see a working menu; with just a few tweaks however, we can make it look even better: <?php // in module/Application/view/layout/layout.phtml: ?> <div class=\"collapse navbar-collapse\"> <?php // update to: ?> <?= $this->navigation('navigation') ->menu() ->setMinDepth(0) ->setMaxDepth(0) ->setUlClass('nav navbar-nav') ?> </div> Here we tell the renderer to give the root <ul> the class of nav (so that Bootstrap styles the menu correctly), and only render the first level of any given page. If you view your application in your browser, you will now see a nicely styled menu appear in the title bar. The great thing about laminas-navigation is that it integrates with laminas-router in order to highlight the currently viewed page. Because of this, it sets the active page to have a class of active in the menu; Bootstrap uses this to highlight your current page accordingly.","title":"Adding the Menu View Helper"},{"location":"navigation/#adding-breadcrumbs","text":"Adding breadcrumbs follows the same process. In our layout.phtml we want to add breadcrumbs above the main content pane, so our users know exactly where they are in our website. Inside the container <div> , before we output the content from the view, let's add a breadcrumb by using the breadcrumbs view helper . <?php // module/Application/view/layout/layout.phtml: ?> <div class=\"container\"> <?php // add the following line: ?> <?= $this->navigation('navigation')->breadcrumbs()->setMinDepth(0) ?> <?= $this->content ?> </div> This adds a simple but functional breadcrumb to every page (we tell it to render from a depth of 0 so we see all page levels), but we can do better than that! Because Bootstrap has a styled breadcrumb as part of its base CSS, let's add a partial that outputs the <ul> using Bootstrap styles. We'll create it in the view directory of the Application module (this partial is application wide, rather than album specific): <?php // in module/Application/view/partial/breadcrumb.phtml: ?> <nav aria-label=\"breadcrumb\"> <ol class=\"breadcrumb\"> <?php // iterate through the pages foreach ($this->pages as $key => $page): ?> <?php // if this isn't the last page, add a link and the separator: if ($key < count($this->pages) - 1): ?> <li class=\"breadcrumb-item\"> <a href=\"<?= $page->getHref() ?>\"> <?= $page->getLabel() ?> </a> </li> <?php // otherwise, output the name only: else: ?> <li class=\"breadcrumb-item active\" aria-current=\"page\"> <?= $page->getLabel() ?> </li> <?php endif; ?> <?php endforeach; ?> </ol> </nav> Notice how the partial is passed a Laminas\\View\\Model\\ViewModel instance with the pages property set to an array of pages to render. Now we need to tell the breadcrumb helper to use the partial we have just written: <?php // in module/Application/view/layout/layout.phtml: ?> <div class=\"container\"> <?php // Update to: ?> <?= $this->navigation('navigation') ->breadcrumbs() ->setMinDepth(0) ->setPartial('partial/breadcrumb') ?> <?= $this->content ?> </div> Refreshing the page now gives us a styled set of breadcrumbs on each page.","title":"Adding Breadcrumbs"},{"location":"pagination/","text":"Using laminas-paginator in your Album Module In this tutorial, we will use the laminas-paginator component to add a handy pagination controller to the bottom of the album list. Currently, we only have a handful of albums to display, so showing everything on one page is not a problem. However, how will the album list look when we have 100 albums or more in our database? The standard solution to this problem is to split the data up into a number of pages, and allow the user to navigate around these pages using a pagination control. Just type \"Laminas\" into Google, and you can see their pagination control at the bottom of the page: Preparation As before, we are going to use sqlite, via PHP's PDO driver. Create a text file data/album-fixtures.sql with the following contents: INSERT INTO \"album\" (\"artist\", \"title\") VALUES (\"David Bowie\", \"The Next Day (Deluxe Version)\"), (\"Bastille\", \"Bad Blood\"), (\"Bruno Mars\", \"Unorthodox Jukebox\"), (\"Emeli Sandé\", \"Our Version of Events (Special Edition)\"), (\"Bon Jovi\", \"What About Now (Deluxe Version)\"), (\"Justin Timberlake\", \"The 20/20 Experience (Deluxe Version)\"), (\"Bastille\", \"Bad Blood (The Extended Cut)\"), (\"P!nk\", \"The Truth About Love\"), (\"Sound City - Real to Reel\", \"Sound City - Real to Reel\"), (\"Jake Bugg\", \"Jake Bugg\"), (\"Various Artists\", \"The Trevor Nelson Collection\"), (\"David Bowie\", \"The Next Day\"), (\"Mumford & Sons\", \"Babel\"), (\"The Lumineers\", \"The Lumineers\"), (\"Various Artists\", \"Get Ur Freak On - R&B Anthems\"), (\"The 1975\", \"Music For Cars EP\"), (\"Various Artists\", \"Saturday Night Club Classics - Ministry of Sound\"), (\"Hurts\", \"Exile (Deluxe)\"), (\"Various Artists\", \"Mixmag - The Greatest Dance Tracks of All Time\"), (\"Ben Howard\", \"Every Kingdom\"), (\"Stereophonics\", \"Graffiti On the Train\"), (\"The Script\", \"#3\"), (\"Stornoway\", \"Tales from Terra Firma\"), (\"David Bowie\", \"Hunky Dory (Remastered)\"), (\"Worship Central\", \"Let It Be Known (Live)\"), (\"Ellie Goulding\", \"Halcyon\"), (\"Various Artists\", \"Dermot O'Leary Presents the Saturday Sessions 2013\"), (\"Stereophonics\", \"Graffiti On the Train (Deluxe Version)\"), (\"Dido\", \"Girl Who Got Away (Deluxe)\"), (\"Hurts\", \"Exile\"), (\"Bruno Mars\", \"Doo-Wops & Hooligans\"), (\"Calvin Harris\", \"18 Months\"), (\"Olly Murs\", \"Right Place Right Time\"), (\"Alt-J (?)\", \"An Awesome Wave\"), (\"One Direction\", \"Take Me Home\"), (\"Various Artists\", \"Pop Stars\"), (\"Various Artists\", \"Now That's What I Call Music! 83\"), (\"John Grant\", \"Pale Green Ghosts\"), (\"Paloma Faith\", \"Fall to Grace\"), (\"Laura Mvula\", \"Sing To the Moon (Deluxe)\"), (\"Duke Dumont\", \"Need U (100%) [feat. A*M*E] - EP\"), (\"Watsky\", \"Cardboard Castles\"), (\"Blondie\", \"Blondie: Greatest Hits\"), (\"Foals\", \"Holy Fire\"), (\"Maroon 5\", \"Overexposed\"), (\"Bastille\", \"Pompeii (Remixes) - EP\"), (\"Imagine Dragons\", \"Hear Me - EP\"), (\"Various Artists\", \"100 Hits: 80s Classics\"), (\"Various Artists\", \"Les Misérables (Highlights From the Motion Picture Soundtrack)\"), (\"Mumford & Sons\", \"Sigh No More\"), (\"Frank Ocean\", \"Channel ORANGE\"), (\"Bon Jovi\", \"What About Now\"), (\"Various Artists\", \"BRIT Awards 2013\"), (\"Taylor Swift\", \"Red\"), (\"Fleetwood Mac\", \"Fleetwood Mac: Greatest Hits\"), (\"David Guetta\", \"Nothing But the Beat Ultimate\"), (\"Various Artists\", \"Clubbers Guide 2013 (Mixed By Danny Howard) - Ministry of Sound\"), (\"David Bowie\", \"Best of Bowie\"), (\"Laura Mvula\", \"Sing To the Moon\"), (\"ADELE\", \"21\"), (\"Of Monsters and Men\", \"My Head Is an Animal\"), (\"Rihanna\", \"Unapologetic\"), (\"Various Artists\", \"BBC Radio 1's Live Lounge - 2012\"), (\"Avicii & Nicky Romero\", \"I Could Be the One (Avicii vs. Nicky Romero)\"), (\"The Streets\", \"A Grand Don't Come for Free\"), (\"Tim McGraw\", \"Two Lanes of Freedom\"), (\"Foo Fighters\", \"Foo Fighters: Greatest Hits\"), (\"Various Artists\", \"Now That's What I Call Running!\"), (\"Swedish House Mafia\", \"Until Now\"), (\"The xx\", \"Coexist\"), (\"Five\", \"Five: Greatest Hits\"), (\"Jimi Hendrix\", \"People, Hell & Angels\"), (\"Biffy Clyro\", \"Opposites (Deluxe)\"), (\"The Smiths\", \"The Sound of the Smiths\"), (\"The Saturdays\", \"What About Us - EP\"), (\"Fleetwood Mac\", \"Rumours\"), (\"Various Artists\", \"The Big Reunion\"), (\"Various Artists\", \"Anthems 90s - Ministry of Sound\"), (\"The Vaccines\", \"Come of Age\"), (\"Nicole Scherzinger\", \"Boomerang (Remixes) - EP\"), (\"Bob Marley\", \"Legend (Bonus Track Version)\"), (\"Josh Groban\", \"All That Echoes\"), (\"Blue\", \"Best of Blue\"), (\"Ed Sheeran\", \"+\"), (\"Olly Murs\", \"In Case You Didn't Know (Deluxe Edition)\"), (\"Macklemore & Ryan Lewis\", \"The Heist (Deluxe Edition)\"), (\"Various Artists\", \"Defected Presents Most Rated Miami 2013\"), (\"Gorgon City\", \"Real EP\"), (\"Mumford & Sons\", \"Babel (Deluxe Version)\"), (\"Various Artists\", \"The Music of Nashville: Season 1, Vol. 1 (Original Soundtrack)\"), (\"Various Artists\", \"The Twilight Saga: Breaking Dawn, Pt. 2 (Original Motion Picture Soundtrack)\"), (\"Various Artists\", \"Mum - The Ultimate Mothers Day Collection\"), (\"One Direction\", \"Up All Night\"), (\"Bon Jovi\", \"Bon Jovi Greatest Hits\"), (\"Agnetha Fältskog\", \"A\"), (\"Fun.\", \"Some Nights\"), (\"Justin Bieber\", \"Believe Acoustic\"), (\"Atoms for Peace\", \"Amok\"), (\"Justin Timberlake\", \"Justified\"), (\"Passenger\", \"All the Little Lights\"), (\"Kodaline\", \"The High Hopes EP\"), (\"Lana Del Rey\", \"Born to Die\"), (\"JAY Z & Kanye West\", \"Watch the Throne (Deluxe Version)\"), (\"Biffy Clyro\", \"Opposites\"), (\"Various Artists\", \"Return of the 90s\"), (\"Gabrielle Aplin\", \"Please Don't Say You Love Me - EP\"), (\"Various Artists\", \"100 Hits - Driving Rock\"), (\"Jimi Hendrix\", \"Experience Hendrix - The Best of Jimi Hendrix\"), (\"Various Artists\", \"The Workout Mix 2013\"), (\"The 1975\", \"Sex\"), (\"Chase & Status\", \"No More Idols\"), (\"Rihanna\", \"Unapologetic (Deluxe Version)\"), (\"The Killers\", \"Battle Born\"), (\"Olly Murs\", \"Right Place Right Time (Deluxe Edition)\"), (\"A$AP Rocky\", \"LONG.LIVE.A$AP (Deluxe Version)\"), (\"Various Artists\", \"Cooking Songs\"), (\"Haim\", \"Forever - EP\"), (\"Lianne La Havas\", \"Is Your Love Big Enough?\"), (\"Michael Bublé\", \"To Be Loved\"), (\"Daughter\", \"If You Leave\"), (\"The xx\", \"xx\"), (\"Eminem\", \"Curtain Call\"), (\"Kendrick Lamar\", \"good kid, m.A.A.d city (Deluxe)\"), (\"Disclosure\", \"The Face - EP\"), (\"Palma Violets\", \"180\"), (\"Cody Simpson\", \"Paradise\"), (\"Ed Sheeran\", \"+ (Deluxe Version)\"), (\"Michael Bublé\", \"Crazy Love (Hollywood Edition)\"), (\"Bon Jovi\", \"Bon Jovi Greatest Hits - The Ultimate Collection\"), (\"Rita Ora\", \"Ora\"), (\"g33k\", \"Spabby\"), (\"Various Artists\", \"Annie Mac Presents 2012\"), (\"David Bowie\", \"The Platinum Collection\"), (\"Bridgit Mendler\", \"Ready or Not (Remixes) - EP\"), (\"Dido\", \"Girl Who Got Away\"), (\"Various Artists\", \"Now That's What I Call Disney\"), (\"The 1975\", \"Facedown - EP\"), (\"Kodaline\", \"The Kodaline - EP\"), (\"Various Artists\", \"100 Hits: Super 70s\"), (\"Fred V & Grafix\", \"Goggles - EP\"), (\"Biffy Clyro\", \"Only Revolutions (Deluxe Version)\"), (\"Train\", \"California 37\"), (\"Ben Howard\", \"Every Kingdom (Deluxe Edition)\"), (\"Various Artists\", \"Motown Anthems\"), (\"Courteeners\", \"ANNA\"), (\"Johnny Marr\", \"The Messenger\"), (\"Rodriguez\", \"Searching for Sugar Man\"), (\"Jessie Ware\", \"Devotion\"), (\"Bruno Mars\", \"Unorthodox Jukebox\"), (\"Various Artists\", \"Call the Midwife (Music From the TV Series)\" ); (The test data chosen happens to be the current 150 top iTunes albums at the time of writing!) Now create the database using the following: $ sqlite data/laminastutorial.db < data/album-fixtures.sql Some systems, including Ubuntu, use the command sqlite3 ; check to see which one to use on your system. Alternative Commands Some systems, including Ubuntu, use the command sqlite3 ; check to see which one to use on your system. SQLite3 If you use sqlite3 create the database using the following command: $ cat data/schema.sql | sqlite3 data/laminastutorial.db Using PHP to Create the Database If you do not have Sqlite installed on your system, you can use PHP to load the database using the same SQL schema file created earlier. Create the file data/load_album_fixtures.php with the following contents: <?php $db = new PDO('sqlite:' . realpath(__DIR__) . '/laminastutorial.db'); $fh = fopen(__DIR__ . '/album-fixtures.sql', 'r'); while ($line = fread($fh, 4096)) { $db->exec($line); } fclose($fh); Once created, execute it: $ php data/load_album_fixtures.php This gives us a handy extra 150 rows to play with. If you now visit your album list at /album , you'll see a huge long list of 150+ albums; it's ugly. Install laminas-paginator laminas-paginator is not installed or configured by default, so we will need to do that. Run the following from the application root: $ composer require laminas/laminas-paginator Assuming you followed the Getting Started tutorial , you will be prompted by the laminas-component-installer plugin to inject Laminas\\Paginator ; be sure to select the option for either config/application.config.php or config/modules.config.php ; since it is the only package you are installing, you can answer either \"y\" or \"n\" to the \"Remember this option for other packages of the same type\" prompt. Manual configuration If you are not using laminas-component-installer, you will need to setup configuration manually. You can do this in one of two ways: Register the Laminas\\Paginator module in either config/application.config.php or config/modules.config.php . Make sure you put it towards the top of the module list, before any modules you have defined or third party modules you are using. Alternately, add a new file, config/autoload/paginator.global.php , with the following contents: <?php use Laminas\\Paginator\\ConfigProvider; return [ 'service_manager' => (new ConfigProvider())->getDependencyConfig(), ]; Once installed, our application is now aware of laminas-paginator, and even has some default factories in place, which we will now make use of. Modifying the AlbumTable In order to let laminas-paginator handle our database queries automatically for us, we will be using the DbSelect pagination adapter This will automatically manipulate and run a Laminas\\Db\\Sql\\Select object to include the correct LIMIT and WHERE clauses so that it returns only the configured amount of data for the given page. Let's modify the fetchAll method of the AlbumTable model, so that it can optionally return a paginator object: // in module/Album/src/Model/AlbumTable.php: namespace Album\\Model; use Laminas\\Db\\ResultSet\\ResultSet; use Laminas\\Db\\Sql\\Select; use Laminas\\Db\\TableGateway\\TableGatewayInterface; use Laminas\\Paginator\\Adapter\\DbSelect; use Laminas\\Paginator\\Paginator; class AlbumTable { /* ... */ public function fetchAll($paginated = false) { if ($paginated) { return $this->fetchPaginatedResults(); } return $this->tableGateway->select(); } private function fetchPaginatedResults() { // Create a new Select object for the table: $select = new Select($this->tableGateway->getTable()); // Create a new result set based on the Album entity: $resultSetPrototype = new ResultSet(); $resultSetPrototype->setArrayObjectPrototype(new Album()); // Create a new pagination adapter object: $paginatorAdapter = new DbSelect( // our configured select object: $select, // the adapter to run it against: $this->tableGateway->getAdapter(), // the result set to hydrate: $resultSetPrototype ); return new Paginator($paginatorAdapter); } /* ... */ } This will return a fully configured Paginator instance. We've already told the DbSelect adapter to use our created Select object, to use the adapter that the TableGateway object uses, and also how to hydrate the result into a Album entity in the same fashion as the TableGateway does. This means that our executed and returned paginator results will return Album objects in exactly the same fashion as the non-paginated results. Modifying the AlbumController Next, we need to tell the album controller to provide the view with a Pagination object instead of a ResultSet . Both these objects can by iterated over to return hydrated Album objects, so we won't need to make many changes to the view script: // in module/Album/src/Controller/AlbumController.php: /* ... */ public function indexAction() { // Grab the paginator from the AlbumTable: $paginator = $this->table->fetchAll(true); // Set the current page to what has been passed in query string, // or to 1 if none is set, or the page is invalid: $page = (int) $this->params()->fromQuery('page', 1); $page = ($page < 1) ? 1 : $page; $paginator->setCurrentPageNumber($page); // Set the number of items per page to 10: $paginator->setItemCountPerPage(10); return new ViewModel(['paginator' => $paginator]); } /* ... */ Here we are getting the configured Paginator object from the AlbumTable , and then telling it to use the page that is optionally passed in the querystring page parameter (after first validating it). We are also telling the paginator we want to display 10 albums per page. Updating the View Script Now, tell the view script to iterate over the pagination view variable, rather than the albums variable: <?php // in module/Album/view/album/album/index.phtml: $title = 'My albums'; $this->headTitle($title); ?> <h1><?= $this->escapeHtml($title); ?></h1> <p> <a href=\"<?= $this->url('album', ['action' => 'add']) ?>\">Add new album</a> </p> <table class=\"table\"> <tr> <th>Title</th> <th>Artist</th> <th> </th> </tr> <?php foreach ($this->paginator as $album) : // <-- change here! ?> <tr> <td><?= $this->escapeHtml($album->title) ?></td> <td><?= $this->escapeHtml($album->artist) ?></td> <td> <a href=\"<?= $this->url('album', ['action' => 'edit', 'id' => $album->id]) ?>\"> Edit </a> <a href=\"<?= $this->url('album', ['action' => 'delete', 'id' => $album->id]) ?>\"> Delete </a> </td> </tr> <?php endforeach; ?> </table> Checking the /album route on your website should now give you a list of just 10 albums, but with no method to navigate through the pages. Let's correct that now. Creating the Pagination Control Partial Much like we created a custom breadcrumbs partial to render our breadcrumb in the navigation tutorial , we need to create a custom pagination control partial to render our pagination control just the way we want it. Again, because we are using Bootstrap, this will primarily involve outputting correctly formatted HTML. Let's create the partial in the module/Application/view/partial/ folder, so that we can use the control in all our modules: <?php // in module/Application/view/partial/paginator.phtml: ?> <?php if ($this->pageCount): ?> <nav> <ul class=\"pagination\"> <!-- Previous page link --> <?php if (isset($this->previous)): ?> <li class=\"page-item\"> <a class=\"page-link\" href=\"<?= $this->url($this->route, [], ['query' => ['page' => $this->previous]]) ?>\"> Previous </a> </li> <?php else: ?> <li class=\"page-item disabled\"> <span class=\"page-link\">Previous</span> </li> <?php endif ?> <!-- Numbered page links --> <?php foreach ($this->pagesInRange as $page): ?> <?php if ($page !== $this->current): ?> <li class=\"page-item\"> <a class=\"page-link\" href=\"<?= $this->url($this->route, [], ['query' => ['page' => $page]]) ?>\"> <?= $page ?> </a> </li> <?php else: ?> <li class=\"page-item active\" aria-current=\"page\"> <span class=\"page-link\"><?= $page ?></span> </li> <?php endif ?> <?php endforeach ?> <!-- Next page link --> <?php if (isset($this->next)): ?> <li class=\"page-item\"> <a class=\"page-link\" href=\"<?= $this->url($this->route, [], ['query' => ['page' => $this->next]]) ?>\"> Next </a> </li> <?php else: ?> <li class=\"page-item disabled\"> <span class=\"page-link\">Next</span> </li> <?php endif ?> </ul> </nav> <?php endif ?> This partial creates a pagination control with links to the correct pages (if there is more than one page in the pagination object). It will render a previous page link (and mark it disabled if you are at the first page), then render a list of intermediate pages (that are passed to the partial based on the rendering style; we'll pass that to the view helper in the next step). Finally, it will create a next page link (and disable it if you're at the end). Notice how we pass the page number via the page querystring parameter which we have already told our controller to use to display the current page. Using the PaginationControl View Helper To page through the albums, we need to invoke the paginationControl view helper to display our pagination control: <?php // In module/Album/view/album/album/index.phtml: // Add at the end of the file after the table: ?> <?= $this->paginationControl( // The paginator object: $this->paginator, // The scrolling style: 'sliding', // The partial to use to render the control: 'partial/paginator', // The route to link to when a user clicks a control link: ['route' => 'album'] ) ?> The above echoes the paginationControl helper, and tells it to use our paginator instance, the sliding scrolling style , our paginator partial, and which route to use for generating links. Refreshing your application now should give you Bootstrap-styled pagination controls!","title":"Adding laminas-paginator to the Album Module"},{"location":"pagination/#using-laminas-paginator-in-your-album-module","text":"In this tutorial, we will use the laminas-paginator component to add a handy pagination controller to the bottom of the album list. Currently, we only have a handful of albums to display, so showing everything on one page is not a problem. However, how will the album list look when we have 100 albums or more in our database? The standard solution to this problem is to split the data up into a number of pages, and allow the user to navigate around these pages using a pagination control. Just type \"Laminas\" into Google, and you can see their pagination control at the bottom of the page:","title":"Using laminas-paginator in your Album Module"},{"location":"pagination/#preparation","text":"As before, we are going to use sqlite, via PHP's PDO driver. Create a text file data/album-fixtures.sql with the following contents: INSERT INTO \"album\" (\"artist\", \"title\") VALUES (\"David Bowie\", \"The Next Day (Deluxe Version)\"), (\"Bastille\", \"Bad Blood\"), (\"Bruno Mars\", \"Unorthodox Jukebox\"), (\"Emeli Sandé\", \"Our Version of Events (Special Edition)\"), (\"Bon Jovi\", \"What About Now (Deluxe Version)\"), (\"Justin Timberlake\", \"The 20/20 Experience (Deluxe Version)\"), (\"Bastille\", \"Bad Blood (The Extended Cut)\"), (\"P!nk\", \"The Truth About Love\"), (\"Sound City - Real to Reel\", \"Sound City - Real to Reel\"), (\"Jake Bugg\", \"Jake Bugg\"), (\"Various Artists\", \"The Trevor Nelson Collection\"), (\"David Bowie\", \"The Next Day\"), (\"Mumford & Sons\", \"Babel\"), (\"The Lumineers\", \"The Lumineers\"), (\"Various Artists\", \"Get Ur Freak On - R&B Anthems\"), (\"The 1975\", \"Music For Cars EP\"), (\"Various Artists\", \"Saturday Night Club Classics - Ministry of Sound\"), (\"Hurts\", \"Exile (Deluxe)\"), (\"Various Artists\", \"Mixmag - The Greatest Dance Tracks of All Time\"), (\"Ben Howard\", \"Every Kingdom\"), (\"Stereophonics\", \"Graffiti On the Train\"), (\"The Script\", \"#3\"), (\"Stornoway\", \"Tales from Terra Firma\"), (\"David Bowie\", \"Hunky Dory (Remastered)\"), (\"Worship Central\", \"Let It Be Known (Live)\"), (\"Ellie Goulding\", \"Halcyon\"), (\"Various Artists\", \"Dermot O'Leary Presents the Saturday Sessions 2013\"), (\"Stereophonics\", \"Graffiti On the Train (Deluxe Version)\"), (\"Dido\", \"Girl Who Got Away (Deluxe)\"), (\"Hurts\", \"Exile\"), (\"Bruno Mars\", \"Doo-Wops & Hooligans\"), (\"Calvin Harris\", \"18 Months\"), (\"Olly Murs\", \"Right Place Right Time\"), (\"Alt-J (?)\", \"An Awesome Wave\"), (\"One Direction\", \"Take Me Home\"), (\"Various Artists\", \"Pop Stars\"), (\"Various Artists\", \"Now That's What I Call Music! 83\"), (\"John Grant\", \"Pale Green Ghosts\"), (\"Paloma Faith\", \"Fall to Grace\"), (\"Laura Mvula\", \"Sing To the Moon (Deluxe)\"), (\"Duke Dumont\", \"Need U (100%) [feat. A*M*E] - EP\"), (\"Watsky\", \"Cardboard Castles\"), (\"Blondie\", \"Blondie: Greatest Hits\"), (\"Foals\", \"Holy Fire\"), (\"Maroon 5\", \"Overexposed\"), (\"Bastille\", \"Pompeii (Remixes) - EP\"), (\"Imagine Dragons\", \"Hear Me - EP\"), (\"Various Artists\", \"100 Hits: 80s Classics\"), (\"Various Artists\", \"Les Misérables (Highlights From the Motion Picture Soundtrack)\"), (\"Mumford & Sons\", \"Sigh No More\"), (\"Frank Ocean\", \"Channel ORANGE\"), (\"Bon Jovi\", \"What About Now\"), (\"Various Artists\", \"BRIT Awards 2013\"), (\"Taylor Swift\", \"Red\"), (\"Fleetwood Mac\", \"Fleetwood Mac: Greatest Hits\"), (\"David Guetta\", \"Nothing But the Beat Ultimate\"), (\"Various Artists\", \"Clubbers Guide 2013 (Mixed By Danny Howard) - Ministry of Sound\"), (\"David Bowie\", \"Best of Bowie\"), (\"Laura Mvula\", \"Sing To the Moon\"), (\"ADELE\", \"21\"), (\"Of Monsters and Men\", \"My Head Is an Animal\"), (\"Rihanna\", \"Unapologetic\"), (\"Various Artists\", \"BBC Radio 1's Live Lounge - 2012\"), (\"Avicii & Nicky Romero\", \"I Could Be the One (Avicii vs. Nicky Romero)\"), (\"The Streets\", \"A Grand Don't Come for Free\"), (\"Tim McGraw\", \"Two Lanes of Freedom\"), (\"Foo Fighters\", \"Foo Fighters: Greatest Hits\"), (\"Various Artists\", \"Now That's What I Call Running!\"), (\"Swedish House Mafia\", \"Until Now\"), (\"The xx\", \"Coexist\"), (\"Five\", \"Five: Greatest Hits\"), (\"Jimi Hendrix\", \"People, Hell & Angels\"), (\"Biffy Clyro\", \"Opposites (Deluxe)\"), (\"The Smiths\", \"The Sound of the Smiths\"), (\"The Saturdays\", \"What About Us - EP\"), (\"Fleetwood Mac\", \"Rumours\"), (\"Various Artists\", \"The Big Reunion\"), (\"Various Artists\", \"Anthems 90s - Ministry of Sound\"), (\"The Vaccines\", \"Come of Age\"), (\"Nicole Scherzinger\", \"Boomerang (Remixes) - EP\"), (\"Bob Marley\", \"Legend (Bonus Track Version)\"), (\"Josh Groban\", \"All That Echoes\"), (\"Blue\", \"Best of Blue\"), (\"Ed Sheeran\", \"+\"), (\"Olly Murs\", \"In Case You Didn't Know (Deluxe Edition)\"), (\"Macklemore & Ryan Lewis\", \"The Heist (Deluxe Edition)\"), (\"Various Artists\", \"Defected Presents Most Rated Miami 2013\"), (\"Gorgon City\", \"Real EP\"), (\"Mumford & Sons\", \"Babel (Deluxe Version)\"), (\"Various Artists\", \"The Music of Nashville: Season 1, Vol. 1 (Original Soundtrack)\"), (\"Various Artists\", \"The Twilight Saga: Breaking Dawn, Pt. 2 (Original Motion Picture Soundtrack)\"), (\"Various Artists\", \"Mum - The Ultimate Mothers Day Collection\"), (\"One Direction\", \"Up All Night\"), (\"Bon Jovi\", \"Bon Jovi Greatest Hits\"), (\"Agnetha Fältskog\", \"A\"), (\"Fun.\", \"Some Nights\"), (\"Justin Bieber\", \"Believe Acoustic\"), (\"Atoms for Peace\", \"Amok\"), (\"Justin Timberlake\", \"Justified\"), (\"Passenger\", \"All the Little Lights\"), (\"Kodaline\", \"The High Hopes EP\"), (\"Lana Del Rey\", \"Born to Die\"), (\"JAY Z & Kanye West\", \"Watch the Throne (Deluxe Version)\"), (\"Biffy Clyro\", \"Opposites\"), (\"Various Artists\", \"Return of the 90s\"), (\"Gabrielle Aplin\", \"Please Don't Say You Love Me - EP\"), (\"Various Artists\", \"100 Hits - Driving Rock\"), (\"Jimi Hendrix\", \"Experience Hendrix - The Best of Jimi Hendrix\"), (\"Various Artists\", \"The Workout Mix 2013\"), (\"The 1975\", \"Sex\"), (\"Chase & Status\", \"No More Idols\"), (\"Rihanna\", \"Unapologetic (Deluxe Version)\"), (\"The Killers\", \"Battle Born\"), (\"Olly Murs\", \"Right Place Right Time (Deluxe Edition)\"), (\"A$AP Rocky\", \"LONG.LIVE.A$AP (Deluxe Version)\"), (\"Various Artists\", \"Cooking Songs\"), (\"Haim\", \"Forever - EP\"), (\"Lianne La Havas\", \"Is Your Love Big Enough?\"), (\"Michael Bublé\", \"To Be Loved\"), (\"Daughter\", \"If You Leave\"), (\"The xx\", \"xx\"), (\"Eminem\", \"Curtain Call\"), (\"Kendrick Lamar\", \"good kid, m.A.A.d city (Deluxe)\"), (\"Disclosure\", \"The Face - EP\"), (\"Palma Violets\", \"180\"), (\"Cody Simpson\", \"Paradise\"), (\"Ed Sheeran\", \"+ (Deluxe Version)\"), (\"Michael Bublé\", \"Crazy Love (Hollywood Edition)\"), (\"Bon Jovi\", \"Bon Jovi Greatest Hits - The Ultimate Collection\"), (\"Rita Ora\", \"Ora\"), (\"g33k\", \"Spabby\"), (\"Various Artists\", \"Annie Mac Presents 2012\"), (\"David Bowie\", \"The Platinum Collection\"), (\"Bridgit Mendler\", \"Ready or Not (Remixes) - EP\"), (\"Dido\", \"Girl Who Got Away\"), (\"Various Artists\", \"Now That's What I Call Disney\"), (\"The 1975\", \"Facedown - EP\"), (\"Kodaline\", \"The Kodaline - EP\"), (\"Various Artists\", \"100 Hits: Super 70s\"), (\"Fred V & Grafix\", \"Goggles - EP\"), (\"Biffy Clyro\", \"Only Revolutions (Deluxe Version)\"), (\"Train\", \"California 37\"), (\"Ben Howard\", \"Every Kingdom (Deluxe Edition)\"), (\"Various Artists\", \"Motown Anthems\"), (\"Courteeners\", \"ANNA\"), (\"Johnny Marr\", \"The Messenger\"), (\"Rodriguez\", \"Searching for Sugar Man\"), (\"Jessie Ware\", \"Devotion\"), (\"Bruno Mars\", \"Unorthodox Jukebox\"), (\"Various Artists\", \"Call the Midwife (Music From the TV Series)\" ); (The test data chosen happens to be the current 150 top iTunes albums at the time of writing!) Now create the database using the following: $ sqlite data/laminastutorial.db < data/album-fixtures.sql Some systems, including Ubuntu, use the command sqlite3 ; check to see which one to use on your system. Alternative Commands Some systems, including Ubuntu, use the command sqlite3 ; check to see which one to use on your system.","title":"Preparation"},{"location":"pagination/#install-laminas-paginator","text":"laminas-paginator is not installed or configured by default, so we will need to do that. Run the following from the application root: $ composer require laminas/laminas-paginator Assuming you followed the Getting Started tutorial , you will be prompted by the laminas-component-installer plugin to inject Laminas\\Paginator ; be sure to select the option for either config/application.config.php or config/modules.config.php ; since it is the only package you are installing, you can answer either \"y\" or \"n\" to the \"Remember this option for other packages of the same type\" prompt.","title":"Install laminas-paginator"},{"location":"pagination/#modifying-the-albumtable","text":"In order to let laminas-paginator handle our database queries automatically for us, we will be using the DbSelect pagination adapter This will automatically manipulate and run a Laminas\\Db\\Sql\\Select object to include the correct LIMIT and WHERE clauses so that it returns only the configured amount of data for the given page. Let's modify the fetchAll method of the AlbumTable model, so that it can optionally return a paginator object: // in module/Album/src/Model/AlbumTable.php: namespace Album\\Model; use Laminas\\Db\\ResultSet\\ResultSet; use Laminas\\Db\\Sql\\Select; use Laminas\\Db\\TableGateway\\TableGatewayInterface; use Laminas\\Paginator\\Adapter\\DbSelect; use Laminas\\Paginator\\Paginator; class AlbumTable { /* ... */ public function fetchAll($paginated = false) { if ($paginated) { return $this->fetchPaginatedResults(); } return $this->tableGateway->select(); } private function fetchPaginatedResults() { // Create a new Select object for the table: $select = new Select($this->tableGateway->getTable()); // Create a new result set based on the Album entity: $resultSetPrototype = new ResultSet(); $resultSetPrototype->setArrayObjectPrototype(new Album()); // Create a new pagination adapter object: $paginatorAdapter = new DbSelect( // our configured select object: $select, // the adapter to run it against: $this->tableGateway->getAdapter(), // the result set to hydrate: $resultSetPrototype ); return new Paginator($paginatorAdapter); } /* ... */ } This will return a fully configured Paginator instance. We've already told the DbSelect adapter to use our created Select object, to use the adapter that the TableGateway object uses, and also how to hydrate the result into a Album entity in the same fashion as the TableGateway does. This means that our executed and returned paginator results will return Album objects in exactly the same fashion as the non-paginated results.","title":"Modifying the AlbumTable"},{"location":"pagination/#modifying-the-albumcontroller","text":"Next, we need to tell the album controller to provide the view with a Pagination object instead of a ResultSet . Both these objects can by iterated over to return hydrated Album objects, so we won't need to make many changes to the view script: // in module/Album/src/Controller/AlbumController.php: /* ... */ public function indexAction() { // Grab the paginator from the AlbumTable: $paginator = $this->table->fetchAll(true); // Set the current page to what has been passed in query string, // or to 1 if none is set, or the page is invalid: $page = (int) $this->params()->fromQuery('page', 1); $page = ($page < 1) ? 1 : $page; $paginator->setCurrentPageNumber($page); // Set the number of items per page to 10: $paginator->setItemCountPerPage(10); return new ViewModel(['paginator' => $paginator]); } /* ... */ Here we are getting the configured Paginator object from the AlbumTable , and then telling it to use the page that is optionally passed in the querystring page parameter (after first validating it). We are also telling the paginator we want to display 10 albums per page.","title":"Modifying the AlbumController"},{"location":"pagination/#updating-the-view-script","text":"Now, tell the view script to iterate over the pagination view variable, rather than the albums variable: <?php // in module/Album/view/album/album/index.phtml: $title = 'My albums'; $this->headTitle($title); ?> <h1><?= $this->escapeHtml($title); ?></h1> <p> <a href=\"<?= $this->url('album', ['action' => 'add']) ?>\">Add new album</a> </p> <table class=\"table\"> <tr> <th>Title</th> <th>Artist</th> <th> </th> </tr> <?php foreach ($this->paginator as $album) : // <-- change here! ?> <tr> <td><?= $this->escapeHtml($album->title) ?></td> <td><?= $this->escapeHtml($album->artist) ?></td> <td> <a href=\"<?= $this->url('album', ['action' => 'edit', 'id' => $album->id]) ?>\"> Edit </a> <a href=\"<?= $this->url('album', ['action' => 'delete', 'id' => $album->id]) ?>\"> Delete </a> </td> </tr> <?php endforeach; ?> </table> Checking the /album route on your website should now give you a list of just 10 albums, but with no method to navigate through the pages. Let's correct that now.","title":"Updating the View Script"},{"location":"pagination/#creating-the-pagination-control-partial","text":"Much like we created a custom breadcrumbs partial to render our breadcrumb in the navigation tutorial , we need to create a custom pagination control partial to render our pagination control just the way we want it. Again, because we are using Bootstrap, this will primarily involve outputting correctly formatted HTML. Let's create the partial in the module/Application/view/partial/ folder, so that we can use the control in all our modules: <?php // in module/Application/view/partial/paginator.phtml: ?> <?php if ($this->pageCount): ?> <nav> <ul class=\"pagination\"> <!-- Previous page link --> <?php if (isset($this->previous)): ?> <li class=\"page-item\"> <a class=\"page-link\" href=\"<?= $this->url($this->route, [], ['query' => ['page' => $this->previous]]) ?>\"> Previous </a> </li> <?php else: ?> <li class=\"page-item disabled\"> <span class=\"page-link\">Previous</span> </li> <?php endif ?> <!-- Numbered page links --> <?php foreach ($this->pagesInRange as $page): ?> <?php if ($page !== $this->current): ?> <li class=\"page-item\"> <a class=\"page-link\" href=\"<?= $this->url($this->route, [], ['query' => ['page' => $page]]) ?>\"> <?= $page ?> </a> </li> <?php else: ?> <li class=\"page-item active\" aria-current=\"page\"> <span class=\"page-link\"><?= $page ?></span> </li> <?php endif ?> <?php endforeach ?> <!-- Next page link --> <?php if (isset($this->next)): ?> <li class=\"page-item\"> <a class=\"page-link\" href=\"<?= $this->url($this->route, [], ['query' => ['page' => $this->next]]) ?>\"> Next </a> </li> <?php else: ?> <li class=\"page-item disabled\"> <span class=\"page-link\">Next</span> </li> <?php endif ?> </ul> </nav> <?php endif ?> This partial creates a pagination control with links to the correct pages (if there is more than one page in the pagination object). It will render a previous page link (and mark it disabled if you are at the first page), then render a list of intermediate pages (that are passed to the partial based on the rendering style; we'll pass that to the view helper in the next step). Finally, it will create a next page link (and disable it if you're at the end). Notice how we pass the page number via the page querystring parameter which we have already told our controller to use to display the current page.","title":"Creating the Pagination Control Partial"},{"location":"unit-testing/","text":"Unit Testing a Laminas MVC application A solid unit test suite is essential for ongoing development in large projects, especially those with many people involved. Going back and manually testing every individual component of an application after every change is impractical. Your unit tests will help alleviate that by automatically testing your application's components and alerting you when something is not working the same way it was when you wrote your tests. This tutorial is written in the hopes of showing how to test different parts of a laminas-mvc application. As such, this tutorial will use the application written in the getting started user guide . It is in no way a guide to unit testing in general, but is here only to help overcome the initial hurdles in writing unit tests for laminas-mvc applications. It is recommended to have at least a basic understanding of unit tests, assertions and mocks. laminas-test , which provides testing integration for laminas-mvc, uses PHPUnit ; this tutorial will cover using that library for testing your applications. Installing laminas-test laminas-test provides PHPUnit integration for laminas-mvc, including application scaffolding and custom assertions. You will need to install it: $ composer require --dev laminas/laminas-test phpunit/phpunit laminas-test package supports very wide range of PHPUnit versions, make sure to always explicitly require phpunit/phpunit versions that are compatible with your tests. The above command will update your composer.json file and perform an update for you, which will also setup autoloading rules. Running the initial tests Out-of-the-box, the skeleton application provides several tests for the shipped Application\\Controller\\IndexController class. Now that you have laminas-test installed, you can run these: $ ./vendor/bin/phpunit PHPUnit invocation on Windows On Windows, you need to wrap the command in double quotes: $ \"vendor/bin/phpunit\" You should see output similar to the following: PHPUnit 9.0.1 by Sebastian Bergmann and contributors. ... 3 / 3 (100%) Time: 116 ms, Memory: 11.00MB OK (3 tests, 7 assertions) There might be 2 failing tests if you followed the getting started guide. This is because the Application\\IndexController is overridden by the AlbumController . This can be ignored for now. Now it's time to write our own tests! Setting up the tests directory As laminas-mvc applications are built from modules that should be standalone blocks of an application, we don't test the application in its entirety, but module by module. We will demonstrate setting up the minimum requirements to test a module, the Album module we wrote in the user guide, which then can be used as a base for testing any other module. Start by creating a directory called test under module/Album/ with the following subdirectories: module/ Album/ test/ Controller/ Additionally, add an autoload-dev rule in your composer.json : \"autoload-dev\": { \"psr-4\": { \"ApplicationTest\\\\\": \"module/Application/test/\", \"AlbumTest\\\\\": \"module/Album/test/\" } } When done, run: $ composer dump-autoload The structure of the test directory matches exactly with that of the module's source files, and it will allow you to keep your tests well-organized and easy to find. Bootstrapping your tests Next, edit the phpunit.xml.dist file at the project root; we'll add a new test suite to it. When done, it should read as follows: <?xml version=\"1.0\" encoding=\"UTF-8\"?> <phpunit colors=\"true\"> <testsuites> <testsuite name=\"Laminas MVC Application Test Suite\"> <directory>./module/Application/test</directory> </testsuite> <testsuite name=\"Album\"> <directory>./module/Album/test</directory> </testsuite> </testsuites> </phpunit> Now run your new Album test suite from the project root: $ ./vendor/bin/phpunit --testsuite Album Windows and PHPUnit On Windows, don't forget to wrap the phpunit command in double quotes: $ \"vendor/bin/phpunit\" --testsuite Album You should get similar output to the following: PHPUnit 9.0.1 by Sebastian Bergmann and contributors. Time: 0 seconds, Memory: 1.75Mb No tests executed! Let's write our first test! Your first controller test Testing controllers is never an easy task, but the laminas-test component makes testing much less cumbersome. First, create AlbumControllerTest.php under module/Album/test/Controller/ with the following contents: <?php namespace AlbumTest\\Controller; use Album\\Controller\\AlbumController; use Laminas\\Stdlib\\ArrayUtils; use Laminas\\Test\\PHPUnit\\Controller\\AbstractHttpControllerTestCase; class AlbumControllerTest extends AbstractHttpControllerTestCase { protected $traceError = false; protected function setUp() : void { // The module configuration should still be applicable for tests. // You can override configuration here with test case specific values, // such as sample view templates, path stacks, module_listener_options, // etc. $configOverrides = []; $this->setApplicationConfig(ArrayUtils::merge( // Grabbing the full application configuration: include __DIR__ . '/../../../../config/application.config.php', $configOverrides )); parent::setUp(); } } The AbstractHttpControllerTestCase class we extend here helps us setting up the application itself, helps with dispatching and other tasks that happen during a request, and offers methods for asserting request params, response headers, redirects, and more. See the laminas-test documentation for more information. The principal requirement for any laminas-test test case is to set the application config with the setApplicationConfig() method. For now, we assume the default application configuration will be appropriate; however, we can override values locally within the test using the $configOverrides variable. Now, add the following method to the AlbumControllerTest class: public function testIndexActionCanBeAccessed() { $this->dispatch('/album'); $this->assertResponseStatusCode(200); $this->assertModuleName('Album'); $this->assertControllerName(AlbumController::class); $this->assertControllerClass('AlbumController'); $this->assertMatchedRouteName('album'); } This test case dispatches the /album URL, asserts that the response code is 200, and that we ended up in the desired module and controller. Assert against controller service names For asserting the controller name we are using the controller name we defined in our routing configuration for the Album module. In our example this should be defined on line 16 of the module.config.php file in the Album module. If you run: $ ./vendor/bin/phpunit --testsuite Album again, you should see something like the following: PHPUnit 9.0.1 by Sebastian Bergmann and contributors. . 1 / 1 (100%) Time: 124 ms, Memory: 11.50MB OK (1 test, 5 assertions) A successful first test! A failing test case We likely don't want to hit the same database during testing as we use for our web property. Let's add some configuration to the test case to remove the database configuration. In your AlbumControllerTest::setUp() method, add the following lines right after the call to parent::setUp(); : $services = $this->getApplicationServiceLocator(); $config = $services->get('config'); unset($config['db']); $services->setAllowOverride(true); $services->setService('config', $config); $services->setAllowOverride(false); The above removes the 'db' configuration entirely; we'll be replacing it with something else before long. When we run the tests now: $ ./vendor/bin/phpunit --testsuite Album PHPUnit 9.0.1 by Sebastian Bergmann and contributors. F Time: 0 seconds, Memory: 8.50Mb There was 1 failure: 1) AlbumTest\\Controller\\AlbumControllerTest::testIndexActionCanBeAccessed Failed asserting response code \"200\", actual status code is \"500\" {projectPath}/vendor/laminas/laminas-test/src/PHPUnit/Controller/AbstractControllerTestCase.php:{lineNumber} {projectPath}/module/Album/test/AlbumTest/Controller/AlbumControllerTest.php:{lineNumber} FAILURES! Tests: 1, Assertions: 0, Failures: 1. The failure message doesn't tell us much, apart from that the expected status code is not 200, but 500. To get a bit more information when something goes wrong in a test case, we set the protected $traceError member to true (which is the default; we set it to false to demonstrate this capability). Modify the following line from just above the setUp method in our AlbumControllerTest class: protected $traceError = true; Running the phpunit command again and we should see some more information about what went wrong in our test. You'll get a list of the exceptions raised, along with their messages, the filename, and line number: 1) AlbumTest\\Controller\\AlbumControllerTest::testIndexActionCanBeAccessed Failed asserting response code \"200\", actual status code is \"500\" Exceptions raised: Exception 'Laminas\\ServiceManager\\Exception\\ServiceNotCreatedException' with message 'Service with name \"Laminas\\Db\\Adapter\\AdapterInterface\" could not be created. Reason: createDriver expects a \"driver\" key to be present inside the parameters' in {projectPath}/vendor/laminas/laminas-servicemanager/src/ServiceManager.php:{lineNumber} Exception 'Laminas\\Db\\Adapter\\Exception\\InvalidArgumentException' with message 'createDriver expects a \"driver\" key to be present inside the parameters' in {projectPath}/vendor/laminas/laminas-db/src/Adapter/Adapter.php:{lineNumber} Based on the exception messages, it appears we are unable to create a laminas-db adapter instance, due to missing configuration! Configuring the service manager for the tests The error says that the service manager can not create an instance of a database adapter for us. The database adapter is indirectly used by our Album\\Model\\AlbumTable to fetch the list of albums from the database. The first thought would be to create an instance of an adapter, pass it to the service manager, and let the code run from there as is. The problem with this approach is that we would end up with our test cases actually doing queries against the database. To keep our tests fast, and to reduce the number of possible failure points in our tests, this should be avoided. The second thought would be then to create a mock of the database adapter, and prevent the actual database calls by mocking them out. This is a much better approach, but creating the adapter mock is tedious (but no doubt we will have to create it at some point). The best thing to do would be to mock out our Album\\Model\\AlbumTable class which retrieves the list of albums from the database. Remember, we are now testing our controller, so we can mock out the actual call to fetchAll and replace the return values with dummy values. At this point, we are not interested in how fetchAll() retrieves the albums, but only that it gets called and that it returns an array of albums; these facts allow us to provide mock instances. When we test AlbumTable itself, we can write the actual tests for the fetchAll method. First, let's do some setup. Add import statements to the top of the test class file for each of the AlbumTable and ServiceManager classes: use Album\\Model\\AlbumTable; use Laminas\\ServiceManager\\ServiceManager; Now add the following property to the test class: protected $albumTable; Next, we'll create three new methods that we'll invoke during setup: protected function configureServiceManager(ServiceManager $services) { $services->setAllowOverride(true); $services->setService('config', $this->updateConfig($services->get('config'))); $services->setService(AlbumTable::class, $this->mockAlbumTable()->reveal()); $services->setAllowOverride(false); } protected function updateConfig($config) { $config['db'] = []; return $config; } protected function mockAlbumTable() { $this->albumTable = $this->prophesize(AlbumTable::class); return $this->albumTable; } By default, the ServiceManager does not allow us to replace existing services. configureServiceManager() calls a special method on the instance to enable overriding services, and then we inject specific overrides we wish to use. When done, we disable overrides to ensure that if, during dispatch, any code attempts to override a service, an exception will be raised. The last method above creates a mock instance of our AlbumTable using Prophecy , an object mocking framework that's bundled and integrated in PHPUnit. The instance returned by prophesize() is a scaffold object; calling reveal() on it, as done in the configureServiceManager() method above, provides the underlying mock object that will then be asserted against. With this in place, we can update our setUp() method to read as follows: protected function setUp() : void { // The module configuration should still be applicable for tests. // You can override configuration here with test case specific values, // such as sample view templates, path stacks, module_listener_options, // etc. $configOverrides = []; $this->setApplicationConfig(ArrayUtils::merge( include __DIR__ . '/../../../../config/application.config.php', $configOverrides )); parent::setUp(); $this->configureServiceManager($this->getApplicationServiceLocator()); } Now update the testIndexActionCanBeAccessed() method to add a line asserting the AlbumTable 's fetchAll() method will be called, and return an array: public function testIndexActionCanBeAccessed() { $this->albumTable->fetchAll()->willReturn([]); $this->dispatch('/album'); $this->assertResponseStatusCode(200); $this->assertModuleName('Album'); $this->assertControllerName(AlbumController::class); $this->assertControllerClass('AlbumController'); $this->assertMatchedRouteName('album'); } Running phpunit at this point, we will get the following output as the tests now pass: $ ./vendor/bin/phpunit --testsuite Album PHPUnit 9.0.1 by Sebastian Bergmann and contributors. . 1 / 1 (100%) Time: 105 ms, Memory: 10.75MB OK (1 test, 5 assertions) Testing actions with POST A common scenario with controllers is processing POST data submitted via a form, as we do in the AlbumController::addAction() . Let's write a test for that. public function testAddActionRedirectsAfterValidPost() { $this->albumTable ->saveAlbum(Argument::type(Album::class)) ->shouldBeCalled(); $postData = [ 'title' => 'Led Zeppelin III', 'artist' => 'Led Zeppelin', 'id' => '', ]; $this->dispatch('/album/add', 'POST', $postData); $this->assertResponseStatusCode(302); $this->assertRedirectTo('/album'); } This test case references two new classes that we need to import; add the following import statements at the top of the class file: use Album\\Model\\Album; use Prophecy\\Argument; Prophecy\\Argument allows us to perform assertions against the values passed as arguments to mock objects. In this case, we want to assert that we received an Album instance. (We could have also done deeper assertions to ensure the Album instance contained expected data.) When we dispatch the application this time, we use the request method POST, and pass data to it. This test case then asserts a 302 response status, and introduces a new assertion against the location to which the response redirects. Running phpunit gives us the following output: $ ./vendor/bin/phpunit --testsuite Album PHPUnit 9.0.1 by Sebastian Bergmann and contributors. .. 2 / 2 (100%) Time: 1.49 seconds, Memory: 13.25MB OK (2 tests, 8 assertions) Testing the editAction() and deleteAction() methods can be performed similarly; however, when testing the editAction() method, you will also need to assert against the AlbumTable::getAlbum() method: $this->albumTable->getAlbum($id)->willReturn(new Album()); Ideally, you should test all the various paths through each method. For example: Test that a non-POST request to addAction() displays an empty form. Test that a invalid data provided to addAction() re-displays the form, but with error messages. Test that absence of an identifier in the route parameters when invoking either editAction() or deleteAction() will redirect to the appropriate location. Test that an invalid identifier passed to editAction() will redirect to the album landing page. Test that non-POST requests to editAction() and deleteAction() display forms. and so on. Doing so will help you understand the paths through your application and controllers, as well as ensure that changes in behavior bubble up as test failures. Testing model entities Now that we know how to test our controllers, let us move to an other important part of our application: the model entity. Here we want to test that the initial state of the entity is what we expect it to be, that we can convert the model's parameters to and from an array, and that it has all the input filters we need. Create the file AlbumTest.php in module/Album/test/Model directory with the following contents: <?php namespace AlbumTest\\Model; use Album\\Model\\Album; use PHPUnit\\Framework\\TestCase; class AlbumTest extends TestCase { public function testInitialAlbumValuesAreNull() { $album = new Album(); $this->assertNull($album->artist, '\"artist\" should be null by default'); $this->assertNull($album->id, '\"id\" should be null by default'); $this->assertNull($album->title, '\"title\" should be null by default'); } public function testExchangeArraySetsPropertiesCorrectly() { $album = new Album(); $data = [ 'artist' => 'some artist', 'id' => 123, 'title' => 'some title' ]; $album->exchangeArray($data); $this->assertSame( $data['artist'], $album->artist, '\"artist\" was not set correctly' ); $this->assertSame( $data['id'], $album->id, '\"id\" was not set correctly' ); $this->assertSame( $data['title'], $album->title, '\"title\" was not set correctly' ); } public function testExchangeArraySetsPropertiesToNullIfKeysAreNotPresent() { $album = new Album(); $album->exchangeArray([ 'artist' => 'some artist', 'id' => 123, 'title' => 'some title', ]); $album->exchangeArray([]); $this->assertNull($album->artist, '\"artist\" should default to null'); $this->assertNull($album->id, '\"id\" should default to null'); $this->assertNull($album->title, '\"title\" should default to null'); } public function testGetArrayCopyReturnsAnArrayWithPropertyValues() { $album = new Album(); $data = [ 'artist' => 'some artist', 'id' => 123, 'title' => 'some title' ]; $album->exchangeArray($data); $copyArray = $album->getArrayCopy(); $this->assertSame($data['artist'], $copyArray['artist'], '\"artist\" was not set correctly'); $this->assertSame($data['id'], $copyArray['id'], '\"id\" was not set correctly'); $this->assertSame($data['title'], $copyArray['title'], '\"title\" was not set correctly'); } public function testInputFiltersAreSetCorrectly() { $album = new Album(); $inputFilter = $album->getInputFilter(); $this->assertSame(3, $inputFilter->count()); $this->assertTrue($inputFilter->has('artist')); $this->assertTrue($inputFilter->has('id')); $this->assertTrue($inputFilter->has('title')); } } We are testing for 5 things: Are all of the Album 's properties initially set to NULL ? Will the Album 's properties be set correctly when we call exchangeArray() ? Will a default value of NULL be used for properties whose keys are not present in the $data array? Can we get an array copy of our model? Do all elements have input filters present? If we run phpunit again, we will get the following output, confirming that our model is indeed correct: $ ./vendor/bin/phpunit --testsuite Album PHPUnit 9.0.1 by Sebastian Bergmann and contributors. ....... 7 / 7 (100%) Time: 186 ms, Memory: 13.75MB OK (7 tests, 24 assertions) Testing model tables The final step in this unit testing tutorial for laminas-mvc applications is writing tests for our model tables. This test assures that we can get a list of albums, or one album by its ID, and that we can save and delete albums from the database. To avoid actual interaction with the database itself, we will replace certain parts with mocks. Create a file AlbumTableTest.php in module/Album/test/Model/ with the following contents: <?php namespace AlbumTest\\Model; use Album\\Model\\AlbumTable; use Album\\Model\\Album; use PHPUnit\\Framework\\TestCase; use RuntimeException; use Laminas\\Db\\ResultSet\\ResultSetInterface; use Laminas\\Db\\TableGateway\\TableGatewayInterface; class AlbumTableTest extends TestCase { protected function setUp() : void { $this->tableGateway = $this->prophesize(TableGatewayInterface::class); $this->albumTable = new AlbumTable($this->tableGateway->reveal()); } public function testFetchAllReturnsAllAlbums() { $resultSet = $this->prophesize(ResultSetInterface::class)->reveal(); $this->tableGateway->select()->willReturn($resultSet); $this->assertSame($resultSet, $this->albumTable->fetchAll()); } } Since we are testing the AlbumTable here and not the TableGateway class (which has already been tested in laminas-db), we only want to make sure that our AlbumTable class is interacting with the TableGateway class the way that we expect it to. Above, we're testing to see if the fetchAll() method of AlbumTable will call the select() method of the $tableGateway property with no parameters. If it does, it should return a ResultSet instance. Finally, we expect that this same ResultSet object will be returned to the calling method. This test should run fine, so now we can add the rest of the test methods: public function testCanDeleteAnAlbumByItsId() { $this->tableGateway->delete(['id' => 123])->shouldBeCalled(); $this->albumTable->deleteAlbum(123); } public function testSaveAlbumWillInsertNewAlbumsIfTheyDontAlreadyHaveAnId() { $albumData = [ 'artist' => 'The Military Wives', 'title' => 'In My Dreams' ]; $album = new Album(); $album->exchangeArray($albumData); $this->tableGateway->insert($albumData)->shouldBeCalled(); $this->albumTable->saveAlbum($album); } public function testSaveAlbumWillUpdateExistingAlbumsIfTheyAlreadyHaveAnId() { $albumData = [ 'id' => 123, 'artist' => 'The Military Wives', 'title' => 'In My Dreams', ]; $album = new Album(); $album->exchangeArray($albumData); $resultSet = $this->prophesize(ResultSetInterface::class); $resultSet->current()->willReturn($album); $this->tableGateway ->select(['id' => 123]) ->willReturn($resultSet->reveal()); $this->tableGateway ->update( array_filter($albumData, function ($key) { return in_array($key, ['artist', 'title']); }, ARRAY_FILTER_USE_KEY), ['id' => 123] )->shouldBeCalled(); $this->albumTable->saveAlbum($album); } public function testExceptionIsThrownWhenGettingNonExistentAlbum() { $resultSet = $this->prophesize(ResultSetInterface::class); $resultSet->current()->willReturn(null); $this->tableGateway ->select(['id' => 123]) ->willReturn($resultSet->reveal()); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Could not find row with identifier 123'); $this->albumTable->getAlbum(123); } These tests are nothing complicated and should be self explanatory. In each test, we add assertions to our mock table gateway, and then call and assert against methods in our AlbumTable . We are testing that: We can retrieve an individual album by its ID. We can delete albums. We can save a new album. We can update existing albums. We will encounter an exception if we're trying to retrieve an album that doesn't exist. Running phpunit one last time, we get the output as follows: $ ./vendor/bin/phpunit --testsuite Album PHPUnit 9.0.1 by Sebastian Bergmann and contributors. ............. 13 / 13 (100%) Time: 151 ms, Memory: 14.00MB OK (13 tests, 31 assertions) Conclusion In this short tutorial, we gave a few examples how different parts of a laminas-mvc application can be tested. We covered setting up the environment for testing, how to test controllers and actions, how to approach failing test cases, how to configure the service manager, as well as how to test model entities and model tables. This tutorial is by no means a definitive guide to writing unit tests, just a small stepping stone helping you develop applications of higher quality.","title":"Unit Testing A laminas-mvc Application"},{"location":"unit-testing/#unit-testing-a-laminas-mvc-application","text":"A solid unit test suite is essential for ongoing development in large projects, especially those with many people involved. Going back and manually testing every individual component of an application after every change is impractical. Your unit tests will help alleviate that by automatically testing your application's components and alerting you when something is not working the same way it was when you wrote your tests. This tutorial is written in the hopes of showing how to test different parts of a laminas-mvc application. As such, this tutorial will use the application written in the getting started user guide . It is in no way a guide to unit testing in general, but is here only to help overcome the initial hurdles in writing unit tests for laminas-mvc applications. It is recommended to have at least a basic understanding of unit tests, assertions and mocks. laminas-test , which provides testing integration for laminas-mvc, uses PHPUnit ; this tutorial will cover using that library for testing your applications.","title":"Unit Testing a Laminas MVC application"},{"location":"unit-testing/#installing-laminas-test","text":"laminas-test provides PHPUnit integration for laminas-mvc, including application scaffolding and custom assertions. You will need to install it: $ composer require --dev laminas/laminas-test phpunit/phpunit laminas-test package supports very wide range of PHPUnit versions, make sure to always explicitly require phpunit/phpunit versions that are compatible with your tests. The above command will update your composer.json file and perform an update for you, which will also setup autoloading rules.","title":"Installing laminas-test"},{"location":"unit-testing/#running-the-initial-tests","text":"Out-of-the-box, the skeleton application provides several tests for the shipped Application\\Controller\\IndexController class. Now that you have laminas-test installed, you can run these: $ ./vendor/bin/phpunit","title":"Running the initial tests"},{"location":"unit-testing/#setting-up-the-tests-directory","text":"As laminas-mvc applications are built from modules that should be standalone blocks of an application, we don't test the application in its entirety, but module by module. We will demonstrate setting up the minimum requirements to test a module, the Album module we wrote in the user guide, which then can be used as a base for testing any other module. Start by creating a directory called test under module/Album/ with the following subdirectories: module/ Album/ test/ Controller/ Additionally, add an autoload-dev rule in your composer.json : \"autoload-dev\": { \"psr-4\": { \"ApplicationTest\\\\\": \"module/Application/test/\", \"AlbumTest\\\\\": \"module/Album/test/\" } } When done, run: $ composer dump-autoload The structure of the test directory matches exactly with that of the module's source files, and it will allow you to keep your tests well-organized and easy to find.","title":"Setting up the tests directory"},{"location":"unit-testing/#bootstrapping-your-tests","text":"Next, edit the phpunit.xml.dist file at the project root; we'll add a new test suite to it. When done, it should read as follows: <?xml version=\"1.0\" encoding=\"UTF-8\"?> <phpunit colors=\"true\"> <testsuites> <testsuite name=\"Laminas MVC Application Test Suite\"> <directory>./module/Application/test</directory> </testsuite> <testsuite name=\"Album\"> <directory>./module/Album/test</directory> </testsuite> </testsuites> </phpunit> Now run your new Album test suite from the project root: $ ./vendor/bin/phpunit --testsuite Album","title":"Bootstrapping your tests"},{"location":"unit-testing/#your-first-controller-test","text":"Testing controllers is never an easy task, but the laminas-test component makes testing much less cumbersome. First, create AlbumControllerTest.php under module/Album/test/Controller/ with the following contents: <?php namespace AlbumTest\\Controller; use Album\\Controller\\AlbumController; use Laminas\\Stdlib\\ArrayUtils; use Laminas\\Test\\PHPUnit\\Controller\\AbstractHttpControllerTestCase; class AlbumControllerTest extends AbstractHttpControllerTestCase { protected $traceError = false; protected function setUp() : void { // The module configuration should still be applicable for tests. // You can override configuration here with test case specific values, // such as sample view templates, path stacks, module_listener_options, // etc. $configOverrides = []; $this->setApplicationConfig(ArrayUtils::merge( // Grabbing the full application configuration: include __DIR__ . '/../../../../config/application.config.php', $configOverrides )); parent::setUp(); } } The AbstractHttpControllerTestCase class we extend here helps us setting up the application itself, helps with dispatching and other tasks that happen during a request, and offers methods for asserting request params, response headers, redirects, and more. See the laminas-test documentation for more information. The principal requirement for any laminas-test test case is to set the application config with the setApplicationConfig() method. For now, we assume the default application configuration will be appropriate; however, we can override values locally within the test using the $configOverrides variable. Now, add the following method to the AlbumControllerTest class: public function testIndexActionCanBeAccessed() { $this->dispatch('/album'); $this->assertResponseStatusCode(200); $this->assertModuleName('Album'); $this->assertControllerName(AlbumController::class); $this->assertControllerClass('AlbumController'); $this->assertMatchedRouteName('album'); } This test case dispatches the /album URL, asserts that the response code is 200, and that we ended up in the desired module and controller.","title":"Your first controller test"},{"location":"unit-testing/#a-failing-test-case","text":"We likely don't want to hit the same database during testing as we use for our web property. Let's add some configuration to the test case to remove the database configuration. In your AlbumControllerTest::setUp() method, add the following lines right after the call to parent::setUp(); : $services = $this->getApplicationServiceLocator(); $config = $services->get('config'); unset($config['db']); $services->setAllowOverride(true); $services->setService('config', $config); $services->setAllowOverride(false); The above removes the 'db' configuration entirely; we'll be replacing it with something else before long. When we run the tests now: $ ./vendor/bin/phpunit --testsuite Album PHPUnit 9.0.1 by Sebastian Bergmann and contributors. F Time: 0 seconds, Memory: 8.50Mb There was 1 failure: 1) AlbumTest\\Controller\\AlbumControllerTest::testIndexActionCanBeAccessed Failed asserting response code \"200\", actual status code is \"500\" {projectPath}/vendor/laminas/laminas-test/src/PHPUnit/Controller/AbstractControllerTestCase.php:{lineNumber} {projectPath}/module/Album/test/AlbumTest/Controller/AlbumControllerTest.php:{lineNumber} FAILURES! Tests: 1, Assertions: 0, Failures: 1. The failure message doesn't tell us much, apart from that the expected status code is not 200, but 500. To get a bit more information when something goes wrong in a test case, we set the protected $traceError member to true (which is the default; we set it to false to demonstrate this capability). Modify the following line from just above the setUp method in our AlbumControllerTest class: protected $traceError = true; Running the phpunit command again and we should see some more information about what went wrong in our test. You'll get a list of the exceptions raised, along with their messages, the filename, and line number: 1) AlbumTest\\Controller\\AlbumControllerTest::testIndexActionCanBeAccessed Failed asserting response code \"200\", actual status code is \"500\" Exceptions raised: Exception 'Laminas\\ServiceManager\\Exception\\ServiceNotCreatedException' with message 'Service with name \"Laminas\\Db\\Adapter\\AdapterInterface\" could not be created. Reason: createDriver expects a \"driver\" key to be present inside the parameters' in {projectPath}/vendor/laminas/laminas-servicemanager/src/ServiceManager.php:{lineNumber} Exception 'Laminas\\Db\\Adapter\\Exception\\InvalidArgumentException' with message 'createDriver expects a \"driver\" key to be present inside the parameters' in {projectPath}/vendor/laminas/laminas-db/src/Adapter/Adapter.php:{lineNumber} Based on the exception messages, it appears we are unable to create a laminas-db adapter instance, due to missing configuration!","title":"A failing test case"},{"location":"unit-testing/#configuring-the-service-manager-for-the-tests","text":"The error says that the service manager can not create an instance of a database adapter for us. The database adapter is indirectly used by our Album\\Model\\AlbumTable to fetch the list of albums from the database. The first thought would be to create an instance of an adapter, pass it to the service manager, and let the code run from there as is. The problem with this approach is that we would end up with our test cases actually doing queries against the database. To keep our tests fast, and to reduce the number of possible failure points in our tests, this should be avoided. The second thought would be then to create a mock of the database adapter, and prevent the actual database calls by mocking them out. This is a much better approach, but creating the adapter mock is tedious (but no doubt we will have to create it at some point). The best thing to do would be to mock out our Album\\Model\\AlbumTable class which retrieves the list of albums from the database. Remember, we are now testing our controller, so we can mock out the actual call to fetchAll and replace the return values with dummy values. At this point, we are not interested in how fetchAll() retrieves the albums, but only that it gets called and that it returns an array of albums; these facts allow us to provide mock instances. When we test AlbumTable itself, we can write the actual tests for the fetchAll method. First, let's do some setup. Add import statements to the top of the test class file for each of the AlbumTable and ServiceManager classes: use Album\\Model\\AlbumTable; use Laminas\\ServiceManager\\ServiceManager; Now add the following property to the test class: protected $albumTable; Next, we'll create three new methods that we'll invoke during setup: protected function configureServiceManager(ServiceManager $services) { $services->setAllowOverride(true); $services->setService('config', $this->updateConfig($services->get('config'))); $services->setService(AlbumTable::class, $this->mockAlbumTable()->reveal()); $services->setAllowOverride(false); } protected function updateConfig($config) { $config['db'] = []; return $config; } protected function mockAlbumTable() { $this->albumTable = $this->prophesize(AlbumTable::class); return $this->albumTable; } By default, the ServiceManager does not allow us to replace existing services. configureServiceManager() calls a special method on the instance to enable overriding services, and then we inject specific overrides we wish to use. When done, we disable overrides to ensure that if, during dispatch, any code attempts to override a service, an exception will be raised. The last method above creates a mock instance of our AlbumTable using Prophecy , an object mocking framework that's bundled and integrated in PHPUnit. The instance returned by prophesize() is a scaffold object; calling reveal() on it, as done in the configureServiceManager() method above, provides the underlying mock object that will then be asserted against. With this in place, we can update our setUp() method to read as follows: protected function setUp() : void { // The module configuration should still be applicable for tests. // You can override configuration here with test case specific values, // such as sample view templates, path stacks, module_listener_options, // etc. $configOverrides = []; $this->setApplicationConfig(ArrayUtils::merge( include __DIR__ . '/../../../../config/application.config.php', $configOverrides )); parent::setUp(); $this->configureServiceManager($this->getApplicationServiceLocator()); } Now update the testIndexActionCanBeAccessed() method to add a line asserting the AlbumTable 's fetchAll() method will be called, and return an array: public function testIndexActionCanBeAccessed() { $this->albumTable->fetchAll()->willReturn([]); $this->dispatch('/album'); $this->assertResponseStatusCode(200); $this->assertModuleName('Album'); $this->assertControllerName(AlbumController::class); $this->assertControllerClass('AlbumController'); $this->assertMatchedRouteName('album'); } Running phpunit at this point, we will get the following output as the tests now pass: $ ./vendor/bin/phpunit --testsuite Album PHPUnit 9.0.1 by Sebastian Bergmann and contributors. . 1 / 1 (100%) Time: 105 ms, Memory: 10.75MB OK (1 test, 5 assertions)","title":"Configuring the service manager for the tests"},{"location":"unit-testing/#testing-actions-with-post","text":"A common scenario with controllers is processing POST data submitted via a form, as we do in the AlbumController::addAction() . Let's write a test for that. public function testAddActionRedirectsAfterValidPost() { $this->albumTable ->saveAlbum(Argument::type(Album::class)) ->shouldBeCalled(); $postData = [ 'title' => 'Led Zeppelin III', 'artist' => 'Led Zeppelin', 'id' => '', ]; $this->dispatch('/album/add', 'POST', $postData); $this->assertResponseStatusCode(302); $this->assertRedirectTo('/album'); } This test case references two new classes that we need to import; add the following import statements at the top of the class file: use Album\\Model\\Album; use Prophecy\\Argument; Prophecy\\Argument allows us to perform assertions against the values passed as arguments to mock objects. In this case, we want to assert that we received an Album instance. (We could have also done deeper assertions to ensure the Album instance contained expected data.) When we dispatch the application this time, we use the request method POST, and pass data to it. This test case then asserts a 302 response status, and introduces a new assertion against the location to which the response redirects. Running phpunit gives us the following output: $ ./vendor/bin/phpunit --testsuite Album PHPUnit 9.0.1 by Sebastian Bergmann and contributors. .. 2 / 2 (100%) Time: 1.49 seconds, Memory: 13.25MB OK (2 tests, 8 assertions) Testing the editAction() and deleteAction() methods can be performed similarly; however, when testing the editAction() method, you will also need to assert against the AlbumTable::getAlbum() method: $this->albumTable->getAlbum($id)->willReturn(new Album()); Ideally, you should test all the various paths through each method. For example: Test that a non-POST request to addAction() displays an empty form. Test that a invalid data provided to addAction() re-displays the form, but with error messages. Test that absence of an identifier in the route parameters when invoking either editAction() or deleteAction() will redirect to the appropriate location. Test that an invalid identifier passed to editAction() will redirect to the album landing page. Test that non-POST requests to editAction() and deleteAction() display forms. and so on. Doing so will help you understand the paths through your application and controllers, as well as ensure that changes in behavior bubble up as test failures.","title":"Testing actions with POST"},{"location":"unit-testing/#testing-model-entities","text":"Now that we know how to test our controllers, let us move to an other important part of our application: the model entity. Here we want to test that the initial state of the entity is what we expect it to be, that we can convert the model's parameters to and from an array, and that it has all the input filters we need. Create the file AlbumTest.php in module/Album/test/Model directory with the following contents: <?php namespace AlbumTest\\Model; use Album\\Model\\Album; use PHPUnit\\Framework\\TestCase; class AlbumTest extends TestCase { public function testInitialAlbumValuesAreNull() { $album = new Album(); $this->assertNull($album->artist, '\"artist\" should be null by default'); $this->assertNull($album->id, '\"id\" should be null by default'); $this->assertNull($album->title, '\"title\" should be null by default'); } public function testExchangeArraySetsPropertiesCorrectly() { $album = new Album(); $data = [ 'artist' => 'some artist', 'id' => 123, 'title' => 'some title' ]; $album->exchangeArray($data); $this->assertSame( $data['artist'], $album->artist, '\"artist\" was not set correctly' ); $this->assertSame( $data['id'], $album->id, '\"id\" was not set correctly' ); $this->assertSame( $data['title'], $album->title, '\"title\" was not set correctly' ); } public function testExchangeArraySetsPropertiesToNullIfKeysAreNotPresent() { $album = new Album(); $album->exchangeArray([ 'artist' => 'some artist', 'id' => 123, 'title' => 'some title', ]); $album->exchangeArray([]); $this->assertNull($album->artist, '\"artist\" should default to null'); $this->assertNull($album->id, '\"id\" should default to null'); $this->assertNull($album->title, '\"title\" should default to null'); } public function testGetArrayCopyReturnsAnArrayWithPropertyValues() { $album = new Album(); $data = [ 'artist' => 'some artist', 'id' => 123, 'title' => 'some title' ]; $album->exchangeArray($data); $copyArray = $album->getArrayCopy(); $this->assertSame($data['artist'], $copyArray['artist'], '\"artist\" was not set correctly'); $this->assertSame($data['id'], $copyArray['id'], '\"id\" was not set correctly'); $this->assertSame($data['title'], $copyArray['title'], '\"title\" was not set correctly'); } public function testInputFiltersAreSetCorrectly() { $album = new Album(); $inputFilter = $album->getInputFilter(); $this->assertSame(3, $inputFilter->count()); $this->assertTrue($inputFilter->has('artist')); $this->assertTrue($inputFilter->has('id')); $this->assertTrue($inputFilter->has('title')); } } We are testing for 5 things: Are all of the Album 's properties initially set to NULL ? Will the Album 's properties be set correctly when we call exchangeArray() ? Will a default value of NULL be used for properties whose keys are not present in the $data array? Can we get an array copy of our model? Do all elements have input filters present? If we run phpunit again, we will get the following output, confirming that our model is indeed correct: $ ./vendor/bin/phpunit --testsuite Album PHPUnit 9.0.1 by Sebastian Bergmann and contributors. ....... 7 / 7 (100%) Time: 186 ms, Memory: 13.75MB OK (7 tests, 24 assertions)","title":"Testing model entities"},{"location":"unit-testing/#testing-model-tables","text":"The final step in this unit testing tutorial for laminas-mvc applications is writing tests for our model tables. This test assures that we can get a list of albums, or one album by its ID, and that we can save and delete albums from the database. To avoid actual interaction with the database itself, we will replace certain parts with mocks. Create a file AlbumTableTest.php in module/Album/test/Model/ with the following contents: <?php namespace AlbumTest\\Model; use Album\\Model\\AlbumTable; use Album\\Model\\Album; use PHPUnit\\Framework\\TestCase; use RuntimeException; use Laminas\\Db\\ResultSet\\ResultSetInterface; use Laminas\\Db\\TableGateway\\TableGatewayInterface; class AlbumTableTest extends TestCase { protected function setUp() : void { $this->tableGateway = $this->prophesize(TableGatewayInterface::class); $this->albumTable = new AlbumTable($this->tableGateway->reveal()); } public function testFetchAllReturnsAllAlbums() { $resultSet = $this->prophesize(ResultSetInterface::class)->reveal(); $this->tableGateway->select()->willReturn($resultSet); $this->assertSame($resultSet, $this->albumTable->fetchAll()); } } Since we are testing the AlbumTable here and not the TableGateway class (which has already been tested in laminas-db), we only want to make sure that our AlbumTable class is interacting with the TableGateway class the way that we expect it to. Above, we're testing to see if the fetchAll() method of AlbumTable will call the select() method of the $tableGateway property with no parameters. If it does, it should return a ResultSet instance. Finally, we expect that this same ResultSet object will be returned to the calling method. This test should run fine, so now we can add the rest of the test methods: public function testCanDeleteAnAlbumByItsId() { $this->tableGateway->delete(['id' => 123])->shouldBeCalled(); $this->albumTable->deleteAlbum(123); } public function testSaveAlbumWillInsertNewAlbumsIfTheyDontAlreadyHaveAnId() { $albumData = [ 'artist' => 'The Military Wives', 'title' => 'In My Dreams' ]; $album = new Album(); $album->exchangeArray($albumData); $this->tableGateway->insert($albumData)->shouldBeCalled(); $this->albumTable->saveAlbum($album); } public function testSaveAlbumWillUpdateExistingAlbumsIfTheyAlreadyHaveAnId() { $albumData = [ 'id' => 123, 'artist' => 'The Military Wives', 'title' => 'In My Dreams', ]; $album = new Album(); $album->exchangeArray($albumData); $resultSet = $this->prophesize(ResultSetInterface::class); $resultSet->current()->willReturn($album); $this->tableGateway ->select(['id' => 123]) ->willReturn($resultSet->reveal()); $this->tableGateway ->update( array_filter($albumData, function ($key) { return in_array($key, ['artist', 'title']); }, ARRAY_FILTER_USE_KEY), ['id' => 123] )->shouldBeCalled(); $this->albumTable->saveAlbum($album); } public function testExceptionIsThrownWhenGettingNonExistentAlbum() { $resultSet = $this->prophesize(ResultSetInterface::class); $resultSet->current()->willReturn(null); $this->tableGateway ->select(['id' => 123]) ->willReturn($resultSet->reveal()); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Could not find row with identifier 123'); $this->albumTable->getAlbum(123); } These tests are nothing complicated and should be self explanatory. In each test, we add assertions to our mock table gateway, and then call and assert against methods in our AlbumTable . We are testing that: We can retrieve an individual album by its ID. We can delete albums. We can save a new album. We can update existing albums. We will encounter an exception if we're trying to retrieve an album that doesn't exist. Running phpunit one last time, we get the output as follows: $ ./vendor/bin/phpunit --testsuite Album PHPUnit 9.0.1 by Sebastian Bergmann and contributors. ............. 13 / 13 (100%) Time: 151 ms, Memory: 14.00MB OK (13 tests, 31 assertions)","title":"Testing model tables"},{"location":"unit-testing/#conclusion","text":"In this short tutorial, we gave a few examples how different parts of a laminas-mvc application can be tested. We covered setting up the environment for testing, how to test controllers and actions, how to approach failing test cases, how to configure the service manager, as well as how to test model entities and model tables. This tutorial is by no means a definitive guide to writing unit tests, just a small stepping stone helping you develop applications of higher quality.","title":"Conclusion"},{"location":"getting-started/conclusion/","text":"Conclusion This concludes our brief look at building a simple, but fully functional, Laminas laminas-mvc application. In this tutorial we but briefly touched quite a number of different parts of the framework. The most important part of applications built with laminas-mvc are the modules , the building blocks of any laminas-mvc application . To ease the work with dependencies inside our applications, we use the service manager . To be able to map a request to controllers and their actions, we use routes . Data persistence was performed using laminas-db to communicate with a relational database. Input data is filtered and validated with input filters , and, together with laminas-form , they provide a strong bridge between the domain model and the view layer. laminas-view is responsible for the View in the MVC stack, together with a vast amount of view helpers .","title":"Conclusion"},{"location":"getting-started/conclusion/#conclusion","text":"This concludes our brief look at building a simple, but fully functional, Laminas laminas-mvc application. In this tutorial we but briefly touched quite a number of different parts of the framework. The most important part of applications built with laminas-mvc are the modules , the building blocks of any laminas-mvc application . To ease the work with dependencies inside our applications, we use the service manager . To be able to map a request to controllers and their actions, we use routes . Data persistence was performed using laminas-db to communicate with a relational database. Input data is filtered and validated with input filters , and, together with laminas-form , they provide a strong bridge between the domain model and the view layer. laminas-view is responsible for the View in the MVC stack, together with a vast amount of view helpers .","title":"Conclusion"},{"location":"getting-started/database-and-models/","text":"Database and models The database Now that we have the Album module set up with controller action methods and view scripts, it is time to look at the model section of our application. Remember that the model is the part that deals with the application's core purpose (the so-called “business rules”) and, in our case, deals with the database. We will make use of laminas-db's Laminas\\Db\\TableGateway\\TableGateway to find, insert, update, and delete rows from a database table. We are going to use Sqlite, via PHP's PDO driver. Create a text file data/schema.sql with the following contents: CREATE TABLE album (id INTEGER PRIMARY KEY AUTOINCREMENT, artist varchar(100) NOT NULL, title varchar(100) NOT NULL); INSERT INTO album (artist, title) VALUES ('The Military Wives', 'In My Dreams'); INSERT INTO album (artist, title) VALUES ('Adele', '21'); INSERT INTO album (artist, title) VALUES ('Bruce Springsteen', 'Wrecking Ball (Deluxe)'); INSERT INTO album (artist, title) VALUES ('Lana Del Rey', 'Born To Die'); INSERT INTO album (artist, title) VALUES ('Gotye', 'Making Mirrors'); (The test data chosen happens to be the Bestsellers on Amazon UK at the time of writing!) Now create the database using the following: $ sqlite data/laminastutorial.db < data/schema.sql Alternative Commands SQLite3 Some systems, including Ubuntu, use the command sqlite3 ; check to see which one to use on your system. If you use sqlite3 create the database using the following command: $ cat data/schema.sql | sqlite3 data/laminastutorial.db Using PHP to Create the Database If you do not have Sqlite installed on your system, you can use PHP to load the database using the same SQL schema file created earlier. Create the file data/load_db.php with the following contents: <?php $db = new PDO('sqlite:' . realpath(__DIR__) . '/laminastutorial.db'); $fh = fopen(__DIR__ . '/schema.sql', 'r'); while ($line = fread($fh, 4096)) { $db->exec($line); } fclose($fh); Once created, execute it: $ php data/load_db.php We now have some data in a database and can write a very simple model for it. The model files Laminas does not provide a laminas-model component because the model is your business logic, and it's up to you to decide how you want it to work. There are many components that you can use for this depending on your needs. One approach is to have model classes represent each entity in your application and then use mapper objects that load and save entities to the database. Another is to use an Object-Relational Mapping (ORM) technology, such as Doctrine or Propel. For this tutorial, we are going to create a model by creating an AlbumTable class that consumes a Laminas\\Db\\TableGateway\\TableGateway , and in which each album will be represented as an Album object (known as an entity ). This is an implementation of the Table Data Gateway design pattern to allow for interfacing with data in a database table. Be aware, though, that the Table Data Gateway pattern can become limiting in larger systems. There is also a temptation to put database access code into controller action methods as these are exposed by Laminas\\Db\\TableGateway\\AbstractTableGateway . Don't do this ! Let's start by creating a file called Album.php under module/Album/src/Model : namespace Album\\Model; class Album { public $id; public $artist; public $title; public function exchangeArray(array $array): void { $this->id = ! empty($array['id']) ? $array['id'] : null; $this->artist = ! empty($array['artist']) ? $array['artist'] : null; $this->title = ! empty($array['title']) ? $array['title'] : null; } } Our Album entity object is a PHP class. In order to work with laminas-db's TableGateway class, we need to implement the exchangeArray() method; this method copies the data from the provided array to our entity's properties. We will add an input filter later to ensure the values injected are valid. Next, we create our AlbumTable.php file in module/Album/src/Model directory like this: namespace Album\\Model; use RuntimeException; use Laminas\\Db\\TableGateway\\TableGatewayInterface; class AlbumTable { private $tableGateway; public function __construct(TableGatewayInterface $tableGateway) { $this->tableGateway = $tableGateway; } public function fetchAll() { return $this->tableGateway->select(); } public function getAlbum($id) { $id = (int) $id; $rowset = $this->tableGateway->select(['id' => $id]); $row = $rowset->current(); if (! $row) { throw new RuntimeException(sprintf( 'Could not find row with identifier %d', $id )); } return $row; } public function saveAlbum(Album $album) { $data = [ 'artist' => $album->artist, 'title' => $album->title, ]; $id = (int) $album->id; if ($id === 0) { $this->tableGateway->insert($data); return; } try { $this->getAlbum($id); } catch (RuntimeException $e) { throw new RuntimeException(sprintf( 'Cannot update album with identifier %d; does not exist', $id )); } $this->tableGateway->update($data, ['id' => $id]); } public function deleteAlbum($id) { $this->tableGateway->delete(['id' => (int) $id]); } } There's a lot going on here. Firstly, we set the protected property $tableGateway to the TableGateway instance passed in the constructor, hinting against the TableGatewayInterface (which allows us to provide alternate implementations easily, including mock instances during testing). We will use this to perform operations on the database table for our albums. We then create some helper methods that our application will use to interface with the table gateway. fetchAll() retrieves all albums rows from the database as a ResultSet , getAlbum() retrieves a single row as an Album object, saveAlbum() either creates a new row in the database or updates a row that already exists, and deleteAlbum() removes the row completely. The code for each of these methods is, hopefully, self-explanatory. Using ServiceManager to configure the table gateway and inject into the AlbumTable In order to always use the same instance of our AlbumTable , we will use the ServiceManager to define how to create one. This is most easily done by adding a ServiceManager configuration to the module.config.php which is automatically loaded by the ModuleManager and applied to the ServiceManager . We'll then be able to retrieve the AlbumTable when we need it. To configure the ServiceManager , we can either supply the name of the class to be instantiated and a factory (closure, callback, or class name of a factory class) that instantiates the object when the ServiceManager needs it. Add a service_manager configuration to module/Album/config/module.config.php : namespace Album; use Album\\Model\\AlbumTableFactory; use Laminas\\Router\\Http\\Segment; use Laminas\\ServiceManager\\Factory\\InvokableFactory; return [ 'controllers' => [ // ... ], 'router' => [ // .. ], 'view_manager' => [ // ... ], 'service_manager' => [ 'factories' => [ Model\\AlbumTable::class => AlbumTableFactory::class, ], ], ]; This method returns an array of factories that are all merged together by the ModuleManager before passing them to the ServiceManager . When requesting the ServiceManager to create Album\\Model\\AlbumTable , the ServiceManager will invoke the AlbumTableFactory class, which we need to create next. Let's create the AlbumTableFactory.php factory in module/Album/src/Model : namespace Album\\Model; use Laminas\\Db\\Adapter\\AdapterInterface; use Laminas\\Db\\ResultSet\\ResultSet; use Laminas\\Db\\TableGateway\\TableGateway; use Laminas\\ServiceManager\\Factory\\FactoryInterface; use Psr\\Container\\ContainerInterface; class AlbumTableFactory implements FactoryInterface { public function __invoke(ContainerInterface $container, $requestedName, ?array $options = null): AlbumTable { $dbAdapter = $container->get(AdapterInterface::class); $resultSetPrototype = new ResultSet(); $resultSetPrototype->setArrayObjectPrototype(new Album()); $tableGateway = new TableGateway('album', $dbAdapter, null, $resultSetPrototype); return new AlbumTable($tableGateway); } } The AlbumTableFactory factory uses the ServiceManager to fetch a Laminas\\Db\\Adapter\\AdapterInterface implementation (also from the ServiceManager ) and use it to create a TableGateway object. The TableGateway is told to use an Album object whenever it creates a new result row. The TableGateway classes use the prototype pattern for creation of result sets and entities. This means that instead of instantiating when required, the system clones a previously instantiated object. Then, finally, the factory creates a AlbumTable object passing it the TableGateway object. See PHP Constructor Best Practices and the Prototype Pattern for more details. Factories The above demonstrates building factories as a class and mapping the class factory in your module configuration. Another option would have been to use a closure that contains the same code a the AlbumTableFactory . Using a class for the factory has a number of benefits: The code is not parsed or executed unless the factory is invoked. You can easily unit test the factory to ensure it does what it should. You can extend the factory if desired. You can re-use the factory across multiple instances that have related construction. Creating factories is covered in the laminas-servicemanager documentation . The Laminas\\Db\\Adapter\\AdapterInterface service is registered by the laminas-db component. You may have noticed earlier that config/modules.config.php contains the following entries: return [ 'Laminas\\Form', 'Laminas\\Db', 'Laminas\\Router', 'Laminas\\Validator', /* ... */ ], All Laminas components that provide laminas-servicemanager configuration are also exposed as modules themselves; the prompts as to where to register the components during our initial installation occurred to ensure that the above entries are created for you. The end result is that we can already rely on having a factory for the Laminas\\Db\\Adapter\\AdapterInterface service; now we need to provide configuration so it can create an adapter for us. Laminas's ModuleManager merges all the configuration from each module's module.config.php file, and then merges in the files in config/autoload/ (first *.global.php files, and then *.local.php files). We'll add our database configuration information to global.php , which you should commit to your version control system. You can use local.php (outside of the VCS) to store the credentials for your database if you want to. Modify config/autoload/global.php (in the project root, not inside the Album module) with following code: return [ 'db' => [ 'driver' => 'Pdo', 'dsn' => sprintf('sqlite:%s/data/laminastutorial.db', realpath(getcwd())), ], ]; If you were configuring a database that required credentials, you would put the general configuration in your config/autoload/global.php , and then the configuration for the current environment, including the DSN and credentials, in the config/autoload/local.php file. These get merged when the application runs, ensuring you have a full definition, but allows you to keep files with credentials outside of version control. Back to the controller Now that we have a model, we need to inject it into our controller so we can use it. Firstly, we'll add a constructor to our controller. Open the file module/Album/src/Controller/AlbumController.php and add the following property and constructor: namespace Album\\Controller; // Add the following import: use Album\\Model\\AlbumTable; use Laminas\\Mvc\\Controller\\AbstractActionController; use Laminas\\View\\Model\\ViewModel; class AlbumController extends AbstractActionController { // Add this property: private $table; // Add this constructor: public function __construct(AlbumTable $table) { $this->table = $table; } /* ... */ } Our controller now depends on AlbumTable , so we will need to update the factory for the controller so that it will inject the AlbumTable . We will use the ReflectionBasedAbstractFactory factory to build the AlbumController . ReflectionBasedAbstractFactory provides a reflection-based approach to instantiation, resolving constructor dependencies to the relevant services. Since the AlbumController constructor has an AlbumTable parameter, the factory will instantiate an AlbumTable instance and pass it to the AlbumController constructor. Then we can modify the controllers section of the module.config.php to use ReflectionBasedAbstractFactory : namespace Album; use Laminas\\ServiceManager\\AbstractFactory\\ReflectionBasedAbstractFactory; use Album\\Model\\AlbumTableFactory; use Laminas\\Router\\Http\\Segment; return [ 'controllers' => [ 'factories' => [ Controller\\AlbumController::class => ReflectionBasedAbstractFactory::class ], ], // the rest of the code ]; We can now access the property $table from within our controller whenever we need to interact with our model. Listing albums In order to list the albums, we need to retrieve them from the model and pass them to the view. To do this, we fill in indexAction() within AlbumController . Update the AlbumController::indexAction() as follows: // module/Album/src/Controller/AlbumController.php: // ... public function indexAction() { return new ViewModel([ 'albums' => $this->table->fetchAll(), ]); } // ... With Laminas, in order to set variables in the view, we return a ViewModel instance where the first parameter of the constructor is an array containing data we wish to represent. These are then automatically passed to the view script. The ViewModel object also allows us to change the view script that is used, but the default is to use {module name}/{controller name}/{action name} . We can now fill in the index.phtml view script: <?php // module/Album/view/album/album/index.phtml: $title = 'My albums'; $this->headTitle($title); ?> <h1><?= $this->escapeHtml($title) ?></h1> <p> <a href=\"<?= $this->url('album', ['action' => 'add']) ?>\">Add new album</a> </p> <table class=\"table\"> <tr> <th>Title</th> <th>Artist</th> <th> </th> </tr> <?php foreach ($albums as $album) : ?> <tr> <td><?= $this->escapeHtml($album->title) ?></td> <td><?= $this->escapeHtml($album->artist) ?></td> <td> <a href=\"<?= $this->url('album', ['action' => 'edit', 'id' => $album->id]) ?>\">Edit</a> <a href=\"<?= $this->url('album', ['action' => 'delete', 'id' => $album->id]) ?>\">Delete</a> </td> </tr> <?php endforeach; ?> </table> The first thing we do is to set the title for the page (used in the layout) and also set the title for the <head> section using the headTitle() view helper which will display in the browser's title bar. We then create a link to add a new album. The url() view helper is provided by laminas-mvc and laminas-view, and is used to create the links we need. The first parameter to url() is the route name we wish to use for construction of the URL, and the second parameter is an array of variables to substitute into route placeholders. In this case we use our album route which is set up to accept two placeholder variables: action and id . We iterate over the $albums that we assigned from the controller action. laminas-view automatically ensures that these variables are extracted into the scope of the view script; you may also access them using $this->{variable name} in order to differentiate between variables provided to the view script and those created inside it. We then create a table to display each album's title and artist, and provide links to allow for editing and deleting the record. A standard foreach: loop is used to iterate over the list of albums, and we use the alternate form using a colon and endforeach; as it is easier to scan than to try and match up braces. Again, the url() view helper is used to create the edit and delete links. Escaping We always use the escapeHtml() view helper to help protect ourselves from Cross Site Scripting (XSS) vulnerabilities . If you open http://localhost:8080/album (or http://laminas-mvc-tutorial.localhost/album if you are using self-hosted Apache) you should see this:","title":"Database and Models"},{"location":"getting-started/database-and-models/#database-and-models","text":"","title":"Database and models"},{"location":"getting-started/database-and-models/#the-database","text":"Now that we have the Album module set up with controller action methods and view scripts, it is time to look at the model section of our application. Remember that the model is the part that deals with the application's core purpose (the so-called “business rules”) and, in our case, deals with the database. We will make use of laminas-db's Laminas\\Db\\TableGateway\\TableGateway to find, insert, update, and delete rows from a database table. We are going to use Sqlite, via PHP's PDO driver. Create a text file data/schema.sql with the following contents: CREATE TABLE album (id INTEGER PRIMARY KEY AUTOINCREMENT, artist varchar(100) NOT NULL, title varchar(100) NOT NULL); INSERT INTO album (artist, title) VALUES ('The Military Wives', 'In My Dreams'); INSERT INTO album (artist, title) VALUES ('Adele', '21'); INSERT INTO album (artist, title) VALUES ('Bruce Springsteen', 'Wrecking Ball (Deluxe)'); INSERT INTO album (artist, title) VALUES ('Lana Del Rey', 'Born To Die'); INSERT INTO album (artist, title) VALUES ('Gotye', 'Making Mirrors'); (The test data chosen happens to be the Bestsellers on Amazon UK at the time of writing!) Now create the database using the following: $ sqlite data/laminastutorial.db < data/schema.sql Alternative Commands","title":"The database"},{"location":"getting-started/database-and-models/#the-model-files","text":"Laminas does not provide a laminas-model component because the model is your business logic, and it's up to you to decide how you want it to work. There are many components that you can use for this depending on your needs. One approach is to have model classes represent each entity in your application and then use mapper objects that load and save entities to the database. Another is to use an Object-Relational Mapping (ORM) technology, such as Doctrine or Propel. For this tutorial, we are going to create a model by creating an AlbumTable class that consumes a Laminas\\Db\\TableGateway\\TableGateway , and in which each album will be represented as an Album object (known as an entity ). This is an implementation of the Table Data Gateway design pattern to allow for interfacing with data in a database table. Be aware, though, that the Table Data Gateway pattern can become limiting in larger systems. There is also a temptation to put database access code into controller action methods as these are exposed by Laminas\\Db\\TableGateway\\AbstractTableGateway . Don't do this ! Let's start by creating a file called Album.php under module/Album/src/Model : namespace Album\\Model; class Album { public $id; public $artist; public $title; public function exchangeArray(array $array): void { $this->id = ! empty($array['id']) ? $array['id'] : null; $this->artist = ! empty($array['artist']) ? $array['artist'] : null; $this->title = ! empty($array['title']) ? $array['title'] : null; } } Our Album entity object is a PHP class. In order to work with laminas-db's TableGateway class, we need to implement the exchangeArray() method; this method copies the data from the provided array to our entity's properties. We will add an input filter later to ensure the values injected are valid. Next, we create our AlbumTable.php file in module/Album/src/Model directory like this: namespace Album\\Model; use RuntimeException; use Laminas\\Db\\TableGateway\\TableGatewayInterface; class AlbumTable { private $tableGateway; public function __construct(TableGatewayInterface $tableGateway) { $this->tableGateway = $tableGateway; } public function fetchAll() { return $this->tableGateway->select(); } public function getAlbum($id) { $id = (int) $id; $rowset = $this->tableGateway->select(['id' => $id]); $row = $rowset->current(); if (! $row) { throw new RuntimeException(sprintf( 'Could not find row with identifier %d', $id )); } return $row; } public function saveAlbum(Album $album) { $data = [ 'artist' => $album->artist, 'title' => $album->title, ]; $id = (int) $album->id; if ($id === 0) { $this->tableGateway->insert($data); return; } try { $this->getAlbum($id); } catch (RuntimeException $e) { throw new RuntimeException(sprintf( 'Cannot update album with identifier %d; does not exist', $id )); } $this->tableGateway->update($data, ['id' => $id]); } public function deleteAlbum($id) { $this->tableGateway->delete(['id' => (int) $id]); } } There's a lot going on here. Firstly, we set the protected property $tableGateway to the TableGateway instance passed in the constructor, hinting against the TableGatewayInterface (which allows us to provide alternate implementations easily, including mock instances during testing). We will use this to perform operations on the database table for our albums. We then create some helper methods that our application will use to interface with the table gateway. fetchAll() retrieves all albums rows from the database as a ResultSet , getAlbum() retrieves a single row as an Album object, saveAlbum() either creates a new row in the database or updates a row that already exists, and deleteAlbum() removes the row completely. The code for each of these methods is, hopefully, self-explanatory.","title":"The model files"},{"location":"getting-started/database-and-models/#using-servicemanager-to-configure-the-table-gateway-and-inject-into-the-albumtable","text":"In order to always use the same instance of our AlbumTable , we will use the ServiceManager to define how to create one. This is most easily done by adding a ServiceManager configuration to the module.config.php which is automatically loaded by the ModuleManager and applied to the ServiceManager . We'll then be able to retrieve the AlbumTable when we need it. To configure the ServiceManager , we can either supply the name of the class to be instantiated and a factory (closure, callback, or class name of a factory class) that instantiates the object when the ServiceManager needs it. Add a service_manager configuration to module/Album/config/module.config.php : namespace Album; use Album\\Model\\AlbumTableFactory; use Laminas\\Router\\Http\\Segment; use Laminas\\ServiceManager\\Factory\\InvokableFactory; return [ 'controllers' => [ // ... ], 'router' => [ // .. ], 'view_manager' => [ // ... ], 'service_manager' => [ 'factories' => [ Model\\AlbumTable::class => AlbumTableFactory::class, ], ], ]; This method returns an array of factories that are all merged together by the ModuleManager before passing them to the ServiceManager . When requesting the ServiceManager to create Album\\Model\\AlbumTable , the ServiceManager will invoke the AlbumTableFactory class, which we need to create next. Let's create the AlbumTableFactory.php factory in module/Album/src/Model : namespace Album\\Model; use Laminas\\Db\\Adapter\\AdapterInterface; use Laminas\\Db\\ResultSet\\ResultSet; use Laminas\\Db\\TableGateway\\TableGateway; use Laminas\\ServiceManager\\Factory\\FactoryInterface; use Psr\\Container\\ContainerInterface; class AlbumTableFactory implements FactoryInterface { public function __invoke(ContainerInterface $container, $requestedName, ?array $options = null): AlbumTable { $dbAdapter = $container->get(AdapterInterface::class); $resultSetPrototype = new ResultSet(); $resultSetPrototype->setArrayObjectPrototype(new Album()); $tableGateway = new TableGateway('album', $dbAdapter, null, $resultSetPrototype); return new AlbumTable($tableGateway); } } The AlbumTableFactory factory uses the ServiceManager to fetch a Laminas\\Db\\Adapter\\AdapterInterface implementation (also from the ServiceManager ) and use it to create a TableGateway object. The TableGateway is told to use an Album object whenever it creates a new result row. The TableGateway classes use the prototype pattern for creation of result sets and entities. This means that instead of instantiating when required, the system clones a previously instantiated object. Then, finally, the factory creates a AlbumTable object passing it the TableGateway object. See PHP Constructor Best Practices and the Prototype Pattern for more details.","title":"Using ServiceManager to configure the table gateway and inject into the AlbumTable"},{"location":"getting-started/database-and-models/#back-to-the-controller","text":"Now that we have a model, we need to inject it into our controller so we can use it. Firstly, we'll add a constructor to our controller. Open the file module/Album/src/Controller/AlbumController.php and add the following property and constructor: namespace Album\\Controller; // Add the following import: use Album\\Model\\AlbumTable; use Laminas\\Mvc\\Controller\\AbstractActionController; use Laminas\\View\\Model\\ViewModel; class AlbumController extends AbstractActionController { // Add this property: private $table; // Add this constructor: public function __construct(AlbumTable $table) { $this->table = $table; } /* ... */ } Our controller now depends on AlbumTable , so we will need to update the factory for the controller so that it will inject the AlbumTable . We will use the ReflectionBasedAbstractFactory factory to build the AlbumController . ReflectionBasedAbstractFactory provides a reflection-based approach to instantiation, resolving constructor dependencies to the relevant services. Since the AlbumController constructor has an AlbumTable parameter, the factory will instantiate an AlbumTable instance and pass it to the AlbumController constructor. Then we can modify the controllers section of the module.config.php to use ReflectionBasedAbstractFactory : namespace Album; use Laminas\\ServiceManager\\AbstractFactory\\ReflectionBasedAbstractFactory; use Album\\Model\\AlbumTableFactory; use Laminas\\Router\\Http\\Segment; return [ 'controllers' => [ 'factories' => [ Controller\\AlbumController::class => ReflectionBasedAbstractFactory::class ], ], // the rest of the code ]; We can now access the property $table from within our controller whenever we need to interact with our model.","title":"Back to the controller"},{"location":"getting-started/database-and-models/#listing-albums","text":"In order to list the albums, we need to retrieve them from the model and pass them to the view. To do this, we fill in indexAction() within AlbumController . Update the AlbumController::indexAction() as follows: // module/Album/src/Controller/AlbumController.php: // ... public function indexAction() { return new ViewModel([ 'albums' => $this->table->fetchAll(), ]); } // ... With Laminas, in order to set variables in the view, we return a ViewModel instance where the first parameter of the constructor is an array containing data we wish to represent. These are then automatically passed to the view script. The ViewModel object also allows us to change the view script that is used, but the default is to use {module name}/{controller name}/{action name} . We can now fill in the index.phtml view script: <?php // module/Album/view/album/album/index.phtml: $title = 'My albums'; $this->headTitle($title); ?> <h1><?= $this->escapeHtml($title) ?></h1> <p> <a href=\"<?= $this->url('album', ['action' => 'add']) ?>\">Add new album</a> </p> <table class=\"table\"> <tr> <th>Title</th> <th>Artist</th> <th> </th> </tr> <?php foreach ($albums as $album) : ?> <tr> <td><?= $this->escapeHtml($album->title) ?></td> <td><?= $this->escapeHtml($album->artist) ?></td> <td> <a href=\"<?= $this->url('album', ['action' => 'edit', 'id' => $album->id]) ?>\">Edit</a> <a href=\"<?= $this->url('album', ['action' => 'delete', 'id' => $album->id]) ?>\">Delete</a> </td> </tr> <?php endforeach; ?> </table> The first thing we do is to set the title for the page (used in the layout) and also set the title for the <head> section using the headTitle() view helper which will display in the browser's title bar. We then create a link to add a new album. The url() view helper is provided by laminas-mvc and laminas-view, and is used to create the links we need. The first parameter to url() is the route name we wish to use for construction of the URL, and the second parameter is an array of variables to substitute into route placeholders. In this case we use our album route which is set up to accept two placeholder variables: action and id . We iterate over the $albums that we assigned from the controller action. laminas-view automatically ensures that these variables are extracted into the scope of the view script; you may also access them using $this->{variable name} in order to differentiate between variables provided to the view script and those created inside it. We then create a table to display each album's title and artist, and provide links to allow for editing and deleting the record. A standard foreach: loop is used to iterate over the list of albums, and we use the alternate form using a colon and endforeach; as it is easier to scan than to try and match up braces. Again, the url() view helper is used to create the edit and delete links.","title":"Listing albums"},{"location":"getting-started/forms-and-actions/","text":"Forms and actions Adding new albums We can now code up the functionality to add new albums. There are two bits to this part: Display a form for user to provide details. Process the form submission and store to database. We will use laminas-form to do this. laminas-form manages the various form inputs as well as their validation, the latter of which is handled by the laminas-inputfilter component. We'll start by creating a new class, Album\\Form\\AlbumForm , extending from Laminas\\Form\\Form . Create the file module/Album/src/Form/AlbumForm.php with the following contents: namespace Album\\Form; use Laminas\\Form\\Element\\Hidden; use Laminas\\Form\\Element\\Submit; use Laminas\\Form\\Element\\Text; use Laminas\\Form\\Form; class AlbumForm extends Form { public function __construct($name = null) { // We will ignore the name provided to the constructor parent::__construct('album'); $this->add([ 'name' => 'id', 'type' => Hidden::class, ]); $this->add([ 'name' => 'title', 'type' => Text::class, 'options' => [ 'label' => 'Title', ], ]); $this->add([ 'name' => 'artist', 'type' => Text::class, 'options' => [ 'label' => 'Artist', ], ]); $this->add([ 'name' => 'submit', 'type' => Submit::class, 'attributes' => [ 'value' => 'Go', 'id' => 'submitbutton', ], ]); } } Within the constructor of AlbumForm we do several things. First, we set the name of the form as we call the parent's constructor. Then, we create four form elements: the id, title, artist, and submit button. For each item we set various attributes and options, including the label to be displayed. Form method HTML forms can be sent using POST and GET . laminas-form defaults to POST ; therefore you don't have to be explicit in setting this option. If you want to change it to GET however, set the method attribute in the constructor: $this->setAttribute('method', 'GET'); We also need to set up validation for this form. laminas-inputfilter provides a general purpose mechanism for input validation. It also provides an interface, InputFilterAwareInterface , which laminas-form will use in order to bind an input filter to a given form. We'll add this capability now to our Album class. // module/Album/src/Model/Album.php: namespace Album\\Model; // Add the following import statements: use DomainException; use Laminas\\Filter\\StringTrim; use Laminas\\Filter\\StripTags; use Laminas\\Filter\\ToInt; use Laminas\\InputFilter\\InputFilter; use Laminas\\InputFilter\\InputFilterAwareInterface; use Laminas\\InputFilter\\InputFilterInterface; use Laminas\\Validator\\StringLength; class Album implements InputFilterAwareInterface { public $id; public $artist; public $title; // Add this property: private $inputFilter; public function exchangeArray(array $data) { $this->id = !empty($data['id']) ? $data['id'] : null; $this->artist = !empty($data['artist']) ? $data['artist'] : null; $this->title = !empty($data['title']) ? $data['title'] : null; } /* Add the following methods: */ public function setInputFilter(InputFilterInterface $inputFilter) { throw new DomainException(sprintf( '%s does not allow injection of an alternate input filter', __CLASS__ )); } public function getInputFilter() { if ($this->inputFilter) { return $this->inputFilter; } $inputFilter = new InputFilter(); $inputFilter->add([ 'name' => 'id', 'required' => true, 'filters' => [ ['name' => ToInt::class], ], ]); $inputFilter->add([ 'name' => 'artist', 'required' => true, 'filters' => [ ['name' => StripTags::class], ['name' => StringTrim::class], ], 'validators' => [ [ 'name' => StringLength::class, 'options' => [ 'encoding' => 'UTF-8', 'min' => 1, 'max' => 100, ], ], ], ]); $inputFilter->add([ 'name' => 'title', 'required' => true, 'filters' => [ ['name' => StripTags::class], ['name' => StringTrim::class], ], 'validators' => [ [ 'name' => StringLength::class, 'options' => [ 'encoding' => 'UTF-8', 'min' => 1, 'max' => 100, ], ], ], ]); $this->inputFilter = $inputFilter; return $this->inputFilter; } } The InputFilterAwareInterface defines two methods: setInputFilter() and getInputFilter() . We only need to implement getInputFilter() so we throw an exception from setInputFilter() . Within getInputFilter() , we instantiate an InputFilter and then add the inputs that we require. We add one input for each property that we wish to filter or validate. For the id field we add an int filter as we only need integers. For the text elements, we add two filters, StripTags and StringTrim , to remove unwanted HTML and unnecessary white space. We also set them to be required and add a StringLength validator to ensure that the user doesn't enter more characters than we can store into the database. We now need to get the form to display and then process it on submission. This is done within the AlbumController::addAction() : // module/Album/src/Controller/AlbumController.php: // Add the following import statements at the top of the file: use Album\\Form\\AlbumForm; use Album\\Model\\Album; class AlbumController extends AbstractActionController { /* ... */ /* Update the following method to read as follows: */ public function addAction() { $form = new AlbumForm(); $form->get('submit')->setValue('Add'); $request = $this->getRequest(); if (! $request->isPost()) { return ['form' => $form]; } $album = new Album(); $form->setInputFilter($album->getInputFilter()); $form->setData($request->getPost()); if (! $form->isValid()) { return ['form' => $form]; } $album->exchangeArray($form->getData()); $this->table->saveAlbum($album); return $this->redirect()->toRoute('album'); } /* ... */ } After adding the Album and AlbumForm classes to the import list, we implement addAction() . Let's look at the addAction() code in a little more detail: $form = new AlbumForm(); $form->get('submit')->setValue('Add'); We instantiate AlbumForm and set the label on the submit button to \"Add\". We do this here as we'll want to re-use the form when editing an album and will use a different label. $request = $this->getRequest(); if (! $request->isPost()) { return ['form' => $form]; } If the request is not a POST request, then no form data has been submitted, and we need to display the form. laminas-mvc allows you to return an array of data instead of a view model if desired; if you do, the array will be used to create a view model. $album = new Album(); $form->setInputFilter($album->getInputFilter()); $form->setData($request->getPost()); At this point, we know we have a form submission. We create an Album instance, and pass its input filter on to the form; additionally, we pass the submitted data from the request instance to the form. if (! $form->isValid()) { return ['form' => $form]; } If form validation fails, we want to redisplay the form. At this point, the form contains information about what fields failed validation, and why, and this information will be communicated to the view layer. $album->exchangeArray($form->getData()); $this->table->saveAlbum($album); If the form is valid, then we grab the data from the form and store to the model using saveAlbum() . return $this->redirect()->toRoute('album'); After we have saved the new album row, we redirect back to the list of albums using the Redirect controller plugin. We now need to render the form in the add.phtml view script: <?php // module/Album/view/album/album/add.phtml: $title = 'Add new album'; $this->headTitle($title); ?> <h1><?= $this->escapeHtml($title) ?></h1> <?php $form->setAttribute('action', $this->url('album', ['action' => 'add'])); $form->prepare(); echo $this->form()->openTag($form); echo $this->formHidden($form->get('id')); echo $this->formRow($form->get('title')); echo $this->formRow($form->get('artist')); echo $this->formSubmit($form->get('submit')); echo $this->form()->closeTag(); We display a title as before, and then we render the form. laminas-form provides several view helpers to make this a little easier. The form() view helper has an openTag() and closeTag() method which we use to open and close the form. Then for each element with a label, we can use formRow() to render the label, input, and any validation error messages; for the two elements that are standalone and have no validation rules, we use formHidden() and formSubmit() . Alternatively, the process of rendering the form can be simplified by using the bundled formCollection view helper. For example, in the view script above replace all the form-rendering echo statements with: echo $this->formCollection($form); This will iterate over the form structure, calling the appropriate label, element, and error view helpers for each element, but you still have to wrap formCollection($form) with the open and close form tags. This helps reduce the complexity of your view script in situations where the default HTML rendering of the form is acceptable. You should now be able to use the \"Add new album\" page of the application at http://localhost:8080/album/add to add a new album record, resulting in something like the following: This doesn't look all that great! The reason is because Bootstrap, the CSS foundation used in the skeleton, has specialized markup for displaying forms! We can address that in our view script by: Adding markup around the elements. Rendering labels, elements, and error messages separately. Adding attributes to elements. Update your add.phtml view script to read as follows: <?php $title = 'Add new album'; $this->headTitle($title); ?> <h1><?= $this->escapeHtml($title) ?></h1> <?php // This provides a default CSS class and placeholder text for the title element: $album = $form->get('title'); $album->setAttribute('class', 'form-control'); $album->setAttribute('placeholder', 'Album title'); // This provides a default CSS class and placeholder text for the artist element: $artist = $form->get('artist'); $artist->setAttribute('class', 'form-control'); $artist->setAttribute('placeholder', 'Artist'); // This provides CSS classes for the submit button: $submit = $form->get('submit'); $submit->setAttribute('class', 'btn btn-primary'); $form->setAttribute('action', $this->url('album', ['action' => 'add'])); $form->prepare(); echo $this->form()->openTag($form); ?> <?php // Wrap the elements in divs marked as form groups, and render the // label, element, and errors separately within ?> <div class=\"form-group\"> <?= $this->formLabel($album) ?> <?= $this->formElement($album) ?> <?= $this->formElementErrors()->render($album, ['class' => 'help-block']) ?> </div> <div class=\"form-group\"> <?= $this->formLabel($artist) ?> <?= $this->formElement($artist) ?> <?= $this->formElementErrors()->render($artist, ['class' => 'help-block']) ?> </div> <?php echo $this->formSubmit($submit); echo $this->formHidden($form->get('id')); echo $this->form()->closeTag(); The results we get are much better: The above is meant to demonstrate both the ease of use of the default form features, as well as some of the customizations possible when rendering forms. You should be able to generate any markup necessary for your site. Editing an album Editing an album is almost identical to adding one, so the code is very similar. This time we use editAction() in the AlbumController : // module/Album/src/Controller/AlbumController.php: // ... public function editAction() { $id = (int) $this->params()->fromRoute('id', 0); if (0 === $id) { return $this->redirect()->toRoute('album', ['action' => 'add']); } // Retrieve the album with the specified id. Doing so raises // an exception if the album is not found, which should result // in redirecting to the landing page. try { $album = $this->table->getAlbum($id); } catch (\\Exception $e) { return $this->redirect()->toRoute('album', ['action' => 'index']); } $form = new AlbumForm(); $form->bind($album); $form->get('submit')->setAttribute('value', 'Edit'); $request = $this->getRequest(); $viewData = ['id' => $id, 'form' => $form]; if (! $request->isPost()) { return $viewData; } $form->setInputFilter($album->getInputFilter()); $form->setData($request->getPost()); if (! $form->isValid()) { return $viewData; } try { $this->table->saveAlbum($album); } catch (\\Exception $e) { } // Redirect to album list return $this->redirect()->toRoute('album', ['action' => 'index']); } This code should look comfortably familiar. Let's look at the differences from adding an album. Firstly, we look for the id that is in the matched route and use it to load the album to be edited: $id = (int) $this->params()->fromRoute('id', 0); if (0 === $id) { return $this->redirect()->toRoute('album', ['action' => 'add']); } // Retrieve the album with the specified id. Doing so raises // an exception if the album is not found, which should result // in redirecting to the landing page. try { $album = $this->table->getAlbum($id); } catch (\\Exception $e) { return $this->redirect()->toRoute('album', ['action' => 'index']); } params is a controller plugin that provides a convenient way to retrieve parameters from the matched route. We use it to retrieve the id from the route we created within the Album module's module.config.php . If the id is zero, then we redirect to the add action, otherwise, we continue by getting the album entity from the database. We have to check to make sure that the album with the specified id can actually be found. If it cannot, then the data access method throws an exception. We catch that exception and re-route the user to the index page. $form = new AlbumForm(); $form->bind($album); $form->get('submit')->setAttribute('value', 'Edit'); The form's bind() method attaches the model to the form. This is used in two ways: When displaying the form, the initial values for each element are extracted from the model. After successful validation in isValid() , the data from the form is put back into the model. These operations are done using a hydrator object. There are a number of hydrators, but the default one is Laminas\\Hydrator\\ArraySerializable which expects to find two methods in the model: getArrayCopy() and exchangeArray() . We have already written exchangeArray() in our Album entity, so we now need to write getArrayCopy() : // module/Album/src/Model/Album.php: // ... public function exchangeArray($data) { $this->id = isset($data['id']) ? $data['id'] : null; $this->artist = isset($data['artist']) ? $data['artist'] : null; $this->title = isset($data['title']) ? $data['title'] : null; } // Add the following method: public function getArrayCopy() { return [ 'id' => $this->id, 'artist' => $this->artist, 'title' => $this->title, ]; } // ... As a result of using bind() with its hydrator, we do not need to populate the form's data back into the $album as that's already been done, so we can just call the mapper's saveAlbum() method to store the changes back to the database. The view template, edit.phtml , looks very similar to the one for adding an album: <?php // module/Album/view/album/album/edit.phtml: $title = 'Edit album'; $this->headTitle($title); ?> <h1><?= $this->escapeHtml($title) ?></h1> <?php $album = $form->get('title'); $album->setAttribute('class', 'form-control'); $album->setAttribute('placeholder', 'Album title'); $artist = $form->get('artist'); $artist->setAttribute('class', 'form-control'); $artist->setAttribute('placeholder', 'Artist'); $submit = $form->get('submit'); $submit->setAttribute('class', 'btn btn-primary'); $form->setAttribute('action', $this->url('album', [ 'action' => 'edit', 'id' => $id, ])); $form->prepare(); echo $this->form()->openTag($form); ?> <div class=\"form-group\"> <?= $this->formLabel($album) ?> <?= $this->formElement($album) ?> <?= $this->formElementErrors()->render($album, ['class' => 'help-block']) ?> </div> <div class=\"form-group\"> <?= $this->formLabel($artist) ?> <?= $this->formElement($artist) ?> <?= $this->formElementErrors()->render($artist, ['class' => 'help-block']) ?> </div> <?php echo $this->formSubmit($submit); echo $this->formHidden($form->get('id')); echo $this->form()->closeTag(); The only changes are to use the ‘Edit Album' title and set the form's action to the 'edit' action too, using the current album identifier. You should now be able to edit albums. Deleting an album To round out our application, we need to add deletion. We have a \"Delete\" link next to each album on our list page, and the naive approach would be to do a delete when it's clicked. This would be wrong. Remembering our HTTP spec, we recall that you shouldn't do an irreversible action using GET and should use POST instead. We shall show a confirmation form when the user clicks delete, and if they then click \"yes\", we will do the deletion. As the form is trivial, we'll code it directly into our view (laminas-form is, after all, optional!). Let's start with the action code in AlbumController::deleteAction() : // module/Album/src/Controller/AlbumController.php: //... // Add content to the following method: public function deleteAction() { $id = (int) $this->params()->fromRoute('id', 0); if (!$id) { return $this->redirect()->toRoute('album'); } $request = $this->getRequest(); if ($request->isPost()) { $del = $request->getPost('del', 'No'); if ($del == 'Yes') { $id = (int) $request->getPost('id'); $this->table->deleteAlbum($id); } // Redirect to list of albums return $this->redirect()->toRoute('album'); } return [ 'id' => $id, 'album' => $this->table->getAlbum($id), ]; } //... As before, we get the id from the matched route, and check the request object's isPost() to determine whether to show the confirmation page or to delete the album. We use the table object to delete the row using the deleteAlbum() method and then redirect back the list of albums. If the request is not a POST, then we retrieve the correct database record and assign to the view, along with the id . The view script is a simple form: <?php // module/Album/view/album/album/delete.phtml: $title = 'Delete album'; $url = $this->url('album', ['action' => 'delete', 'id' => $id]); $this->headTitle($title); ?> <h1><?= $this->escapeHtml($title) ?></h1> <p> Are you sure that you want to delete \"<?= $this->escapeHtml($album->title) ?>\" by \"<?= $this->escapeHtml($album->artist) ?>\"? </p> <form action=\"<?= $url ?>\" method=\"post\"> <div class=\"form-group\"> <input type=\"hidden\" name=\"id\" value=\"<?= (int) $album->id ?>\" /> <input type=\"submit\" class=\"btn btn-danger\" name=\"del\" value=\"Yes\" /> <input type=\"submit\" class=\"btn btn-success\" name=\"del\" value=\"No\" /> </div> </form> In this script, we display a confirmation message to the user and then a form with \"Yes\" and \"No\" buttons. In the action, we checked specifically for the \"Yes\" value when doing the deletion. Ensuring that the home page displays the list of albums One final point. At the moment, the home page, http://laminas-mvc-tutorial.localhost/ doesn't display the list of albums. This is due to a route set up in the Application module's module.config.php . To change it, open module/Application/config/module.config.php and find the home route: 'home' => [ 'type' => \\Laminas\\Router\\Http\\Literal::class, 'options' => [ 'route' => '/', 'defaults' => [ 'controller' => Controller\\IndexController::class, 'action' => 'index', ], ], ], Import Album\\Controller\\AlbumController at the top of the file: use Album\\Controller\\AlbumController; and change the controller from Controller\\IndexController::class to AlbumController::class : 'home' => [ 'type' => \\Laminas\\Router\\Http\\Literal::class, 'options' => [ 'route' => '/', 'defaults' => [ 'controller' => AlbumController::class, // < -- change here 'action' => 'index', ], ], ], That's it — you now have a fully working application!","title":"Forms and Actions"},{"location":"getting-started/forms-and-actions/#forms-and-actions","text":"","title":"Forms and actions"},{"location":"getting-started/forms-and-actions/#adding-new-albums","text":"We can now code up the functionality to add new albums. There are two bits to this part: Display a form for user to provide details. Process the form submission and store to database. We will use laminas-form to do this. laminas-form manages the various form inputs as well as their validation, the latter of which is handled by the laminas-inputfilter component. We'll start by creating a new class, Album\\Form\\AlbumForm , extending from Laminas\\Form\\Form . Create the file module/Album/src/Form/AlbumForm.php with the following contents: namespace Album\\Form; use Laminas\\Form\\Element\\Hidden; use Laminas\\Form\\Element\\Submit; use Laminas\\Form\\Element\\Text; use Laminas\\Form\\Form; class AlbumForm extends Form { public function __construct($name = null) { // We will ignore the name provided to the constructor parent::__construct('album'); $this->add([ 'name' => 'id', 'type' => Hidden::class, ]); $this->add([ 'name' => 'title', 'type' => Text::class, 'options' => [ 'label' => 'Title', ], ]); $this->add([ 'name' => 'artist', 'type' => Text::class, 'options' => [ 'label' => 'Artist', ], ]); $this->add([ 'name' => 'submit', 'type' => Submit::class, 'attributes' => [ 'value' => 'Go', 'id' => 'submitbutton', ], ]); } } Within the constructor of AlbumForm we do several things. First, we set the name of the form as we call the parent's constructor. Then, we create four form elements: the id, title, artist, and submit button. For each item we set various attributes and options, including the label to be displayed.","title":"Adding new albums"},{"location":"getting-started/forms-and-actions/#editing-an-album","text":"Editing an album is almost identical to adding one, so the code is very similar. This time we use editAction() in the AlbumController : // module/Album/src/Controller/AlbumController.php: // ... public function editAction() { $id = (int) $this->params()->fromRoute('id', 0); if (0 === $id) { return $this->redirect()->toRoute('album', ['action' => 'add']); } // Retrieve the album with the specified id. Doing so raises // an exception if the album is not found, which should result // in redirecting to the landing page. try { $album = $this->table->getAlbum($id); } catch (\\Exception $e) { return $this->redirect()->toRoute('album', ['action' => 'index']); } $form = new AlbumForm(); $form->bind($album); $form->get('submit')->setAttribute('value', 'Edit'); $request = $this->getRequest(); $viewData = ['id' => $id, 'form' => $form]; if (! $request->isPost()) { return $viewData; } $form->setInputFilter($album->getInputFilter()); $form->setData($request->getPost()); if (! $form->isValid()) { return $viewData; } try { $this->table->saveAlbum($album); } catch (\\Exception $e) { } // Redirect to album list return $this->redirect()->toRoute('album', ['action' => 'index']); } This code should look comfortably familiar. Let's look at the differences from adding an album. Firstly, we look for the id that is in the matched route and use it to load the album to be edited: $id = (int) $this->params()->fromRoute('id', 0); if (0 === $id) { return $this->redirect()->toRoute('album', ['action' => 'add']); } // Retrieve the album with the specified id. Doing so raises // an exception if the album is not found, which should result // in redirecting to the landing page. try { $album = $this->table->getAlbum($id); } catch (\\Exception $e) { return $this->redirect()->toRoute('album', ['action' => 'index']); } params is a controller plugin that provides a convenient way to retrieve parameters from the matched route. We use it to retrieve the id from the route we created within the Album module's module.config.php . If the id is zero, then we redirect to the add action, otherwise, we continue by getting the album entity from the database. We have to check to make sure that the album with the specified id can actually be found. If it cannot, then the data access method throws an exception. We catch that exception and re-route the user to the index page. $form = new AlbumForm(); $form->bind($album); $form->get('submit')->setAttribute('value', 'Edit'); The form's bind() method attaches the model to the form. This is used in two ways: When displaying the form, the initial values for each element are extracted from the model. After successful validation in isValid() , the data from the form is put back into the model. These operations are done using a hydrator object. There are a number of hydrators, but the default one is Laminas\\Hydrator\\ArraySerializable which expects to find two methods in the model: getArrayCopy() and exchangeArray() . We have already written exchangeArray() in our Album entity, so we now need to write getArrayCopy() : // module/Album/src/Model/Album.php: // ... public function exchangeArray($data) { $this->id = isset($data['id']) ? $data['id'] : null; $this->artist = isset($data['artist']) ? $data['artist'] : null; $this->title = isset($data['title']) ? $data['title'] : null; } // Add the following method: public function getArrayCopy() { return [ 'id' => $this->id, 'artist' => $this->artist, 'title' => $this->title, ]; } // ... As a result of using bind() with its hydrator, we do not need to populate the form's data back into the $album as that's already been done, so we can just call the mapper's saveAlbum() method to store the changes back to the database. The view template, edit.phtml , looks very similar to the one for adding an album: <?php // module/Album/view/album/album/edit.phtml: $title = 'Edit album'; $this->headTitle($title); ?> <h1><?= $this->escapeHtml($title) ?></h1> <?php $album = $form->get('title'); $album->setAttribute('class', 'form-control'); $album->setAttribute('placeholder', 'Album title'); $artist = $form->get('artist'); $artist->setAttribute('class', 'form-control'); $artist->setAttribute('placeholder', 'Artist'); $submit = $form->get('submit'); $submit->setAttribute('class', 'btn btn-primary'); $form->setAttribute('action', $this->url('album', [ 'action' => 'edit', 'id' => $id, ])); $form->prepare(); echo $this->form()->openTag($form); ?> <div class=\"form-group\"> <?= $this->formLabel($album) ?> <?= $this->formElement($album) ?> <?= $this->formElementErrors()->render($album, ['class' => 'help-block']) ?> </div> <div class=\"form-group\"> <?= $this->formLabel($artist) ?> <?= $this->formElement($artist) ?> <?= $this->formElementErrors()->render($artist, ['class' => 'help-block']) ?> </div> <?php echo $this->formSubmit($submit); echo $this->formHidden($form->get('id')); echo $this->form()->closeTag(); The only changes are to use the ‘Edit Album' title and set the form's action to the 'edit' action too, using the current album identifier. You should now be able to edit albums.","title":"Editing an album"},{"location":"getting-started/forms-and-actions/#deleting-an-album","text":"To round out our application, we need to add deletion. We have a \"Delete\" link next to each album on our list page, and the naive approach would be to do a delete when it's clicked. This would be wrong. Remembering our HTTP spec, we recall that you shouldn't do an irreversible action using GET and should use POST instead. We shall show a confirmation form when the user clicks delete, and if they then click \"yes\", we will do the deletion. As the form is trivial, we'll code it directly into our view (laminas-form is, after all, optional!). Let's start with the action code in AlbumController::deleteAction() : // module/Album/src/Controller/AlbumController.php: //... // Add content to the following method: public function deleteAction() { $id = (int) $this->params()->fromRoute('id', 0); if (!$id) { return $this->redirect()->toRoute('album'); } $request = $this->getRequest(); if ($request->isPost()) { $del = $request->getPost('del', 'No'); if ($del == 'Yes') { $id = (int) $request->getPost('id'); $this->table->deleteAlbum($id); } // Redirect to list of albums return $this->redirect()->toRoute('album'); } return [ 'id' => $id, 'album' => $this->table->getAlbum($id), ]; } //... As before, we get the id from the matched route, and check the request object's isPost() to determine whether to show the confirmation page or to delete the album. We use the table object to delete the row using the deleteAlbum() method and then redirect back the list of albums. If the request is not a POST, then we retrieve the correct database record and assign to the view, along with the id . The view script is a simple form: <?php // module/Album/view/album/album/delete.phtml: $title = 'Delete album'; $url = $this->url('album', ['action' => 'delete', 'id' => $id]); $this->headTitle($title); ?> <h1><?= $this->escapeHtml($title) ?></h1> <p> Are you sure that you want to delete \"<?= $this->escapeHtml($album->title) ?>\" by \"<?= $this->escapeHtml($album->artist) ?>\"? </p> <form action=\"<?= $url ?>\" method=\"post\"> <div class=\"form-group\"> <input type=\"hidden\" name=\"id\" value=\"<?= (int) $album->id ?>\" /> <input type=\"submit\" class=\"btn btn-danger\" name=\"del\" value=\"Yes\" /> <input type=\"submit\" class=\"btn btn-success\" name=\"del\" value=\"No\" /> </div> </form> In this script, we display a confirmation message to the user and then a form with \"Yes\" and \"No\" buttons. In the action, we checked specifically for the \"Yes\" value when doing the deletion.","title":"Deleting an album"},{"location":"getting-started/forms-and-actions/#ensuring-that-the-home-page-displays-the-list-of-albums","text":"One final point. At the moment, the home page, http://laminas-mvc-tutorial.localhost/ doesn't display the list of albums. This is due to a route set up in the Application module's module.config.php . To change it, open module/Application/config/module.config.php and find the home route: 'home' => [ 'type' => \\Laminas\\Router\\Http\\Literal::class, 'options' => [ 'route' => '/', 'defaults' => [ 'controller' => Controller\\IndexController::class, 'action' => 'index', ], ], ], Import Album\\Controller\\AlbumController at the top of the file: use Album\\Controller\\AlbumController; and change the controller from Controller\\IndexController::class to AlbumController::class : 'home' => [ 'type' => \\Laminas\\Router\\Http\\Literal::class, 'options' => [ 'route' => '/', 'defaults' => [ 'controller' => AlbumController::class, // < -- change here 'action' => 'index', ], ], ], That's it — you now have a fully working application!","title":"Ensuring that the home page displays the list of albums"},{"location":"getting-started/modules/","text":"Modules laminas-mvc uses a module system to organise your main application-specific code within each module. The Application module provided by the skeleton is used to provide bootstrapping, error, and routing configuration to the whole application. It is usually used to provide application level controllers for the home page of an application, but we are not going to use the default one provided in this tutorial as we want our album list to be the home page, which will live in our own module. We are going to put all our code into the Album module which will contain our controllers, models, forms and views, along with configuration. We’ll also tweak the Application module as required. Let’s start with the directories required. Setting up the Album module Start by creating a directory called Album under module with the following subdirectories to hold the module’s files: laminas-mvc-tutorial/ /module /Album /config /src /Controller /Form /Model /view /album /album The Album module has separate directories for the different types of files we will have. The PHP files that contain classes within the Album namespace live in the src/ directory. The view directory also has a sub-folder called album for our module's view scripts. In order to load and configure a module, Laminas provides a ModuleManager . This will look for a Module class in the specified module namespace (i.e., Album ); in the case of our new module, that means the class Album\\Module , which will be found in module/Album/src/Module.php . Let's create that file now, with the following contents: namespace Album; use Laminas\\ModuleManager\\Feature\\ConfigProviderInterface; class Module implements ConfigProviderInterface { public function getConfig() { return include __DIR__ . '/../config/module.config.php'; } } The ModuleManager will call getConfig() automatically for us. Autoloading While Laminas provides autoloading capabilities via its laminas-loader component, we recommend using Composer's autoloading capabilities. As such, we need to inform Composer of our new namespace, and where its files live. Open composer.json in your project root, and look for the autoload section; it should look like the following by default: \"autoload\": { \"psr-4\": { \"Application\\\\\": \"module/Application/src/\" } }, We'll now add our new module to the list, so it now reads: \"autoload\": { \"psr-4\": { \"Application\\\\\": \"module/Application/src/\", \"Album\\\\\": \"module/Album/src/\" } }, Once you've made that change, run the following to ensure Composer updates its autoloading rules: $ composer dump-autoload Configuration Having registered the autoloader, let’s have a quick look at the getConfig() method in Album\\Module . This method loads the config/module.config.php file under the module's root directory. Create a file called module.config.php under laminas-mvc-tutorial/module/Album/config/ : namespace Album; use Laminas\\ServiceManager\\Factory\\InvokableFactory; return [ 'controllers' => [ 'factories' => [ Controller\\AlbumController::class => InvokableFactory::class, ], ], 'view_manager' => [ 'template_path_stack' => [ 'album' => __DIR__ . '/../view', ], ], ]; The config information is passed to the relevant components by the ServiceManager . We need two initial sections: controllers and view_manager . The controllers section provides a list of all the controllers provided by the module. We will need one controller, AlbumController ; we'll reference it by its fully qualified class name, and use the laminas-servicemanager InvokableFactory to create instances of it. Within the view_manager section, we add our view directory to the TemplatePathStack configuration. This will allow it to find the view scripts for the Album module that are stored in our view/ directory. Informing the application about our new module We now need to tell the ModuleManager that this new module exists. This is done in the application’s config/modules.config.php file which is provided by the skeleton application. Update this file so that the array it returns contains the Album module as well, so the file now looks like this: (Changes required are highlighted using comments; original comments from the file are omitted for brevity.) return [ 'Laminas\\Form', 'Laminas\\Db', 'Laminas\\Router', 'Laminas\\Validator', 'Application', 'Album', // < -- Add this line ]; As you can see, we have added our Album module into the list of modules after the Application module. We have now set up the module ready for putting our custom code into it.","title":"Modules"},{"location":"getting-started/modules/#modules","text":"laminas-mvc uses a module system to organise your main application-specific code within each module. The Application module provided by the skeleton is used to provide bootstrapping, error, and routing configuration to the whole application. It is usually used to provide application level controllers for the home page of an application, but we are not going to use the default one provided in this tutorial as we want our album list to be the home page, which will live in our own module. We are going to put all our code into the Album module which will contain our controllers, models, forms and views, along with configuration. We’ll also tweak the Application module as required. Let’s start with the directories required.","title":"Modules"},{"location":"getting-started/modules/#setting-up-the-album-module","text":"Start by creating a directory called Album under module with the following subdirectories to hold the module’s files: laminas-mvc-tutorial/ /module /Album /config /src /Controller /Form /Model /view /album /album The Album module has separate directories for the different types of files we will have. The PHP files that contain classes within the Album namespace live in the src/ directory. The view directory also has a sub-folder called album for our module's view scripts. In order to load and configure a module, Laminas provides a ModuleManager . This will look for a Module class in the specified module namespace (i.e., Album ); in the case of our new module, that means the class Album\\Module , which will be found in module/Album/src/Module.php . Let's create that file now, with the following contents: namespace Album; use Laminas\\ModuleManager\\Feature\\ConfigProviderInterface; class Module implements ConfigProviderInterface { public function getConfig() { return include __DIR__ . '/../config/module.config.php'; } } The ModuleManager will call getConfig() automatically for us.","title":"Setting up the Album module"},{"location":"getting-started/modules/#configuration","text":"Having registered the autoloader, let’s have a quick look at the getConfig() method in Album\\Module . This method loads the config/module.config.php file under the module's root directory. Create a file called module.config.php under laminas-mvc-tutorial/module/Album/config/ : namespace Album; use Laminas\\ServiceManager\\Factory\\InvokableFactory; return [ 'controllers' => [ 'factories' => [ Controller\\AlbumController::class => InvokableFactory::class, ], ], 'view_manager' => [ 'template_path_stack' => [ 'album' => __DIR__ . '/../view', ], ], ]; The config information is passed to the relevant components by the ServiceManager . We need two initial sections: controllers and view_manager . The controllers section provides a list of all the controllers provided by the module. We will need one controller, AlbumController ; we'll reference it by its fully qualified class name, and use the laminas-servicemanager InvokableFactory to create instances of it. Within the view_manager section, we add our view directory to the TemplatePathStack configuration. This will allow it to find the view scripts for the Album module that are stored in our view/ directory.","title":"Configuration"},{"location":"getting-started/modules/#informing-the-application-about-our-new-module","text":"We now need to tell the ModuleManager that this new module exists. This is done in the application’s config/modules.config.php file which is provided by the skeleton application. Update this file so that the array it returns contains the Album module as well, so the file now looks like this: (Changes required are highlighted using comments; original comments from the file are omitted for brevity.) return [ 'Laminas\\Form', 'Laminas\\Db', 'Laminas\\Router', 'Laminas\\Validator', 'Application', 'Album', // < -- Add this line ]; As you can see, we have added our Album module into the list of modules after the Application module. We have now set up the module ready for putting our custom code into it.","title":"Informing the application about our new module"},{"location":"getting-started/overview/","text":"Getting Started with Laminas MVC Applications This tutorial is intended to give an introduction to using Laminas by creating a simple database driven application using the Model-View-Controller paradigm. By the end you will have a working Laminas application and you can then poke around the code to find out more about how it all works and fits together. The tutorial application The application that we are going to build is a simple inventory system to display which albums we own. The main page will list our collection and allow us to add, edit and delete CDs. We are going to need four pages in our website: Page Description List of albums This will display the list of albums and provide links to edit and delete them. Also, a link to enable adding new albums will be provided. Add new album This page will provide a form for adding a new album. Edit album This page will provide a form for editing an album. Delete album This page will confirm that we want to delete an album and then delete it. We will also need to store our data into a database. We will only need one table with these fields in it: Field name Type Null? Notes id integer No Primary key, auto-increment artist varchar(100) No title varchar(100) No","title":"Overview"},{"location":"getting-started/overview/#getting-started-with-laminas-mvc-applications","text":"This tutorial is intended to give an introduction to using Laminas by creating a simple database driven application using the Model-View-Controller paradigm. By the end you will have a working Laminas application and you can then poke around the code to find out more about how it all works and fits together.","title":"Getting Started with Laminas MVC Applications"},{"location":"getting-started/overview/#the-tutorial-application","text":"The application that we are going to build is a simple inventory system to display which albums we own. The main page will list our collection and allow us to add, edit and delete CDs. We are going to need four pages in our website: Page Description List of albums This will display the list of albums and provide links to edit and delete them. Also, a link to enable adding new albums will be provided. Add new album This page will provide a form for adding a new album. Edit album This page will provide a form for editing an album. Delete album This page will confirm that we want to delete an album and then delete it. We will also need to store our data into a database. We will only need one table with these fields in it: Field name Type Null? Notes id integer No Primary key, auto-increment artist varchar(100) No title varchar(100) No","title":"The tutorial application"},{"location":"getting-started/routing-and-controllers/","text":"Routing and controllers We will build a very simple inventory system to display our album collection. The home page will list our collection and allow us to add, edit and delete albums. Hence the following pages are required: Page Description Home This will display the list of albums and provide links to edit and delete them. Also, a link to enable adding new albums will be provided. Add new album This page will provide a form for adding a new album. Edit album This page will provide a form for editing an album. Delete album This page will confirm that we want to delete an album and then delete it. Before we set up our files, it's important to understand how the framework expects the pages to be organised. Each page of the application is known as an action and actions are grouped into controllers within modules . Hence, you would generally group related actions into a controller; for instance, a news controller might have actions of current , archived , and view . As we have four pages that all apply to albums, we will group them in a single controller AlbumController within our Album module as four actions. The four actions will be: Page Controller Action Home AlbumController index Add new album AlbumController add Edit album AlbumController edit Delete album AlbumController delete The mapping of a URL to a particular action is done using routes that are defined in the module’s module.config.php file. We will add a route for our album actions. This is the updated module config file with the new code highlighted using comments. namespace Album; use Laminas\\Router\\Http\\Segment; use Laminas\\ServiceManager\\Factory\\InvokableFactory; return [ 'controllers' => [ 'factories' => [ Controller\\AlbumController::class => InvokableFactory::class, ], ], // The following section is new and should be added to your file: 'router' => [ 'routes' => [ 'album' => [ 'type' => Segment::class, 'options' => [ 'route' => '/album[/:action[/:id]]', 'constraints' => [ 'action' => '[a-zA-Z][a-zA-Z0-9_-]*', 'id' => '[0-9]+', ], 'defaults' => [ 'controller' => Controller\\AlbumController::class, 'action' => 'index', ], ], ], ], ], 'view_manager' => [ 'template_path_stack' => [ 'album' => __DIR__ . '/../view', ], ], ]; The name of the route is ‘album’ and has a type of ‘segment’. The segment route allows us to specify placeholders in the URL pattern (route) that will be mapped to named parameters in the matched route. In this case, the route is /album[/:action[/:id]] which will match any URL that starts with /album . The next segment will be an optional action name, and then finally the next segment will be mapped to an optional id. The square brackets indicate that a segment is optional. The constraints section allows us to ensure that the characters within a segment are as expected, so we have limited actions to starting with a letter and then subsequent characters only being alphanumeric, underscore, or hyphen. We also limit the id to digits. This route allows us to have the following URLs: URL Page Action /album Home (list of albums) index /album/add Add new album add /album/edit/2 Edit album with an id of 2 edit /album/delete/4 Delete album with an id of 4 delete Create the controller We are now ready to set up our controller. For laminas-mvc, the controller is a class that is generally called {Controller name}Controller ; note that {Controller name} must start with a capital letter. This class lives in a file called {Controller name}Controller.php within the Controller subdirectory for the module; in our case that is module/Album/src/Controller/ . Each action is a public method within the controller class that is named {action name}Action , where {action name} should start with a lower case letter. Conventions not strictly enforced This is by convention. laminas-mvc doesn't provide many restrictions on controllers other than that they must implement the Laminas\\Stdlib\\Dispatchable interface. The framework provides two abstract classes that do this for us: Laminas\\Mvc\\Controller\\AbstractActionController and Laminas\\Mvc\\Controller\\AbstractRestfulController . We'll be using the standard AbstractActionController , but if you’re intending to write a RESTful web service, AbstractRestfulController may be useful. Let’s go ahead and create our controller class in the file laminas-mvc-tutorials/module/Album/src/Controller/AlbumController.php : namespace Album\\Controller; use Laminas\\Mvc\\Controller\\AbstractActionController; use Laminas\\View\\Model\\ViewModel; class AlbumController extends AbstractActionController { public function indexAction() { } public function addAction() { } public function editAction() { } public function deleteAction() { } } We have now set up the four actions that we want to use. They won't work yet until we set up the views. The URLs for each action are: URL Method called http://localhost:8080/album Album\\Controller\\AlbumController::indexAction http://localhost:8080/album/add Album\\Controller\\AlbumController::addAction http://localhost:8080/album/edit Album\\Controller\\AlbumController::editAction http://localhost:8080/album/delete Album\\Controller\\AlbumController::deleteAction Note If you are using self-hosted Apache, replace http://localhost:8080/ by http://laminas-mvc-tutorial.localhost/ We now have a working router and the actions are set up for each page of our application. It's time to build the view and the model layer. Initialise the view scripts To integrate the view into our application, we need to create some view script files. These files will be executed by the DefaultViewStrategy and will be passed any variables or view models that are returned from the controller action method. These view scripts are stored in our module’s views directory within a directory named after the controller. Create these four empty files now: module/Album/view/album/album/index.phtml module/Album/view/album/album/add.phtml module/Album/view/album/album/edit.phtml module/Album/view/album/album/delete.phtml We can now start filling everything in, starting with our database and models.","title":"Routing and Controllers"},{"location":"getting-started/routing-and-controllers/#routing-and-controllers","text":"We will build a very simple inventory system to display our album collection. The home page will list our collection and allow us to add, edit and delete albums. Hence the following pages are required: Page Description Home This will display the list of albums and provide links to edit and delete them. Also, a link to enable adding new albums will be provided. Add new album This page will provide a form for adding a new album. Edit album This page will provide a form for editing an album. Delete album This page will confirm that we want to delete an album and then delete it. Before we set up our files, it's important to understand how the framework expects the pages to be organised. Each page of the application is known as an action and actions are grouped into controllers within modules . Hence, you would generally group related actions into a controller; for instance, a news controller might have actions of current , archived , and view . As we have four pages that all apply to albums, we will group them in a single controller AlbumController within our Album module as four actions. The four actions will be: Page Controller Action Home AlbumController index Add new album AlbumController add Edit album AlbumController edit Delete album AlbumController delete The mapping of a URL to a particular action is done using routes that are defined in the module’s module.config.php file. We will add a route for our album actions. This is the updated module config file with the new code highlighted using comments. namespace Album; use Laminas\\Router\\Http\\Segment; use Laminas\\ServiceManager\\Factory\\InvokableFactory; return [ 'controllers' => [ 'factories' => [ Controller\\AlbumController::class => InvokableFactory::class, ], ], // The following section is new and should be added to your file: 'router' => [ 'routes' => [ 'album' => [ 'type' => Segment::class, 'options' => [ 'route' => '/album[/:action[/:id]]', 'constraints' => [ 'action' => '[a-zA-Z][a-zA-Z0-9_-]*', 'id' => '[0-9]+', ], 'defaults' => [ 'controller' => Controller\\AlbumController::class, 'action' => 'index', ], ], ], ], ], 'view_manager' => [ 'template_path_stack' => [ 'album' => __DIR__ . '/../view', ], ], ]; The name of the route is ‘album’ and has a type of ‘segment’. The segment route allows us to specify placeholders in the URL pattern (route) that will be mapped to named parameters in the matched route. In this case, the route is /album[/:action[/:id]] which will match any URL that starts with /album . The next segment will be an optional action name, and then finally the next segment will be mapped to an optional id. The square brackets indicate that a segment is optional. The constraints section allows us to ensure that the characters within a segment are as expected, so we have limited actions to starting with a letter and then subsequent characters only being alphanumeric, underscore, or hyphen. We also limit the id to digits. This route allows us to have the following URLs: URL Page Action /album Home (list of albums) index /album/add Add new album add /album/edit/2 Edit album with an id of 2 edit /album/delete/4 Delete album with an id of 4 delete","title":"Routing and controllers"},{"location":"getting-started/routing-and-controllers/#initialise-the-view-scripts","text":"To integrate the view into our application, we need to create some view script files. These files will be executed by the DefaultViewStrategy and will be passed any variables or view models that are returned from the controller action method. These view scripts are stored in our module’s views directory within a directory named after the controller. Create these four empty files now: module/Album/view/album/album/index.phtml module/Album/view/album/album/add.phtml module/Album/view/album/album/edit.phtml module/Album/view/album/album/delete.phtml We can now start filling everything in, starting with our database and models.","title":"Initialise the view scripts"},{"location":"getting-started/skeleton-application/","text":"Getting started: A skeleton application Create a New Project In order to build our application, we need to have at least PHP 8.1. We will start with the Laminas MVC Skeleton Application available on GitHub . Use Composer to create a new project from scratch: $ composer create-project -s dev laminas/laminas-mvc-skeleton path/to/install This will install an initial set of dependencies, including: laminas-component-installer, which helps automate injection of component configuration into your application. laminas-mvc, the kernel for MVC applications. The default is to provide the minimum amount of dependencies necessary to run a laminas-mvc application. However, you may have additional needs that you know at the outset, and, as such, the skeleton also ships with an installer plugin that will prompt you for a number of items. First, it will prompt: Do you want a minimal install (no optional packages)? Y/n Prompts and Default Values All prompts emitted by the installer provide the list of options available, and will specify the default option via a capital letter. Default values are used if the user presses \"Enter\" with no value. In the previous example, \"Y\" is the default. If you answer \"Y\", or press enter with no selection, the installer will not raise any additional prompts, and finish installing your application. If you answer \"n\", it will continue prompting you: Would you like to install the developer toolbar? y/N The developer toolbar provides an in-browser toolbar with timing and profiling information, and can be useful when debugging an application. For the purposes of the tutorial, however, we will not be using it; hit either \"Enter\", or \"n\" followed by \"Enter\". Would you like to install caching support? y/N We will not be demonstrating caching in this tutorial, so either hit \"Enter\", or \"n\" followed by \"Enter\". Would you like to install database support (installs laminas-db)? y/N We will be using laminas-db extensively in this tutorial, so hit \"y\" followed by \"Enter\". You should see the following text appear: Will install laminas/laminas-db (^2.17.0) When prompted to install as a module, select application.config.php or modules.config.php The next prompt is: Would you like to install forms support (installs laminas-form)? y/N This tutorial also uses laminas-form, so we will again select \"y\" to install this; doing so emits a similar message to that used for laminas-db. At this point, we can answer \"n\" to the remaining features: Would you like to install JSON de/serialization support? y/N Would you like to install logging support? y/N Would you like to install command-line interface support? y/N Would you like to install i18n support? y/N Would you like to install the official MVC plugins, including PRG support, identity, and flash messages? y/N Would you like to use the PSR-7 middleware dispatcher? y/N Would you like to install sessions support? y/N Would you like to install the laminas-di integration for laminas-servicemanager? y/N At a certain point, you'll see the following text: Updating root package Running an update to install optional packages ... Updating application configuration... Please select which config file you wish to inject 'Laminas\\Db' into: [0] Do not inject [1] config/modules.config.php Make your selection (default is 1): We want to enable the various selections we made in the application. As such, we'll choose 1 , which will then give us the following prompt: Remember this option for other packages of the same type? (y/N) In our case, we can safely say \"y\", which will mean we will no longer be prompted for additional packages. (The only package in the default set of prompts that you may not want to enable by default is Laminas\\Test .) Once the installation is done, the skeleton installer removes itself, and the new application is ready to start! Downloading the Skeleton Another way to install the Laminas MVC Skeleton Application is to use github to download a compressed archive. Go to https://github.com/laminas/laminas-mvc-skeleton, click the \"Clone or download\" button, and select \"Download ZIP\". This will download a file with a name like laminas-mvc-skeleton-master.zip or similar. Unzip this file into the directory where you keep all your vhosts and rename the resultant directory to laminas-mvc-tutorial . laminas-mvc-skeleton is set up to use Composer to resolve its dependencies. Run the following from within your new laminas-mvc-tutorial folder to install them: $ composer self-update $ composer install This takes a while. You should see output like the following: Installing dependencies from lock file - Installing laminas/laminas-component-installer (2.1.2) ... Generating autoload files At this point, you will be prompted to answer questions as noted above. Alternately, if you do not have Composer installed, but do have docker-compose available, you can run Composer via those: $ docker-compose build $ docker-compose run laminas composer install Timeouts If you see this message: [RuntimeException] The process timed out. then your connection was too slow to download the entire package in time, and composer timed out. To avoid this, instead of running: $ composer install run instead: $ COMPOSER_PROCESS_TIMEOUT=5000 composer install Windows Users Using WAMP For windows users with wamp: 1. Install Composer for Windows Check Composer is properly installed by running: $ composer Otherwise follow the installation guide for Composer . 2. Install Git for Windows Check Git is properly installed by running: $ git Otherwise follow the installation guide for GitHub Desktop . 3. Now Install the Skeleton Using $ composer create-project -s dev laminas/laminas-mvc-skeleton path/to/install We can now move on to the web server setup. Web Servers In this tutorial, we will step you through different ways to set up your web server: Via the PHP built-in web server. Via docker-compose. Using Apache. Using the Built-in PHP Web Server You can use PHP's built-in web server when developing your application. To do this, start the server from the project's root directory: $ php -S 0.0.0.0:8080 -t public public/index.php This will make the website available on port 8080 on all network interfaces, using public/index.php to handle routing. This means the site is accessible via http://localhost:8080 or http://<your-local-IP>:8080 . If you’ve done it right, you should see the following. To test that your routing is working, navigate to http://localhost:8080/1234 , and you should see the following 404 page: Development only PHP's built-in web server should be used for development only . Using docker-compose Docker containers wrap a piece of software and everything needed to run it, guaranteeing consistent operation regardless of the host environment; it is an alternative to virtual machines, as it runs as a layer on top of the host environment. docker-compose is a tool for automating configuration of containers and composing dependencies between them, such as volume storage, networking, etc. The skeleton application ships with a Dockerfile and configuration for docker-compose; we recommend using docker-compose, as it provides a foundation for mapping additional containers you might need as part of your application, including a database server, cache servers, and more. To build and start the image, use: $ docker-compose up -d --build After the first build, you can truncate this to: $ docker-compose up -d Once built, you can also run commands on the container. The docker-compose configuration initially only defines one container, with the environment name \"laminas\"; use that to execute commands, such as updating dependencies via composer: $ docker-compose run laminas composer update The configuration includes both PHP 8.3 and Apache 2.4, and maps the host port 8080 to port 80 of the container. Using the Apache Web Server We will not cover installing Apache , and will assume you already have it installed. We recommend installing Apache 2.4, and will only cover configuration for that version. You now need to create an Apache virtual host for the application and edit your hosts file so that http://laminas-mvc-tutorial.localhost will serve index.php from the laminas-mvc-tutorial/public/ directory. Setting up the virtual host is usually done within httpd.conf or extra/httpd-vhosts.conf . If you are using httpd-vhosts.conf , ensure that this file is included by your main httpd.conf file. Some Linux distributions (ex: Ubuntu) package Apache so that configuration files are stored in /etc/apache2 and create one file per virtual host inside folder /etc/apache2/sites-enabled . In this case, you would place the virtual host block below into the file /etc/apache2/sites-enabled/laminas-mvc-tutorial . Ensure that NameVirtualHost is defined and set to *:80 or similar, and then define a virtual host along these lines: <VirtualHost *:80> ServerName laminas-mvc-tutorial.localhost DocumentRoot /path/to/laminas-mvc-tutorial/public SetEnv APPLICATION_ENV \"development\" <Directory /path/to/laminas-mvc-tutorial/public> DirectoryIndex index.php AllowOverride All Require all granted </Directory> </VirtualHost> Make sure that you update your /etc/hosts or c:\\windows\\system32\\drivers\\etc\\hosts file so that laminas-mvc-tutorial.localhost is mapped to 127.0.0.1 . The website can then be accessed using http://laminas-mvc-tutorial.localhost . 127.0.0.1 laminas-mvc-tutorial.localhost localhost Restart Apache. If you've done so correctly, you will get the same results as covered under the PHP built-in web server . To test that your .htaccess file is working, navigate to http://laminas-mvc-tutorial.localhost/1234 , and you should see the 404 page as noted earlier. If you see a standard Apache 404 error, then you need to fix your .htaccess usage before continuing. If you're are using IIS with the URL Rewrite Module, import the following: RewriteCond %{REQUEST_FILENAME} !-f RewriteRule ^ index.php [NC,L] Error Reporting Optionally, when using Apache , you can use the APPLICATION_ENV setting in your VirtualHost to let PHP output all its errors to the browser. This can be useful during the development of your application. Edit laminas-mvc-tutorial/public/index.php directory and change it to the following: use Laminas\\Mvc\\Application; use Laminas\\Stdlib\\ArrayUtils; /** * Display all errors when APPLICATION_ENV is development. */ if ($_SERVER['APPLICATION_ENV'] === 'development') { error_reporting(E_ALL); ini_set(\"display_errors\", '1'); } /** * This makes our life easier when dealing with paths. Everything is relative * to the application root now. */ chdir(dirname(__DIR__)); // Decline static file requests back to the PHP built-in webserver if (php_sapi_name() === 'cli-server') { $path = realpath(__DIR__ . parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH)); if (__FILE__ !== $path && is_file($path)) { return false; } unset($path); } // Composer autoloading include __DIR__ . '/../vendor/autoload.php'; if (! class_exists(Application::class)) { throw new RuntimeException( \"Unable to load application.\\n\" . \"- Type `composer install` if you are developing locally.\\n\" . \"- Type `docker-compose run laminas composer install` if you are using Docker.\\n\" ); } // Retrieve configuration $appConfig = require __DIR__ . '/../config/application.config.php'; if (file_exists(__DIR__ . '/../config/development.config.php')) { $appConfig = ArrayUtils::merge($appConfig, require __DIR__ . '/../config/development.config.php'); } // Run the application! Application::init($appConfig)->run(); You now have a working skeleton application, and we can start adding the specifics for our application. Development Mode Before we begin, we're going to enable development mode for the application. The skeleton application provides two files that allow us to specify general development settings we want to use everywhere; these may include enabling modules for debugging, or enabling error display in our view scripts. These files are located at: config/development.config.php.dist config/autoload/development.local.php.dist When we enable development mode, these files are copied to: config/development.config.php config/autoload/development.local.php This allows them to be merged into our application. When we disable development mode, these two files that were created are then removed, leaving only the .dist versions. (The repository also contains rules to ignore the copies.) Let's enable development mode now: $ composer development-enable Never Enable Development Mode in Production You should never enable development mode in production, as the typical reason to enable it is to enable debugging! As noted, the artifacts generated by enabling development mode cannot be committed to your repository, so assuming you don't run the command in production, you should be safe. You can test the status of development mode using: $ composer development-status And you can disable it using: $ composer development-disable","title":"The Skeleton Application"},{"location":"getting-started/skeleton-application/#getting-started-a-skeleton-application","text":"","title":"Getting started: A skeleton application"},{"location":"getting-started/skeleton-application/#create-a-new-project","text":"In order to build our application, we need to have at least PHP 8.1. We will start with the Laminas MVC Skeleton Application available on GitHub . Use Composer to create a new project from scratch: $ composer create-project -s dev laminas/laminas-mvc-skeleton path/to/install This will install an initial set of dependencies, including: laminas-component-installer, which helps automate injection of component configuration into your application. laminas-mvc, the kernel for MVC applications. The default is to provide the minimum amount of dependencies necessary to run a laminas-mvc application. However, you may have additional needs that you know at the outset, and, as such, the skeleton also ships with an installer plugin that will prompt you for a number of items. First, it will prompt: Do you want a minimal install (no optional packages)? Y/n Prompts and Default Values All prompts emitted by the installer provide the list of options available, and will specify the default option via a capital letter. Default values are used if the user presses \"Enter\" with no value. In the previous example, \"Y\" is the default. If you answer \"Y\", or press enter with no selection, the installer will not raise any additional prompts, and finish installing your application. If you answer \"n\", it will continue prompting you: Would you like to install the developer toolbar? y/N The developer toolbar provides an in-browser toolbar with timing and profiling information, and can be useful when debugging an application. For the purposes of the tutorial, however, we will not be using it; hit either \"Enter\", or \"n\" followed by \"Enter\". Would you like to install caching support? y/N We will not be demonstrating caching in this tutorial, so either hit \"Enter\", or \"n\" followed by \"Enter\". Would you like to install database support (installs laminas-db)? y/N We will be using laminas-db extensively in this tutorial, so hit \"y\" followed by \"Enter\". You should see the following text appear: Will install laminas/laminas-db (^2.17.0) When prompted to install as a module, select application.config.php or modules.config.php The next prompt is: Would you like to install forms support (installs laminas-form)? y/N This tutorial also uses laminas-form, so we will again select \"y\" to install this; doing so emits a similar message to that used for laminas-db. At this point, we can answer \"n\" to the remaining features: Would you like to install JSON de/serialization support? y/N Would you like to install logging support? y/N Would you like to install command-line interface support? y/N Would you like to install i18n support? y/N Would you like to install the official MVC plugins, including PRG support, identity, and flash messages? y/N Would you like to use the PSR-7 middleware dispatcher? y/N Would you like to install sessions support? y/N Would you like to install the laminas-di integration for laminas-servicemanager? y/N At a certain point, you'll see the following text: Updating root package Running an update to install optional packages ... Updating application configuration... Please select which config file you wish to inject 'Laminas\\Db' into: [0] Do not inject [1] config/modules.config.php Make your selection (default is 1): We want to enable the various selections we made in the application. As such, we'll choose 1 , which will then give us the following prompt: Remember this option for other packages of the same type? (y/N) In our case, we can safely say \"y\", which will mean we will no longer be prompted for additional packages. (The only package in the default set of prompts that you may not want to enable by default is Laminas\\Test .) Once the installation is done, the skeleton installer removes itself, and the new application is ready to start! Downloading the Skeleton Another way to install the Laminas MVC Skeleton Application is to use github to download a compressed archive. Go to https://github.com/laminas/laminas-mvc-skeleton, click the \"Clone or download\" button, and select \"Download ZIP\". This will download a file with a name like laminas-mvc-skeleton-master.zip or similar. Unzip this file into the directory where you keep all your vhosts and rename the resultant directory to laminas-mvc-tutorial . laminas-mvc-skeleton is set up to use Composer to resolve its dependencies. Run the following from within your new laminas-mvc-tutorial folder to install them: $ composer self-update $ composer install This takes a while. You should see output like the following: Installing dependencies from lock file - Installing laminas/laminas-component-installer (2.1.2) ... Generating autoload files At this point, you will be prompted to answer questions as noted above. Alternately, if you do not have Composer installed, but do have docker-compose available, you can run Composer via those: $ docker-compose build $ docker-compose run laminas composer install Timeouts If you see this message: [RuntimeException] The process timed out. then your connection was too slow to download the entire package in time, and composer timed out. To avoid this, instead of running: $ composer install run instead: $ COMPOSER_PROCESS_TIMEOUT=5000 composer install Windows Users Using WAMP For windows users with wamp:","title":"Create a New Project"},{"location":"getting-started/skeleton-application/#web-servers","text":"In this tutorial, we will step you through different ways to set up your web server: Via the PHP built-in web server. Via docker-compose. Using Apache.","title":"Web Servers"},{"location":"getting-started/skeleton-application/#development-mode","text":"Before we begin, we're going to enable development mode for the application. The skeleton application provides two files that allow us to specify general development settings we want to use everywhere; these may include enabling modules for debugging, or enabling error display in our view scripts. These files are located at: config/development.config.php.dist config/autoload/development.local.php.dist When we enable development mode, these files are copied to: config/development.config.php config/autoload/development.local.php This allows them to be merged into our application. When we disable development mode, these two files that were created are then removed, leaving only the .dist versions. (The repository also contains rules to ignore the copies.) Let's enable development mode now: $ composer development-enable Never Enable Development Mode in Production You should never enable development mode in production, as the typical reason to enable it is to enable debugging! As noted, the artifacts generated by enabling development mode cannot be committed to your repository, so assuming you don't run the command in production, you should be safe. You can test the status of development mode using: $ composer development-status And you can disable it using: $ composer development-disable","title":"Development Mode"},{"location":"in-depth-guide/data-binding/","text":"Editing and Deleting Data In the previous chapter we've come to learn how we can use the laminas-form and laminas-db components for creating new data-sets. This chapter will focus on finalizing the CRUD functionality by introducing the concepts for editing and deleting data. Binding Objects to Forms The one fundamental difference between our \"add post\" and \"edit post\" forms is the existence of data. This means we need to find a way to get data from our repository into the form. Luckily, laminas-form provides this via a data-binding feature. In order to use this feature, you will need to retrieve a Post instance, and bind it to the form. To do this, we will need to: Add a dependency in our WriteController on our PostRepositoryInterface , from which we will retrieve our Post . Add a new method to our WriteController , editAction() , that will retrieve a Post , bind it to the form, and either display the form or process it. Update our WriteControllerFactory to inject the PostRepositoryInterface . We'll begin by updating the WriteController : We will import the PostRepositoryInterface . We will add a property for storing the PostRepositoryInterface . We will update the constructor to accept the PostRepositoryInterface . We will add the editAction() implementation. The final result will look like the following: <?php // In module/Blog/src/Controller/WriteController.php: namespace Blog\\Controller; use Blog\\Form\\PostForm; use Blog\\Model\\Post; use Blog\\Model\\PostCommandInterface; use Blog\\Model\\PostRepositoryInterface; use InvalidArgumentException; use Laminas\\Mvc\\Controller\\AbstractActionController; use Laminas\\View\\Model\\ViewModel; class WriteController extends AbstractActionController { /** * @var PostCommandInterface */ private $command; /** * @var PostForm */ private $form; /** * @var PostRepositoryInterface */ private $repository; /** * @param PostCommandInterface $command * @param PostForm $form * @param PostRepositoryInterface $repository */ public function __construct( PostCommandInterface $command, PostForm $form, PostRepositoryInterface $repository ) { $this->command = $command; $this->form = $form; $this->repository = $repository; } public function addAction() { $request = $this->getRequest(); $viewModel = new ViewModel(['form' => $this->form]); if (! $request->isPost()) { return $viewModel; } $this->form->setData($request->getPost()); if (! $this->form->isValid()) { return $viewModel; } $post = $this->form->getData(); try { $post = $this->command->insertPost($post); } catch (\\Exception $ex) { // An exception occurred; we may want to log this later and/or // report it to the user. For now, we'll just re-throw. throw $ex; } return $this->redirect()->toRoute( 'blog/detail', ['id' => $post->getId()] ); } public function editAction() { $id = $this->params()->fromRoute('id'); if (! $id) { return $this->redirect()->toRoute('blog'); } try { $post = $this->repository->findPost($id); } catch (InvalidArgumentException $ex) { return $this->redirect()->toRoute('blog'); } $this->form->bind($post); $viewModel = new ViewModel(['form' => $this->form]); $request = $this->getRequest(); if (! $request->isPost()) { return $viewModel; } $this->form->setData($request->getPost()); if (! $this->form->isValid()) { return $viewModel; } $post = $this->command->updatePost($post); return $this->redirect()->toRoute( 'blog/detail', ['id' => $post->getId()] ); } } The primary differences between addAction() and editAction() are that the latter needs to first fetch a Post , and this post is bound to the form. By binding it, we ensure that the data is populated in the form for the initial display, and, once validated, the same instance is updated. This means that we can omit the call to getData() after validating the form. Now we need to update our WriteControllerFactory . First, add a new import statement to it: // In module/Blog/src/Factory/WriteControllerFactory.php: use Blog\\Model\\PostRepositoryInterface; Next, update the body of the factory to read as follows: // In module/Blog/src/Factory/WriteControllerFactory.php: public function __invoke(ContainerInterface $container, $requestedName, array $options = null) { $formManager = $container->get('FormElementManager'); return new WriteController( $container->get(PostCommandInterface::class), $formManager->get(PostForm::class), $container->get(PostRepositoryInterface::class) ); } The controller and model are now wired together, so it's time to turn to routing. Adding the edit route The edit route is identical to the blog/detail route we previously defined, with two exceptions: it will have a path prefix, /edit it will route to our WriteController Update the 'blog' child_routes to add the new route: // In module/Blog/config/module.config.php: use Laminas\\Router\\Http\\Segment; return [ 'service_manager' => [ /* ... */ ], 'controllers' => [ /* ... */ ], 'router' => [ 'routes' => [ 'blog' => [ /* ... */ 'child_routes' => [ /* ... */ 'edit' => [ 'type' => Segment::class, 'options' => [ 'route' => '/edit/:id', 'defaults' => [ 'controller' => Controller\\WriteController::class, 'action' => 'edit', ], 'constraints' => [ 'id' => '[1-9]\\d*', ], ], ], ], ], ], ], 'view_manager' => [ /* ... */ ], ]; Creating the edit template Rendering the form remains essentially the same between the add and edit templates; the only difference between them is the form action. As such, we will create a new partial script for the form, update the add template to use it, and create a new edit template. Create a new file, module/Blog/view/blog/write/form.phtml , with the following contents: <?php $form = $this->form; $fieldset = $form->get('post'); $title = $fieldset->get('title'); $title->setAttribute('class', 'form-control'); $title->setAttribute('placeholder', 'Post title'); $text = $fieldset->get('text'); $text->setAttribute('class', 'form-control'); $text->setAttribute('placeholder', 'Post content'); $submit = $form->get('submit'); $submit->setValue($this->submitLabel); $submit->setAttribute('class', 'btn btn-primary'); $form->prepare(); echo $this->form()->openTag($form); ?> <fieldset> <div class=\"form-group\"> <?= $this->formLabel($title) ?> <?= $this->formElement($title) ?> <?= $this->formElementErrors()->render($title, ['class' => 'help-block']) ?> </div> <div class=\"form-group\"> <?= $this->formLabel($text) ?> <?= $this->formElement($text) ?> <?= $this->formElementErrors()->render($text, ['class' => 'help-block']) ?> </div> </fieldset> <?php echo $this->formSubmit($submit); echo $this->formHidden($fieldset->get('id')); echo $this->form()->closeTag(); Now, update the add template, module/Blog/view/blog/write/add.phtml to read as follows: <h1>Add a blog post</h1> <?php $form = $this->form; $form->setAttribute('action', $this->url()); echo $this->partial('blog/write/form', [ 'form' => $form, 'submitLabel' => 'Insert new post', ]); The above retrieves the form, sets the form action, provides a context-appropriate label for the submit button, and renders it with our new partial view script. Next in line is the creation of the new template, blog/write/edit : <h1>Edit blog post</h1> <?php $form = $this->form; $form->setAttribute('action', $this->url('blog/edit', [], true)); echo $this->partial('blog/write/form', [ 'form' => $form, 'submitLabel' => 'Update post', ]); The three differences between the add and edit templates are: The heading at the top of the page. The URI used for the form action. The label used for the submit button. Because the URI requires the identifier, we need to ensure the identifier is passed. The way we've done this in the controllers is to pass the identifier as a parameter: $this->url('blog/edit/', ['id' => $id]) . This would require that we pass the original Post instance or the identifier we pull from it to the view, however. laminas-router allows another option, however: you can tell it to re-use currently matched parameters. This is done by setting the last parameter of the view-helper to true : $this->url('blog/edit', [], true) . If you try and update the post, you will receive the following error: Call to member function getId() on null That is because we have not yet implemented the update functionality in our command class which will return a Post object on success. Let's do that now. Edit the file module/Blog/src/Model/LaminasDbSqlCommand.php , and update the updatePost() method to read as follows: public function updatePost(Post $post) { if (! $post->getId()) { throw new RuntimeException('Cannot update post; missing identifier'); } $update = new Update('posts'); $update->set([ 'title' => $post->getTitle(), 'text' => $post->getText(), ]); $update->where(['id = ?' => $post->getId()]); $sql = new Sql($this->db); $statement = $sql->prepareStatementForSqlObject($update); $result = $statement->execute(); if (! $result instanceof ResultInterface) { throw new RuntimeException( 'Database error occurred during blog post update operation' ); } return $post; } This looks very similar to the insertPost() implementation we did earlier. The primary difference is the usage of the Update class; instead of calling a values() method on it, we call: set() , to provide the values we are updating. where() , to provide criteria to determine which records (record singular, in our case) are updated. Additionally, we test for the presence of an identifier before performing the operation, and, because we already have one, and the Post submitted to us contains all the edits we submitted to the database, we return it verbatim on success. Implementing the delete functionality Last but not least, it's time to delete some data. We start this process by implementing the deletePost() method in our LaminasDbSqlCommand class: // In module/Blog/src/Model/LaminasDbSqlCommand.php: public function deletePost(Post $post) { if (! $post->getId()) { throw new RuntimeException('Cannot delete post; missing identifier'); } $delete = new Delete('posts'); $delete->where(['id = ?' => $post->getId()]); $sql = new Sql($this->db); $statement = $sql->prepareStatementForSqlObject($delete); $result = $statement->execute(); if (! $result instanceof ResultInterface) { return false; } return true; } The above uses Laminas\\Db\\Sql\\Delete to create the SQL necessary to delete the post with the given identifier, which we then execute. Next, let's create a new controller, Blog\\Controller\\DeleteController , in a new file module/Blog/src/Controller/DeleteController.php , with the following contents: <?php namespace Blog\\Controller; use Blog\\Model\\Post; use Blog\\Model\\PostCommandInterface; use Blog\\Model\\PostRepositoryInterface; use InvalidArgumentException; use Laminas\\Mvc\\Controller\\AbstractActionController; use Laminas\\View\\Model\\ViewModel; class DeleteController extends AbstractActionController { /** * @var PostCommandInterface */ private $command; /** * @var PostRepositoryInterface */ private $repository; /** * @param PostCommandInterface $command * @param PostRepositoryInterface $repository */ public function __construct( PostCommandInterface $command, PostRepositoryInterface $repository ) { $this->command = $command; $this->repository = $repository; } public function deleteAction() { $id = $this->params()->fromRoute('id'); if (! $id) { return $this->redirect()->toRoute('blog'); } try { $post = $this->repository->findPost($id); } catch (InvalidArgumentException $ex) { return $this->redirect()->toRoute('blog'); } $request = $this->getRequest(); if (! $request->isPost()) { return new ViewModel(['post' => $post]); } if ($id != $request->getPost('id') || 'Delete' !== $request->getPost('confirm', 'no') ) { return $this->redirect()->toRoute('blog'); } $post = $this->command->deletePost($post); return $this->redirect()->toRoute('blog'); } } Like the WriteController , it composes both our PostRepositoryInterface and PostCommandInterface . The former is used to ensure we are referencing a valid post instance, and the latter to perform the actual deletion. When a user requests the page via the GET method, we will display a page containing details of the post, and a confirmation form. When submitted, we'll check to make sure they confirmed the deletion before issuing our delete command. If any conditions fail, or on a successful deletion, we redirect to our blog listing page. Like the other controllers, we now need a factory. Create the file module/Blog/src/Factory/DeleteControllerFactory.php with the following contents: <?php namespace Blog\\Factory; use Blog\\Controller\\DeleteController; use Blog\\Model\\PostCommandInterface; use Blog\\Model\\PostRepositoryInterface; use Interop\\Container\\ContainerInterface; use Laminas\\ServiceManager\\Factory\\FactoryInterface; class DeleteControllerFactory implements FactoryInterface { /** * @param ContainerInterface $container * @param string $requestedName * @param null|array $options * @return DeleteController */ public function __invoke(ContainerInterface $container, $requestedName, array $options = null) { return new DeleteController( $container->get(PostCommandInterface::class), $container->get(PostRepositoryInterface::class) ); } } We'll now wire this into the application, mapping the controller to its factory, and providing a new route. Open the file module/Blog/config/module.config.php and make the following edits. First, map the controller to its factory: 'controllers' => [ 'factories' => [ Controller\\ListController::class => Factory\\ListControllerFactory::class, Controller\\WriteController::class => Factory\\WriteControllerFactory::class, // Add the following line: Controller\\DeleteController::class => Factory\\DeleteControllerFactory::class, ], ], Now add another child route to our \"blog\" route: 'router' => [ 'routes' => [ 'blog' => [ /* ... */ 'child_routes' => [ /* ... */ 'delete' => [ 'type' => Segment::class, 'options' => [ 'route' => '/delete/:id', 'defaults' => [ 'controller' => Controller\\DeleteController::class, 'action' => 'delete', ], 'constraints' => [ 'id' => '[1-9]\\d*', ], ], ], ], ], ], ], Finally, we'll create a new view script, module/Blog/view/blog/delete/delete.phtml , with the following contents: <h1>Delete post</h1> <p>Are you sure you want to delete the following post?</p> <ul class=\"list-group\"> <li class=\"list-group-item\"><?= $this->escapeHtml($this->post->getTitle()) ?></li> </ul> <form action=\"<?php $this->url('blog/delete', [], true) ?>\" method=\"post\"> <input type=\"hidden\" name=\"id\" value=\"<?= $this->escapeHtmlAttr($this->post->getId()) ?>\" /> <input class=\"btn btn-default\" type=\"submit\" name=\"confirm\" value=\"Cancel\" /> <input class=\"btn btn-danger\" type=\"submit\" name=\"confirm\" value=\"Delete\" /> </form> This time around, we're not using laminas-form; as it consists of just a hidden element and cancel/confirm buttons, there's no need to provide an OOP model for it. From here, you can now visit one of the existing blog posts, e.g., http://localhost:8080/blog/delete/1 to see the form. If you choose Cancel , you should be taken back to the list; if you choose Delete , it should delete the post and then take you back to the list, and you should see the post is no longer present. Making the list more useful Our blog post list currently lists everything about all of our blog posts; additionally, it doesn't link to them, which means we have to manually update the URL in our browser in order to test functionality. Let's update the list view to be more useful; we'll: List just the title of each blog post; linking the title to the post display; and providing links for editing and deleting the post. Add a button to allow users to add a new post. In a real-world application, we'd probably use some sort of access controls to determine if the edit and delete links will be displayed; we'll leave that for another tutorial, however. Open your module/Blog/view/blog/list/index.phtml file, and update it to read as follows: <h1>Blog Posts</h1> <div class=\"list-group\"> <?php foreach ($this->posts as $post): ?> <div class=\"list-group-item\"> <h4 class=\"list-group-item-heading\"> <a href=\"<?= $this->url('blog/detail', ['id' => $post->getId()]) ?>\"> <?= $post->getTitle() ?> </a> </h4> <div class=\"btn-group\" role=\"group\" aria-label=\"Post actions\"> <a class=\"btn btn-xs btn-default\" href=\"<?= $this->url('blog/edit', ['id' => $post->getId()]) ?>\">Edit</a> <a class=\"btn btn-xs btn-danger\" href=\"<?= $this->url('blog/delete', ['id' => $post->getId()]) ?>\">Delete</a> </div> </div> <?php endforeach ?> </div> <div class=\"btn-group\" role=\"group\" aria-label=\"Post actions\"> <a class=\"btn btn-primary\" href=\"<?= $this->url('blog/add') ?>\">Write new post</a> </div> At this point, we have a far more functional blog, as we can move around between pages using links and buttons. Summary In this chapter we've learned how data binding within the laminas-form component works, and used it to provide functionality for our update routine. We also learned how this allows us to de-couple our controllers from the details of how a form is structured, helping us keep implementation details out of our controller. We also demonstrated the use of view partials, which allow us to split out duplication in our views and re-use them. In particular, we did this with our form, to prevent needlessly duplicating the form markup. Finally, we looked at two more aspects of the Laminas\\Db\\Sql subcomponent, and learned how to perform Update and Delete operations. In the next chapter we'll summarize everything we've done. We'll talk about the design patterns we've used, and we'll cover several questions that likely arose during the course of this tutorial.","title":"Editing and Deleting Data"},{"location":"in-depth-guide/data-binding/#editing-and-deleting-data","text":"In the previous chapter we've come to learn how we can use the laminas-form and laminas-db components for creating new data-sets. This chapter will focus on finalizing the CRUD functionality by introducing the concepts for editing and deleting data.","title":"Editing and Deleting Data"},{"location":"in-depth-guide/data-binding/#binding-objects-to-forms","text":"The one fundamental difference between our \"add post\" and \"edit post\" forms is the existence of data. This means we need to find a way to get data from our repository into the form. Luckily, laminas-form provides this via a data-binding feature. In order to use this feature, you will need to retrieve a Post instance, and bind it to the form. To do this, we will need to: Add a dependency in our WriteController on our PostRepositoryInterface , from which we will retrieve our Post . Add a new method to our WriteController , editAction() , that will retrieve a Post , bind it to the form, and either display the form or process it. Update our WriteControllerFactory to inject the PostRepositoryInterface . We'll begin by updating the WriteController : We will import the PostRepositoryInterface . We will add a property for storing the PostRepositoryInterface . We will update the constructor to accept the PostRepositoryInterface . We will add the editAction() implementation. The final result will look like the following: <?php // In module/Blog/src/Controller/WriteController.php: namespace Blog\\Controller; use Blog\\Form\\PostForm; use Blog\\Model\\Post; use Blog\\Model\\PostCommandInterface; use Blog\\Model\\PostRepositoryInterface; use InvalidArgumentException; use Laminas\\Mvc\\Controller\\AbstractActionController; use Laminas\\View\\Model\\ViewModel; class WriteController extends AbstractActionController { /** * @var PostCommandInterface */ private $command; /** * @var PostForm */ private $form; /** * @var PostRepositoryInterface */ private $repository; /** * @param PostCommandInterface $command * @param PostForm $form * @param PostRepositoryInterface $repository */ public function __construct( PostCommandInterface $command, PostForm $form, PostRepositoryInterface $repository ) { $this->command = $command; $this->form = $form; $this->repository = $repository; } public function addAction() { $request = $this->getRequest(); $viewModel = new ViewModel(['form' => $this->form]); if (! $request->isPost()) { return $viewModel; } $this->form->setData($request->getPost()); if (! $this->form->isValid()) { return $viewModel; } $post = $this->form->getData(); try { $post = $this->command->insertPost($post); } catch (\\Exception $ex) { // An exception occurred; we may want to log this later and/or // report it to the user. For now, we'll just re-throw. throw $ex; } return $this->redirect()->toRoute( 'blog/detail', ['id' => $post->getId()] ); } public function editAction() { $id = $this->params()->fromRoute('id'); if (! $id) { return $this->redirect()->toRoute('blog'); } try { $post = $this->repository->findPost($id); } catch (InvalidArgumentException $ex) { return $this->redirect()->toRoute('blog'); } $this->form->bind($post); $viewModel = new ViewModel(['form' => $this->form]); $request = $this->getRequest(); if (! $request->isPost()) { return $viewModel; } $this->form->setData($request->getPost()); if (! $this->form->isValid()) { return $viewModel; } $post = $this->command->updatePost($post); return $this->redirect()->toRoute( 'blog/detail', ['id' => $post->getId()] ); } } The primary differences between addAction() and editAction() are that the latter needs to first fetch a Post , and this post is bound to the form. By binding it, we ensure that the data is populated in the form for the initial display, and, once validated, the same instance is updated. This means that we can omit the call to getData() after validating the form. Now we need to update our WriteControllerFactory . First, add a new import statement to it: // In module/Blog/src/Factory/WriteControllerFactory.php: use Blog\\Model\\PostRepositoryInterface; Next, update the body of the factory to read as follows: // In module/Blog/src/Factory/WriteControllerFactory.php: public function __invoke(ContainerInterface $container, $requestedName, array $options = null) { $formManager = $container->get('FormElementManager'); return new WriteController( $container->get(PostCommandInterface::class), $formManager->get(PostForm::class), $container->get(PostRepositoryInterface::class) ); } The controller and model are now wired together, so it's time to turn to routing.","title":"Binding Objects to Forms"},{"location":"in-depth-guide/data-binding/#adding-the-edit-route","text":"The edit route is identical to the blog/detail route we previously defined, with two exceptions: it will have a path prefix, /edit it will route to our WriteController Update the 'blog' child_routes to add the new route: // In module/Blog/config/module.config.php: use Laminas\\Router\\Http\\Segment; return [ 'service_manager' => [ /* ... */ ], 'controllers' => [ /* ... */ ], 'router' => [ 'routes' => [ 'blog' => [ /* ... */ 'child_routes' => [ /* ... */ 'edit' => [ 'type' => Segment::class, 'options' => [ 'route' => '/edit/:id', 'defaults' => [ 'controller' => Controller\\WriteController::class, 'action' => 'edit', ], 'constraints' => [ 'id' => '[1-9]\\d*', ], ], ], ], ], ], ], 'view_manager' => [ /* ... */ ], ];","title":"Adding the edit route"},{"location":"in-depth-guide/data-binding/#creating-the-edit-template","text":"Rendering the form remains essentially the same between the add and edit templates; the only difference between them is the form action. As such, we will create a new partial script for the form, update the add template to use it, and create a new edit template. Create a new file, module/Blog/view/blog/write/form.phtml , with the following contents: <?php $form = $this->form; $fieldset = $form->get('post'); $title = $fieldset->get('title'); $title->setAttribute('class', 'form-control'); $title->setAttribute('placeholder', 'Post title'); $text = $fieldset->get('text'); $text->setAttribute('class', 'form-control'); $text->setAttribute('placeholder', 'Post content'); $submit = $form->get('submit'); $submit->setValue($this->submitLabel); $submit->setAttribute('class', 'btn btn-primary'); $form->prepare(); echo $this->form()->openTag($form); ?> <fieldset> <div class=\"form-group\"> <?= $this->formLabel($title) ?> <?= $this->formElement($title) ?> <?= $this->formElementErrors()->render($title, ['class' => 'help-block']) ?> </div> <div class=\"form-group\"> <?= $this->formLabel($text) ?> <?= $this->formElement($text) ?> <?= $this->formElementErrors()->render($text, ['class' => 'help-block']) ?> </div> </fieldset> <?php echo $this->formSubmit($submit); echo $this->formHidden($fieldset->get('id')); echo $this->form()->closeTag(); Now, update the add template, module/Blog/view/blog/write/add.phtml to read as follows: <h1>Add a blog post</h1> <?php $form = $this->form; $form->setAttribute('action', $this->url()); echo $this->partial('blog/write/form', [ 'form' => $form, 'submitLabel' => 'Insert new post', ]); The above retrieves the form, sets the form action, provides a context-appropriate label for the submit button, and renders it with our new partial view script. Next in line is the creation of the new template, blog/write/edit : <h1>Edit blog post</h1> <?php $form = $this->form; $form->setAttribute('action', $this->url('blog/edit', [], true)); echo $this->partial('blog/write/form', [ 'form' => $form, 'submitLabel' => 'Update post', ]); The three differences between the add and edit templates are: The heading at the top of the page. The URI used for the form action. The label used for the submit button. Because the URI requires the identifier, we need to ensure the identifier is passed. The way we've done this in the controllers is to pass the identifier as a parameter: $this->url('blog/edit/', ['id' => $id]) . This would require that we pass the original Post instance or the identifier we pull from it to the view, however. laminas-router allows another option, however: you can tell it to re-use currently matched parameters. This is done by setting the last parameter of the view-helper to true : $this->url('blog/edit', [], true) . If you try and update the post, you will receive the following error: Call to member function getId() on null That is because we have not yet implemented the update functionality in our command class which will return a Post object on success. Let's do that now. Edit the file module/Blog/src/Model/LaminasDbSqlCommand.php , and update the updatePost() method to read as follows: public function updatePost(Post $post) { if (! $post->getId()) { throw new RuntimeException('Cannot update post; missing identifier'); } $update = new Update('posts'); $update->set([ 'title' => $post->getTitle(), 'text' => $post->getText(), ]); $update->where(['id = ?' => $post->getId()]); $sql = new Sql($this->db); $statement = $sql->prepareStatementForSqlObject($update); $result = $statement->execute(); if (! $result instanceof ResultInterface) { throw new RuntimeException( 'Database error occurred during blog post update operation' ); } return $post; } This looks very similar to the insertPost() implementation we did earlier. The primary difference is the usage of the Update class; instead of calling a values() method on it, we call: set() , to provide the values we are updating. where() , to provide criteria to determine which records (record singular, in our case) are updated. Additionally, we test for the presence of an identifier before performing the operation, and, because we already have one, and the Post submitted to us contains all the edits we submitted to the database, we return it verbatim on success.","title":"Creating the edit template"},{"location":"in-depth-guide/data-binding/#implementing-the-delete-functionality","text":"Last but not least, it's time to delete some data. We start this process by implementing the deletePost() method in our LaminasDbSqlCommand class: // In module/Blog/src/Model/LaminasDbSqlCommand.php: public function deletePost(Post $post) { if (! $post->getId()) { throw new RuntimeException('Cannot delete post; missing identifier'); } $delete = new Delete('posts'); $delete->where(['id = ?' => $post->getId()]); $sql = new Sql($this->db); $statement = $sql->prepareStatementForSqlObject($delete); $result = $statement->execute(); if (! $result instanceof ResultInterface) { return false; } return true; } The above uses Laminas\\Db\\Sql\\Delete to create the SQL necessary to delete the post with the given identifier, which we then execute. Next, let's create a new controller, Blog\\Controller\\DeleteController , in a new file module/Blog/src/Controller/DeleteController.php , with the following contents: <?php namespace Blog\\Controller; use Blog\\Model\\Post; use Blog\\Model\\PostCommandInterface; use Blog\\Model\\PostRepositoryInterface; use InvalidArgumentException; use Laminas\\Mvc\\Controller\\AbstractActionController; use Laminas\\View\\Model\\ViewModel; class DeleteController extends AbstractActionController { /** * @var PostCommandInterface */ private $command; /** * @var PostRepositoryInterface */ private $repository; /** * @param PostCommandInterface $command * @param PostRepositoryInterface $repository */ public function __construct( PostCommandInterface $command, PostRepositoryInterface $repository ) { $this->command = $command; $this->repository = $repository; } public function deleteAction() { $id = $this->params()->fromRoute('id'); if (! $id) { return $this->redirect()->toRoute('blog'); } try { $post = $this->repository->findPost($id); } catch (InvalidArgumentException $ex) { return $this->redirect()->toRoute('blog'); } $request = $this->getRequest(); if (! $request->isPost()) { return new ViewModel(['post' => $post]); } if ($id != $request->getPost('id') || 'Delete' !== $request->getPost('confirm', 'no') ) { return $this->redirect()->toRoute('blog'); } $post = $this->command->deletePost($post); return $this->redirect()->toRoute('blog'); } } Like the WriteController , it composes both our PostRepositoryInterface and PostCommandInterface . The former is used to ensure we are referencing a valid post instance, and the latter to perform the actual deletion. When a user requests the page via the GET method, we will display a page containing details of the post, and a confirmation form. When submitted, we'll check to make sure they confirmed the deletion before issuing our delete command. If any conditions fail, or on a successful deletion, we redirect to our blog listing page. Like the other controllers, we now need a factory. Create the file module/Blog/src/Factory/DeleteControllerFactory.php with the following contents: <?php namespace Blog\\Factory; use Blog\\Controller\\DeleteController; use Blog\\Model\\PostCommandInterface; use Blog\\Model\\PostRepositoryInterface; use Interop\\Container\\ContainerInterface; use Laminas\\ServiceManager\\Factory\\FactoryInterface; class DeleteControllerFactory implements FactoryInterface { /** * @param ContainerInterface $container * @param string $requestedName * @param null|array $options * @return DeleteController */ public function __invoke(ContainerInterface $container, $requestedName, array $options = null) { return new DeleteController( $container->get(PostCommandInterface::class), $container->get(PostRepositoryInterface::class) ); } } We'll now wire this into the application, mapping the controller to its factory, and providing a new route. Open the file module/Blog/config/module.config.php and make the following edits. First, map the controller to its factory: 'controllers' => [ 'factories' => [ Controller\\ListController::class => Factory\\ListControllerFactory::class, Controller\\WriteController::class => Factory\\WriteControllerFactory::class, // Add the following line: Controller\\DeleteController::class => Factory\\DeleteControllerFactory::class, ], ], Now add another child route to our \"blog\" route: 'router' => [ 'routes' => [ 'blog' => [ /* ... */ 'child_routes' => [ /* ... */ 'delete' => [ 'type' => Segment::class, 'options' => [ 'route' => '/delete/:id', 'defaults' => [ 'controller' => Controller\\DeleteController::class, 'action' => 'delete', ], 'constraints' => [ 'id' => '[1-9]\\d*', ], ], ], ], ], ], ], Finally, we'll create a new view script, module/Blog/view/blog/delete/delete.phtml , with the following contents: <h1>Delete post</h1> <p>Are you sure you want to delete the following post?</p> <ul class=\"list-group\"> <li class=\"list-group-item\"><?= $this->escapeHtml($this->post->getTitle()) ?></li> </ul> <form action=\"<?php $this->url('blog/delete', [], true) ?>\" method=\"post\"> <input type=\"hidden\" name=\"id\" value=\"<?= $this->escapeHtmlAttr($this->post->getId()) ?>\" /> <input class=\"btn btn-default\" type=\"submit\" name=\"confirm\" value=\"Cancel\" /> <input class=\"btn btn-danger\" type=\"submit\" name=\"confirm\" value=\"Delete\" /> </form> This time around, we're not using laminas-form; as it consists of just a hidden element and cancel/confirm buttons, there's no need to provide an OOP model for it. From here, you can now visit one of the existing blog posts, e.g., http://localhost:8080/blog/delete/1 to see the form. If you choose Cancel , you should be taken back to the list; if you choose Delete , it should delete the post and then take you back to the list, and you should see the post is no longer present.","title":"Implementing the delete functionality"},{"location":"in-depth-guide/data-binding/#making-the-list-more-useful","text":"Our blog post list currently lists everything about all of our blog posts; additionally, it doesn't link to them, which means we have to manually update the URL in our browser in order to test functionality. Let's update the list view to be more useful; we'll: List just the title of each blog post; linking the title to the post display; and providing links for editing and deleting the post. Add a button to allow users to add a new post. In a real-world application, we'd probably use some sort of access controls to determine if the edit and delete links will be displayed; we'll leave that for another tutorial, however. Open your module/Blog/view/blog/list/index.phtml file, and update it to read as follows: <h1>Blog Posts</h1> <div class=\"list-group\"> <?php foreach ($this->posts as $post): ?> <div class=\"list-group-item\"> <h4 class=\"list-group-item-heading\"> <a href=\"<?= $this->url('blog/detail', ['id' => $post->getId()]) ?>\"> <?= $post->getTitle() ?> </a> </h4> <div class=\"btn-group\" role=\"group\" aria-label=\"Post actions\"> <a class=\"btn btn-xs btn-default\" href=\"<?= $this->url('blog/edit', ['id' => $post->getId()]) ?>\">Edit</a> <a class=\"btn btn-xs btn-danger\" href=\"<?= $this->url('blog/delete', ['id' => $post->getId()]) ?>\">Delete</a> </div> </div> <?php endforeach ?> </div> <div class=\"btn-group\" role=\"group\" aria-label=\"Post actions\"> <a class=\"btn btn-primary\" href=\"<?= $this->url('blog/add') ?>\">Write new post</a> </div> At this point, we have a far more functional blog, as we can move around between pages using links and buttons.","title":"Making the list more useful"},{"location":"in-depth-guide/data-binding/#summary","text":"In this chapter we've learned how data binding within the laminas-form component works, and used it to provide functionality for our update routine. We also learned how this allows us to de-couple our controllers from the details of how a form is structured, helping us keep implementation details out of our controller. We also demonstrated the use of view partials, which allow us to split out duplication in our views and re-use them. In particular, we did this with our form, to prevent needlessly duplicating the form markup. Finally, we looked at two more aspects of the Laminas\\Db\\Sql subcomponent, and learned how to perform Update and Delete operations. In the next chapter we'll summarize everything we've done. We'll talk about the design patterns we've used, and we'll cover several questions that likely arose during the course of this tutorial.","title":"Summary"},{"location":"in-depth-guide/first-module/","text":"Introducing the Blog Module Now that we know about the basics of the laminas-mvc skeleton application, let's continue and create our very own module. We will create a module named \"Blog\". This module will display a list of database entries that represent a single blog post. Each post will have three properties: id , text , and title . We will create forms to enter new posts into our database and to edit existing posts. Furthermore we will do so by using best-practices throughout the whole tutorial. Writing a new Module Let's start by creating a new folder under the /module directory called Blog , with the following stucture: module/ Blog/ config/ src/ view/ To be recognized as a module by the ModuleManager , we need to do three things: Tell Composer how to autoload classes from our new module. Create a Module class in the Blog namespace. Notify the application of the new module. Let's tell Composer about our new module. Open the composer.json file in the project root, and edit the autoload section to add a new PSR-4 entry for the Blog module; when you're done, it should read: \"autoload\": { \"psr-4\": { \"Application\\\\\": \"module/Application/src/\", \"Album\\\\\": \"module/Album/src/\", \"Blog\\\\\": \"module/Blog/src/\" } } Once you're done, tell Composer to update its autoloading definitions: $ composer dump-autoload Next, we will create a Module class under the Blog namespace. Create the file module/Blog/src/Module.php with the following contents: namespace Blog; class Module { } We now have a module that can be detected by the ModuleManager . Let's add this module to our application. Although our module doesn't do anything yet, just having the Module.php class allows it to be loaded by the ModuleManager. To do this, add an entry for Blog to the modules array inside config/modules.config.php : // In config/modules.config.php: return [ /* ... */ 'Application', 'Album', 'Blog', ]; If you refresh your application you should see no change at all (but also no errors). At this point it's worth taking a step back to discuss what modules are for. In short, a module is an encapsulated set of features for your application. A module might add features to the application that you can see, like our Blog module; or it might provide background functionality for other modules in the application to use, such as interacting with a third party API. Organizing your code into modules makes it easier for you to reuse functionality in other applications, or to use modules written by the community. Configuring the Module The next thing we're going to do is add a route to our application so that our module can be accessed through the URL localhost:8080/blog . We do this by adding router configuration to our module, but first we need to let the ModuleManager know that our module has configuration that it needs to load. This is done by adding a getConfig() method to the Module class that returns the configuration. (This method is defined in the ConfigProviderInterface , although explicitly implementing this interface in the module class is optional.) This method should return either an array or a Traversable object. Continue by editing module/Blog/src/Module.php : // In /module/Blog/src/Module.php: class Module { public function getConfig() : array { return []; } } With this, our module is now able to be configured. Configuration files can become quite big, though, and keeping everything inside the getConfig() method won't be optimal. To help keep our project organized, we're going to put our array configuration in a separate file. Go ahead and create this file at module/Blog/config/module.config.php : return []; Now rewrite the getConfig() function to include this newly created file instead of directly returning the array: // In /module/Blog/src/Module.php: public function getConfig() : array { return include __DIR__ . '/../config/module.config.php'; } Reload your application and you'll see that nothing changes. Creating, registering, and adding empty configuration for a new module has no visible effect on the application. Next we add the new route to our configuration file: // In /module/Blog/config/module.config.php: namespace Blog; use Laminas\\Router\\Http\\Literal; return [ // This lines opens the configuration for the RouteManager 'router' => [ // Open configuration for all possible routes 'routes' => [ // Define a new route called \"blog\" 'blog' => [ // Define a \"literal\" route type: 'type' => Literal::class, // Configure the route itself 'options' => [ // Listen to \"/blog\" as uri: 'route' => '/blog', // Define default controller and action to be called when // this route is matched 'defaults' => [ 'controller' => Controller\\ListController::class, 'action' => 'index', ], ], ], ], ], ]; We've now created a route called blog that listens to the URL localhost:8080/blog . Whenever someone accesses this route, the indexAction() function of the class Blog\\Controller\\ListController will be executed. However, this controller does not exist yet, so if you reload the page you will see this error message: A 404 error occurred Page not found. The requested controller could not be mapped by routing. Controller: Blog\\Controller\\ListController(resolves to invalid controller class or alias: Blog\\Controller\\ListController) We now need to tell our module where to find this controller named Blog\\Controller\\ListController . To achieve this we have to add this key to the controllers configuration key inside your module/Blog/config/module.config.php . namespace Blog; use Laminas\\ServiceManager\\Factory\\InvokableFactory; return [ 'controllers' => [ 'factories' => [ Controller\\ListController::class => InvokableFactory::class, ], ], /* ... */ ]; This configuration defines a factory for the controller class Blog\\Controller\\ListController , using the laminas-servicemanager InvokableFactory (which, internally, instantiates the class with no arguments). Reloading the page should then give you: Fatal error: Class 'Blog\\Controller\\ListController' not found in {projectPath}/vendor/laminas/laminas-servicemanager/src/Factory/InvokableFactory.php on line 32 This error tells us that the application knows what class to load, but was not able to autoload it. In our case, we've already setup autoloading, but have not yet defined the controller class! Create the file module/Blog/src/Controller/ListController.php with the following contents: namespace Blog\\Controller; class ListController { } Reloading the page now will finally result into a new screen. The new error message looks like this: A 404 error occurred Page not found. The requested controller was not dispatchable. Controller: Blog\\Controller\\List(resolves to invalid controller class or alias: Blog\\Controller\\List) Additional information: Laminas\\ServiceManager\\Exception\\InvalidServiceException File: {projectPath}/vendor/laminas/laminas-mvc/src/Controller/ControllerManager.php:{lineNumber} Message: Plugin of type \"Blog\\Controller\\ListController\" is invalid; must implement Laminas\\Stdlib\\DispatchableInterface This happens because our controller must implement DispatchableInterface in order to be 'dispatched' (or run) by laminas-mvc. laminas-mvc provides a base controller implementation of it with AbstractActionController , which we are going to use. Let's modify our controller now: // In /module/Blog/src/Controller/ListController.php: namespace Blog\\Controller; use Laminas\\Mvc\\Controller\\AbstractActionController; class ListController extends AbstractActionController { } It's now time for another refresh of the site. You should now see a new error message: An error occurred An error occurred during execution; please try again later. Additional information: Laminas\\View\\Exception\\RuntimeException File: {projectPath}/vendor/laminas/laminas-view/src/Renderer/PhpRenderer.php:{lineNumber} Message: Laminas\\View\\Renderer\\PhpRenderer::render: Unable to render template \"blog/list/index\"; resolver could not resolve to a file Now the application tells you that a view template-file cannot be rendered, which is to be expected as we've not created it yet. The application is expecting it to be at module/Blog/view/blog/list/index.phtml . Create this file and add some dummy content to it: <!-- Filename: module/Blog/view/blog/list/index.phtml --> <h1>Blog\\Controller\\ListController::indexAction()</h1> Before we continue let us quickly take a look at where we placed this file. Note that view files are found within the /view subdirectory, not /src as they are not PHP class files, but template files for rendering HTML. The path, however, deserves some explanation. First we have the lowercased namespace blog , followed by the lowercased controller name list (without the suffix 'controller'), and lastly comes the name of the action that we are accessing, index (again without the suffix 'action'). As a templated string, you can think of it as: view/{namespace}/{controller}/{action}.phtml . This has become a community standard but you have the freedom to specify custom paths if desired. However creating this file alone is not enough and this brings as to the final topic of this part of the tutorial. We need to let the application know where to look for view files. We do this within our module's configuration file, module.config.php . // In module/Blog/config/module.config.php: return [ 'controllers' => [ /** Controller Configuration */ ], 'router' => [ /** Route Configuration */ ], 'view_manager' => [ 'template_path_stack' => [ __DIR__ . '/../view', ], ], ]; The above configuration tells the application that the folder module/Blog/view/ has view files in it that match the standard path format: view/{namespace}/{controller}/{action}.phtml . It is important to note that the view_manager configuration not only allows you to ship view files for your module, but also to overwrite view files from other modules. Reload your site now. Finally we are at a point where we see something different than an error being displayed! You should see the standard Laminas Skeleton Application template page with Blog\\Controller\\ListController::indexAction() as the header. Congratulations, not only have you created a simple \"Hello World\" style module, you also learned about many error messages and their causes. If we didn't exhaust you too much, continue with our tutorial, and let's create a module that actually does something.","title":"Introducing the Blog Module"},{"location":"in-depth-guide/first-module/#introducing-the-blog-module","text":"Now that we know about the basics of the laminas-mvc skeleton application, let's continue and create our very own module. We will create a module named \"Blog\". This module will display a list of database entries that represent a single blog post. Each post will have three properties: id , text , and title . We will create forms to enter new posts into our database and to edit existing posts. Furthermore we will do so by using best-practices throughout the whole tutorial.","title":"Introducing the Blog Module"},{"location":"in-depth-guide/first-module/#writing-a-new-module","text":"Let's start by creating a new folder under the /module directory called Blog , with the following stucture: module/ Blog/ config/ src/ view/ To be recognized as a module by the ModuleManager , we need to do three things: Tell Composer how to autoload classes from our new module. Create a Module class in the Blog namespace. Notify the application of the new module. Let's tell Composer about our new module. Open the composer.json file in the project root, and edit the autoload section to add a new PSR-4 entry for the Blog module; when you're done, it should read: \"autoload\": { \"psr-4\": { \"Application\\\\\": \"module/Application/src/\", \"Album\\\\\": \"module/Album/src/\", \"Blog\\\\\": \"module/Blog/src/\" } } Once you're done, tell Composer to update its autoloading definitions: $ composer dump-autoload Next, we will create a Module class under the Blog namespace. Create the file module/Blog/src/Module.php with the following contents: namespace Blog; class Module { } We now have a module that can be detected by the ModuleManager . Let's add this module to our application. Although our module doesn't do anything yet, just having the Module.php class allows it to be loaded by the ModuleManager. To do this, add an entry for Blog to the modules array inside config/modules.config.php : // In config/modules.config.php: return [ /* ... */ 'Application', 'Album', 'Blog', ]; If you refresh your application you should see no change at all (but also no errors). At this point it's worth taking a step back to discuss what modules are for. In short, a module is an encapsulated set of features for your application. A module might add features to the application that you can see, like our Blog module; or it might provide background functionality for other modules in the application to use, such as interacting with a third party API. Organizing your code into modules makes it easier for you to reuse functionality in other applications, or to use modules written by the community.","title":"Writing a new Module"},{"location":"in-depth-guide/first-module/#configuring-the-module","text":"The next thing we're going to do is add a route to our application so that our module can be accessed through the URL localhost:8080/blog . We do this by adding router configuration to our module, but first we need to let the ModuleManager know that our module has configuration that it needs to load. This is done by adding a getConfig() method to the Module class that returns the configuration. (This method is defined in the ConfigProviderInterface , although explicitly implementing this interface in the module class is optional.) This method should return either an array or a Traversable object. Continue by editing module/Blog/src/Module.php : // In /module/Blog/src/Module.php: class Module { public function getConfig() : array { return []; } } With this, our module is now able to be configured. Configuration files can become quite big, though, and keeping everything inside the getConfig() method won't be optimal. To help keep our project organized, we're going to put our array configuration in a separate file. Go ahead and create this file at module/Blog/config/module.config.php : return []; Now rewrite the getConfig() function to include this newly created file instead of directly returning the array: // In /module/Blog/src/Module.php: public function getConfig() : array { return include __DIR__ . '/../config/module.config.php'; } Reload your application and you'll see that nothing changes. Creating, registering, and adding empty configuration for a new module has no visible effect on the application. Next we add the new route to our configuration file: // In /module/Blog/config/module.config.php: namespace Blog; use Laminas\\Router\\Http\\Literal; return [ // This lines opens the configuration for the RouteManager 'router' => [ // Open configuration for all possible routes 'routes' => [ // Define a new route called \"blog\" 'blog' => [ // Define a \"literal\" route type: 'type' => Literal::class, // Configure the route itself 'options' => [ // Listen to \"/blog\" as uri: 'route' => '/blog', // Define default controller and action to be called when // this route is matched 'defaults' => [ 'controller' => Controller\\ListController::class, 'action' => 'index', ], ], ], ], ], ]; We've now created a route called blog that listens to the URL localhost:8080/blog . Whenever someone accesses this route, the indexAction() function of the class Blog\\Controller\\ListController will be executed. However, this controller does not exist yet, so if you reload the page you will see this error message: A 404 error occurred Page not found. The requested controller could not be mapped by routing. Controller: Blog\\Controller\\ListController(resolves to invalid controller class or alias: Blog\\Controller\\ListController) We now need to tell our module where to find this controller named Blog\\Controller\\ListController . To achieve this we have to add this key to the controllers configuration key inside your module/Blog/config/module.config.php . namespace Blog; use Laminas\\ServiceManager\\Factory\\InvokableFactory; return [ 'controllers' => [ 'factories' => [ Controller\\ListController::class => InvokableFactory::class, ], ], /* ... */ ]; This configuration defines a factory for the controller class Blog\\Controller\\ListController , using the laminas-servicemanager InvokableFactory (which, internally, instantiates the class with no arguments). Reloading the page should then give you: Fatal error: Class 'Blog\\Controller\\ListController' not found in {projectPath}/vendor/laminas/laminas-servicemanager/src/Factory/InvokableFactory.php on line 32 This error tells us that the application knows what class to load, but was not able to autoload it. In our case, we've already setup autoloading, but have not yet defined the controller class! Create the file module/Blog/src/Controller/ListController.php with the following contents: namespace Blog\\Controller; class ListController { } Reloading the page now will finally result into a new screen. The new error message looks like this: A 404 error occurred Page not found. The requested controller was not dispatchable. Controller: Blog\\Controller\\List(resolves to invalid controller class or alias: Blog\\Controller\\List) Additional information: Laminas\\ServiceManager\\Exception\\InvalidServiceException File: {projectPath}/vendor/laminas/laminas-mvc/src/Controller/ControllerManager.php:{lineNumber} Message: Plugin of type \"Blog\\Controller\\ListController\" is invalid; must implement Laminas\\Stdlib\\DispatchableInterface This happens because our controller must implement DispatchableInterface in order to be 'dispatched' (or run) by laminas-mvc. laminas-mvc provides a base controller implementation of it with AbstractActionController , which we are going to use. Let's modify our controller now: // In /module/Blog/src/Controller/ListController.php: namespace Blog\\Controller; use Laminas\\Mvc\\Controller\\AbstractActionController; class ListController extends AbstractActionController { } It's now time for another refresh of the site. You should now see a new error message: An error occurred An error occurred during execution; please try again later. Additional information: Laminas\\View\\Exception\\RuntimeException File: {projectPath}/vendor/laminas/laminas-view/src/Renderer/PhpRenderer.php:{lineNumber} Message: Laminas\\View\\Renderer\\PhpRenderer::render: Unable to render template \"blog/list/index\"; resolver could not resolve to a file Now the application tells you that a view template-file cannot be rendered, which is to be expected as we've not created it yet. The application is expecting it to be at module/Blog/view/blog/list/index.phtml . Create this file and add some dummy content to it: <!-- Filename: module/Blog/view/blog/list/index.phtml --> <h1>Blog\\Controller\\ListController::indexAction()</h1> Before we continue let us quickly take a look at where we placed this file. Note that view files are found within the /view subdirectory, not /src as they are not PHP class files, but template files for rendering HTML. The path, however, deserves some explanation. First we have the lowercased namespace blog , followed by the lowercased controller name list (without the suffix 'controller'), and lastly comes the name of the action that we are accessing, index (again without the suffix 'action'). As a templated string, you can think of it as: view/{namespace}/{controller}/{action}.phtml . This has become a community standard but you have the freedom to specify custom paths if desired. However creating this file alone is not enough and this brings as to the final topic of this part of the tutorial. We need to let the application know where to look for view files. We do this within our module's configuration file, module.config.php . // In module/Blog/config/module.config.php: return [ 'controllers' => [ /** Controller Configuration */ ], 'router' => [ /** Route Configuration */ ], 'view_manager' => [ 'template_path_stack' => [ __DIR__ . '/../view', ], ], ]; The above configuration tells the application that the folder module/Blog/view/ has view files in it that match the standard path format: view/{namespace}/{controller}/{action}.phtml . It is important to note that the view_manager configuration not only allows you to ship view files for your module, but also to overwrite view files from other modules. Reload your site now. Finally we are at a point where we see something different than an error being displayed! You should see the standard Laminas Skeleton Application template page with Blog\\Controller\\ListController::indexAction() as the header. Congratulations, not only have you created a simple \"Hello World\" style module, you also learned about many error messages and their causes. If we didn't exhaust you too much, continue with our tutorial, and let's create a module that actually does something.","title":"Configuring the Module"},{"location":"in-depth-guide/laminas-db-sql-laminas-hydrator/","text":"SQL Abstraction and Object Hydration In the last chapter, we introduced database abstraction and a new command interface for operations that might change what blog posts we store. We'll now start creating database-backed versions of the PostRepositoryInterface and PostCommandInterface , demonstrating usage of the various Laminas\\Db\\Sql classes. Preparing the Database This tutorial assumes you've followed the Getting Started tutorial, and that you've already populated the data/laminastutorial.db SQLite database. We will be re-using it, and adding another table to it. Create the file data/posts.schema.sql with the following contents: CREATE TABLE posts (id INTEGER PRIMARY KEY AUTOINCREMENT, title varchar(100) NOT NULL, text TEXT NOT NULL); INSERT INTO posts (title, text) VALUES ('Blog #1', 'Welcome to my first blog post'); INSERT INTO posts (title, text) VALUES ('Blog #2', 'Welcome to my second blog post'); INSERT INTO posts (title, text) VALUES ('Blog #3', 'Welcome to my third blog post'); INSERT INTO posts (title, text) VALUES ('Blog #4', 'Welcome to my fourth blog post'); INSERT INTO posts (title, text) VALUES ('Blog #5', 'Welcome to my fifth blog post'); Now we will execute this against the existing data/laminastutorial.db SQLite database using the sqlite command (or sqlite3 ; check your operating system): $ sqlite data/laminastutorial.db < data/posts.schema.sql If you don't have a sqlite command, you can populate it using PHP. Create the following script in data/load_posts.php : <?php $db = new PDO('sqlite:' . __DIR__ . '/laminastutorial.db'); $fh = fopen(__DIR__ . '/posts.schema.sql', 'r'); while ($line = fread($fh, 4096)) { $line = trim($line); $db->exec($line); } fclose($fh); and execute it using: $ php data/load_posts.php Quick Facts Laminas\\Db\\Sql To create queries against a database using Laminas\\Db\\Sql , you need to have a database adapter available. The \"Getting Started\" tutorial covered this in the database chapter , and we can re-use that adapter. With the adapter in place and the new table populated, we can run queries against the database. The construction of queries is best done through the \"QueryBuilder\" features of Laminas\\Db\\Sql which are Laminas\\Db\\Sql\\Sql for select queries, Laminas\\Db\\Sql\\Insert for insert queries, Laminas\\Db\\Sql\\Update for update queries and Laminas\\Db\\Sql\\Delete for delete queries. The basic workflow of these components is: Build a query using the relevant class: Sql , Insert , Update , or Delete . Create a SQL statement from the Sql object. Execute the query. Do something with the result. Let's start writing database-driven implementations of our interfaces now. Writing the repository implementation Create a class named LaminasDbSqlRepository in the Blog\\Model namespace that implements PostRepositoryInterface ; leave the methods empty for now: // In module/Blog/src/Model/LaminasDbSqlRepository.php: namespace Blog\\Model; use InvalidArgumentException; use RuntimeException; class LaminasDbSqlRepository implements PostRepositoryInterface { /** * {@inheritDoc} */ public function findAllPosts() { } /** * {@inheritDoc} * @throws InvalidArgumentException * @throws RuntimeException */ public function findPost($id) { } } Now recall what we have learned earlier: for Laminas\\Db\\Sql to function, we will need a working implementation of the AdapterInterface . This is a requirement , and therefore will be injected using constructor injection . Create a __construct() method that accepts an AdapterInterface as its sole parameter, and stores it as an instance property: // In module/Blog/src/Model/LaminasDbSqlRepository.php: namespace Blog\\Model; use InvalidArgumentException; use RuntimeException; use Laminas\\Db\\Adapter\\AdapterInterface; class LaminasDbSqlRepository implements PostRepositoryInterface { /** * @var AdapterInterface */ private $db; /** * @param AdapterInterface $db */ public function __construct(AdapterInterface $db) { $this->db = $db; } /** * {@inheritDoc} */ public function findAllPosts() { } /** * {@inheritDoc} * @throws InvalidArgumentException * @throws RuntimeException */ public function findPost($id) { } } Whenever we have a required parameter, we need to write a factory for the class. Go ahead and create a factory for our new repository implementation: // In module/Blog/src/Factory/LaminasDbSqlRepositoryFactory.php namespace Blog\\Factory; use Interop\\Container\\ContainerInterface; use Blog\\Model\\LaminasDbSqlRepository; use Laminas\\Db\\Adapter\\AdapterInterface; use Laminas\\ServiceManager\\Factory\\FactoryInterface; class LaminasDbSqlRepositoryFactory implements FactoryInterface { /** * @param ContainerInterface $container * @param string $requestedName * @param null|array $options * @return LaminasDbSqlRepository */ public function __invoke(ContainerInterface $container, $requestedName, array $options = null) { return new LaminasDbSqlRepository($container->get(AdapterInterface::class)); } } We're now able to register our repository implementation as a service. To do so, we'll make two changes: Register a factory entry for the new repository. Update the existing alias for PostRepositoryInterface to point to the new repository. Update module/Blog/config/module.config.php as follows: return [ 'service_manager' => [ 'aliases' => [ // Update this line: Model\\PostRepositoryInterface::class => Model\\LaminasDbSqlRepository::class, ], 'factories' => [ Model\\PostRepository::class => InvokableFactory::class, // Add this line: Model\\LaminasDbSqlRepository::class => Factory\\LaminasDbSqlRepositoryFactory::class, ], ], 'controllers' => [ /* ... */ ], 'router' => [ /* ... */ ], 'view_manager' => [ /* ... */ ], ]; With the adapter in place you're now able to refresh the blog index at localhost:8080/blog and you'll notice that the ServiceNotFoundException is gone and we get the following PHP Warning: Warning: Invalid argument supplied for foreach() in {projectPath}/module/Blog/view/blog/list/index.phtml on line {lineNumber} This is due to the fact that our mapper doesn't return anything yet. Let's modify the findAllPosts() function to return all blog posts from the database table: // In /module/Blog/src/Model/LaminasDbSqlRepository.php: namespace Blog\\Model; use InvalidArgumentException; use RuntimeException; use Laminas\\Db\\Adapter\\AdapterInterface; use Laminas\\Db\\Sql\\Sql; class LaminasDbSqlRepository implements PostRepositoryInterface { /** * @var AdapterInterface */ private $db; /** * @param AdapterInterface $db */ public function __construct(AdapterInterface $db) { $this->db = $db; } /** * {@inheritDoc} */ public function findAllPosts() { $sql = new Sql($this->db); $select = $sql->select('posts'); $stmt = $sql->prepareStatementForSqlObject($select); $result = $stmt->execute(); return $result; } /** * {@inheritDoc} * @throws InvalidArgumentException * @throw RuntimeException */ public function findPost($id) { } } Sadly, though, a refresh of the application reveals another error message: PHP Fatal error: Call to a member function getId() on array in {projectPath}/module/Blog/view/blog/list/index.phtml on line {lineNumber} Let's not return the $result variable for now and do a dump of it to see what we get here. Change the findAllPosts() method and dump the result: public function findAllPosts() { $sql = new Sql($this->db); $select = $sql->select('posts'); $stmt = $sql->prepareStatementForSqlObject($select); $result = $stmt->execute(); var_export($result); die(); return $result; } Refreshing the application you should now see output similar to the following: Laminas\\Db\\Adapter\\Driver\\Pdo\\Result::__set_state(array( 'statementMode' => 'forward', 'fetchMode' => 2, 'resource' => PDOStatement::__set_state(array( 'queryString' => 'SELECT \"posts\".* FROM \"posts\"', )), 'options' => null, 'currentComplete' => false, 'currentData' => null, 'position' => -1, 'generatedValue' => '0', 'rowCount' => Closure::__set_state(array()), )) As you can see, we do not get any data returned. Instead we are presented with a dump of some Result object that appears to have no data in it whatsoever. But this is a faulty assumption. This Result object only has information available for you when you actually try to access it. If you can determine that the query was successful, the best way to make use of the data within the Result object is to pass it to a ResultSet object. First, add two more import statements to the class file: use Laminas\\Db\\Adapter\\Driver\\ResultInterface; use Laminas\\Db\\ResultSet\\ResultSet; Now update the findAllPosts() method as follows: public function findAllPosts() { $sql = new Sql($this->db); $select = $sql->select('posts'); $stmt = $sql->prepareStatementForSqlObject($select); $result = $stmt->execute(); if ($result instanceof ResultInterface && $result->isQueryResult()) { $resultSet = new ResultSet(); $resultSet->initialize($result); var_export($resultSet); die(); } die('no data'); } Refreshing the page, you should now see the dump of a ResultSet instance: Laminas\\Db\\ResultSet\\ResultSet::__set_state(array( 'allowedReturnTypes' => array( 0 => 'arrayobject', 1 => 'array', ), 'arrayObjectPrototype' => ArrayObject::__set_state(array( )), 'returnType' => 'arrayobject', 'buffer' => null, 'count' => null, 'dataSource' => Laminas\\Db\\Adapter\\Driver\\Pdo\\Result::__set_state(array( 'statementMode' => 'forward', 'fetchMode' => 2, 'resource' => PDOStatement::__set_state(array( 'queryString' => 'SELECT \"posts\".* FROM \"posts\"', )), 'options' => null, 'currentComplete' => false, 'currentData' => null, 'position' => -1, 'generatedValue' => '0', 'rowCount' => Closure::__set_state(array( )), )), 'fieldCount' => 3, 'position' => 0, )) Of particular interest is the returnType property, which has a value of arrayobject . This tells us that all database entries will be returned as an ArrayObject instances. And this is a little problem for us, as the PostRepositoryInterface requires us to return an array of Post instances. Luckily the Laminas\\Db\\ResultSet subcomponent offers a solution for us, via the HydratingResultSet ; this result set type will populate an object of a type we specify with the data returned. Let's modify our code. First, remove the following import statement from the class file: use Laminas\\Db\\ResultSet\\ResultSet; Next, we'll add the following import statements to our class file: use Laminas\\Hydrator\\ReflectionHydrator; use Laminas\\Db\\ResultSet\\HydratingResultSet; Now, update the findAllPosts() method to read as follows: public function findAllPosts() { $sql = new Sql($this->db); $select = $sql->select('posts'); $statement = $sql->prepareStatementForSqlObject($select); $result = $statement->execute(); if (! $result instanceof ResultInterface || ! $result->isQueryResult()) { return []; } $resultSet = new HydratingResultSet( new ReflectionHydrator(), new Post('', '') ); $resultSet->initialize($result); return $resultSet; } We have changed a couple of things here. First, instead of a normal ResultSet , we are now using the HydratingResultSet . This specialized result set requires two parameters, the second one being an object to hydrate with data, and the first one being the hydrator that will be used (a hydrator is an object that will transform an array of data into an object, and vice versa). We use Laminas\\Hydrator\\Reflection here, which is capable of injecting private properties of an instance. We provide an empty Post instance, which the hydrator will clone to create new instances with data from individual rows. Instead of dumping the $result variable, we now directly return the initialized HydratingResultSet so we can access the data stored within. In case we get something else returned that is not an instance of a ResultInterface , we return an empty array. Refreshing the page you will now see all your blog posts listed on the page. Great! Refactoring hidden dependencies There's one little thing that we have done that's not a best-practice. We use both a hydrator and a Post prototype inside our LaminasDbSqlRepository . Let's inject those instead, so that we can reuse them between our repository and command implementations, or vary them based on environment. Update your LaminasDbSqlRepository as follows: // In module/Blog/src/Model/LaminasDbSqlRepository.php: namespace Blog\\Model; use InvalidArgumentException; use RuntimeException; // Replace the import of the Reflection hydrator with this: use Laminas\\Hydrator\\HydratorInterface; use Laminas\\Db\\Adapter\\AdapterInterface; use Laminas\\Db\\Adapter\\Driver\\ResultInterface; use Laminas\\Db\\ResultSet\\HydratingResultSet; use Laminas\\Db\\Sql\\Sql; class LaminasDbSqlRepository implements PostRepositoryInterface { /** * @var AdapterInterface */ private $db; /** * @var HydratorInterface */ private $hydrator; /** * @var Post */ private $postPrototype; public function __construct( AdapterInterface $db, HydratorInterface $hydrator, Post $postPrototype ) { $this->db = $db; $this->hydrator = $hydrator; $this->postPrototype = $postPrototype; } /** * Return a set of all blog posts that we can iterate over. * * Each entry should be a Post instance. * * @return Post[] */ public function findAllPosts() { $sql = new Sql($this->db); $select = $sql->select('posts'); $statement = $sql->prepareStatementForSqlObject($select); $result = $statement->execute(); if (! $result instanceof ResultInterface || ! $result->isQueryResult()) { return []; } $resultSet = new HydratingResultSet($this->hydrator, $this->postPrototype); $resultSet->initialize($result); return $resultSet; } /** * Return a single blog post. * * @param int $id Identifier of the post to return. * @return Post */ public function findPost($id) { } } Now that our repository requires more parameters, we need to update the LaminasDbSqlRepositoryFactory and inject those parameters: // In /module/Blog/src/Factory/LaminasDbSqlRepositoryFactory.php namespace Blog\\Factory; use Interop\\Container\\ContainerInterface; use Blog\\Model\\Post; use Blog\\Model\\LaminasDbSqlRepository; use Laminas\\Db\\Adapter\\AdapterInterface; use Laminas\\Hydrator\\ReflectionHydrator; use Laminas\\ServiceManager\\Factory\\FactoryInterface; class LaminasDbSqlRepositoryFactory implements FactoryInterface { public function __invoke(ContainerInterface $container, $requestedName, array $options = null) { return new LaminasDbSqlRepository( $container->get(AdapterInterface::class), new ReflectionHydrator(), new Post('', '') ); } } With this in place you can refresh the application again and you'll see your blog posts listed once again. Our repository no longer has hidden dependencies, and works with a database! Finishing the repository Before we jump into the next chapter, let's quickly finish the repository implementation by completing the findPost() method: public function findPost($id) { $sql = new Sql($this->db); $select = $sql->select('posts'); $select->where(['id = ?' => $id]); $statement = $sql->prepareStatementForSqlObject($select); $result = $statement->execute(); if (! $result instanceof ResultInterface || ! $result->isQueryResult()) { throw new RuntimeException(sprintf( 'Failed retrieving blog post with identifier \"%s\"; unknown database error.', $id )); } $resultSet = new HydratingResultSet($this->hydrator, $this->postPrototype); $resultSet->initialize($result); $post = $resultSet->current(); if (! $post) { throw new InvalidArgumentException(sprintf( 'Blog post with identifier \"%s\" not found.', $id )); } return $post; } The findPost() function looks similar to the findAllPosts() method, with several differences. We need to add a condition to the query to select only the row matching the provided identifier; this is done using the where() method of the Sql object. We check if the $result is valid, using isQueryResult() ; if not, an error occurred during the query that we report via a RuntimeException . We pull the current() item off the result set we create, and test to make sure we received something; if not, we had an invalid identifier, and raise an InvalidArgumentException . Conclusion Finishing this chapter, you now know how to query for data using the Laminas\\Db\\Sql classes. You have also learned a little about the laminas-hydrator component, and the integration laminas-db provides with it. Furthermore, we've continued demonstrating dependency injection in all aspects of our application. In the next chapter we'll take a closer look at the router so we'll be able to start displaying individual blog posts.","title":"SQL Abstraction and Object Hydration"},{"location":"in-depth-guide/laminas-db-sql-laminas-hydrator/#sql-abstraction-and-object-hydration","text":"In the last chapter, we introduced database abstraction and a new command interface for operations that might change what blog posts we store. We'll now start creating database-backed versions of the PostRepositoryInterface and PostCommandInterface , demonstrating usage of the various Laminas\\Db\\Sql classes.","title":"SQL Abstraction and Object Hydration"},{"location":"in-depth-guide/laminas-db-sql-laminas-hydrator/#preparing-the-database","text":"This tutorial assumes you've followed the Getting Started tutorial, and that you've already populated the data/laminastutorial.db SQLite database. We will be re-using it, and adding another table to it. Create the file data/posts.schema.sql with the following contents: CREATE TABLE posts (id INTEGER PRIMARY KEY AUTOINCREMENT, title varchar(100) NOT NULL, text TEXT NOT NULL); INSERT INTO posts (title, text) VALUES ('Blog #1', 'Welcome to my first blog post'); INSERT INTO posts (title, text) VALUES ('Blog #2', 'Welcome to my second blog post'); INSERT INTO posts (title, text) VALUES ('Blog #3', 'Welcome to my third blog post'); INSERT INTO posts (title, text) VALUES ('Blog #4', 'Welcome to my fourth blog post'); INSERT INTO posts (title, text) VALUES ('Blog #5', 'Welcome to my fifth blog post'); Now we will execute this against the existing data/laminastutorial.db SQLite database using the sqlite command (or sqlite3 ; check your operating system): $ sqlite data/laminastutorial.db < data/posts.schema.sql If you don't have a sqlite command, you can populate it using PHP. Create the following script in data/load_posts.php : <?php $db = new PDO('sqlite:' . __DIR__ . '/laminastutorial.db'); $fh = fopen(__DIR__ . '/posts.schema.sql', 'r'); while ($line = fread($fh, 4096)) { $line = trim($line); $db->exec($line); } fclose($fh); and execute it using: $ php data/load_posts.php","title":"Preparing the Database"},{"location":"in-depth-guide/laminas-db-sql-laminas-hydrator/#quick-facts-laminasdbsql","text":"To create queries against a database using Laminas\\Db\\Sql , you need to have a database adapter available. The \"Getting Started\" tutorial covered this in the database chapter , and we can re-use that adapter. With the adapter in place and the new table populated, we can run queries against the database. The construction of queries is best done through the \"QueryBuilder\" features of Laminas\\Db\\Sql which are Laminas\\Db\\Sql\\Sql for select queries, Laminas\\Db\\Sql\\Insert for insert queries, Laminas\\Db\\Sql\\Update for update queries and Laminas\\Db\\Sql\\Delete for delete queries. The basic workflow of these components is: Build a query using the relevant class: Sql , Insert , Update , or Delete . Create a SQL statement from the Sql object. Execute the query. Do something with the result. Let's start writing database-driven implementations of our interfaces now.","title":"Quick Facts Laminas\\Db\\Sql"},{"location":"in-depth-guide/laminas-db-sql-laminas-hydrator/#writing-the-repository-implementation","text":"Create a class named LaminasDbSqlRepository in the Blog\\Model namespace that implements PostRepositoryInterface ; leave the methods empty for now: // In module/Blog/src/Model/LaminasDbSqlRepository.php: namespace Blog\\Model; use InvalidArgumentException; use RuntimeException; class LaminasDbSqlRepository implements PostRepositoryInterface { /** * {@inheritDoc} */ public function findAllPosts() { } /** * {@inheritDoc} * @throws InvalidArgumentException * @throws RuntimeException */ public function findPost($id) { } } Now recall what we have learned earlier: for Laminas\\Db\\Sql to function, we will need a working implementation of the AdapterInterface . This is a requirement , and therefore will be injected using constructor injection . Create a __construct() method that accepts an AdapterInterface as its sole parameter, and stores it as an instance property: // In module/Blog/src/Model/LaminasDbSqlRepository.php: namespace Blog\\Model; use InvalidArgumentException; use RuntimeException; use Laminas\\Db\\Adapter\\AdapterInterface; class LaminasDbSqlRepository implements PostRepositoryInterface { /** * @var AdapterInterface */ private $db; /** * @param AdapterInterface $db */ public function __construct(AdapterInterface $db) { $this->db = $db; } /** * {@inheritDoc} */ public function findAllPosts() { } /** * {@inheritDoc} * @throws InvalidArgumentException * @throws RuntimeException */ public function findPost($id) { } } Whenever we have a required parameter, we need to write a factory for the class. Go ahead and create a factory for our new repository implementation: // In module/Blog/src/Factory/LaminasDbSqlRepositoryFactory.php namespace Blog\\Factory; use Interop\\Container\\ContainerInterface; use Blog\\Model\\LaminasDbSqlRepository; use Laminas\\Db\\Adapter\\AdapterInterface; use Laminas\\ServiceManager\\Factory\\FactoryInterface; class LaminasDbSqlRepositoryFactory implements FactoryInterface { /** * @param ContainerInterface $container * @param string $requestedName * @param null|array $options * @return LaminasDbSqlRepository */ public function __invoke(ContainerInterface $container, $requestedName, array $options = null) { return new LaminasDbSqlRepository($container->get(AdapterInterface::class)); } } We're now able to register our repository implementation as a service. To do so, we'll make two changes: Register a factory entry for the new repository. Update the existing alias for PostRepositoryInterface to point to the new repository. Update module/Blog/config/module.config.php as follows: return [ 'service_manager' => [ 'aliases' => [ // Update this line: Model\\PostRepositoryInterface::class => Model\\LaminasDbSqlRepository::class, ], 'factories' => [ Model\\PostRepository::class => InvokableFactory::class, // Add this line: Model\\LaminasDbSqlRepository::class => Factory\\LaminasDbSqlRepositoryFactory::class, ], ], 'controllers' => [ /* ... */ ], 'router' => [ /* ... */ ], 'view_manager' => [ /* ... */ ], ]; With the adapter in place you're now able to refresh the blog index at localhost:8080/blog and you'll notice that the ServiceNotFoundException is gone and we get the following PHP Warning: Warning: Invalid argument supplied for foreach() in {projectPath}/module/Blog/view/blog/list/index.phtml on line {lineNumber} This is due to the fact that our mapper doesn't return anything yet. Let's modify the findAllPosts() function to return all blog posts from the database table: // In /module/Blog/src/Model/LaminasDbSqlRepository.php: namespace Blog\\Model; use InvalidArgumentException; use RuntimeException; use Laminas\\Db\\Adapter\\AdapterInterface; use Laminas\\Db\\Sql\\Sql; class LaminasDbSqlRepository implements PostRepositoryInterface { /** * @var AdapterInterface */ private $db; /** * @param AdapterInterface $db */ public function __construct(AdapterInterface $db) { $this->db = $db; } /** * {@inheritDoc} */ public function findAllPosts() { $sql = new Sql($this->db); $select = $sql->select('posts'); $stmt = $sql->prepareStatementForSqlObject($select); $result = $stmt->execute(); return $result; } /** * {@inheritDoc} * @throws InvalidArgumentException * @throw RuntimeException */ public function findPost($id) { } } Sadly, though, a refresh of the application reveals another error message: PHP Fatal error: Call to a member function getId() on array in {projectPath}/module/Blog/view/blog/list/index.phtml on line {lineNumber} Let's not return the $result variable for now and do a dump of it to see what we get here. Change the findAllPosts() method and dump the result: public function findAllPosts() { $sql = new Sql($this->db); $select = $sql->select('posts'); $stmt = $sql->prepareStatementForSqlObject($select); $result = $stmt->execute(); var_export($result); die(); return $result; } Refreshing the application you should now see output similar to the following: Laminas\\Db\\Adapter\\Driver\\Pdo\\Result::__set_state(array( 'statementMode' => 'forward', 'fetchMode' => 2, 'resource' => PDOStatement::__set_state(array( 'queryString' => 'SELECT \"posts\".* FROM \"posts\"', )), 'options' => null, 'currentComplete' => false, 'currentData' => null, 'position' => -1, 'generatedValue' => '0', 'rowCount' => Closure::__set_state(array()), )) As you can see, we do not get any data returned. Instead we are presented with a dump of some Result object that appears to have no data in it whatsoever. But this is a faulty assumption. This Result object only has information available for you when you actually try to access it. If you can determine that the query was successful, the best way to make use of the data within the Result object is to pass it to a ResultSet object. First, add two more import statements to the class file: use Laminas\\Db\\Adapter\\Driver\\ResultInterface; use Laminas\\Db\\ResultSet\\ResultSet; Now update the findAllPosts() method as follows: public function findAllPosts() { $sql = new Sql($this->db); $select = $sql->select('posts'); $stmt = $sql->prepareStatementForSqlObject($select); $result = $stmt->execute(); if ($result instanceof ResultInterface && $result->isQueryResult()) { $resultSet = new ResultSet(); $resultSet->initialize($result); var_export($resultSet); die(); } die('no data'); } Refreshing the page, you should now see the dump of a ResultSet instance: Laminas\\Db\\ResultSet\\ResultSet::__set_state(array( 'allowedReturnTypes' => array( 0 => 'arrayobject', 1 => 'array', ), 'arrayObjectPrototype' => ArrayObject::__set_state(array( )), 'returnType' => 'arrayobject', 'buffer' => null, 'count' => null, 'dataSource' => Laminas\\Db\\Adapter\\Driver\\Pdo\\Result::__set_state(array( 'statementMode' => 'forward', 'fetchMode' => 2, 'resource' => PDOStatement::__set_state(array( 'queryString' => 'SELECT \"posts\".* FROM \"posts\"', )), 'options' => null, 'currentComplete' => false, 'currentData' => null, 'position' => -1, 'generatedValue' => '0', 'rowCount' => Closure::__set_state(array( )), )), 'fieldCount' => 3, 'position' => 0, )) Of particular interest is the returnType property, which has a value of arrayobject . This tells us that all database entries will be returned as an ArrayObject instances. And this is a little problem for us, as the PostRepositoryInterface requires us to return an array of Post instances. Luckily the Laminas\\Db\\ResultSet subcomponent offers a solution for us, via the HydratingResultSet ; this result set type will populate an object of a type we specify with the data returned. Let's modify our code. First, remove the following import statement from the class file: use Laminas\\Db\\ResultSet\\ResultSet; Next, we'll add the following import statements to our class file: use Laminas\\Hydrator\\ReflectionHydrator; use Laminas\\Db\\ResultSet\\HydratingResultSet; Now, update the findAllPosts() method to read as follows: public function findAllPosts() { $sql = new Sql($this->db); $select = $sql->select('posts'); $statement = $sql->prepareStatementForSqlObject($select); $result = $statement->execute(); if (! $result instanceof ResultInterface || ! $result->isQueryResult()) { return []; } $resultSet = new HydratingResultSet( new ReflectionHydrator(), new Post('', '') ); $resultSet->initialize($result); return $resultSet; } We have changed a couple of things here. First, instead of a normal ResultSet , we are now using the HydratingResultSet . This specialized result set requires two parameters, the second one being an object to hydrate with data, and the first one being the hydrator that will be used (a hydrator is an object that will transform an array of data into an object, and vice versa). We use Laminas\\Hydrator\\Reflection here, which is capable of injecting private properties of an instance. We provide an empty Post instance, which the hydrator will clone to create new instances with data from individual rows. Instead of dumping the $result variable, we now directly return the initialized HydratingResultSet so we can access the data stored within. In case we get something else returned that is not an instance of a ResultInterface , we return an empty array. Refreshing the page you will now see all your blog posts listed on the page. Great!","title":"Writing the repository implementation"},{"location":"in-depth-guide/laminas-db-sql-laminas-hydrator/#refactoring-hidden-dependencies","text":"There's one little thing that we have done that's not a best-practice. We use both a hydrator and a Post prototype inside our LaminasDbSqlRepository . Let's inject those instead, so that we can reuse them between our repository and command implementations, or vary them based on environment. Update your LaminasDbSqlRepository as follows: // In module/Blog/src/Model/LaminasDbSqlRepository.php: namespace Blog\\Model; use InvalidArgumentException; use RuntimeException; // Replace the import of the Reflection hydrator with this: use Laminas\\Hydrator\\HydratorInterface; use Laminas\\Db\\Adapter\\AdapterInterface; use Laminas\\Db\\Adapter\\Driver\\ResultInterface; use Laminas\\Db\\ResultSet\\HydratingResultSet; use Laminas\\Db\\Sql\\Sql; class LaminasDbSqlRepository implements PostRepositoryInterface { /** * @var AdapterInterface */ private $db; /** * @var HydratorInterface */ private $hydrator; /** * @var Post */ private $postPrototype; public function __construct( AdapterInterface $db, HydratorInterface $hydrator, Post $postPrototype ) { $this->db = $db; $this->hydrator = $hydrator; $this->postPrototype = $postPrototype; } /** * Return a set of all blog posts that we can iterate over. * * Each entry should be a Post instance. * * @return Post[] */ public function findAllPosts() { $sql = new Sql($this->db); $select = $sql->select('posts'); $statement = $sql->prepareStatementForSqlObject($select); $result = $statement->execute(); if (! $result instanceof ResultInterface || ! $result->isQueryResult()) { return []; } $resultSet = new HydratingResultSet($this->hydrator, $this->postPrototype); $resultSet->initialize($result); return $resultSet; } /** * Return a single blog post. * * @param int $id Identifier of the post to return. * @return Post */ public function findPost($id) { } } Now that our repository requires more parameters, we need to update the LaminasDbSqlRepositoryFactory and inject those parameters: // In /module/Blog/src/Factory/LaminasDbSqlRepositoryFactory.php namespace Blog\\Factory; use Interop\\Container\\ContainerInterface; use Blog\\Model\\Post; use Blog\\Model\\LaminasDbSqlRepository; use Laminas\\Db\\Adapter\\AdapterInterface; use Laminas\\Hydrator\\ReflectionHydrator; use Laminas\\ServiceManager\\Factory\\FactoryInterface; class LaminasDbSqlRepositoryFactory implements FactoryInterface { public function __invoke(ContainerInterface $container, $requestedName, array $options = null) { return new LaminasDbSqlRepository( $container->get(AdapterInterface::class), new ReflectionHydrator(), new Post('', '') ); } } With this in place you can refresh the application again and you'll see your blog posts listed once again. Our repository no longer has hidden dependencies, and works with a database!","title":"Refactoring hidden dependencies"},{"location":"in-depth-guide/laminas-db-sql-laminas-hydrator/#finishing-the-repository","text":"Before we jump into the next chapter, let's quickly finish the repository implementation by completing the findPost() method: public function findPost($id) { $sql = new Sql($this->db); $select = $sql->select('posts'); $select->where(['id = ?' => $id]); $statement = $sql->prepareStatementForSqlObject($select); $result = $statement->execute(); if (! $result instanceof ResultInterface || ! $result->isQueryResult()) { throw new RuntimeException(sprintf( 'Failed retrieving blog post with identifier \"%s\"; unknown database error.', $id )); } $resultSet = new HydratingResultSet($this->hydrator, $this->postPrototype); $resultSet->initialize($result); $post = $resultSet->current(); if (! $post) { throw new InvalidArgumentException(sprintf( 'Blog post with identifier \"%s\" not found.', $id )); } return $post; } The findPost() function looks similar to the findAllPosts() method, with several differences. We need to add a condition to the query to select only the row matching the provided identifier; this is done using the where() method of the Sql object. We check if the $result is valid, using isQueryResult() ; if not, an error occurred during the query that we report via a RuntimeException . We pull the current() item off the result set we create, and test to make sure we received something; if not, we had an invalid identifier, and raise an InvalidArgumentException .","title":"Finishing the repository"},{"location":"in-depth-guide/laminas-db-sql-laminas-hydrator/#conclusion","text":"Finishing this chapter, you now know how to query for data using the Laminas\\Db\\Sql classes. You have also learned a little about the laminas-hydrator component, and the integration laminas-db provides with it. Furthermore, we've continued demonstrating dependency injection in all aspects of our application. In the next chapter we'll take a closer look at the router so we'll be able to start displaying individual blog posts.","title":"Conclusion"},{"location":"in-depth-guide/laminas-form-laminas-form-fieldset/","text":"Making Use of Forms and Fieldsets So far all we have done is read data from the database. In a real-life application, this won't get us very far, as we'll often need to support the full range of full Create , Read , Update and Delete operations (CRUD). Typically, new data will arrive via web form submissions. Form components The laminas-form and laminas-inputfilter components provide us with the ability to create fully-featured forms and their validation rules. laminas-form consumes laminas-inputfilter internally, so let's take a look at the elements of laminas-form that we will use for our application. Fieldsets Laminas\\Form\\Fieldset models a reusable set of elements. You will use a Fieldset to create the various HTML inputs needed to map to your server-side entities. It is considered good practice to have one Fieldset for every entity in your application. The Fieldset component, however, is not a form, meaning you will not be able to use a Fieldset without attaching it to the Laminas\\Form\\Form instance. The advantage here is that you have one set of elements that you can re-use for as many forms as you like. Forms Laminas\\Form\\Form is a container for all elements of your HTML <form> . You are able to add both single elements or fieldsets (modeled as Laminas\\Form\\Fieldset instances). Creating your first Fieldset Explaining how laminas-form works is best done by giving you real code to work with. So let's jump right into it and create all the forms we need to finish our Blog module. We start by creating a Fieldset that contains all the input elements that we need to work with our blog data: You will need one hidden input for the id property, which is only needed for editting and deleting data. You will need one text input for the title property. You will need one textarea for the text property. Create the file module/Blog/src/Form/PostFieldset.php with the following contents: <?php namespace Blog\\Form; use Laminas\\Form\\Fieldset; class PostFieldset extends Fieldset { public function init() { $this->add([ 'type' => 'hidden', 'name' => 'id', ]); $this->add([ 'type' => 'text', 'name' => 'title', 'options' => [ 'label' => 'Post Title', ], ]); $this->add([ 'type' => 'textarea', 'name' => 'text', 'options' => [ 'label' => 'Post Text', ], ]); } } This new class creates an extension of Laminas\\Form\\Fieldset that, in an init() method (more on this later), adds elements for each aspect of our blog post. We can now re-use this fieldset in as many forms as we want. Let's create our first form. Creating the PostForm Now that we have our PostFieldset in place, we can use it inside a Form . The form will use the PostFieldset , and also include a submit button so that the user can submit the data. Create the file module/Blog/src/Form/PostForm.php with the following contents: <?php namespace Blog\\Form; use Laminas\\Form\\Form; class PostForm extends Form { public function init() { $this->add([ 'name' => 'post', 'type' => PostFieldset::class, ]); $this->add([ 'type' => 'submit', 'name' => 'submit', 'attributes' => [ 'value' => 'Insert new Post', ], ]); } } And that's our form. Nothing special here, we add our PostFieldset to the form, we add a submit button to the form, and nothing more. Adding a new Post Now that we have the PostForm written, it's time to use it. But there are a few more tasks left: We need to create a new controller WriteController which accepts the following instances via its constructor: a PostCommandInterface instance a PostForm instance We need to create an addAction() method in the new WriteController to handle displaying the form and processing it. We need to create a new route, blog/add , that routes to the WriteController and its addAction() method. We need to create a new view script to display the form. Creating the WriteController While we could re-use our existing controller, it has a different responsibility: it will be writing new blog posts. As such, it will need to emit commands , and thus use the PostCommandInterface that we have defined previously. To do that, it needs to accept and process user input, which we have modeled in our PostForm in a previous section of this chapter. Let's create this new class now. Open a new file, module/Blog/src/Controller/WriteController.php , and add the following contents: <?php namespace Blog\\Controller; use Blog\\Form\\PostForm; use Blog\\Model\\Post; use Blog\\Model\\PostCommandInterface; use Laminas\\Mvc\\Controller\\AbstractActionController; use Laminas\\View\\Model\\ViewModel; class WriteController extends AbstractActionController { /** * @var PostCommandInterface */ private $command; /** * @var PostForm */ private $form; /** * @param PostCommandInterface $command * @param PostForm $form */ public function __construct(PostCommandInterface $command, PostForm $form) { $this->command = $command; $this->form = $form; } public function addAction() { } } We'll now create a factory for this new controller; create a new file, module/Blog/src/Factory/WriteControllerFactory.php , with the following contents: <?php namespace Blog\\Factory; use Blog\\Controller\\WriteController; use Blog\\Form\\PostForm; use Blog\\Model\\PostCommandInterface; use Interop\\Container\\ContainerInterface; use Laminas\\ServiceManager\\Factory\\FactoryInterface; class WriteControllerFactory implements FactoryInterface { /** * @param ContainerInterface $container * @param string $requestedName * @param null|array $options * @return WriteController */ public function __invoke(ContainerInterface $container, $requestedName, array $options = null) { $formManager = $container->get('FormElementManager'); return new WriteController( $container->get(PostCommandInterface::class), $formManager->get(PostForm::class) ); } } The above factory introduces something new: the FormElementManager . This is a plugin manager implementation that is specifically for forms. We don't necessarily need to register our forms with it, as it will check to see if a requested instance is a form when attempting to pull one from it. However, it does provide a couple nice features: If the form or fieldset or element retrieved implements an init() method, it invokes that method after instantiation. This is useful, as that way we're initializing after we have all our dependencies injected, such as input filters. Our form and fieldset define this method! It ensures that the various plugin managers related to input validation are shared with the instance, a feature we'll be using later. Finally, we need to configure the new factory; in module/Blog/config/module.config.php , add an entry in the controllers configuration section: 'controllers' => [ 'factories' => [ Controller\\ListController::class => Factory\\ListControllerFactory::class, // Add the following line: Controller\\WriteController::class => Factory\\WriteControllerFactory::class, ], ], Now that we have the basics for our controller in place, we can create a route to it: <?php // In module/Blog/config/module.config.php: namespace Blog; use Laminas\\Router\\Http\\Literal; use Laminas\\Router\\Http\\Segment; use Laminas\\ServiceManager\\Factory\\InvokableFactory; return [ 'service_manager' => [ /* ... */ ], 'controllers' => [ /* ... */ ], 'router' => [ 'routes' => [ 'blog' => [ 'type' => Literal::class, 'options' => [ 'route' => '/blog', 'defaults' => [ 'controller' => Controller\\ListController::class, 'action' => 'index', ], ], 'may_terminate' => true, 'child_routes' => [ 'detail' => [ 'type' => Segment::class, 'options' => [ 'route' => '/:id', 'defaults' => [ 'action' => 'detail', ], 'constraints' => [ 'id' => '\\d+', ], ], ], // Add the following route: 'add' => [ 'type' => Literal::class, 'options' => [ 'route' => '/add', 'defaults' => [ 'controller' => Controller\\WriteController::class, 'action' => 'add', ], ], ], ], ], ], ], 'view_manager' => [ /* ... */ ], ]; Finally, we'll create a dummy template: <!-- Filename: module/Blog/view/blog/write/add.phtml --> <h1>WriteController::addAction()</h1> Check-in If you try to access the new route localhost:8080/blog/add you're supposed to see the following error message: An error occurred An error occurred during execution; please try again later. Additional information: Laminas\\ServiceManager\\Exception\\ServiceNotFoundException File: {projectPath}/vendor/laminas/laminas-servicemanager/src/ServiceManager.php:{lineNumber} Message: Unable to resolve service \"Blog\\Model\\PostCommandInterface\" to a factory; are you certain you provided it during configuration? If this is not the case, be sure to follow the tutorial correctly and carefully check all your files. The error is due to the fact that we have not yet defined an implementation of our PostCommandInterface , much less wired the implementation into our application! Let's create a dummy implementation, as we did when we first started working with repositories. Create the file module/Blog/src/Model/PostCommand.php with the following contents: <?php namespace Blog\\Model; class PostCommand implements PostCommandInterface { /** * {@inheritDoc} */ public function insertPost(Post $post) { } /** * {@inheritDoc} */ public function updatePost(Post $post) { } /** * {@inheritDoc} */ public function deletePost(Post $post) { } } Now add service configuration in module/Blog/config/module.config.php : 'service_manager' => [ 'aliases' => [ /* ... */ // Add the following line: Model\\PostCommandInterface::class => Model\\PostCommand::class, ], 'factories' => [ /* ... */ // Add the following line: Model\\PostCommand::class => InvokableFactory::class, ], ], Reloading your application now will yield you the desired result. Displaying the form Now that we have new controller working, it's time to pass this form to the view and render it. Change your controller so that the form is passed to the view: // In /module/Blog/src/Controller/WriteController.php: public function addAction() { return new ViewModel([ 'form' => $this->form, ]); } And then we need to modify our view to render the form: <!-- Filename: module/Blog/view/blog/write/add.phtml --> <h1>Add a blog post</h1> <?php $form = $this->form; $form->setAttribute('action', $this->url()); $form->prepare(); echo $this->form()->openTag($form); echo $this->formCollection($form); echo $this->form()->closeTag(); The above does the following: We set the action attribute of the form to the current URL. We \"prepare\" the form; this ensures any data or error messages bound to the form or its various elements are injected and ready to use for display purposes. We render an opening tag for the form we are using. We render the contents of the form, using the formCollection() view helper; this is a convenience method with some typically sane default markup. We'll be changing it momentarily. We render a closing tag for the form. Form method HTML forms can be sent using POST and GET . laminas-form defaults to POST . If you want to switch to GET : $form->setAttribute('method', 'GET'); Refreshing the browser you will now see your form properly displayed. It's not pretty, though, as the default markup does not follow semantics for Bootstrap (which is used in the skeleton application by default). Let's update it a bit to make it look better; we'll do that in the view script itself, as markup-related concerns belong in the view layer: <!-- Filename: module/Blog/view/blog/write/add.phtml --> <h1>Add a blog post</h1> <?php $form = $this->form; $form->setAttribute('action', $this->url()); $fieldset = $form->get('post'); $title = $fieldset->get('title'); $title->setAttribute('class', 'form-control'); $title->setAttribute('placeholder', 'Post title'); $text = $fieldset->get('text'); $text->setAttribute('class', 'form-control'); $text->setAttribute('placeholder', 'Post content'); $submit = $form->get('submit'); $submit->setAttribute('class', 'btn btn-primary'); $form->prepare(); echo $this->form()->openTag($form); ?> <fieldset> <div class=\"form-group\"> <?= $this->formLabel($title) ?> <?= $this->formElement($title) ?> <?= $this->formElementErrors()->render($title, ['class' => 'help-block']) ?> </div> <div class=\"form-group\"> <?= $this->formLabel($text) ?> <?= $this->formElement($text) ?> <?= $this->formElementErrors()->render($text, ['class' => 'help-block']) ?> </div> </fieldset> <?php echo $this->formSubmit($submit); echo $this->formHidden($fieldset->get('id')); echo $this->form()->closeTag(); The above adds HTML attributes to a number of the elements we've defined, and uses more specific view helpers to allow us to render the exact markup we want for our form. However, if we're submitting the form all we see is our form being displayed again. And this is due to the simple fact that we didn't add any logic to the controller yet. General form-handling logic for controllers Writing a controller that handles a form workflow follows the same basic pattern regardless of form and entities: You need to check if the HTTP request method is via POST , meaning if the form has been sent. If the form has been sent, you need to: pass the submitted data to your Form instance validate the Form instance If the form passes validation, you will: persist the form data redirect the user to either the detail page of the entered data, or to an overview page In all other cases, you need to display the form, potentially with error messages. Modify your WriteController:addAction() to read as follows: public function addAction() { $request = $this->getRequest(); $viewModel = new ViewModel(['form' => $this->form]); if (! $request->isPost()) { return $viewModel; } $this->form->setData($request->getPost()); if (! $this->form->isValid()) { return $viewModel; } $data = $this->form->getData()['post']; $post = new Post($data['title'], $data['text']); try { $post = $this->command->insertPost($post); } catch (\\Exception $ex) { // An exception occurred; we may want to log this later and/or // report it to the user. For now, we'll just re-throw. throw $ex; } return $this->redirect()->toRoute( 'blog/detail', ['id' => $post->getId()] ); } Stepping through the code: We retrieve the current request. We create a default view model containing the form. If we do not have a POST request, we return the default view model. We populate the form with data from the request. If the form is not valid, we return the default view model; at this point, the form will also contain error messages. We create a Post instance from the validated data. We attempt to insert the post. On success, we redirect to the post's detail page. Child route names When using the various url() helpers provided in laminas-mvc and laminas-view, you need to provide the name of a route. When using child routes, the route name is of the form <parent>/<child> — i.e., the parent name and child name are separated with a slash. Submitting the form right now will return into the following error Fatal error: Call to a member function getId() on null in {projectPath}/module/Blog/src/Controller/WriteController.php on line {lineNumber} This is because our stub PostCommand class does not return a new Post instance, violating the contract! Let's create a new implementation to work against laminas-db. Create the file module/Blog/src/Model/LaminasDbSqlCommand.php with the following contents: <?php namespace Blog\\Model; use RuntimeException; use Laminas\\Db\\Adapter\\AdapterInterface; use Laminas\\Db\\Adapter\\Driver\\ResultInterface; use Laminas\\Db\\Sql\\Delete; use Laminas\\Db\\Sql\\Insert; use Laminas\\Db\\Sql\\Sql; use Laminas\\Db\\Sql\\Update; class LaminasDbSqlCommand implements PostCommandInterface { /** * @var AdapterInterface */ private $db; /** * @param AdapterInterface $db */ public function __construct(AdapterInterface $db) { $this->db = $db; } /** * {@inheritDoc} */ public function insertPost(Post $post) { $insert = new Insert('posts'); $insert->values([ 'title' => $post->getTitle(), 'text' => $post->getText(), ]); $sql = new Sql($this->db); $statement = $sql->prepareStatementForSqlObject($insert); $result = $statement->execute(); if (! $result instanceof ResultInterface) { throw new RuntimeException( 'Database error occurred during blog post insert operation' ); } $id = $result->getGeneratedValue(); return new Post( $post->getTitle(), $post->getText(), $id ); } /** * {@inheritDoc} */ public function updatePost(Post $post) { } /** * {@inheritDoc} */ public function deletePost(Post $post) { } } In the insertPost() method, we do the following: We create a Laminas\\Db\\Sql\\Insert instance, providing it the table name. We add values to the Insert instance. We create a Laminas\\Db\\Sql\\Sql instance with the database adapter, and prepare a statement from our Insert instance. We execute the statement and check for a valid result. We marshal a return value. Now that we have this in place, we'll create a factory for it; create the file module/Blog/src/Factory/LaminasDbSqlCommandFactory.php with the following contents: <?php namespace Blog\\Factory; use Interop\\Container\\ContainerInterface; use Blog\\Model\\LaminasDbSqlCommand; use Laminas\\Db\\Adapter\\AdapterInterface; use Laminas\\ServiceManager\\Factory\\FactoryInterface; class LaminasDbSqlCommandFactory implements FactoryInterface { public function __invoke(ContainerInterface $container, $requestedName, array $options = null) { return new LaminasDbSqlCommand($container->get(AdapterInterface::class)); } } And finally, we'll wire it up in the configuration; update the service_manager section of module/Blog/config/module.config.php to read as follows: 'service_manager' => [ 'aliases' => [ Model\\PostRepositoryInterface::class => Model\\LaminasDbSqlRepository::class, // Update the following alias: Model\\PostCommandInterface::class => Model\\LaminasDbSqlCommand::class, ], 'factories' => [ Model\\PostRepository::class => InvokableFactory::class, Model\\LaminasDbSqlRepository::class => Factory\\LaminasDbSqlRepositoryFactory::class, Model\\PostCommand::class => InvokableFactory::class, // Add the following line: Model\\LaminasDbSqlCommand::class => Factory\\LaminasDbSqlCommandFactory::class, ], ], Submitting your form again, it should process the form and redirect you to the detail page for the new entry! Let's see if we can improve this a bit. Using laminas-hydrator with laminas-form In our controller currently, we have the following: $data = $this->form->getData()['post']; $post = new Post($data['title'], $data['text']); What if we could automate that, so we didn't need to worry about: Whether or not we're using a fieldset What the form fields are named Fortunately, laminas-form features integration with laminas-hydrator. This will allow us to return a Post instance when we retrieve the validated values! Let's udpate our fieldset to provide a hydrator and a prototype object. First, add two import statements to the top of the class file: // In module/Blog/src/Form/PostFieldset.php: use Blog\\Model\\Post; use Laminas\\Hydrator\\ReflectionHydrator; Next, update the init() method to add the following two lines: // In /module/Blog/src/Form/PostFieldset.php: public function init() { $this->setHydrator(new ReflectionHydrator()); $this->setObject(new Post('', '')); /* ... */ } When you grab the data from this fieldset, it will be returned as a Post instance. However, we grab data from the form ; how can we simplify that interaction? Since we only have the one fieldset, we'll set it as the form's base fieldset . This hints to the form that when we retrieve data from it, it should return the values from the specified fieldset instead; since our fieldset returns the Post instance, we'll have exactly what we need. Modify your PostForm class as follows: // In /module/Blog/src/Form/PostForm.php: public function init() { $this->add([ 'name' => 'post', 'type' => PostFieldset::class, 'options' => [ 'use_as_base_fieldset' => true, ], ]); /* ... */ Let's update our WriteController ; modify the addAction() method to replace the following two lines: $data = $this->form->getData()['post']; $post = new Post($data['title'], $data['text']); to: $post = $this->form->getData(); Everything should continue to work. The changes done serve the purpose of de-coupling the details of how the form is structured from the controller, allowing us to work directly with our entities at all times! Conclusion In this chapter, we've learned the fundamentals of using laminas-form, including adding fieldsets and elements, rendering the form, validating input, and wiring forms and fieldsets to use entities. In the next chapter we will finalize the CRUD functionality by creating the update and delete routines for the blog module.","title":"Making Use of Forms and Fieldsets"},{"location":"in-depth-guide/laminas-form-laminas-form-fieldset/#making-use-of-forms-and-fieldsets","text":"So far all we have done is read data from the database. In a real-life application, this won't get us very far, as we'll often need to support the full range of full Create , Read , Update and Delete operations (CRUD). Typically, new data will arrive via web form submissions.","title":"Making Use of Forms and Fieldsets"},{"location":"in-depth-guide/laminas-form-laminas-form-fieldset/#form-components","text":"The laminas-form and laminas-inputfilter components provide us with the ability to create fully-featured forms and their validation rules. laminas-form consumes laminas-inputfilter internally, so let's take a look at the elements of laminas-form that we will use for our application.","title":"Form components"},{"location":"in-depth-guide/laminas-form-laminas-form-fieldset/#creating-your-first-fieldset","text":"Explaining how laminas-form works is best done by giving you real code to work with. So let's jump right into it and create all the forms we need to finish our Blog module. We start by creating a Fieldset that contains all the input elements that we need to work with our blog data: You will need one hidden input for the id property, which is only needed for editting and deleting data. You will need one text input for the title property. You will need one textarea for the text property. Create the file module/Blog/src/Form/PostFieldset.php with the following contents: <?php namespace Blog\\Form; use Laminas\\Form\\Fieldset; class PostFieldset extends Fieldset { public function init() { $this->add([ 'type' => 'hidden', 'name' => 'id', ]); $this->add([ 'type' => 'text', 'name' => 'title', 'options' => [ 'label' => 'Post Title', ], ]); $this->add([ 'type' => 'textarea', 'name' => 'text', 'options' => [ 'label' => 'Post Text', ], ]); } } This new class creates an extension of Laminas\\Form\\Fieldset that, in an init() method (more on this later), adds elements for each aspect of our blog post. We can now re-use this fieldset in as many forms as we want. Let's create our first form.","title":"Creating your first Fieldset"},{"location":"in-depth-guide/laminas-form-laminas-form-fieldset/#creating-the-postform","text":"Now that we have our PostFieldset in place, we can use it inside a Form . The form will use the PostFieldset , and also include a submit button so that the user can submit the data. Create the file module/Blog/src/Form/PostForm.php with the following contents: <?php namespace Blog\\Form; use Laminas\\Form\\Form; class PostForm extends Form { public function init() { $this->add([ 'name' => 'post', 'type' => PostFieldset::class, ]); $this->add([ 'type' => 'submit', 'name' => 'submit', 'attributes' => [ 'value' => 'Insert new Post', ], ]); } } And that's our form. Nothing special here, we add our PostFieldset to the form, we add a submit button to the form, and nothing more.","title":"Creating the PostForm"},{"location":"in-depth-guide/laminas-form-laminas-form-fieldset/#adding-a-new-post","text":"Now that we have the PostForm written, it's time to use it. But there are a few more tasks left: We need to create a new controller WriteController which accepts the following instances via its constructor: a PostCommandInterface instance a PostForm instance We need to create an addAction() method in the new WriteController to handle displaying the form and processing it. We need to create a new route, blog/add , that routes to the WriteController and its addAction() method. We need to create a new view script to display the form.","title":"Adding a new Post"},{"location":"in-depth-guide/laminas-form-laminas-form-fieldset/#displaying-the-form","text":"Now that we have new controller working, it's time to pass this form to the view and render it. Change your controller so that the form is passed to the view: // In /module/Blog/src/Controller/WriteController.php: public function addAction() { return new ViewModel([ 'form' => $this->form, ]); } And then we need to modify our view to render the form: <!-- Filename: module/Blog/view/blog/write/add.phtml --> <h1>Add a blog post</h1> <?php $form = $this->form; $form->setAttribute('action', $this->url()); $form->prepare(); echo $this->form()->openTag($form); echo $this->formCollection($form); echo $this->form()->closeTag(); The above does the following: We set the action attribute of the form to the current URL. We \"prepare\" the form; this ensures any data or error messages bound to the form or its various elements are injected and ready to use for display purposes. We render an opening tag for the form we are using. We render the contents of the form, using the formCollection() view helper; this is a convenience method with some typically sane default markup. We'll be changing it momentarily. We render a closing tag for the form.","title":"Displaying the form"},{"location":"in-depth-guide/laminas-form-laminas-form-fieldset/#general-form-handling-logic-for-controllers","text":"Writing a controller that handles a form workflow follows the same basic pattern regardless of form and entities: You need to check if the HTTP request method is via POST , meaning if the form has been sent. If the form has been sent, you need to: pass the submitted data to your Form instance validate the Form instance If the form passes validation, you will: persist the form data redirect the user to either the detail page of the entered data, or to an overview page In all other cases, you need to display the form, potentially with error messages. Modify your WriteController:addAction() to read as follows: public function addAction() { $request = $this->getRequest(); $viewModel = new ViewModel(['form' => $this->form]); if (! $request->isPost()) { return $viewModel; } $this->form->setData($request->getPost()); if (! $this->form->isValid()) { return $viewModel; } $data = $this->form->getData()['post']; $post = new Post($data['title'], $data['text']); try { $post = $this->command->insertPost($post); } catch (\\Exception $ex) { // An exception occurred; we may want to log this later and/or // report it to the user. For now, we'll just re-throw. throw $ex; } return $this->redirect()->toRoute( 'blog/detail', ['id' => $post->getId()] ); } Stepping through the code: We retrieve the current request. We create a default view model containing the form. If we do not have a POST request, we return the default view model. We populate the form with data from the request. If the form is not valid, we return the default view model; at this point, the form will also contain error messages. We create a Post instance from the validated data. We attempt to insert the post. On success, we redirect to the post's detail page.","title":"General form-handling logic for controllers"},{"location":"in-depth-guide/laminas-form-laminas-form-fieldset/#using-laminas-hydrator-with-laminas-form","text":"In our controller currently, we have the following: $data = $this->form->getData()['post']; $post = new Post($data['title'], $data['text']); What if we could automate that, so we didn't need to worry about: Whether or not we're using a fieldset What the form fields are named Fortunately, laminas-form features integration with laminas-hydrator. This will allow us to return a Post instance when we retrieve the validated values! Let's udpate our fieldset to provide a hydrator and a prototype object. First, add two import statements to the top of the class file: // In module/Blog/src/Form/PostFieldset.php: use Blog\\Model\\Post; use Laminas\\Hydrator\\ReflectionHydrator; Next, update the init() method to add the following two lines: // In /module/Blog/src/Form/PostFieldset.php: public function init() { $this->setHydrator(new ReflectionHydrator()); $this->setObject(new Post('', '')); /* ... */ } When you grab the data from this fieldset, it will be returned as a Post instance. However, we grab data from the form ; how can we simplify that interaction? Since we only have the one fieldset, we'll set it as the form's base fieldset . This hints to the form that when we retrieve data from it, it should return the values from the specified fieldset instead; since our fieldset returns the Post instance, we'll have exactly what we need. Modify your PostForm class as follows: // In /module/Blog/src/Form/PostForm.php: public function init() { $this->add([ 'name' => 'post', 'type' => PostFieldset::class, 'options' => [ 'use_as_base_fieldset' => true, ], ]); /* ... */ Let's update our WriteController ; modify the addAction() method to replace the following two lines: $data = $this->form->getData()['post']; $post = new Post($data['title'], $data['text']); to: $post = $this->form->getData(); Everything should continue to work. The changes done serve the purpose of de-coupling the details of how the form is structured from the controller, allowing us to work directly with our entities at all times!","title":"Using laminas-hydrator with laminas-form"},{"location":"in-depth-guide/laminas-form-laminas-form-fieldset/#conclusion","text":"In this chapter, we've learned the fundamentals of using laminas-form, including adding fieldsets and elements, rendering the form, validating input, and wiring forms and fieldsets to use entities. In the next chapter we will finalize the CRUD functionality by creating the update and delete routines for the blog module.","title":"Conclusion"},{"location":"in-depth-guide/models-and-servicemanager/","text":"Models and the ServiceManager In the previous chapter we've learned how to create a \"Hello World\" Application using laminas-mvc. This is a good start, but the application itself doesn't really do anything. In this chapter we will introduce you into the concept of models, and with this, introduce laminas-servicemanager. What is a Model? A model encapsulates application logic. This often entails entity or value objects representing specific things in our model, and repositories for retrieving and updating these objects. For what we're trying to accomplish with our Blog module, this means that we need functionality for retrieving and saving blog posts. The posts themselves are our entities, and the repository will be what we retrieve them from and save them with. The model will get its data from some source; when writing the model, we don't really care about what the source actually is. The model will be written against an interface that we define and that future data providers must implement. Writing the PostRepository When writing a repository, it is a common best-practice to define an interface first. Interfaces are a good way to ensure that other programmers can easily build their own implementations. In other words, they can write classes with identical function names, but which internally do completely different things, while producing the same expected results. In our case, we want to create a PostRepository . This means first we are going to define a PostRepositoryInterface . The task of our repository is to provide us with data from our blog posts. For now, we are going to focus on the read-only side of things: we will define a method that will give us all posts, and another method that will give us a single post. Let's start by creating the interface at module/Blog/src/Model/PostRepositoryInterface.php namespace Blog\\Model; interface PostRepositoryInterface { /** * Return a set of all blog posts that we can iterate over. * * Each entry should be a Post instance. * * @return Post[] */ public function findAllPosts(); /** * Return a single blog post. * * @param int $id Identifier of the post to return. * @return Post */ public function findPost($id); } The first method, findAllPosts() , will return all posts, and the second method, findPost($id) , will return the post matching the given identifier $id . What's new in here is the fact that we actually define a return value - Post - that doesn't exist yet. We will define this Post class at a later point; for now, we will create the PostRepository class. Create the class PostRepository at module/Blog/src/Model/PostRepository.php ; be sure to implement the PostRepositoryInterface and its required method (we will fill these in later). You then should have a class that looks like the following: namespace Blog\\Model; class PostRepository implements PostRepositoryInterface { /** * {@inheritDoc} */ public function findAllPosts() { // TODO: Implement findAllPosts() method. } /** * {@inheritDoc} */ public function findPost($id) { // TODO: Implement findPost() method. } } Create an entity Since our PostRepository will return Post instances, we must create that class, too. Let's create module/Blog/src/Model/Post.php : namespace Blog\\Model; class Post { /** * @var int */ private $id; /** * @var string */ private $text; /** * @var string */ private $title; /** * @param string $title * @param string $text * @param int|null $id */ public function __construct($title, $text, $id = null) { $this->title = $title; $this->text = $text; $this->id = $id; } /** * @return int|null */ public function getId() { return $this->id; } /** * @return string */ public function getText() { return $this->text; } /** * @return string */ public function getTitle() { return $this->title; } } Notice that we only created getter methods; this is because each instance should be unchangeable, allowing us to cache instances in the repository as necessary. Bringing Life into our PostRepository Now that we have our entity in place, we can bring life into our PostRepository class. To keep the repository easy to understand, for now we will only return some hard-coded content from our PostRepository class directly. Create a property inside the PostRepository class called $data and make this an array of our Post type. Edit PostRepository as follows: namespace Blog\\Model; class PostRepository implements PostRepositoryInterface { private $data = [ 1 => [ 'id' => 1, 'title' => 'Hello World #1', 'text' => 'This is our first blog post!', ], 2 => [ 'id' => 2, 'title' => 'Hello World #2', 'text' => 'This is our second blog post!', ], 3 => [ 'id' => 3, 'title' => 'Hello World #3', 'text' => 'This is our third blog post!', ], 4 => [ 'id' => 4, 'title' => 'Hello World #4', 'text' => 'This is our fourth blog post!', ], 5 => [ 'id' => 5, 'title' => 'Hello World #5', 'text' => 'This is our fifth blog post!', ], ]; /** * {@inheritDoc} */ public function findAllPosts() { // TODO: Implement findAllPosts() method. } /** * {@inheritDoc} */ public function findPost($id) { // TODO: Implement findPost() method. } } Now that we have some data, let's modify our find*() functions to return the appropriate entities: namespace Blog\\Model; use DomainException; class PostRepository implements PostRepositoryInterface { private $data = [ 1 => [ 'id' => 1, 'title' => 'Hello World #1', 'text' => 'This is our first blog post!', ], 2 => [ 'id' => 2, 'title' => 'Hello World #2', 'text' => 'This is our second blog post!', ], 3 => [ 'id' => 3, 'title' => 'Hello World #3', 'text' => 'This is our third blog post!', ], 4 => [ 'id' => 4, 'title' => 'Hello World #4', 'text' => 'This is our fourth blog post!', ], 5 => [ 'id' => 5, 'title' => 'Hello World #5', 'text' => 'This is our fifth blog post!', ], ]; /** * {@inheritDoc} */ public function findAllPosts() { return array_map(function ($post) { return new Post( $post['title'], $post['text'], $post['id'] ); }, $this->data); } /** * {@inheritDoc} */ public function findPost($id) { if (! isset($this->data[$id])) { throw new DomainException(sprintf('Post by id \"%s\" not found', $id)); } return new Post( $this->data[$id]['title'], $this->data[$id]['text'], $this->data[$id]['id'] ); } } Both methods now have appropriate return values. Please note that from a technical point of view, the current implementation is far from perfect. We will improve this repository in the future, but for now we have a working repository that is able to give us some data in a way that is defined by our PostRepositoryInterface . Bringing the Service into the Controller Now that we have our PostRepository written, we want to get access to this repository in our controllers. For this task, we will step into a new topic called \"Dependency Injection\" (DI). When we're talking about dependency injection, we're talking about a way to get dependencies into our classes. The most common form, \"Constructor Injection\", is used for all dependencies that are required by a class at all times. In our case, we want to have our ListController somehow interact with our PostRepository . This means that the class PostRepository is a dependency of the class ListController ; without the PostRepository , our ListController will not be able to function properly. To make sure that our ListController will always get the appropriate dependency, we will first define the dependency inside the ListController constructor. Modify ListController as follows: namespace Blog\\Controller; use Blog\\Model\\PostRepositoryInterface; use Laminas\\Mvc\\Controller\\AbstractActionController; class ListController extends AbstractActionController { /** * @var PostRepositoryInterface */ private $postRepository; public function __construct(PostRepositoryInterface $postRepository) { $this->postRepository = $postRepository; } } The constructor now has a required argument; we will not be able to create instances of this class anymore without providing a PostRepositoryInterface implementation. If you were to go back to your browser and reload your project with the url localhost:8080/blog , you'd see the following error message: Catchable fatal error: Argument 1 passed to Blog\\Controller\\ListController::__construct() must be an instance of Blog\\Model\\PostRepositoryInterface, none given, called in {projectPath}/vendor/laminas/src/Factory/InvokableFactory.php on line {lineNumber} and defined in {projectPath}/module/Blog/src/Controller/ListController.php on line {lineNumber} And this error message is expected. It tells you exactly that our ListController expects to be passed an implementation of the PostRepositoryInterface . So how do we make sure that our ListController will receive such an implementation? To solve this, we need to tell the application how to create instances of the Blog\\Controller\\ListController . If you remember back to when we created the controller, we mapped it to the InvokableFactory in the module configuration: // In module/Blog/config/module.config.php: namespace Blog; use Laminas\\ServiceManager\\Factory\\InvokableFactory; return [ 'controllers' => [ 'factories' => [ Controller\\ListController::class => InvokableFactory::class, ], ], 'router' => [ /** Router Config */ ] 'view_manager' => [ /** ViewManager Config */ ], ); The InvokableFactory instantiates the mapped class using no constructor arguments. Since our ListController now has a required argument, we need to change this. We will now create a custom factory for our ListController . First, update the configuration as follows: // In module/Blog/config/module.config.php: namespace Blog; // Remove the InvokableFactory import statement return [ 'controllers' => [ 'factories' => [ // Update the following line: Controller\\ListController::class => Factory\\ListControllerFactory::class, ], ], 'router' => [ /** Router Config */ ] 'view_manager' => [ /** ViewManager Config */ ], ); The above changes the mapping for the ListController to use a new factory class we'll be creating, Blog\\Factory\\ListControllerFactory . If you refresh your browser you'll see a different error message: An error occurred An error occurred during execution; please try again later. Additional information: Laminas\\ServiceManager\\Exception\\ServiceNotFoundException File: {projectPath}/laminas/laminas-servicemanager/src/ServiceManager.php:{lineNumber} Message: Unable to resolve service \"Blog\\Controller\\ListController\" to a factory; are you certain you provided it during configuration? This exception message indicates that the service container could not resolve the service to a factory, and asks if we provided it during configuration. We did, so the end result is that the factory must not exist. Let's write the factory now. Writing a Factory Class Factory classes for laminas-servicemanager may implement either Laminas\\ServiceManager\\Factory\\FactoryInterface , or be callable classes (classes that implement the __invoke() method); FactoryInterface itself defines the __invoke() method. The first argument is the application container, and is required; if you implement the FactoryInterface , you must also define a second argument, $requestedName , which is the service name mapping to the factory, and an optional third argument, $options , which will be any options provided by the controller manager at instantiation. In most situations, the last argument can be ignored; however, you can create re-usable factories by implementing the second argument, so this is a good one to consider when writing your factories! For our purposes, this is a one-off factory, so we'll only use the first argument. Let's implement our factory class: // In /module/Blog/src/Factory/ListControllerFactory.php: namespace Blog\\Factory; use Blog\\Controller\\ListController; use Blog\\Model\\PostRepositoryInterface; use Interop\\Container\\ContainerInterface; use Laminas\\ServiceManager\\Factory\\FactoryInterface; class ListControllerFactory implements FactoryInterface { /** * @param ContainerInterface $container * @param string $requestedName * @param null|array $options * @return ListController */ public function __invoke(ContainerInterface $container, $requestedName, array $options = null) { return new ListController($container->get(PostRepositoryInterface::class)); } } The factory receives an instance of the application container, which, in our case, is a Laminas\\ServiceManager\\ServiceManager instance. The container also conforms to Interop\\Container\\ContainerInterface , allowing re-use in other dependency injection systems if desired. We pull a service matching the PostRepositoryInterface fully qualified class name and pass it directly to the controller's constructor. There's no magic happening; it's just PHP code. Refresh your browser and you will see this error message: An error occurred An error occurred during execution; please try again later. Additional information: Laminas\\ServiceManager\\Exception\\ServiceNotFoundException File: {projectPath}/vendor/laminas/laminas-servicemanager/src/ServiceManager.php:{lineNumber} Message: Unable to resolve service \"Blog\\Model\\PostRepositoryInterface\" to a factory; are you certain you provided it during configuration? Exactly what we expected. Within our factory, the service Blog\\Model\\PostRepositoryInterface is requested but the ServiceManager doesn't know about it yet. Therefore it isn't able to create an instance for the requested name. Registering Services Registering other services follows the same pattern as registering a controller. We will modify our module.config.php and add a new key called service_manager ; the configuration of this key is the same as that for the controllers key. We will add two entries, one for aliases and one for factories , as follows: // In module/Blog/config/module.config.php namespace Blog; // Re-add the following import: use Laminas\\ServiceManager\\Factory\\InvokableFactory; return [ // Add this section: 'service_manager' => [ 'aliases' => [ Model\\PostRepositoryInterface::class => Model\\PostRepository::class, ], 'factories' => [ Model\\PostRepository::class => InvokableFactory::class, ], ], 'controllers' => [ /** Controller Config */ ], 'router' => [ /** Router Config */ ], 'view_manager' => [ /** View Manager Config */ ], ]; This aliases PostRepositoryInterface to our PostRepository implementation, and then creates a factory for the PostRepository class by mapping it to the InvokableFactory (like we originally did for the ListController ); we can do this as our PostRepository implementation has no dependencies of its own. Aliasing services In laminas-servicemanager, when you request a service by an alias you get the service it is mapped to. So when you request Model\\PostRepositoryInterface::class you get the PostRepository class using its fully qualified class name (FQCN). We often alias an interface to an implementation service, as that allows the user to indicate they want an implementation of the interface, but do not care which implementation. For more information see the laminas-servicemanager Aliases documentation . Try refreshing your browser. You should see no more error messages, but rather exactly the page that we have created in the previous chapter of the tutorial. Using the repository in our controller Let's now use the PostRepository within our ListController . For this we will need to overwrite the default indexAction() and return a view with the results from the PostRepository . Modify ListController as follows: // In module/Blog/src/Controller/ListController.php: namespace Blog\\Controller; use Blog\\Model\\PostRepositoryInterface; use Laminas\\Mvc\\Controller\\AbstractActionController; // Add the following import statement: use Laminas\\View\\Model\\ViewModel; class ListController extends AbstractActionController { /** * @var PostRepositoryInterface */ private $postRepository; public function __construct(PostRepositoryInterface $postRepository) { $this->postRepository = $postRepository; } // Add the following method: public function indexAction() { return new ViewModel([ 'posts' => $this->postRepository->findAllPosts(), ]); } } First, please note that our controller imported another class, Laminas\\View\\Model\\ViewModel ; this is what controllers will usually return within laminas-mvc applications. ViewModel instances allow you to provide variables to render within your template, as well as indicate which template to use. In this case we have assigned a variable called $posts with the value of whatever the repository method findAllPosts() returns (an array of Post instances). Refreshing the browser won't change anything yet because we haven't updated our template to display the data. ViewModels are not required You do not actually need to return an instance of ViewModel ; when you return a normal PHP array, laminas-mvc internally converts it into a ViewModel . The following are equivalent: // Explicit ViewModel: return new ViewModel(['foo' => 'bar']); // Implicit ViewModel: return ['foo' => 'bar']; Accessing View Variables Let's modify our view to display a table of all blog posts that our repository returns: <!-- Filename: module/Blog/view/blog/list/index.phtml --> <h1>Blog</h1> <?php foreach ($this->posts as $post): ?> <article> <h1 id=\"post<?= $post->getId() ?>\"><?= $post->getTitle() ?></h1> <p><?= $post->getText() ?></p> </article> <?php endforeach ?> In the view script, we iterate over the posts passed to the view model. Since every single entry of our array is of type Blog\\Model\\Post , we can use its getter methods and render it. Instance Variables Vs Script Variables By default, all variables passed via a view model to the renderer are imported directly into the view script, and can therefore be referenced as either instance or script variables (i.e., $this->posts is the same as $posts ). However, we recommend to reference any variables defined as part of the original view model using instance variable notation ( $this->posts ), to make it clear where they originate, and to only use script variable notation ( $posts ) for variables defined in the script itself. After saving this file, refresh your browser, and you should now see a list of blog entries! Summary In this chapter, we learned: An approach to building the models for an application. A little bit about dependency injection. How to use laminas-servicemanager to implement dependency injection in laminas-mvc applications. How to pass variables to view scripts from the controller. In the next chapter, we will take a first look at the things we should do when we want to get data from a database.","title":"Models and the ServiceManager"},{"location":"in-depth-guide/models-and-servicemanager/#models-and-the-servicemanager","text":"In the previous chapter we've learned how to create a \"Hello World\" Application using laminas-mvc. This is a good start, but the application itself doesn't really do anything. In this chapter we will introduce you into the concept of models, and with this, introduce laminas-servicemanager.","title":"Models and the ServiceManager"},{"location":"in-depth-guide/models-and-servicemanager/#what-is-a-model","text":"A model encapsulates application logic. This often entails entity or value objects representing specific things in our model, and repositories for retrieving and updating these objects. For what we're trying to accomplish with our Blog module, this means that we need functionality for retrieving and saving blog posts. The posts themselves are our entities, and the repository will be what we retrieve them from and save them with. The model will get its data from some source; when writing the model, we don't really care about what the source actually is. The model will be written against an interface that we define and that future data providers must implement.","title":"What is a Model?"},{"location":"in-depth-guide/models-and-servicemanager/#writing-the-postrepository","text":"When writing a repository, it is a common best-practice to define an interface first. Interfaces are a good way to ensure that other programmers can easily build their own implementations. In other words, they can write classes with identical function names, but which internally do completely different things, while producing the same expected results. In our case, we want to create a PostRepository . This means first we are going to define a PostRepositoryInterface . The task of our repository is to provide us with data from our blog posts. For now, we are going to focus on the read-only side of things: we will define a method that will give us all posts, and another method that will give us a single post. Let's start by creating the interface at module/Blog/src/Model/PostRepositoryInterface.php namespace Blog\\Model; interface PostRepositoryInterface { /** * Return a set of all blog posts that we can iterate over. * * Each entry should be a Post instance. * * @return Post[] */ public function findAllPosts(); /** * Return a single blog post. * * @param int $id Identifier of the post to return. * @return Post */ public function findPost($id); } The first method, findAllPosts() , will return all posts, and the second method, findPost($id) , will return the post matching the given identifier $id . What's new in here is the fact that we actually define a return value - Post - that doesn't exist yet. We will define this Post class at a later point; for now, we will create the PostRepository class. Create the class PostRepository at module/Blog/src/Model/PostRepository.php ; be sure to implement the PostRepositoryInterface and its required method (we will fill these in later). You then should have a class that looks like the following: namespace Blog\\Model; class PostRepository implements PostRepositoryInterface { /** * {@inheritDoc} */ public function findAllPosts() { // TODO: Implement findAllPosts() method. } /** * {@inheritDoc} */ public function findPost($id) { // TODO: Implement findPost() method. } }","title":"Writing the PostRepository"},{"location":"in-depth-guide/models-and-servicemanager/#create-an-entity","text":"Since our PostRepository will return Post instances, we must create that class, too. Let's create module/Blog/src/Model/Post.php : namespace Blog\\Model; class Post { /** * @var int */ private $id; /** * @var string */ private $text; /** * @var string */ private $title; /** * @param string $title * @param string $text * @param int|null $id */ public function __construct($title, $text, $id = null) { $this->title = $title; $this->text = $text; $this->id = $id; } /** * @return int|null */ public function getId() { return $this->id; } /** * @return string */ public function getText() { return $this->text; } /** * @return string */ public function getTitle() { return $this->title; } } Notice that we only created getter methods; this is because each instance should be unchangeable, allowing us to cache instances in the repository as necessary.","title":"Create an entity"},{"location":"in-depth-guide/models-and-servicemanager/#bringing-life-into-our-postrepository","text":"Now that we have our entity in place, we can bring life into our PostRepository class. To keep the repository easy to understand, for now we will only return some hard-coded content from our PostRepository class directly. Create a property inside the PostRepository class called $data and make this an array of our Post type. Edit PostRepository as follows: namespace Blog\\Model; class PostRepository implements PostRepositoryInterface { private $data = [ 1 => [ 'id' => 1, 'title' => 'Hello World #1', 'text' => 'This is our first blog post!', ], 2 => [ 'id' => 2, 'title' => 'Hello World #2', 'text' => 'This is our second blog post!', ], 3 => [ 'id' => 3, 'title' => 'Hello World #3', 'text' => 'This is our third blog post!', ], 4 => [ 'id' => 4, 'title' => 'Hello World #4', 'text' => 'This is our fourth blog post!', ], 5 => [ 'id' => 5, 'title' => 'Hello World #5', 'text' => 'This is our fifth blog post!', ], ]; /** * {@inheritDoc} */ public function findAllPosts() { // TODO: Implement findAllPosts() method. } /** * {@inheritDoc} */ public function findPost($id) { // TODO: Implement findPost() method. } } Now that we have some data, let's modify our find*() functions to return the appropriate entities: namespace Blog\\Model; use DomainException; class PostRepository implements PostRepositoryInterface { private $data = [ 1 => [ 'id' => 1, 'title' => 'Hello World #1', 'text' => 'This is our first blog post!', ], 2 => [ 'id' => 2, 'title' => 'Hello World #2', 'text' => 'This is our second blog post!', ], 3 => [ 'id' => 3, 'title' => 'Hello World #3', 'text' => 'This is our third blog post!', ], 4 => [ 'id' => 4, 'title' => 'Hello World #4', 'text' => 'This is our fourth blog post!', ], 5 => [ 'id' => 5, 'title' => 'Hello World #5', 'text' => 'This is our fifth blog post!', ], ]; /** * {@inheritDoc} */ public function findAllPosts() { return array_map(function ($post) { return new Post( $post['title'], $post['text'], $post['id'] ); }, $this->data); } /** * {@inheritDoc} */ public function findPost($id) { if (! isset($this->data[$id])) { throw new DomainException(sprintf('Post by id \"%s\" not found', $id)); } return new Post( $this->data[$id]['title'], $this->data[$id]['text'], $this->data[$id]['id'] ); } } Both methods now have appropriate return values. Please note that from a technical point of view, the current implementation is far from perfect. We will improve this repository in the future, but for now we have a working repository that is able to give us some data in a way that is defined by our PostRepositoryInterface .","title":"Bringing Life into our PostRepository"},{"location":"in-depth-guide/models-and-servicemanager/#bringing-the-service-into-the-controller","text":"Now that we have our PostRepository written, we want to get access to this repository in our controllers. For this task, we will step into a new topic called \"Dependency Injection\" (DI). When we're talking about dependency injection, we're talking about a way to get dependencies into our classes. The most common form, \"Constructor Injection\", is used for all dependencies that are required by a class at all times. In our case, we want to have our ListController somehow interact with our PostRepository . This means that the class PostRepository is a dependency of the class ListController ; without the PostRepository , our ListController will not be able to function properly. To make sure that our ListController will always get the appropriate dependency, we will first define the dependency inside the ListController constructor. Modify ListController as follows: namespace Blog\\Controller; use Blog\\Model\\PostRepositoryInterface; use Laminas\\Mvc\\Controller\\AbstractActionController; class ListController extends AbstractActionController { /** * @var PostRepositoryInterface */ private $postRepository; public function __construct(PostRepositoryInterface $postRepository) { $this->postRepository = $postRepository; } } The constructor now has a required argument; we will not be able to create instances of this class anymore without providing a PostRepositoryInterface implementation. If you were to go back to your browser and reload your project with the url localhost:8080/blog , you'd see the following error message: Catchable fatal error: Argument 1 passed to Blog\\Controller\\ListController::__construct() must be an instance of Blog\\Model\\PostRepositoryInterface, none given, called in {projectPath}/vendor/laminas/src/Factory/InvokableFactory.php on line {lineNumber} and defined in {projectPath}/module/Blog/src/Controller/ListController.php on line {lineNumber} And this error message is expected. It tells you exactly that our ListController expects to be passed an implementation of the PostRepositoryInterface . So how do we make sure that our ListController will receive such an implementation? To solve this, we need to tell the application how to create instances of the Blog\\Controller\\ListController . If you remember back to when we created the controller, we mapped it to the InvokableFactory in the module configuration: // In module/Blog/config/module.config.php: namespace Blog; use Laminas\\ServiceManager\\Factory\\InvokableFactory; return [ 'controllers' => [ 'factories' => [ Controller\\ListController::class => InvokableFactory::class, ], ], 'router' => [ /** Router Config */ ] 'view_manager' => [ /** ViewManager Config */ ], ); The InvokableFactory instantiates the mapped class using no constructor arguments. Since our ListController now has a required argument, we need to change this. We will now create a custom factory for our ListController . First, update the configuration as follows: // In module/Blog/config/module.config.php: namespace Blog; // Remove the InvokableFactory import statement return [ 'controllers' => [ 'factories' => [ // Update the following line: Controller\\ListController::class => Factory\\ListControllerFactory::class, ], ], 'router' => [ /** Router Config */ ] 'view_manager' => [ /** ViewManager Config */ ], ); The above changes the mapping for the ListController to use a new factory class we'll be creating, Blog\\Factory\\ListControllerFactory . If you refresh your browser you'll see a different error message: An error occurred An error occurred during execution; please try again later. Additional information: Laminas\\ServiceManager\\Exception\\ServiceNotFoundException File: {projectPath}/laminas/laminas-servicemanager/src/ServiceManager.php:{lineNumber} Message: Unable to resolve service \"Blog\\Controller\\ListController\" to a factory; are you certain you provided it during configuration? This exception message indicates that the service container could not resolve the service to a factory, and asks if we provided it during configuration. We did, so the end result is that the factory must not exist. Let's write the factory now.","title":"Bringing the Service into the Controller"},{"location":"in-depth-guide/models-and-servicemanager/#writing-a-factory-class","text":"Factory classes for laminas-servicemanager may implement either Laminas\\ServiceManager\\Factory\\FactoryInterface , or be callable classes (classes that implement the __invoke() method); FactoryInterface itself defines the __invoke() method. The first argument is the application container, and is required; if you implement the FactoryInterface , you must also define a second argument, $requestedName , which is the service name mapping to the factory, and an optional third argument, $options , which will be any options provided by the controller manager at instantiation. In most situations, the last argument can be ignored; however, you can create re-usable factories by implementing the second argument, so this is a good one to consider when writing your factories! For our purposes, this is a one-off factory, so we'll only use the first argument. Let's implement our factory class: // In /module/Blog/src/Factory/ListControllerFactory.php: namespace Blog\\Factory; use Blog\\Controller\\ListController; use Blog\\Model\\PostRepositoryInterface; use Interop\\Container\\ContainerInterface; use Laminas\\ServiceManager\\Factory\\FactoryInterface; class ListControllerFactory implements FactoryInterface { /** * @param ContainerInterface $container * @param string $requestedName * @param null|array $options * @return ListController */ public function __invoke(ContainerInterface $container, $requestedName, array $options = null) { return new ListController($container->get(PostRepositoryInterface::class)); } } The factory receives an instance of the application container, which, in our case, is a Laminas\\ServiceManager\\ServiceManager instance. The container also conforms to Interop\\Container\\ContainerInterface , allowing re-use in other dependency injection systems if desired. We pull a service matching the PostRepositoryInterface fully qualified class name and pass it directly to the controller's constructor. There's no magic happening; it's just PHP code. Refresh your browser and you will see this error message: An error occurred An error occurred during execution; please try again later. Additional information: Laminas\\ServiceManager\\Exception\\ServiceNotFoundException File: {projectPath}/vendor/laminas/laminas-servicemanager/src/ServiceManager.php:{lineNumber} Message: Unable to resolve service \"Blog\\Model\\PostRepositoryInterface\" to a factory; are you certain you provided it during configuration? Exactly what we expected. Within our factory, the service Blog\\Model\\PostRepositoryInterface is requested but the ServiceManager doesn't know about it yet. Therefore it isn't able to create an instance for the requested name.","title":"Writing a Factory Class"},{"location":"in-depth-guide/models-and-servicemanager/#registering-services","text":"Registering other services follows the same pattern as registering a controller. We will modify our module.config.php and add a new key called service_manager ; the configuration of this key is the same as that for the controllers key. We will add two entries, one for aliases and one for factories , as follows: // In module/Blog/config/module.config.php namespace Blog; // Re-add the following import: use Laminas\\ServiceManager\\Factory\\InvokableFactory; return [ // Add this section: 'service_manager' => [ 'aliases' => [ Model\\PostRepositoryInterface::class => Model\\PostRepository::class, ], 'factories' => [ Model\\PostRepository::class => InvokableFactory::class, ], ], 'controllers' => [ /** Controller Config */ ], 'router' => [ /** Router Config */ ], 'view_manager' => [ /** View Manager Config */ ], ]; This aliases PostRepositoryInterface to our PostRepository implementation, and then creates a factory for the PostRepository class by mapping it to the InvokableFactory (like we originally did for the ListController ); we can do this as our PostRepository implementation has no dependencies of its own.","title":"Registering Services"},{"location":"in-depth-guide/models-and-servicemanager/#using-the-repository-in-our-controller","text":"Let's now use the PostRepository within our ListController . For this we will need to overwrite the default indexAction() and return a view with the results from the PostRepository . Modify ListController as follows: // In module/Blog/src/Controller/ListController.php: namespace Blog\\Controller; use Blog\\Model\\PostRepositoryInterface; use Laminas\\Mvc\\Controller\\AbstractActionController; // Add the following import statement: use Laminas\\View\\Model\\ViewModel; class ListController extends AbstractActionController { /** * @var PostRepositoryInterface */ private $postRepository; public function __construct(PostRepositoryInterface $postRepository) { $this->postRepository = $postRepository; } // Add the following method: public function indexAction() { return new ViewModel([ 'posts' => $this->postRepository->findAllPosts(), ]); } } First, please note that our controller imported another class, Laminas\\View\\Model\\ViewModel ; this is what controllers will usually return within laminas-mvc applications. ViewModel instances allow you to provide variables to render within your template, as well as indicate which template to use. In this case we have assigned a variable called $posts with the value of whatever the repository method findAllPosts() returns (an array of Post instances). Refreshing the browser won't change anything yet because we haven't updated our template to display the data.","title":"Using the repository in our controller"},{"location":"in-depth-guide/models-and-servicemanager/#accessing-view-variables","text":"Let's modify our view to display a table of all blog posts that our repository returns: <!-- Filename: module/Blog/view/blog/list/index.phtml --> <h1>Blog</h1> <?php foreach ($this->posts as $post): ?> <article> <h1 id=\"post<?= $post->getId() ?>\"><?= $post->getTitle() ?></h1> <p><?= $post->getText() ?></p> </article> <?php endforeach ?> In the view script, we iterate over the posts passed to the view model. Since every single entry of our array is of type Blog\\Model\\Post , we can use its getter methods and render it.","title":"Accessing View Variables"},{"location":"in-depth-guide/models-and-servicemanager/#summary","text":"In this chapter, we learned: An approach to building the models for an application. A little bit about dependency injection. How to use laminas-servicemanager to implement dependency injection in laminas-mvc applications. How to pass variables to view scripts from the controller. In the next chapter, we will take a first look at the things we should do when we want to get data from a database.","title":"Summary"},{"location":"in-depth-guide/preparing-databases/","text":"Preparing for Different Databases In the previous chapter, we created a PostRepository that returns some data from blog posts. While the implementation was adequate for learning purposes, it is quite impractical for real world applications; no one would want to modify the source files each time a new post is added! Fortunately, we can always turn to databases for the actual storage of posts; all we need to learn is how to interact with databases within our application. There's one small catch: there are many database backend systems, including relational databases, documentent databases, key/value stores, and graph databases. You may be inclined to code directly to the solution that fits your application's immediate needs, but it is a better practice to create another layer in front of the actual database access that abstracts the database interaction. The repository approach we used in the previous chapter is one such approach, primarily geared towards queries . In this section, we'll expand on it to add command capabilities for creating, updating, and deleting records. What is database abstraction? \"Database abstraction\" is the act of providing a common interface for all database interactions. Consider a SQL and a NoSQL database; both have methods for CRUD (Create, Read, Update, Delete) operations. For example, to query the database against a given row in MySQL you might use $results = mysqli_query('SELECT foo FROM bar')`; However, for MongoDB, for example you'd use something like: $results = $mongoDbClient->app->bar->find([], ['foo' => 1, '_id' => 0])`; Both engines would give you the same result, but the execution is different. So if we start using a SQL database and write those codes directly into our PostRepository and a year later we decide to switch to a NoSQL database, the existing implementation is useless to us. And in a few years later, when a new persistence engine pops up, we have to start over yet again. If we hadn't created an interface first, we'd also likely need to change our consuming code! On top of that, we may find that we want to use some sort of distributed caching layer for read operations (fetching items), while write operations will be written to a relational database. Most likely, we don't want our controllers to need to worry about those implementation details, but we will want to ensure that we account for this in our architecture. At the code level, the interface is our abstraction layer for dealing with differences in implementations. However, currently, we only deal with queries. Let's expand on that. Adding command abstraction Let's first think a bit about what possible database interactions we can think of. We need to be able to: find a single blog post find all blog posts insert new blog post update existing blog posts delete existing blog posts At this time, our PostRepositoryInterface deals with the first two. Considering this is the layer that is most likely to use different backend implementations, we probably want to keep it separate from the operations that cause changes. Let's create a new interface, Blog\\Model\\PostCommandInterface , in module/Blog/src/Model/PostCommandInterface.php , and have it read as follows: namespace Blog\\Model; interface PostCommandInterface { /** * Persist a new post in the system. * * @param Post $post The post to insert; may or may not have an identifier. * @return Post The inserted post, with identifier. */ public function insertPost(Post $post); /** * Update an existing post in the system. * * @param Post $post The post to update; must have an identifier. * @return Post The updated post. */ public function updatePost(Post $post); /** * Delete a post from the system. * * @param Post $post The post to delete. * @return bool */ public function deletePost(Post $post); } This new interface defines methods for each command within our model. Each expects a Post instance, and it is up to the implementation to determine how to use that instance to issue the command. In the case of an insert operation, our Post does not require an identifier (which is why the value is nullable in the constructor), but will return a new instance that is guaranteed to have one. Similarly, the update operation will return the updated post (which may be the same instance!), and a delete operation will indicate if the operation was successful. Conclusion We're not quite ready to use the new interface; we're using it to set the stage for the next few chapters, where we look at using laminas-db to implement our persistence, and later creating new controllers to handle blog post manipulation.","title":"Preparing for Different Databases"},{"location":"in-depth-guide/preparing-databases/#preparing-for-different-databases","text":"In the previous chapter, we created a PostRepository that returns some data from blog posts. While the implementation was adequate for learning purposes, it is quite impractical for real world applications; no one would want to modify the source files each time a new post is added! Fortunately, we can always turn to databases for the actual storage of posts; all we need to learn is how to interact with databases within our application. There's one small catch: there are many database backend systems, including relational databases, documentent databases, key/value stores, and graph databases. You may be inclined to code directly to the solution that fits your application's immediate needs, but it is a better practice to create another layer in front of the actual database access that abstracts the database interaction. The repository approach we used in the previous chapter is one such approach, primarily geared towards queries . In this section, we'll expand on it to add command capabilities for creating, updating, and deleting records.","title":"Preparing for Different Databases"},{"location":"in-depth-guide/preparing-databases/#what-is-database-abstraction","text":"\"Database abstraction\" is the act of providing a common interface for all database interactions. Consider a SQL and a NoSQL database; both have methods for CRUD (Create, Read, Update, Delete) operations. For example, to query the database against a given row in MySQL you might use $results = mysqli_query('SELECT foo FROM bar')`; However, for MongoDB, for example you'd use something like: $results = $mongoDbClient->app->bar->find([], ['foo' => 1, '_id' => 0])`; Both engines would give you the same result, but the execution is different. So if we start using a SQL database and write those codes directly into our PostRepository and a year later we decide to switch to a NoSQL database, the existing implementation is useless to us. And in a few years later, when a new persistence engine pops up, we have to start over yet again. If we hadn't created an interface first, we'd also likely need to change our consuming code! On top of that, we may find that we want to use some sort of distributed caching layer for read operations (fetching items), while write operations will be written to a relational database. Most likely, we don't want our controllers to need to worry about those implementation details, but we will want to ensure that we account for this in our architecture. At the code level, the interface is our abstraction layer for dealing with differences in implementations. However, currently, we only deal with queries. Let's expand on that.","title":"What is database abstraction?"},{"location":"in-depth-guide/preparing-databases/#adding-command-abstraction","text":"Let's first think a bit about what possible database interactions we can think of. We need to be able to: find a single blog post find all blog posts insert new blog post update existing blog posts delete existing blog posts At this time, our PostRepositoryInterface deals with the first two. Considering this is the layer that is most likely to use different backend implementations, we probably want to keep it separate from the operations that cause changes. Let's create a new interface, Blog\\Model\\PostCommandInterface , in module/Blog/src/Model/PostCommandInterface.php , and have it read as follows: namespace Blog\\Model; interface PostCommandInterface { /** * Persist a new post in the system. * * @param Post $post The post to insert; may or may not have an identifier. * @return Post The inserted post, with identifier. */ public function insertPost(Post $post); /** * Update an existing post in the system. * * @param Post $post The post to update; must have an identifier. * @return Post The updated post. */ public function updatePost(Post $post); /** * Delete a post from the system. * * @param Post $post The post to delete. * @return bool */ public function deletePost(Post $post); } This new interface defines methods for each command within our model. Each expects a Post instance, and it is up to the implementation to determine how to use that instance to issue the command. In the case of an insert operation, our Post does not require an identifier (which is why the value is nullable in the constructor), but will return a new instance that is guaranteed to have one. Similarly, the update operation will return the updated post (which may be the same instance!), and a delete operation will indicate if the operation was successful.","title":"Adding command abstraction"},{"location":"in-depth-guide/preparing-databases/#conclusion","text":"We're not quite ready to use the new interface; we're using it to set the stage for the next few chapters, where we look at using laminas-db to implement our persistence, and later creating new controllers to handle blog post manipulation.","title":"Conclusion"},{"location":"in-depth-guide/review/","text":"Reviewing the Blog Module Throughout the tutorial, we have created a fully functional CRUD module using a blog as an example. While doing so, we've made use of several different design patterns and best-practices. Now it's time to reiterate and take a look at some of the code samples we've written. This is going to be done in a Q&A fashion. Do we always need all the layers and interfaces? Short answer: no. Long answer: The importance of interfaces increases the bigger your application becomes. If you can foresee that your application will be used by other people or should be extendable, then you should strongly consider creating interfaces and coding to them. This is a very common best-practice that is not tied to Laminas specifically, but rather more general object oriented programming. The main role of the multiple layers that we have introduced are to provide a strict separation of concerns for our application. It is tempting to include your database access directly in your controllers. We recommend splitting it out to other objects, and providing interfaces for the interactions whenever you can. Doing so helps decouple your controllers from the implementation, allowing you to swap out the implementation later without changing the controllers. Using interfaces also simplifies testing, as you can provide mock implementations easily. Why are there so many controllers? With the exception of our ListController , we created a controller for each route we added. We could have combined these into a single controller. In practice, we have observed the following when doing so: Controllers grow in complexity, making maintenance and additions more difficult. The number of dependencies grows with the number of responsibilities. Many actions may need only a subset of the dependencies, leading to needless performance and resource overhead. Testing becomes more difficult. Re-use becomes more difficult. The primary problem is that such controllers quickly break the Single Responsibility Principle , and inherit all the problems that principle attempts to combat. We recommend a single action per controller whenever possible. Do you have more questions? PR them! If there's anything you feel that's missing in this FAQ, please create an issue or send a pull request with your question!","title":"Reviewing the Blog Module"},{"location":"in-depth-guide/review/#reviewing-the-blog-module","text":"Throughout the tutorial, we have created a fully functional CRUD module using a blog as an example. While doing so, we've made use of several different design patterns and best-practices. Now it's time to reiterate and take a look at some of the code samples we've written. This is going to be done in a Q&A fashion.","title":"Reviewing the Blog Module"},{"location":"in-depth-guide/review/#do-we-always-need-all-the-layers-and-interfaces","text":"Short answer: no. Long answer: The importance of interfaces increases the bigger your application becomes. If you can foresee that your application will be used by other people or should be extendable, then you should strongly consider creating interfaces and coding to them. This is a very common best-practice that is not tied to Laminas specifically, but rather more general object oriented programming. The main role of the multiple layers that we have introduced are to provide a strict separation of concerns for our application. It is tempting to include your database access directly in your controllers. We recommend splitting it out to other objects, and providing interfaces for the interactions whenever you can. Doing so helps decouple your controllers from the implementation, allowing you to swap out the implementation later without changing the controllers. Using interfaces also simplifies testing, as you can provide mock implementations easily.","title":"Do we always need all the layers and interfaces?"},{"location":"in-depth-guide/review/#why-are-there-so-many-controllers","text":"With the exception of our ListController , we created a controller for each route we added. We could have combined these into a single controller. In practice, we have observed the following when doing so: Controllers grow in complexity, making maintenance and additions more difficult. The number of dependencies grows with the number of responsibilities. Many actions may need only a subset of the dependencies, leading to needless performance and resource overhead. Testing becomes more difficult. Re-use becomes more difficult. The primary problem is that such controllers quickly break the Single Responsibility Principle , and inherit all the problems that principle attempts to combat. We recommend a single action per controller whenever possible.","title":"Why are there so many controllers?"},{"location":"in-depth-guide/review/#do-you-have-more-questions-pr-them","text":"If there's anything you feel that's missing in this FAQ, please create an issue or send a pull request with your question!","title":"Do you have more questions? PR them!"},{"location":"in-depth-guide/understanding-routing/","text":"Understanding the Router Our module is coming along nicely. However, we're not really doing all that much yet; to be precise, all we do is display all blog entries on one page. In this chapter, you will learn everything you need to know about the Router in order to route to controllers and actions for displaying a single blog post, adding a new blog post, editing an existing post, and deleting a post. Different route types Before we go into details on our application, let's take a look at the most often used route types. Literal routes As mentioned in a previous chapter, a literal route is one that exactly matches a specific string. Examples of URLs that can utilize literal routes include: http://domain.com/blog http://domain.com/blog/add http://domain.com/about-me http://domain.com/my/very/deep/page Configuration for a literal route requires you to provide the path to match, and the \"defaults\" to return on a match. The \"defaults\" are then returned as route match parameters; one use case for these is to specify the controller to invoke and the action method on that controller to use. As an example: 'router' => [ 'routes' => [ 'about' => [ 'type' => \\Laminas\\Router\\Http\\Literal::class, 'options' => [ 'route' => '/about-me', 'defaults' => [ 'controller' => 'AboutMeController', 'action' => 'aboutme', ], ], ], ], ], Segment routes Segment routes allow you to define routes with variable parameters; a common use case is for specifying an identifier in the path. Examples of URLs that might require segment routes include: http://domain.com/blog/1 (parameter \"1\" is dynamic) http://domain.com/blog/details/1 (parameter \"1\" is dynamic) http://domain.com/blog/edit/1 (parameter \"1\" is dynamic) http://domain.com/blog/1/edit (parameter \"1\" is dynamic) http://domain.com/news/archive/2014 (parameter \"2014\" is dynamic) http://domain.com/news/archive/2014/january (parameter \"2014\" and \"january\" are dynamic) Configuring a segment route is similar to that of a literal route. The primary differences are: The route will have one or more :<varname> segments, indicating items that will be dynamically filled. <varname> should be a string, and will be used to identify the variable to return when routing is successful. The route may also contain optional segments, which are items surrounded by square braces ( [] ), and which can contain any mix of literal and variable segments internally. The \"defaults\" can include the names of variable segments; in case that segment is missing, the default will be used. (They can also be completely independent; for instance, the \"controller\" rarely should be included as a segment!). You may also specify \"constraints\" for each variable segment; each constraint will be a regular expression that must pass for matching to be successful. As an example, let's consider a route where we want to specify a variable \"year\" segment, and indicate that the segment must contain exactly four digits; when matched, we should use the ArchiveController and its byYear action: 'router' => [ 'routes' => [ 'archives' => [ 'type' => \\Laminas\\Router\\Http\\Segment::class, 'options' => [ 'route' => '/news/archive[/:year]', 'defaults' => [ 'controller' => ArchiveController::class, 'action' => 'byYear', 'year' => date('Y'), ], 'constraints' => [ 'year' => '\\d{4}', ], ], ], ], ], This configuration defines a route for a URL such as //example.com/news/archive/2014 . The route contains the variable segment :year , which has a regex constraint defined as \\d{4} , indicating it will match if and only if it is exactly four digits. As such, the URL //example.com/news/archive/123 will fail to match, but //example.com/news/archive/1234 will. The definition marks an optional segment, denoted by [/:year] . This has a couple of implications. First, it means that we can also match: //example.com/news/archive //example.com/news/archive/ In both cases, we'll also still receive a value for the :year segment, because we defined a default for it: the expression date('Y') (returning the current year). Segment routes allow you to dynamically match paths, and provide extensive capabilities for how you shape those paths, matching variable segments, and providing constraints for them. Different routing concepts When thinking about an entire application, you'll quickly realize that you may have many, many routes to define. When writing these routes you have two options: Spend less time writing routes that in turn are a little slow in matching. Write very explicit routes that match faster, but require more work to define. Generic routes A generic route is greedy, and will match as many URLs as possible. A common approach is to write a route that matches the controller and action: 'router' => [ 'routes' => [ 'default' => [ 'type' => \\Laminas\\Router\\Http\\Segment::class, 'options' => [ 'route' => '/[:controller[/:action]]', 'defaults' => [ 'controller' => Application\\Controller\\IndexController::class, 'action' => 'index', ], 'constraints' => [ 'controller' => '[a-zA-Z][a-zA-Z0-9_-]*', 'action' => '[a-zA-Z][a-zA-Z0-9_-]*', ], ], ], ], ], Let's take a closer look as to what has been defined in this configuration. The route part now contains two optional parameters, controller and action . The action parameter is optional only when the controller parameter is present. Both have constraints that ensure they only allow strings that would be valid PHP class and method names. The big advantage of this approach is the immense time you save when developing your application; one route, and then all you need to do is create controllers, add action methods to them, and they are immediately available. The downsides are in the details. In order for this to work, you will need to use aliases when defining your controllers, so that you can alias shorter names that omit namespaces to the fully qualified controller class names; this sets up the potential for collisions between different application modules which might define the same controller class names. Second, matching nested optional segments, each with regular expression constraints, adds performance overhead to routing. Third, such a route does not match any additional segments, constraining your controllers to omit dynamic route segments and instead rely on query string arguments for route parameters — which in turn leaves parameter validation to your controllers. Finally, there is no guarantee that a valid match will result in a valid controller and action. As an example, if somebody requested //example.com/strange/nonExistent , and no controller maps to strange , or the controller has no nonExistentAction() method, the application will use more cycles to discover and report the error condition than it would if routing had simply failed to match. This is both a performance and a security consideration, as an attacker could use this fact to launch a Denial of Service. Basic routing By now, you should be convinced that generic routes, while nice for prototyping, should likely be avoided. That means defining explicit routes. Your initial approach might be to create one route for every permutation: 'router' => [ 'routes' => [ 'news' => [ 'type' => \\Laminas\\Router\\Http\\Literal::class, 'options' => [ 'route' => '/news', 'defaults' => [ 'controller' => NewsController::class, 'action' => 'showAll', ], ], ], 'news-archive' => [ 'type' => \\Laminas\\Router\\Http\\Segment::class, 'options' => [ 'route' => '/news/archive[/:year]', 'defaults' => [ 'controller' => NewsController::class, 'action' => 'archive', ], 'constraints' => [ 'year' => '\\d{4}', ], ], ], 'news-single' => [ 'type' => \\Laminas\\Router\\Http\\Segment::class, 'options' => [ 'route' => '/news/:id', 'defaults' => [ 'controller' => NewsController::class, 'action' => 'detail', ], 'constraints' => [ 'id' => '\\d+', ], ], ], ], ], Routing is done as a stack, meaning last in, first out (LIFO). The trick is to define your most general routes first, and your most specific routes last. In the example above, our most general route is a literal match against the path /news . We then have two additional routes that are more specific, one matching /news/archive (with an optional segment for the year), and another one matching /news/:id . These exhibit a fair bit of repetition: In order to prevent naming collisions between routes, each route name is prefixed with news- . Each routing string contains /news . Each defines the same default controller. Clearly, this can get tedious. Additionally, if you have many routes with repitition such as this, you need to pay special attention to the stack and possible route overlaps, as well as performance (if the stack becomes large). Child routes To solve the problems detailed in the last section, laminas-router allows defining \"child routes\". Child routes inherit all options from their respective parents; this means that if an option, such as the controller default, doesn't change, you do not need to redefine it. Additionally, child routes match relative to the parent route. This provides several optimizations: You do not need to duplicate common path segments. Routing will ignore the child routes unless the parent matches , which can provide enormous performance benefits during routing. Let's take a look at a child routes configuration using the same example as above: 'router' => [ 'routes' => [ 'news' => [ // First we define the basic options for the parent route: 'type' => \\Laminas\\Router\\Http\\Literal::class, 'options' => [ 'route' => '/news', 'defaults' => [ 'controller' => NewsController::class, 'action' => 'showAll', ], ], // The following allows \"/news\" to match on its own if no child // routes match: 'may_terminate' => true, // Child routes begin: 'child_routes' => [ 'archive' => [ 'type' => \\Laminas\\Router\\Http\\Segment::class, 'options' => [ 'route' => '/archive[/:year]', 'defaults' => [ 'action' => 'archive', ], 'constraints' => [ 'year' => '\\d{4}', ], ], ], 'single' => [ 'type' => \\Laminas\\Router\\Http\\Segment::class, 'options' => [ 'route' => '/:id', 'defaults' => [ 'action' => 'detail', ], 'constraints' => [ 'id' => '\\d+', ], ], ], ], ], ], ], At its most basic, we define a parent route as normal, and then add an additional key, child_routes , which is normal routing configuration for additional routes to match if the parent route matches. The may_terminate configuration key is used to determine if the parent route is allowed to match on its own; in other words, if no child routes match, is the parent route a valid route match? The flag is false by default; setting it to true allows the parent to match on its own. The child_routes themselves look like standard routing at the top-level, and follow the same rules; they themselves can have child routes, too! The thing to remember is that any routing strings defined are relative to the parent . As such, the above definition allows matching any of the following: /news /news/archive /news/archive/2014 /news/42 (If may_terminate was set to false , the first path above, /news , would not match .) You'll note that the child routes defined above do not specify a controller default. Child routes inherit options from the parent, however, which means that, effectively, each of these will use the same controller as the parent! The advantages to using child routes include: Explicit routes mean fewer error conditions with regards to matching controllers and action methods. Performance; the router ignores child routes unless the parent matches. De-duplication; the parent route contains the common path prefix and common options. Organization; you can see at a glance all route definitions that start with a common path segment. The primary disadvantage is the verbosity of configuration. A practical example for our blog module Now that we know how to configure routes, let's first create a route to display only a single blog entry based on internal identifier. Given that ID is a variable parameter, we need a segment route. Furthermore, we know that the route will also match against the same /blog path prefix, so we can define it as a child route of our existing route. Let's update our configuration: // In module/Blog/config/module.config.php: namespace Blog; use Laminas\\Router\\Http\\Literal; use Laminas\\Router\\Http\\Segment; use Laminas\\ServiceManager\\Factory\\InvokableFactory; return [ 'service_manager' => [ /* ... */ ], 'controllers' => [ /* ... */ ], 'router' => [ 'routes' => [ 'blog' => [ 'type' => Literal::class, 'options' => [ 'route' => '/blog', 'defaults' => [ 'controller' => Controller\\ListController::class, 'action' => 'index', ], ], 'may_terminate' => true, 'child_routes' => [ 'detail' => [ 'type' => Segment::class, 'options' => [ 'route' => '/:id', 'defaults' => [ 'action' => 'detail', ], 'constraints' => [ 'id' => '[1-9]\\d*', ], ], ], ], ], ], ], 'view_manager' => [ /* ... */ ], ]; With this we have set up a new route that we use to display a single blog entry. The route defines a parameter, id , which needs to be a sequence of 1 or more positive digits, not beginning with 0. The route will call the same controller as the parent route, but using the detailAction() method instead. Go to your browser and request the URL http://localhost:8080/blog/2 ; you'll see the following error message: A 404 error occurred Page not found. The requested controller was unable to dispatch the request. Controller: Blog\\Controller\\ListController No Exception available This is due to the fact that the controller tries to access the detailAction() , which does not yet exist. We'll create this action now; go to your ListController and add the following action, which will return an empty view model // In module/Blog/src/Controller/ListController.php: /* .. */ class ListController extends AbstractActionController { /* ... */ public function detailAction() { return new ViewModel(); } } Refresh your browser, which should result in the familiar message that a template was unable to be rendered. Let's create this template now and assume that we will get a Post instance passed to the template to see the details of our blog. Create a new view file under module/Blog/view/blog/list/detail.phtml : <h1>Post Details</h1> <dl> <dt>Post Title</dt> <dd><?= $this->escapeHtml($this->post->getTitle()) ?></dd> <dt>Post Text</dt> <dd><?= $this->escapeHtml($this->post->getText()) ?></dd> </dl> The above template is expecting a $post variable referencing a Post instance in the view model. We'll now update the ListController to provide that: public function detailAction() { $id = $this->params()->fromRoute('id'); return new ViewModel([ 'post' => $this->postRepository->findPost($id), ]); } If you refresh your application now, you'll see the details for our Post are displayed. However, there is one problem with what we have done: while we have our repository set up to throw an InvalidArgumentException when no post is found matching a given identifier, we do not check for it in our controller. Go to your browser and open the URL http://localhost:8080/blog/99 ; you will see the following error message: An error occurred An error occurred during execution; please try again later. Additional information: InvalidArgumentException File: {projectPath}/module/Blog/src/Model/LaminasDbSqlRepository.php:{lineNumber} Message: Blog post with identifier \"99\" not found. This is kind of ugly, so our ListController should be prepared to do something whenever an InvalidArgumentException is thrown by the PostService . Let's have the controller redirect to the blog post overview. First, add a new import to the ListController class file: use InvalidArgumentException; Now add the following try-catch statement to the detailAction() method: public function detailAction() { $id = $this->params()->fromRoute('id'); try { $post = $this->postRepository->findPost($id); } catch (\\InvalidArgumentException $ex) { return $this->redirect()->toRoute('blog'); } return new ViewModel([ 'post' => $post, ]); } Now whenever a user requests an invalid identifier, you'll be redirected to the route blog , which is our list of blog posts!","title":"Understanding the Router"},{"location":"in-depth-guide/understanding-routing/#understanding-the-router","text":"Our module is coming along nicely. However, we're not really doing all that much yet; to be precise, all we do is display all blog entries on one page. In this chapter, you will learn everything you need to know about the Router in order to route to controllers and actions for displaying a single blog post, adding a new blog post, editing an existing post, and deleting a post.","title":"Understanding the Router"},{"location":"in-depth-guide/understanding-routing/#different-route-types","text":"Before we go into details on our application, let's take a look at the most often used route types.","title":"Different route types"},{"location":"in-depth-guide/understanding-routing/#different-routing-concepts","text":"When thinking about an entire application, you'll quickly realize that you may have many, many routes to define. When writing these routes you have two options: Spend less time writing routes that in turn are a little slow in matching. Write very explicit routes that match faster, but require more work to define.","title":"Different routing concepts"},{"location":"in-depth-guide/understanding-routing/#a-practical-example-for-our-blog-module","text":"Now that we know how to configure routes, let's first create a route to display only a single blog entry based on internal identifier. Given that ID is a variable parameter, we need a segment route. Furthermore, we know that the route will also match against the same /blog path prefix, so we can define it as a child route of our existing route. Let's update our configuration: // In module/Blog/config/module.config.php: namespace Blog; use Laminas\\Router\\Http\\Literal; use Laminas\\Router\\Http\\Segment; use Laminas\\ServiceManager\\Factory\\InvokableFactory; return [ 'service_manager' => [ /* ... */ ], 'controllers' => [ /* ... */ ], 'router' => [ 'routes' => [ 'blog' => [ 'type' => Literal::class, 'options' => [ 'route' => '/blog', 'defaults' => [ 'controller' => Controller\\ListController::class, 'action' => 'index', ], ], 'may_terminate' => true, 'child_routes' => [ 'detail' => [ 'type' => Segment::class, 'options' => [ 'route' => '/:id', 'defaults' => [ 'action' => 'detail', ], 'constraints' => [ 'id' => '[1-9]\\d*', ], ], ], ], ], ], ], 'view_manager' => [ /* ... */ ], ]; With this we have set up a new route that we use to display a single blog entry. The route defines a parameter, id , which needs to be a sequence of 1 or more positive digits, not beginning with 0. The route will call the same controller as the parent route, but using the detailAction() method instead. Go to your browser and request the URL http://localhost:8080/blog/2 ; you'll see the following error message: A 404 error occurred Page not found. The requested controller was unable to dispatch the request. Controller: Blog\\Controller\\ListController No Exception available This is due to the fact that the controller tries to access the detailAction() , which does not yet exist. We'll create this action now; go to your ListController and add the following action, which will return an empty view model // In module/Blog/src/Controller/ListController.php: /* .. */ class ListController extends AbstractActionController { /* ... */ public function detailAction() { return new ViewModel(); } } Refresh your browser, which should result in the familiar message that a template was unable to be rendered. Let's create this template now and assume that we will get a Post instance passed to the template to see the details of our blog. Create a new view file under module/Blog/view/blog/list/detail.phtml : <h1>Post Details</h1> <dl> <dt>Post Title</dt> <dd><?= $this->escapeHtml($this->post->getTitle()) ?></dd> <dt>Post Text</dt> <dd><?= $this->escapeHtml($this->post->getText()) ?></dd> </dl> The above template is expecting a $post variable referencing a Post instance in the view model. We'll now update the ListController to provide that: public function detailAction() { $id = $this->params()->fromRoute('id'); return new ViewModel([ 'post' => $this->postRepository->findPost($id), ]); } If you refresh your application now, you'll see the details for our Post are displayed. However, there is one problem with what we have done: while we have our repository set up to throw an InvalidArgumentException when no post is found matching a given identifier, we do not check for it in our controller. Go to your browser and open the URL http://localhost:8080/blog/99 ; you will see the following error message: An error occurred An error occurred during execution; please try again later. Additional information: InvalidArgumentException File: {projectPath}/module/Blog/src/Model/LaminasDbSqlRepository.php:{lineNumber} Message: Blog post with identifier \"99\" not found. This is kind of ugly, so our ListController should be prepared to do something whenever an InvalidArgumentException is thrown by the PostService . Let's have the controller redirect to the blog post overview. First, add a new import to the ListController class file: use InvalidArgumentException; Now add the following try-catch statement to the detailAction() method: public function detailAction() { $id = $this->params()->fromRoute('id'); try { $post = $this->postRepository->findPost($id); } catch (\\InvalidArgumentException $ex) { return $this->redirect()->toRoute('blog'); } return new ViewModel([ 'post' => $post, ]); } Now whenever a user requests an invalid identifier, you'll be redirected to the route blog , which is our list of blog posts!","title":"A practical example for our blog module"},{"location":"migration/to-v3/application/","text":"Upgrading Applications If you have an existing Laminas v2 application, and want to update it to the latest versions, you will have some special considerations. Upgrading Laminas Since the 2.5 release, the laminas package has been essentially a \"metapackage\", defining no code, and only dependencies on the various component packages. This means that when you install laminas/laminas , you get the full set of components, at the latest 2.* versions. With the release of version 3, we recommend: Removing the laminas/laminas package. Installing the laminas/laminas-component-installer package. Installing the laminas/laminas-mvc package. Installing each Laminas component package you actually use in your application. The process would look like this: $ composer remove laminas/laminas $ composer require laminas/laminas-component-installer $ composer require laminas/laminas-mvc # Repeat as necessary for components you use if not already installed When you install laminas-mvc, it will prompt you to add configuration for components; choose either application.config.php or modules.config.php , and re-use your selection for all other packages. This step ensures that the various components installed, and any news ones you add later, are configured in your application correctly. This approach will ensure you are only installing what you actually need. As an example, if you are not using laminas-barcode, or laminas-permissions-acl, or laminas-mail, there's no reason to install them. Keeping the laminas package If you want to upgrade quickly, and cannot easily determine which components you use in your application, you can upgrade your laminas requirement. When you do, you should also install the laminas-component-installer, to ensure that component configuration is properly injected in your application. $ composer require laminas/laminas-component-installer \"laminas/laminas:^3.0\" During installation, it will prompt you to add configuration for components; choose either application.config.php or modules.config.php , and re-use your selection for all other packages. This step ensures that the various components installed, and any news ones you add later, are configured in your application correctly. This will upgrade you to the latest releases of all Laminas components at once; it will also install new components developed as part of the version 3 initiative. We still recommend reducing your dependencies at a later date, however. Integration packages During the Laminas initiative, one goal was to reduce the number of dependencies for each package. This affected the MVC in particular, as a number of features were optional or presented deep integrations between the MVC and other components. These include the following: Console tooling If you were using the MVC console tooling, and are doing a partial update per the recommendations, you will need to install laminas-mvc-console . Forms integration If you were using the forms in your MVC application, and are doing a partial update per the recommendations, you will need to install laminas-mvc-form . i18n integration If you were using i18n features in your MVC application, and are doing a partial update per the recommendations, you will need to install laminas-mvc-i18n . Plugins If you were using any of the prg() , fileprg() , identity() , or flashMessenger() MVC controller plugins, and are doing a partial update per the recommendations, you will need to install laminas-mvc-plugins . laminas-di integration If you were using the laminas-servicemanager <-> laminas-di integration within your application, you will need to install laminas-servicemanager-di . Autoloading If you are doing a partial upgrade per the above recommendations (vs. upgrading the full laminas package), one change is that laminas-loader is no longer installed by default, nor recommended. Instead, we recommend using Composer for autoloading . As such, you will need to setup autoloading rules for each module specific to your application. As an example, if you are still defining the default Application module, you can add autoloading for it as follows in your project's composer.json : \"autoload\": { \"psr-4\": { \"Application\\\\\": \"module/Application/src/Application/\" }, \"files\": [ \"module/Application/Module.php\" ] } The above creates a PSR-4 autoloading rule for the Application module, telling it to look in the module/Application/src/Application/ directory. Since the Application\\Module class is defined at the module root, we specify it in the files configuration. To improve on this, and simplify autoloading, we recommend adopting a complete PSR-4 directory structure for your module class files. As an example, to change the existing Application module to PSR-4, you can do the following: $ cd module/Application $ mv src temp $ mv temp/Application src $ rm -Rf ./temp $ mv Module.php src/ Update your Module.php file to do the following: Remove the getAutoloaderConfig() method entirely, if defined. Update the getConfig() method from include __DIR__ . '/config/module.config.php to include _DIR__ . '/../config/module.config.php . You can then update the autoload configuration to: \"autoload\": { \"psr-4\": { \"Application\\\\\": \"module/Application/src/\" } } Afterwards, run the following to update the generated autoloader: $ composer dump-autoload The updated application skeleton already takes this approach. Bootstrap Because version 3 requires usage of Composer for autoloading, you can simplify your application bootstrap. First, if you were using an init_autoloader.php file, you can now remove it. Second, update your public/index.php to read as follows: <?php use Laminas\\Mvc\\Application; /** * This makes our life easier when dealing with paths. Everything is relative * to the application root now. */ chdir(dirname(__DIR__)); // Decline static file requests back to the PHP built-in webserver if (php_sapi_name() === 'cli-server') { $path = realpath(__DIR__ . parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH)); if (__FILE__ !== $path && is_file($path)) { return false; } unset($path); } // Composer autoloading include __DIR__ . '/../vendor/autoload.php'; if (! class_exists(Application::class)) { throw new RuntimeException( \"Unable to load application.\\n\" . \"- Type `composer install` if you are developing locally.\\n\" ); } // Run the application! Application::init(require __DIR__ . '/../config/application.config.php')->run(); Scripts The skeleton application for version 2 shipped three scripts with it: bin/classmap_generator.php bin/pluginmap_generator.php bin/templatemap_generator.php If you are upgrading an existing application, these will still be present. However, if you are starting a new application, and used these previously, they are no longer present. classmap_generator.php was removed as it's unnecessary when using Composer for autoloading. When preparing a production installation, run composer dump-autoload -o and/or composer dump-autoload -a ; both will generate optimized class map autoloading rules for you. pluginmap_generator.php was essentially obsolete due to the presence of classmap_generator.php anyways. templatemap_generator.php was moved to the laminas-view component with the 2.8.0 release of that component, and is now available via ./vendor/bin/templatemap_generator.php . Additionally, its usage signature has changed; please use the --help or -h switches on first invocation to discover how to use it. Development mode Version 3 of the skeleton application adds a requirement on laminas/laminas-development-mode , which provides a way to store common development-specific settings in your repository and then selectively enable/disable them during development. If you are upgrading from an existing application, you can install this feature: $ composer require laminas/laminas-development-mode Please refer to the package documentation for details on how to setup your application configuration to make use of this feature.","title":"Applications"},{"location":"migration/to-v3/application/#upgrading-applications","text":"If you have an existing Laminas v2 application, and want to update it to the latest versions, you will have some special considerations.","title":"Upgrading Applications"},{"location":"migration/to-v3/application/#upgrading-laminas","text":"Since the 2.5 release, the laminas package has been essentially a \"metapackage\", defining no code, and only dependencies on the various component packages. This means that when you install laminas/laminas , you get the full set of components, at the latest 2.* versions. With the release of version 3, we recommend: Removing the laminas/laminas package. Installing the laminas/laminas-component-installer package. Installing the laminas/laminas-mvc package. Installing each Laminas component package you actually use in your application. The process would look like this: $ composer remove laminas/laminas $ composer require laminas/laminas-component-installer $ composer require laminas/laminas-mvc # Repeat as necessary for components you use if not already installed When you install laminas-mvc, it will prompt you to add configuration for components; choose either application.config.php or modules.config.php , and re-use your selection for all other packages. This step ensures that the various components installed, and any news ones you add later, are configured in your application correctly. This approach will ensure you are only installing what you actually need. As an example, if you are not using laminas-barcode, or laminas-permissions-acl, or laminas-mail, there's no reason to install them.","title":"Upgrading Laminas"},{"location":"migration/to-v3/application/#integration-packages","text":"During the Laminas initiative, one goal was to reduce the number of dependencies for each package. This affected the MVC in particular, as a number of features were optional or presented deep integrations between the MVC and other components. These include the following:","title":"Integration packages"},{"location":"migration/to-v3/application/#autoloading","text":"If you are doing a partial upgrade per the above recommendations (vs. upgrading the full laminas package), one change is that laminas-loader is no longer installed by default, nor recommended. Instead, we recommend using Composer for autoloading . As such, you will need to setup autoloading rules for each module specific to your application. As an example, if you are still defining the default Application module, you can add autoloading for it as follows in your project's composer.json : \"autoload\": { \"psr-4\": { \"Application\\\\\": \"module/Application/src/Application/\" }, \"files\": [ \"module/Application/Module.php\" ] } The above creates a PSR-4 autoloading rule for the Application module, telling it to look in the module/Application/src/Application/ directory. Since the Application\\Module class is defined at the module root, we specify it in the files configuration. To improve on this, and simplify autoloading, we recommend adopting a complete PSR-4 directory structure for your module class files. As an example, to change the existing Application module to PSR-4, you can do the following: $ cd module/Application $ mv src temp $ mv temp/Application src $ rm -Rf ./temp $ mv Module.php src/ Update your Module.php file to do the following: Remove the getAutoloaderConfig() method entirely, if defined. Update the getConfig() method from include __DIR__ . '/config/module.config.php to include _DIR__ . '/../config/module.config.php . You can then update the autoload configuration to: \"autoload\": { \"psr-4\": { \"Application\\\\\": \"module/Application/src/\" } } Afterwards, run the following to update the generated autoloader: $ composer dump-autoload The updated application skeleton already takes this approach.","title":"Autoloading"},{"location":"migration/to-v3/application/#bootstrap","text":"Because version 3 requires usage of Composer for autoloading, you can simplify your application bootstrap. First, if you were using an init_autoloader.php file, you can now remove it. Second, update your public/index.php to read as follows: <?php use Laminas\\Mvc\\Application; /** * This makes our life easier when dealing with paths. Everything is relative * to the application root now. */ chdir(dirname(__DIR__)); // Decline static file requests back to the PHP built-in webserver if (php_sapi_name() === 'cli-server') { $path = realpath(__DIR__ . parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH)); if (__FILE__ !== $path && is_file($path)) { return false; } unset($path); } // Composer autoloading include __DIR__ . '/../vendor/autoload.php'; if (! class_exists(Application::class)) { throw new RuntimeException( \"Unable to load application.\\n\" . \"- Type `composer install` if you are developing locally.\\n\" ); } // Run the application! Application::init(require __DIR__ . '/../config/application.config.php')->run();","title":"Bootstrap"},{"location":"migration/to-v3/application/#scripts","text":"The skeleton application for version 2 shipped three scripts with it: bin/classmap_generator.php bin/pluginmap_generator.php bin/templatemap_generator.php If you are upgrading an existing application, these will still be present. However, if you are starting a new application, and used these previously, they are no longer present. classmap_generator.php was removed as it's unnecessary when using Composer for autoloading. When preparing a production installation, run composer dump-autoload -o and/or composer dump-autoload -a ; both will generate optimized class map autoloading rules for you. pluginmap_generator.php was essentially obsolete due to the presence of classmap_generator.php anyways. templatemap_generator.php was moved to the laminas-view component with the 2.8.0 release of that component, and is now available via ./vendor/bin/templatemap_generator.php . Additionally, its usage signature has changed; please use the --help or -h switches on first invocation to discover how to use it.","title":"Scripts"},{"location":"migration/to-v3/application/#development-mode","text":"Version 3 of the skeleton application adds a requirement on laminas/laminas-development-mode , which provides a way to store common development-specific settings in your repository and then selectively enable/disable them during development. If you are upgrading from an existing application, you can install this feature: $ composer require laminas/laminas-development-mode Please refer to the package documentation for details on how to setup your application configuration to make use of this feature.","title":"Development mode"},{"location":"migration/to-v3/components/","text":"Component migration documentation The following is a list of migration documents for components we ship. laminas-code laminas-eventmanager laminas-hydrator laminas-json laminas-math laminas-mvc laminas-mvc-console (for migrating MVC-based console functionality) laminas-mvc-i18n (for migrating MVC-based console functionality) laminas-router (for migrating MVC-based router functionality) laminas-servicemanager laminas-servicemanager-di (for migrating laminas-servicemanager <-> laminas-di integration) laminas-stdlib","title":"Components"},{"location":"migration/to-v3/components/#component-migration-documentation","text":"The following is a list of migration documents for components we ship. laminas-code laminas-eventmanager laminas-hydrator laminas-json laminas-math laminas-mvc laminas-mvc-console (for migrating MVC-based console functionality) laminas-mvc-i18n (for migrating MVC-based console functionality) laminas-router (for migrating MVC-based router functionality) laminas-servicemanager laminas-servicemanager-di (for migrating laminas-servicemanager <-> laminas-di integration) laminas-stdlib","title":"Component migration documentation"},{"location":"migration/to-v3/overview/","text":"Migration from Laminas v2 to v3 Laminas v2 to v3 has been intended as an incremental upgrade. We have even made efforts in the past year to provide forwards compatibility features in v2 versions of components, to allow users to prepare their code for upgrade. This is not a comprehensive migration guide, however. While we know the majority of the areas where breakage can and will occur, we also know that only when developers are actually updating will we see the full situation. As such, treat this as a work in progress, and please feel free to propose updates or changes via issues or pull requests so we can improve!","title":"Overview"},{"location":"migration/to-v3/overview/#migration-from-laminas-v2-to-v3","text":"Laminas v2 to v3 has been intended as an incremental upgrade. We have even made efforts in the past year to provide forwards compatibility features in v2 versions of components, to allow users to prepare their code for upgrade. This is not a comprehensive migration guide, however. While we know the majority of the areas where breakage can and will occur, we also know that only when developers are actually updating will we see the full situation. As such, treat this as a work in progress, and please feel free to propose updates or changes via issues or pull requests so we can improve!","title":"Migration from Laminas v2 to v3"}]}
\ No newline at end of file
+{"config":{"indexing":"full","lang":["en"],"min_search_length":3,"prebuild_index":false,"separator":"[\\s\\-]+"},"docs":[{"location":"","text":"MVC Tutorials The following tutorials will guide you through creating your first laminas-mvc application, testing it, and adding features to it. The \"In-Depth\" tutorial dives into some more advanced details of how the MVC works, along with strategies for developing models and achieving separation of concerns. Getting Started with Laminas Extensions for Album Module Unit Testing a laminas-mvc Application Adding laminas-navigation to the Album Module Adding laminas-paginator to the Album Module Internationalization and Configuration Internationalization Advanced Configuration Tricks In-Depth Tutorial The in-dept tutorial includes a more complex example. It combines best-practices and interesting features, like a repository, SQL abstraction and the form element manager. In-Depth Tutorial Application Integrations The following tutorials show possible integrations of components within laminas-mvc based applications. Adding laminas-cache to a laminas-mvc Application Adding laminas-eventmanager to a laminas-mvc Application Adding laminas-form to a laminas-mvc Application Adding laminas-inputfilter to a laminas-mvc Application Adding laminas-session to a laminas-mvc Application Component Tutorials The following are focused tutorials on specific components. Setting up a Database Adapter Using the EventManager Migrating to Version 3 Overview Components Applications","title":"Home"},{"location":"#mvc-tutorials","text":"The following tutorials will guide you through creating your first laminas-mvc application, testing it, and adding features to it. The \"In-Depth\" tutorial dives into some more advanced details of how the MVC works, along with strategies for developing models and achieving separation of concerns. Getting Started with Laminas","title":"MVC Tutorials"},{"location":"#component-tutorials","text":"The following are focused tutorials on specific components. Setting up a Database Adapter Using the EventManager","title":"Component Tutorials"},{"location":"#migrating-to-version-3","text":"Overview Components Applications","title":"Migrating to Version 3"},{"location":"advanced-config/","text":"Advanced Configuration Tricks Configuration of laminas-mvc applications happens in several steps: Initial configuration is passed to the Application instance and used to seed the ModuleManager and ServiceManager . In this tutorial, we will call this configuration system configuration . The ModuleManager 's ConfigListener aggregates configuration and merges it while modules are being loaded. In this tutorial, we will call this configuration application configuration . Once configuration is aggregated from all modules, the ConfigListener will also merge application configuration globbed in specified directories (typically config/autoload/ ). Finally, immediately prior to the merged application configuration being passed to the ServiceManager , it is passed to a special EVENT_MERGE_CONFIG event to allow further modification. In this tutorial, we'll look at the exact sequence, and how you can tie into it. System configuration To begin module loading, we have to tell the Application instance about the available modules and where they live, optionally provide some information to the default module listeners (e.g., where application configuration lives, and what files to load; whether to cache merged configuration, and where; etc.), and optionally seed the ServiceManager . For purposes of this tutorial we will call this the system configuration . When using the skeleton application, the system configuration is by default in config/application.config.php . The defaults look like this: return [ // Retrieve list of modules used in this application. 'modules' => require __DIR__ . '/modules.config.php', // These are various options for the listeners attached to the ModuleManager 'module_listener_options' => [ // use composer autoloader instead of laminas-loader 'use_laminas_loader' => false, // An array of paths from which to glob configuration files after // modules are loaded. These effectively override configuration // provided by modules themselves. Paths may use GLOB_BRACE notation. 'config_glob_paths' => [ realpath(__DIR__) . '/autoload/{{,*.}global,{,*.}local}.php', ], // Whether or not to enable a configuration cache. // If enabled, the merged configuration will be cached and used in // subsequent requests. 'config_cache_enabled' => true, // The key used to create the configuration cache file name. 'config_cache_key' => 'application.config.cache', // Whether or not to enable a module class map cache. // If enabled, creates a module class map cache which will be used // by in future requests, to reduce the autoloading process. 'module_map_cache_enabled' => true, // The key used to create the class map cache file name. 'module_map_cache_key' => 'application.module.cache', // The path in which to cache merged configuration. 'cache_dir' => 'data/cache/', // Whether or not to enable modules dependency checking. // Enabled by default, prevents usage of modules that depend on other modules // that weren't loaded. // 'check_dependencies' => true, ], // Used to create an own service manager. May contain one or more child arrays. // 'service_listener_options' => [ // [ // 'service_manager' => $stringServiceManagerName, // 'config_key' => $stringConfigKey, // 'interface' => $stringOptionalInterface, // 'method' => $stringRequiredMethodName, // ], // ], // Initial configuration with which to seed the ServiceManager. // Should be compatible with Laminas\\ServiceManager\\Config. // 'service_manager' => [], ]; The system configuration is for the bits and pieces related to the MVC that run before your application is ready. The configuration is usually brief, and quite minimal. Also, system configuration is used immediately , and is not merged with any other configuration — which means, with the exception of the values under the service_manager key, it cannot be overridden by a module. This leads us to our first trick: how do you provide environment-specific system configuration? Environment-specific system configuration What happens when you want to change the set of modules you use based on the environment? Or if the configuration caching should be enabled based on environment? It is for this reason that the default system configuration we provide in the skeleton application is in PHP; providing it in PHP means you can programmatically manipulate it. As an example, let's make the following requirements: We want to use the Laminas\\\\DeveloperTools module in development only. We want to have configuration caching on in production only. laminas/laminas-development-mode provides a concise and conventions-based approach to switching between specifically production and development. The package is installed by default with version 3+ skeletons, and can be installed with existing v2 skeletons using the following: $ composer require laminas/laminas-development-mode The approach it takes is as follows: The user provides production settings in config/application.config.php . The user provides development settings in config/development.config.php.dist to override bootstrap-level settings such as modules and configuration caching, and optionally also in config/autoload/development.local.php.dist (to override application settings). The bootstrap script ( public/index.php ) checks for config/development.config.php , and, if found, merges its configuration with the application configuration prior to configuring the Application instance. When you execute: $ ./vendor/bin/laminas-development-mode enable The .dist files are copied to versions removing the suffix; doing so ensures they will then be used when invoking the application. As such, to accomplish our goals, we will do the following: In config/development.config.php.dist , add Laminas\\\\DeveloperTools to the list of modules: 'modules' => [ 'LaminasDeveloperTools', ], Also in config/development.config.php.dist , we will disable config caching: 'config_cache_enable' => false, In config/application.config.php , we will enable config caching: 'config_cache_enable' => true, Enabling development mode now enables the selected module, and disables configuration caching; disabling development mode enables configuration caching. (Also, either operation clears the configuration cache.) If you require additional environments, you can extend laminas-development-mode to address them using the same workflow. Environment-specific application configuration Sometimes you want to change application configuration to load things such as database adapters, log writers, cache adapters, and more based on the environment. These are typically managed in the service manager, and may be defined by modules. You can override them at the application level via Laminas\\ModuleManager\\Listener\\ConfigListener , by specifying a glob path in the system configuration — the module_listener_options.config_glob_paths key from the previous examples. The default value for this is config/autoload/{{,*.}global,{,*.}local}.php . What this means is that it will look for application configuration files in the config/autoload directory, in the following order: global.php *.global.php local.php *.local.php This allows you to define application-level defaults in \"global\" configuration files, which you would then commit to your version control system, and environment-specific overrides in your \"local\" configuration files, which you would omit from version control. Additional glob patterns for development mode When using laminas-development-mode, as detailed in the previous section, the shipped config/development.config.php.dist file provides an additional glob pattern for specifying development configuration: config/autoload/{,*.}{global,local}-development.php This will match files such as: database.global-development.php database.local-development.php These will only be considered when development mode is enabled! This is a great solution for development, as it allows you to specify alternate configuration that's specific to your development environment without worrying about accidently deploying it. However, what if you have more environments — such as a \"testing\" or \"staging\" environment — and they each have their own specific overrides? To accomplish this, we'll provide an environment variable via our web server configuration, APP_ENV . In Apache, you'd put a directive like the following in either your system-wide apache.conf or httpd.conf, or in the definition for your virtual host; alternately, it can be placed in an .htaccess file. SetEnv \"APP_ENV\" \"development\" For other web servers, consult the web server documentation to determine how to set environment variables. To simplify matters, we'll assume the environment is \"production\" if no environment variable is present. With that in place, We can alter the glob path in the system configuration slightly: 'config_glob_paths' => [ realpath(__DIR__) . sprintf('/autoload/{,*.}{global,%s,local}.php', getenv('APP_ENV') ?: 'production') ], The above will allow you to define an additional set of application configuration files per environment; furthermore, these will be loaded only if that environment is detected! As an example, consider the following tree of configuration files: config/ autoload/ global.php local.php users.development.php users.testing.php users.local.php If $env evaluates to testing , then the following files will be merged, in the following order: global.php users.testing.php local.php users.local.php Note that users.development.php is not loaded — this is because it will not match the glob pattern! Also, because of the order in which they are loaded, you can predict which values will overwrite the others, allowing you to both selectively overwrite as well as debug later. Order of config merging The files under config/autoload/ are merged after your module configuration, detailed in next section. We have detailed it here, however, as setting up the application configuration glob path happens within the system configuration ( config/application.config.php ). Module Configuration One responsibility of modules is to provide their own configuration to the application. Modules have two general mechanisms for doing this. First , modules that either implement Laminas\\ModuleManager\\Feature\\ConfigProviderInterface and/or a getConfig() method can return their configuration. The default, recommended implementation of the getConfig() method is: public function getConfig() { return include __DIR__ . '/config/module.config.php'; } where module.config.php returns a PHP array. From that PHP array you can provide general configuration as well as configuration for all the available Manager classes provided by the ServiceManager. Please refer to the Configuration mapping table to see which configuration key is used for each specific Manager . Second , modules can implement a number of interfaces and/or methods related to specific service manager or plugin manager configuration. You will find an overview of all interfaces and their matching Module Configuration functions inside the Configuration mapping table . Most interfaces are in the Laminas\\ModuleManager\\Feature namespace (some have moved to the individual components), and each is expected to return an array of configuration for a service manager, as denoted in the section on default service configuration . Configuration mapping table Manager name Interface name Module method name Config key name ControllerPluginManager ControllerPluginProviderInterface getControllerPluginConfig() controller_plugins ControllerManager ControllerProviderInterface getControllerConfig() controllers FilterManager FilterProviderInterface getFilterConfig() filters FormElementManager FormElementProviderInterface getFormElementConfig() form_elements HydratorManager HydratorProviderInterface getHydratorConfig() hydrators InputFilterManager InputFilterProviderInterface getInputFilterConfig() input_filters RoutePluginManager RouteProviderInterface getRouteConfig() route_manager SerializerAdapterManager SerializerProviderInterface getSerializerConfig() serializers ServiceLocator ServiceProviderInterface getServiceConfig() service_manager ValidatorManager ValidatorProviderInterface getValidatorConfig() validators ViewHelperManager ViewHelperProviderInterface getViewHelperConfig() view_helpers LogProcessorManager LogProcessorProviderInterface getLogProcessorConfig log_processors LogWriterManager LogWriterProviderInterface getLogWriterConfig log_writers Configuration Priority Considering that you may have service configuration in your module configuration file, what has precedence? The order in which they are merged is: configuration returned by the various service configuration methods in a module class configuration returned by getConfig() In other words, your getConfig() wins over the various service configuration methods. Additionally, and of particular note: the configuration returned from those methods will not be cached. Use cases for service configuration methods Use the various service configuration methods when you need to define closures or instance callbacks for factories, abstract factories, and initializers. This prevents caching problems, and also allows you to write your configuration files in other markup formats. Manipulating merged configuration Occasionally you will want to not just override an application configuration key, but actually remove it. Since merging will not remove keys, how can you handle this? Laminas\\ModuleManager\\Listener\\ConfigListener triggers a special event, Laminas\\ModuleManager\\ModuleEvent::EVENT_MERGE_CONFIG , after merging all configuration, but prior to it being passed to the ServiceManager . By listening to this event, you can inspect the merged configuration and manipulate it. The ConfigListener itself listens to the event at priority 1000 (i.e., very high), which is when the configuration is merged. You can tie into this to modify the merged configuration from your module, via the init() method. namespace Foo; use Laminas\\ModuleManager\\ModuleEvent; use Laminas\\ModuleManager\\ModuleManager; class Module { public function init(ModuleManager $moduleManager) { $events = $moduleManager->getEventManager(); // Registering a listener at default priority, 1, which will trigger // after the ConfigListener merges config. $events->attach(ModuleEvent::EVENT_MERGE_CONFIG, [$this, 'onMergeConfig']); } public function onMergeConfig(ModuleEvent $e) { $configListener = $e->getConfigListener(); $config = $configListener->getMergedConfig(false); // Modify the configuration; here, we'll remove a specific key: if (isset($config['some_key'])) { unset($config['some_key']); } // Pass the changed configuration back to the listener: $configListener->setMergedConfig($config); } } At this point, the merged application configuration will no longer contain the key some_key . Cached configuration and merging If a cached config is used by the ModuleManager , the EVENT_MERGE_CONFIG event will not be triggered. However, typically that means that what is cached will be what was originally manipulated by your listener. Configuration merging workflow To cap off the tutorial, let's review how and when configuration is defined and merged. System configuration Defined in config/application.config.php No merging occurs Allows manipulation programmatically, which allows the ability to: Alter flags based on computed values Alter the configuration glob path based on computed values Configuration is passed to the Application instance, and then the ModuleManager in order to initialize the system. Application configuration The ModuleManager loops through each module class in the order defined in the system configuration Service configuration defined in Module class methods is aggregated Configuration returned by Module::getConfig() is aggregated Files detected from the service configuration config_glob_paths setting are merged, based on the order they resolve in the glob path. ConfigListener triggers EVENT_MERGE_CONFIG : ConfigListener merges configuration Any other event listeners manipulate the configuration Merged configuration is finally passed to the ServiceManager","title":"Advanced Configuration Tricks"},{"location":"advanced-config/#advanced-configuration-tricks","text":"Configuration of laminas-mvc applications happens in several steps: Initial configuration is passed to the Application instance and used to seed the ModuleManager and ServiceManager . In this tutorial, we will call this configuration system configuration . The ModuleManager 's ConfigListener aggregates configuration and merges it while modules are being loaded. In this tutorial, we will call this configuration application configuration . Once configuration is aggregated from all modules, the ConfigListener will also merge application configuration globbed in specified directories (typically config/autoload/ ). Finally, immediately prior to the merged application configuration being passed to the ServiceManager , it is passed to a special EVENT_MERGE_CONFIG event to allow further modification. In this tutorial, we'll look at the exact sequence, and how you can tie into it.","title":"Advanced Configuration Tricks"},{"location":"advanced-config/#system-configuration","text":"To begin module loading, we have to tell the Application instance about the available modules and where they live, optionally provide some information to the default module listeners (e.g., where application configuration lives, and what files to load; whether to cache merged configuration, and where; etc.), and optionally seed the ServiceManager . For purposes of this tutorial we will call this the system configuration . When using the skeleton application, the system configuration is by default in config/application.config.php . The defaults look like this: return [ // Retrieve list of modules used in this application. 'modules' => require __DIR__ . '/modules.config.php', // These are various options for the listeners attached to the ModuleManager 'module_listener_options' => [ // use composer autoloader instead of laminas-loader 'use_laminas_loader' => false, // An array of paths from which to glob configuration files after // modules are loaded. These effectively override configuration // provided by modules themselves. Paths may use GLOB_BRACE notation. 'config_glob_paths' => [ realpath(__DIR__) . '/autoload/{{,*.}global,{,*.}local}.php', ], // Whether or not to enable a configuration cache. // If enabled, the merged configuration will be cached and used in // subsequent requests. 'config_cache_enabled' => true, // The key used to create the configuration cache file name. 'config_cache_key' => 'application.config.cache', // Whether or not to enable a module class map cache. // If enabled, creates a module class map cache which will be used // by in future requests, to reduce the autoloading process. 'module_map_cache_enabled' => true, // The key used to create the class map cache file name. 'module_map_cache_key' => 'application.module.cache', // The path in which to cache merged configuration. 'cache_dir' => 'data/cache/', // Whether or not to enable modules dependency checking. // Enabled by default, prevents usage of modules that depend on other modules // that weren't loaded. // 'check_dependencies' => true, ], // Used to create an own service manager. May contain one or more child arrays. // 'service_listener_options' => [ // [ // 'service_manager' => $stringServiceManagerName, // 'config_key' => $stringConfigKey, // 'interface' => $stringOptionalInterface, // 'method' => $stringRequiredMethodName, // ], // ], // Initial configuration with which to seed the ServiceManager. // Should be compatible with Laminas\\ServiceManager\\Config. // 'service_manager' => [], ]; The system configuration is for the bits and pieces related to the MVC that run before your application is ready. The configuration is usually brief, and quite minimal. Also, system configuration is used immediately , and is not merged with any other configuration — which means, with the exception of the values under the service_manager key, it cannot be overridden by a module. This leads us to our first trick: how do you provide environment-specific system configuration?","title":"System configuration"},{"location":"advanced-config/#module-configuration","text":"One responsibility of modules is to provide their own configuration to the application. Modules have two general mechanisms for doing this. First , modules that either implement Laminas\\ModuleManager\\Feature\\ConfigProviderInterface and/or a getConfig() method can return their configuration. The default, recommended implementation of the getConfig() method is: public function getConfig() { return include __DIR__ . '/config/module.config.php'; } where module.config.php returns a PHP array. From that PHP array you can provide general configuration as well as configuration for all the available Manager classes provided by the ServiceManager. Please refer to the Configuration mapping table to see which configuration key is used for each specific Manager . Second , modules can implement a number of interfaces and/or methods related to specific service manager or plugin manager configuration. You will find an overview of all interfaces and their matching Module Configuration functions inside the Configuration mapping table . Most interfaces are in the Laminas\\ModuleManager\\Feature namespace (some have moved to the individual components), and each is expected to return an array of configuration for a service manager, as denoted in the section on default service configuration .","title":"Module Configuration"},{"location":"advanced-config/#configuration-mapping-table","text":"Manager name Interface name Module method name Config key name ControllerPluginManager ControllerPluginProviderInterface getControllerPluginConfig() controller_plugins ControllerManager ControllerProviderInterface getControllerConfig() controllers FilterManager FilterProviderInterface getFilterConfig() filters FormElementManager FormElementProviderInterface getFormElementConfig() form_elements HydratorManager HydratorProviderInterface getHydratorConfig() hydrators InputFilterManager InputFilterProviderInterface getInputFilterConfig() input_filters RoutePluginManager RouteProviderInterface getRouteConfig() route_manager SerializerAdapterManager SerializerProviderInterface getSerializerConfig() serializers ServiceLocator ServiceProviderInterface getServiceConfig() service_manager ValidatorManager ValidatorProviderInterface getValidatorConfig() validators ViewHelperManager ViewHelperProviderInterface getViewHelperConfig() view_helpers LogProcessorManager LogProcessorProviderInterface getLogProcessorConfig log_processors LogWriterManager LogWriterProviderInterface getLogWriterConfig log_writers","title":"Configuration mapping table"},{"location":"advanced-config/#configuration-priority","text":"Considering that you may have service configuration in your module configuration file, what has precedence? The order in which they are merged is: configuration returned by the various service configuration methods in a module class configuration returned by getConfig() In other words, your getConfig() wins over the various service configuration methods. Additionally, and of particular note: the configuration returned from those methods will not be cached.","title":"Configuration Priority"},{"location":"advanced-config/#manipulating-merged-configuration","text":"Occasionally you will want to not just override an application configuration key, but actually remove it. Since merging will not remove keys, how can you handle this? Laminas\\ModuleManager\\Listener\\ConfigListener triggers a special event, Laminas\\ModuleManager\\ModuleEvent::EVENT_MERGE_CONFIG , after merging all configuration, but prior to it being passed to the ServiceManager . By listening to this event, you can inspect the merged configuration and manipulate it. The ConfigListener itself listens to the event at priority 1000 (i.e., very high), which is when the configuration is merged. You can tie into this to modify the merged configuration from your module, via the init() method. namespace Foo; use Laminas\\ModuleManager\\ModuleEvent; use Laminas\\ModuleManager\\ModuleManager; class Module { public function init(ModuleManager $moduleManager) { $events = $moduleManager->getEventManager(); // Registering a listener at default priority, 1, which will trigger // after the ConfigListener merges config. $events->attach(ModuleEvent::EVENT_MERGE_CONFIG, [$this, 'onMergeConfig']); } public function onMergeConfig(ModuleEvent $e) { $configListener = $e->getConfigListener(); $config = $configListener->getMergedConfig(false); // Modify the configuration; here, we'll remove a specific key: if (isset($config['some_key'])) { unset($config['some_key']); } // Pass the changed configuration back to the listener: $configListener->setMergedConfig($config); } } At this point, the merged application configuration will no longer contain the key some_key .","title":"Manipulating merged configuration"},{"location":"advanced-config/#configuration-merging-workflow","text":"To cap off the tutorial, let's review how and when configuration is defined and merged. System configuration Defined in config/application.config.php No merging occurs Allows manipulation programmatically, which allows the ability to: Alter flags based on computed values Alter the configuration glob path based on computed values Configuration is passed to the Application instance, and then the ModuleManager in order to initialize the system. Application configuration The ModuleManager loops through each module class in the order defined in the system configuration Service configuration defined in Module class methods is aggregated Configuration returned by Module::getConfig() is aggregated Files detected from the service configuration config_glob_paths setting are merged, based on the order they resolve in the glob path. ConfigListener triggers EVENT_MERGE_CONFIG : ConfigListener merges configuration Any other event listeners manipulate the configuration Merged configuration is finally passed to the ServiceManager","title":"Configuration merging workflow"},{"location":"db-adapter/","text":"Setting up a Database Adapter laminas-db provides a general purpose database abstraction layer. At its heart is the Adapter , which abstracts common database operations across the variety of drivers we support. In this guide, we will document how to configure both a single, default adapter as well as multiple adapters (which may be useful in architectures that have a cluster of read-only replicated servers and a single writable server of record). Installing laminas-db First, install laminas-db using Composer: $ composer require laminas/laminas-db Installation and automated Configuration If you are using laminas-component-installer (installed by default with the skeleton application, and optionally for Mezzio applications), you will be prompted to install the package configuration. For laminas-mvc applications, choose either application.config.php or modules.config.php . For Mezzio applications, choose config/config.php . Installation and manual Configuration If you are not using the installer, you will need to manually configure add the component to your application. Configuration for a laminas-mvc-based Application For laminas-mvc applications, update your list of modules in either config/application.config.php or config/modules.config.php to add an entry for 'Laminas\\Db' at the top of the list: // In config/modules.config.php return [ 'Laminas\\Db', // <-- This line 'Laminas\\Form', /* ... */ ]; // OR in config/application.config.php return [ /* ... */ // Retrieve list of modules used in this application. 'modules' => [ 'Laminas\\Db', // <-- This line 'Laminas\\Form', /* ... */ ], /* ... */ ]; Configuration for a mezzio-based Application For Mezzio applications, create a new file, config/autoload/laminas-db.global.php , with the following contents: use Laminas\\Db\\ConfigProvider; return (new ConfigProvider())(); Configuring the default Adapter Within your service factories, you may retrieve the default adapter from your application container using the class name Laminas\\Db\\Adapter\\AdapterInterface : use Laminas\\Db\\Adapter\\AdapterInterface; function ($container) { return new SomeServiceObject($container->get(AdapterInterface::class)); } When installed and configured, the factory associated with AdapterInterface will look for a top-level db key in the configuration, and use it to create an adapter. As an example, the following would connect to a MySQL database using PDO, and the supplied PDO DSN: // In config/autoload/global.php return [ 'db' => [ 'driver' => 'Pdo', 'dsn' => 'mysql:dbname=laminastutorial;host=localhost;charset=utf8', ], ]; More information on adapter configuration can be found in the docs for Laminas\\Db\\Adapter . Configuring named Adapters Sometimes you may need multiple adapters. As an example, if you work with a cluster of databases, one may allow write operations, while another may be read-only. laminas-db provides an abstract factory , Laminas\\Db\\Adapter\\AdapterAbstractServiceFactory , for this purpose. To use it, you will need to create named configuration keys under db.adapters , each with configuration for an adapter: // In config/autoload/global.php return [ 'db' => [ 'adapters' => [ 'Application\\Db\\WriteAdapter' => [ 'driver' => 'Pdo', 'dsn' => 'mysql:dbname=application;host=canonical.example.com;charset=utf8', ], 'Application\\Db\\ReadOnlyAdapter' => [ 'driver' => 'Pdo', 'dsn' => 'mysql:dbname=application;host=replica.example.com;charset=utf8', ], ], ], ]; You retrieve the database adapters using the keys you define, so ensure they are unique to your application, and descriptive of their purpose! Retrieving named Adapters Retrieve named adapters in your service factories just as you would another service: function ($container) { return new SomeServiceObject($container->get('Application\\Db\\ReadOnlyAdapter')); } Using the AdapterAbstractServiceFactory as a Factory Depending on what application container you use, abstract factories may not be available. Alternately, you may want to reduce lookup time when retrieving an adapter from the container (abstract factories are consulted last!). laminas-servicemanager abstract factories work as factories in their own right, and are passed the service name as an argument, allowing them to vary their return value based on requested service name. As such, you can add the following service configuration as well: use Laminas\\Db\\Adapter\\AdapterAbstractServiceFactory; // If using laminas-mvc: // In module/YourModule/config/module.config.php 'service_manager' => [ 'factories' => [ 'Application\\Db\\WriteAdapter' => AdapterAbstractServiceFactory::class, ], ], // If using Mezzio 'dependencies' => [ 'factories' => [ 'Application\\Db\\WriteAdapter' => AdapterAbstractServiceFactory::class, ], ],","title":"Setting Up A Database Adapter"},{"location":"db-adapter/#setting-up-a-database-adapter","text":"laminas-db provides a general purpose database abstraction layer. At its heart is the Adapter , which abstracts common database operations across the variety of drivers we support. In this guide, we will document how to configure both a single, default adapter as well as multiple adapters (which may be useful in architectures that have a cluster of read-only replicated servers and a single writable server of record).","title":"Setting up a Database Adapter"},{"location":"db-adapter/#installing-laminas-db","text":"First, install laminas-db using Composer: $ composer require laminas/laminas-db","title":"Installing laminas-db"},{"location":"db-adapter/#configuring-the-default-adapter","text":"Within your service factories, you may retrieve the default adapter from your application container using the class name Laminas\\Db\\Adapter\\AdapterInterface : use Laminas\\Db\\Adapter\\AdapterInterface; function ($container) { return new SomeServiceObject($container->get(AdapterInterface::class)); } When installed and configured, the factory associated with AdapterInterface will look for a top-level db key in the configuration, and use it to create an adapter. As an example, the following would connect to a MySQL database using PDO, and the supplied PDO DSN: // In config/autoload/global.php return [ 'db' => [ 'driver' => 'Pdo', 'dsn' => 'mysql:dbname=laminastutorial;host=localhost;charset=utf8', ], ]; More information on adapter configuration can be found in the docs for Laminas\\Db\\Adapter .","title":"Configuring the default Adapter"},{"location":"db-adapter/#configuring-named-adapters","text":"Sometimes you may need multiple adapters. As an example, if you work with a cluster of databases, one may allow write operations, while another may be read-only. laminas-db provides an abstract factory , Laminas\\Db\\Adapter\\AdapterAbstractServiceFactory , for this purpose. To use it, you will need to create named configuration keys under db.adapters , each with configuration for an adapter: // In config/autoload/global.php return [ 'db' => [ 'adapters' => [ 'Application\\Db\\WriteAdapter' => [ 'driver' => 'Pdo', 'dsn' => 'mysql:dbname=application;host=canonical.example.com;charset=utf8', ], 'Application\\Db\\ReadOnlyAdapter' => [ 'driver' => 'Pdo', 'dsn' => 'mysql:dbname=application;host=replica.example.com;charset=utf8', ], ], ], ]; You retrieve the database adapters using the keys you define, so ensure they are unique to your application, and descriptive of their purpose!","title":"Configuring named Adapters"},{"location":"event-manager/","text":"Using the EventManager This tutorial explores the features of laminas-eventmanager in-depth. Terminology An Event is a named action. A Listener is any PHP callback that reacts to an event . An EventManager aggregates listeners for one or more named events, and triggers events. Typically, an event will be modeled as an object, containing metadata surrounding when and how it was triggered, including the event name, what object triggered the event (the \"target\"), and what parameters were provided. Events are named , which allows a single listener to branch logic based on the event. Getting started The minimal things necessary to start using events are: An EventManager instance One or more listeners on one or more events A call to trigger() an event A basic example looks something like this: use Laminas\\EventManager\\EventManager; $events = new EventManager(); $events->attach('do', function ($e) { $event = $e->getName(); $params = $e->getParams(); printf( 'Handled event \"%s\", with parameters %s', $event, json_encode($params) ); }); $params = ['foo' => 'bar', 'baz' => 'bat']; $events->trigger('do', null, $params); The above will result in the following: Handled event \"do\", with parameters {\"foo\":\"bar\",\"baz\":\"bat\"} Closures are not required Throughout this tutorial, we use closures as listeners. However, any valid PHP callback can be attached as a listeners: PHP function names, static class methods, object instance methods, function, or closures. We use closures within this post for illustration only. Event instances trigger() is useful as it will create a Laminas\\EventManager\\Event instance for you. You may want to create such an instance manually; for instance, you may want to re-use the same event instance to trigger multiple events, or you may want to use a custom instance. Laminas\\EventManager\\Event , which is the shipped event type and the one used by the EventManager by default has a constructor that accepts the same three arguments passed to trigger() : use Laminas\\EventManager\\Event; $event = new Event('do', null, $params); When you have an instance available, you will use a different EventManager method to trigger the event: triggerEvent() . As an example: $events->triggerEvent($event); Event targets If you were paying attention to the first example, you will have noted the null second argument both when calling trigger() as well as creating an Event instance. Why is it there? Typically, you will compose an EventManager within a class, to allow triggering actions within methods. The middle argument to trigger() is the \"target\", and in the case described, would be the current object instance. This gives event listeners access to the calling object, which can often be useful. use Laminas\\EventManager\\EventManager; use Laminas\\EventManager\\EventManagerAwareInterface; use Laminas\\EventManager\\EventManagerInterface; class Example implements EventManagerAwareInterface { protected $events; public function setEventManager(EventManagerInterface $events) { $events->setIdentifiers([ __CLASS__, get_class($this), ]); $this->events = $events; } public function getEventManager() { if (! $this->events) { $this->setEventManager(new EventManager()); } return $this->events; } public function doIt($foo, $baz) { $params = compact('foo', 'baz'); $this->getEventManager()->trigger(__FUNCTION__, $this, $params); } } $example = new Example(); $example->getEventManager()->attach('doIt', function($e) { $event = $e->getName(); $target = get_class($e->getTarget()); // \"Example\" $params = $e->getParams(); printf( 'Handled event \"%s\" on target \"%s\", with parameters %s', $event, $target, json_encode($params) ); }); $example->doIt('bar', 'bat'); The above is basically the same as the first example. The main difference is that we're now using that middle argument in order to pass the target, the instance of Example , on to the listeners. Our listener is now retrieving that ( $e->getTarget() ), and doing something with it. If you're reading this critically, you should have a new question: What is the call to setIdentifiers() for? Shared managers One aspect that the EventManager implementation provides is an ability to compose a SharedEventManagerInterface implementation. Laminas\\EventManager\\SharedEventManagerInterface describes an object that aggregates listeners for events attached to objects with specific identifiers . It does not trigger events itself. Instead, an EventManager instance that composes a SharedEventManager will query the SharedEventManager for listeners on identifiers it's interested in, and trigger those listeners as well. How does this work, exactly? Consider the following: use Laminas\\EventManager\\SharedEventManager; $sharedEvents = new SharedEventManager(); $sharedEvents->attach('Example', 'do', function ($e) { $event = $e->getName(); $target = get_class($e->getTarget()); // \"Example\" $params = $e->getParams(); printf( 'Handled event \"%s\" on target \"%s\", with parameters %s', $event, $target, json_encode($params) ); }); This looks almost identical to the previous example; the key difference is that there is an additional argument at the start of the list, 'Example' . This code is saying, \"Listen to the 'do' event of the 'Example' target, and, when notified, execute this callback.\" This is where the setIdentifiers() method of EventManager comes into play. The method allows passing an array of strings, defining the names of the context or targets the given instance will be interested in. If an array is given, then any listener on any of the targets given will be notified. So, getting back to our example, let's assume that the above shared listener is registered, and also that the Example class is defined as above. We can then execute the following: $example = new Example(); $example->getEventManager()->setSharedManager($sharedEvents); $example->do('bar', 'bat'); and expect the following to be echo 'd: Handled event \"do\" on target \"Example\", with parameters {\"foo\":\"bar\",\"baz\":\"bat\"} Now, let's say we extended Example as follows: class SubExample extends Example { } One interesting aspect of our setEventManager() method is that we defined it to listen both on __CLASS__ and get_class($this) . This means that calling do() on our SubExample class would also trigger the shared listener! It also means that, if desired, we could attach to specifically SubExample , and listeners attached to only the Example target would not be triggered. Finally, the names used as contexts or targets need not be class names; they can be some name that only has meaning in your application if desired. As an example, you could have a set of classes that respond to \"log\" or \"cache\" — and listeners on these would be notified by any of them. Use class names as identifiers We recommend using class names, interface names, and/or abstract class names for identifiers. This makes determining what events are available easier, as well as finding which listeners might be attaching to those events. Interfaces make a particularly good use case, as they allow attaching to a group of related classes a single operation. At any point, if you do not want to notify shared listeners, pass a null value to setSharedManager() : $events->setSharedManager(null); and they will be ignored. If at any point, you want to enable them again, pass the SharedEventManager instance: $events->setSharedManager($sharedEvents); Wildcards So far, with both a normal EventManager instance and with the SharedEventManager instance, we've seen the usage of string event and string target names to which we want to attach. What if you want to attach a listener to multiple events or targets? The answer is to supply an array of events or targets, or a wildcard, * . Consider the following examples: // Multiple named events: $events->attach( ['foo', 'bar', 'baz'], // events $listener ); // All events via wildcard: $events->attach( '*', // all events $listener ); // Multiple named targets: $sharedEvents->attach( ['Foo', 'Bar', 'Baz'], // targets 'doSomething', // named event $listener ); // All targets via wildcard $sharedEvents->attach( '*', // all targets 'doSomething', // named event $listener ); // Mix and match: multiple named events on multiple named targets: $sharedEvents->attach( ['Foo', 'Bar', 'Baz'], // targets ['foo', 'bar', 'baz'], // events $listener ); // Mix and match: all events on multiple named targets: $sharedEvents->attach( ['Foo', 'Bar', 'Baz'], // targets '*', // events $listener ); // Mix and match: multiple named events on all targets: $sharedEvents->attach( '*', // targets ['foo', 'bar', 'baz'], // events $listener ); // Mix and match: all events on all targets: $sharedEvents->attach( '*', // targets '*', // events $listener ); The ability to specify multiple targets and/or events when attaching can slim down your code immensely. Wildcards can cause problems Wildcards, while they simplify listener attachment, can cause some problems. First, the listener must either be able to accept any incoming event, or it must have logic to branch based on the type of event, the target, or the event parameters. This can quickly become difficult to manage. Additionally, there are performance considerations. Each time an event is triggered, it loops through all attached listeners; if your listener cannot actually handle the event, but was attached as a wildcard listener, you're introducing needless cycles both in aggregating the listeners to trigger, and by handling the event itself. We recommend being specific about what you attach a listener to, in order to prevent these problems. Listener aggregates Another approach to listening to multiple events is via a concept of listener aggregates, represented by Laminas\\EventManager\\ListenerAggregateInterface . Via this approach, a single class can listen to multiple events, attaching one or more instance methods as listeners. This interface defines two methods, attach(EventManagerInterface $events) and detach(EventManagerInterface $events) . You pass an EventManager instance to one and/or the other, and then it's up to the implementing class to determine what to do. The trait Laminas\\EventManager\\ListenerAggregateTrait defines a $listeners property and common logic for detaching an aggregate's listeners. We'll use that to demonstrate creating an aggregate logging listener: use Laminas\\EventManager\\EventInterface; use Laminas\\EventManager\\EventManagerInterface; use Laminas\\EventManager\\ListenerAggregateInterface; use Laminas\\EventManager\\ListenerAggregateTrait; use Laminas\\Log\\Logger; class LogEvents implements ListenerAggregateInterface { use ListenerAggregateTrait; private $log; public function __construct(Logger $log) { $this->log = $log; } public function attach(EventManagerInterface $events) { $this->listeners[] = $events->attach('do', [$this, 'log']); $this->listeners[] = $events->attach('doSomethingElse', [$this, 'log']); } public function log(EventInterface $e) { $event = $e->getName(); $params = $e->getParams(); $this->log->info(sprintf('%s: %s', $event, json_encode($params))); } } Attach the aggregate by passing it an event manager instance: $logListener = new LogEvents($logger); $logListener->attach($events); Any events the aggregate attaches to will then be notified when triggered. Why bother? For a couple of reasons: Aggregates allow you to have stateful listeners. The above example demonstrates this via the composition of the logger; another example would be tracking configuration options. Aggregates make detaching listeners easier, as you can detach all listeners a class defines at once. Introspecting results Sometimes you'll want to know what your listeners returned. One thing to remember is that you may have multiple listeners on the same event; the interface for results must be consistent regardless of the number of listeners. The EventManager implementation by default returns a Laminas\\EventManager\\ResponseCollection instance. This class extends PHP's SplStack , allowing you to loop through responses in reverse order (since the last one executed is likely the one you're most interested in). It also implements the following methods: first() will retrieve the first result received last() will retrieve the last result received contains($value) allows you to test all values to see if a given one was received, and returns a boolean true if found, and false if not. stopped() will return a boolean value indicating whether or not a short-circuit occured; more on this in the next section. Typically, you should not worry about the return values from events, as the object triggering the event shouldn't really have much insight into what listeners are attached. However, sometimes you may want to short-circuit execution if interesting results are obtained. (laminas-mvc uses this feature to check for listeners returning responses, which are then returned immediately.) Short-circuiting listener execution You may want to short-circuit execution if a particular result is obtained, or if a listener determines that something is wrong, or that it can return something quicker than the target. As examples, one rationale for adding an EventManager is as a caching mechanism. You can trigger one event early in the method, returning if a cache is found, and trigger another event late in the method, seeding the cache. The EventManager component offers two ways to handle this, depending on whether you have an event instance already, or want the event manager to create one for you. triggerEventUntil(callable $callback, EventInterface $event) triggerUntil(callable $callback, $eventName, $target = null, $argv = []) In each case, $callback will be any PHP callable, and will be passed the return value from the most recently executed listener. The $callback must then return a boolean value indicating whether or not to halt execution; boolean true indicates execution should halt. Your consuming code can then check to see if execution was short-circuited by using the stopped() method of the returned ResponseCollection . Here's an example: public function someExpensiveCall($criteria1, $criteria2) { $params = compact('criteria1', 'criteria2'); $results = $this->getEventManager()->triggerUntil( function ($r) { return ($r instanceof SomeResultClass); }, __FUNCTION__, $this, $params ); if ($results->stopped()) { return $results->last(); } // ... do some work ... } With this paradigm, we know that the likely reason of execution halting is due to the last result meeting the test callback criteria; as such, we return that last result. The other way to halt execution is within a listener, acting on the Event object it receives. In this case, the listener calls stopPropagation(true) , and the EventManager will then return without notifying any additional listeners. $events->attach('do', function ($e) { $e->stopPropagation(); return new SomeResultClass(); }); This, of course, raises some ambiguity when using the trigger paradigm, as you can no longer be certain that the last result meets the criteria it's searching on. As such, we recommend that you standardize on one approach or the other. Keeping it in order On occasion, you may be concerned about the order in which listeners execute. As an example, you may want to do any logging early, to ensure that if short-circuiting occurs, you've logged; if implementing a cache, you may want to return early if a cache hit is found, and execute late when saving to a cache. Each of EventManager::attach() and SharedEventManager::attach() accept one additional argument, a priority . By default, if this is omitted, listeners get a priority of 1, and are executed in the order in which they are attached. However, if you provide a priority value, you can influence order of execution. Higher priority values execute earlier . Lower (negative) priority values execute later . To borrow an example from earlier: $priority = 100; $events->attach('Example', 'do', function($e) { $event = $e->getName(); $target = get_class($e->getTarget()); // \"Example\" $params = $e->getParams(); printf( 'Handled event \"%s\" on target \"%s\", with parameters %s', $event, $target, json_encode($params) ); }, $priority); This would execute with high priority, meaning it would execute early. If we changed $priority to -100 , it would execute with low priority, executing late. While you can't necessarily know all the listeners attached, chances are you can make adequate guesses when necessary in order to set appropriate priority values. We advise avoiding setting a priority value unless absolutely necessary. Custom event objects As noted earlier, an Event instance is created when you call either trigger() or triggerUntil() , using the arguments passed to each; additionally, you can manually create an instance. Why would you do so, however? One thing that looks like a code smell is when you have code like this: $routeMatch = $e->getParam('route-match', false); if (! $routeMatch) { // Oh noes! we cannot do our work! whatever shall we do?!?!?! } The problems with this are several: Relying on string keys for event parameters is going to very quickly run into problems — typos when setting or retrieving the argument can lead to hard to debug situations. Second, we now have a documentation issue; how do we document expected arguments? how do we document what we're shoving into the event? Third, as a side effect, we can't use IDE or editor hinting support — string keys give these tools nothing to work with. Similarly, consider how you might represent a computational result of a method when triggering an event. As an example: // in the method: $params['__RESULT__'] = $computedResult; $events->trigger(__FUNCTION__ . '.post', $this, $params); // in the listener: $result = $e->getParam('__RESULT__'); if (! $result) { // Oh noes! we cannot do our work! whatever shall we do?!?!?! } Sure, that key may be unique, but it suffers from a lot of the same issues. The solution is to create custom event types . As an example, laminas-mvc defines a custom MvcEvent ; this event composes the application instance, the router, the route match, the request and response instances, the view model, and also a result. We end up with code like this in our listeners: $response = $e->getResponse(); $result = $e->getResult(); if (is_string($result)) { $content = $view->render('layout.phtml', ['content' => $result]); $response->setContent($content); } As noted earlier, if using a custom event, you will need to use the triggerEvent() and/or triggerEventUntil() methods instead of the normal trigger() and triggerUntil() . Putting it together: Implementing a caching system In previous sections, I indicated that short-circuiting is a way to potentially implement a caching solution. Let's create a full example. First, let's define a method that could use caching. You'll note that in most of the examples, we use __FUNCTION__ as the event name; this is a good practice, as it makes code completion simpler, maps event names directly to the method triggering the event, and typically keeps the event names unique. However, in the case of a caching example, this might lead to identical events being triggered, as we will be triggering multiple events from the same method. In such cases, we recommend adding a semantic suffix: __FUNCTION__ . 'pre' , __FUNCTION__ . 'post' , __FUNCTION__ . 'error' , etc. We will use this convention in the upcoming example. Additionally, you'll notice that the $params passed to the event are usually the parameters passed to the method. This is because those are often not stored in the object, and also to ensure the listeners have the exact same context as the calling method. In the upcoming example, however, we will be triggering an event using the results of execution , and will need a way of representing that. We have two possibilities: Use a \"magic\" key, such as __RESULT__ , and add that to our parameter list. Create a custom event that allows injecting the result. The latter is a more correct approach, as it introduces type safety, and prevents typographical errors. Let's create that event now: use Laminas\\EventManager\\Event; class ExpensiveCallEvent extends Event { private $criteria1; private $criteria2; private $result; public function __construct($target, $criteria1, $criteria2) { // Set the default event name: $this->setName('someExpensiveCall'); $this->setTarget($target); $this->criteria1 = $criteria1; $this->criteria2 = $criteria2; } public function getCriteria1() { return $this->criteria1; } public function getCriteria2() { return $this->criteria2; } public function setResult(SomeResultClass $result) { $this->result = $result; } public function getResult() { return $this->result; } } We can now create an instance of this within our class method, and use it to trigger listeners: public function someExpensiveCall($criteria1, $criteria2) { $event = new ExpensiveCallEvent($this, $criteria1, $criteria2); $event->setName(__FUNCTION__ . '.pre'); $results = $this->getEventManager()->triggerEventUntil( function ($r) { return ($r instanceof SomeResultClass); }, $event ); if ($results->stopped()) { return $results->last(); } // ... do some work ... $event->setName(__FUNCTION__ . '.post'); $event->setResult($calculatedResult); $this->events()->triggerEvent($event); return $calculatedResult; } Before triggering either event, we set the event name in the instance to ensure the correct listeners are notified. The first trigger checks to see if we get a result class returned, and, if so, we return it. The second trigger is a fire-and-forget; we don't care what is returned, and only want to notify listeners of the result. To provide some caching listeners, we'll need to attach to each of the someExpensiveCall.pre and someExpensiveCall.post events. In the former case, if a cache hit is detected, we return it. In the latter, we store the value in the cache. The following listeners attach to the .pre and .post events triggered by the above method. We'll assume $cache is defined, and is a laminas-cache storage adapter. The first listener will return a result when a cache hit occurs, and the second will store a result in the cache if one is provided. $events->attach('someExpensiveCall.pre', function (ExpensiveCallEvent $e) use ($cache) { $key = md5(json_encode([ 'criteria1' => $e->getCriteria1(), 'criteria2' => $e->getCriteria2(), ])); $result = $cache->getItem($key, $success); if (! $success) { return; } $result = new SomeResultClass($result); $e->setResult($result); return $result; }); $events->attach('someExpensiveCall.post', function (ExpensiveCallEvent $e) use ($cache) { $result = $e->getResult(); if (! $result instanceof SomeResultClass) { return; } $key = md5(json_encode([ 'criteria1' => $e->getCriteria1(), 'criteria2' => $e->getCriteria2(), ])); $cache->setItem($key, $result); }); ListenerAggregates allow stateful listeners The above could have been done within a ListenerAggregate , which would have allowed keeping the $cache instance as a stateful property, instead of importing it into closures. Another approach would be to move the body of the method to a listener as well, which would allow using the priority system in order to implement caching. If we did that, we'd modify the ExpensiveCallEvent to omit the .pre suffix on the default event name, and then implement the class that triggers the event as follows: public function setEventManager(EventManagerInterface $events) { $this->events = $events; $events->setIdentifiers([__CLASS__, get_class($this)]); $events->attach('someExpensiveCall', [$this, 'doSomeExpensiveCall']); } public function someExpensiveCall($criteria1, $criteria2) { $event = new ExpensiveCallEvent($this, $criteria1, $criteria2); $this->getEventManager()->triggerEventUntil( function ($r) { return $r instanceof SomeResultClass; }, $event ); return $event->getResult(); } public function doSomeExpensiveCall(ExpensiveCallEvent $e) { // ... do some work ... $e->setResult($calculatedResult); } Note that the doSomeExpensiveCall method does not return the result directly; this allows what was originally our .post listener to trigger. You'll also notice that we return the result from the Event instance; this is why the first listener passes the result into the event, as we can then use it from the calling method! We will need to change how we attach the listeners; they will now attach directly to the someExpensiveCall event, without any suffixes; they will also now use priority in order to intercept before and after the default listener registered by the class. The first listener will listen at priority 100 to ensure it executes before the default listener, and the second will listen at priority -100 to ensure it triggers after we already have a result: $events->attach('someExpensiveCall', function (ExpensiveCallEvent $e) use ($cache) { // listener for checking against the cache }, 100); $events->attach('someExpensiveCall', function (ExpensiveCallEvent $e) use ($cache) { // listener for injecting into the cache }, -100); The workflow ends up being approximately the same, but eliminates the conditional logic from the original version, and reduces the number of events to one. The alternative, of course, is to have the object compose a cache instance and use it directly. However, the event-based approach allows: Re-using the listeners with multiple events. Attaching multiple listeners to the event; as an example, to implement argument validation, or to add logging. The point is that if you design your object with events in mind, you can add flexibility and extension points without requiring decoration or class extension. Conclusion laminas-eventmanager is a powerful component. It drives the workflow of laminas-mvc, and is used in many Laminas components to provide hook points for developers to manipulate the workflow. It can be a powerful tool in your development toolbox.","title":"Using the EventManager"},{"location":"event-manager/#using-the-eventmanager","text":"This tutorial explores the features of laminas-eventmanager in-depth.","title":"Using the EventManager"},{"location":"event-manager/#terminology","text":"An Event is a named action. A Listener is any PHP callback that reacts to an event . An EventManager aggregates listeners for one or more named events, and triggers events. Typically, an event will be modeled as an object, containing metadata surrounding when and how it was triggered, including the event name, what object triggered the event (the \"target\"), and what parameters were provided. Events are named , which allows a single listener to branch logic based on the event.","title":"Terminology"},{"location":"event-manager/#getting-started","text":"The minimal things necessary to start using events are: An EventManager instance One or more listeners on one or more events A call to trigger() an event A basic example looks something like this: use Laminas\\EventManager\\EventManager; $events = new EventManager(); $events->attach('do', function ($e) { $event = $e->getName(); $params = $e->getParams(); printf( 'Handled event \"%s\", with parameters %s', $event, json_encode($params) ); }); $params = ['foo' => 'bar', 'baz' => 'bat']; $events->trigger('do', null, $params); The above will result in the following: Handled event \"do\", with parameters {\"foo\":\"bar\",\"baz\":\"bat\"}","title":"Getting started"},{"location":"event-manager/#shared-managers","text":"One aspect that the EventManager implementation provides is an ability to compose a SharedEventManagerInterface implementation. Laminas\\EventManager\\SharedEventManagerInterface describes an object that aggregates listeners for events attached to objects with specific identifiers . It does not trigger events itself. Instead, an EventManager instance that composes a SharedEventManager will query the SharedEventManager for listeners on identifiers it's interested in, and trigger those listeners as well. How does this work, exactly? Consider the following: use Laminas\\EventManager\\SharedEventManager; $sharedEvents = new SharedEventManager(); $sharedEvents->attach('Example', 'do', function ($e) { $event = $e->getName(); $target = get_class($e->getTarget()); // \"Example\" $params = $e->getParams(); printf( 'Handled event \"%s\" on target \"%s\", with parameters %s', $event, $target, json_encode($params) ); }); This looks almost identical to the previous example; the key difference is that there is an additional argument at the start of the list, 'Example' . This code is saying, \"Listen to the 'do' event of the 'Example' target, and, when notified, execute this callback.\" This is where the setIdentifiers() method of EventManager comes into play. The method allows passing an array of strings, defining the names of the context or targets the given instance will be interested in. If an array is given, then any listener on any of the targets given will be notified. So, getting back to our example, let's assume that the above shared listener is registered, and also that the Example class is defined as above. We can then execute the following: $example = new Example(); $example->getEventManager()->setSharedManager($sharedEvents); $example->do('bar', 'bat'); and expect the following to be echo 'd: Handled event \"do\" on target \"Example\", with parameters {\"foo\":\"bar\",\"baz\":\"bat\"} Now, let's say we extended Example as follows: class SubExample extends Example { } One interesting aspect of our setEventManager() method is that we defined it to listen both on __CLASS__ and get_class($this) . This means that calling do() on our SubExample class would also trigger the shared listener! It also means that, if desired, we could attach to specifically SubExample , and listeners attached to only the Example target would not be triggered. Finally, the names used as contexts or targets need not be class names; they can be some name that only has meaning in your application if desired. As an example, you could have a set of classes that respond to \"log\" or \"cache\" — and listeners on these would be notified by any of them.","title":"Shared managers"},{"location":"event-manager/#introspecting-results","text":"Sometimes you'll want to know what your listeners returned. One thing to remember is that you may have multiple listeners on the same event; the interface for results must be consistent regardless of the number of listeners. The EventManager implementation by default returns a Laminas\\EventManager\\ResponseCollection instance. This class extends PHP's SplStack , allowing you to loop through responses in reverse order (since the last one executed is likely the one you're most interested in). It also implements the following methods: first() will retrieve the first result received last() will retrieve the last result received contains($value) allows you to test all values to see if a given one was received, and returns a boolean true if found, and false if not. stopped() will return a boolean value indicating whether or not a short-circuit occured; more on this in the next section. Typically, you should not worry about the return values from events, as the object triggering the event shouldn't really have much insight into what listeners are attached. However, sometimes you may want to short-circuit execution if interesting results are obtained. (laminas-mvc uses this feature to check for listeners returning responses, which are then returned immediately.)","title":"Introspecting results"},{"location":"event-manager/#keeping-it-in-order","text":"On occasion, you may be concerned about the order in which listeners execute. As an example, you may want to do any logging early, to ensure that if short-circuiting occurs, you've logged; if implementing a cache, you may want to return early if a cache hit is found, and execute late when saving to a cache. Each of EventManager::attach() and SharedEventManager::attach() accept one additional argument, a priority . By default, if this is omitted, listeners get a priority of 1, and are executed in the order in which they are attached. However, if you provide a priority value, you can influence order of execution. Higher priority values execute earlier . Lower (negative) priority values execute later . To borrow an example from earlier: $priority = 100; $events->attach('Example', 'do', function($e) { $event = $e->getName(); $target = get_class($e->getTarget()); // \"Example\" $params = $e->getParams(); printf( 'Handled event \"%s\" on target \"%s\", with parameters %s', $event, $target, json_encode($params) ); }, $priority); This would execute with high priority, meaning it would execute early. If we changed $priority to -100 , it would execute with low priority, executing late. While you can't necessarily know all the listeners attached, chances are you can make adequate guesses when necessary in order to set appropriate priority values. We advise avoiding setting a priority value unless absolutely necessary.","title":"Keeping it in order"},{"location":"event-manager/#custom-event-objects","text":"As noted earlier, an Event instance is created when you call either trigger() or triggerUntil() , using the arguments passed to each; additionally, you can manually create an instance. Why would you do so, however? One thing that looks like a code smell is when you have code like this: $routeMatch = $e->getParam('route-match', false); if (! $routeMatch) { // Oh noes! we cannot do our work! whatever shall we do?!?!?! } The problems with this are several: Relying on string keys for event parameters is going to very quickly run into problems — typos when setting or retrieving the argument can lead to hard to debug situations. Second, we now have a documentation issue; how do we document expected arguments? how do we document what we're shoving into the event? Third, as a side effect, we can't use IDE or editor hinting support — string keys give these tools nothing to work with. Similarly, consider how you might represent a computational result of a method when triggering an event. As an example: // in the method: $params['__RESULT__'] = $computedResult; $events->trigger(__FUNCTION__ . '.post', $this, $params); // in the listener: $result = $e->getParam('__RESULT__'); if (! $result) { // Oh noes! we cannot do our work! whatever shall we do?!?!?! } Sure, that key may be unique, but it suffers from a lot of the same issues. The solution is to create custom event types . As an example, laminas-mvc defines a custom MvcEvent ; this event composes the application instance, the router, the route match, the request and response instances, the view model, and also a result. We end up with code like this in our listeners: $response = $e->getResponse(); $result = $e->getResult(); if (is_string($result)) { $content = $view->render('layout.phtml', ['content' => $result]); $response->setContent($content); } As noted earlier, if using a custom event, you will need to use the triggerEvent() and/or triggerEventUntil() methods instead of the normal trigger() and triggerUntil() .","title":"Custom event objects"},{"location":"event-manager/#putting-it-together-implementing-a-caching-system","text":"In previous sections, I indicated that short-circuiting is a way to potentially implement a caching solution. Let's create a full example. First, let's define a method that could use caching. You'll note that in most of the examples, we use __FUNCTION__ as the event name; this is a good practice, as it makes code completion simpler, maps event names directly to the method triggering the event, and typically keeps the event names unique. However, in the case of a caching example, this might lead to identical events being triggered, as we will be triggering multiple events from the same method. In such cases, we recommend adding a semantic suffix: __FUNCTION__ . 'pre' , __FUNCTION__ . 'post' , __FUNCTION__ . 'error' , etc. We will use this convention in the upcoming example. Additionally, you'll notice that the $params passed to the event are usually the parameters passed to the method. This is because those are often not stored in the object, and also to ensure the listeners have the exact same context as the calling method. In the upcoming example, however, we will be triggering an event using the results of execution , and will need a way of representing that. We have two possibilities: Use a \"magic\" key, such as __RESULT__ , and add that to our parameter list. Create a custom event that allows injecting the result. The latter is a more correct approach, as it introduces type safety, and prevents typographical errors. Let's create that event now: use Laminas\\EventManager\\Event; class ExpensiveCallEvent extends Event { private $criteria1; private $criteria2; private $result; public function __construct($target, $criteria1, $criteria2) { // Set the default event name: $this->setName('someExpensiveCall'); $this->setTarget($target); $this->criteria1 = $criteria1; $this->criteria2 = $criteria2; } public function getCriteria1() { return $this->criteria1; } public function getCriteria2() { return $this->criteria2; } public function setResult(SomeResultClass $result) { $this->result = $result; } public function getResult() { return $this->result; } } We can now create an instance of this within our class method, and use it to trigger listeners: public function someExpensiveCall($criteria1, $criteria2) { $event = new ExpensiveCallEvent($this, $criteria1, $criteria2); $event->setName(__FUNCTION__ . '.pre'); $results = $this->getEventManager()->triggerEventUntil( function ($r) { return ($r instanceof SomeResultClass); }, $event ); if ($results->stopped()) { return $results->last(); } // ... do some work ... $event->setName(__FUNCTION__ . '.post'); $event->setResult($calculatedResult); $this->events()->triggerEvent($event); return $calculatedResult; } Before triggering either event, we set the event name in the instance to ensure the correct listeners are notified. The first trigger checks to see if we get a result class returned, and, if so, we return it. The second trigger is a fire-and-forget; we don't care what is returned, and only want to notify listeners of the result. To provide some caching listeners, we'll need to attach to each of the someExpensiveCall.pre and someExpensiveCall.post events. In the former case, if a cache hit is detected, we return it. In the latter, we store the value in the cache. The following listeners attach to the .pre and .post events triggered by the above method. We'll assume $cache is defined, and is a laminas-cache storage adapter. The first listener will return a result when a cache hit occurs, and the second will store a result in the cache if one is provided. $events->attach('someExpensiveCall.pre', function (ExpensiveCallEvent $e) use ($cache) { $key = md5(json_encode([ 'criteria1' => $e->getCriteria1(), 'criteria2' => $e->getCriteria2(), ])); $result = $cache->getItem($key, $success); if (! $success) { return; } $result = new SomeResultClass($result); $e->setResult($result); return $result; }); $events->attach('someExpensiveCall.post', function (ExpensiveCallEvent $e) use ($cache) { $result = $e->getResult(); if (! $result instanceof SomeResultClass) { return; } $key = md5(json_encode([ 'criteria1' => $e->getCriteria1(), 'criteria2' => $e->getCriteria2(), ])); $cache->setItem($key, $result); });","title":"Putting it together: Implementing a caching system"},{"location":"event-manager/#conclusion","text":"laminas-eventmanager is a powerful component. It drives the workflow of laminas-mvc, and is used in many Laminas components to provide hook points for developers to manipulate the workflow. It can be a powerful tool in your development toolbox.","title":"Conclusion"},{"location":"i18n/","text":"Internationalization If you are building a site for an international audience, you will likely want to provide localized versions of common strings on your website, including menu items, form labels, button labels, and more. Additionally, some websites require that route path segments be localized. Laminas provides internationalization (i18n) tools via the laminas-i18n component, and integration with laminas-mvc via the laminas-mvc-i18n component. Installation Install laminas-mvc-i18n via Composer: $ composer require laminas/laminas-mvc-i18n Assuming you are using laminas-component-installer (which is installed by default with the skeleton application), this will prompt you to install the component as a module in your application; make sure you select either application.config.php or modules.config.php for the location. Once installed, this component exposes several services, including: MvcTranslator , which implements the laminas-i18n TranslatorInterface , as well as the version specific to laminas-validator, providing an instance that can be used for all application contexts. A \"translator aware\" router. By default, until you configure translations, installation has no practical effect. So the next step is creating translations to use in your application. Creating translations The laminas-i18n Translation chapter covers the details of adding translations to your application. You can use PHP arrays, INI files, or the popular gettext package (which allows you to use industry standard tools such as poedit to edit translations). Once you have some translation sources, you will need to put them somewhere your application can access them. Options include: In a subdirectory of the module that defines and/or consumes the translation strings. As an example, module/Application/language/ . In your application data directory; e.g., data/language/ . Make sure you follow the guidelines from the laminas-i18n documentation with regards to naming your files. Additionally, you may want to further segregate any such directory by text domain. From here, you need to configure the translator to use your files. This requires adding configuration in either your module or application configuration files that provides: The default locale if none is provided. Translation file patterns, which include: the translation source type (e.g., gettext , phparray , ini ) the base directory in which they are stored a file pattern for identifying the files to use As examples: // in a module's module.config.php: 'translator' => [ 'locale' => 'en_US', 'translation_file_patterns' => [ [ 'type' => 'gettext', 'base_dir' => __DIR__ . '/../language', 'pattern' => '%s.mo', ], ], ], // or in config/autoload/global.php: 'translator' => [ 'locale' => 'en_US', 'translation_file_patterns' => [ [ 'type' => 'gettext', 'base_dir' => getcwd() . '/data/language', 'pattern' => '%s.mo', ], ], ], Once the above configuration is in place, the translator will be active in your application, allowing you to use it. Translating strings in templates Once you have defined some strings to translate, and configured the application to use them, you can translate them in your application. The translate() and translatePlural() view helpers allow you to provide translations within your view scripts. As an example, you might want to translate the string \"All rights reserved\" in your footer. You could do the following in your layout script: <p>© 2016 by Examples Ltd. <?= $this->translate('All rights reserved') ?></p> Translating route segments In order to enable route translation, you need to do two things: Tell the router to use the translation-aware route class. Optionally, tell it which text domain to use (if not using the default text domain). To tell the application to use the translation-aware route class, we can update our routing configuration. Underneath the top-level router key, we'll add the router_class key: // In a module.config.php file, or config/autoload/global.php: 'router' => [ 'router_class' => Laminas\\Mvc\\I18n\\Router\\TranslatorAwareTreeRouteStack::class, 'routes' => [ /* ... */ ], ], If you want to use an alternate text domain, you can do so via the translator_text_domain key, also directly below the router key: // In a module.config.php file, or config/autoload/global.php: 'router' => [ 'router_class' => Laminas\\Mvc\\I18n\\Router\\TranslatorAwareTreeRouteStack::class, 'translator_text_domain' => 'router', 'routes' => [ /* ... */ ], ], Now that the router is aware of translations, we can use translatable strings in our routes. To do so, surround the string capable of translation with braces ( {} ). As an example: 'route' => '/{login}', specifies the word \"login\" as translatable.","title":"Internationalization"},{"location":"i18n/#internationalization","text":"If you are building a site for an international audience, you will likely want to provide localized versions of common strings on your website, including menu items, form labels, button labels, and more. Additionally, some websites require that route path segments be localized. Laminas provides internationalization (i18n) tools via the laminas-i18n component, and integration with laminas-mvc via the laminas-mvc-i18n component.","title":"Internationalization"},{"location":"i18n/#installation","text":"Install laminas-mvc-i18n via Composer: $ composer require laminas/laminas-mvc-i18n Assuming you are using laminas-component-installer (which is installed by default with the skeleton application), this will prompt you to install the component as a module in your application; make sure you select either application.config.php or modules.config.php for the location. Once installed, this component exposes several services, including: MvcTranslator , which implements the laminas-i18n TranslatorInterface , as well as the version specific to laminas-validator, providing an instance that can be used for all application contexts. A \"translator aware\" router. By default, until you configure translations, installation has no practical effect. So the next step is creating translations to use in your application.","title":"Installation"},{"location":"i18n/#creating-translations","text":"The laminas-i18n Translation chapter covers the details of adding translations to your application. You can use PHP arrays, INI files, or the popular gettext package (which allows you to use industry standard tools such as poedit to edit translations). Once you have some translation sources, you will need to put them somewhere your application can access them. Options include: In a subdirectory of the module that defines and/or consumes the translation strings. As an example, module/Application/language/ . In your application data directory; e.g., data/language/ . Make sure you follow the guidelines from the laminas-i18n documentation with regards to naming your files. Additionally, you may want to further segregate any such directory by text domain. From here, you need to configure the translator to use your files. This requires adding configuration in either your module or application configuration files that provides: The default locale if none is provided. Translation file patterns, which include: the translation source type (e.g., gettext , phparray , ini ) the base directory in which they are stored a file pattern for identifying the files to use As examples: // in a module's module.config.php: 'translator' => [ 'locale' => 'en_US', 'translation_file_patterns' => [ [ 'type' => 'gettext', 'base_dir' => __DIR__ . '/../language', 'pattern' => '%s.mo', ], ], ], // or in config/autoload/global.php: 'translator' => [ 'locale' => 'en_US', 'translation_file_patterns' => [ [ 'type' => 'gettext', 'base_dir' => getcwd() . '/data/language', 'pattern' => '%s.mo', ], ], ], Once the above configuration is in place, the translator will be active in your application, allowing you to use it.","title":"Creating translations"},{"location":"i18n/#translating-strings-in-templates","text":"Once you have defined some strings to translate, and configured the application to use them, you can translate them in your application. The translate() and translatePlural() view helpers allow you to provide translations within your view scripts. As an example, you might want to translate the string \"All rights reserved\" in your footer. You could do the following in your layout script: <p>© 2016 by Examples Ltd. <?= $this->translate('All rights reserved') ?></p>","title":"Translating strings in templates"},{"location":"i18n/#translating-route-segments","text":"In order to enable route translation, you need to do two things: Tell the router to use the translation-aware route class. Optionally, tell it which text domain to use (if not using the default text domain). To tell the application to use the translation-aware route class, we can update our routing configuration. Underneath the top-level router key, we'll add the router_class key: // In a module.config.php file, or config/autoload/global.php: 'router' => [ 'router_class' => Laminas\\Mvc\\I18n\\Router\\TranslatorAwareTreeRouteStack::class, 'routes' => [ /* ... */ ], ], If you want to use an alternate text domain, you can do so via the translator_text_domain key, also directly below the router key: // In a module.config.php file, or config/autoload/global.php: 'router' => [ 'router_class' => Laminas\\Mvc\\I18n\\Router\\TranslatorAwareTreeRouteStack::class, 'translator_text_domain' => 'router', 'routes' => [ /* ... */ ], ], Now that the router is aware of translations, we can use translatable strings in our routes. To do so, surround the string capable of translation with braces ( {} ). As an example: 'route' => '/{login}', specifies the word \"login\" as translatable.","title":"Translating route segments"},{"location":"migration-from-zendframework/","text":"document.addEventListener(\"DOMContentLoaded\", function (event) { window.location.pathname = '/migration/'; });","title":"_migration-from-zendframework"},{"location":"navigation/","text":"Using laminas-navigation in your Album Module In this tutorial we will use the laminas-navigation component to add a navigation menu to the black bar at the top of the screen, and add breadcrumbs above the main site content. Preparation In a real world application, the album browser would be only a portion of a working website. Usually the user would land on a homepage first, and be able to view albums by using a standard navigation menu. So that we have a site that is more realistic than just the albums feature, lets make the standard skeleton welcome page our homepage, with the /album route still showing our album module. In order to make this change, we need to undo some work we did earlier. Currently, navigating to the root of your app ( / ) routes you to the AlbumController 's default action. Let's undo this route change so we have two discrete entry points to the app, a home page, and an albums area. // In module/Application/config/module.config.php: 'home' => [ 'type' => Literal::class, 'options' => [ 'route' => '/', 'defaults' => [ 'controller' => Controller\\IndexController::class, // <-- change back here 'action' => 'index', ], ], ], (You can also now remove the import for the Album\\Controller\\AlbumController class.) This change means that if you go to the home page of your application ( http://localhost:8080/ or http://laminas-mvc-tutorial.localhost/ ), you see the default skeleton application introduction. Your list of albums is still available at the /album route. Setting Up laminas-navigation First, we need to install laminas-navigation. From your root directory, execute the following: $ composer require laminas/laminas-navigation Assuming you followed the Getting Started tutorial , you will be prompted by the laminas-component-installer plugin to inject Laminas\\Navigation ; be sure to select the option for either config/application.config.php or config/modules.config.php ; since it is the only package you are installing, you can answer either \"y\" or \"n\" to the \"Remember this option for other packages of the same type\" prompt. Manual configuration If you are not using laminas-component-installer, you will need to setup configuration manually. You can do this in one of two ways: Register the Laminas\\Navigation module in either config/application.config.php or config/modules.config.php . Make sure you put it towards the top of the module list, before any modules you have defined or third party modules you are using. Alternately, add a new file, config/autoload/navigation.global.php , with the following contents: <?php use Laminas\\Navigation\\ConfigProvider; return [ 'service_manager' => (new ConfigProvider())->getDependencyConfig(), ]; Once installed, our application is now aware of laminas-navigation, and even has some default factories in place, which we will now make use of. Configuring our Site Map Next up, we need laminas-navigation to understand the hierarchy of our site. To do this, we can add a navigation key to our configuration, with the site structure details. We'll do that in the Application module configuration: // in module/Application/config/module.config.php: return [ /* ... */ 'navigation' => [ 'default' => [ [ 'label' => 'Home', 'route' => 'home', ], [ 'label' => 'Album', 'route' => 'album', 'pages' => [ [ 'label' => 'Add', 'route' => 'album', 'action' => 'add', ], [ 'label' => 'Edit', 'route' => 'album', 'action' => 'edit', ], [ 'label' => 'Delete', 'route' => 'album', 'action' => 'delete', ], ], ], ], ], /* ... */ ]; This configuration maps out the pages we've defined in our Album module, with labels linking to the given route names and actions. You can define highly complex hierarchical sites here with pages and sub-pages linking to route names, controller/action pairs, or external uris. For more information, see the laminas-navigation quick start . Adding the Menu View Helper Now that we have the navigation helper configured by our service manager and merged config, we can add the menu to the title bar to our layout by using the menu view helper : <?php // in module/Application/view/layout/layout.phtml: ?> <div class=\"collapse navbar-collapse\"> <?php // add this: ?> <?= $this->navigation('navigation')->menu() ?> </div> The navigation helper is provided by default with laminas-view, and uses the service manager configuration we've already defined to configure itself automatically. Refreshing your application, you will see a working menu; with just a few tweaks however, we can make it look even better: <?php // in module/Application/view/layout/layout.phtml: ?> <div class=\"collapse navbar-collapse\"> <?php // update to: ?> <?= $this->navigation('navigation') ->menu() ->setMinDepth(0) ->setMaxDepth(0) ->setUlClass('nav navbar-nav') ?> </div> Here we tell the renderer to give the root <ul> the class of nav (so that Bootstrap styles the menu correctly), and only render the first level of any given page. If you view your application in your browser, you will now see a nicely styled menu appear in the title bar. The great thing about laminas-navigation is that it integrates with laminas-router in order to highlight the currently viewed page. Because of this, it sets the active page to have a class of active in the menu; Bootstrap uses this to highlight your current page accordingly. Adding Breadcrumbs Adding breadcrumbs follows the same process. In our layout.phtml we want to add breadcrumbs above the main content pane, so our users know exactly where they are in our website. Inside the container <div> , before we output the content from the view, let's add a breadcrumb by using the breadcrumbs view helper . <?php // module/Application/view/layout/layout.phtml: ?> <div class=\"container\"> <?php // add the following line: ?> <?= $this->navigation('navigation')->breadcrumbs()->setMinDepth(0) ?> <?= $this->content ?> </div> This adds a simple but functional breadcrumb to every page (we tell it to render from a depth of 0 so we see all page levels), but we can do better than that! Because Bootstrap has a styled breadcrumb as part of its base CSS, let's add a partial that outputs the <ul> using Bootstrap styles. We'll create it in the view directory of the Application module (this partial is application wide, rather than album specific). Let's create the partial module/Application/view/partial/breadcrumbs.phtml : <?php // in module/Application/view/partial/breadcrumb.phtml: ?> <nav aria-label=\"breadcrumb\"> <ol class=\"breadcrumb\"> <?php // iterate through the pages foreach ($this->pages as $key => $page): ?> <?php // if this isn't the last page, add a link and the separator: if ($key < count($this->pages) - 1): ?> <li class=\"breadcrumb-item\"> <a href=\"<?= $page->getHref() ?>\"> <?= $page->getLabel() ?> </a> </li> <?php // otherwise, output the name only: else: ?> <li class=\"breadcrumb-item active\" aria-current=\"page\"> <?= $page->getLabel() ?> </li> <?php endif; ?> <?php endforeach; ?> </ol> </nav> Notice how the partial is passed a Laminas\\View\\Model\\ViewModel instance with the pages property set to an array of pages to render. Now we need to tell the breadcrumb helper to use the partial we have just written: <?php // in module/Application/view/layout/layout.phtml: ?> <div class=\"container\"> <?php // Update to: ?> <?= $this->navigation('navigation') ->breadcrumbs() ->setMinDepth(0) ->setPartial('partial/breadcrumb') ?> <?= $this->content ?> </div> Refreshing the page now gives us a styled set of breadcrumbs on each page that should look like this:","title":"Adding laminas-navigation to the Album Module"},{"location":"navigation/#using-laminas-navigation-in-your-album-module","text":"In this tutorial we will use the laminas-navigation component to add a navigation menu to the black bar at the top of the screen, and add breadcrumbs above the main site content.","title":"Using laminas-navigation in your Album Module"},{"location":"navigation/#preparation","text":"In a real world application, the album browser would be only a portion of a working website. Usually the user would land on a homepage first, and be able to view albums by using a standard navigation menu. So that we have a site that is more realistic than just the albums feature, lets make the standard skeleton welcome page our homepage, with the /album route still showing our album module. In order to make this change, we need to undo some work we did earlier. Currently, navigating to the root of your app ( / ) routes you to the AlbumController 's default action. Let's undo this route change so we have two discrete entry points to the app, a home page, and an albums area. // In module/Application/config/module.config.php: 'home' => [ 'type' => Literal::class, 'options' => [ 'route' => '/', 'defaults' => [ 'controller' => Controller\\IndexController::class, // <-- change back here 'action' => 'index', ], ], ], (You can also now remove the import for the Album\\Controller\\AlbumController class.) This change means that if you go to the home page of your application ( http://localhost:8080/ or http://laminas-mvc-tutorial.localhost/ ), you see the default skeleton application introduction. Your list of albums is still available at the /album route.","title":"Preparation"},{"location":"navigation/#setting-up-laminas-navigation","text":"First, we need to install laminas-navigation. From your root directory, execute the following: $ composer require laminas/laminas-navigation Assuming you followed the Getting Started tutorial , you will be prompted by the laminas-component-installer plugin to inject Laminas\\Navigation ; be sure to select the option for either config/application.config.php or config/modules.config.php ; since it is the only package you are installing, you can answer either \"y\" or \"n\" to the \"Remember this option for other packages of the same type\" prompt.","title":"Setting Up laminas-navigation"},{"location":"navigation/#configuring-our-site-map","text":"Next up, we need laminas-navigation to understand the hierarchy of our site. To do this, we can add a navigation key to our configuration, with the site structure details. We'll do that in the Application module configuration: // in module/Application/config/module.config.php: return [ /* ... */ 'navigation' => [ 'default' => [ [ 'label' => 'Home', 'route' => 'home', ], [ 'label' => 'Album', 'route' => 'album', 'pages' => [ [ 'label' => 'Add', 'route' => 'album', 'action' => 'add', ], [ 'label' => 'Edit', 'route' => 'album', 'action' => 'edit', ], [ 'label' => 'Delete', 'route' => 'album', 'action' => 'delete', ], ], ], ], ], /* ... */ ]; This configuration maps out the pages we've defined in our Album module, with labels linking to the given route names and actions. You can define highly complex hierarchical sites here with pages and sub-pages linking to route names, controller/action pairs, or external uris. For more information, see the laminas-navigation quick start .","title":"Configuring our Site Map"},{"location":"navigation/#adding-the-menu-view-helper","text":"Now that we have the navigation helper configured by our service manager and merged config, we can add the menu to the title bar to our layout by using the menu view helper : <?php // in module/Application/view/layout/layout.phtml: ?> <div class=\"collapse navbar-collapse\"> <?php // add this: ?> <?= $this->navigation('navigation')->menu() ?> </div> The navigation helper is provided by default with laminas-view, and uses the service manager configuration we've already defined to configure itself automatically. Refreshing your application, you will see a working menu; with just a few tweaks however, we can make it look even better: <?php // in module/Application/view/layout/layout.phtml: ?> <div class=\"collapse navbar-collapse\"> <?php // update to: ?> <?= $this->navigation('navigation') ->menu() ->setMinDepth(0) ->setMaxDepth(0) ->setUlClass('nav navbar-nav') ?> </div> Here we tell the renderer to give the root <ul> the class of nav (so that Bootstrap styles the menu correctly), and only render the first level of any given page. If you view your application in your browser, you will now see a nicely styled menu appear in the title bar. The great thing about laminas-navigation is that it integrates with laminas-router in order to highlight the currently viewed page. Because of this, it sets the active page to have a class of active in the menu; Bootstrap uses this to highlight your current page accordingly.","title":"Adding the Menu View Helper"},{"location":"navigation/#adding-breadcrumbs","text":"Adding breadcrumbs follows the same process. In our layout.phtml we want to add breadcrumbs above the main content pane, so our users know exactly where they are in our website. Inside the container <div> , before we output the content from the view, let's add a breadcrumb by using the breadcrumbs view helper . <?php // module/Application/view/layout/layout.phtml: ?> <div class=\"container\"> <?php // add the following line: ?> <?= $this->navigation('navigation')->breadcrumbs()->setMinDepth(0) ?> <?= $this->content ?> </div> This adds a simple but functional breadcrumb to every page (we tell it to render from a depth of 0 so we see all page levels), but we can do better than that! Because Bootstrap has a styled breadcrumb as part of its base CSS, let's add a partial that outputs the <ul> using Bootstrap styles. We'll create it in the view directory of the Application module (this partial is application wide, rather than album specific). Let's create the partial module/Application/view/partial/breadcrumbs.phtml : <?php // in module/Application/view/partial/breadcrumb.phtml: ?> <nav aria-label=\"breadcrumb\"> <ol class=\"breadcrumb\"> <?php // iterate through the pages foreach ($this->pages as $key => $page): ?> <?php // if this isn't the last page, add a link and the separator: if ($key < count($this->pages) - 1): ?> <li class=\"breadcrumb-item\"> <a href=\"<?= $page->getHref() ?>\"> <?= $page->getLabel() ?> </a> </li> <?php // otherwise, output the name only: else: ?> <li class=\"breadcrumb-item active\" aria-current=\"page\"> <?= $page->getLabel() ?> </li> <?php endif; ?> <?php endforeach; ?> </ol> </nav> Notice how the partial is passed a Laminas\\View\\Model\\ViewModel instance with the pages property set to an array of pages to render. Now we need to tell the breadcrumb helper to use the partial we have just written: <?php // in module/Application/view/layout/layout.phtml: ?> <div class=\"container\"> <?php // Update to: ?> <?= $this->navigation('navigation') ->breadcrumbs() ->setMinDepth(0) ->setPartial('partial/breadcrumb') ?> <?= $this->content ?> </div> Refreshing the page now gives us a styled set of breadcrumbs on each page that should look like this:","title":"Adding Breadcrumbs"},{"location":"pagination/","text":"Using laminas-paginator in your Album Module In this tutorial, we will use the laminas-paginator component to add a handy pagination controller to the bottom of the album list. Currently, we only have a handful of albums to display, so showing everything on one page is not a problem. However, how will the album list look when we have 100 albums or more in our database? The standard solution to this problem is to split the data up into a number of pages, and allow the user to navigate around these pages using a pagination control. A typical paginator on a web page looks like this: Preparation As before, we are going to use sqlite, via PHP's PDO driver. Create a text file data/album-fixtures.sql with the following contents: INSERT INTO \"album\" (\"artist\", \"title\") VALUES (\"David Bowie\", \"The Next Day (Deluxe Version)\"), (\"Bastille\", \"Bad Blood\"), (\"Bruno Mars\", \"Unorthodox Jukebox\"), (\"Emeli Sandé\", \"Our Version of Events (Special Edition)\"), (\"Bon Jovi\", \"What About Now (Deluxe Version)\"), (\"Justin Timberlake\", \"The 20/20 Experience (Deluxe Version)\"), (\"Bastille\", \"Bad Blood (The Extended Cut)\"), (\"P!nk\", \"The Truth About Love\"), (\"Sound City - Real to Reel\", \"Sound City - Real to Reel\"), (\"Jake Bugg\", \"Jake Bugg\"), (\"Various Artists\", \"The Trevor Nelson Collection\"), (\"David Bowie\", \"The Next Day\"), (\"Mumford & Sons\", \"Babel\"), (\"The Lumineers\", \"The Lumineers\"), (\"Various Artists\", \"Get Ur Freak On - R&B Anthems\"), (\"The 1975\", \"Music For Cars EP\"), (\"Various Artists\", \"Saturday Night Club Classics - Ministry of Sound\"), (\"Hurts\", \"Exile (Deluxe)\"), (\"Various Artists\", \"Mixmag - The Greatest Dance Tracks of All Time\"), (\"Ben Howard\", \"Every Kingdom\"), (\"Stereophonics\", \"Graffiti On the Train\"), (\"The Script\", \"#3\"), (\"Stornoway\", \"Tales from Terra Firma\"), (\"David Bowie\", \"Hunky Dory (Remastered)\"), (\"Worship Central\", \"Let It Be Known (Live)\"), (\"Ellie Goulding\", \"Halcyon\"), (\"Various Artists\", \"Dermot O'Leary Presents the Saturday Sessions 2013\"), (\"Stereophonics\", \"Graffiti On the Train (Deluxe Version)\"), (\"Dido\", \"Girl Who Got Away (Deluxe)\"), (\"Hurts\", \"Exile\"), (\"Bruno Mars\", \"Doo-Wops & Hooligans\"), (\"Calvin Harris\", \"18 Months\"), (\"Olly Murs\", \"Right Place Right Time\"), (\"Alt-J (?)\", \"An Awesome Wave\"), (\"One Direction\", \"Take Me Home\"), (\"Various Artists\", \"Pop Stars\"), (\"Various Artists\", \"Now That's What I Call Music! 83\"), (\"John Grant\", \"Pale Green Ghosts\"), (\"Paloma Faith\", \"Fall to Grace\"), (\"Laura Mvula\", \"Sing To the Moon (Deluxe)\"), (\"Duke Dumont\", \"Need U (100%) [feat. A*M*E] - EP\"), (\"Watsky\", \"Cardboard Castles\"), (\"Blondie\", \"Blondie: Greatest Hits\"), (\"Foals\", \"Holy Fire\"), (\"Maroon 5\", \"Overexposed\"), (\"Bastille\", \"Pompeii (Remixes) - EP\"), (\"Imagine Dragons\", \"Hear Me - EP\"), (\"Various Artists\", \"100 Hits: 80s Classics\"), (\"Various Artists\", \"Les Misérables (Highlights From the Motion Picture Soundtrack)\"), (\"Mumford & Sons\", \"Sigh No More\"), (\"Frank Ocean\", \"Channel ORANGE\"), (\"Bon Jovi\", \"What About Now\"), (\"Various Artists\", \"BRIT Awards 2013\"), (\"Taylor Swift\", \"Red\"), (\"Fleetwood Mac\", \"Fleetwood Mac: Greatest Hits\"), (\"David Guetta\", \"Nothing But the Beat Ultimate\"), (\"Various Artists\", \"Clubbers Guide 2013 (Mixed By Danny Howard) - Ministry of Sound\"), (\"David Bowie\", \"Best of Bowie\"), (\"Laura Mvula\", \"Sing To the Moon\"), (\"ADELE\", \"21\"), (\"Of Monsters and Men\", \"My Head Is an Animal\"), (\"Rihanna\", \"Unapologetic\"), (\"Various Artists\", \"BBC Radio 1's Live Lounge - 2012\"), (\"Avicii & Nicky Romero\", \"I Could Be the One (Avicii vs. Nicky Romero)\"), (\"The Streets\", \"A Grand Don't Come for Free\"), (\"Tim McGraw\", \"Two Lanes of Freedom\"), (\"Foo Fighters\", \"Foo Fighters: Greatest Hits\"), (\"Various Artists\", \"Now That's What I Call Running!\"), (\"Swedish House Mafia\", \"Until Now\"), (\"The xx\", \"Coexist\"), (\"Five\", \"Five: Greatest Hits\"), (\"Jimi Hendrix\", \"People, Hell & Angels\"), (\"Biffy Clyro\", \"Opposites (Deluxe)\"), (\"The Smiths\", \"The Sound of the Smiths\"), (\"The Saturdays\", \"What About Us - EP\"), (\"Fleetwood Mac\", \"Rumours\"), (\"Various Artists\", \"The Big Reunion\"), (\"Various Artists\", \"Anthems 90s - Ministry of Sound\"), (\"The Vaccines\", \"Come of Age\"), (\"Nicole Scherzinger\", \"Boomerang (Remixes) - EP\"), (\"Bob Marley\", \"Legend (Bonus Track Version)\"), (\"Josh Groban\", \"All That Echoes\"), (\"Blue\", \"Best of Blue\"), (\"Ed Sheeran\", \"+\"), (\"Olly Murs\", \"In Case You Didn't Know (Deluxe Edition)\"), (\"Macklemore & Ryan Lewis\", \"The Heist (Deluxe Edition)\"), (\"Various Artists\", \"Defected Presents Most Rated Miami 2013\"), (\"Gorgon City\", \"Real EP\"), (\"Mumford & Sons\", \"Babel (Deluxe Version)\"), (\"Various Artists\", \"The Music of Nashville: Season 1, Vol. 1 (Original Soundtrack)\"), (\"Various Artists\", \"The Twilight Saga: Breaking Dawn, Pt. 2 (Original Motion Picture Soundtrack)\"), (\"Various Artists\", \"Mum - The Ultimate Mothers Day Collection\"), (\"One Direction\", \"Up All Night\"), (\"Bon Jovi\", \"Bon Jovi Greatest Hits\"), (\"Agnetha Fältskog\", \"A\"), (\"Fun.\", \"Some Nights\"), (\"Justin Bieber\", \"Believe Acoustic\"), (\"Atoms for Peace\", \"Amok\"), (\"Justin Timberlake\", \"Justified\"), (\"Passenger\", \"All the Little Lights\"), (\"Kodaline\", \"The High Hopes EP\"), (\"Lana Del Rey\", \"Born to Die\"), (\"JAY Z & Kanye West\", \"Watch the Throne (Deluxe Version)\"), (\"Biffy Clyro\", \"Opposites\"), (\"Various Artists\", \"Return of the 90s\"), (\"Gabrielle Aplin\", \"Please Don't Say You Love Me - EP\"), (\"Various Artists\", \"100 Hits - Driving Rock\"), (\"Jimi Hendrix\", \"Experience Hendrix - The Best of Jimi Hendrix\"), (\"Various Artists\", \"The Workout Mix 2013\"), (\"The 1975\", \"Sex\"), (\"Chase & Status\", \"No More Idols\"), (\"Rihanna\", \"Unapologetic (Deluxe Version)\"), (\"The Killers\", \"Battle Born\"), (\"Olly Murs\", \"Right Place Right Time (Deluxe Edition)\"), (\"A$AP Rocky\", \"LONG.LIVE.A$AP (Deluxe Version)\"), (\"Various Artists\", \"Cooking Songs\"), (\"Haim\", \"Forever - EP\"), (\"Lianne La Havas\", \"Is Your Love Big Enough?\"), (\"Michael Bublé\", \"To Be Loved\"), (\"Daughter\", \"If You Leave\"), (\"The xx\", \"xx\"), (\"Eminem\", \"Curtain Call\"), (\"Kendrick Lamar\", \"good kid, m.A.A.d city (Deluxe)\"), (\"Disclosure\", \"The Face - EP\"), (\"Palma Violets\", \"180\"), (\"Cody Simpson\", \"Paradise\"), (\"Ed Sheeran\", \"+ (Deluxe Version)\"), (\"Michael Bublé\", \"Crazy Love (Hollywood Edition)\"), (\"Bon Jovi\", \"Bon Jovi Greatest Hits - The Ultimate Collection\"), (\"Rita Ora\", \"Ora\"), (\"g33k\", \"Spabby\"), (\"Various Artists\", \"Annie Mac Presents 2012\"), (\"David Bowie\", \"The Platinum Collection\"), (\"Bridgit Mendler\", \"Ready or Not (Remixes) - EP\"), (\"Dido\", \"Girl Who Got Away\"), (\"Various Artists\", \"Now That's What I Call Disney\"), (\"The 1975\", \"Facedown - EP\"), (\"Kodaline\", \"The Kodaline - EP\"), (\"Various Artists\", \"100 Hits: Super 70s\"), (\"Fred V & Grafix\", \"Goggles - EP\"), (\"Biffy Clyro\", \"Only Revolutions (Deluxe Version)\"), (\"Train\", \"California 37\"), (\"Ben Howard\", \"Every Kingdom (Deluxe Edition)\"), (\"Various Artists\", \"Motown Anthems\"), (\"Courteeners\", \"ANNA\"), (\"Johnny Marr\", \"The Messenger\"), (\"Rodriguez\", \"Searching for Sugar Man\"), (\"Jessie Ware\", \"Devotion\"), (\"Bruno Mars\", \"Unorthodox Jukebox\"), (\"Various Artists\", \"Call the Midwife (Music From the TV Series)\" ); (The test data chosen happens to be the current 150 top iTunes albums at the time of writing!) Now create the database using the following: $ sqlite data/laminastutorial.db < data/album-fixtures.sql Some systems, including Ubuntu, use the command sqlite3 ; check to see which one to use on your system. Alternative Commands Some systems, including Ubuntu, use the command sqlite3 ; check to see which one to use on your system. SQLite3 If you use sqlite3 create the database using the following command: $ cat data/schema.sql | sqlite3 data/laminastutorial.db Using PHP to Create the Database If you do not have Sqlite installed on your system, you can use PHP to load the database using the same SQL schema file created earlier. Create the file data/load_album_fixtures.php with the following contents: <?php $db = new PDO('sqlite:' . realpath(__DIR__) . '/laminastutorial.db'); $fh = fopen(__DIR__ . '/album-fixtures.sql', 'r'); while ($line = fread($fh, 4096)) { $db->exec($line); } fclose($fh); Once created, execute it: $ php data/load_album_fixtures.php This gives us a handy extra 150 rows to play with. If you now visit your album list at /album , you'll see a huge long list of 150+ albums; it's ugly. Install laminas-paginator laminas-paginator is not installed or configured by default, so we will need to do that. laminas-paginator uses data source adapters to access data collections. In order to access data into our database, we will need an adapter that uses laminas-db which is provided by an additional component: laminas-paginator-adapter-laminasdb. Run the following from the application root: $ composer require laminas/laminas-paginator laminas-paginator-adapter-laminasdb Assuming you followed the Getting Started tutorial , you will be prompted by the laminas-component-installer plugin to inject Laminas\\Paginator and Laminas\\Paginator\\Adapter\\LaminasDb ; be sure to select the option for either config/application.config.php or config/modules.config.php ; since you are installing more than one package, you can answer \"y\" to the \"Remember this option for other packages of the same type\" prompt such that the same option is applied to both components. Manual configuration If you are not using laminas-component-installer, you will need to setup configuration manually. You can do this in one of two ways: Register the Laminas\\Paginator and Laminas\\Paginator\\Adapter\\LaminasDb modules in either config/application.config.php or config/modules.config.php . Make sure you put it towards the top of the module list, before any modules you have defined or third party modules you are using. Alternately, add a new file, config/autoload/paginator.global.php , with the following contents: <?php use Laminas\\Paginator\\ConfigProvider; use Laminas\\Paginator\\Adapter\\LaminasDb\\ConfigProvider as LaminasDbAdapterConfigProvider; return [ 'service_manager' => (new ConfigProvider())->getDependencyConfig(), 'paginators' => (new LaminasDbAdapterConfigProvider())->getPaginatorConfig(), ]; Once installed, our application is now aware of laminas-paginator and its data source adapters, and even has some default factories in place, which we will now make use of. Modifying the AlbumTable In order to let laminas-paginator handle our database queries automatically for us, we will be using the DbSelect pagination adapter This will automatically manipulate and run a Laminas\\Db\\Sql\\Select object to include the correct LIMIT and WHERE clauses so that it returns only the configured amount of data for the given page. Let's modify the fetchAll method of the AlbumTable model, so that it can optionally return a paginator object: // in module/Album/src/Model/AlbumTable.php: namespace Album\\Model; use Laminas\\Db\\ResultSet\\ResultSet; use Laminas\\Db\\Sql\\Select; use Laminas\\Db\\TableGateway\\TableGatewayInterface; use Laminas\\Paginator\\Adapter\\LaminasDb\\DbSelect; use Laminas\\Paginator\\Paginator; use RuntimeException; class AlbumTable { /* ... */ public function fetchAll($paginated = false) { if ($paginated) { return $this->fetchPaginatedResults(); } return $this->tableGateway->select(); } private function fetchPaginatedResults() { // Create a new Select object for the table: $select = new Select($this->tableGateway->getTable()); // Create a new result set based on the Album entity: $resultSetPrototype = new ResultSet(); $resultSetPrototype->setArrayObjectPrototype(new Album()); // Create a new pagination adapter object: $paginatorAdapter = new DbSelect( // our configured select object: $select, // the adapter to run it against: $this->tableGateway->getAdapter(), // the result set to hydrate: $resultSetPrototype ); return new Paginator($paginatorAdapter); } /* ... */ } This will return a fully configured Paginator instance using a DbSelect adapter. We've already told the DbSelect adapter to use our created Select object, to use the adapter that the TableGateway object uses, and also how to hydrate the result into a Album entity in the same fashion as the TableGateway does. This means that our executed and returned paginator results will return Album objects in exactly the same fashion as the non-paginated results. Modifying the AlbumController Next, we need to tell the album controller to provide the view with a Pagination object instead of a ResultSet . Both these objects can by iterated over to return hydrated Album objects, so we won't need to make many changes to the view script: // in module/Album/src/Controller/AlbumController.php: /* ... */ public function indexAction() { // Grab the paginator from the AlbumTable: $paginator = $this->table->fetchAll(true); // Set the current page to what has been passed in query string, // or to 1 if none is set, or the page is invalid: $page = (int) $this->params()->fromQuery('page', 1); $page = ($page < 1) ? 1 : $page; $paginator->setCurrentPageNumber($page); // Set the number of items per page to 10: $paginator->setItemCountPerPage(10); return new ViewModel(['paginator' => $paginator]); } /* ... */ Here we are getting the configured Paginator object from the AlbumTable , and then telling it to use the page that is optionally passed in the querystring page parameter (after first validating it). We are also telling the paginator we want to display 10 albums per page. Updating the View Script Now, tell the view script to iterate over the pagination view variable, rather than the albums variable: <?php // in module/Album/view/album/album/index.phtml: $title = 'My albums'; $this->headTitle($title); ?> <h1><?= $this->escapeHtml($title); ?></h1> <p> <a href=\"<?= $this->url('album', ['action' => 'add']) ?>\">Add new album</a> </p> <table class=\"table\"> <tr> <th>Title</th> <th>Artist</th> <th> </th> </tr> <?php foreach ($this->paginator as $album) : // <-- change here! ?> <tr> <td><?= $this->escapeHtml($album->title) ?></td> <td><?= $this->escapeHtml($album->artist) ?></td> <td> <a href=\"<?= $this->url('album', ['action' => 'edit', 'id' => $album->id]) ?>\"> Edit </a> <a href=\"<?= $this->url('album', ['action' => 'delete', 'id' => $album->id]) ?>\"> Delete </a> </td> </tr> <?php endforeach; ?> </table> Checking the /album route on your website should now give you a list of just 10 albums, but with no method to navigate through the pages. Let's correct that now. Creating the Pagination Control Partial Much like we created a custom breadcrumbs partial to render our breadcrumb in the navigation tutorial , we need to create a custom pagination control partial to render our pagination control just the way we want it. Again, because we are using Bootstrap, this will primarily involve outputting correctly formatted HTML. Let's create the partial in the module/Application/view/partial/ folder, so that we can use the control in all our modules: <?php // in module/Application/view/partial/paginator.phtml: ?> <?php if ($this->pageCount): ?> <nav> <ul class=\"pagination\"> <!-- Previous page link --> <?php if (isset($this->previous)): ?> <li class=\"page-item\"> <a class=\"page-link\" href=\"<?= $this->url($this->route, [], ['query' => ['page' => $this->previous]]) ?>\"> Previous </a> </li> <?php else: ?> <li class=\"page-item disabled\"> <span class=\"page-link\">Previous</span> </li> <?php endif ?> <!-- Numbered page links --> <?php foreach ($this->pagesInRange as $page): ?> <?php if ($page !== $this->current): ?> <li class=\"page-item\"> <a class=\"page-link\" href=\"<?= $this->url($this->route, [], ['query' => ['page' => $page]]) ?>\"> <?= $page ?> </a> </li> <?php else: ?> <li class=\"page-item active\" aria-current=\"page\"> <span class=\"page-link\"><?= $page ?></span> </li> <?php endif ?> <?php endforeach ?> <!-- Next page link --> <?php if (isset($this->next)): ?> <li class=\"page-item\"> <a class=\"page-link\" href=\"<?= $this->url($this->route, [], ['query' => ['page' => $this->next]]) ?>\"> Next </a> </li> <?php else: ?> <li class=\"page-item disabled\"> <span class=\"page-link\">Next</span> </li> <?php endif ?> </ul> </nav> <?php endif ?> This partial creates a pagination control with links to the correct pages (if there is more than one page in the pagination object). It will render a previous page link (and mark it disabled if you are at the first page), then render a list of intermediate pages (that are passed to the partial based on the rendering style; we'll pass that to the view helper in the next step). Finally, it will create a next page link (and disable it if you're at the end). Notice how we pass the page number via the page querystring parameter which we have already told our controller to use to display the current page. Using the PaginationControl View Helper To page through the albums, we need to invoke the paginationControl view helper to display our pagination control: <?php // In module/Album/view/album/album/index.phtml: // Add at the end of the file after the table: ?> <?= $this->paginationControl( // The paginator object: $this->paginator, // The scrolling style: 'sliding', // The partial to use to render the control: 'partial/paginator', // The route to link to when a user clicks a control link: ['route' => 'album'] ) ?> The above echoes the paginationControl helper, and tells it to use our paginator instance, the sliding scrolling style , our paginator partial, and which route to use for generating links. Refreshing your application now should give you Bootstrap-styled pagination controls!","title":"Adding laminas-paginator to the Album Module"},{"location":"pagination/#using-laminas-paginator-in-your-album-module","text":"In this tutorial, we will use the laminas-paginator component to add a handy pagination controller to the bottom of the album list. Currently, we only have a handful of albums to display, so showing everything on one page is not a problem. However, how will the album list look when we have 100 albums or more in our database? The standard solution to this problem is to split the data up into a number of pages, and allow the user to navigate around these pages using a pagination control. A typical paginator on a web page looks like this:","title":"Using laminas-paginator in your Album Module"},{"location":"pagination/#preparation","text":"As before, we are going to use sqlite, via PHP's PDO driver. Create a text file data/album-fixtures.sql with the following contents: INSERT INTO \"album\" (\"artist\", \"title\") VALUES (\"David Bowie\", \"The Next Day (Deluxe Version)\"), (\"Bastille\", \"Bad Blood\"), (\"Bruno Mars\", \"Unorthodox Jukebox\"), (\"Emeli Sandé\", \"Our Version of Events (Special Edition)\"), (\"Bon Jovi\", \"What About Now (Deluxe Version)\"), (\"Justin Timberlake\", \"The 20/20 Experience (Deluxe Version)\"), (\"Bastille\", \"Bad Blood (The Extended Cut)\"), (\"P!nk\", \"The Truth About Love\"), (\"Sound City - Real to Reel\", \"Sound City - Real to Reel\"), (\"Jake Bugg\", \"Jake Bugg\"), (\"Various Artists\", \"The Trevor Nelson Collection\"), (\"David Bowie\", \"The Next Day\"), (\"Mumford & Sons\", \"Babel\"), (\"The Lumineers\", \"The Lumineers\"), (\"Various Artists\", \"Get Ur Freak On - R&B Anthems\"), (\"The 1975\", \"Music For Cars EP\"), (\"Various Artists\", \"Saturday Night Club Classics - Ministry of Sound\"), (\"Hurts\", \"Exile (Deluxe)\"), (\"Various Artists\", \"Mixmag - The Greatest Dance Tracks of All Time\"), (\"Ben Howard\", \"Every Kingdom\"), (\"Stereophonics\", \"Graffiti On the Train\"), (\"The Script\", \"#3\"), (\"Stornoway\", \"Tales from Terra Firma\"), (\"David Bowie\", \"Hunky Dory (Remastered)\"), (\"Worship Central\", \"Let It Be Known (Live)\"), (\"Ellie Goulding\", \"Halcyon\"), (\"Various Artists\", \"Dermot O'Leary Presents the Saturday Sessions 2013\"), (\"Stereophonics\", \"Graffiti On the Train (Deluxe Version)\"), (\"Dido\", \"Girl Who Got Away (Deluxe)\"), (\"Hurts\", \"Exile\"), (\"Bruno Mars\", \"Doo-Wops & Hooligans\"), (\"Calvin Harris\", \"18 Months\"), (\"Olly Murs\", \"Right Place Right Time\"), (\"Alt-J (?)\", \"An Awesome Wave\"), (\"One Direction\", \"Take Me Home\"), (\"Various Artists\", \"Pop Stars\"), (\"Various Artists\", \"Now That's What I Call Music! 83\"), (\"John Grant\", \"Pale Green Ghosts\"), (\"Paloma Faith\", \"Fall to Grace\"), (\"Laura Mvula\", \"Sing To the Moon (Deluxe)\"), (\"Duke Dumont\", \"Need U (100%) [feat. A*M*E] - EP\"), (\"Watsky\", \"Cardboard Castles\"), (\"Blondie\", \"Blondie: Greatest Hits\"), (\"Foals\", \"Holy Fire\"), (\"Maroon 5\", \"Overexposed\"), (\"Bastille\", \"Pompeii (Remixes) - EP\"), (\"Imagine Dragons\", \"Hear Me - EP\"), (\"Various Artists\", \"100 Hits: 80s Classics\"), (\"Various Artists\", \"Les Misérables (Highlights From the Motion Picture Soundtrack)\"), (\"Mumford & Sons\", \"Sigh No More\"), (\"Frank Ocean\", \"Channel ORANGE\"), (\"Bon Jovi\", \"What About Now\"), (\"Various Artists\", \"BRIT Awards 2013\"), (\"Taylor Swift\", \"Red\"), (\"Fleetwood Mac\", \"Fleetwood Mac: Greatest Hits\"), (\"David Guetta\", \"Nothing But the Beat Ultimate\"), (\"Various Artists\", \"Clubbers Guide 2013 (Mixed By Danny Howard) - Ministry of Sound\"), (\"David Bowie\", \"Best of Bowie\"), (\"Laura Mvula\", \"Sing To the Moon\"), (\"ADELE\", \"21\"), (\"Of Monsters and Men\", \"My Head Is an Animal\"), (\"Rihanna\", \"Unapologetic\"), (\"Various Artists\", \"BBC Radio 1's Live Lounge - 2012\"), (\"Avicii & Nicky Romero\", \"I Could Be the One (Avicii vs. Nicky Romero)\"), (\"The Streets\", \"A Grand Don't Come for Free\"), (\"Tim McGraw\", \"Two Lanes of Freedom\"), (\"Foo Fighters\", \"Foo Fighters: Greatest Hits\"), (\"Various Artists\", \"Now That's What I Call Running!\"), (\"Swedish House Mafia\", \"Until Now\"), (\"The xx\", \"Coexist\"), (\"Five\", \"Five: Greatest Hits\"), (\"Jimi Hendrix\", \"People, Hell & Angels\"), (\"Biffy Clyro\", \"Opposites (Deluxe)\"), (\"The Smiths\", \"The Sound of the Smiths\"), (\"The Saturdays\", \"What About Us - EP\"), (\"Fleetwood Mac\", \"Rumours\"), (\"Various Artists\", \"The Big Reunion\"), (\"Various Artists\", \"Anthems 90s - Ministry of Sound\"), (\"The Vaccines\", \"Come of Age\"), (\"Nicole Scherzinger\", \"Boomerang (Remixes) - EP\"), (\"Bob Marley\", \"Legend (Bonus Track Version)\"), (\"Josh Groban\", \"All That Echoes\"), (\"Blue\", \"Best of Blue\"), (\"Ed Sheeran\", \"+\"), (\"Olly Murs\", \"In Case You Didn't Know (Deluxe Edition)\"), (\"Macklemore & Ryan Lewis\", \"The Heist (Deluxe Edition)\"), (\"Various Artists\", \"Defected Presents Most Rated Miami 2013\"), (\"Gorgon City\", \"Real EP\"), (\"Mumford & Sons\", \"Babel (Deluxe Version)\"), (\"Various Artists\", \"The Music of Nashville: Season 1, Vol. 1 (Original Soundtrack)\"), (\"Various Artists\", \"The Twilight Saga: Breaking Dawn, Pt. 2 (Original Motion Picture Soundtrack)\"), (\"Various Artists\", \"Mum - The Ultimate Mothers Day Collection\"), (\"One Direction\", \"Up All Night\"), (\"Bon Jovi\", \"Bon Jovi Greatest Hits\"), (\"Agnetha Fältskog\", \"A\"), (\"Fun.\", \"Some Nights\"), (\"Justin Bieber\", \"Believe Acoustic\"), (\"Atoms for Peace\", \"Amok\"), (\"Justin Timberlake\", \"Justified\"), (\"Passenger\", \"All the Little Lights\"), (\"Kodaline\", \"The High Hopes EP\"), (\"Lana Del Rey\", \"Born to Die\"), (\"JAY Z & Kanye West\", \"Watch the Throne (Deluxe Version)\"), (\"Biffy Clyro\", \"Opposites\"), (\"Various Artists\", \"Return of the 90s\"), (\"Gabrielle Aplin\", \"Please Don't Say You Love Me - EP\"), (\"Various Artists\", \"100 Hits - Driving Rock\"), (\"Jimi Hendrix\", \"Experience Hendrix - The Best of Jimi Hendrix\"), (\"Various Artists\", \"The Workout Mix 2013\"), (\"The 1975\", \"Sex\"), (\"Chase & Status\", \"No More Idols\"), (\"Rihanna\", \"Unapologetic (Deluxe Version)\"), (\"The Killers\", \"Battle Born\"), (\"Olly Murs\", \"Right Place Right Time (Deluxe Edition)\"), (\"A$AP Rocky\", \"LONG.LIVE.A$AP (Deluxe Version)\"), (\"Various Artists\", \"Cooking Songs\"), (\"Haim\", \"Forever - EP\"), (\"Lianne La Havas\", \"Is Your Love Big Enough?\"), (\"Michael Bublé\", \"To Be Loved\"), (\"Daughter\", \"If You Leave\"), (\"The xx\", \"xx\"), (\"Eminem\", \"Curtain Call\"), (\"Kendrick Lamar\", \"good kid, m.A.A.d city (Deluxe)\"), (\"Disclosure\", \"The Face - EP\"), (\"Palma Violets\", \"180\"), (\"Cody Simpson\", \"Paradise\"), (\"Ed Sheeran\", \"+ (Deluxe Version)\"), (\"Michael Bublé\", \"Crazy Love (Hollywood Edition)\"), (\"Bon Jovi\", \"Bon Jovi Greatest Hits - The Ultimate Collection\"), (\"Rita Ora\", \"Ora\"), (\"g33k\", \"Spabby\"), (\"Various Artists\", \"Annie Mac Presents 2012\"), (\"David Bowie\", \"The Platinum Collection\"), (\"Bridgit Mendler\", \"Ready or Not (Remixes) - EP\"), (\"Dido\", \"Girl Who Got Away\"), (\"Various Artists\", \"Now That's What I Call Disney\"), (\"The 1975\", \"Facedown - EP\"), (\"Kodaline\", \"The Kodaline - EP\"), (\"Various Artists\", \"100 Hits: Super 70s\"), (\"Fred V & Grafix\", \"Goggles - EP\"), (\"Biffy Clyro\", \"Only Revolutions (Deluxe Version)\"), (\"Train\", \"California 37\"), (\"Ben Howard\", \"Every Kingdom (Deluxe Edition)\"), (\"Various Artists\", \"Motown Anthems\"), (\"Courteeners\", \"ANNA\"), (\"Johnny Marr\", \"The Messenger\"), (\"Rodriguez\", \"Searching for Sugar Man\"), (\"Jessie Ware\", \"Devotion\"), (\"Bruno Mars\", \"Unorthodox Jukebox\"), (\"Various Artists\", \"Call the Midwife (Music From the TV Series)\" ); (The test data chosen happens to be the current 150 top iTunes albums at the time of writing!) Now create the database using the following: $ sqlite data/laminastutorial.db < data/album-fixtures.sql Some systems, including Ubuntu, use the command sqlite3 ; check to see which one to use on your system. Alternative Commands Some systems, including Ubuntu, use the command sqlite3 ; check to see which one to use on your system.","title":"Preparation"},{"location":"pagination/#install-laminas-paginator","text":"laminas-paginator is not installed or configured by default, so we will need to do that. laminas-paginator uses data source adapters to access data collections. In order to access data into our database, we will need an adapter that uses laminas-db which is provided by an additional component: laminas-paginator-adapter-laminasdb. Run the following from the application root: $ composer require laminas/laminas-paginator laminas-paginator-adapter-laminasdb Assuming you followed the Getting Started tutorial , you will be prompted by the laminas-component-installer plugin to inject Laminas\\Paginator and Laminas\\Paginator\\Adapter\\LaminasDb ; be sure to select the option for either config/application.config.php or config/modules.config.php ; since you are installing more than one package, you can answer \"y\" to the \"Remember this option for other packages of the same type\" prompt such that the same option is applied to both components.","title":"Install laminas-paginator"},{"location":"pagination/#modifying-the-albumtable","text":"In order to let laminas-paginator handle our database queries automatically for us, we will be using the DbSelect pagination adapter This will automatically manipulate and run a Laminas\\Db\\Sql\\Select object to include the correct LIMIT and WHERE clauses so that it returns only the configured amount of data for the given page. Let's modify the fetchAll method of the AlbumTable model, so that it can optionally return a paginator object: // in module/Album/src/Model/AlbumTable.php: namespace Album\\Model; use Laminas\\Db\\ResultSet\\ResultSet; use Laminas\\Db\\Sql\\Select; use Laminas\\Db\\TableGateway\\TableGatewayInterface; use Laminas\\Paginator\\Adapter\\LaminasDb\\DbSelect; use Laminas\\Paginator\\Paginator; use RuntimeException; class AlbumTable { /* ... */ public function fetchAll($paginated = false) { if ($paginated) { return $this->fetchPaginatedResults(); } return $this->tableGateway->select(); } private function fetchPaginatedResults() { // Create a new Select object for the table: $select = new Select($this->tableGateway->getTable()); // Create a new result set based on the Album entity: $resultSetPrototype = new ResultSet(); $resultSetPrototype->setArrayObjectPrototype(new Album()); // Create a new pagination adapter object: $paginatorAdapter = new DbSelect( // our configured select object: $select, // the adapter to run it against: $this->tableGateway->getAdapter(), // the result set to hydrate: $resultSetPrototype ); return new Paginator($paginatorAdapter); } /* ... */ } This will return a fully configured Paginator instance using a DbSelect adapter. We've already told the DbSelect adapter to use our created Select object, to use the adapter that the TableGateway object uses, and also how to hydrate the result into a Album entity in the same fashion as the TableGateway does. This means that our executed and returned paginator results will return Album objects in exactly the same fashion as the non-paginated results.","title":"Modifying the AlbumTable"},{"location":"pagination/#modifying-the-albumcontroller","text":"Next, we need to tell the album controller to provide the view with a Pagination object instead of a ResultSet . Both these objects can by iterated over to return hydrated Album objects, so we won't need to make many changes to the view script: // in module/Album/src/Controller/AlbumController.php: /* ... */ public function indexAction() { // Grab the paginator from the AlbumTable: $paginator = $this->table->fetchAll(true); // Set the current page to what has been passed in query string, // or to 1 if none is set, or the page is invalid: $page = (int) $this->params()->fromQuery('page', 1); $page = ($page < 1) ? 1 : $page; $paginator->setCurrentPageNumber($page); // Set the number of items per page to 10: $paginator->setItemCountPerPage(10); return new ViewModel(['paginator' => $paginator]); } /* ... */ Here we are getting the configured Paginator object from the AlbumTable , and then telling it to use the page that is optionally passed in the querystring page parameter (after first validating it). We are also telling the paginator we want to display 10 albums per page.","title":"Modifying the AlbumController"},{"location":"pagination/#updating-the-view-script","text":"Now, tell the view script to iterate over the pagination view variable, rather than the albums variable: <?php // in module/Album/view/album/album/index.phtml: $title = 'My albums'; $this->headTitle($title); ?> <h1><?= $this->escapeHtml($title); ?></h1> <p> <a href=\"<?= $this->url('album', ['action' => 'add']) ?>\">Add new album</a> </p> <table class=\"table\"> <tr> <th>Title</th> <th>Artist</th> <th> </th> </tr> <?php foreach ($this->paginator as $album) : // <-- change here! ?> <tr> <td><?= $this->escapeHtml($album->title) ?></td> <td><?= $this->escapeHtml($album->artist) ?></td> <td> <a href=\"<?= $this->url('album', ['action' => 'edit', 'id' => $album->id]) ?>\"> Edit </a> <a href=\"<?= $this->url('album', ['action' => 'delete', 'id' => $album->id]) ?>\"> Delete </a> </td> </tr> <?php endforeach; ?> </table> Checking the /album route on your website should now give you a list of just 10 albums, but with no method to navigate through the pages. Let's correct that now.","title":"Updating the View Script"},{"location":"pagination/#creating-the-pagination-control-partial","text":"Much like we created a custom breadcrumbs partial to render our breadcrumb in the navigation tutorial , we need to create a custom pagination control partial to render our pagination control just the way we want it. Again, because we are using Bootstrap, this will primarily involve outputting correctly formatted HTML. Let's create the partial in the module/Application/view/partial/ folder, so that we can use the control in all our modules: <?php // in module/Application/view/partial/paginator.phtml: ?> <?php if ($this->pageCount): ?> <nav> <ul class=\"pagination\"> <!-- Previous page link --> <?php if (isset($this->previous)): ?> <li class=\"page-item\"> <a class=\"page-link\" href=\"<?= $this->url($this->route, [], ['query' => ['page' => $this->previous]]) ?>\"> Previous </a> </li> <?php else: ?> <li class=\"page-item disabled\"> <span class=\"page-link\">Previous</span> </li> <?php endif ?> <!-- Numbered page links --> <?php foreach ($this->pagesInRange as $page): ?> <?php if ($page !== $this->current): ?> <li class=\"page-item\"> <a class=\"page-link\" href=\"<?= $this->url($this->route, [], ['query' => ['page' => $page]]) ?>\"> <?= $page ?> </a> </li> <?php else: ?> <li class=\"page-item active\" aria-current=\"page\"> <span class=\"page-link\"><?= $page ?></span> </li> <?php endif ?> <?php endforeach ?> <!-- Next page link --> <?php if (isset($this->next)): ?> <li class=\"page-item\"> <a class=\"page-link\" href=\"<?= $this->url($this->route, [], ['query' => ['page' => $this->next]]) ?>\"> Next </a> </li> <?php else: ?> <li class=\"page-item disabled\"> <span class=\"page-link\">Next</span> </li> <?php endif ?> </ul> </nav> <?php endif ?> This partial creates a pagination control with links to the correct pages (if there is more than one page in the pagination object). It will render a previous page link (and mark it disabled if you are at the first page), then render a list of intermediate pages (that are passed to the partial based on the rendering style; we'll pass that to the view helper in the next step). Finally, it will create a next page link (and disable it if you're at the end). Notice how we pass the page number via the page querystring parameter which we have already told our controller to use to display the current page.","title":"Creating the Pagination Control Partial"},{"location":"unit-testing/","text":"Unit Testing a Laminas MVC application A solid unit test suite is essential for ongoing development in large projects, especially those with many people involved. Going back and manually testing every individual component of an application after every change is impractical. Your unit tests will help alleviate that by automatically testing your application's components and alerting you when something is not working the same way it was when you wrote your tests. This tutorial is written in the hopes of showing how to test different parts of a laminas-mvc application. As such, this tutorial will use the application written in the getting started user guide . It is in no way a guide to unit testing in general, but is here only to help overcome the initial hurdles in writing unit tests for laminas-mvc applications. It is recommended to have at least a basic understanding of unit tests, assertions and mocks. laminas-test , which provides testing integration for laminas-mvc, uses PHPUnit ; this tutorial will cover using that library for testing your applications. Installing laminas-test laminas-test provides PHPUnit integration for laminas-mvc, including application scaffolding and custom assertions. You will need to install it: $ composer require --dev laminas/laminas-test This will also install phpunit/phpunit since it is required by laminas-test. The above command will update your composer.json file and perform an update for you, which will also setup autoloading rules. Running the initial tests Out-of-the-box, the skeleton application provides several tests for the shipped Application\\Controller\\IndexController class. Now that you have laminas-test installed, you can run these: $ ./vendor/bin/phpunit PHPUnit invocation on Windows Command Shell On Windows, you need to wrap the command in double quotes: C:\\> \"vendor/bin/phpunit\" You should see output similar to the following: PHPUnit 10.5.13 by Sebastian Bergmann and contributors. .... 4 / 4 (100%) Time: 00:00.334, Memory: 16.00 MB Tests: 4, Assertions: 6, Failures: 0. There might be 1 failing test if you followed the getting started guide. This is because the Application\\IndexController is overridden by the AlbumController . This can be ignored for now. Now it's time to write our own tests! Setting up the tests directory As laminas-mvc applications are built from modules that should be standalone blocks of an application, we don't test the application in its entirety, but module by module. We will demonstrate setting up the minimum requirements to test a module, the Album module we wrote in the user guide, which then can be used as a base for testing any other module. Start by creating a directory called test under module/Album/ with the following subdirectories: module/ Album/ test/ Controller/ Additionally, add an autoload-dev rule in your composer.json : \"autoload-dev\": { \"psr-4\": { \"ApplicationTest\\\\\": \"module/Application/test/\", \"AlbumTest\\\\\": \"module/Album/test/\" } } When done, run: $ composer dump-autoload The structure of the test directory matches exactly with that of the module's source files, and it will allow you to keep your tests well-organized and easy to find. Bootstrapping your tests Next, edit the phpunit.xml.dist file at the project root; we'll add a new test suite to it and modify the existing \"Laminas MVC Application Test Suite\". When done, it should read as follows: <?xml version=\"1.0\" encoding=\"UTF-8\"?> <phpunit colors=\"true\"> <testsuites> <testsuite name=\"Laminas MVC Application Test Suite\"> <directory>./module/Application/test</directory> </testsuite> <testsuite name=\"Album\"> <directory>./module/Album/test</directory> </testsuite> </testsuites> </phpunit> Now run your new Album test suite from the project root: $ ./vendor/bin/phpunit --testsuite Album Windows and PHPUnit On Windows, don't forget to wrap the phpunit command in double quotes: $ \"vendor/bin/phpunit\" --testsuite Album You should get similar output to the following: PHPUnit 10.5.13 by Sebastian Bergmann and contributors. Runtime: PHP 8.3.2 Configuration: <your_local_path>\\phpunit.xml.dist No tests executed! Let's write our first test! Your first controller test Testing controllers is never an easy task, but the laminas-test component makes testing much less cumbersome. First, create AlbumControllerTest.php under module/Album/test/Controller/ with the following contents: <?php namespace AlbumTest\\Controller; use Album\\Controller\\AlbumController; use Laminas\\Stdlib\\ArrayUtils; use Laminas\\Test\\PHPUnit\\Controller\\AbstractHttpControllerTestCase; class AlbumControllerTest extends AbstractHttpControllerTestCase { protected $traceError = false; protected function setUp() : void { // The module configuration should still be applicable for tests. // You can override configuration here with test case specific values, // such as sample view templates, path stacks, module_listener_options, // etc. $configOverrides = []; $this->setApplicationConfig(ArrayUtils::merge( // Grabbing the full application configuration: include __DIR__ . '/../../../../config/application.config.php', $configOverrides )); parent::setUp(); } } The AbstractHttpControllerTestCase class we extend here helps us setting up the application itself, helps with dispatching and other tasks that happen during a request, and offers methods for asserting request params, response headers, redirects, and more. See the laminas-test documentation for more information. The principal requirement for any laminas-test test case is to set the application config with the setApplicationConfig() method. For now, we assume the default application configuration will be appropriate; however, we can override values locally within the test using the $configOverrides variable. Now, add the following method to the AlbumControllerTest class: public function testIndexActionCanBeAccessed() { $this->dispatch('/album'); $this->assertResponseStatusCode(200); $this->assertModuleName('Album'); $this->assertControllerName(AlbumController::class); $this->assertControllerClass('AlbumController'); $this->assertMatchedRouteName('album'); } This test case dispatches the /album URL, asserts that the response code is 200, and that we ended up in the desired module and controller. Assert against controller service names For asserting the controller name we are using the controller name we defined in our routing configuration for the Album module. In our example this should be defined on line 16 of the module.config.php file in the Album module. If you run: $ ./vendor/bin/phpunit --testsuite Album again, you should see something like the following: PHPUnit 10.5.13 by Sebastian Bergmann and contributors. Runtime: PHP 8.3.2 Configuration: <your_local_path>\\phpunit.xml.dist . 1 / 1 (100%) Time: 00:00.210, Memory: 14.00 MB OK (1 test, 7 assertions) A successful first test! A failing test case We likely don't want to hit the same database during testing as we use for our web property. Let's add some configuration to the test case to remove the database configuration. In your AlbumControllerTest::setUp() method, add the following lines right after the call to parent::setUp(); : $services = $this->getApplicationServiceLocator(); $config = $services->get('config'); unset($config['db']); $services->setAllowOverride(true); $services->setService('config', $config); $services->setAllowOverride(false); The above removes the 'db' configuration entirely; we'll be replacing it with something else before long. When we run the tests now: $ ./vendor/bin/phpunit --testsuite Album PHPUnit 10.5.13 by Sebastian Bergmann and contributors. Runtime: PHP 8.3.2 Configuration: <your_local_path>\\phpunit.xml.dist F 1 / 1 (100%) Time: 00:00.208, Memory: 12.00 MB There was 1 failure: 1) AlbumTest\\Controller\\AlbumControllerTest::testIndexActionCanBeAccessed Failed asserting response code \"200\", actual status code is \"500\" <your_local_path>\\vendor\\laminas\\laminas-test\\src\\PHPUnit\\Controller\\AbstractControllerTestCase.php:433 <your_local_path>\\module\\Album\\test\\Controller\\AlbumControllerTest.php:38 -- There was 1 risky test: 1) AlbumTest\\Controller\\AlbumControllerTest::testIndexActionCanBeAccessed This test did not perform any assertions <your_local_path>\\module\\Album\\test\\Controller\\AlbumControllerTest.php:35 -- 1 test triggered 1 PHP warning: 1) <your_local_path>\\vendor\\laminas\\laminas-db\\src\\Adapter\\AdapterServiceFactory.php:21 Undefined array key \"db\" Triggered by: * AlbumTest\\Controller\\AlbumControllerTest::testIndexActionCanBeAccessed <your_local_path>\\module\\Album\\test\\Controller\\AlbumControllerTest.php:35 FAILURES! Tests: 1, Assertions: 0, Failures: 1, Warnings: 1, Risky: 1. The failure message doesn't tell us much, apart from that the expected status code is not 200, but 500. To get a bit more information when something goes wrong in a test case, we set the protected $traceError member to true (which is the default; we set it to false to demonstrate this capability). We also got risky test and warning report. The warning was expected since we removed the db key from the configuration which is expected by the AdapterServiceFactory database adapter factory. And since the test case does not execute beyond the response status code assertion, we get a risk test indication that the test did not performed any assertions. Modify the following line from just above the setUp method in our AlbumControllerTest class: protected $traceError = true; Running the phpunit command again and we should see some more information about what went wrong in our test. You'll get a list of the exceptions raised, along with their messages, the filename, and line number: There was 1 failure: 1) AlbumTest\\Controller\\AlbumControllerTest::testIndexActionCanBeAccessed Failed asserting response code \"200\", actual status code is \"500\" Exceptions raised: Exception 'Laminas\\ServiceManager\\Exception\\ServiceNotCreatedException' with message 'Service with name \"Laminas\\Db\\Adapter\\AdapterInterface\" could not be created. Reason: The supplied or instantiated driver object does not implement Laminas\\Db\\Adapter\\Driver\\DriverInterface' in <your_local_path>\\vendor\\laminas\\laminas-servicemanager\\src\\ServiceManager.php:649 Exception 'Laminas\\Db\\Adapter\\Exception\\InvalidArgumentException' with message 'The supplied or instantiated driver object does not implement Laminas\\Db\\Adapter\\Driver\\DriverInterface' in <your_local_path>\\vendor\\laminas\\laminas-db\\src\\Adapter\\Adapter.php:78 Based on the exception messages, it appears we are unable to create a laminas-db adapter instance, due to missing configuration! Configuring the service manager for the tests The error says that the service manager can not create an instance of a database adapter for us. The database adapter is indirectly used by our Album\\Model\\AlbumTable to fetch the list of albums from the database. The first thought would be to create an instance of an adapter, pass it to the service manager, and let the code run from there as is. The problem with this approach is that we would end up with our test cases actually doing queries against the database. To keep our tests fast, and to reduce the number of possible failure points in our tests, this should be avoided. The second thought would be then to create a mock of the database adapter, and prevent the actual database calls by mocking them out. This is a much better approach, but creating the adapter mock is tedious (but no doubt we will have to create it at some point). The best thing to do would be to mock out our Album\\Model\\AlbumTable class which retrieves the list of albums from the database. Remember, we are now testing our controller , so we can mock out the actual call to fetchAll and replace the return values with dummy values. At this point, we are not interested in how fetchAll() retrieves the albums, but only that it gets called and that it returns an array of albums; these facts allow us to provide mock instances. When we test AlbumTable itself, we can write the actual tests for the fetchAll method. First, let's do some setup. Add import statements to the top of the test class file for each of the AlbumTable and ServiceManager classes: use Album\\Model\\AlbumTable; use Laminas\\ServiceManager\\ServiceManager; Now add the following property to the test class: protected $albumTable; Next, we'll create three new methods that we'll invoke during setup: protected function configureServiceManager(ServiceManager $services): void { $services->setAllowOverride(true); $services->setService('config', $this->updateConfig($services->get('config'))); $services->setService(AlbumTable::class, $this->mockAlbumTable()); $services->setAllowOverride(false); } protected function updateConfig($config) { $config['db'] = []; return $config; } protected function mockAlbumTable(): AlbumTable { $this->albumTable = $this->createMock(AlbumTable::class); return $this->albumTable; } By default, the ServiceManager does not allow us to replace existing services. configureServiceManager() calls a special method on the instance to enable overriding services, and then we inject specific overrides we wish to use. When done, we disable overrides to ensure that if, during dispatch, any code attempts to override a service, an exception will be raised. The last method above creates a mock instance of our AlbumTable . The instance returned by $this->createMock() is a mock AlbumTable object that will then be asserted against. With this in place, we can update our setUp() method to read as follows: protected function setUp() : void { // The module configuration should still be applicable for tests. // You can override configuration here with test case specific values, // such as sample view templates, path stacks, module_listener_options, // etc. $configOverrides = []; $this->setApplicationConfig(ArrayUtils::merge( include __DIR__ . '/../../../../config/application.config.php', $configOverrides )); parent::setUp(); $this->configureServiceManager($this->getApplicationServiceLocator()); } Now update the testIndexActionCanBeAccessed() method to add a line asserting the AlbumTable 's fetchAll() method will be called, and return an array. This is achieved by configuring the mock AlbumTable to expect the saveAlbum method to be called once and to return an empty array. public function testIndexActionCanBeAccessed() { $this->albumTable->expects($this->once()) ->method('fetchAll') ->willReturn([]); $this->dispatch('/album'); $this->assertResponseStatusCode(200); $this->assertModuleName('Album'); $this->assertControllerName(AlbumController::class); $this->assertControllerClass('AlbumController'); $this->assertMatchedRouteName('album'); } Running phpunit at this point, we will get the following output as the tests now pass: $ ./vendor/bin/phpunit --testsuite Album PHPUnit 10.5.13 by Sebastian Bergmann and contributors. Runtime: PHP 8.3.2 Configuration: <your_local_path>\\phpunit.xml.dist . 1 / 1 (100%) Time: 00:00.219, Memory: 12.00 MB OK (1 test, 8 assertions) Testing actions with POST A common scenario with controllers is processing POST data submitted via a form, as we do in the AlbumController::addAction() . Let's write a test for that. public function testAddActionRedirectAfterValidPost() { $this->albumTable->expects($this->once()) ->method('saveAlbum') ->with($this->isInstanceOf(Album::class)); $postData = [ 'title' => 'Lez Zeppelin III', 'artist' => 'Led Zeppelin', 'id' => '', ]; $this->dispatch('/album/add', 'POST', $postData); $this->assertResponseStatusCode(302); $this->assertRedirectTo('/album'); } This test case references the Album class that we need to import; add the following import statement at the top of the class file: use Album\\Model\\Album; For this test case, the AlbumTable mock is configured to expect the saveAlbum method to be called once with an argument that must be an instance of Album . (We could have also done deeper assertions to ensure the Album instance contained expected data.) When we dispatch the application this time, we use the request method POST, and pass data to it. This test case then asserts a 302 response status, and introduces a new assertion against the location to which the response redirects. Running phpunit gives us the following output: $ ./vendor/bin/phpunit --testsuite Album PHPUnit 10.5.13 by Sebastian Bergmann and contributors. Runtime: PHP 8.3.2 Configuration: <your_local_path>\\phpunit.xml.dist .. 2 / 2 (100%) Time: 00:00.236, Memory: 14.00 MB OK (2 tests, 11 assertions) Testing the editAction() and deleteAction() methods can be performed similarly; however, when testing the editAction() method, you will also need to assert against the AlbumTable::getAlbum() method: $this->albumTable->expects($this->once())->method('getAlbum')->willReturn(new Album()); Ideally, you should test all the various paths through each method. For example: Test that a non-POST request to addAction() displays an empty form. Test that a invalid data provided to addAction() re-displays the form, but with error messages. Test that absence of an identifier in the route parameters when invoking either editAction() or deleteAction() will redirect to the appropriate location. Test that an invalid identifier passed to editAction() will redirect to the album landing page. Test that non-POST requests to editAction() and deleteAction() display forms. and so on. Doing so will help you understand the paths through your application and controllers, as well as ensure that changes in behavior bubble up as test failures. Testing model entities Now that we know how to test our controllers, let us move to an other important part of our application: the model entity. Here we want to test that the initial state of the entity is what we expect it to be, that we can convert the model's parameters to and from an array, and that it has all the input filters we need. Create the file AlbumTest.php in module/Album/test/Model directory with the following contents: <?php namespace AlbumTest\\Model; use Album\\Model\\Album; use PHPUnit\\Framework\\TestCase; class AlbumTest extends TestCase { public function testInitialAlbumValuesAreNull() { $album = new Album(); $this->assertNull($album->artist, '\"artist\" should be null by default'); $this->assertNull($album->id, '\"id\" should be null by default'); $this->assertNull($album->title, '\"title\" should be null by default'); } public function testExchangeArraySetsPropertiesCorrectly() { $album = new Album(); $data = [ 'artist' => 'some artist', 'id' => 123, 'title' => 'some title' ]; $album->exchangeArray($data); $this->assertSame( $data['artist'], $album->artist, '\"artist\" was not set correctly' ); $this->assertSame( $data['id'], $album->id, '\"id\" was not set correctly' ); $this->assertSame( $data['title'], $album->title, '\"title\" was not set correctly' ); } public function testExchangeArraySetsPropertiesToNullIfKeysAreNotPresent() { $album = new Album(); $album->exchangeArray([ 'artist' => 'some artist', 'id' => 123, 'title' => 'some title', ]); $album->exchangeArray([]); $this->assertNull($album->artist, '\"artist\" should default to null'); $this->assertNull($album->id, '\"id\" should default to null'); $this->assertNull($album->title, '\"title\" should default to null'); } public function testGetArrayCopyReturnsAnArrayWithPropertyValues() { $album = new Album(); $data = [ 'artist' => 'some artist', 'id' => 123, 'title' => 'some title' ]; $album->exchangeArray($data); $copyArray = $album->getArrayCopy(); $this->assertSame($data['artist'], $copyArray['artist'], '\"artist\" was not set correctly'); $this->assertSame($data['id'], $copyArray['id'], '\"id\" was not set correctly'); $this->assertSame($data['title'], $copyArray['title'], '\"title\" was not set correctly'); } public function testInputFiltersAreSetCorrectly() { $album = new Album(); $inputFilter = $album->getInputFilter(); $this->assertSame(3, $inputFilter->count()); $this->assertTrue($inputFilter->has('artist')); $this->assertTrue($inputFilter->has('id')); $this->assertTrue($inputFilter->has('title')); } } We are testing for 5 things: Are all of the Album 's properties initially set to NULL ? Will the Album 's properties be set correctly when we call exchangeArray() ? Will a default value of NULL be used for properties whose keys are not present in the $data array? Can we get an array copy of our model? Do all elements have input filters present? If we run phpunit again, we will get the following output, confirming that our model is indeed correct: $ ./vendor/bin/phpunit --testsuite Album PHPUnit 10.5.13 by Sebastian Bergmann and contributors. Runtime: PHP 8.3.2 Configuration: <your_local_path>\\phpunit.xml.dist ....... 7 / 7 (100%) Time: 00:00.319, Memory: 14.00 MB OK (7 tests, 27 assertions) Testing model tables The final step in this unit testing tutorial for laminas-mvc applications is writing tests for our model tables. This test assures that we can get a list of albums, or one album by its ID, and that we can save and delete albums from the database. To avoid actual interaction with the database itself, we will replace certain parts with mocks. Create a file AlbumTableTest.php in module/Album/test/Model/ with the following contents: <?php namespace AlbumTest\\Model; use Album\\Model\\AlbumTable; use Laminas\\Db\\ResultSet\\ResultSetInterface; use Laminas\\Db\\TableGateway\\TableGatewayInterface; use PHPUnit\\Framework\\TestCase; class AlbumTableTest extends TestCase { private $tableGateway; private $albumTable; protected function setUp(): void { $this->tableGateway = $this->createMock(TableGatewayInterface::class); $this->albumTable = new AlbumTable($this->tableGateway); } public function testFetchAllReturnsAllAlbums(): void { $resultSet = $this->createMock(ResultSetInterface::class); $this->tableGateway->expects($this->once()) ->method('select') ->willReturn($resultSet); $this->assertSame($resultSet, $this->albumTable->fetchAll()); } } Since we are testing the AlbumTable here and not the TableGateway class (which has already been tested in laminas-db), we only want to make sure that our AlbumTable class is interacting with the TableGateway class the way that we expect it to. Above, we're testing to see if the fetchAll() method of AlbumTable will call the select() method of the $tableGateway property with no parameters. If it does, it should return a ResultSet instance. Finally, we expect that this same ResultSet object will be returned to the calling method. This test should run fine, so now we can add the rest of the test methods: public function testCanDeleteAnAlbumByItsId(): void { $this->tableGateway->expects($this->once()) ->method('delete'); $this->albumTable->deleteAlbum(123); } public function testSaveAlbumWillInsertNewAlbumsIfTheyDontAlreadyHaveAnId(): void { $albumData = [ 'artist' => 'The Military Wives', 'title' => 'In My Dreams' ]; $album = new Album(); $album->exchangeArray($albumData); $this->tableGateway->expects($this->once()) ->method('insert'); $this->albumTable->saveAlbum($album); } public function testSaveAlbumWillUpdateExistingAlbumsIfTheyAlreadyHaveAnId(): void { $albumData = [ 'id' => 123, 'artist' => 'The Military Wives', 'title' => 'In My Dreams', ]; $album = new Album(); $album->exchangeArray($albumData); $resultSet = $this->createMock(ResultSetInterface::class); $resultSet->expects($this->once()) ->method('current') ->willReturn($album); $this->tableGateway->expects($this->once()) ->method('select') ->with(['id' => 123]) ->willReturn($resultSet); $this->tableGateway->expects($this->once()) ->method('update') ->with( array_filter($albumData, function ($key) { return in_array($key, ['artist', 'title']); }, ARRAY_FILTER_USE_KEY), ['id' => 123] ); $this->albumTable->saveAlbum($album); } public function testExceptionIsThrownWhenGettingNonExistentAlbum(): void { $resultSet = $this->createMock(ResultSetInterface::class); $resultSet->expects($this->once()) ->method('current') ->willReturn(null); $this->tableGateway->expects($this->once()) ->method('select') ->willReturn($resultSet); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Could not find row with identifier 123'); $this->albumTable->getAlbum(123); } These tests are nothing complicated and should be self explanatory. In each test, we add assertions to our mock table gateway, and then call and assert against methods in our AlbumTable . We are testing that: We can retrieve an individual album by its ID. We can delete albums. We can save a new album. We can update existing albums. We will encounter an exception if we're trying to retrieve an album that doesn't exist. Running phpunit one last time, we get the output as follows: $ ./vendor/bin/phpunit --testsuite Album PHPUnit 10.5.13 by Sebastian Bergmann and contributors. Runtime: PHP 8.3.2 Configuration: <your_local_path>\\phpunit.xml.dist ............ 12 / 12 (100%) Time: 00:00.326, Memory: 14.00 MB OK (12 tests, 37 assertions) Conclusion In this short tutorial, we gave a few examples how different parts of a laminas-mvc application can be tested. We covered setting up the environment for testing, how to test controllers and actions, how to approach failing test cases, how to configure the service manager, as well as how to test model entities and model tables. This tutorial is by no means a definitive guide to writing unit tests, just a small stepping stone helping you develop applications of higher quality.","title":"Unit Testing A laminas-mvc Application"},{"location":"unit-testing/#unit-testing-a-laminas-mvc-application","text":"A solid unit test suite is essential for ongoing development in large projects, especially those with many people involved. Going back and manually testing every individual component of an application after every change is impractical. Your unit tests will help alleviate that by automatically testing your application's components and alerting you when something is not working the same way it was when you wrote your tests. This tutorial is written in the hopes of showing how to test different parts of a laminas-mvc application. As such, this tutorial will use the application written in the getting started user guide . It is in no way a guide to unit testing in general, but is here only to help overcome the initial hurdles in writing unit tests for laminas-mvc applications. It is recommended to have at least a basic understanding of unit tests, assertions and mocks. laminas-test , which provides testing integration for laminas-mvc, uses PHPUnit ; this tutorial will cover using that library for testing your applications.","title":"Unit Testing a Laminas MVC application"},{"location":"unit-testing/#installing-laminas-test","text":"laminas-test provides PHPUnit integration for laminas-mvc, including application scaffolding and custom assertions. You will need to install it: $ composer require --dev laminas/laminas-test This will also install phpunit/phpunit since it is required by laminas-test. The above command will update your composer.json file and perform an update for you, which will also setup autoloading rules.","title":"Installing laminas-test"},{"location":"unit-testing/#running-the-initial-tests","text":"Out-of-the-box, the skeleton application provides several tests for the shipped Application\\Controller\\IndexController class. Now that you have laminas-test installed, you can run these: $ ./vendor/bin/phpunit","title":"Running the initial tests"},{"location":"unit-testing/#setting-up-the-tests-directory","text":"As laminas-mvc applications are built from modules that should be standalone blocks of an application, we don't test the application in its entirety, but module by module. We will demonstrate setting up the minimum requirements to test a module, the Album module we wrote in the user guide, which then can be used as a base for testing any other module. Start by creating a directory called test under module/Album/ with the following subdirectories: module/ Album/ test/ Controller/ Additionally, add an autoload-dev rule in your composer.json : \"autoload-dev\": { \"psr-4\": { \"ApplicationTest\\\\\": \"module/Application/test/\", \"AlbumTest\\\\\": \"module/Album/test/\" } } When done, run: $ composer dump-autoload The structure of the test directory matches exactly with that of the module's source files, and it will allow you to keep your tests well-organized and easy to find.","title":"Setting up the tests directory"},{"location":"unit-testing/#bootstrapping-your-tests","text":"Next, edit the phpunit.xml.dist file at the project root; we'll add a new test suite to it and modify the existing \"Laminas MVC Application Test Suite\". When done, it should read as follows: <?xml version=\"1.0\" encoding=\"UTF-8\"?> <phpunit colors=\"true\"> <testsuites> <testsuite name=\"Laminas MVC Application Test Suite\"> <directory>./module/Application/test</directory> </testsuite> <testsuite name=\"Album\"> <directory>./module/Album/test</directory> </testsuite> </testsuites> </phpunit> Now run your new Album test suite from the project root: $ ./vendor/bin/phpunit --testsuite Album","title":"Bootstrapping your tests"},{"location":"unit-testing/#your-first-controller-test","text":"Testing controllers is never an easy task, but the laminas-test component makes testing much less cumbersome. First, create AlbumControllerTest.php under module/Album/test/Controller/ with the following contents: <?php namespace AlbumTest\\Controller; use Album\\Controller\\AlbumController; use Laminas\\Stdlib\\ArrayUtils; use Laminas\\Test\\PHPUnit\\Controller\\AbstractHttpControllerTestCase; class AlbumControllerTest extends AbstractHttpControllerTestCase { protected $traceError = false; protected function setUp() : void { // The module configuration should still be applicable for tests. // You can override configuration here with test case specific values, // such as sample view templates, path stacks, module_listener_options, // etc. $configOverrides = []; $this->setApplicationConfig(ArrayUtils::merge( // Grabbing the full application configuration: include __DIR__ . '/../../../../config/application.config.php', $configOverrides )); parent::setUp(); } } The AbstractHttpControllerTestCase class we extend here helps us setting up the application itself, helps with dispatching and other tasks that happen during a request, and offers methods for asserting request params, response headers, redirects, and more. See the laminas-test documentation for more information. The principal requirement for any laminas-test test case is to set the application config with the setApplicationConfig() method. For now, we assume the default application configuration will be appropriate; however, we can override values locally within the test using the $configOverrides variable. Now, add the following method to the AlbumControllerTest class: public function testIndexActionCanBeAccessed() { $this->dispatch('/album'); $this->assertResponseStatusCode(200); $this->assertModuleName('Album'); $this->assertControllerName(AlbumController::class); $this->assertControllerClass('AlbumController'); $this->assertMatchedRouteName('album'); } This test case dispatches the /album URL, asserts that the response code is 200, and that we ended up in the desired module and controller.","title":"Your first controller test"},{"location":"unit-testing/#a-failing-test-case","text":"We likely don't want to hit the same database during testing as we use for our web property. Let's add some configuration to the test case to remove the database configuration. In your AlbumControllerTest::setUp() method, add the following lines right after the call to parent::setUp(); : $services = $this->getApplicationServiceLocator(); $config = $services->get('config'); unset($config['db']); $services->setAllowOverride(true); $services->setService('config', $config); $services->setAllowOverride(false); The above removes the 'db' configuration entirely; we'll be replacing it with something else before long. When we run the tests now: $ ./vendor/bin/phpunit --testsuite Album PHPUnit 10.5.13 by Sebastian Bergmann and contributors. Runtime: PHP 8.3.2 Configuration: <your_local_path>\\phpunit.xml.dist F 1 / 1 (100%) Time: 00:00.208, Memory: 12.00 MB There was 1 failure: 1) AlbumTest\\Controller\\AlbumControllerTest::testIndexActionCanBeAccessed Failed asserting response code \"200\", actual status code is \"500\" <your_local_path>\\vendor\\laminas\\laminas-test\\src\\PHPUnit\\Controller\\AbstractControllerTestCase.php:433 <your_local_path>\\module\\Album\\test\\Controller\\AlbumControllerTest.php:38 -- There was 1 risky test: 1) AlbumTest\\Controller\\AlbumControllerTest::testIndexActionCanBeAccessed This test did not perform any assertions <your_local_path>\\module\\Album\\test\\Controller\\AlbumControllerTest.php:35 -- 1 test triggered 1 PHP warning: 1) <your_local_path>\\vendor\\laminas\\laminas-db\\src\\Adapter\\AdapterServiceFactory.php:21 Undefined array key \"db\" Triggered by: * AlbumTest\\Controller\\AlbumControllerTest::testIndexActionCanBeAccessed <your_local_path>\\module\\Album\\test\\Controller\\AlbumControllerTest.php:35 FAILURES! Tests: 1, Assertions: 0, Failures: 1, Warnings: 1, Risky: 1. The failure message doesn't tell us much, apart from that the expected status code is not 200, but 500. To get a bit more information when something goes wrong in a test case, we set the protected $traceError member to true (which is the default; we set it to false to demonstrate this capability). We also got risky test and warning report. The warning was expected since we removed the db key from the configuration which is expected by the AdapterServiceFactory database adapter factory. And since the test case does not execute beyond the response status code assertion, we get a risk test indication that the test did not performed any assertions. Modify the following line from just above the setUp method in our AlbumControllerTest class: protected $traceError = true; Running the phpunit command again and we should see some more information about what went wrong in our test. You'll get a list of the exceptions raised, along with their messages, the filename, and line number: There was 1 failure: 1) AlbumTest\\Controller\\AlbumControllerTest::testIndexActionCanBeAccessed Failed asserting response code \"200\", actual status code is \"500\" Exceptions raised: Exception 'Laminas\\ServiceManager\\Exception\\ServiceNotCreatedException' with message 'Service with name \"Laminas\\Db\\Adapter\\AdapterInterface\" could not be created. Reason: The supplied or instantiated driver object does not implement Laminas\\Db\\Adapter\\Driver\\DriverInterface' in <your_local_path>\\vendor\\laminas\\laminas-servicemanager\\src\\ServiceManager.php:649 Exception 'Laminas\\Db\\Adapter\\Exception\\InvalidArgumentException' with message 'The supplied or instantiated driver object does not implement Laminas\\Db\\Adapter\\Driver\\DriverInterface' in <your_local_path>\\vendor\\laminas\\laminas-db\\src\\Adapter\\Adapter.php:78 Based on the exception messages, it appears we are unable to create a laminas-db adapter instance, due to missing configuration!","title":"A failing test case"},{"location":"unit-testing/#configuring-the-service-manager-for-the-tests","text":"The error says that the service manager can not create an instance of a database adapter for us. The database adapter is indirectly used by our Album\\Model\\AlbumTable to fetch the list of albums from the database. The first thought would be to create an instance of an adapter, pass it to the service manager, and let the code run from there as is. The problem with this approach is that we would end up with our test cases actually doing queries against the database. To keep our tests fast, and to reduce the number of possible failure points in our tests, this should be avoided. The second thought would be then to create a mock of the database adapter, and prevent the actual database calls by mocking them out. This is a much better approach, but creating the adapter mock is tedious (but no doubt we will have to create it at some point). The best thing to do would be to mock out our Album\\Model\\AlbumTable class which retrieves the list of albums from the database. Remember, we are now testing our controller , so we can mock out the actual call to fetchAll and replace the return values with dummy values. At this point, we are not interested in how fetchAll() retrieves the albums, but only that it gets called and that it returns an array of albums; these facts allow us to provide mock instances. When we test AlbumTable itself, we can write the actual tests for the fetchAll method. First, let's do some setup. Add import statements to the top of the test class file for each of the AlbumTable and ServiceManager classes: use Album\\Model\\AlbumTable; use Laminas\\ServiceManager\\ServiceManager; Now add the following property to the test class: protected $albumTable; Next, we'll create three new methods that we'll invoke during setup: protected function configureServiceManager(ServiceManager $services): void { $services->setAllowOverride(true); $services->setService('config', $this->updateConfig($services->get('config'))); $services->setService(AlbumTable::class, $this->mockAlbumTable()); $services->setAllowOverride(false); } protected function updateConfig($config) { $config['db'] = []; return $config; } protected function mockAlbumTable(): AlbumTable { $this->albumTable = $this->createMock(AlbumTable::class); return $this->albumTable; } By default, the ServiceManager does not allow us to replace existing services. configureServiceManager() calls a special method on the instance to enable overriding services, and then we inject specific overrides we wish to use. When done, we disable overrides to ensure that if, during dispatch, any code attempts to override a service, an exception will be raised. The last method above creates a mock instance of our AlbumTable . The instance returned by $this->createMock() is a mock AlbumTable object that will then be asserted against. With this in place, we can update our setUp() method to read as follows: protected function setUp() : void { // The module configuration should still be applicable for tests. // You can override configuration here with test case specific values, // such as sample view templates, path stacks, module_listener_options, // etc. $configOverrides = []; $this->setApplicationConfig(ArrayUtils::merge( include __DIR__ . '/../../../../config/application.config.php', $configOverrides )); parent::setUp(); $this->configureServiceManager($this->getApplicationServiceLocator()); } Now update the testIndexActionCanBeAccessed() method to add a line asserting the AlbumTable 's fetchAll() method will be called, and return an array. This is achieved by configuring the mock AlbumTable to expect the saveAlbum method to be called once and to return an empty array. public function testIndexActionCanBeAccessed() { $this->albumTable->expects($this->once()) ->method('fetchAll') ->willReturn([]); $this->dispatch('/album'); $this->assertResponseStatusCode(200); $this->assertModuleName('Album'); $this->assertControllerName(AlbumController::class); $this->assertControllerClass('AlbumController'); $this->assertMatchedRouteName('album'); } Running phpunit at this point, we will get the following output as the tests now pass: $ ./vendor/bin/phpunit --testsuite Album PHPUnit 10.5.13 by Sebastian Bergmann and contributors. Runtime: PHP 8.3.2 Configuration: <your_local_path>\\phpunit.xml.dist . 1 / 1 (100%) Time: 00:00.219, Memory: 12.00 MB OK (1 test, 8 assertions)","title":"Configuring the service manager for the tests"},{"location":"unit-testing/#testing-actions-with-post","text":"A common scenario with controllers is processing POST data submitted via a form, as we do in the AlbumController::addAction() . Let's write a test for that. public function testAddActionRedirectAfterValidPost() { $this->albumTable->expects($this->once()) ->method('saveAlbum') ->with($this->isInstanceOf(Album::class)); $postData = [ 'title' => 'Lez Zeppelin III', 'artist' => 'Led Zeppelin', 'id' => '', ]; $this->dispatch('/album/add', 'POST', $postData); $this->assertResponseStatusCode(302); $this->assertRedirectTo('/album'); } This test case references the Album class that we need to import; add the following import statement at the top of the class file: use Album\\Model\\Album; For this test case, the AlbumTable mock is configured to expect the saveAlbum method to be called once with an argument that must be an instance of Album . (We could have also done deeper assertions to ensure the Album instance contained expected data.) When we dispatch the application this time, we use the request method POST, and pass data to it. This test case then asserts a 302 response status, and introduces a new assertion against the location to which the response redirects. Running phpunit gives us the following output: $ ./vendor/bin/phpunit --testsuite Album PHPUnit 10.5.13 by Sebastian Bergmann and contributors. Runtime: PHP 8.3.2 Configuration: <your_local_path>\\phpunit.xml.dist .. 2 / 2 (100%) Time: 00:00.236, Memory: 14.00 MB OK (2 tests, 11 assertions) Testing the editAction() and deleteAction() methods can be performed similarly; however, when testing the editAction() method, you will also need to assert against the AlbumTable::getAlbum() method: $this->albumTable->expects($this->once())->method('getAlbum')->willReturn(new Album()); Ideally, you should test all the various paths through each method. For example: Test that a non-POST request to addAction() displays an empty form. Test that a invalid data provided to addAction() re-displays the form, but with error messages. Test that absence of an identifier in the route parameters when invoking either editAction() or deleteAction() will redirect to the appropriate location. Test that an invalid identifier passed to editAction() will redirect to the album landing page. Test that non-POST requests to editAction() and deleteAction() display forms. and so on. Doing so will help you understand the paths through your application and controllers, as well as ensure that changes in behavior bubble up as test failures.","title":"Testing actions with POST"},{"location":"unit-testing/#testing-model-entities","text":"Now that we know how to test our controllers, let us move to an other important part of our application: the model entity. Here we want to test that the initial state of the entity is what we expect it to be, that we can convert the model's parameters to and from an array, and that it has all the input filters we need. Create the file AlbumTest.php in module/Album/test/Model directory with the following contents: <?php namespace AlbumTest\\Model; use Album\\Model\\Album; use PHPUnit\\Framework\\TestCase; class AlbumTest extends TestCase { public function testInitialAlbumValuesAreNull() { $album = new Album(); $this->assertNull($album->artist, '\"artist\" should be null by default'); $this->assertNull($album->id, '\"id\" should be null by default'); $this->assertNull($album->title, '\"title\" should be null by default'); } public function testExchangeArraySetsPropertiesCorrectly() { $album = new Album(); $data = [ 'artist' => 'some artist', 'id' => 123, 'title' => 'some title' ]; $album->exchangeArray($data); $this->assertSame( $data['artist'], $album->artist, '\"artist\" was not set correctly' ); $this->assertSame( $data['id'], $album->id, '\"id\" was not set correctly' ); $this->assertSame( $data['title'], $album->title, '\"title\" was not set correctly' ); } public function testExchangeArraySetsPropertiesToNullIfKeysAreNotPresent() { $album = new Album(); $album->exchangeArray([ 'artist' => 'some artist', 'id' => 123, 'title' => 'some title', ]); $album->exchangeArray([]); $this->assertNull($album->artist, '\"artist\" should default to null'); $this->assertNull($album->id, '\"id\" should default to null'); $this->assertNull($album->title, '\"title\" should default to null'); } public function testGetArrayCopyReturnsAnArrayWithPropertyValues() { $album = new Album(); $data = [ 'artist' => 'some artist', 'id' => 123, 'title' => 'some title' ]; $album->exchangeArray($data); $copyArray = $album->getArrayCopy(); $this->assertSame($data['artist'], $copyArray['artist'], '\"artist\" was not set correctly'); $this->assertSame($data['id'], $copyArray['id'], '\"id\" was not set correctly'); $this->assertSame($data['title'], $copyArray['title'], '\"title\" was not set correctly'); } public function testInputFiltersAreSetCorrectly() { $album = new Album(); $inputFilter = $album->getInputFilter(); $this->assertSame(3, $inputFilter->count()); $this->assertTrue($inputFilter->has('artist')); $this->assertTrue($inputFilter->has('id')); $this->assertTrue($inputFilter->has('title')); } } We are testing for 5 things: Are all of the Album 's properties initially set to NULL ? Will the Album 's properties be set correctly when we call exchangeArray() ? Will a default value of NULL be used for properties whose keys are not present in the $data array? Can we get an array copy of our model? Do all elements have input filters present? If we run phpunit again, we will get the following output, confirming that our model is indeed correct: $ ./vendor/bin/phpunit --testsuite Album PHPUnit 10.5.13 by Sebastian Bergmann and contributors. Runtime: PHP 8.3.2 Configuration: <your_local_path>\\phpunit.xml.dist ....... 7 / 7 (100%) Time: 00:00.319, Memory: 14.00 MB OK (7 tests, 27 assertions)","title":"Testing model entities"},{"location":"unit-testing/#testing-model-tables","text":"The final step in this unit testing tutorial for laminas-mvc applications is writing tests for our model tables. This test assures that we can get a list of albums, or one album by its ID, and that we can save and delete albums from the database. To avoid actual interaction with the database itself, we will replace certain parts with mocks. Create a file AlbumTableTest.php in module/Album/test/Model/ with the following contents: <?php namespace AlbumTest\\Model; use Album\\Model\\AlbumTable; use Laminas\\Db\\ResultSet\\ResultSetInterface; use Laminas\\Db\\TableGateway\\TableGatewayInterface; use PHPUnit\\Framework\\TestCase; class AlbumTableTest extends TestCase { private $tableGateway; private $albumTable; protected function setUp(): void { $this->tableGateway = $this->createMock(TableGatewayInterface::class); $this->albumTable = new AlbumTable($this->tableGateway); } public function testFetchAllReturnsAllAlbums(): void { $resultSet = $this->createMock(ResultSetInterface::class); $this->tableGateway->expects($this->once()) ->method('select') ->willReturn($resultSet); $this->assertSame($resultSet, $this->albumTable->fetchAll()); } } Since we are testing the AlbumTable here and not the TableGateway class (which has already been tested in laminas-db), we only want to make sure that our AlbumTable class is interacting with the TableGateway class the way that we expect it to. Above, we're testing to see if the fetchAll() method of AlbumTable will call the select() method of the $tableGateway property with no parameters. If it does, it should return a ResultSet instance. Finally, we expect that this same ResultSet object will be returned to the calling method. This test should run fine, so now we can add the rest of the test methods: public function testCanDeleteAnAlbumByItsId(): void { $this->tableGateway->expects($this->once()) ->method('delete'); $this->albumTable->deleteAlbum(123); } public function testSaveAlbumWillInsertNewAlbumsIfTheyDontAlreadyHaveAnId(): void { $albumData = [ 'artist' => 'The Military Wives', 'title' => 'In My Dreams' ]; $album = new Album(); $album->exchangeArray($albumData); $this->tableGateway->expects($this->once()) ->method('insert'); $this->albumTable->saveAlbum($album); } public function testSaveAlbumWillUpdateExistingAlbumsIfTheyAlreadyHaveAnId(): void { $albumData = [ 'id' => 123, 'artist' => 'The Military Wives', 'title' => 'In My Dreams', ]; $album = new Album(); $album->exchangeArray($albumData); $resultSet = $this->createMock(ResultSetInterface::class); $resultSet->expects($this->once()) ->method('current') ->willReturn($album); $this->tableGateway->expects($this->once()) ->method('select') ->with(['id' => 123]) ->willReturn($resultSet); $this->tableGateway->expects($this->once()) ->method('update') ->with( array_filter($albumData, function ($key) { return in_array($key, ['artist', 'title']); }, ARRAY_FILTER_USE_KEY), ['id' => 123] ); $this->albumTable->saveAlbum($album); } public function testExceptionIsThrownWhenGettingNonExistentAlbum(): void { $resultSet = $this->createMock(ResultSetInterface::class); $resultSet->expects($this->once()) ->method('current') ->willReturn(null); $this->tableGateway->expects($this->once()) ->method('select') ->willReturn($resultSet); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Could not find row with identifier 123'); $this->albumTable->getAlbum(123); } These tests are nothing complicated and should be self explanatory. In each test, we add assertions to our mock table gateway, and then call and assert against methods in our AlbumTable . We are testing that: We can retrieve an individual album by its ID. We can delete albums. We can save a new album. We can update existing albums. We will encounter an exception if we're trying to retrieve an album that doesn't exist. Running phpunit one last time, we get the output as follows: $ ./vendor/bin/phpunit --testsuite Album PHPUnit 10.5.13 by Sebastian Bergmann and contributors. Runtime: PHP 8.3.2 Configuration: <your_local_path>\\phpunit.xml.dist ............ 12 / 12 (100%) Time: 00:00.326, Memory: 14.00 MB OK (12 tests, 37 assertions)","title":"Testing model tables"},{"location":"unit-testing/#conclusion","text":"In this short tutorial, we gave a few examples how different parts of a laminas-mvc application can be tested. We covered setting up the environment for testing, how to test controllers and actions, how to approach failing test cases, how to configure the service manager, as well as how to test model entities and model tables. This tutorial is by no means a definitive guide to writing unit tests, just a small stepping stone helping you develop applications of higher quality.","title":"Conclusion"},{"location":"getting-started/conclusion/","text":"Conclusion This concludes our brief look at building a simple, but fully functional, Laminas laminas-mvc application. In this tutorial we but briefly touched quite a number of different parts of the framework. The most important part of applications built with laminas-mvc are the modules , the building blocks of any laminas-mvc application . To ease the work with dependencies inside our applications, we use the service manager . To be able to map a request to controllers and their actions, we use routes . Data persistence was performed using laminas-db to communicate with a relational database. Input data is filtered and validated with input filters , and, together with laminas-form , they provide a strong bridge between the domain model and the view layer. laminas-view is responsible for the View in the MVC stack, together with a vast amount of view helpers .","title":"Conclusion"},{"location":"getting-started/conclusion/#conclusion","text":"This concludes our brief look at building a simple, but fully functional, Laminas laminas-mvc application. In this tutorial we but briefly touched quite a number of different parts of the framework. The most important part of applications built with laminas-mvc are the modules , the building blocks of any laminas-mvc application . To ease the work with dependencies inside our applications, we use the service manager . To be able to map a request to controllers and their actions, we use routes . Data persistence was performed using laminas-db to communicate with a relational database. Input data is filtered and validated with input filters , and, together with laminas-form , they provide a strong bridge between the domain model and the view layer. laminas-view is responsible for the View in the MVC stack, together with a vast amount of view helpers .","title":"Conclusion"},{"location":"getting-started/database-and-models/","text":"Database and models The database Now that we have the Album module set up with controller action methods and view scripts, it is time to look at the model section of our application. Remember that the model is the part that deals with the application's core purpose (the so-called “business rules”) and, in our case, deals with the database. We will make use of laminas-db's Laminas\\Db\\TableGateway\\TableGateway to find, insert, update, and delete rows from a database table. We are going to use Sqlite, via PHP's PDO driver. Create a text file data/schema.sql with the following contents: CREATE TABLE album (id INTEGER PRIMARY KEY AUTOINCREMENT, artist varchar(100) NOT NULL, title varchar(100) NOT NULL); INSERT INTO album (artist, title) VALUES ('The Military Wives', 'In My Dreams'); INSERT INTO album (artist, title) VALUES ('Adele', '21'); INSERT INTO album (artist, title) VALUES ('Bruce Springsteen', 'Wrecking Ball (Deluxe)'); INSERT INTO album (artist, title) VALUES ('Lana Del Rey', 'Born To Die'); INSERT INTO album (artist, title) VALUES ('Gotye', 'Making Mirrors'); (The test data chosen happens to be the Bestsellers on Amazon UK at the time of writing!) Now create the database using the following: $ sqlite data/laminastutorial.db < data/schema.sql Alternative Commands SQLite3 Some systems, including Ubuntu, use the command sqlite3 ; check to see which one to use on your system. If you use sqlite3 create the database using the following command: $ cat data/schema.sql | sqlite3 data/laminastutorial.db Using PHP to Create the Database If you do not have Sqlite installed on your system, you can use PHP to load the database using the same SQL schema file created earlier. Create the file data/load_db.php with the following contents: <?php $db = new PDO('sqlite:' . realpath(__DIR__) . '/laminastutorial.db'); $fh = fopen(__DIR__ . '/schema.sql', 'r'); while ($line = fread($fh, 4096)) { $db->exec($line); } fclose($fh); Once created, execute it: $ php data/load_db.php We now have some data in a database and can write a very simple model for it. The model files Laminas does not provide a laminas-model component because the model is your business logic, and it's up to you to decide how you want it to work. There are many components that you can use for this depending on your needs. One approach is to have model classes represent each entity in your application and then use mapper objects that load and save entities to the database. Another is to use an Object-Relational Mapping (ORM) technology, such as Doctrine or Propel. For this tutorial, we are going to create a model by creating an AlbumTable class that consumes a Laminas\\Db\\TableGateway\\TableGateway , and in which each album will be represented as an Album object (known as an entity ). This is an implementation of the Table Data Gateway design pattern to allow for interfacing with data in a database table. Be aware, though, that the Table Data Gateway pattern can become limiting in larger systems. There is also a temptation to put database access code into controller action methods as these are exposed by Laminas\\Db\\TableGateway\\AbstractTableGateway . Don't do this ! Let's start by creating a file called Album.php under module/Album/src/Model : namespace Album\\Model; class Album { public $id; public $artist; public $title; public function exchangeArray(array $array): void { $this->id = ! empty($array['id']) ? $array['id'] : null; $this->artist = ! empty($array['artist']) ? $array['artist'] : null; $this->title = ! empty($array['title']) ? $array['title'] : null; } } Our Album entity object is a PHP class. In order to work with laminas-db's TableGateway class, we need to implement the exchangeArray() method; this method copies the data from the provided array to our entity's properties. We will add an input filter later to ensure the values injected are valid. Next, we create our AlbumTable.php file in module/Album/src/Model directory like this: namespace Album\\Model; use RuntimeException; use Laminas\\Db\\TableGateway\\TableGatewayInterface; class AlbumTable { private $tableGateway; public function __construct(TableGatewayInterface $tableGateway) { $this->tableGateway = $tableGateway; } public function fetchAll() { return $this->tableGateway->select(); } public function getAlbum($id) { $id = (int) $id; $rowset = $this->tableGateway->select(['id' => $id]); $row = $rowset->current(); if (! $row) { throw new RuntimeException(sprintf( 'Could not find row with identifier %d', $id )); } return $row; } public function saveAlbum(Album $album) { $data = [ 'artist' => $album->artist, 'title' => $album->title, ]; $id = (int) $album->id; if ($id === 0) { $this->tableGateway->insert($data); return; } try { $this->getAlbum($id); } catch (RuntimeException $e) { throw new RuntimeException(sprintf( 'Cannot update album with identifier %d; does not exist', $id )); } $this->tableGateway->update($data, ['id' => $id]); } public function deleteAlbum($id) { $this->tableGateway->delete(['id' => (int) $id]); } } There's a lot going on here. Firstly, we set the protected property $tableGateway to the TableGateway instance passed in the constructor, hinting against the TableGatewayInterface (which allows us to provide alternate implementations easily, including mock instances during testing). We will use this to perform operations on the database table for our albums. We then create some helper methods that our application will use to interface with the table gateway. fetchAll() retrieves all albums rows from the database as a ResultSet , getAlbum() retrieves a single row as an Album object, saveAlbum() either creates a new row in the database or updates a row that already exists, and deleteAlbum() removes the row completely. The code for each of these methods is, hopefully, self-explanatory. Using ServiceManager to configure the table gateway and inject into the AlbumTable In order to always use the same instance of our AlbumTable , we will use the ServiceManager to define how to create one. This is most easily done by adding a ServiceManager configuration to the module.config.php which is automatically loaded by the ModuleManager and applied to the ServiceManager . We'll then be able to retrieve the AlbumTable when we need it. To configure the ServiceManager , we can either supply the name of the class to be instantiated and a factory (closure, callback, or class name of a factory class) that instantiates the object when the ServiceManager needs it. Add a service_manager configuration to module/Album/config/module.config.php : namespace Album; use Album\\Model\\AlbumTableFactory; use Laminas\\Router\\Http\\Segment; use Laminas\\ServiceManager\\Factory\\InvokableFactory; return [ 'controllers' => [ // ... ], 'router' => [ // .. ], 'view_manager' => [ // ... ], 'service_manager' => [ 'factories' => [ Model\\AlbumTable::class => AlbumTableFactory::class, ], ], ]; This method returns an array of factories that are all merged together by the ModuleManager before passing them to the ServiceManager . When requesting the ServiceManager to create Album\\Model\\AlbumTable , the ServiceManager will invoke the AlbumTableFactory class, which we need to create next. Let's create the AlbumTableFactory.php factory in module/Album/src/Model : namespace Album\\Model; use Laminas\\Db\\Adapter\\AdapterInterface; use Laminas\\Db\\ResultSet\\ResultSet; use Laminas\\Db\\TableGateway\\TableGateway; use Laminas\\ServiceManager\\Factory\\FactoryInterface; use Psr\\Container\\ContainerInterface; class AlbumTableFactory implements FactoryInterface { public function __invoke(ContainerInterface $container, $requestedName, ?array $options = null): AlbumTable { $dbAdapter = $container->get(AdapterInterface::class); $resultSetPrototype = new ResultSet(); $resultSetPrototype->setArrayObjectPrototype(new Album()); $tableGateway = new TableGateway('album', $dbAdapter, null, $resultSetPrototype); return new AlbumTable($tableGateway); } } The AlbumTableFactory factory uses the ServiceManager to fetch a Laminas\\Db\\Adapter\\AdapterInterface implementation (also from the ServiceManager ) and use it to create a TableGateway object. The TableGateway is told to use an Album object whenever it creates a new result row. The TableGateway classes use the prototype pattern for creation of result sets and entities. This means that instead of instantiating when required, the system clones a previously instantiated object. Then, finally, the factory creates a AlbumTable object passing it the TableGateway object. See PHP Constructor Best Practices and the Prototype Pattern for more details. Factories The above demonstrates building factories as a class and mapping the class factory in your module configuration. Another option would have been to use a closure that contains the same code a the AlbumTableFactory . Using a class for the factory has a number of benefits: The code is not parsed or executed unless the factory is invoked. You can easily unit test the factory to ensure it does what it should. You can extend the factory if desired. You can re-use the factory across multiple instances that have related construction. Creating factories is covered in the laminas-servicemanager documentation . The Laminas\\Db\\Adapter\\AdapterInterface service is registered by the laminas-db component. You may have noticed earlier that config/modules.config.php contains the following entries: return [ 'Laminas\\Form', 'Laminas\\Db', 'Laminas\\Router', 'Laminas\\Validator', /* ... */ ], All Laminas components that provide laminas-servicemanager configuration are also exposed as modules themselves; the prompts as to where to register the components during our initial installation occurred to ensure that the above entries are created for you. The end result is that we can already rely on having a factory for the Laminas\\Db\\Adapter\\AdapterInterface service; now we need to provide configuration so it can create an adapter for us. Laminas's ModuleManager merges all the configuration from each module's module.config.php file, and then merges in the files in config/autoload/ (first *.global.php files, and then *.local.php files). We'll add our database configuration information to global.php , which you should commit to your version control system. You can use local.php (outside of the VCS) to store the credentials for your database if you want to. Modify config/autoload/global.php (in the project root, not inside the Album module) with following code: return [ 'db' => [ 'driver' => 'Pdo', 'dsn' => sprintf('sqlite:%s/data/laminastutorial.db', realpath(getcwd())), ], ]; If you were configuring a database that required credentials, you would put the general configuration in your config/autoload/global.php , and then the configuration for the current environment, including the DSN and credentials, in the config/autoload/local.php file. These get merged when the application runs, ensuring you have a full definition, but allows you to keep files with credentials outside of version control. Back to the controller Now that we have a model, we need to inject it into our controller so we can use it. Firstly, we'll add a constructor to our controller. Open the file module/Album/src/Controller/AlbumController.php and add the following property and constructor: namespace Album\\Controller; // Add the following import: use Album\\Model\\AlbumTable; use Laminas\\Mvc\\Controller\\AbstractActionController; use Laminas\\View\\Model\\ViewModel; class AlbumController extends AbstractActionController { // Add this property: private $table; // Add this constructor: public function __construct(AlbumTable $table) { $this->table = $table; } /* ... */ } Our controller now depends on AlbumTable , so we will need to update the factory for the controller so that it will inject the AlbumTable . We will use the ReflectionBasedAbstractFactory factory to build the AlbumController . ReflectionBasedAbstractFactory provides a reflection-based approach to instantiation, resolving constructor dependencies to the relevant services. Since the AlbumController constructor has an AlbumTable parameter, the factory will instantiate an AlbumTable instance and pass it to the AlbumController constructor. Then we can modify the controllers section of the module.config.php to use ReflectionBasedAbstractFactory : namespace Album; use Laminas\\ServiceManager\\AbstractFactory\\ReflectionBasedAbstractFactory; use Album\\Model\\AlbumTableFactory; use Laminas\\Router\\Http\\Segment; return [ 'controllers' => [ 'factories' => [ Controller\\AlbumController::class => ReflectionBasedAbstractFactory::class ], ], // the rest of the code ]; We can now access the property $table from within our controller whenever we need to interact with our model. Listing albums In order to list the albums, we need to retrieve them from the model and pass them to the view. To do this, we fill in indexAction() within AlbumController . Update the AlbumController::indexAction() as follows: // module/Album/src/Controller/AlbumController.php: // ... public function indexAction() { return new ViewModel([ 'albums' => $this->table->fetchAll(), ]); } // ... With Laminas, in order to set variables in the view, we return a ViewModel instance where the first parameter of the constructor is an array containing data we wish to represent. These are then automatically passed to the view script. The ViewModel object also allows us to change the view script that is used, but the default is to use {module name}/{controller name}/{action name} . We can now fill in the index.phtml view script: <?php // module/Album/view/album/album/index.phtml: $title = 'My albums'; $this->headTitle($title); ?> <h1><?= $this->escapeHtml($title) ?></h1> <p> <a href=\"<?= $this->url('album', ['action' => 'add']) ?>\">Add new album</a> </p> <table class=\"table\"> <tr> <th>Title</th> <th>Artist</th> <th> </th> </tr> <?php foreach ($albums as $album) : ?> <tr> <td><?= $this->escapeHtml($album->title) ?></td> <td><?= $this->escapeHtml($album->artist) ?></td> <td> <a href=\"<?= $this->url('album', ['action' => 'edit', 'id' => $album->id]) ?>\">Edit</a> <a href=\"<?= $this->url('album', ['action' => 'delete', 'id' => $album->id]) ?>\">Delete</a> </td> </tr> <?php endforeach; ?> </table> The first thing we do is to set the title for the page (used in the layout) and also set the title for the <head> section using the headTitle() view helper which will display in the browser's title bar. We then create a link to add a new album. The url() view helper is provided by laminas-mvc and laminas-view, and is used to create the links we need. The first parameter to url() is the route name we wish to use for construction of the URL, and the second parameter is an array of variables to substitute into route placeholders. In this case we use our album route which is set up to accept two placeholder variables: action and id . We iterate over the $albums that we assigned from the controller action. laminas-view automatically ensures that these variables are extracted into the scope of the view script; you may also access them using $this->{variable name} in order to differentiate between variables provided to the view script and those created inside it. We then create a table to display each album's title and artist, and provide links to allow for editing and deleting the record. A standard foreach: loop is used to iterate over the list of albums, and we use the alternate form using a colon and endforeach; as it is easier to scan than to try and match up braces. Again, the url() view helper is used to create the edit and delete links. Escaping We always use the escapeHtml() view helper to help protect ourselves from Cross Site Scripting (XSS) vulnerabilities . If you open http://localhost:8080/album (or http://laminas-mvc-tutorial.localhost/album if you are using self-hosted Apache) you should see this:","title":"Database and Models"},{"location":"getting-started/database-and-models/#database-and-models","text":"","title":"Database and models"},{"location":"getting-started/database-and-models/#the-database","text":"Now that we have the Album module set up with controller action methods and view scripts, it is time to look at the model section of our application. Remember that the model is the part that deals with the application's core purpose (the so-called “business rules”) and, in our case, deals with the database. We will make use of laminas-db's Laminas\\Db\\TableGateway\\TableGateway to find, insert, update, and delete rows from a database table. We are going to use Sqlite, via PHP's PDO driver. Create a text file data/schema.sql with the following contents: CREATE TABLE album (id INTEGER PRIMARY KEY AUTOINCREMENT, artist varchar(100) NOT NULL, title varchar(100) NOT NULL); INSERT INTO album (artist, title) VALUES ('The Military Wives', 'In My Dreams'); INSERT INTO album (artist, title) VALUES ('Adele', '21'); INSERT INTO album (artist, title) VALUES ('Bruce Springsteen', 'Wrecking Ball (Deluxe)'); INSERT INTO album (artist, title) VALUES ('Lana Del Rey', 'Born To Die'); INSERT INTO album (artist, title) VALUES ('Gotye', 'Making Mirrors'); (The test data chosen happens to be the Bestsellers on Amazon UK at the time of writing!) Now create the database using the following: $ sqlite data/laminastutorial.db < data/schema.sql Alternative Commands","title":"The database"},{"location":"getting-started/database-and-models/#the-model-files","text":"Laminas does not provide a laminas-model component because the model is your business logic, and it's up to you to decide how you want it to work. There are many components that you can use for this depending on your needs. One approach is to have model classes represent each entity in your application and then use mapper objects that load and save entities to the database. Another is to use an Object-Relational Mapping (ORM) technology, such as Doctrine or Propel. For this tutorial, we are going to create a model by creating an AlbumTable class that consumes a Laminas\\Db\\TableGateway\\TableGateway , and in which each album will be represented as an Album object (known as an entity ). This is an implementation of the Table Data Gateway design pattern to allow for interfacing with data in a database table. Be aware, though, that the Table Data Gateway pattern can become limiting in larger systems. There is also a temptation to put database access code into controller action methods as these are exposed by Laminas\\Db\\TableGateway\\AbstractTableGateway . Don't do this ! Let's start by creating a file called Album.php under module/Album/src/Model : namespace Album\\Model; class Album { public $id; public $artist; public $title; public function exchangeArray(array $array): void { $this->id = ! empty($array['id']) ? $array['id'] : null; $this->artist = ! empty($array['artist']) ? $array['artist'] : null; $this->title = ! empty($array['title']) ? $array['title'] : null; } } Our Album entity object is a PHP class. In order to work with laminas-db's TableGateway class, we need to implement the exchangeArray() method; this method copies the data from the provided array to our entity's properties. We will add an input filter later to ensure the values injected are valid. Next, we create our AlbumTable.php file in module/Album/src/Model directory like this: namespace Album\\Model; use RuntimeException; use Laminas\\Db\\TableGateway\\TableGatewayInterface; class AlbumTable { private $tableGateway; public function __construct(TableGatewayInterface $tableGateway) { $this->tableGateway = $tableGateway; } public function fetchAll() { return $this->tableGateway->select(); } public function getAlbum($id) { $id = (int) $id; $rowset = $this->tableGateway->select(['id' => $id]); $row = $rowset->current(); if (! $row) { throw new RuntimeException(sprintf( 'Could not find row with identifier %d', $id )); } return $row; } public function saveAlbum(Album $album) { $data = [ 'artist' => $album->artist, 'title' => $album->title, ]; $id = (int) $album->id; if ($id === 0) { $this->tableGateway->insert($data); return; } try { $this->getAlbum($id); } catch (RuntimeException $e) { throw new RuntimeException(sprintf( 'Cannot update album with identifier %d; does not exist', $id )); } $this->tableGateway->update($data, ['id' => $id]); } public function deleteAlbum($id) { $this->tableGateway->delete(['id' => (int) $id]); } } There's a lot going on here. Firstly, we set the protected property $tableGateway to the TableGateway instance passed in the constructor, hinting against the TableGatewayInterface (which allows us to provide alternate implementations easily, including mock instances during testing). We will use this to perform operations on the database table for our albums. We then create some helper methods that our application will use to interface with the table gateway. fetchAll() retrieves all albums rows from the database as a ResultSet , getAlbum() retrieves a single row as an Album object, saveAlbum() either creates a new row in the database or updates a row that already exists, and deleteAlbum() removes the row completely. The code for each of these methods is, hopefully, self-explanatory.","title":"The model files"},{"location":"getting-started/database-and-models/#using-servicemanager-to-configure-the-table-gateway-and-inject-into-the-albumtable","text":"In order to always use the same instance of our AlbumTable , we will use the ServiceManager to define how to create one. This is most easily done by adding a ServiceManager configuration to the module.config.php which is automatically loaded by the ModuleManager and applied to the ServiceManager . We'll then be able to retrieve the AlbumTable when we need it. To configure the ServiceManager , we can either supply the name of the class to be instantiated and a factory (closure, callback, or class name of a factory class) that instantiates the object when the ServiceManager needs it. Add a service_manager configuration to module/Album/config/module.config.php : namespace Album; use Album\\Model\\AlbumTableFactory; use Laminas\\Router\\Http\\Segment; use Laminas\\ServiceManager\\Factory\\InvokableFactory; return [ 'controllers' => [ // ... ], 'router' => [ // .. ], 'view_manager' => [ // ... ], 'service_manager' => [ 'factories' => [ Model\\AlbumTable::class => AlbumTableFactory::class, ], ], ]; This method returns an array of factories that are all merged together by the ModuleManager before passing them to the ServiceManager . When requesting the ServiceManager to create Album\\Model\\AlbumTable , the ServiceManager will invoke the AlbumTableFactory class, which we need to create next. Let's create the AlbumTableFactory.php factory in module/Album/src/Model : namespace Album\\Model; use Laminas\\Db\\Adapter\\AdapterInterface; use Laminas\\Db\\ResultSet\\ResultSet; use Laminas\\Db\\TableGateway\\TableGateway; use Laminas\\ServiceManager\\Factory\\FactoryInterface; use Psr\\Container\\ContainerInterface; class AlbumTableFactory implements FactoryInterface { public function __invoke(ContainerInterface $container, $requestedName, ?array $options = null): AlbumTable { $dbAdapter = $container->get(AdapterInterface::class); $resultSetPrototype = new ResultSet(); $resultSetPrototype->setArrayObjectPrototype(new Album()); $tableGateway = new TableGateway('album', $dbAdapter, null, $resultSetPrototype); return new AlbumTable($tableGateway); } } The AlbumTableFactory factory uses the ServiceManager to fetch a Laminas\\Db\\Adapter\\AdapterInterface implementation (also from the ServiceManager ) and use it to create a TableGateway object. The TableGateway is told to use an Album object whenever it creates a new result row. The TableGateway classes use the prototype pattern for creation of result sets and entities. This means that instead of instantiating when required, the system clones a previously instantiated object. Then, finally, the factory creates a AlbumTable object passing it the TableGateway object. See PHP Constructor Best Practices and the Prototype Pattern for more details.","title":"Using ServiceManager to configure the table gateway and inject into the AlbumTable"},{"location":"getting-started/database-and-models/#back-to-the-controller","text":"Now that we have a model, we need to inject it into our controller so we can use it. Firstly, we'll add a constructor to our controller. Open the file module/Album/src/Controller/AlbumController.php and add the following property and constructor: namespace Album\\Controller; // Add the following import: use Album\\Model\\AlbumTable; use Laminas\\Mvc\\Controller\\AbstractActionController; use Laminas\\View\\Model\\ViewModel; class AlbumController extends AbstractActionController { // Add this property: private $table; // Add this constructor: public function __construct(AlbumTable $table) { $this->table = $table; } /* ... */ } Our controller now depends on AlbumTable , so we will need to update the factory for the controller so that it will inject the AlbumTable . We will use the ReflectionBasedAbstractFactory factory to build the AlbumController . ReflectionBasedAbstractFactory provides a reflection-based approach to instantiation, resolving constructor dependencies to the relevant services. Since the AlbumController constructor has an AlbumTable parameter, the factory will instantiate an AlbumTable instance and pass it to the AlbumController constructor. Then we can modify the controllers section of the module.config.php to use ReflectionBasedAbstractFactory : namespace Album; use Laminas\\ServiceManager\\AbstractFactory\\ReflectionBasedAbstractFactory; use Album\\Model\\AlbumTableFactory; use Laminas\\Router\\Http\\Segment; return [ 'controllers' => [ 'factories' => [ Controller\\AlbumController::class => ReflectionBasedAbstractFactory::class ], ], // the rest of the code ]; We can now access the property $table from within our controller whenever we need to interact with our model.","title":"Back to the controller"},{"location":"getting-started/database-and-models/#listing-albums","text":"In order to list the albums, we need to retrieve them from the model and pass them to the view. To do this, we fill in indexAction() within AlbumController . Update the AlbumController::indexAction() as follows: // module/Album/src/Controller/AlbumController.php: // ... public function indexAction() { return new ViewModel([ 'albums' => $this->table->fetchAll(), ]); } // ... With Laminas, in order to set variables in the view, we return a ViewModel instance where the first parameter of the constructor is an array containing data we wish to represent. These are then automatically passed to the view script. The ViewModel object also allows us to change the view script that is used, but the default is to use {module name}/{controller name}/{action name} . We can now fill in the index.phtml view script: <?php // module/Album/view/album/album/index.phtml: $title = 'My albums'; $this->headTitle($title); ?> <h1><?= $this->escapeHtml($title) ?></h1> <p> <a href=\"<?= $this->url('album', ['action' => 'add']) ?>\">Add new album</a> </p> <table class=\"table\"> <tr> <th>Title</th> <th>Artist</th> <th> </th> </tr> <?php foreach ($albums as $album) : ?> <tr> <td><?= $this->escapeHtml($album->title) ?></td> <td><?= $this->escapeHtml($album->artist) ?></td> <td> <a href=\"<?= $this->url('album', ['action' => 'edit', 'id' => $album->id]) ?>\">Edit</a> <a href=\"<?= $this->url('album', ['action' => 'delete', 'id' => $album->id]) ?>\">Delete</a> </td> </tr> <?php endforeach; ?> </table> The first thing we do is to set the title for the page (used in the layout) and also set the title for the <head> section using the headTitle() view helper which will display in the browser's title bar. We then create a link to add a new album. The url() view helper is provided by laminas-mvc and laminas-view, and is used to create the links we need. The first parameter to url() is the route name we wish to use for construction of the URL, and the second parameter is an array of variables to substitute into route placeholders. In this case we use our album route which is set up to accept two placeholder variables: action and id . We iterate over the $albums that we assigned from the controller action. laminas-view automatically ensures that these variables are extracted into the scope of the view script; you may also access them using $this->{variable name} in order to differentiate between variables provided to the view script and those created inside it. We then create a table to display each album's title and artist, and provide links to allow for editing and deleting the record. A standard foreach: loop is used to iterate over the list of albums, and we use the alternate form using a colon and endforeach; as it is easier to scan than to try and match up braces. Again, the url() view helper is used to create the edit and delete links.","title":"Listing albums"},{"location":"getting-started/forms-and-actions/","text":"Forms and actions Adding new albums We can now code up the functionality to add new albums. There are two bits to this part: Display a form for user to provide details. Process the form submission and store to database. We will use laminas-form to do this. laminas-form manages the various form inputs as well as their validation, the latter of which is handled by the laminas-inputfilter component. We'll start by creating a new class, Album\\Form\\AlbumForm , extending from Laminas\\Form\\Form . Create the file module/Album/src/Form/AlbumForm.php with the following contents: namespace Album\\Form; use Laminas\\Form\\Element\\Hidden; use Laminas\\Form\\Element\\Submit; use Laminas\\Form\\Element\\Text; use Laminas\\Form\\Form; class AlbumForm extends Form { public function __construct($name = null) { // We will ignore the name provided to the constructor parent::__construct('album'); $this->add([ 'name' => 'id', 'type' => Hidden::class, ]); $this->add([ 'name' => 'title', 'type' => Text::class, 'options' => [ 'label' => 'Title', ], ]); $this->add([ 'name' => 'artist', 'type' => Text::class, 'options' => [ 'label' => 'Artist', ], ]); $this->add([ 'name' => 'submit', 'type' => Submit::class, 'attributes' => [ 'value' => 'Go', 'id' => 'submitbutton', ], ]); } } Within the constructor of AlbumForm we do several things. First, we set the name of the form as we call the parent's constructor. Then, we create four form elements: the id, title, artist, and submit button. For each item we set various attributes and options, including the label to be displayed. Form method HTML forms can be sent using POST and GET . laminas-form defaults to POST ; therefore you don't have to be explicit in setting this option. If you want to change it to GET however, set the method attribute in the constructor: $this->setAttribute('method', 'GET'); We also need to set up validation for this form. laminas-inputfilter provides a general purpose mechanism for input validation. It also provides an interface, InputFilterAwareInterface , which laminas-form will use in order to bind an input filter to a given form. We'll add this capability now to our Album class. // module/Album/src/Model/Album.php: namespace Album\\Model; // Add the following import statements: use DomainException; use Laminas\\Filter\\StringTrim; use Laminas\\Filter\\StripTags; use Laminas\\Filter\\ToInt; use Laminas\\InputFilter\\InputFilter; use Laminas\\InputFilter\\InputFilterAwareInterface; use Laminas\\InputFilter\\InputFilterInterface; use Laminas\\Validator\\StringLength; class Album implements InputFilterAwareInterface { public $id; public $artist; public $title; // Add this property: private $inputFilter; public function exchangeArray(array $data) { $this->id = !empty($data['id']) ? $data['id'] : null; $this->artist = !empty($data['artist']) ? $data['artist'] : null; $this->title = !empty($data['title']) ? $data['title'] : null; } /* Add the following methods: */ public function setInputFilter(InputFilterInterface $inputFilter) { throw new DomainException(sprintf( '%s does not allow injection of an alternate input filter', __CLASS__ )); } public function getInputFilter() { if ($this->inputFilter) { return $this->inputFilter; } $inputFilter = new InputFilter(); $inputFilter->add([ 'name' => 'id', 'required' => true, 'filters' => [ ['name' => ToInt::class], ], ]); $inputFilter->add([ 'name' => 'artist', 'required' => true, 'filters' => [ ['name' => StripTags::class], ['name' => StringTrim::class], ], 'validators' => [ [ 'name' => StringLength::class, 'options' => [ 'encoding' => 'UTF-8', 'min' => 1, 'max' => 100, ], ], ], ]); $inputFilter->add([ 'name' => 'title', 'required' => true, 'filters' => [ ['name' => StripTags::class], ['name' => StringTrim::class], ], 'validators' => [ [ 'name' => StringLength::class, 'options' => [ 'encoding' => 'UTF-8', 'min' => 1, 'max' => 100, ], ], ], ]); $this->inputFilter = $inputFilter; return $this->inputFilter; } } The InputFilterAwareInterface defines two methods: setInputFilter() and getInputFilter() . We only need to implement getInputFilter() so we throw an exception from setInputFilter() . Within getInputFilter() , we instantiate an InputFilter and then add the inputs that we require. We add one input for each property that we wish to filter or validate. For the id field we add an int filter as we only need integers. For the text elements, we add two filters, StripTags and StringTrim , to remove unwanted HTML and unnecessary white space. We also set them to be required and add a StringLength validator to ensure that the user doesn't enter more characters than we can store into the database. We now need to get the form to display and then process it on submission. This is done within the AlbumController::addAction() : // module/Album/src/Controller/AlbumController.php: // Add the following import statements at the top of the file: use Album\\Form\\AlbumForm; use Album\\Model\\Album; class AlbumController extends AbstractActionController { /* ... */ /* Update the following method to read as follows: */ public function addAction() { $form = new AlbumForm(); $form->get('submit')->setValue('Add'); $request = $this->getRequest(); if (! $request->isPost()) { return ['form' => $form]; } $album = new Album(); $form->setInputFilter($album->getInputFilter()); $form->setData($request->getPost()); if (! $form->isValid()) { return ['form' => $form]; } $album->exchangeArray($form->getData()); $this->table->saveAlbum($album); return $this->redirect()->toRoute('album'); } /* ... */ } After adding the Album and AlbumForm classes to the import list, we implement addAction() . Let's look at the addAction() code in a little more detail: $form = new AlbumForm(); $form->get('submit')->setValue('Add'); We instantiate AlbumForm and set the label on the submit button to \"Add\". We do this here as we'll want to re-use the form when editing an album and will use a different label. $request = $this->getRequest(); if (! $request->isPost()) { return ['form' => $form]; } If the request is not a POST request, then no form data has been submitted, and we need to display the form. laminas-mvc allows you to return an array of data instead of a view model if desired; if you do, the array will be used to create a view model. $album = new Album(); $form->setInputFilter($album->getInputFilter()); $form->setData($request->getPost()); At this point, we know we have a form submission. We create an Album instance, and pass its input filter on to the form; additionally, we pass the submitted data from the request instance to the form. if (! $form->isValid()) { return ['form' => $form]; } If form validation fails, we want to redisplay the form. At this point, the form contains information about what fields failed validation, and why, and this information will be communicated to the view layer. $album->exchangeArray($form->getData()); $this->table->saveAlbum($album); If the form is valid, then we grab the data from the form and store to the model using saveAlbum() . return $this->redirect()->toRoute('album'); After we have saved the new album row, we redirect back to the list of albums using the Redirect controller plugin. We now need to render the form in the add.phtml view script: <?php // module/Album/view/album/album/add.phtml: $title = 'Add new album'; $this->headTitle($title); ?> <h1><?= $this->escapeHtml($title) ?></h1> <?php $form->setAttribute('action', $this->url('album', ['action' => 'add'])); $form->prepare(); echo $this->form()->openTag($form); echo $this->formHidden($form->get('id')); echo $this->formRow($form->get('title')); echo $this->formRow($form->get('artist')); echo $this->formSubmit($form->get('submit')); echo $this->form()->closeTag(); We display a title as before, and then we render the form. laminas-form provides several view helpers to make this a little easier. The form() view helper has an openTag() and closeTag() method which we use to open and close the form. Then for each element with a label, we can use formRow() to render the label, input, and any validation error messages; for the two elements that are standalone and have no validation rules, we use formHidden() and formSubmit() . Alternatively, the process of rendering the form can be simplified by using the bundled formCollection view helper. For example, in the view script above replace all the form-rendering echo statements with: echo $this->formCollection($form); This will iterate over the form structure, calling the appropriate label, element, and error view helpers for each element, but you still have to wrap formCollection($form) with the open and close form tags. This helps reduce the complexity of your view script in situations where the default HTML rendering of the form is acceptable. You should now be able to use the \"Add new album\" page of the application at http://localhost:8080/album/add to add a new album record, resulting in something like the following: This doesn't look all that great! The reason is because Bootstrap, the CSS foundation used in the skeleton, has specialized markup for displaying forms! We can address that in our view script by: Adding markup around the elements. Rendering labels, elements, and error messages separately. Adding attributes to elements. Update your add.phtml view script to read as follows: <?php $title = 'Add new album'; $this->headTitle($title); ?> <h1><?= $this->escapeHtml($title) ?></h1> <?php // This provides a default CSS class and placeholder text for the title element: $album = $form->get('title'); $album->setAttribute('class', 'form-control'); $album->setAttribute('placeholder', 'Album title'); // This provides a default CSS class and placeholder text for the artist element: $artist = $form->get('artist'); $artist->setAttribute('class', 'form-control'); $artist->setAttribute('placeholder', 'Artist'); // This provides CSS classes for the submit button: $submit = $form->get('submit'); $submit->setAttribute('class', 'btn btn-primary'); $form->setAttribute('action', $this->url('album', ['action' => 'add'])); $form->prepare(); echo $this->form()->openTag($form); ?> <?php // Wrap the elements in divs marked as form groups, and render the // label, element, and errors separately within ?> <div class=\"form-group\"> <?= $this->formLabel($album) ?> <?= $this->formElement($album) ?> <?= $this->formElementErrors()->render($album, ['class' => 'help-block']) ?> </div> <div class=\"form-group\"> <?= $this->formLabel($artist) ?> <?= $this->formElement($artist) ?> <?= $this->formElementErrors()->render($artist, ['class' => 'help-block']) ?> </div> <?php echo $this->formSubmit($submit); echo $this->formHidden($form->get('id')); echo $this->form()->closeTag(); The results we get are much better: The above is meant to demonstrate both the ease of use of the default form features, as well as some of the customizations possible when rendering forms. You should be able to generate any markup necessary for your site. Editing an album Editing an album is almost identical to adding one, so the code is very similar. This time we use editAction() in the AlbumController : // module/Album/src/Controller/AlbumController.php: // ... public function editAction() { $id = (int) $this->params()->fromRoute('id', 0); if (0 === $id) { return $this->redirect()->toRoute('album', ['action' => 'add']); } // Retrieve the album with the specified id. Doing so raises // an exception if the album is not found, which should result // in redirecting to the landing page. try { $album = $this->table->getAlbum($id); } catch (\\Exception $e) { return $this->redirect()->toRoute('album', ['action' => 'index']); } $form = new AlbumForm(); $form->bind($album); $form->get('submit')->setAttribute('value', 'Edit'); $request = $this->getRequest(); $viewData = ['id' => $id, 'form' => $form]; if (! $request->isPost()) { return $viewData; } $form->setInputFilter($album->getInputFilter()); $form->setData($request->getPost()); if (! $form->isValid()) { return $viewData; } try { $this->table->saveAlbum($album); } catch (\\Exception $e) { } // Redirect to album list return $this->redirect()->toRoute('album', ['action' => 'index']); } This code should look comfortably familiar. Let's look at the differences from adding an album. Firstly, we look for the id that is in the matched route and use it to load the album to be edited: $id = (int) $this->params()->fromRoute('id', 0); if (0 === $id) { return $this->redirect()->toRoute('album', ['action' => 'add']); } // Retrieve the album with the specified id. Doing so raises // an exception if the album is not found, which should result // in redirecting to the landing page. try { $album = $this->table->getAlbum($id); } catch (\\Exception $e) { return $this->redirect()->toRoute('album', ['action' => 'index']); } params is a controller plugin that provides a convenient way to retrieve parameters from the matched route. We use it to retrieve the id from the route we created within the Album module's module.config.php . If the id is zero, then we redirect to the add action, otherwise, we continue by getting the album entity from the database. We have to check to make sure that the album with the specified id can actually be found. If it cannot, then the data access method throws an exception. We catch that exception and re-route the user to the index page. $form = new AlbumForm(); $form->bind($album); $form->get('submit')->setAttribute('value', 'Edit'); The form's bind() method attaches the model to the form. This is used in two ways: When displaying the form, the initial values for each element are extracted from the model. After successful validation in isValid() , the data from the form is put back into the model. These operations are done using a hydrator object. There are a number of hydrators, but the default one is Laminas\\Hydrator\\ArraySerializable which expects to find two methods in the model: getArrayCopy() and exchangeArray() . We have already written exchangeArray() in our Album entity, so we now need to write getArrayCopy() : // module/Album/src/Model/Album.php: // ... public function exchangeArray($data) { $this->id = isset($data['id']) ? $data['id'] : null; $this->artist = isset($data['artist']) ? $data['artist'] : null; $this->title = isset($data['title']) ? $data['title'] : null; } // Add the following method: public function getArrayCopy() { return [ 'id' => $this->id, 'artist' => $this->artist, 'title' => $this->title, ]; } // ... As a result of using bind() with its hydrator, we do not need to populate the form's data back into the $album as that's already been done, so we can just call the mapper's saveAlbum() method to store the changes back to the database. The view template, edit.phtml , looks very similar to the one for adding an album: <?php // module/Album/view/album/album/edit.phtml: $title = 'Edit album'; $this->headTitle($title); ?> <h1><?= $this->escapeHtml($title) ?></h1> <?php $album = $form->get('title'); $album->setAttribute('class', 'form-control'); $album->setAttribute('placeholder', 'Album title'); $artist = $form->get('artist'); $artist->setAttribute('class', 'form-control'); $artist->setAttribute('placeholder', 'Artist'); $submit = $form->get('submit'); $submit->setAttribute('class', 'btn btn-primary'); $form->setAttribute('action', $this->url('album', [ 'action' => 'edit', 'id' => $id, ])); $form->prepare(); echo $this->form()->openTag($form); ?> <div class=\"form-group\"> <?= $this->formLabel($album) ?> <?= $this->formElement($album) ?> <?= $this->formElementErrors()->render($album, ['class' => 'help-block']) ?> </div> <div class=\"form-group\"> <?= $this->formLabel($artist) ?> <?= $this->formElement($artist) ?> <?= $this->formElementErrors()->render($artist, ['class' => 'help-block']) ?> </div> <?php echo $this->formSubmit($submit); echo $this->formHidden($form->get('id')); echo $this->form()->closeTag(); The only changes are to use the ‘Edit Album' title and set the form's action to the 'edit' action too, using the current album identifier. You should now be able to edit albums. Deleting an album To round out our application, we need to add deletion. We have a \"Delete\" link next to each album on our list page, and the naive approach would be to do a delete when it's clicked. This would be wrong. Remembering our HTTP spec, we recall that you shouldn't do an irreversible action using GET and should use POST instead. We shall show a confirmation form when the user clicks delete, and if they then click \"yes\", we will do the deletion. As the form is trivial, we'll code it directly into our view (laminas-form is, after all, optional!). Let's start with the action code in AlbumController::deleteAction() : // module/Album/src/Controller/AlbumController.php: //... // Add content to the following method: public function deleteAction() { $id = (int) $this->params()->fromRoute('id', 0); if (!$id) { return $this->redirect()->toRoute('album'); } $request = $this->getRequest(); if ($request->isPost()) { $del = $request->getPost('del', 'No'); if ($del == 'Yes') { $id = (int) $request->getPost('id'); $this->table->deleteAlbum($id); } // Redirect to list of albums return $this->redirect()->toRoute('album'); } return [ 'id' => $id, 'album' => $this->table->getAlbum($id), ]; } //... As before, we get the id from the matched route, and check the request object's isPost() to determine whether to show the confirmation page or to delete the album. We use the table object to delete the row using the deleteAlbum() method and then redirect back the list of albums. If the request is not a POST, then we retrieve the correct database record and assign to the view, along with the id . The view script is a simple form: <?php // module/Album/view/album/album/delete.phtml: $title = 'Delete album'; $url = $this->url('album', ['action' => 'delete', 'id' => $id]); $this->headTitle($title); ?> <h1><?= $this->escapeHtml($title) ?></h1> <p> Are you sure that you want to delete \"<?= $this->escapeHtml($album->title) ?>\" by \"<?= $this->escapeHtml($album->artist) ?>\"? </p> <form action=\"<?= $url ?>\" method=\"post\"> <div class=\"form-group\"> <input type=\"hidden\" name=\"id\" value=\"<?= (int) $album->id ?>\" /> <input type=\"submit\" class=\"btn btn-danger\" name=\"del\" value=\"Yes\" /> <input type=\"submit\" class=\"btn btn-success\" name=\"del\" value=\"No\" /> </div> </form> In this script, we display a confirmation message to the user and then a form with \"Yes\" and \"No\" buttons. In the action, we checked specifically for the \"Yes\" value when doing the deletion. Ensuring that the home page displays the list of albums One final point. At the moment, the home page, http://laminas-mvc-tutorial.localhost/ doesn't display the list of albums. This is due to a route set up in the Application module's module.config.php . To change it, open module/Application/config/module.config.php and find the home route: 'home' => [ 'type' => \\Laminas\\Router\\Http\\Literal::class, 'options' => [ 'route' => '/', 'defaults' => [ 'controller' => Controller\\IndexController::class, 'action' => 'index', ], ], ], Import Album\\Controller\\AlbumController at the top of the file: use Album\\Controller\\AlbumController; and change the controller from Controller\\IndexController::class to AlbumController::class : 'home' => [ 'type' => \\Laminas\\Router\\Http\\Literal::class, 'options' => [ 'route' => '/', 'defaults' => [ 'controller' => AlbumController::class, // < -- change here 'action' => 'index', ], ], ], That's it — you now have a fully working application!","title":"Forms and Actions"},{"location":"getting-started/forms-and-actions/#forms-and-actions","text":"","title":"Forms and actions"},{"location":"getting-started/forms-and-actions/#adding-new-albums","text":"We can now code up the functionality to add new albums. There are two bits to this part: Display a form for user to provide details. Process the form submission and store to database. We will use laminas-form to do this. laminas-form manages the various form inputs as well as their validation, the latter of which is handled by the laminas-inputfilter component. We'll start by creating a new class, Album\\Form\\AlbumForm , extending from Laminas\\Form\\Form . Create the file module/Album/src/Form/AlbumForm.php with the following contents: namespace Album\\Form; use Laminas\\Form\\Element\\Hidden; use Laminas\\Form\\Element\\Submit; use Laminas\\Form\\Element\\Text; use Laminas\\Form\\Form; class AlbumForm extends Form { public function __construct($name = null) { // We will ignore the name provided to the constructor parent::__construct('album'); $this->add([ 'name' => 'id', 'type' => Hidden::class, ]); $this->add([ 'name' => 'title', 'type' => Text::class, 'options' => [ 'label' => 'Title', ], ]); $this->add([ 'name' => 'artist', 'type' => Text::class, 'options' => [ 'label' => 'Artist', ], ]); $this->add([ 'name' => 'submit', 'type' => Submit::class, 'attributes' => [ 'value' => 'Go', 'id' => 'submitbutton', ], ]); } } Within the constructor of AlbumForm we do several things. First, we set the name of the form as we call the parent's constructor. Then, we create four form elements: the id, title, artist, and submit button. For each item we set various attributes and options, including the label to be displayed.","title":"Adding new albums"},{"location":"getting-started/forms-and-actions/#editing-an-album","text":"Editing an album is almost identical to adding one, so the code is very similar. This time we use editAction() in the AlbumController : // module/Album/src/Controller/AlbumController.php: // ... public function editAction() { $id = (int) $this->params()->fromRoute('id', 0); if (0 === $id) { return $this->redirect()->toRoute('album', ['action' => 'add']); } // Retrieve the album with the specified id. Doing so raises // an exception if the album is not found, which should result // in redirecting to the landing page. try { $album = $this->table->getAlbum($id); } catch (\\Exception $e) { return $this->redirect()->toRoute('album', ['action' => 'index']); } $form = new AlbumForm(); $form->bind($album); $form->get('submit')->setAttribute('value', 'Edit'); $request = $this->getRequest(); $viewData = ['id' => $id, 'form' => $form]; if (! $request->isPost()) { return $viewData; } $form->setInputFilter($album->getInputFilter()); $form->setData($request->getPost()); if (! $form->isValid()) { return $viewData; } try { $this->table->saveAlbum($album); } catch (\\Exception $e) { } // Redirect to album list return $this->redirect()->toRoute('album', ['action' => 'index']); } This code should look comfortably familiar. Let's look at the differences from adding an album. Firstly, we look for the id that is in the matched route and use it to load the album to be edited: $id = (int) $this->params()->fromRoute('id', 0); if (0 === $id) { return $this->redirect()->toRoute('album', ['action' => 'add']); } // Retrieve the album with the specified id. Doing so raises // an exception if the album is not found, which should result // in redirecting to the landing page. try { $album = $this->table->getAlbum($id); } catch (\\Exception $e) { return $this->redirect()->toRoute('album', ['action' => 'index']); } params is a controller plugin that provides a convenient way to retrieve parameters from the matched route. We use it to retrieve the id from the route we created within the Album module's module.config.php . If the id is zero, then we redirect to the add action, otherwise, we continue by getting the album entity from the database. We have to check to make sure that the album with the specified id can actually be found. If it cannot, then the data access method throws an exception. We catch that exception and re-route the user to the index page. $form = new AlbumForm(); $form->bind($album); $form->get('submit')->setAttribute('value', 'Edit'); The form's bind() method attaches the model to the form. This is used in two ways: When displaying the form, the initial values for each element are extracted from the model. After successful validation in isValid() , the data from the form is put back into the model. These operations are done using a hydrator object. There are a number of hydrators, but the default one is Laminas\\Hydrator\\ArraySerializable which expects to find two methods in the model: getArrayCopy() and exchangeArray() . We have already written exchangeArray() in our Album entity, so we now need to write getArrayCopy() : // module/Album/src/Model/Album.php: // ... public function exchangeArray($data) { $this->id = isset($data['id']) ? $data['id'] : null; $this->artist = isset($data['artist']) ? $data['artist'] : null; $this->title = isset($data['title']) ? $data['title'] : null; } // Add the following method: public function getArrayCopy() { return [ 'id' => $this->id, 'artist' => $this->artist, 'title' => $this->title, ]; } // ... As a result of using bind() with its hydrator, we do not need to populate the form's data back into the $album as that's already been done, so we can just call the mapper's saveAlbum() method to store the changes back to the database. The view template, edit.phtml , looks very similar to the one for adding an album: <?php // module/Album/view/album/album/edit.phtml: $title = 'Edit album'; $this->headTitle($title); ?> <h1><?= $this->escapeHtml($title) ?></h1> <?php $album = $form->get('title'); $album->setAttribute('class', 'form-control'); $album->setAttribute('placeholder', 'Album title'); $artist = $form->get('artist'); $artist->setAttribute('class', 'form-control'); $artist->setAttribute('placeholder', 'Artist'); $submit = $form->get('submit'); $submit->setAttribute('class', 'btn btn-primary'); $form->setAttribute('action', $this->url('album', [ 'action' => 'edit', 'id' => $id, ])); $form->prepare(); echo $this->form()->openTag($form); ?> <div class=\"form-group\"> <?= $this->formLabel($album) ?> <?= $this->formElement($album) ?> <?= $this->formElementErrors()->render($album, ['class' => 'help-block']) ?> </div> <div class=\"form-group\"> <?= $this->formLabel($artist) ?> <?= $this->formElement($artist) ?> <?= $this->formElementErrors()->render($artist, ['class' => 'help-block']) ?> </div> <?php echo $this->formSubmit($submit); echo $this->formHidden($form->get('id')); echo $this->form()->closeTag(); The only changes are to use the ‘Edit Album' title and set the form's action to the 'edit' action too, using the current album identifier. You should now be able to edit albums.","title":"Editing an album"},{"location":"getting-started/forms-and-actions/#deleting-an-album","text":"To round out our application, we need to add deletion. We have a \"Delete\" link next to each album on our list page, and the naive approach would be to do a delete when it's clicked. This would be wrong. Remembering our HTTP spec, we recall that you shouldn't do an irreversible action using GET and should use POST instead. We shall show a confirmation form when the user clicks delete, and if they then click \"yes\", we will do the deletion. As the form is trivial, we'll code it directly into our view (laminas-form is, after all, optional!). Let's start with the action code in AlbumController::deleteAction() : // module/Album/src/Controller/AlbumController.php: //... // Add content to the following method: public function deleteAction() { $id = (int) $this->params()->fromRoute('id', 0); if (!$id) { return $this->redirect()->toRoute('album'); } $request = $this->getRequest(); if ($request->isPost()) { $del = $request->getPost('del', 'No'); if ($del == 'Yes') { $id = (int) $request->getPost('id'); $this->table->deleteAlbum($id); } // Redirect to list of albums return $this->redirect()->toRoute('album'); } return [ 'id' => $id, 'album' => $this->table->getAlbum($id), ]; } //... As before, we get the id from the matched route, and check the request object's isPost() to determine whether to show the confirmation page or to delete the album. We use the table object to delete the row using the deleteAlbum() method and then redirect back the list of albums. If the request is not a POST, then we retrieve the correct database record and assign to the view, along with the id . The view script is a simple form: <?php // module/Album/view/album/album/delete.phtml: $title = 'Delete album'; $url = $this->url('album', ['action' => 'delete', 'id' => $id]); $this->headTitle($title); ?> <h1><?= $this->escapeHtml($title) ?></h1> <p> Are you sure that you want to delete \"<?= $this->escapeHtml($album->title) ?>\" by \"<?= $this->escapeHtml($album->artist) ?>\"? </p> <form action=\"<?= $url ?>\" method=\"post\"> <div class=\"form-group\"> <input type=\"hidden\" name=\"id\" value=\"<?= (int) $album->id ?>\" /> <input type=\"submit\" class=\"btn btn-danger\" name=\"del\" value=\"Yes\" /> <input type=\"submit\" class=\"btn btn-success\" name=\"del\" value=\"No\" /> </div> </form> In this script, we display a confirmation message to the user and then a form with \"Yes\" and \"No\" buttons. In the action, we checked specifically for the \"Yes\" value when doing the deletion.","title":"Deleting an album"},{"location":"getting-started/forms-and-actions/#ensuring-that-the-home-page-displays-the-list-of-albums","text":"One final point. At the moment, the home page, http://laminas-mvc-tutorial.localhost/ doesn't display the list of albums. This is due to a route set up in the Application module's module.config.php . To change it, open module/Application/config/module.config.php and find the home route: 'home' => [ 'type' => \\Laminas\\Router\\Http\\Literal::class, 'options' => [ 'route' => '/', 'defaults' => [ 'controller' => Controller\\IndexController::class, 'action' => 'index', ], ], ], Import Album\\Controller\\AlbumController at the top of the file: use Album\\Controller\\AlbumController; and change the controller from Controller\\IndexController::class to AlbumController::class : 'home' => [ 'type' => \\Laminas\\Router\\Http\\Literal::class, 'options' => [ 'route' => '/', 'defaults' => [ 'controller' => AlbumController::class, // < -- change here 'action' => 'index', ], ], ], That's it — you now have a fully working application!","title":"Ensuring that the home page displays the list of albums"},{"location":"getting-started/modules/","text":"Modules laminas-mvc uses a module system to organise your main application-specific code within each module. The Application module provided by the skeleton is used to provide bootstrapping, error, and routing configuration to the whole application. It is usually used to provide application level controllers for the home page of an application, but we are not going to use the default one provided in this tutorial as we want our album list to be the home page, which will live in our own module. We are going to put all our code into the Album module which will contain our controllers, models, forms and views, along with configuration. We’ll also tweak the Application module as required. Let’s start with the directories required. Setting up the Album module Start by creating a directory called Album under module with the following subdirectories to hold the module’s files: laminas-mvc-tutorial/ /module /Album /config /src /Controller /Form /Model /view /album /album The Album module has separate directories for the different types of files we will have. The PHP files that contain classes within the Album namespace live in the src/ directory. The view directory also has a sub-folder called album for our module's view scripts. In order to load and configure a module, Laminas provides a ModuleManager . This will look for a Module class in the specified module namespace (i.e., Album ); in the case of our new module, that means the class Album\\Module , which will be found in module/Album/src/Module.php . Let's create that file now, with the following contents: namespace Album; use Laminas\\ModuleManager\\Feature\\ConfigProviderInterface; class Module implements ConfigProviderInterface { public function getConfig() { return include __DIR__ . '/../config/module.config.php'; } } The ModuleManager will call getConfig() automatically for us. Autoloading While Laminas provides autoloading capabilities via its laminas-loader component, we recommend using Composer's autoloading capabilities. As such, we need to inform Composer of our new namespace, and where its files live. Open composer.json in your project root, and look for the autoload section; it should look like the following by default: \"autoload\": { \"psr-4\": { \"Application\\\\\": \"module/Application/src/\" } }, We'll now add our new module to the list, so it now reads: \"autoload\": { \"psr-4\": { \"Application\\\\\": \"module/Application/src/\", \"Album\\\\\": \"module/Album/src/\" } }, Once you've made that change, run the following to ensure Composer updates its autoloading rules: $ composer dump-autoload Configuration Having registered the autoloader, let’s have a quick look at the getConfig() method in Album\\Module . This method loads the config/module.config.php file under the module's root directory. Create a file called module.config.php under laminas-mvc-tutorial/module/Album/config/ : namespace Album; use Laminas\\ServiceManager\\Factory\\InvokableFactory; return [ 'controllers' => [ 'factories' => [ Controller\\AlbumController::class => InvokableFactory::class, ], ], 'view_manager' => [ 'template_path_stack' => [ 'album' => __DIR__ . '/../view', ], ], ]; The config information is passed to the relevant components by the ServiceManager . We need two initial sections: controllers and view_manager . The controllers section provides a list of all the controllers provided by the module. We will need one controller, AlbumController ; we'll reference it by its fully qualified class name, and use the laminas-servicemanager InvokableFactory to create instances of it. Within the view_manager section, we add our view directory to the TemplatePathStack configuration. This will allow it to find the view scripts for the Album module that are stored in our view/ directory. Informing the application about our new module We now need to tell the ModuleManager that this new module exists. This is done in the application’s config/modules.config.php file which is provided by the skeleton application. Update this file so that the array it returns contains the Album module as well, so the file now looks like this: (Changes required are highlighted using comments; original comments from the file are omitted for brevity.) return [ 'Laminas\\Form', 'Laminas\\Db', 'Laminas\\Router', 'Laminas\\Validator', 'Application', 'Album', // < -- Add this line ]; As you can see, we have added our Album module into the list of modules after the Application module. We have now set up the module ready for putting our custom code into it.","title":"Modules"},{"location":"getting-started/modules/#modules","text":"laminas-mvc uses a module system to organise your main application-specific code within each module. The Application module provided by the skeleton is used to provide bootstrapping, error, and routing configuration to the whole application. It is usually used to provide application level controllers for the home page of an application, but we are not going to use the default one provided in this tutorial as we want our album list to be the home page, which will live in our own module. We are going to put all our code into the Album module which will contain our controllers, models, forms and views, along with configuration. We’ll also tweak the Application module as required. Let’s start with the directories required.","title":"Modules"},{"location":"getting-started/modules/#setting-up-the-album-module","text":"Start by creating a directory called Album under module with the following subdirectories to hold the module’s files: laminas-mvc-tutorial/ /module /Album /config /src /Controller /Form /Model /view /album /album The Album module has separate directories for the different types of files we will have. The PHP files that contain classes within the Album namespace live in the src/ directory. The view directory also has a sub-folder called album for our module's view scripts. In order to load and configure a module, Laminas provides a ModuleManager . This will look for a Module class in the specified module namespace (i.e., Album ); in the case of our new module, that means the class Album\\Module , which will be found in module/Album/src/Module.php . Let's create that file now, with the following contents: namespace Album; use Laminas\\ModuleManager\\Feature\\ConfigProviderInterface; class Module implements ConfigProviderInterface { public function getConfig() { return include __DIR__ . '/../config/module.config.php'; } } The ModuleManager will call getConfig() automatically for us.","title":"Setting up the Album module"},{"location":"getting-started/modules/#configuration","text":"Having registered the autoloader, let’s have a quick look at the getConfig() method in Album\\Module . This method loads the config/module.config.php file under the module's root directory. Create a file called module.config.php under laminas-mvc-tutorial/module/Album/config/ : namespace Album; use Laminas\\ServiceManager\\Factory\\InvokableFactory; return [ 'controllers' => [ 'factories' => [ Controller\\AlbumController::class => InvokableFactory::class, ], ], 'view_manager' => [ 'template_path_stack' => [ 'album' => __DIR__ . '/../view', ], ], ]; The config information is passed to the relevant components by the ServiceManager . We need two initial sections: controllers and view_manager . The controllers section provides a list of all the controllers provided by the module. We will need one controller, AlbumController ; we'll reference it by its fully qualified class name, and use the laminas-servicemanager InvokableFactory to create instances of it. Within the view_manager section, we add our view directory to the TemplatePathStack configuration. This will allow it to find the view scripts for the Album module that are stored in our view/ directory.","title":"Configuration"},{"location":"getting-started/modules/#informing-the-application-about-our-new-module","text":"We now need to tell the ModuleManager that this new module exists. This is done in the application’s config/modules.config.php file which is provided by the skeleton application. Update this file so that the array it returns contains the Album module as well, so the file now looks like this: (Changes required are highlighted using comments; original comments from the file are omitted for brevity.) return [ 'Laminas\\Form', 'Laminas\\Db', 'Laminas\\Router', 'Laminas\\Validator', 'Application', 'Album', // < -- Add this line ]; As you can see, we have added our Album module into the list of modules after the Application module. We have now set up the module ready for putting our custom code into it.","title":"Informing the application about our new module"},{"location":"getting-started/overview/","text":"Getting Started with Laminas MVC Applications This tutorial is intended to give an introduction to using Laminas by creating a simple database driven application using the Model-View-Controller paradigm. By the end you will have a working Laminas application and you can then poke around the code to find out more about how it all works and fits together. The tutorial application The application that we are going to build is a simple inventory system to display which albums we own. The main page will list our collection and allow us to add, edit and delete CDs. We are going to need four pages in our website: Page Description List of albums This will display the list of albums and provide links to edit and delete them. Also, a link to enable adding new albums will be provided. Add new album This page will provide a form for adding a new album. Edit album This page will provide a form for editing an album. Delete album This page will confirm that we want to delete an album and then delete it. We will also need to store our data into a database. We will only need one table with these fields in it: Field name Type Null? Notes id integer No Primary key, auto-increment artist varchar(100) No title varchar(100) No","title":"Overview"},{"location":"getting-started/overview/#getting-started-with-laminas-mvc-applications","text":"This tutorial is intended to give an introduction to using Laminas by creating a simple database driven application using the Model-View-Controller paradigm. By the end you will have a working Laminas application and you can then poke around the code to find out more about how it all works and fits together.","title":"Getting Started with Laminas MVC Applications"},{"location":"getting-started/overview/#the-tutorial-application","text":"The application that we are going to build is a simple inventory system to display which albums we own. The main page will list our collection and allow us to add, edit and delete CDs. We are going to need four pages in our website: Page Description List of albums This will display the list of albums and provide links to edit and delete them. Also, a link to enable adding new albums will be provided. Add new album This page will provide a form for adding a new album. Edit album This page will provide a form for editing an album. Delete album This page will confirm that we want to delete an album and then delete it. We will also need to store our data into a database. We will only need one table with these fields in it: Field name Type Null? Notes id integer No Primary key, auto-increment artist varchar(100) No title varchar(100) No","title":"The tutorial application"},{"location":"getting-started/routing-and-controllers/","text":"Routing and controllers We will build a very simple inventory system to display our album collection. The home page will list our collection and allow us to add, edit and delete albums. Hence the following pages are required: Page Description Home This will display the list of albums and provide links to edit and delete them. Also, a link to enable adding new albums will be provided. Add new album This page will provide a form for adding a new album. Edit album This page will provide a form for editing an album. Delete album This page will confirm that we want to delete an album and then delete it. Before we set up our files, it's important to understand how the framework expects the pages to be organised. Each page of the application is known as an action and actions are grouped into controllers within modules . Hence, you would generally group related actions into a controller; for instance, a news controller might have actions of current , archived , and view . As we have four pages that all apply to albums, we will group them in a single controller AlbumController within our Album module as four actions. The four actions will be: Page Controller Action Home AlbumController index Add new album AlbumController add Edit album AlbumController edit Delete album AlbumController delete The mapping of a URL to a particular action is done using routes that are defined in the module’s module.config.php file. We will add a route for our album actions. This is the updated module config file with the new code highlighted using comments. namespace Album; use Laminas\\Router\\Http\\Segment; use Laminas\\ServiceManager\\Factory\\InvokableFactory; return [ 'controllers' => [ 'factories' => [ Controller\\AlbumController::class => InvokableFactory::class, ], ], // The following section is new and should be added to your file: 'router' => [ 'routes' => [ 'album' => [ 'type' => Segment::class, 'options' => [ 'route' => '/album[/:action[/:id]]', 'constraints' => [ 'action' => '[a-zA-Z][a-zA-Z0-9_-]*', 'id' => '[0-9]+', ], 'defaults' => [ 'controller' => Controller\\AlbumController::class, 'action' => 'index', ], ], ], ], ], 'view_manager' => [ 'template_path_stack' => [ 'album' => __DIR__ . '/../view', ], ], ]; The name of the route is ‘album’ and has a type of ‘segment’. The segment route allows us to specify placeholders in the URL pattern (route) that will be mapped to named parameters in the matched route. In this case, the route is /album[/:action[/:id]] which will match any URL that starts with /album . The next segment will be an optional action name, and then finally the next segment will be mapped to an optional id. The square brackets indicate that a segment is optional. The constraints section allows us to ensure that the characters within a segment are as expected, so we have limited actions to starting with a letter and then subsequent characters only being alphanumeric, underscore, or hyphen. We also limit the id to digits. This route allows us to have the following URLs: URL Page Action /album Home (list of albums) index /album/add Add new album add /album/edit/2 Edit album with an id of 2 edit /album/delete/4 Delete album with an id of 4 delete Create the controller We are now ready to set up our controller. For laminas-mvc, the controller is a class that is generally called {Controller name}Controller ; note that {Controller name} must start with a capital letter. This class lives in a file called {Controller name}Controller.php within the Controller subdirectory for the module; in our case that is module/Album/src/Controller/ . Each action is a public method within the controller class that is named {action name}Action , where {action name} should start with a lower case letter. Conventions not strictly enforced This is by convention. laminas-mvc doesn't provide many restrictions on controllers other than that they must implement the Laminas\\Stdlib\\Dispatchable interface. The framework provides two abstract classes that do this for us: Laminas\\Mvc\\Controller\\AbstractActionController and Laminas\\Mvc\\Controller\\AbstractRestfulController . We'll be using the standard AbstractActionController , but if you’re intending to write a RESTful web service, AbstractRestfulController may be useful. Let’s go ahead and create our controller class in the file laminas-mvc-tutorials/module/Album/src/Controller/AlbumController.php : namespace Album\\Controller; use Laminas\\Mvc\\Controller\\AbstractActionController; use Laminas\\View\\Model\\ViewModel; class AlbumController extends AbstractActionController { public function indexAction() { } public function addAction() { } public function editAction() { } public function deleteAction() { } } We have now set up the four actions that we want to use. They won't work yet until we set up the views. The URLs for each action are: URL Method called http://localhost:8080/album Album\\Controller\\AlbumController::indexAction http://localhost:8080/album/add Album\\Controller\\AlbumController::addAction http://localhost:8080/album/edit Album\\Controller\\AlbumController::editAction http://localhost:8080/album/delete Album\\Controller\\AlbumController::deleteAction Note If you are using self-hosted Apache, replace http://localhost:8080/ by http://laminas-mvc-tutorial.localhost/ We now have a working router and the actions are set up for each page of our application. It's time to build the view and the model layer. Initialise the view scripts To integrate the view into our application, we need to create some view script files. These files will be executed by the DefaultViewStrategy and will be passed any variables or view models that are returned from the controller action method. These view scripts are stored in our module’s views directory within a directory named after the controller. Create these four empty files now: module/Album/view/album/album/index.phtml module/Album/view/album/album/add.phtml module/Album/view/album/album/edit.phtml module/Album/view/album/album/delete.phtml We can now start filling everything in, starting with our database and models.","title":"Routing and Controllers"},{"location":"getting-started/routing-and-controllers/#routing-and-controllers","text":"We will build a very simple inventory system to display our album collection. The home page will list our collection and allow us to add, edit and delete albums. Hence the following pages are required: Page Description Home This will display the list of albums and provide links to edit and delete them. Also, a link to enable adding new albums will be provided. Add new album This page will provide a form for adding a new album. Edit album This page will provide a form for editing an album. Delete album This page will confirm that we want to delete an album and then delete it. Before we set up our files, it's important to understand how the framework expects the pages to be organised. Each page of the application is known as an action and actions are grouped into controllers within modules . Hence, you would generally group related actions into a controller; for instance, a news controller might have actions of current , archived , and view . As we have four pages that all apply to albums, we will group them in a single controller AlbumController within our Album module as four actions. The four actions will be: Page Controller Action Home AlbumController index Add new album AlbumController add Edit album AlbumController edit Delete album AlbumController delete The mapping of a URL to a particular action is done using routes that are defined in the module’s module.config.php file. We will add a route for our album actions. This is the updated module config file with the new code highlighted using comments. namespace Album; use Laminas\\Router\\Http\\Segment; use Laminas\\ServiceManager\\Factory\\InvokableFactory; return [ 'controllers' => [ 'factories' => [ Controller\\AlbumController::class => InvokableFactory::class, ], ], // The following section is new and should be added to your file: 'router' => [ 'routes' => [ 'album' => [ 'type' => Segment::class, 'options' => [ 'route' => '/album[/:action[/:id]]', 'constraints' => [ 'action' => '[a-zA-Z][a-zA-Z0-9_-]*', 'id' => '[0-9]+', ], 'defaults' => [ 'controller' => Controller\\AlbumController::class, 'action' => 'index', ], ], ], ], ], 'view_manager' => [ 'template_path_stack' => [ 'album' => __DIR__ . '/../view', ], ], ]; The name of the route is ‘album’ and has a type of ‘segment’. The segment route allows us to specify placeholders in the URL pattern (route) that will be mapped to named parameters in the matched route. In this case, the route is /album[/:action[/:id]] which will match any URL that starts with /album . The next segment will be an optional action name, and then finally the next segment will be mapped to an optional id. The square brackets indicate that a segment is optional. The constraints section allows us to ensure that the characters within a segment are as expected, so we have limited actions to starting with a letter and then subsequent characters only being alphanumeric, underscore, or hyphen. We also limit the id to digits. This route allows us to have the following URLs: URL Page Action /album Home (list of albums) index /album/add Add new album add /album/edit/2 Edit album with an id of 2 edit /album/delete/4 Delete album with an id of 4 delete","title":"Routing and controllers"},{"location":"getting-started/routing-and-controllers/#initialise-the-view-scripts","text":"To integrate the view into our application, we need to create some view script files. These files will be executed by the DefaultViewStrategy and will be passed any variables or view models that are returned from the controller action method. These view scripts are stored in our module’s views directory within a directory named after the controller. Create these four empty files now: module/Album/view/album/album/index.phtml module/Album/view/album/album/add.phtml module/Album/view/album/album/edit.phtml module/Album/view/album/album/delete.phtml We can now start filling everything in, starting with our database and models.","title":"Initialise the view scripts"},{"location":"getting-started/skeleton-application/","text":"Getting started: A skeleton application Create a New Project In order to build our application, we need to have at least PHP 8.1. We will start with the Laminas MVC Skeleton Application available on GitHub . Use Composer to create a new project from scratch: $ composer create-project -s dev laminas/laminas-mvc-skeleton path/to/install This will install an initial set of dependencies, including: laminas-component-installer, which helps automate injection of component configuration into your application. laminas-mvc, the kernel for MVC applications. The default is to provide the minimum amount of dependencies necessary to run a laminas-mvc application. However, you may have additional needs that you know at the outset, and, as such, the skeleton also ships with an installer plugin that will prompt you for a number of items. First, it will prompt: Do you want a minimal install (no optional packages)? Y/n Prompts and Default Values All prompts emitted by the installer provide the list of options available, and will specify the default option via a capital letter. Default values are used if the user presses \"Enter\" with no value. In the previous example, \"Y\" is the default. If you answer \"Y\", or press enter with no selection, the installer will not raise any additional prompts, and finish installing your application. If you answer \"n\", it will continue prompting you: Would you like to install the developer toolbar? y/N The developer toolbar provides an in-browser toolbar with timing and profiling information, and can be useful when debugging an application. For the purposes of the tutorial, however, we will not be using it; hit either \"Enter\", or \"n\" followed by \"Enter\". Would you like to install caching support? y/N We will not be demonstrating caching in this tutorial, so either hit \"Enter\", or \"n\" followed by \"Enter\". Would you like to install database support (installs laminas-db)? y/N We will be using laminas-db extensively in this tutorial, so hit \"y\" followed by \"Enter\". You should see the following text appear: Will install laminas/laminas-db (^2.17.0) When prompted to install as a module, select application.config.php or modules.config.php The next prompt is: Would you like to install forms support (installs laminas-form)? y/N This tutorial also uses laminas-form, so we will again select \"y\" to install this; doing so emits a similar message to that used for laminas-db. At this point, we can answer \"n\" to the remaining features: Would you like to install JSON de/serialization support? y/N Would you like to install logging support? y/N Would you like to install command-line interface support? y/N Would you like to install i18n support? y/N Would you like to install the official MVC plugins, including PRG support, identity, and flash messages? y/N Would you like to use the PSR-7 middleware dispatcher? y/N Would you like to install sessions support? y/N Would you like to install the laminas-di integration for laminas-servicemanager? y/N At a certain point, you'll see the following text: Updating root package Running an update to install optional packages ... Updating application configuration... Please select which config file you wish to inject 'Laminas\\Db' into: [0] Do not inject [1] config/modules.config.php Make your selection (default is 1): We want to enable the various selections we made in the application. As such, we'll choose 1 , which will then give us the following prompt: Remember this option for other packages of the same type? (y/N) In our case, we can safely say \"y\", which will mean we will no longer be prompted for additional packages. (The only package in the default set of prompts that you may not want to enable by default is Laminas\\Test .) Once the installation is done, the skeleton installer removes itself, and the new application is ready to start! Downloading the Skeleton Another way to install the Laminas MVC Skeleton Application is to use github to download a compressed archive. Go to https://github.com/laminas/laminas-mvc-skeleton, click the \"Clone or download\" button, and select \"Download ZIP\". This will download a file with a name like laminas-mvc-skeleton-master.zip or similar. Unzip this file into the directory where you keep all your vhosts and rename the resultant directory to laminas-mvc-tutorial . laminas-mvc-skeleton is set up to use Composer to resolve its dependencies. Run the following from within your new laminas-mvc-tutorial folder to install them: $ composer self-update $ composer install This takes a while. You should see output like the following: Installing dependencies from lock file - Installing laminas/laminas-component-installer (2.1.2) ... Generating autoload files At this point, you will be prompted to answer questions as noted above. Alternately, if you do not have Composer installed, but do have docker-compose available, you can run Composer via those: $ docker-compose build $ docker-compose run laminas composer install Timeouts If you see this message: [RuntimeException] The process timed out. then your connection was too slow to download the entire package in time, and composer timed out. To avoid this, instead of running: $ composer install run instead: $ COMPOSER_PROCESS_TIMEOUT=5000 composer install Windows Users Using WAMP For windows users with wamp: 1. Install Composer for Windows Check Composer is properly installed by running: $ composer Otherwise follow the installation guide for Composer . 2. Install Git for Windows Check Git is properly installed by running: $ git Otherwise follow the installation guide for GitHub Desktop . 3. Now Install the Skeleton Using $ composer create-project -s dev laminas/laminas-mvc-skeleton path/to/install We can now move on to the web server setup. Web Servers In this tutorial, we will step you through different ways to set up your web server: Via the PHP built-in web server. Via docker-compose. Using Apache. Using the Built-in PHP Web Server You can use PHP's built-in web server when developing your application. To do this, start the server from the project's root directory: $ php -S 0.0.0.0:8080 -t public public/index.php This will make the website available on port 8080 on all network interfaces, using public/index.php to handle routing. This means the site is accessible via http://localhost:8080 or http://<your-local-IP>:8080 . If you’ve done it right, you should see the following. To test that your routing is working, navigate to http://localhost:8080/1234 , and you should see the following 404 page: Development only PHP's built-in web server should be used for development only . Using docker-compose Docker containers wrap a piece of software and everything needed to run it, guaranteeing consistent operation regardless of the host environment; it is an alternative to virtual machines, as it runs as a layer on top of the host environment. docker-compose is a tool for automating configuration of containers and composing dependencies between them, such as volume storage, networking, etc. The skeleton application ships with a Dockerfile and configuration for docker-compose; we recommend using docker-compose, as it provides a foundation for mapping additional containers you might need as part of your application, including a database server, cache servers, and more. To build and start the image, use: $ docker-compose up -d --build After the first build, you can truncate this to: $ docker-compose up -d Once built, you can also run commands on the container. The docker-compose configuration initially only defines one container, with the environment name \"laminas\"; use that to execute commands, such as updating dependencies via composer: $ docker-compose run laminas composer update The configuration includes both PHP 8.3 and Apache 2.4, and maps the host port 8080 to port 80 of the container. Using the Apache Web Server We will not cover installing Apache , and will assume you already have it installed. We recommend installing Apache 2.4, and will only cover configuration for that version. You now need to create an Apache virtual host for the application and edit your hosts file so that http://laminas-mvc-tutorial.localhost will serve index.php from the laminas-mvc-tutorial/public/ directory. Setting up the virtual host is usually done within httpd.conf or extra/httpd-vhosts.conf . If you are using httpd-vhosts.conf , ensure that this file is included by your main httpd.conf file. Some Linux distributions (ex: Ubuntu) package Apache so that configuration files are stored in /etc/apache2 and create one file per virtual host inside folder /etc/apache2/sites-enabled . In this case, you would place the virtual host block below into the file /etc/apache2/sites-enabled/laminas-mvc-tutorial . Ensure that NameVirtualHost is defined and set to *:80 or similar, and then define a virtual host along these lines: <VirtualHost *:80> ServerName laminas-mvc-tutorial.localhost DocumentRoot /path/to/laminas-mvc-tutorial/public SetEnv APPLICATION_ENV \"development\" <Directory /path/to/laminas-mvc-tutorial/public> DirectoryIndex index.php AllowOverride All Require all granted </Directory> </VirtualHost> Make sure that you update your /etc/hosts or c:\\windows\\system32\\drivers\\etc\\hosts file so that laminas-mvc-tutorial.localhost is mapped to 127.0.0.1 . The website can then be accessed using http://laminas-mvc-tutorial.localhost . 127.0.0.1 laminas-mvc-tutorial.localhost localhost Restart Apache. If you've done so correctly, you will get the same results as covered under the PHP built-in web server . To test that your .htaccess file is working, navigate to http://laminas-mvc-tutorial.localhost/1234 , and you should see the 404 page as noted earlier. If you see a standard Apache 404 error, then you need to fix your .htaccess usage before continuing. If you're are using IIS with the URL Rewrite Module, import the following: RewriteCond %{REQUEST_FILENAME} !-f RewriteRule ^ index.php [NC,L] Error Reporting Optionally, when using Apache , you can use the APPLICATION_ENV setting in your VirtualHost to let PHP output all its errors to the browser. This can be useful during the development of your application. Edit laminas-mvc-tutorial/public/index.php directory and change it to the following: use Laminas\\Mvc\\Application; use Laminas\\Stdlib\\ArrayUtils; /** * Display all errors when APPLICATION_ENV is development. */ if ($_SERVER['APPLICATION_ENV'] === 'development') { error_reporting(E_ALL); ini_set(\"display_errors\", '1'); } /** * This makes our life easier when dealing with paths. Everything is relative * to the application root now. */ chdir(dirname(__DIR__)); // Decline static file requests back to the PHP built-in webserver if (php_sapi_name() === 'cli-server') { $path = realpath(__DIR__ . parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH)); if (__FILE__ !== $path && is_file($path)) { return false; } unset($path); } // Composer autoloading include __DIR__ . '/../vendor/autoload.php'; if (! class_exists(Application::class)) { throw new RuntimeException( \"Unable to load application.\\n\" . \"- Type `composer install` if you are developing locally.\\n\" . \"- Type `docker-compose run laminas composer install` if you are using Docker.\\n\" ); } // Retrieve configuration $appConfig = require __DIR__ . '/../config/application.config.php'; if (file_exists(__DIR__ . '/../config/development.config.php')) { $appConfig = ArrayUtils::merge($appConfig, require __DIR__ . '/../config/development.config.php'); } // Run the application! Application::init($appConfig)->run(); You now have a working skeleton application, and we can start adding the specifics for our application. Development Mode Before we begin, we're going to enable development mode for the application. The skeleton application provides two files that allow us to specify general development settings we want to use everywhere; these may include enabling modules for debugging, or enabling error display in our view scripts. These files are located at: config/development.config.php.dist config/autoload/development.local.php.dist When we enable development mode, these files are copied to: config/development.config.php config/autoload/development.local.php This allows them to be merged into our application. When we disable development mode, these two files that were created are then removed, leaving only the .dist versions. (The repository also contains rules to ignore the copies.) Let's enable development mode now: $ composer development-enable Never Enable Development Mode in Production You should never enable development mode in production, as the typical reason to enable it is to enable debugging! As noted, the artifacts generated by enabling development mode cannot be committed to your repository, so assuming you don't run the command in production, you should be safe. You can test the status of development mode using: $ composer development-status And you can disable it using: $ composer development-disable","title":"The Skeleton Application"},{"location":"getting-started/skeleton-application/#getting-started-a-skeleton-application","text":"","title":"Getting started: A skeleton application"},{"location":"getting-started/skeleton-application/#create-a-new-project","text":"In order to build our application, we need to have at least PHP 8.1. We will start with the Laminas MVC Skeleton Application available on GitHub . Use Composer to create a new project from scratch: $ composer create-project -s dev laminas/laminas-mvc-skeleton path/to/install This will install an initial set of dependencies, including: laminas-component-installer, which helps automate injection of component configuration into your application. laminas-mvc, the kernel for MVC applications. The default is to provide the minimum amount of dependencies necessary to run a laminas-mvc application. However, you may have additional needs that you know at the outset, and, as such, the skeleton also ships with an installer plugin that will prompt you for a number of items. First, it will prompt: Do you want a minimal install (no optional packages)? Y/n Prompts and Default Values All prompts emitted by the installer provide the list of options available, and will specify the default option via a capital letter. Default values are used if the user presses \"Enter\" with no value. In the previous example, \"Y\" is the default. If you answer \"Y\", or press enter with no selection, the installer will not raise any additional prompts, and finish installing your application. If you answer \"n\", it will continue prompting you: Would you like to install the developer toolbar? y/N The developer toolbar provides an in-browser toolbar with timing and profiling information, and can be useful when debugging an application. For the purposes of the tutorial, however, we will not be using it; hit either \"Enter\", or \"n\" followed by \"Enter\". Would you like to install caching support? y/N We will not be demonstrating caching in this tutorial, so either hit \"Enter\", or \"n\" followed by \"Enter\". Would you like to install database support (installs laminas-db)? y/N We will be using laminas-db extensively in this tutorial, so hit \"y\" followed by \"Enter\". You should see the following text appear: Will install laminas/laminas-db (^2.17.0) When prompted to install as a module, select application.config.php or modules.config.php The next prompt is: Would you like to install forms support (installs laminas-form)? y/N This tutorial also uses laminas-form, so we will again select \"y\" to install this; doing so emits a similar message to that used for laminas-db. At this point, we can answer \"n\" to the remaining features: Would you like to install JSON de/serialization support? y/N Would you like to install logging support? y/N Would you like to install command-line interface support? y/N Would you like to install i18n support? y/N Would you like to install the official MVC plugins, including PRG support, identity, and flash messages? y/N Would you like to use the PSR-7 middleware dispatcher? y/N Would you like to install sessions support? y/N Would you like to install the laminas-di integration for laminas-servicemanager? y/N At a certain point, you'll see the following text: Updating root package Running an update to install optional packages ... Updating application configuration... Please select which config file you wish to inject 'Laminas\\Db' into: [0] Do not inject [1] config/modules.config.php Make your selection (default is 1): We want to enable the various selections we made in the application. As such, we'll choose 1 , which will then give us the following prompt: Remember this option for other packages of the same type? (y/N) In our case, we can safely say \"y\", which will mean we will no longer be prompted for additional packages. (The only package in the default set of prompts that you may not want to enable by default is Laminas\\Test .) Once the installation is done, the skeleton installer removes itself, and the new application is ready to start! Downloading the Skeleton Another way to install the Laminas MVC Skeleton Application is to use github to download a compressed archive. Go to https://github.com/laminas/laminas-mvc-skeleton, click the \"Clone or download\" button, and select \"Download ZIP\". This will download a file with a name like laminas-mvc-skeleton-master.zip or similar. Unzip this file into the directory where you keep all your vhosts and rename the resultant directory to laminas-mvc-tutorial . laminas-mvc-skeleton is set up to use Composer to resolve its dependencies. Run the following from within your new laminas-mvc-tutorial folder to install them: $ composer self-update $ composer install This takes a while. You should see output like the following: Installing dependencies from lock file - Installing laminas/laminas-component-installer (2.1.2) ... Generating autoload files At this point, you will be prompted to answer questions as noted above. Alternately, if you do not have Composer installed, but do have docker-compose available, you can run Composer via those: $ docker-compose build $ docker-compose run laminas composer install Timeouts If you see this message: [RuntimeException] The process timed out. then your connection was too slow to download the entire package in time, and composer timed out. To avoid this, instead of running: $ composer install run instead: $ COMPOSER_PROCESS_TIMEOUT=5000 composer install Windows Users Using WAMP For windows users with wamp:","title":"Create a New Project"},{"location":"getting-started/skeleton-application/#web-servers","text":"In this tutorial, we will step you through different ways to set up your web server: Via the PHP built-in web server. Via docker-compose. Using Apache.","title":"Web Servers"},{"location":"getting-started/skeleton-application/#development-mode","text":"Before we begin, we're going to enable development mode for the application. The skeleton application provides two files that allow us to specify general development settings we want to use everywhere; these may include enabling modules for debugging, or enabling error display in our view scripts. These files are located at: config/development.config.php.dist config/autoload/development.local.php.dist When we enable development mode, these files are copied to: config/development.config.php config/autoload/development.local.php This allows them to be merged into our application. When we disable development mode, these two files that were created are then removed, leaving only the .dist versions. (The repository also contains rules to ignore the copies.) Let's enable development mode now: $ composer development-enable Never Enable Development Mode in Production You should never enable development mode in production, as the typical reason to enable it is to enable debugging! As noted, the artifacts generated by enabling development mode cannot be committed to your repository, so assuming you don't run the command in production, you should be safe. You can test the status of development mode using: $ composer development-status And you can disable it using: $ composer development-disable","title":"Development Mode"},{"location":"in-depth-guide/data-binding/","text":"Editing and Deleting Data In the previous chapter we've come to learn how we can use the laminas-form and laminas-db components for creating new data-sets. This chapter will focus on finalizing the CRUD functionality by introducing the concepts for editing and deleting data. Binding Objects to Forms The one fundamental difference between our \"add post\" and \"edit post\" forms is the existence of data. This means we need to find a way to get data from our repository into the form. Luckily, laminas-form provides this via a data-binding feature. In order to use this feature, you will need to retrieve a Post instance, and bind it to the form. To do this, we will need to: Add a dependency in our WriteController on our PostRepositoryInterface , from which we will retrieve our Post . Add a new method to our WriteController , editAction() , that will retrieve a Post , bind it to the form, and either display the form or process it. Update our WriteControllerFactory to inject the PostRepositoryInterface . We'll begin by updating the WriteController : We will import the PostRepositoryInterface . We will add a property for storing the PostRepositoryInterface . We will update the constructor to accept the PostRepositoryInterface . We will add the editAction() implementation. The final result will look like the following: <?php // In module/Blog/src/Controller/WriteController.php: namespace Blog\\Controller; use Blog\\Form\\PostForm; use Blog\\Model\\Post; use Blog\\Model\\PostCommandInterface; use Blog\\Model\\PostRepositoryInterface; use InvalidArgumentException; use Laminas\\Mvc\\Controller\\AbstractActionController; use Laminas\\View\\Model\\ViewModel; class WriteController extends AbstractActionController { /** * @var PostCommandInterface */ private $command; /** * @var PostForm */ private $form; /** * @var PostRepositoryInterface */ private $repository; /** * @param PostCommandInterface $command * @param PostForm $form * @param PostRepositoryInterface $repository */ public function __construct( PostCommandInterface $command, PostForm $form, PostRepositoryInterface $repository ) { $this->command = $command; $this->form = $form; $this->repository = $repository; } public function addAction() { $request = $this->getRequest(); $viewModel = new ViewModel(['form' => $this->form]); if (! $request->isPost()) { return $viewModel; } $this->form->setData($request->getPost()); if (! $this->form->isValid()) { return $viewModel; } $post = $this->form->getData(); try { $post = $this->command->insertPost($post); } catch (\\Exception $ex) { // An exception occurred; we may want to log this later and/or // report it to the user. For now, we'll just re-throw. throw $ex; } return $this->redirect()->toRoute( 'blog/detail', ['id' => $post->getId()] ); } public function editAction() { $id = $this->params()->fromRoute('id'); if (! $id) { return $this->redirect()->toRoute('blog'); } try { $post = $this->repository->findPost($id); } catch (InvalidArgumentException $ex) { return $this->redirect()->toRoute('blog'); } $this->form->bind($post); $viewModel = new ViewModel(['form' => $this->form]); $request = $this->getRequest(); if (! $request->isPost()) { return $viewModel; } $this->form->setData($request->getPost()); if (! $this->form->isValid()) { return $viewModel; } $post = $this->command->updatePost($post); return $this->redirect()->toRoute( 'blog/detail', ['id' => $post->getId()] ); } } The primary differences between addAction() and editAction() are that the latter needs to first fetch a Post , and this post is bound to the form. By binding it, we ensure that the data is populated in the form for the initial display, and, once validated, the same instance is updated. This means that we can omit the call to getData() after validating the form. Now we need to update our WriteControllerFactory . First, add a new import statement to it: // In module/Blog/src/Factory/WriteControllerFactory.php: use Blog\\Model\\PostRepositoryInterface; Next, update the body of the factory to read as follows: // In module/Blog/src/Factory/WriteControllerFactory.php: public function __invoke(ContainerInterface $container, $requestedName, array $options = null) { $formManager = $container->get('FormElementManager'); return new WriteController( $container->get(PostCommandInterface::class), $formManager->get(PostForm::class), $container->get(PostRepositoryInterface::class) ); } The controller and model are now wired together, so it's time to turn to routing. Adding the edit route The edit route is identical to the blog/detail route we previously defined, with two exceptions: it will have a path prefix, /edit it will route to our WriteController Update the 'blog' child_routes to add the new route: // In module/Blog/config/module.config.php: use Laminas\\Router\\Http\\Segment; return [ 'service_manager' => [ /* ... */ ], 'controllers' => [ /* ... */ ], 'router' => [ 'routes' => [ 'blog' => [ /* ... */ 'child_routes' => [ /* ... */ 'edit' => [ 'type' => Segment::class, 'options' => [ 'route' => '/edit/:id', 'defaults' => [ 'controller' => Controller\\WriteController::class, 'action' => 'edit', ], 'constraints' => [ 'id' => '[1-9]\\d*', ], ], ], ], ], ], ], 'view_manager' => [ /* ... */ ], ]; Creating the edit template Rendering the form remains essentially the same between the add and edit templates; the only difference between them is the form action. As such, we will create a new partial script for the form, update the add template to use it, and create a new edit template. Create a new file, module/Blog/view/blog/write/form.phtml , with the following contents: <?php $form = $this->form; $fieldset = $form->get('post'); $title = $fieldset->get('title'); $title->setAttribute('class', 'form-control'); $title->setAttribute('placeholder', 'Post title'); $text = $fieldset->get('text'); $text->setAttribute('class', 'form-control'); $text->setAttribute('placeholder', 'Post content'); $submit = $form->get('submit'); $submit->setValue($this->submitLabel); $submit->setAttribute('class', 'btn btn-primary'); $form->prepare(); echo $this->form()->openTag($form); ?> <fieldset> <div class=\"form-group\"> <?= $this->formLabel($title) ?> <?= $this->formElement($title) ?> <?= $this->formElementErrors()->render($title, ['class' => 'help-block']) ?> </div> <div class=\"form-group\"> <?= $this->formLabel($text) ?> <?= $this->formElement($text) ?> <?= $this->formElementErrors()->render($text, ['class' => 'help-block']) ?> </div> </fieldset> <?php echo $this->formSubmit($submit); echo $this->formHidden($fieldset->get('id')); echo $this->form()->closeTag(); Now, update the add template, module/Blog/view/blog/write/add.phtml to read as follows: <h1>Add a blog post</h1> <?php $form = $this->form; $form->setAttribute('action', $this->url()); echo $this->partial('blog/write/form', [ 'form' => $form, 'submitLabel' => 'Insert new post', ]); The above retrieves the form, sets the form action, provides a context-appropriate label for the submit button, and renders it with our new partial view script. Next in line is the creation of the new template, blog/write/edit : <h1>Edit blog post</h1> <?php $form = $this->form; $form->setAttribute('action', $this->url('blog/edit', [], true)); echo $this->partial('blog/write/form', [ 'form' => $form, 'submitLabel' => 'Update post', ]); The three differences between the add and edit templates are: The heading at the top of the page. The URI used for the form action. The label used for the submit button. Because the URI requires the identifier, we need to ensure the identifier is passed. The way we've done this in the controllers is to pass the identifier as a parameter: $this->url('blog/edit/', ['id' => $id]) . This would require that we pass the original Post instance or the identifier we pull from it to the view, however. laminas-router allows another option, however: you can tell it to re-use currently matched parameters. This is done by setting the last parameter of the view-helper to true : $this->url('blog/edit', [], true) . If you try and update the post, you will receive the following error: Call to member function getId() on null That is because we have not yet implemented the update functionality in our command class which will return a Post object on success. Let's do that now. Edit the file module/Blog/src/Model/LaminasDbSqlCommand.php , and update the updatePost() method to read as follows: public function updatePost(Post $post) { if (! $post->getId()) { throw new RuntimeException('Cannot update post; missing identifier'); } $update = new Update('posts'); $update->set([ 'title' => $post->getTitle(), 'text' => $post->getText(), ]); $update->where(['id = ?' => $post->getId()]); $sql = new Sql($this->db); $statement = $sql->prepareStatementForSqlObject($update); $result = $statement->execute(); if (! $result instanceof ResultInterface) { throw new RuntimeException( 'Database error occurred during blog post update operation' ); } return $post; } This looks very similar to the insertPost() implementation we did earlier. The primary difference is the usage of the Update class; instead of calling a values() method on it, we call: set() , to provide the values we are updating. where() , to provide criteria to determine which records (record singular, in our case) are updated. Additionally, we test for the presence of an identifier before performing the operation, and, because we already have one, and the Post submitted to us contains all the edits we submitted to the database, we return it verbatim on success. Implementing the delete functionality Last but not least, it's time to delete some data. We start this process by implementing the deletePost() method in our LaminasDbSqlCommand class: // In module/Blog/src/Model/LaminasDbSqlCommand.php: public function deletePost(Post $post) { if (! $post->getId()) { throw new RuntimeException('Cannot delete post; missing identifier'); } $delete = new Delete('posts'); $delete->where(['id = ?' => $post->getId()]); $sql = new Sql($this->db); $statement = $sql->prepareStatementForSqlObject($delete); $result = $statement->execute(); if (! $result instanceof ResultInterface) { return false; } return true; } The above uses Laminas\\Db\\Sql\\Delete to create the SQL necessary to delete the post with the given identifier, which we then execute. Next, let's create a new controller, Blog\\Controller\\DeleteController , in a new file module/Blog/src/Controller/DeleteController.php , with the following contents: <?php namespace Blog\\Controller; use Blog\\Model\\Post; use Blog\\Model\\PostCommandInterface; use Blog\\Model\\PostRepositoryInterface; use InvalidArgumentException; use Laminas\\Mvc\\Controller\\AbstractActionController; use Laminas\\View\\Model\\ViewModel; class DeleteController extends AbstractActionController { /** * @var PostCommandInterface */ private $command; /** * @var PostRepositoryInterface */ private $repository; /** * @param PostCommandInterface $command * @param PostRepositoryInterface $repository */ public function __construct( PostCommandInterface $command, PostRepositoryInterface $repository ) { $this->command = $command; $this->repository = $repository; } public function deleteAction() { $id = $this->params()->fromRoute('id'); if (! $id) { return $this->redirect()->toRoute('blog'); } try { $post = $this->repository->findPost($id); } catch (InvalidArgumentException $ex) { return $this->redirect()->toRoute('blog'); } $request = $this->getRequest(); if (! $request->isPost()) { return new ViewModel(['post' => $post]); } if ($id != $request->getPost('id') || 'Delete' !== $request->getPost('confirm', 'no') ) { return $this->redirect()->toRoute('blog'); } $post = $this->command->deletePost($post); return $this->redirect()->toRoute('blog'); } } Like the WriteController , it composes both our PostRepositoryInterface and PostCommandInterface . The former is used to ensure we are referencing a valid post instance, and the latter to perform the actual deletion. When a user requests the page via the GET method, we will display a page containing details of the post, and a confirmation form. When submitted, we'll check to make sure they confirmed the deletion before issuing our delete command. If any conditions fail, or on a successful deletion, we redirect to our blog listing page. Like the other controllers, we now need a factory. Create the file module/Blog/src/Factory/DeleteControllerFactory.php with the following contents: <?php namespace Blog\\Factory; use Blog\\Controller\\DeleteController; use Blog\\Model\\PostCommandInterface; use Blog\\Model\\PostRepositoryInterface; use Interop\\Container\\ContainerInterface; use Laminas\\ServiceManager\\Factory\\FactoryInterface; class DeleteControllerFactory implements FactoryInterface { /** * @param ContainerInterface $container * @param string $requestedName * @param null|array $options * @return DeleteController */ public function __invoke(ContainerInterface $container, $requestedName, array $options = null) { return new DeleteController( $container->get(PostCommandInterface::class), $container->get(PostRepositoryInterface::class) ); } } We'll now wire this into the application, mapping the controller to its factory, and providing a new route. Open the file module/Blog/config/module.config.php and make the following edits. First, map the controller to its factory: 'controllers' => [ 'factories' => [ Controller\\ListController::class => Factory\\ListControllerFactory::class, Controller\\WriteController::class => Factory\\WriteControllerFactory::class, // Add the following line: Controller\\DeleteController::class => Factory\\DeleteControllerFactory::class, ], ], Now add another child route to our \"blog\" route: 'router' => [ 'routes' => [ 'blog' => [ /* ... */ 'child_routes' => [ /* ... */ 'delete' => [ 'type' => Segment::class, 'options' => [ 'route' => '/delete/:id', 'defaults' => [ 'controller' => Controller\\DeleteController::class, 'action' => 'delete', ], 'constraints' => [ 'id' => '[1-9]\\d*', ], ], ], ], ], ], ], Finally, we'll create a new view script, module/Blog/view/blog/delete/delete.phtml , with the following contents: <h1>Delete post</h1> <p>Are you sure you want to delete the following post?</p> <ul class=\"list-group\"> <li class=\"list-group-item\"><?= $this->escapeHtml($this->post->getTitle()) ?></li> </ul> <form action=\"<?php $this->url('blog/delete', [], true) ?>\" method=\"post\"> <input type=\"hidden\" name=\"id\" value=\"<?= $this->escapeHtmlAttr($this->post->getId()) ?>\" /> <input class=\"btn btn-default\" type=\"submit\" name=\"confirm\" value=\"Cancel\" /> <input class=\"btn btn-danger\" type=\"submit\" name=\"confirm\" value=\"Delete\" /> </form> This time around, we're not using laminas-form; as it consists of just a hidden element and cancel/confirm buttons, there's no need to provide an OOP model for it. From here, you can now visit one of the existing blog posts, e.g., http://localhost:8080/blog/delete/1 to see the form. If you choose Cancel , you should be taken back to the list; if you choose Delete , it should delete the post and then take you back to the list, and you should see the post is no longer present. Making the list more useful Our blog post list currently lists everything about all of our blog posts; additionally, it doesn't link to them, which means we have to manually update the URL in our browser in order to test functionality. Let's update the list view to be more useful; we'll: List just the title of each blog post; linking the title to the post display; and providing links for editing and deleting the post. Add a button to allow users to add a new post. In a real-world application, we'd probably use some sort of access controls to determine if the edit and delete links will be displayed; we'll leave that for another tutorial, however. Open your module/Blog/view/blog/list/index.phtml file, and update it to read as follows: <h1>Blog Posts</h1> <div class=\"list-group\"> <?php foreach ($this->posts as $post): ?> <div class=\"list-group-item\"> <h4 class=\"list-group-item-heading\"> <a href=\"<?= $this->url('blog/detail', ['id' => $post->getId()]) ?>\"> <?= $post->getTitle() ?> </a> </h4> <div class=\"btn-group\" role=\"group\" aria-label=\"Post actions\"> <a class=\"btn btn-xs btn-default\" href=\"<?= $this->url('blog/edit', ['id' => $post->getId()]) ?>\">Edit</a> <a class=\"btn btn-xs btn-danger\" href=\"<?= $this->url('blog/delete', ['id' => $post->getId()]) ?>\">Delete</a> </div> </div> <?php endforeach ?> </div> <div class=\"btn-group\" role=\"group\" aria-label=\"Post actions\"> <a class=\"btn btn-primary\" href=\"<?= $this->url('blog/add') ?>\">Write new post</a> </div> At this point, we have a far more functional blog, as we can move around between pages using links and buttons. Summary In this chapter we've learned how data binding within the laminas-form component works, and used it to provide functionality for our update routine. We also learned how this allows us to de-couple our controllers from the details of how a form is structured, helping us keep implementation details out of our controller. We also demonstrated the use of view partials, which allow us to split out duplication in our views and re-use them. In particular, we did this with our form, to prevent needlessly duplicating the form markup. Finally, we looked at two more aspects of the Laminas\\Db\\Sql subcomponent, and learned how to perform Update and Delete operations. In the next chapter we'll summarize everything we've done. We'll talk about the design patterns we've used, and we'll cover several questions that likely arose during the course of this tutorial.","title":"Editing and Deleting Data"},{"location":"in-depth-guide/data-binding/#editing-and-deleting-data","text":"In the previous chapter we've come to learn how we can use the laminas-form and laminas-db components for creating new data-sets. This chapter will focus on finalizing the CRUD functionality by introducing the concepts for editing and deleting data.","title":"Editing and Deleting Data"},{"location":"in-depth-guide/data-binding/#binding-objects-to-forms","text":"The one fundamental difference between our \"add post\" and \"edit post\" forms is the existence of data. This means we need to find a way to get data from our repository into the form. Luckily, laminas-form provides this via a data-binding feature. In order to use this feature, you will need to retrieve a Post instance, and bind it to the form. To do this, we will need to: Add a dependency in our WriteController on our PostRepositoryInterface , from which we will retrieve our Post . Add a new method to our WriteController , editAction() , that will retrieve a Post , bind it to the form, and either display the form or process it. Update our WriteControllerFactory to inject the PostRepositoryInterface . We'll begin by updating the WriteController : We will import the PostRepositoryInterface . We will add a property for storing the PostRepositoryInterface . We will update the constructor to accept the PostRepositoryInterface . We will add the editAction() implementation. The final result will look like the following: <?php // In module/Blog/src/Controller/WriteController.php: namespace Blog\\Controller; use Blog\\Form\\PostForm; use Blog\\Model\\Post; use Blog\\Model\\PostCommandInterface; use Blog\\Model\\PostRepositoryInterface; use InvalidArgumentException; use Laminas\\Mvc\\Controller\\AbstractActionController; use Laminas\\View\\Model\\ViewModel; class WriteController extends AbstractActionController { /** * @var PostCommandInterface */ private $command; /** * @var PostForm */ private $form; /** * @var PostRepositoryInterface */ private $repository; /** * @param PostCommandInterface $command * @param PostForm $form * @param PostRepositoryInterface $repository */ public function __construct( PostCommandInterface $command, PostForm $form, PostRepositoryInterface $repository ) { $this->command = $command; $this->form = $form; $this->repository = $repository; } public function addAction() { $request = $this->getRequest(); $viewModel = new ViewModel(['form' => $this->form]); if (! $request->isPost()) { return $viewModel; } $this->form->setData($request->getPost()); if (! $this->form->isValid()) { return $viewModel; } $post = $this->form->getData(); try { $post = $this->command->insertPost($post); } catch (\\Exception $ex) { // An exception occurred; we may want to log this later and/or // report it to the user. For now, we'll just re-throw. throw $ex; } return $this->redirect()->toRoute( 'blog/detail', ['id' => $post->getId()] ); } public function editAction() { $id = $this->params()->fromRoute('id'); if (! $id) { return $this->redirect()->toRoute('blog'); } try { $post = $this->repository->findPost($id); } catch (InvalidArgumentException $ex) { return $this->redirect()->toRoute('blog'); } $this->form->bind($post); $viewModel = new ViewModel(['form' => $this->form]); $request = $this->getRequest(); if (! $request->isPost()) { return $viewModel; } $this->form->setData($request->getPost()); if (! $this->form->isValid()) { return $viewModel; } $post = $this->command->updatePost($post); return $this->redirect()->toRoute( 'blog/detail', ['id' => $post->getId()] ); } } The primary differences between addAction() and editAction() are that the latter needs to first fetch a Post , and this post is bound to the form. By binding it, we ensure that the data is populated in the form for the initial display, and, once validated, the same instance is updated. This means that we can omit the call to getData() after validating the form. Now we need to update our WriteControllerFactory . First, add a new import statement to it: // In module/Blog/src/Factory/WriteControllerFactory.php: use Blog\\Model\\PostRepositoryInterface; Next, update the body of the factory to read as follows: // In module/Blog/src/Factory/WriteControllerFactory.php: public function __invoke(ContainerInterface $container, $requestedName, array $options = null) { $formManager = $container->get('FormElementManager'); return new WriteController( $container->get(PostCommandInterface::class), $formManager->get(PostForm::class), $container->get(PostRepositoryInterface::class) ); } The controller and model are now wired together, so it's time to turn to routing.","title":"Binding Objects to Forms"},{"location":"in-depth-guide/data-binding/#adding-the-edit-route","text":"The edit route is identical to the blog/detail route we previously defined, with two exceptions: it will have a path prefix, /edit it will route to our WriteController Update the 'blog' child_routes to add the new route: // In module/Blog/config/module.config.php: use Laminas\\Router\\Http\\Segment; return [ 'service_manager' => [ /* ... */ ], 'controllers' => [ /* ... */ ], 'router' => [ 'routes' => [ 'blog' => [ /* ... */ 'child_routes' => [ /* ... */ 'edit' => [ 'type' => Segment::class, 'options' => [ 'route' => '/edit/:id', 'defaults' => [ 'controller' => Controller\\WriteController::class, 'action' => 'edit', ], 'constraints' => [ 'id' => '[1-9]\\d*', ], ], ], ], ], ], ], 'view_manager' => [ /* ... */ ], ];","title":"Adding the edit route"},{"location":"in-depth-guide/data-binding/#creating-the-edit-template","text":"Rendering the form remains essentially the same between the add and edit templates; the only difference between them is the form action. As such, we will create a new partial script for the form, update the add template to use it, and create a new edit template. Create a new file, module/Blog/view/blog/write/form.phtml , with the following contents: <?php $form = $this->form; $fieldset = $form->get('post'); $title = $fieldset->get('title'); $title->setAttribute('class', 'form-control'); $title->setAttribute('placeholder', 'Post title'); $text = $fieldset->get('text'); $text->setAttribute('class', 'form-control'); $text->setAttribute('placeholder', 'Post content'); $submit = $form->get('submit'); $submit->setValue($this->submitLabel); $submit->setAttribute('class', 'btn btn-primary'); $form->prepare(); echo $this->form()->openTag($form); ?> <fieldset> <div class=\"form-group\"> <?= $this->formLabel($title) ?> <?= $this->formElement($title) ?> <?= $this->formElementErrors()->render($title, ['class' => 'help-block']) ?> </div> <div class=\"form-group\"> <?= $this->formLabel($text) ?> <?= $this->formElement($text) ?> <?= $this->formElementErrors()->render($text, ['class' => 'help-block']) ?> </div> </fieldset> <?php echo $this->formSubmit($submit); echo $this->formHidden($fieldset->get('id')); echo $this->form()->closeTag(); Now, update the add template, module/Blog/view/blog/write/add.phtml to read as follows: <h1>Add a blog post</h1> <?php $form = $this->form; $form->setAttribute('action', $this->url()); echo $this->partial('blog/write/form', [ 'form' => $form, 'submitLabel' => 'Insert new post', ]); The above retrieves the form, sets the form action, provides a context-appropriate label for the submit button, and renders it with our new partial view script. Next in line is the creation of the new template, blog/write/edit : <h1>Edit blog post</h1> <?php $form = $this->form; $form->setAttribute('action', $this->url('blog/edit', [], true)); echo $this->partial('blog/write/form', [ 'form' => $form, 'submitLabel' => 'Update post', ]); The three differences between the add and edit templates are: The heading at the top of the page. The URI used for the form action. The label used for the submit button. Because the URI requires the identifier, we need to ensure the identifier is passed. The way we've done this in the controllers is to pass the identifier as a parameter: $this->url('blog/edit/', ['id' => $id]) . This would require that we pass the original Post instance or the identifier we pull from it to the view, however. laminas-router allows another option, however: you can tell it to re-use currently matched parameters. This is done by setting the last parameter of the view-helper to true : $this->url('blog/edit', [], true) . If you try and update the post, you will receive the following error: Call to member function getId() on null That is because we have not yet implemented the update functionality in our command class which will return a Post object on success. Let's do that now. Edit the file module/Blog/src/Model/LaminasDbSqlCommand.php , and update the updatePost() method to read as follows: public function updatePost(Post $post) { if (! $post->getId()) { throw new RuntimeException('Cannot update post; missing identifier'); } $update = new Update('posts'); $update->set([ 'title' => $post->getTitle(), 'text' => $post->getText(), ]); $update->where(['id = ?' => $post->getId()]); $sql = new Sql($this->db); $statement = $sql->prepareStatementForSqlObject($update); $result = $statement->execute(); if (! $result instanceof ResultInterface) { throw new RuntimeException( 'Database error occurred during blog post update operation' ); } return $post; } This looks very similar to the insertPost() implementation we did earlier. The primary difference is the usage of the Update class; instead of calling a values() method on it, we call: set() , to provide the values we are updating. where() , to provide criteria to determine which records (record singular, in our case) are updated. Additionally, we test for the presence of an identifier before performing the operation, and, because we already have one, and the Post submitted to us contains all the edits we submitted to the database, we return it verbatim on success.","title":"Creating the edit template"},{"location":"in-depth-guide/data-binding/#implementing-the-delete-functionality","text":"Last but not least, it's time to delete some data. We start this process by implementing the deletePost() method in our LaminasDbSqlCommand class: // In module/Blog/src/Model/LaminasDbSqlCommand.php: public function deletePost(Post $post) { if (! $post->getId()) { throw new RuntimeException('Cannot delete post; missing identifier'); } $delete = new Delete('posts'); $delete->where(['id = ?' => $post->getId()]); $sql = new Sql($this->db); $statement = $sql->prepareStatementForSqlObject($delete); $result = $statement->execute(); if (! $result instanceof ResultInterface) { return false; } return true; } The above uses Laminas\\Db\\Sql\\Delete to create the SQL necessary to delete the post with the given identifier, which we then execute. Next, let's create a new controller, Blog\\Controller\\DeleteController , in a new file module/Blog/src/Controller/DeleteController.php , with the following contents: <?php namespace Blog\\Controller; use Blog\\Model\\Post; use Blog\\Model\\PostCommandInterface; use Blog\\Model\\PostRepositoryInterface; use InvalidArgumentException; use Laminas\\Mvc\\Controller\\AbstractActionController; use Laminas\\View\\Model\\ViewModel; class DeleteController extends AbstractActionController { /** * @var PostCommandInterface */ private $command; /** * @var PostRepositoryInterface */ private $repository; /** * @param PostCommandInterface $command * @param PostRepositoryInterface $repository */ public function __construct( PostCommandInterface $command, PostRepositoryInterface $repository ) { $this->command = $command; $this->repository = $repository; } public function deleteAction() { $id = $this->params()->fromRoute('id'); if (! $id) { return $this->redirect()->toRoute('blog'); } try { $post = $this->repository->findPost($id); } catch (InvalidArgumentException $ex) { return $this->redirect()->toRoute('blog'); } $request = $this->getRequest(); if (! $request->isPost()) { return new ViewModel(['post' => $post]); } if ($id != $request->getPost('id') || 'Delete' !== $request->getPost('confirm', 'no') ) { return $this->redirect()->toRoute('blog'); } $post = $this->command->deletePost($post); return $this->redirect()->toRoute('blog'); } } Like the WriteController , it composes both our PostRepositoryInterface and PostCommandInterface . The former is used to ensure we are referencing a valid post instance, and the latter to perform the actual deletion. When a user requests the page via the GET method, we will display a page containing details of the post, and a confirmation form. When submitted, we'll check to make sure they confirmed the deletion before issuing our delete command. If any conditions fail, or on a successful deletion, we redirect to our blog listing page. Like the other controllers, we now need a factory. Create the file module/Blog/src/Factory/DeleteControllerFactory.php with the following contents: <?php namespace Blog\\Factory; use Blog\\Controller\\DeleteController; use Blog\\Model\\PostCommandInterface; use Blog\\Model\\PostRepositoryInterface; use Interop\\Container\\ContainerInterface; use Laminas\\ServiceManager\\Factory\\FactoryInterface; class DeleteControllerFactory implements FactoryInterface { /** * @param ContainerInterface $container * @param string $requestedName * @param null|array $options * @return DeleteController */ public function __invoke(ContainerInterface $container, $requestedName, array $options = null) { return new DeleteController( $container->get(PostCommandInterface::class), $container->get(PostRepositoryInterface::class) ); } } We'll now wire this into the application, mapping the controller to its factory, and providing a new route. Open the file module/Blog/config/module.config.php and make the following edits. First, map the controller to its factory: 'controllers' => [ 'factories' => [ Controller\\ListController::class => Factory\\ListControllerFactory::class, Controller\\WriteController::class => Factory\\WriteControllerFactory::class, // Add the following line: Controller\\DeleteController::class => Factory\\DeleteControllerFactory::class, ], ], Now add another child route to our \"blog\" route: 'router' => [ 'routes' => [ 'blog' => [ /* ... */ 'child_routes' => [ /* ... */ 'delete' => [ 'type' => Segment::class, 'options' => [ 'route' => '/delete/:id', 'defaults' => [ 'controller' => Controller\\DeleteController::class, 'action' => 'delete', ], 'constraints' => [ 'id' => '[1-9]\\d*', ], ], ], ], ], ], ], Finally, we'll create a new view script, module/Blog/view/blog/delete/delete.phtml , with the following contents: <h1>Delete post</h1> <p>Are you sure you want to delete the following post?</p> <ul class=\"list-group\"> <li class=\"list-group-item\"><?= $this->escapeHtml($this->post->getTitle()) ?></li> </ul> <form action=\"<?php $this->url('blog/delete', [], true) ?>\" method=\"post\"> <input type=\"hidden\" name=\"id\" value=\"<?= $this->escapeHtmlAttr($this->post->getId()) ?>\" /> <input class=\"btn btn-default\" type=\"submit\" name=\"confirm\" value=\"Cancel\" /> <input class=\"btn btn-danger\" type=\"submit\" name=\"confirm\" value=\"Delete\" /> </form> This time around, we're not using laminas-form; as it consists of just a hidden element and cancel/confirm buttons, there's no need to provide an OOP model for it. From here, you can now visit one of the existing blog posts, e.g., http://localhost:8080/blog/delete/1 to see the form. If you choose Cancel , you should be taken back to the list; if you choose Delete , it should delete the post and then take you back to the list, and you should see the post is no longer present.","title":"Implementing the delete functionality"},{"location":"in-depth-guide/data-binding/#making-the-list-more-useful","text":"Our blog post list currently lists everything about all of our blog posts; additionally, it doesn't link to them, which means we have to manually update the URL in our browser in order to test functionality. Let's update the list view to be more useful; we'll: List just the title of each blog post; linking the title to the post display; and providing links for editing and deleting the post. Add a button to allow users to add a new post. In a real-world application, we'd probably use some sort of access controls to determine if the edit and delete links will be displayed; we'll leave that for another tutorial, however. Open your module/Blog/view/blog/list/index.phtml file, and update it to read as follows: <h1>Blog Posts</h1> <div class=\"list-group\"> <?php foreach ($this->posts as $post): ?> <div class=\"list-group-item\"> <h4 class=\"list-group-item-heading\"> <a href=\"<?= $this->url('blog/detail', ['id' => $post->getId()]) ?>\"> <?= $post->getTitle() ?> </a> </h4> <div class=\"btn-group\" role=\"group\" aria-label=\"Post actions\"> <a class=\"btn btn-xs btn-default\" href=\"<?= $this->url('blog/edit', ['id' => $post->getId()]) ?>\">Edit</a> <a class=\"btn btn-xs btn-danger\" href=\"<?= $this->url('blog/delete', ['id' => $post->getId()]) ?>\">Delete</a> </div> </div> <?php endforeach ?> </div> <div class=\"btn-group\" role=\"group\" aria-label=\"Post actions\"> <a class=\"btn btn-primary\" href=\"<?= $this->url('blog/add') ?>\">Write new post</a> </div> At this point, we have a far more functional blog, as we can move around between pages using links and buttons.","title":"Making the list more useful"},{"location":"in-depth-guide/data-binding/#summary","text":"In this chapter we've learned how data binding within the laminas-form component works, and used it to provide functionality for our update routine. We also learned how this allows us to de-couple our controllers from the details of how a form is structured, helping us keep implementation details out of our controller. We also demonstrated the use of view partials, which allow us to split out duplication in our views and re-use them. In particular, we did this with our form, to prevent needlessly duplicating the form markup. Finally, we looked at two more aspects of the Laminas\\Db\\Sql subcomponent, and learned how to perform Update and Delete operations. In the next chapter we'll summarize everything we've done. We'll talk about the design patterns we've used, and we'll cover several questions that likely arose during the course of this tutorial.","title":"Summary"},{"location":"in-depth-guide/first-module/","text":"Introducing the Blog Module Now that we know about the basics of the laminas-mvc skeleton application, let's continue and create our very own module. We will create a module named \"Blog\". This module will display a list of database entries that represent a single blog post. Each post will have three properties: id , text , and title . We will create forms to enter new posts into our database and to edit existing posts. Furthermore we will do so by using best-practices throughout the whole tutorial. Writing a new Module Let's start by creating a new folder under the /module directory called Blog , with the following stucture: module/ Blog/ config/ src/ view/ To be recognized as a module by the ModuleManager , we need to do three things: Tell Composer how to autoload classes from our new module. Create a Module class in the Blog namespace. Notify the application of the new module. Let's tell Composer about our new module. Open the composer.json file in the project root, and edit the autoload section to add a new PSR-4 entry for the Blog module; when you're done, it should read: \"autoload\": { \"psr-4\": { \"Application\\\\\": \"module/Application/src/\", \"Album\\\\\": \"module/Album/src/\", \"Blog\\\\\": \"module/Blog/src/\" } } Once you're done, tell Composer to update its autoloading definitions: $ composer dump-autoload Next, we will create a Module class under the Blog namespace. Create the file module/Blog/src/Module.php with the following contents: namespace Blog; class Module { } We now have a module that can be detected by the ModuleManager . Let's add this module to our application. Although our module doesn't do anything yet, just having the Module.php class allows it to be loaded by the ModuleManager. To do this, add an entry for Blog to the modules array inside config/modules.config.php : // In config/modules.config.php: return [ /* ... */ 'Application', 'Album', 'Blog', ]; If you refresh your application you should see no change at all (but also no errors). At this point it's worth taking a step back to discuss what modules are for. In short, a module is an encapsulated set of features for your application. A module might add features to the application that you can see, like our Blog module; or it might provide background functionality for other modules in the application to use, such as interacting with a third party API. Organizing your code into modules makes it easier for you to reuse functionality in other applications, or to use modules written by the community. Configuring the Module The next thing we're going to do is add a route to our application so that our module can be accessed through the URL localhost:8080/blog . We do this by adding router configuration to our module, but first we need to let the ModuleManager know that our module has configuration that it needs to load. This is done by adding a getConfig() method to the Module class that returns the configuration. (This method is defined in the ConfigProviderInterface , although explicitly implementing this interface in the module class is optional.) This method should return either an array or a Traversable object. Continue by editing module/Blog/src/Module.php : // In /module/Blog/src/Module.php: class Module { public function getConfig() : array { return []; } } With this, our module is now able to be configured. Configuration files can become quite big, though, and keeping everything inside the getConfig() method won't be optimal. To help keep our project organized, we're going to put our array configuration in a separate file. Go ahead and create this file at module/Blog/config/module.config.php : return []; Now rewrite the getConfig() function to include this newly created file instead of directly returning the array: // In /module/Blog/src/Module.php: public function getConfig() : array { return include __DIR__ . '/../config/module.config.php'; } Reload your application and you'll see that nothing changes. Creating, registering, and adding empty configuration for a new module has no visible effect on the application. Next we add the new route to our configuration file: // In /module/Blog/config/module.config.php: namespace Blog; use Laminas\\Router\\Http\\Literal; return [ // This lines opens the configuration for the RouteManager 'router' => [ // Open configuration for all possible routes 'routes' => [ // Define a new route called \"blog\" 'blog' => [ // Define a \"literal\" route type: 'type' => Literal::class, // Configure the route itself 'options' => [ // Listen to \"/blog\" as uri: 'route' => '/blog', // Define default controller and action to be called when // this route is matched 'defaults' => [ 'controller' => Controller\\ListController::class, 'action' => 'index', ], ], ], ], ], ]; We've now created a route called blog that listens to the URL localhost:8080/blog . Whenever someone accesses this route, the indexAction() function of the class Blog\\Controller\\ListController will be executed. However, this controller does not exist yet, so if you reload the page you will see this error message: A 404 error occurred Page not found. The requested controller could not be mapped by routing. Controller: Blog\\Controller\\ListController(resolves to invalid controller class or alias: Blog\\Controller\\ListController) We now need to tell our module where to find this controller named Blog\\Controller\\ListController . To achieve this we have to add this key to the controllers configuration key inside your module/Blog/config/module.config.php . namespace Blog; use Laminas\\ServiceManager\\Factory\\InvokableFactory; return [ 'controllers' => [ 'factories' => [ Controller\\ListController::class => InvokableFactory::class, ], ], /* ... */ ]; This configuration defines a factory for the controller class Blog\\Controller\\ListController , using the laminas-servicemanager InvokableFactory (which, internally, instantiates the class with no arguments). Reloading the page should then give you: Fatal error: Class 'Blog\\Controller\\ListController' not found in {projectPath}/vendor/laminas/laminas-servicemanager/src/Factory/InvokableFactory.php on line 32 This error tells us that the application knows what class to load, but was not able to autoload it. In our case, we've already setup autoloading, but have not yet defined the controller class! Create the file module/Blog/src/Controller/ListController.php with the following contents: namespace Blog\\Controller; class ListController { } Reloading the page now will finally result into a new screen. The new error message looks like this: A 404 error occurred Page not found. The requested controller was not dispatchable. Controller: Blog\\Controller\\List(resolves to invalid controller class or alias: Blog\\Controller\\List) Additional information: Laminas\\ServiceManager\\Exception\\InvalidServiceException File: {projectPath}/vendor/laminas/laminas-mvc/src/Controller/ControllerManager.php:{lineNumber} Message: Plugin of type \"Blog\\Controller\\ListController\" is invalid; must implement Laminas\\Stdlib\\DispatchableInterface This happens because our controller must implement DispatchableInterface in order to be 'dispatched' (or run) by laminas-mvc. laminas-mvc provides a base controller implementation of it with AbstractActionController , which we are going to use. Let's modify our controller now: // In /module/Blog/src/Controller/ListController.php: namespace Blog\\Controller; use Laminas\\Mvc\\Controller\\AbstractActionController; class ListController extends AbstractActionController { } It's now time for another refresh of the site. You should now see a new error message: An error occurred An error occurred during execution; please try again later. Additional information: Laminas\\View\\Exception\\RuntimeException File: {projectPath}/vendor/laminas/laminas-view/src/Renderer/PhpRenderer.php:{lineNumber} Message: Laminas\\View\\Renderer\\PhpRenderer::render: Unable to render template \"blog/list/index\"; resolver could not resolve to a file Now the application tells you that a view template-file cannot be rendered, which is to be expected as we've not created it yet. The application is expecting it to be at module/Blog/view/blog/list/index.phtml . Create this file and add some dummy content to it: <!-- Filename: module/Blog/view/blog/list/index.phtml --> <h1>Blog\\Controller\\ListController::indexAction()</h1> Before we continue let us quickly take a look at where we placed this file. Note that view files are found within the /view subdirectory, not /src as they are not PHP class files, but template files for rendering HTML. The path, however, deserves some explanation. First we have the lowercased namespace blog , followed by the lowercased controller name list (without the suffix 'controller'), and lastly comes the name of the action that we are accessing, index (again without the suffix 'action'). As a templated string, you can think of it as: view/{namespace}/{controller}/{action}.phtml . This has become a community standard but you have the freedom to specify custom paths if desired. However creating this file alone is not enough and this brings as to the final topic of this part of the tutorial. We need to let the application know where to look for view files. We do this within our module's configuration file, module.config.php . // In module/Blog/config/module.config.php: return [ 'controllers' => [ /** Controller Configuration */ ], 'router' => [ /** Route Configuration */ ], 'view_manager' => [ 'template_path_stack' => [ __DIR__ . '/../view', ], ], ]; The above configuration tells the application that the folder module/Blog/view/ has view files in it that match the standard path format: view/{namespace}/{controller}/{action}.phtml . It is important to note that the view_manager configuration not only allows you to ship view files for your module, but also to overwrite view files from other modules. Reload your site now. Finally we are at a point where we see something different than an error being displayed! You should see the standard Laminas Skeleton Application template page with Blog\\Controller\\ListController::indexAction() as the header. Congratulations, not only have you created a simple \"Hello World\" style module, you also learned about many error messages and their causes. If we didn't exhaust you too much, continue with our tutorial, and let's create a module that actually does something.","title":"Introducing the Blog Module"},{"location":"in-depth-guide/first-module/#introducing-the-blog-module","text":"Now that we know about the basics of the laminas-mvc skeleton application, let's continue and create our very own module. We will create a module named \"Blog\". This module will display a list of database entries that represent a single blog post. Each post will have three properties: id , text , and title . We will create forms to enter new posts into our database and to edit existing posts. Furthermore we will do so by using best-practices throughout the whole tutorial.","title":"Introducing the Blog Module"},{"location":"in-depth-guide/first-module/#writing-a-new-module","text":"Let's start by creating a new folder under the /module directory called Blog , with the following stucture: module/ Blog/ config/ src/ view/ To be recognized as a module by the ModuleManager , we need to do three things: Tell Composer how to autoload classes from our new module. Create a Module class in the Blog namespace. Notify the application of the new module. Let's tell Composer about our new module. Open the composer.json file in the project root, and edit the autoload section to add a new PSR-4 entry for the Blog module; when you're done, it should read: \"autoload\": { \"psr-4\": { \"Application\\\\\": \"module/Application/src/\", \"Album\\\\\": \"module/Album/src/\", \"Blog\\\\\": \"module/Blog/src/\" } } Once you're done, tell Composer to update its autoloading definitions: $ composer dump-autoload Next, we will create a Module class under the Blog namespace. Create the file module/Blog/src/Module.php with the following contents: namespace Blog; class Module { } We now have a module that can be detected by the ModuleManager . Let's add this module to our application. Although our module doesn't do anything yet, just having the Module.php class allows it to be loaded by the ModuleManager. To do this, add an entry for Blog to the modules array inside config/modules.config.php : // In config/modules.config.php: return [ /* ... */ 'Application', 'Album', 'Blog', ]; If you refresh your application you should see no change at all (but also no errors). At this point it's worth taking a step back to discuss what modules are for. In short, a module is an encapsulated set of features for your application. A module might add features to the application that you can see, like our Blog module; or it might provide background functionality for other modules in the application to use, such as interacting with a third party API. Organizing your code into modules makes it easier for you to reuse functionality in other applications, or to use modules written by the community.","title":"Writing a new Module"},{"location":"in-depth-guide/first-module/#configuring-the-module","text":"The next thing we're going to do is add a route to our application so that our module can be accessed through the URL localhost:8080/blog . We do this by adding router configuration to our module, but first we need to let the ModuleManager know that our module has configuration that it needs to load. This is done by adding a getConfig() method to the Module class that returns the configuration. (This method is defined in the ConfigProviderInterface , although explicitly implementing this interface in the module class is optional.) This method should return either an array or a Traversable object. Continue by editing module/Blog/src/Module.php : // In /module/Blog/src/Module.php: class Module { public function getConfig() : array { return []; } } With this, our module is now able to be configured. Configuration files can become quite big, though, and keeping everything inside the getConfig() method won't be optimal. To help keep our project organized, we're going to put our array configuration in a separate file. Go ahead and create this file at module/Blog/config/module.config.php : return []; Now rewrite the getConfig() function to include this newly created file instead of directly returning the array: // In /module/Blog/src/Module.php: public function getConfig() : array { return include __DIR__ . '/../config/module.config.php'; } Reload your application and you'll see that nothing changes. Creating, registering, and adding empty configuration for a new module has no visible effect on the application. Next we add the new route to our configuration file: // In /module/Blog/config/module.config.php: namespace Blog; use Laminas\\Router\\Http\\Literal; return [ // This lines opens the configuration for the RouteManager 'router' => [ // Open configuration for all possible routes 'routes' => [ // Define a new route called \"blog\" 'blog' => [ // Define a \"literal\" route type: 'type' => Literal::class, // Configure the route itself 'options' => [ // Listen to \"/blog\" as uri: 'route' => '/blog', // Define default controller and action to be called when // this route is matched 'defaults' => [ 'controller' => Controller\\ListController::class, 'action' => 'index', ], ], ], ], ], ]; We've now created a route called blog that listens to the URL localhost:8080/blog . Whenever someone accesses this route, the indexAction() function of the class Blog\\Controller\\ListController will be executed. However, this controller does not exist yet, so if you reload the page you will see this error message: A 404 error occurred Page not found. The requested controller could not be mapped by routing. Controller: Blog\\Controller\\ListController(resolves to invalid controller class or alias: Blog\\Controller\\ListController) We now need to tell our module where to find this controller named Blog\\Controller\\ListController . To achieve this we have to add this key to the controllers configuration key inside your module/Blog/config/module.config.php . namespace Blog; use Laminas\\ServiceManager\\Factory\\InvokableFactory; return [ 'controllers' => [ 'factories' => [ Controller\\ListController::class => InvokableFactory::class, ], ], /* ... */ ]; This configuration defines a factory for the controller class Blog\\Controller\\ListController , using the laminas-servicemanager InvokableFactory (which, internally, instantiates the class with no arguments). Reloading the page should then give you: Fatal error: Class 'Blog\\Controller\\ListController' not found in {projectPath}/vendor/laminas/laminas-servicemanager/src/Factory/InvokableFactory.php on line 32 This error tells us that the application knows what class to load, but was not able to autoload it. In our case, we've already setup autoloading, but have not yet defined the controller class! Create the file module/Blog/src/Controller/ListController.php with the following contents: namespace Blog\\Controller; class ListController { } Reloading the page now will finally result into a new screen. The new error message looks like this: A 404 error occurred Page not found. The requested controller was not dispatchable. Controller: Blog\\Controller\\List(resolves to invalid controller class or alias: Blog\\Controller\\List) Additional information: Laminas\\ServiceManager\\Exception\\InvalidServiceException File: {projectPath}/vendor/laminas/laminas-mvc/src/Controller/ControllerManager.php:{lineNumber} Message: Plugin of type \"Blog\\Controller\\ListController\" is invalid; must implement Laminas\\Stdlib\\DispatchableInterface This happens because our controller must implement DispatchableInterface in order to be 'dispatched' (or run) by laminas-mvc. laminas-mvc provides a base controller implementation of it with AbstractActionController , which we are going to use. Let's modify our controller now: // In /module/Blog/src/Controller/ListController.php: namespace Blog\\Controller; use Laminas\\Mvc\\Controller\\AbstractActionController; class ListController extends AbstractActionController { } It's now time for another refresh of the site. You should now see a new error message: An error occurred An error occurred during execution; please try again later. Additional information: Laminas\\View\\Exception\\RuntimeException File: {projectPath}/vendor/laminas/laminas-view/src/Renderer/PhpRenderer.php:{lineNumber} Message: Laminas\\View\\Renderer\\PhpRenderer::render: Unable to render template \"blog/list/index\"; resolver could not resolve to a file Now the application tells you that a view template-file cannot be rendered, which is to be expected as we've not created it yet. The application is expecting it to be at module/Blog/view/blog/list/index.phtml . Create this file and add some dummy content to it: <!-- Filename: module/Blog/view/blog/list/index.phtml --> <h1>Blog\\Controller\\ListController::indexAction()</h1> Before we continue let us quickly take a look at where we placed this file. Note that view files are found within the /view subdirectory, not /src as they are not PHP class files, but template files for rendering HTML. The path, however, deserves some explanation. First we have the lowercased namespace blog , followed by the lowercased controller name list (without the suffix 'controller'), and lastly comes the name of the action that we are accessing, index (again without the suffix 'action'). As a templated string, you can think of it as: view/{namespace}/{controller}/{action}.phtml . This has become a community standard but you have the freedom to specify custom paths if desired. However creating this file alone is not enough and this brings as to the final topic of this part of the tutorial. We need to let the application know where to look for view files. We do this within our module's configuration file, module.config.php . // In module/Blog/config/module.config.php: return [ 'controllers' => [ /** Controller Configuration */ ], 'router' => [ /** Route Configuration */ ], 'view_manager' => [ 'template_path_stack' => [ __DIR__ . '/../view', ], ], ]; The above configuration tells the application that the folder module/Blog/view/ has view files in it that match the standard path format: view/{namespace}/{controller}/{action}.phtml . It is important to note that the view_manager configuration not only allows you to ship view files for your module, but also to overwrite view files from other modules. Reload your site now. Finally we are at a point where we see something different than an error being displayed! You should see the standard Laminas Skeleton Application template page with Blog\\Controller\\ListController::indexAction() as the header. Congratulations, not only have you created a simple \"Hello World\" style module, you also learned about many error messages and their causes. If we didn't exhaust you too much, continue with our tutorial, and let's create a module that actually does something.","title":"Configuring the Module"},{"location":"in-depth-guide/laminas-db-sql-laminas-hydrator/","text":"SQL Abstraction and Object Hydration In the last chapter, we introduced database abstraction and a new command interface for operations that might change what blog posts we store. We'll now start creating database-backed versions of the PostRepositoryInterface and PostCommandInterface , demonstrating usage of the various Laminas\\Db\\Sql classes. Preparing the Database This tutorial assumes you've followed the Getting Started tutorial, and that you've already populated the data/laminastutorial.db SQLite database. We will be re-using it, and adding another table to it. Create the file data/posts.schema.sql with the following contents: CREATE TABLE posts (id INTEGER PRIMARY KEY AUTOINCREMENT, title varchar(100) NOT NULL, text TEXT NOT NULL); INSERT INTO posts (title, text) VALUES ('Blog #1', 'Welcome to my first blog post'); INSERT INTO posts (title, text) VALUES ('Blog #2', 'Welcome to my second blog post'); INSERT INTO posts (title, text) VALUES ('Blog #3', 'Welcome to my third blog post'); INSERT INTO posts (title, text) VALUES ('Blog #4', 'Welcome to my fourth blog post'); INSERT INTO posts (title, text) VALUES ('Blog #5', 'Welcome to my fifth blog post'); Now we will execute this against the existing data/laminastutorial.db SQLite database using the sqlite command (or sqlite3 ; check your operating system): $ sqlite data/laminastutorial.db < data/posts.schema.sql If you don't have a sqlite command, you can populate it using PHP. Create the following script in data/load_posts.php : <?php $db = new PDO('sqlite:' . __DIR__ . '/laminastutorial.db'); $fh = fopen(__DIR__ . '/posts.schema.sql', 'r'); while ($line = fread($fh, 4096)) { $line = trim($line); $db->exec($line); } fclose($fh); and execute it using: $ php data/load_posts.php Quick Facts Laminas\\Db\\Sql To create queries against a database using Laminas\\Db\\Sql , you need to have a database adapter available. The \"Getting Started\" tutorial covered this in the database chapter , and we can re-use that adapter. With the adapter in place and the new table populated, we can run queries against the database. The construction of queries is best done through the \"QueryBuilder\" features of Laminas\\Db\\Sql which are Laminas\\Db\\Sql\\Sql for select queries, Laminas\\Db\\Sql\\Insert for insert queries, Laminas\\Db\\Sql\\Update for update queries and Laminas\\Db\\Sql\\Delete for delete queries. The basic workflow of these components is: Build a query using the relevant class: Sql , Insert , Update , or Delete . Create a SQL statement from the Sql object. Execute the query. Do something with the result. Let's start writing database-driven implementations of our interfaces now. Writing the repository implementation Create a class named LaminasDbSqlRepository in the Blog\\Model namespace that implements PostRepositoryInterface ; leave the methods empty for now: // In module/Blog/src/Model/LaminasDbSqlRepository.php: namespace Blog\\Model; use InvalidArgumentException; use RuntimeException; class LaminasDbSqlRepository implements PostRepositoryInterface { /** * {@inheritDoc} */ public function findAllPosts() { } /** * {@inheritDoc} * @throws InvalidArgumentException * @throws RuntimeException */ public function findPost($id) { } } Now recall what we have learned earlier: for Laminas\\Db\\Sql to function, we will need a working implementation of the AdapterInterface . This is a requirement , and therefore will be injected using constructor injection . Create a __construct() method that accepts an AdapterInterface as its sole parameter, and stores it as an instance property: // In module/Blog/src/Model/LaminasDbSqlRepository.php: namespace Blog\\Model; use InvalidArgumentException; use RuntimeException; use Laminas\\Db\\Adapter\\AdapterInterface; class LaminasDbSqlRepository implements PostRepositoryInterface { /** * @var AdapterInterface */ private $db; /** * @param AdapterInterface $db */ public function __construct(AdapterInterface $db) { $this->db = $db; } /** * {@inheritDoc} */ public function findAllPosts() { } /** * {@inheritDoc} * @throws InvalidArgumentException * @throws RuntimeException */ public function findPost($id) { } } Whenever we have a required parameter, we need to write a factory for the class. Go ahead and create a factory for our new repository implementation: // In module/Blog/src/Factory/LaminasDbSqlRepositoryFactory.php namespace Blog\\Factory; use Interop\\Container\\ContainerInterface; use Blog\\Model\\LaminasDbSqlRepository; use Laminas\\Db\\Adapter\\AdapterInterface; use Laminas\\ServiceManager\\Factory\\FactoryInterface; class LaminasDbSqlRepositoryFactory implements FactoryInterface { /** * @param ContainerInterface $container * @param string $requestedName * @param null|array $options * @return LaminasDbSqlRepository */ public function __invoke(ContainerInterface $container, $requestedName, array $options = null) { return new LaminasDbSqlRepository($container->get(AdapterInterface::class)); } } We're now able to register our repository implementation as a service. To do so, we'll make two changes: Register a factory entry for the new repository. Update the existing alias for PostRepositoryInterface to point to the new repository. Update module/Blog/config/module.config.php as follows: return [ 'service_manager' => [ 'aliases' => [ // Update this line: Model\\PostRepositoryInterface::class => Model\\LaminasDbSqlRepository::class, ], 'factories' => [ Model\\PostRepository::class => InvokableFactory::class, // Add this line: Model\\LaminasDbSqlRepository::class => Factory\\LaminasDbSqlRepositoryFactory::class, ], ], 'controllers' => [ /* ... */ ], 'router' => [ /* ... */ ], 'view_manager' => [ /* ... */ ], ]; With the adapter in place you're now able to refresh the blog index at localhost:8080/blog and you'll notice that the ServiceNotFoundException is gone and we get the following PHP Warning: Warning: Invalid argument supplied for foreach() in {projectPath}/module/Blog/view/blog/list/index.phtml on line {lineNumber} This is due to the fact that our mapper doesn't return anything yet. Let's modify the findAllPosts() function to return all blog posts from the database table: // In /module/Blog/src/Model/LaminasDbSqlRepository.php: namespace Blog\\Model; use InvalidArgumentException; use RuntimeException; use Laminas\\Db\\Adapter\\AdapterInterface; use Laminas\\Db\\Sql\\Sql; class LaminasDbSqlRepository implements PostRepositoryInterface { /** * @var AdapterInterface */ private $db; /** * @param AdapterInterface $db */ public function __construct(AdapterInterface $db) { $this->db = $db; } /** * {@inheritDoc} */ public function findAllPosts() { $sql = new Sql($this->db); $select = $sql->select('posts'); $stmt = $sql->prepareStatementForSqlObject($select); $result = $stmt->execute(); return $result; } /** * {@inheritDoc} * @throws InvalidArgumentException * @throw RuntimeException */ public function findPost($id) { } } Sadly, though, a refresh of the application reveals another error message: PHP Fatal error: Call to a member function getId() on array in {projectPath}/module/Blog/view/blog/list/index.phtml on line {lineNumber} Let's not return the $result variable for now and do a dump of it to see what we get here. Change the findAllPosts() method and dump the result: public function findAllPosts() { $sql = new Sql($this->db); $select = $sql->select('posts'); $stmt = $sql->prepareStatementForSqlObject($select); $result = $stmt->execute(); var_export($result); die(); return $result; } Refreshing the application you should now see output similar to the following: Laminas\\Db\\Adapter\\Driver\\Pdo\\Result::__set_state(array( 'statementMode' => 'forward', 'fetchMode' => 2, 'resource' => PDOStatement::__set_state(array( 'queryString' => 'SELECT \"posts\".* FROM \"posts\"', )), 'options' => null, 'currentComplete' => false, 'currentData' => null, 'position' => -1, 'generatedValue' => '0', 'rowCount' => Closure::__set_state(array()), )) As you can see, we do not get any data returned. Instead we are presented with a dump of some Result object that appears to have no data in it whatsoever. But this is a faulty assumption. This Result object only has information available for you when you actually try to access it. If you can determine that the query was successful, the best way to make use of the data within the Result object is to pass it to a ResultSet object. First, add two more import statements to the class file: use Laminas\\Db\\Adapter\\Driver\\ResultInterface; use Laminas\\Db\\ResultSet\\ResultSet; Now update the findAllPosts() method as follows: public function findAllPosts() { $sql = new Sql($this->db); $select = $sql->select('posts'); $stmt = $sql->prepareStatementForSqlObject($select); $result = $stmt->execute(); if ($result instanceof ResultInterface && $result->isQueryResult()) { $resultSet = new ResultSet(); $resultSet->initialize($result); var_export($resultSet); die(); } die('no data'); } Refreshing the page, you should now see the dump of a ResultSet instance: Laminas\\Db\\ResultSet\\ResultSet::__set_state(array( 'allowedReturnTypes' => array( 0 => 'arrayobject', 1 => 'array', ), 'arrayObjectPrototype' => ArrayObject::__set_state(array( )), 'returnType' => 'arrayobject', 'buffer' => null, 'count' => null, 'dataSource' => Laminas\\Db\\Adapter\\Driver\\Pdo\\Result::__set_state(array( 'statementMode' => 'forward', 'fetchMode' => 2, 'resource' => PDOStatement::__set_state(array( 'queryString' => 'SELECT \"posts\".* FROM \"posts\"', )), 'options' => null, 'currentComplete' => false, 'currentData' => null, 'position' => -1, 'generatedValue' => '0', 'rowCount' => Closure::__set_state(array( )), )), 'fieldCount' => 3, 'position' => 0, )) Of particular interest is the returnType property, which has a value of arrayobject . This tells us that all database entries will be returned as an ArrayObject instances. And this is a little problem for us, as the PostRepositoryInterface requires us to return an array of Post instances. Luckily the Laminas\\Db\\ResultSet subcomponent offers a solution for us, via the HydratingResultSet ; this result set type will populate an object of a type we specify with the data returned. Let's modify our code. First, remove the following import statement from the class file: use Laminas\\Db\\ResultSet\\ResultSet; Next, we'll add the following import statements to our class file: use Laminas\\Hydrator\\ReflectionHydrator; use Laminas\\Db\\ResultSet\\HydratingResultSet; Now, update the findAllPosts() method to read as follows: public function findAllPosts() { $sql = new Sql($this->db); $select = $sql->select('posts'); $statement = $sql->prepareStatementForSqlObject($select); $result = $statement->execute(); if (! $result instanceof ResultInterface || ! $result->isQueryResult()) { return []; } $resultSet = new HydratingResultSet( new ReflectionHydrator(), new Post('', '') ); $resultSet->initialize($result); return $resultSet; } We have changed a couple of things here. First, instead of a normal ResultSet , we are now using the HydratingResultSet . This specialized result set requires two parameters, the second one being an object to hydrate with data, and the first one being the hydrator that will be used (a hydrator is an object that will transform an array of data into an object, and vice versa). We use Laminas\\Hydrator\\Reflection here, which is capable of injecting private properties of an instance. We provide an empty Post instance, which the hydrator will clone to create new instances with data from individual rows. Instead of dumping the $result variable, we now directly return the initialized HydratingResultSet so we can access the data stored within. In case we get something else returned that is not an instance of a ResultInterface , we return an empty array. Refreshing the page you will now see all your blog posts listed on the page. Great! Refactoring hidden dependencies There's one little thing that we have done that's not a best-practice. We use both a hydrator and a Post prototype inside our LaminasDbSqlRepository . Let's inject those instead, so that we can reuse them between our repository and command implementations, or vary them based on environment. Update your LaminasDbSqlRepository as follows: // In module/Blog/src/Model/LaminasDbSqlRepository.php: namespace Blog\\Model; use InvalidArgumentException; use RuntimeException; // Replace the import of the Reflection hydrator with this: use Laminas\\Hydrator\\HydratorInterface; use Laminas\\Db\\Adapter\\AdapterInterface; use Laminas\\Db\\Adapter\\Driver\\ResultInterface; use Laminas\\Db\\ResultSet\\HydratingResultSet; use Laminas\\Db\\Sql\\Sql; class LaminasDbSqlRepository implements PostRepositoryInterface { /** * @var AdapterInterface */ private $db; /** * @var HydratorInterface */ private $hydrator; /** * @var Post */ private $postPrototype; public function __construct( AdapterInterface $db, HydratorInterface $hydrator, Post $postPrototype ) { $this->db = $db; $this->hydrator = $hydrator; $this->postPrototype = $postPrototype; } /** * Return a set of all blog posts that we can iterate over. * * Each entry should be a Post instance. * * @return Post[] */ public function findAllPosts() { $sql = new Sql($this->db); $select = $sql->select('posts'); $statement = $sql->prepareStatementForSqlObject($select); $result = $statement->execute(); if (! $result instanceof ResultInterface || ! $result->isQueryResult()) { return []; } $resultSet = new HydratingResultSet($this->hydrator, $this->postPrototype); $resultSet->initialize($result); return $resultSet; } /** * Return a single blog post. * * @param int $id Identifier of the post to return. * @return Post */ public function findPost($id) { } } Now that our repository requires more parameters, we need to update the LaminasDbSqlRepositoryFactory and inject those parameters: // In /module/Blog/src/Factory/LaminasDbSqlRepositoryFactory.php namespace Blog\\Factory; use Interop\\Container\\ContainerInterface; use Blog\\Model\\Post; use Blog\\Model\\LaminasDbSqlRepository; use Laminas\\Db\\Adapter\\AdapterInterface; use Laminas\\Hydrator\\ReflectionHydrator; use Laminas\\ServiceManager\\Factory\\FactoryInterface; class LaminasDbSqlRepositoryFactory implements FactoryInterface { public function __invoke(ContainerInterface $container, $requestedName, array $options = null) { return new LaminasDbSqlRepository( $container->get(AdapterInterface::class), new ReflectionHydrator(), new Post('', '') ); } } With this in place you can refresh the application again and you'll see your blog posts listed once again. Our repository no longer has hidden dependencies, and works with a database! Finishing the repository Before we jump into the next chapter, let's quickly finish the repository implementation by completing the findPost() method: public function findPost($id) { $sql = new Sql($this->db); $select = $sql->select('posts'); $select->where(['id = ?' => $id]); $statement = $sql->prepareStatementForSqlObject($select); $result = $statement->execute(); if (! $result instanceof ResultInterface || ! $result->isQueryResult()) { throw new RuntimeException(sprintf( 'Failed retrieving blog post with identifier \"%s\"; unknown database error.', $id )); } $resultSet = new HydratingResultSet($this->hydrator, $this->postPrototype); $resultSet->initialize($result); $post = $resultSet->current(); if (! $post) { throw new InvalidArgumentException(sprintf( 'Blog post with identifier \"%s\" not found.', $id )); } return $post; } The findPost() function looks similar to the findAllPosts() method, with several differences. We need to add a condition to the query to select only the row matching the provided identifier; this is done using the where() method of the Sql object. We check if the $result is valid, using isQueryResult() ; if not, an error occurred during the query that we report via a RuntimeException . We pull the current() item off the result set we create, and test to make sure we received something; if not, we had an invalid identifier, and raise an InvalidArgumentException . Conclusion Finishing this chapter, you now know how to query for data using the Laminas\\Db\\Sql classes. You have also learned a little about the laminas-hydrator component, and the integration laminas-db provides with it. Furthermore, we've continued demonstrating dependency injection in all aspects of our application. In the next chapter we'll take a closer look at the router so we'll be able to start displaying individual blog posts.","title":"SQL Abstraction and Object Hydration"},{"location":"in-depth-guide/laminas-db-sql-laminas-hydrator/#sql-abstraction-and-object-hydration","text":"In the last chapter, we introduced database abstraction and a new command interface for operations that might change what blog posts we store. We'll now start creating database-backed versions of the PostRepositoryInterface and PostCommandInterface , demonstrating usage of the various Laminas\\Db\\Sql classes.","title":"SQL Abstraction and Object Hydration"},{"location":"in-depth-guide/laminas-db-sql-laminas-hydrator/#preparing-the-database","text":"This tutorial assumes you've followed the Getting Started tutorial, and that you've already populated the data/laminastutorial.db SQLite database. We will be re-using it, and adding another table to it. Create the file data/posts.schema.sql with the following contents: CREATE TABLE posts (id INTEGER PRIMARY KEY AUTOINCREMENT, title varchar(100) NOT NULL, text TEXT NOT NULL); INSERT INTO posts (title, text) VALUES ('Blog #1', 'Welcome to my first blog post'); INSERT INTO posts (title, text) VALUES ('Blog #2', 'Welcome to my second blog post'); INSERT INTO posts (title, text) VALUES ('Blog #3', 'Welcome to my third blog post'); INSERT INTO posts (title, text) VALUES ('Blog #4', 'Welcome to my fourth blog post'); INSERT INTO posts (title, text) VALUES ('Blog #5', 'Welcome to my fifth blog post'); Now we will execute this against the existing data/laminastutorial.db SQLite database using the sqlite command (or sqlite3 ; check your operating system): $ sqlite data/laminastutorial.db < data/posts.schema.sql If you don't have a sqlite command, you can populate it using PHP. Create the following script in data/load_posts.php : <?php $db = new PDO('sqlite:' . __DIR__ . '/laminastutorial.db'); $fh = fopen(__DIR__ . '/posts.schema.sql', 'r'); while ($line = fread($fh, 4096)) { $line = trim($line); $db->exec($line); } fclose($fh); and execute it using: $ php data/load_posts.php","title":"Preparing the Database"},{"location":"in-depth-guide/laminas-db-sql-laminas-hydrator/#quick-facts-laminasdbsql","text":"To create queries against a database using Laminas\\Db\\Sql , you need to have a database adapter available. The \"Getting Started\" tutorial covered this in the database chapter , and we can re-use that adapter. With the adapter in place and the new table populated, we can run queries against the database. The construction of queries is best done through the \"QueryBuilder\" features of Laminas\\Db\\Sql which are Laminas\\Db\\Sql\\Sql for select queries, Laminas\\Db\\Sql\\Insert for insert queries, Laminas\\Db\\Sql\\Update for update queries and Laminas\\Db\\Sql\\Delete for delete queries. The basic workflow of these components is: Build a query using the relevant class: Sql , Insert , Update , or Delete . Create a SQL statement from the Sql object. Execute the query. Do something with the result. Let's start writing database-driven implementations of our interfaces now.","title":"Quick Facts Laminas\\Db\\Sql"},{"location":"in-depth-guide/laminas-db-sql-laminas-hydrator/#writing-the-repository-implementation","text":"Create a class named LaminasDbSqlRepository in the Blog\\Model namespace that implements PostRepositoryInterface ; leave the methods empty for now: // In module/Blog/src/Model/LaminasDbSqlRepository.php: namespace Blog\\Model; use InvalidArgumentException; use RuntimeException; class LaminasDbSqlRepository implements PostRepositoryInterface { /** * {@inheritDoc} */ public function findAllPosts() { } /** * {@inheritDoc} * @throws InvalidArgumentException * @throws RuntimeException */ public function findPost($id) { } } Now recall what we have learned earlier: for Laminas\\Db\\Sql to function, we will need a working implementation of the AdapterInterface . This is a requirement , and therefore will be injected using constructor injection . Create a __construct() method that accepts an AdapterInterface as its sole parameter, and stores it as an instance property: // In module/Blog/src/Model/LaminasDbSqlRepository.php: namespace Blog\\Model; use InvalidArgumentException; use RuntimeException; use Laminas\\Db\\Adapter\\AdapterInterface; class LaminasDbSqlRepository implements PostRepositoryInterface { /** * @var AdapterInterface */ private $db; /** * @param AdapterInterface $db */ public function __construct(AdapterInterface $db) { $this->db = $db; } /** * {@inheritDoc} */ public function findAllPosts() { } /** * {@inheritDoc} * @throws InvalidArgumentException * @throws RuntimeException */ public function findPost($id) { } } Whenever we have a required parameter, we need to write a factory for the class. Go ahead and create a factory for our new repository implementation: // In module/Blog/src/Factory/LaminasDbSqlRepositoryFactory.php namespace Blog\\Factory; use Interop\\Container\\ContainerInterface; use Blog\\Model\\LaminasDbSqlRepository; use Laminas\\Db\\Adapter\\AdapterInterface; use Laminas\\ServiceManager\\Factory\\FactoryInterface; class LaminasDbSqlRepositoryFactory implements FactoryInterface { /** * @param ContainerInterface $container * @param string $requestedName * @param null|array $options * @return LaminasDbSqlRepository */ public function __invoke(ContainerInterface $container, $requestedName, array $options = null) { return new LaminasDbSqlRepository($container->get(AdapterInterface::class)); } } We're now able to register our repository implementation as a service. To do so, we'll make two changes: Register a factory entry for the new repository. Update the existing alias for PostRepositoryInterface to point to the new repository. Update module/Blog/config/module.config.php as follows: return [ 'service_manager' => [ 'aliases' => [ // Update this line: Model\\PostRepositoryInterface::class => Model\\LaminasDbSqlRepository::class, ], 'factories' => [ Model\\PostRepository::class => InvokableFactory::class, // Add this line: Model\\LaminasDbSqlRepository::class => Factory\\LaminasDbSqlRepositoryFactory::class, ], ], 'controllers' => [ /* ... */ ], 'router' => [ /* ... */ ], 'view_manager' => [ /* ... */ ], ]; With the adapter in place you're now able to refresh the blog index at localhost:8080/blog and you'll notice that the ServiceNotFoundException is gone and we get the following PHP Warning: Warning: Invalid argument supplied for foreach() in {projectPath}/module/Blog/view/blog/list/index.phtml on line {lineNumber} This is due to the fact that our mapper doesn't return anything yet. Let's modify the findAllPosts() function to return all blog posts from the database table: // In /module/Blog/src/Model/LaminasDbSqlRepository.php: namespace Blog\\Model; use InvalidArgumentException; use RuntimeException; use Laminas\\Db\\Adapter\\AdapterInterface; use Laminas\\Db\\Sql\\Sql; class LaminasDbSqlRepository implements PostRepositoryInterface { /** * @var AdapterInterface */ private $db; /** * @param AdapterInterface $db */ public function __construct(AdapterInterface $db) { $this->db = $db; } /** * {@inheritDoc} */ public function findAllPosts() { $sql = new Sql($this->db); $select = $sql->select('posts'); $stmt = $sql->prepareStatementForSqlObject($select); $result = $stmt->execute(); return $result; } /** * {@inheritDoc} * @throws InvalidArgumentException * @throw RuntimeException */ public function findPost($id) { } } Sadly, though, a refresh of the application reveals another error message: PHP Fatal error: Call to a member function getId() on array in {projectPath}/module/Blog/view/blog/list/index.phtml on line {lineNumber} Let's not return the $result variable for now and do a dump of it to see what we get here. Change the findAllPosts() method and dump the result: public function findAllPosts() { $sql = new Sql($this->db); $select = $sql->select('posts'); $stmt = $sql->prepareStatementForSqlObject($select); $result = $stmt->execute(); var_export($result); die(); return $result; } Refreshing the application you should now see output similar to the following: Laminas\\Db\\Adapter\\Driver\\Pdo\\Result::__set_state(array( 'statementMode' => 'forward', 'fetchMode' => 2, 'resource' => PDOStatement::__set_state(array( 'queryString' => 'SELECT \"posts\".* FROM \"posts\"', )), 'options' => null, 'currentComplete' => false, 'currentData' => null, 'position' => -1, 'generatedValue' => '0', 'rowCount' => Closure::__set_state(array()), )) As you can see, we do not get any data returned. Instead we are presented with a dump of some Result object that appears to have no data in it whatsoever. But this is a faulty assumption. This Result object only has information available for you when you actually try to access it. If you can determine that the query was successful, the best way to make use of the data within the Result object is to pass it to a ResultSet object. First, add two more import statements to the class file: use Laminas\\Db\\Adapter\\Driver\\ResultInterface; use Laminas\\Db\\ResultSet\\ResultSet; Now update the findAllPosts() method as follows: public function findAllPosts() { $sql = new Sql($this->db); $select = $sql->select('posts'); $stmt = $sql->prepareStatementForSqlObject($select); $result = $stmt->execute(); if ($result instanceof ResultInterface && $result->isQueryResult()) { $resultSet = new ResultSet(); $resultSet->initialize($result); var_export($resultSet); die(); } die('no data'); } Refreshing the page, you should now see the dump of a ResultSet instance: Laminas\\Db\\ResultSet\\ResultSet::__set_state(array( 'allowedReturnTypes' => array( 0 => 'arrayobject', 1 => 'array', ), 'arrayObjectPrototype' => ArrayObject::__set_state(array( )), 'returnType' => 'arrayobject', 'buffer' => null, 'count' => null, 'dataSource' => Laminas\\Db\\Adapter\\Driver\\Pdo\\Result::__set_state(array( 'statementMode' => 'forward', 'fetchMode' => 2, 'resource' => PDOStatement::__set_state(array( 'queryString' => 'SELECT \"posts\".* FROM \"posts\"', )), 'options' => null, 'currentComplete' => false, 'currentData' => null, 'position' => -1, 'generatedValue' => '0', 'rowCount' => Closure::__set_state(array( )), )), 'fieldCount' => 3, 'position' => 0, )) Of particular interest is the returnType property, which has a value of arrayobject . This tells us that all database entries will be returned as an ArrayObject instances. And this is a little problem for us, as the PostRepositoryInterface requires us to return an array of Post instances. Luckily the Laminas\\Db\\ResultSet subcomponent offers a solution for us, via the HydratingResultSet ; this result set type will populate an object of a type we specify with the data returned. Let's modify our code. First, remove the following import statement from the class file: use Laminas\\Db\\ResultSet\\ResultSet; Next, we'll add the following import statements to our class file: use Laminas\\Hydrator\\ReflectionHydrator; use Laminas\\Db\\ResultSet\\HydratingResultSet; Now, update the findAllPosts() method to read as follows: public function findAllPosts() { $sql = new Sql($this->db); $select = $sql->select('posts'); $statement = $sql->prepareStatementForSqlObject($select); $result = $statement->execute(); if (! $result instanceof ResultInterface || ! $result->isQueryResult()) { return []; } $resultSet = new HydratingResultSet( new ReflectionHydrator(), new Post('', '') ); $resultSet->initialize($result); return $resultSet; } We have changed a couple of things here. First, instead of a normal ResultSet , we are now using the HydratingResultSet . This specialized result set requires two parameters, the second one being an object to hydrate with data, and the first one being the hydrator that will be used (a hydrator is an object that will transform an array of data into an object, and vice versa). We use Laminas\\Hydrator\\Reflection here, which is capable of injecting private properties of an instance. We provide an empty Post instance, which the hydrator will clone to create new instances with data from individual rows. Instead of dumping the $result variable, we now directly return the initialized HydratingResultSet so we can access the data stored within. In case we get something else returned that is not an instance of a ResultInterface , we return an empty array. Refreshing the page you will now see all your blog posts listed on the page. Great!","title":"Writing the repository implementation"},{"location":"in-depth-guide/laminas-db-sql-laminas-hydrator/#refactoring-hidden-dependencies","text":"There's one little thing that we have done that's not a best-practice. We use both a hydrator and a Post prototype inside our LaminasDbSqlRepository . Let's inject those instead, so that we can reuse them between our repository and command implementations, or vary them based on environment. Update your LaminasDbSqlRepository as follows: // In module/Blog/src/Model/LaminasDbSqlRepository.php: namespace Blog\\Model; use InvalidArgumentException; use RuntimeException; // Replace the import of the Reflection hydrator with this: use Laminas\\Hydrator\\HydratorInterface; use Laminas\\Db\\Adapter\\AdapterInterface; use Laminas\\Db\\Adapter\\Driver\\ResultInterface; use Laminas\\Db\\ResultSet\\HydratingResultSet; use Laminas\\Db\\Sql\\Sql; class LaminasDbSqlRepository implements PostRepositoryInterface { /** * @var AdapterInterface */ private $db; /** * @var HydratorInterface */ private $hydrator; /** * @var Post */ private $postPrototype; public function __construct( AdapterInterface $db, HydratorInterface $hydrator, Post $postPrototype ) { $this->db = $db; $this->hydrator = $hydrator; $this->postPrototype = $postPrototype; } /** * Return a set of all blog posts that we can iterate over. * * Each entry should be a Post instance. * * @return Post[] */ public function findAllPosts() { $sql = new Sql($this->db); $select = $sql->select('posts'); $statement = $sql->prepareStatementForSqlObject($select); $result = $statement->execute(); if (! $result instanceof ResultInterface || ! $result->isQueryResult()) { return []; } $resultSet = new HydratingResultSet($this->hydrator, $this->postPrototype); $resultSet->initialize($result); return $resultSet; } /** * Return a single blog post. * * @param int $id Identifier of the post to return. * @return Post */ public function findPost($id) { } } Now that our repository requires more parameters, we need to update the LaminasDbSqlRepositoryFactory and inject those parameters: // In /module/Blog/src/Factory/LaminasDbSqlRepositoryFactory.php namespace Blog\\Factory; use Interop\\Container\\ContainerInterface; use Blog\\Model\\Post; use Blog\\Model\\LaminasDbSqlRepository; use Laminas\\Db\\Adapter\\AdapterInterface; use Laminas\\Hydrator\\ReflectionHydrator; use Laminas\\ServiceManager\\Factory\\FactoryInterface; class LaminasDbSqlRepositoryFactory implements FactoryInterface { public function __invoke(ContainerInterface $container, $requestedName, array $options = null) { return new LaminasDbSqlRepository( $container->get(AdapterInterface::class), new ReflectionHydrator(), new Post('', '') ); } } With this in place you can refresh the application again and you'll see your blog posts listed once again. Our repository no longer has hidden dependencies, and works with a database!","title":"Refactoring hidden dependencies"},{"location":"in-depth-guide/laminas-db-sql-laminas-hydrator/#finishing-the-repository","text":"Before we jump into the next chapter, let's quickly finish the repository implementation by completing the findPost() method: public function findPost($id) { $sql = new Sql($this->db); $select = $sql->select('posts'); $select->where(['id = ?' => $id]); $statement = $sql->prepareStatementForSqlObject($select); $result = $statement->execute(); if (! $result instanceof ResultInterface || ! $result->isQueryResult()) { throw new RuntimeException(sprintf( 'Failed retrieving blog post with identifier \"%s\"; unknown database error.', $id )); } $resultSet = new HydratingResultSet($this->hydrator, $this->postPrototype); $resultSet->initialize($result); $post = $resultSet->current(); if (! $post) { throw new InvalidArgumentException(sprintf( 'Blog post with identifier \"%s\" not found.', $id )); } return $post; } The findPost() function looks similar to the findAllPosts() method, with several differences. We need to add a condition to the query to select only the row matching the provided identifier; this is done using the where() method of the Sql object. We check if the $result is valid, using isQueryResult() ; if not, an error occurred during the query that we report via a RuntimeException . We pull the current() item off the result set we create, and test to make sure we received something; if not, we had an invalid identifier, and raise an InvalidArgumentException .","title":"Finishing the repository"},{"location":"in-depth-guide/laminas-db-sql-laminas-hydrator/#conclusion","text":"Finishing this chapter, you now know how to query for data using the Laminas\\Db\\Sql classes. You have also learned a little about the laminas-hydrator component, and the integration laminas-db provides with it. Furthermore, we've continued demonstrating dependency injection in all aspects of our application. In the next chapter we'll take a closer look at the router so we'll be able to start displaying individual blog posts.","title":"Conclusion"},{"location":"in-depth-guide/laminas-form-laminas-form-fieldset/","text":"Making Use of Forms and Fieldsets So far all we have done is read data from the database. In a real-life application, this won't get us very far, as we'll often need to support the full range of full Create , Read , Update and Delete operations (CRUD). Typically, new data will arrive via web form submissions. Form components The laminas-form and laminas-inputfilter components provide us with the ability to create fully-featured forms and their validation rules. laminas-form consumes laminas-inputfilter internally, so let's take a look at the elements of laminas-form that we will use for our application. Fieldsets Laminas\\Form\\Fieldset models a reusable set of elements. You will use a Fieldset to create the various HTML inputs needed to map to your server-side entities. It is considered good practice to have one Fieldset for every entity in your application. The Fieldset component, however, is not a form, meaning you will not be able to use a Fieldset without attaching it to the Laminas\\Form\\Form instance. The advantage here is that you have one set of elements that you can re-use for as many forms as you like. Forms Laminas\\Form\\Form is a container for all elements of your HTML <form> . You are able to add both single elements or fieldsets (modeled as Laminas\\Form\\Fieldset instances). Creating your first Fieldset Explaining how laminas-form works is best done by giving you real code to work with. So let's jump right into it and create all the forms we need to finish our Blog module. We start by creating a Fieldset that contains all the input elements that we need to work with our blog data: You will need one hidden input for the id property, which is only needed for editting and deleting data. You will need one text input for the title property. You will need one textarea for the text property. Create the file module/Blog/src/Form/PostFieldset.php with the following contents: <?php namespace Blog\\Form; use Laminas\\Form\\Fieldset; class PostFieldset extends Fieldset { public function init() { $this->add([ 'type' => 'hidden', 'name' => 'id', ]); $this->add([ 'type' => 'text', 'name' => 'title', 'options' => [ 'label' => 'Post Title', ], ]); $this->add([ 'type' => 'textarea', 'name' => 'text', 'options' => [ 'label' => 'Post Text', ], ]); } } This new class creates an extension of Laminas\\Form\\Fieldset that, in an init() method (more on this later), adds elements for each aspect of our blog post. We can now re-use this fieldset in as many forms as we want. Let's create our first form. Creating the PostForm Now that we have our PostFieldset in place, we can use it inside a Form . The form will use the PostFieldset , and also include a submit button so that the user can submit the data. Create the file module/Blog/src/Form/PostForm.php with the following contents: <?php namespace Blog\\Form; use Laminas\\Form\\Form; class PostForm extends Form { public function init() { $this->add([ 'name' => 'post', 'type' => PostFieldset::class, ]); $this->add([ 'type' => 'submit', 'name' => 'submit', 'attributes' => [ 'value' => 'Insert new Post', ], ]); } } And that's our form. Nothing special here, we add our PostFieldset to the form, we add a submit button to the form, and nothing more. Adding a new Post Now that we have the PostForm written, it's time to use it. But there are a few more tasks left: We need to create a new controller WriteController which accepts the following instances via its constructor: a PostCommandInterface instance a PostForm instance We need to create an addAction() method in the new WriteController to handle displaying the form and processing it. We need to create a new route, blog/add , that routes to the WriteController and its addAction() method. We need to create a new view script to display the form. Creating the WriteController While we could re-use our existing controller, it has a different responsibility: it will be writing new blog posts. As such, it will need to emit commands , and thus use the PostCommandInterface that we have defined previously. To do that, it needs to accept and process user input, which we have modeled in our PostForm in a previous section of this chapter. Let's create this new class now. Open a new file, module/Blog/src/Controller/WriteController.php , and add the following contents: <?php namespace Blog\\Controller; use Blog\\Form\\PostForm; use Blog\\Model\\Post; use Blog\\Model\\PostCommandInterface; use Laminas\\Mvc\\Controller\\AbstractActionController; use Laminas\\View\\Model\\ViewModel; class WriteController extends AbstractActionController { /** * @var PostCommandInterface */ private $command; /** * @var PostForm */ private $form; /** * @param PostCommandInterface $command * @param PostForm $form */ public function __construct(PostCommandInterface $command, PostForm $form) { $this->command = $command; $this->form = $form; } public function addAction() { } } We'll now create a factory for this new controller; create a new file, module/Blog/src/Factory/WriteControllerFactory.php , with the following contents: <?php namespace Blog\\Factory; use Blog\\Controller\\WriteController; use Blog\\Form\\PostForm; use Blog\\Model\\PostCommandInterface; use Interop\\Container\\ContainerInterface; use Laminas\\ServiceManager\\Factory\\FactoryInterface; class WriteControllerFactory implements FactoryInterface { /** * @param ContainerInterface $container * @param string $requestedName * @param null|array $options * @return WriteController */ public function __invoke(ContainerInterface $container, $requestedName, array $options = null) { $formManager = $container->get('FormElementManager'); return new WriteController( $container->get(PostCommandInterface::class), $formManager->get(PostForm::class) ); } } The above factory introduces something new: the FormElementManager . This is a plugin manager implementation that is specifically for forms. We don't necessarily need to register our forms with it, as it will check to see if a requested instance is a form when attempting to pull one from it. However, it does provide a couple nice features: If the form or fieldset or element retrieved implements an init() method, it invokes that method after instantiation. This is useful, as that way we're initializing after we have all our dependencies injected, such as input filters. Our form and fieldset define this method! It ensures that the various plugin managers related to input validation are shared with the instance, a feature we'll be using later. Finally, we need to configure the new factory; in module/Blog/config/module.config.php , add an entry in the controllers configuration section: 'controllers' => [ 'factories' => [ Controller\\ListController::class => Factory\\ListControllerFactory::class, // Add the following line: Controller\\WriteController::class => Factory\\WriteControllerFactory::class, ], ], Now that we have the basics for our controller in place, we can create a route to it: <?php // In module/Blog/config/module.config.php: namespace Blog; use Laminas\\Router\\Http\\Literal; use Laminas\\Router\\Http\\Segment; use Laminas\\ServiceManager\\Factory\\InvokableFactory; return [ 'service_manager' => [ /* ... */ ], 'controllers' => [ /* ... */ ], 'router' => [ 'routes' => [ 'blog' => [ 'type' => Literal::class, 'options' => [ 'route' => '/blog', 'defaults' => [ 'controller' => Controller\\ListController::class, 'action' => 'index', ], ], 'may_terminate' => true, 'child_routes' => [ 'detail' => [ 'type' => Segment::class, 'options' => [ 'route' => '/:id', 'defaults' => [ 'action' => 'detail', ], 'constraints' => [ 'id' => '\\d+', ], ], ], // Add the following route: 'add' => [ 'type' => Literal::class, 'options' => [ 'route' => '/add', 'defaults' => [ 'controller' => Controller\\WriteController::class, 'action' => 'add', ], ], ], ], ], ], ], 'view_manager' => [ /* ... */ ], ]; Finally, we'll create a dummy template: <!-- Filename: module/Blog/view/blog/write/add.phtml --> <h1>WriteController::addAction()</h1> Check-in If you try to access the new route localhost:8080/blog/add you're supposed to see the following error message: An error occurred An error occurred during execution; please try again later. Additional information: Laminas\\ServiceManager\\Exception\\ServiceNotFoundException File: {projectPath}/vendor/laminas/laminas-servicemanager/src/ServiceManager.php:{lineNumber} Message: Unable to resolve service \"Blog\\Model\\PostCommandInterface\" to a factory; are you certain you provided it during configuration? If this is not the case, be sure to follow the tutorial correctly and carefully check all your files. The error is due to the fact that we have not yet defined an implementation of our PostCommandInterface , much less wired the implementation into our application! Let's create a dummy implementation, as we did when we first started working with repositories. Create the file module/Blog/src/Model/PostCommand.php with the following contents: <?php namespace Blog\\Model; class PostCommand implements PostCommandInterface { /** * {@inheritDoc} */ public function insertPost(Post $post) { } /** * {@inheritDoc} */ public function updatePost(Post $post) { } /** * {@inheritDoc} */ public function deletePost(Post $post) { } } Now add service configuration in module/Blog/config/module.config.php : 'service_manager' => [ 'aliases' => [ /* ... */ // Add the following line: Model\\PostCommandInterface::class => Model\\PostCommand::class, ], 'factories' => [ /* ... */ // Add the following line: Model\\PostCommand::class => InvokableFactory::class, ], ], Reloading your application now will yield you the desired result. Displaying the form Now that we have new controller working, it's time to pass this form to the view and render it. Change your controller so that the form is passed to the view: // In /module/Blog/src/Controller/WriteController.php: public function addAction() { return new ViewModel([ 'form' => $this->form, ]); } And then we need to modify our view to render the form: <!-- Filename: module/Blog/view/blog/write/add.phtml --> <h1>Add a blog post</h1> <?php $form = $this->form; $form->setAttribute('action', $this->url()); $form->prepare(); echo $this->form()->openTag($form); echo $this->formCollection($form); echo $this->form()->closeTag(); The above does the following: We set the action attribute of the form to the current URL. We \"prepare\" the form; this ensures any data or error messages bound to the form or its various elements are injected and ready to use for display purposes. We render an opening tag for the form we are using. We render the contents of the form, using the formCollection() view helper; this is a convenience method with some typically sane default markup. We'll be changing it momentarily. We render a closing tag for the form. Form method HTML forms can be sent using POST and GET . laminas-form defaults to POST . If you want to switch to GET : $form->setAttribute('method', 'GET'); Refreshing the browser you will now see your form properly displayed. It's not pretty, though, as the default markup does not follow semantics for Bootstrap (which is used in the skeleton application by default). Let's update it a bit to make it look better; we'll do that in the view script itself, as markup-related concerns belong in the view layer: <!-- Filename: module/Blog/view/blog/write/add.phtml --> <h1>Add a blog post</h1> <?php $form = $this->form; $form->setAttribute('action', $this->url()); $fieldset = $form->get('post'); $title = $fieldset->get('title'); $title->setAttribute('class', 'form-control'); $title->setAttribute('placeholder', 'Post title'); $text = $fieldset->get('text'); $text->setAttribute('class', 'form-control'); $text->setAttribute('placeholder', 'Post content'); $submit = $form->get('submit'); $submit->setAttribute('class', 'btn btn-primary'); $form->prepare(); echo $this->form()->openTag($form); ?> <fieldset> <div class=\"form-group\"> <?= $this->formLabel($title) ?> <?= $this->formElement($title) ?> <?= $this->formElementErrors()->render($title, ['class' => 'help-block']) ?> </div> <div class=\"form-group\"> <?= $this->formLabel($text) ?> <?= $this->formElement($text) ?> <?= $this->formElementErrors()->render($text, ['class' => 'help-block']) ?> </div> </fieldset> <?php echo $this->formSubmit($submit); echo $this->formHidden($fieldset->get('id')); echo $this->form()->closeTag(); The above adds HTML attributes to a number of the elements we've defined, and uses more specific view helpers to allow us to render the exact markup we want for our form. However, if we're submitting the form all we see is our form being displayed again. And this is due to the simple fact that we didn't add any logic to the controller yet. General form-handling logic for controllers Writing a controller that handles a form workflow follows the same basic pattern regardless of form and entities: You need to check if the HTTP request method is via POST , meaning if the form has been sent. If the form has been sent, you need to: pass the submitted data to your Form instance validate the Form instance If the form passes validation, you will: persist the form data redirect the user to either the detail page of the entered data, or to an overview page In all other cases, you need to display the form, potentially with error messages. Modify your WriteController:addAction() to read as follows: public function addAction() { $request = $this->getRequest(); $viewModel = new ViewModel(['form' => $this->form]); if (! $request->isPost()) { return $viewModel; } $this->form->setData($request->getPost()); if (! $this->form->isValid()) { return $viewModel; } $data = $this->form->getData()['post']; $post = new Post($data['title'], $data['text']); try { $post = $this->command->insertPost($post); } catch (\\Exception $ex) { // An exception occurred; we may want to log this later and/or // report it to the user. For now, we'll just re-throw. throw $ex; } return $this->redirect()->toRoute( 'blog/detail', ['id' => $post->getId()] ); } Stepping through the code: We retrieve the current request. We create a default view model containing the form. If we do not have a POST request, we return the default view model. We populate the form with data from the request. If the form is not valid, we return the default view model; at this point, the form will also contain error messages. We create a Post instance from the validated data. We attempt to insert the post. On success, we redirect to the post's detail page. Child route names When using the various url() helpers provided in laminas-mvc and laminas-view, you need to provide the name of a route. When using child routes, the route name is of the form <parent>/<child> — i.e., the parent name and child name are separated with a slash. Submitting the form right now will return into the following error Fatal error: Call to a member function getId() on null in {projectPath}/module/Blog/src/Controller/WriteController.php on line {lineNumber} This is because our stub PostCommand class does not return a new Post instance, violating the contract! Let's create a new implementation to work against laminas-db. Create the file module/Blog/src/Model/LaminasDbSqlCommand.php with the following contents: <?php namespace Blog\\Model; use RuntimeException; use Laminas\\Db\\Adapter\\AdapterInterface; use Laminas\\Db\\Adapter\\Driver\\ResultInterface; use Laminas\\Db\\Sql\\Delete; use Laminas\\Db\\Sql\\Insert; use Laminas\\Db\\Sql\\Sql; use Laminas\\Db\\Sql\\Update; class LaminasDbSqlCommand implements PostCommandInterface { /** * @var AdapterInterface */ private $db; /** * @param AdapterInterface $db */ public function __construct(AdapterInterface $db) { $this->db = $db; } /** * {@inheritDoc} */ public function insertPost(Post $post) { $insert = new Insert('posts'); $insert->values([ 'title' => $post->getTitle(), 'text' => $post->getText(), ]); $sql = new Sql($this->db); $statement = $sql->prepareStatementForSqlObject($insert); $result = $statement->execute(); if (! $result instanceof ResultInterface) { throw new RuntimeException( 'Database error occurred during blog post insert operation' ); } $id = $result->getGeneratedValue(); return new Post( $post->getTitle(), $post->getText(), $id ); } /** * {@inheritDoc} */ public function updatePost(Post $post) { } /** * {@inheritDoc} */ public function deletePost(Post $post) { } } In the insertPost() method, we do the following: We create a Laminas\\Db\\Sql\\Insert instance, providing it the table name. We add values to the Insert instance. We create a Laminas\\Db\\Sql\\Sql instance with the database adapter, and prepare a statement from our Insert instance. We execute the statement and check for a valid result. We marshal a return value. Now that we have this in place, we'll create a factory for it; create the file module/Blog/src/Factory/LaminasDbSqlCommandFactory.php with the following contents: <?php namespace Blog\\Factory; use Interop\\Container\\ContainerInterface; use Blog\\Model\\LaminasDbSqlCommand; use Laminas\\Db\\Adapter\\AdapterInterface; use Laminas\\ServiceManager\\Factory\\FactoryInterface; class LaminasDbSqlCommandFactory implements FactoryInterface { public function __invoke(ContainerInterface $container, $requestedName, array $options = null) { return new LaminasDbSqlCommand($container->get(AdapterInterface::class)); } } And finally, we'll wire it up in the configuration; update the service_manager section of module/Blog/config/module.config.php to read as follows: 'service_manager' => [ 'aliases' => [ Model\\PostRepositoryInterface::class => Model\\LaminasDbSqlRepository::class, // Update the following alias: Model\\PostCommandInterface::class => Model\\LaminasDbSqlCommand::class, ], 'factories' => [ Model\\PostRepository::class => InvokableFactory::class, Model\\LaminasDbSqlRepository::class => Factory\\LaminasDbSqlRepositoryFactory::class, Model\\PostCommand::class => InvokableFactory::class, // Add the following line: Model\\LaminasDbSqlCommand::class => Factory\\LaminasDbSqlCommandFactory::class, ], ], Submitting your form again, it should process the form and redirect you to the detail page for the new entry! Let's see if we can improve this a bit. Using laminas-hydrator with laminas-form In our controller currently, we have the following: $data = $this->form->getData()['post']; $post = new Post($data['title'], $data['text']); What if we could automate that, so we didn't need to worry about: Whether or not we're using a fieldset What the form fields are named Fortunately, laminas-form features integration with laminas-hydrator. This will allow us to return a Post instance when we retrieve the validated values! Let's udpate our fieldset to provide a hydrator and a prototype object. First, add two import statements to the top of the class file: // In module/Blog/src/Form/PostFieldset.php: use Blog\\Model\\Post; use Laminas\\Hydrator\\ReflectionHydrator; Next, update the init() method to add the following two lines: // In /module/Blog/src/Form/PostFieldset.php: public function init() { $this->setHydrator(new ReflectionHydrator()); $this->setObject(new Post('', '')); /* ... */ } When you grab the data from this fieldset, it will be returned as a Post instance. However, we grab data from the form ; how can we simplify that interaction? Since we only have the one fieldset, we'll set it as the form's base fieldset . This hints to the form that when we retrieve data from it, it should return the values from the specified fieldset instead; since our fieldset returns the Post instance, we'll have exactly what we need. Modify your PostForm class as follows: // In /module/Blog/src/Form/PostForm.php: public function init() { $this->add([ 'name' => 'post', 'type' => PostFieldset::class, 'options' => [ 'use_as_base_fieldset' => true, ], ]); /* ... */ Let's update our WriteController ; modify the addAction() method to replace the following two lines: $data = $this->form->getData()['post']; $post = new Post($data['title'], $data['text']); to: $post = $this->form->getData(); Everything should continue to work. The changes done serve the purpose of de-coupling the details of how the form is structured from the controller, allowing us to work directly with our entities at all times! Conclusion In this chapter, we've learned the fundamentals of using laminas-form, including adding fieldsets and elements, rendering the form, validating input, and wiring forms and fieldsets to use entities. In the next chapter we will finalize the CRUD functionality by creating the update and delete routines for the blog module.","title":"Making Use of Forms and Fieldsets"},{"location":"in-depth-guide/laminas-form-laminas-form-fieldset/#making-use-of-forms-and-fieldsets","text":"So far all we have done is read data from the database. In a real-life application, this won't get us very far, as we'll often need to support the full range of full Create , Read , Update and Delete operations (CRUD). Typically, new data will arrive via web form submissions.","title":"Making Use of Forms and Fieldsets"},{"location":"in-depth-guide/laminas-form-laminas-form-fieldset/#form-components","text":"The laminas-form and laminas-inputfilter components provide us with the ability to create fully-featured forms and their validation rules. laminas-form consumes laminas-inputfilter internally, so let's take a look at the elements of laminas-form that we will use for our application.","title":"Form components"},{"location":"in-depth-guide/laminas-form-laminas-form-fieldset/#creating-your-first-fieldset","text":"Explaining how laminas-form works is best done by giving you real code to work with. So let's jump right into it and create all the forms we need to finish our Blog module. We start by creating a Fieldset that contains all the input elements that we need to work with our blog data: You will need one hidden input for the id property, which is only needed for editting and deleting data. You will need one text input for the title property. You will need one textarea for the text property. Create the file module/Blog/src/Form/PostFieldset.php with the following contents: <?php namespace Blog\\Form; use Laminas\\Form\\Fieldset; class PostFieldset extends Fieldset { public function init() { $this->add([ 'type' => 'hidden', 'name' => 'id', ]); $this->add([ 'type' => 'text', 'name' => 'title', 'options' => [ 'label' => 'Post Title', ], ]); $this->add([ 'type' => 'textarea', 'name' => 'text', 'options' => [ 'label' => 'Post Text', ], ]); } } This new class creates an extension of Laminas\\Form\\Fieldset that, in an init() method (more on this later), adds elements for each aspect of our blog post. We can now re-use this fieldset in as many forms as we want. Let's create our first form.","title":"Creating your first Fieldset"},{"location":"in-depth-guide/laminas-form-laminas-form-fieldset/#creating-the-postform","text":"Now that we have our PostFieldset in place, we can use it inside a Form . The form will use the PostFieldset , and also include a submit button so that the user can submit the data. Create the file module/Blog/src/Form/PostForm.php with the following contents: <?php namespace Blog\\Form; use Laminas\\Form\\Form; class PostForm extends Form { public function init() { $this->add([ 'name' => 'post', 'type' => PostFieldset::class, ]); $this->add([ 'type' => 'submit', 'name' => 'submit', 'attributes' => [ 'value' => 'Insert new Post', ], ]); } } And that's our form. Nothing special here, we add our PostFieldset to the form, we add a submit button to the form, and nothing more.","title":"Creating the PostForm"},{"location":"in-depth-guide/laminas-form-laminas-form-fieldset/#adding-a-new-post","text":"Now that we have the PostForm written, it's time to use it. But there are a few more tasks left: We need to create a new controller WriteController which accepts the following instances via its constructor: a PostCommandInterface instance a PostForm instance We need to create an addAction() method in the new WriteController to handle displaying the form and processing it. We need to create a new route, blog/add , that routes to the WriteController and its addAction() method. We need to create a new view script to display the form.","title":"Adding a new Post"},{"location":"in-depth-guide/laminas-form-laminas-form-fieldset/#displaying-the-form","text":"Now that we have new controller working, it's time to pass this form to the view and render it. Change your controller so that the form is passed to the view: // In /module/Blog/src/Controller/WriteController.php: public function addAction() { return new ViewModel([ 'form' => $this->form, ]); } And then we need to modify our view to render the form: <!-- Filename: module/Blog/view/blog/write/add.phtml --> <h1>Add a blog post</h1> <?php $form = $this->form; $form->setAttribute('action', $this->url()); $form->prepare(); echo $this->form()->openTag($form); echo $this->formCollection($form); echo $this->form()->closeTag(); The above does the following: We set the action attribute of the form to the current URL. We \"prepare\" the form; this ensures any data or error messages bound to the form or its various elements are injected and ready to use for display purposes. We render an opening tag for the form we are using. We render the contents of the form, using the formCollection() view helper; this is a convenience method with some typically sane default markup. We'll be changing it momentarily. We render a closing tag for the form.","title":"Displaying the form"},{"location":"in-depth-guide/laminas-form-laminas-form-fieldset/#general-form-handling-logic-for-controllers","text":"Writing a controller that handles a form workflow follows the same basic pattern regardless of form and entities: You need to check if the HTTP request method is via POST , meaning if the form has been sent. If the form has been sent, you need to: pass the submitted data to your Form instance validate the Form instance If the form passes validation, you will: persist the form data redirect the user to either the detail page of the entered data, or to an overview page In all other cases, you need to display the form, potentially with error messages. Modify your WriteController:addAction() to read as follows: public function addAction() { $request = $this->getRequest(); $viewModel = new ViewModel(['form' => $this->form]); if (! $request->isPost()) { return $viewModel; } $this->form->setData($request->getPost()); if (! $this->form->isValid()) { return $viewModel; } $data = $this->form->getData()['post']; $post = new Post($data['title'], $data['text']); try { $post = $this->command->insertPost($post); } catch (\\Exception $ex) { // An exception occurred; we may want to log this later and/or // report it to the user. For now, we'll just re-throw. throw $ex; } return $this->redirect()->toRoute( 'blog/detail', ['id' => $post->getId()] ); } Stepping through the code: We retrieve the current request. We create a default view model containing the form. If we do not have a POST request, we return the default view model. We populate the form with data from the request. If the form is not valid, we return the default view model; at this point, the form will also contain error messages. We create a Post instance from the validated data. We attempt to insert the post. On success, we redirect to the post's detail page.","title":"General form-handling logic for controllers"},{"location":"in-depth-guide/laminas-form-laminas-form-fieldset/#using-laminas-hydrator-with-laminas-form","text":"In our controller currently, we have the following: $data = $this->form->getData()['post']; $post = new Post($data['title'], $data['text']); What if we could automate that, so we didn't need to worry about: Whether or not we're using a fieldset What the form fields are named Fortunately, laminas-form features integration with laminas-hydrator. This will allow us to return a Post instance when we retrieve the validated values! Let's udpate our fieldset to provide a hydrator and a prototype object. First, add two import statements to the top of the class file: // In module/Blog/src/Form/PostFieldset.php: use Blog\\Model\\Post; use Laminas\\Hydrator\\ReflectionHydrator; Next, update the init() method to add the following two lines: // In /module/Blog/src/Form/PostFieldset.php: public function init() { $this->setHydrator(new ReflectionHydrator()); $this->setObject(new Post('', '')); /* ... */ } When you grab the data from this fieldset, it will be returned as a Post instance. However, we grab data from the form ; how can we simplify that interaction? Since we only have the one fieldset, we'll set it as the form's base fieldset . This hints to the form that when we retrieve data from it, it should return the values from the specified fieldset instead; since our fieldset returns the Post instance, we'll have exactly what we need. Modify your PostForm class as follows: // In /module/Blog/src/Form/PostForm.php: public function init() { $this->add([ 'name' => 'post', 'type' => PostFieldset::class, 'options' => [ 'use_as_base_fieldset' => true, ], ]); /* ... */ Let's update our WriteController ; modify the addAction() method to replace the following two lines: $data = $this->form->getData()['post']; $post = new Post($data['title'], $data['text']); to: $post = $this->form->getData(); Everything should continue to work. The changes done serve the purpose of de-coupling the details of how the form is structured from the controller, allowing us to work directly with our entities at all times!","title":"Using laminas-hydrator with laminas-form"},{"location":"in-depth-guide/laminas-form-laminas-form-fieldset/#conclusion","text":"In this chapter, we've learned the fundamentals of using laminas-form, including adding fieldsets and elements, rendering the form, validating input, and wiring forms and fieldsets to use entities. In the next chapter we will finalize the CRUD functionality by creating the update and delete routines for the blog module.","title":"Conclusion"},{"location":"in-depth-guide/models-and-servicemanager/","text":"Models and the ServiceManager In the previous chapter we've learned how to create a \"Hello World\" Application using laminas-mvc. This is a good start, but the application itself doesn't really do anything. In this chapter we will introduce you into the concept of models, and with this, introduce laminas-servicemanager. What is a Model? A model encapsulates application logic. This often entails entity or value objects representing specific things in our model, and repositories for retrieving and updating these objects. For what we're trying to accomplish with our Blog module, this means that we need functionality for retrieving and saving blog posts. The posts themselves are our entities, and the repository will be what we retrieve them from and save them with. The model will get its data from some source; when writing the model, we don't really care about what the source actually is. The model will be written against an interface that we define and that future data providers must implement. Writing the PostRepository When writing a repository, it is a common best-practice to define an interface first. Interfaces are a good way to ensure that other programmers can easily build their own implementations. In other words, they can write classes with identical function names, but which internally do completely different things, while producing the same expected results. In our case, we want to create a PostRepository . This means first we are going to define a PostRepositoryInterface . The task of our repository is to provide us with data from our blog posts. For now, we are going to focus on the read-only side of things: we will define a method that will give us all posts, and another method that will give us a single post. Let's start by creating the interface at module/Blog/src/Model/PostRepositoryInterface.php namespace Blog\\Model; interface PostRepositoryInterface { /** * Return a set of all blog posts that we can iterate over. * * Each entry should be a Post instance. * * @return Post[] */ public function findAllPosts(); /** * Return a single blog post. * * @param int $id Identifier of the post to return. * @return Post */ public function findPost($id); } The first method, findAllPosts() , will return all posts, and the second method, findPost($id) , will return the post matching the given identifier $id . What's new in here is the fact that we actually define a return value - Post - that doesn't exist yet. We will define this Post class at a later point; for now, we will create the PostRepository class. Create the class PostRepository at module/Blog/src/Model/PostRepository.php ; be sure to implement the PostRepositoryInterface and its required method (we will fill these in later). You then should have a class that looks like the following: namespace Blog\\Model; class PostRepository implements PostRepositoryInterface { /** * {@inheritDoc} */ public function findAllPosts() { // TODO: Implement findAllPosts() method. } /** * {@inheritDoc} */ public function findPost($id) { // TODO: Implement findPost() method. } } Create an entity Since our PostRepository will return Post instances, we must create that class, too. Let's create module/Blog/src/Model/Post.php : namespace Blog\\Model; class Post { /** * @var int */ private $id; /** * @var string */ private $text; /** * @var string */ private $title; /** * @param string $title * @param string $text * @param int|null $id */ public function __construct($title, $text, $id = null) { $this->title = $title; $this->text = $text; $this->id = $id; } /** * @return int|null */ public function getId() { return $this->id; } /** * @return string */ public function getText() { return $this->text; } /** * @return string */ public function getTitle() { return $this->title; } } Notice that we only created getter methods; this is because each instance should be unchangeable, allowing us to cache instances in the repository as necessary. Bringing Life into our PostRepository Now that we have our entity in place, we can bring life into our PostRepository class. To keep the repository easy to understand, for now we will only return some hard-coded content from our PostRepository class directly. Create a property inside the PostRepository class called $data and make this an array of our Post type. Edit PostRepository as follows: namespace Blog\\Model; class PostRepository implements PostRepositoryInterface { private $data = [ 1 => [ 'id' => 1, 'title' => 'Hello World #1', 'text' => 'This is our first blog post!', ], 2 => [ 'id' => 2, 'title' => 'Hello World #2', 'text' => 'This is our second blog post!', ], 3 => [ 'id' => 3, 'title' => 'Hello World #3', 'text' => 'This is our third blog post!', ], 4 => [ 'id' => 4, 'title' => 'Hello World #4', 'text' => 'This is our fourth blog post!', ], 5 => [ 'id' => 5, 'title' => 'Hello World #5', 'text' => 'This is our fifth blog post!', ], ]; /** * {@inheritDoc} */ public function findAllPosts() { // TODO: Implement findAllPosts() method. } /** * {@inheritDoc} */ public function findPost($id) { // TODO: Implement findPost() method. } } Now that we have some data, let's modify our find*() functions to return the appropriate entities: namespace Blog\\Model; use DomainException; class PostRepository implements PostRepositoryInterface { private $data = [ 1 => [ 'id' => 1, 'title' => 'Hello World #1', 'text' => 'This is our first blog post!', ], 2 => [ 'id' => 2, 'title' => 'Hello World #2', 'text' => 'This is our second blog post!', ], 3 => [ 'id' => 3, 'title' => 'Hello World #3', 'text' => 'This is our third blog post!', ], 4 => [ 'id' => 4, 'title' => 'Hello World #4', 'text' => 'This is our fourth blog post!', ], 5 => [ 'id' => 5, 'title' => 'Hello World #5', 'text' => 'This is our fifth blog post!', ], ]; /** * {@inheritDoc} */ public function findAllPosts() { return array_map(function ($post) { return new Post( $post['title'], $post['text'], $post['id'] ); }, $this->data); } /** * {@inheritDoc} */ public function findPost($id) { if (! isset($this->data[$id])) { throw new DomainException(sprintf('Post by id \"%s\" not found', $id)); } return new Post( $this->data[$id]['title'], $this->data[$id]['text'], $this->data[$id]['id'] ); } } Both methods now have appropriate return values. Please note that from a technical point of view, the current implementation is far from perfect. We will improve this repository in the future, but for now we have a working repository that is able to give us some data in a way that is defined by our PostRepositoryInterface . Bringing the Service into the Controller Now that we have our PostRepository written, we want to get access to this repository in our controllers. For this task, we will step into a new topic called \"Dependency Injection\" (DI). When we're talking about dependency injection, we're talking about a way to get dependencies into our classes. The most common form, \"Constructor Injection\", is used for all dependencies that are required by a class at all times. In our case, we want to have our ListController somehow interact with our PostRepository . This means that the class PostRepository is a dependency of the class ListController ; without the PostRepository , our ListController will not be able to function properly. To make sure that our ListController will always get the appropriate dependency, we will first define the dependency inside the ListController constructor. Modify ListController as follows: namespace Blog\\Controller; use Blog\\Model\\PostRepositoryInterface; use Laminas\\Mvc\\Controller\\AbstractActionController; class ListController extends AbstractActionController { /** * @var PostRepositoryInterface */ private $postRepository; public function __construct(PostRepositoryInterface $postRepository) { $this->postRepository = $postRepository; } } The constructor now has a required argument; we will not be able to create instances of this class anymore without providing a PostRepositoryInterface implementation. If you were to go back to your browser and reload your project with the url localhost:8080/blog , you'd see the following error message: Catchable fatal error: Argument 1 passed to Blog\\Controller\\ListController::__construct() must be an instance of Blog\\Model\\PostRepositoryInterface, none given, called in {projectPath}/vendor/laminas/src/Factory/InvokableFactory.php on line {lineNumber} and defined in {projectPath}/module/Blog/src/Controller/ListController.php on line {lineNumber} And this error message is expected. It tells you exactly that our ListController expects to be passed an implementation of the PostRepositoryInterface . So how do we make sure that our ListController will receive such an implementation? To solve this, we need to tell the application how to create instances of the Blog\\Controller\\ListController . If you remember back to when we created the controller, we mapped it to the InvokableFactory in the module configuration: // In module/Blog/config/module.config.php: namespace Blog; use Laminas\\ServiceManager\\Factory\\InvokableFactory; return [ 'controllers' => [ 'factories' => [ Controller\\ListController::class => InvokableFactory::class, ], ], 'router' => [ /** Router Config */ ] 'view_manager' => [ /** ViewManager Config */ ], ); The InvokableFactory instantiates the mapped class using no constructor arguments. Since our ListController now has a required argument, we need to change this. We will now create a custom factory for our ListController . First, update the configuration as follows: // In module/Blog/config/module.config.php: namespace Blog; // Remove the InvokableFactory import statement return [ 'controllers' => [ 'factories' => [ // Update the following line: Controller\\ListController::class => Factory\\ListControllerFactory::class, ], ], 'router' => [ /** Router Config */ ] 'view_manager' => [ /** ViewManager Config */ ], ); The above changes the mapping for the ListController to use a new factory class we'll be creating, Blog\\Factory\\ListControllerFactory . If you refresh your browser you'll see a different error message: An error occurred An error occurred during execution; please try again later. Additional information: Laminas\\ServiceManager\\Exception\\ServiceNotFoundException File: {projectPath}/laminas/laminas-servicemanager/src/ServiceManager.php:{lineNumber} Message: Unable to resolve service \"Blog\\Controller\\ListController\" to a factory; are you certain you provided it during configuration? This exception message indicates that the service container could not resolve the service to a factory, and asks if we provided it during configuration. We did, so the end result is that the factory must not exist. Let's write the factory now. Writing a Factory Class Factory classes for laminas-servicemanager may implement either Laminas\\ServiceManager\\Factory\\FactoryInterface , or be callable classes (classes that implement the __invoke() method); FactoryInterface itself defines the __invoke() method. The first argument is the application container, and is required; if you implement the FactoryInterface , you must also define a second argument, $requestedName , which is the service name mapping to the factory, and an optional third argument, $options , which will be any options provided by the controller manager at instantiation. In most situations, the last argument can be ignored; however, you can create re-usable factories by implementing the second argument, so this is a good one to consider when writing your factories! For our purposes, this is a one-off factory, so we'll only use the first argument. Let's implement our factory class: // In /module/Blog/src/Factory/ListControllerFactory.php: namespace Blog\\Factory; use Blog\\Controller\\ListController; use Blog\\Model\\PostRepositoryInterface; use Interop\\Container\\ContainerInterface; use Laminas\\ServiceManager\\Factory\\FactoryInterface; class ListControllerFactory implements FactoryInterface { /** * @param ContainerInterface $container * @param string $requestedName * @param null|array $options * @return ListController */ public function __invoke(ContainerInterface $container, $requestedName, array $options = null) { return new ListController($container->get(PostRepositoryInterface::class)); } } The factory receives an instance of the application container, which, in our case, is a Laminas\\ServiceManager\\ServiceManager instance. The container also conforms to Interop\\Container\\ContainerInterface , allowing re-use in other dependency injection systems if desired. We pull a service matching the PostRepositoryInterface fully qualified class name and pass it directly to the controller's constructor. There's no magic happening; it's just PHP code. Refresh your browser and you will see this error message: An error occurred An error occurred during execution; please try again later. Additional information: Laminas\\ServiceManager\\Exception\\ServiceNotFoundException File: {projectPath}/vendor/laminas/laminas-servicemanager/src/ServiceManager.php:{lineNumber} Message: Unable to resolve service \"Blog\\Model\\PostRepositoryInterface\" to a factory; are you certain you provided it during configuration? Exactly what we expected. Within our factory, the service Blog\\Model\\PostRepositoryInterface is requested but the ServiceManager doesn't know about it yet. Therefore it isn't able to create an instance for the requested name. Registering Services Registering other services follows the same pattern as registering a controller. We will modify our module.config.php and add a new key called service_manager ; the configuration of this key is the same as that for the controllers key. We will add two entries, one for aliases and one for factories , as follows: // In module/Blog/config/module.config.php namespace Blog; // Re-add the following import: use Laminas\\ServiceManager\\Factory\\InvokableFactory; return [ // Add this section: 'service_manager' => [ 'aliases' => [ Model\\PostRepositoryInterface::class => Model\\PostRepository::class, ], 'factories' => [ Model\\PostRepository::class => InvokableFactory::class, ], ], 'controllers' => [ /** Controller Config */ ], 'router' => [ /** Router Config */ ], 'view_manager' => [ /** View Manager Config */ ], ]; This aliases PostRepositoryInterface to our PostRepository implementation, and then creates a factory for the PostRepository class by mapping it to the InvokableFactory (like we originally did for the ListController ); we can do this as our PostRepository implementation has no dependencies of its own. Aliasing services In laminas-servicemanager, when you request a service by an alias you get the service it is mapped to. So when you request Model\\PostRepositoryInterface::class you get the PostRepository class using its fully qualified class name (FQCN). We often alias an interface to an implementation service, as that allows the user to indicate they want an implementation of the interface, but do not care which implementation. For more information see the laminas-servicemanager Aliases documentation . Try refreshing your browser. You should see no more error messages, but rather exactly the page that we have created in the previous chapter of the tutorial. Using the repository in our controller Let's now use the PostRepository within our ListController . For this we will need to overwrite the default indexAction() and return a view with the results from the PostRepository . Modify ListController as follows: // In module/Blog/src/Controller/ListController.php: namespace Blog\\Controller; use Blog\\Model\\PostRepositoryInterface; use Laminas\\Mvc\\Controller\\AbstractActionController; // Add the following import statement: use Laminas\\View\\Model\\ViewModel; class ListController extends AbstractActionController { /** * @var PostRepositoryInterface */ private $postRepository; public function __construct(PostRepositoryInterface $postRepository) { $this->postRepository = $postRepository; } // Add the following method: public function indexAction() { return new ViewModel([ 'posts' => $this->postRepository->findAllPosts(), ]); } } First, please note that our controller imported another class, Laminas\\View\\Model\\ViewModel ; this is what controllers will usually return within laminas-mvc applications. ViewModel instances allow you to provide variables to render within your template, as well as indicate which template to use. In this case we have assigned a variable called $posts with the value of whatever the repository method findAllPosts() returns (an array of Post instances). Refreshing the browser won't change anything yet because we haven't updated our template to display the data. ViewModels are not required You do not actually need to return an instance of ViewModel ; when you return a normal PHP array, laminas-mvc internally converts it into a ViewModel . The following are equivalent: // Explicit ViewModel: return new ViewModel(['foo' => 'bar']); // Implicit ViewModel: return ['foo' => 'bar']; Accessing View Variables Let's modify our view to display a table of all blog posts that our repository returns: <!-- Filename: module/Blog/view/blog/list/index.phtml --> <h1>Blog</h1> <?php foreach ($this->posts as $post): ?> <article> <h1 id=\"post<?= $post->getId() ?>\"><?= $post->getTitle() ?></h1> <p><?= $post->getText() ?></p> </article> <?php endforeach ?> In the view script, we iterate over the posts passed to the view model. Since every single entry of our array is of type Blog\\Model\\Post , we can use its getter methods and render it. Instance Variables Vs Script Variables By default, all variables passed via a view model to the renderer are imported directly into the view script, and can therefore be referenced as either instance or script variables (i.e., $this->posts is the same as $posts ). However, we recommend to reference any variables defined as part of the original view model using instance variable notation ( $this->posts ), to make it clear where they originate, and to only use script variable notation ( $posts ) for variables defined in the script itself. After saving this file, refresh your browser, and you should now see a list of blog entries! Summary In this chapter, we learned: An approach to building the models for an application. A little bit about dependency injection. How to use laminas-servicemanager to implement dependency injection in laminas-mvc applications. How to pass variables to view scripts from the controller. In the next chapter, we will take a first look at the things we should do when we want to get data from a database.","title":"Models and the ServiceManager"},{"location":"in-depth-guide/models-and-servicemanager/#models-and-the-servicemanager","text":"In the previous chapter we've learned how to create a \"Hello World\" Application using laminas-mvc. This is a good start, but the application itself doesn't really do anything. In this chapter we will introduce you into the concept of models, and with this, introduce laminas-servicemanager.","title":"Models and the ServiceManager"},{"location":"in-depth-guide/models-and-servicemanager/#what-is-a-model","text":"A model encapsulates application logic. This often entails entity or value objects representing specific things in our model, and repositories for retrieving and updating these objects. For what we're trying to accomplish with our Blog module, this means that we need functionality for retrieving and saving blog posts. The posts themselves are our entities, and the repository will be what we retrieve them from and save them with. The model will get its data from some source; when writing the model, we don't really care about what the source actually is. The model will be written against an interface that we define and that future data providers must implement.","title":"What is a Model?"},{"location":"in-depth-guide/models-and-servicemanager/#writing-the-postrepository","text":"When writing a repository, it is a common best-practice to define an interface first. Interfaces are a good way to ensure that other programmers can easily build their own implementations. In other words, they can write classes with identical function names, but which internally do completely different things, while producing the same expected results. In our case, we want to create a PostRepository . This means first we are going to define a PostRepositoryInterface . The task of our repository is to provide us with data from our blog posts. For now, we are going to focus on the read-only side of things: we will define a method that will give us all posts, and another method that will give us a single post. Let's start by creating the interface at module/Blog/src/Model/PostRepositoryInterface.php namespace Blog\\Model; interface PostRepositoryInterface { /** * Return a set of all blog posts that we can iterate over. * * Each entry should be a Post instance. * * @return Post[] */ public function findAllPosts(); /** * Return a single blog post. * * @param int $id Identifier of the post to return. * @return Post */ public function findPost($id); } The first method, findAllPosts() , will return all posts, and the second method, findPost($id) , will return the post matching the given identifier $id . What's new in here is the fact that we actually define a return value - Post - that doesn't exist yet. We will define this Post class at a later point; for now, we will create the PostRepository class. Create the class PostRepository at module/Blog/src/Model/PostRepository.php ; be sure to implement the PostRepositoryInterface and its required method (we will fill these in later). You then should have a class that looks like the following: namespace Blog\\Model; class PostRepository implements PostRepositoryInterface { /** * {@inheritDoc} */ public function findAllPosts() { // TODO: Implement findAllPosts() method. } /** * {@inheritDoc} */ public function findPost($id) { // TODO: Implement findPost() method. } }","title":"Writing the PostRepository"},{"location":"in-depth-guide/models-and-servicemanager/#create-an-entity","text":"Since our PostRepository will return Post instances, we must create that class, too. Let's create module/Blog/src/Model/Post.php : namespace Blog\\Model; class Post { /** * @var int */ private $id; /** * @var string */ private $text; /** * @var string */ private $title; /** * @param string $title * @param string $text * @param int|null $id */ public function __construct($title, $text, $id = null) { $this->title = $title; $this->text = $text; $this->id = $id; } /** * @return int|null */ public function getId() { return $this->id; } /** * @return string */ public function getText() { return $this->text; } /** * @return string */ public function getTitle() { return $this->title; } } Notice that we only created getter methods; this is because each instance should be unchangeable, allowing us to cache instances in the repository as necessary.","title":"Create an entity"},{"location":"in-depth-guide/models-and-servicemanager/#bringing-life-into-our-postrepository","text":"Now that we have our entity in place, we can bring life into our PostRepository class. To keep the repository easy to understand, for now we will only return some hard-coded content from our PostRepository class directly. Create a property inside the PostRepository class called $data and make this an array of our Post type. Edit PostRepository as follows: namespace Blog\\Model; class PostRepository implements PostRepositoryInterface { private $data = [ 1 => [ 'id' => 1, 'title' => 'Hello World #1', 'text' => 'This is our first blog post!', ], 2 => [ 'id' => 2, 'title' => 'Hello World #2', 'text' => 'This is our second blog post!', ], 3 => [ 'id' => 3, 'title' => 'Hello World #3', 'text' => 'This is our third blog post!', ], 4 => [ 'id' => 4, 'title' => 'Hello World #4', 'text' => 'This is our fourth blog post!', ], 5 => [ 'id' => 5, 'title' => 'Hello World #5', 'text' => 'This is our fifth blog post!', ], ]; /** * {@inheritDoc} */ public function findAllPosts() { // TODO: Implement findAllPosts() method. } /** * {@inheritDoc} */ public function findPost($id) { // TODO: Implement findPost() method. } } Now that we have some data, let's modify our find*() functions to return the appropriate entities: namespace Blog\\Model; use DomainException; class PostRepository implements PostRepositoryInterface { private $data = [ 1 => [ 'id' => 1, 'title' => 'Hello World #1', 'text' => 'This is our first blog post!', ], 2 => [ 'id' => 2, 'title' => 'Hello World #2', 'text' => 'This is our second blog post!', ], 3 => [ 'id' => 3, 'title' => 'Hello World #3', 'text' => 'This is our third blog post!', ], 4 => [ 'id' => 4, 'title' => 'Hello World #4', 'text' => 'This is our fourth blog post!', ], 5 => [ 'id' => 5, 'title' => 'Hello World #5', 'text' => 'This is our fifth blog post!', ], ]; /** * {@inheritDoc} */ public function findAllPosts() { return array_map(function ($post) { return new Post( $post['title'], $post['text'], $post['id'] ); }, $this->data); } /** * {@inheritDoc} */ public function findPost($id) { if (! isset($this->data[$id])) { throw new DomainException(sprintf('Post by id \"%s\" not found', $id)); } return new Post( $this->data[$id]['title'], $this->data[$id]['text'], $this->data[$id]['id'] ); } } Both methods now have appropriate return values. Please note that from a technical point of view, the current implementation is far from perfect. We will improve this repository in the future, but for now we have a working repository that is able to give us some data in a way that is defined by our PostRepositoryInterface .","title":"Bringing Life into our PostRepository"},{"location":"in-depth-guide/models-and-servicemanager/#bringing-the-service-into-the-controller","text":"Now that we have our PostRepository written, we want to get access to this repository in our controllers. For this task, we will step into a new topic called \"Dependency Injection\" (DI). When we're talking about dependency injection, we're talking about a way to get dependencies into our classes. The most common form, \"Constructor Injection\", is used for all dependencies that are required by a class at all times. In our case, we want to have our ListController somehow interact with our PostRepository . This means that the class PostRepository is a dependency of the class ListController ; without the PostRepository , our ListController will not be able to function properly. To make sure that our ListController will always get the appropriate dependency, we will first define the dependency inside the ListController constructor. Modify ListController as follows: namespace Blog\\Controller; use Blog\\Model\\PostRepositoryInterface; use Laminas\\Mvc\\Controller\\AbstractActionController; class ListController extends AbstractActionController { /** * @var PostRepositoryInterface */ private $postRepository; public function __construct(PostRepositoryInterface $postRepository) { $this->postRepository = $postRepository; } } The constructor now has a required argument; we will not be able to create instances of this class anymore without providing a PostRepositoryInterface implementation. If you were to go back to your browser and reload your project with the url localhost:8080/blog , you'd see the following error message: Catchable fatal error: Argument 1 passed to Blog\\Controller\\ListController::__construct() must be an instance of Blog\\Model\\PostRepositoryInterface, none given, called in {projectPath}/vendor/laminas/src/Factory/InvokableFactory.php on line {lineNumber} and defined in {projectPath}/module/Blog/src/Controller/ListController.php on line {lineNumber} And this error message is expected. It tells you exactly that our ListController expects to be passed an implementation of the PostRepositoryInterface . So how do we make sure that our ListController will receive such an implementation? To solve this, we need to tell the application how to create instances of the Blog\\Controller\\ListController . If you remember back to when we created the controller, we mapped it to the InvokableFactory in the module configuration: // In module/Blog/config/module.config.php: namespace Blog; use Laminas\\ServiceManager\\Factory\\InvokableFactory; return [ 'controllers' => [ 'factories' => [ Controller\\ListController::class => InvokableFactory::class, ], ], 'router' => [ /** Router Config */ ] 'view_manager' => [ /** ViewManager Config */ ], ); The InvokableFactory instantiates the mapped class using no constructor arguments. Since our ListController now has a required argument, we need to change this. We will now create a custom factory for our ListController . First, update the configuration as follows: // In module/Blog/config/module.config.php: namespace Blog; // Remove the InvokableFactory import statement return [ 'controllers' => [ 'factories' => [ // Update the following line: Controller\\ListController::class => Factory\\ListControllerFactory::class, ], ], 'router' => [ /** Router Config */ ] 'view_manager' => [ /** ViewManager Config */ ], ); The above changes the mapping for the ListController to use a new factory class we'll be creating, Blog\\Factory\\ListControllerFactory . If you refresh your browser you'll see a different error message: An error occurred An error occurred during execution; please try again later. Additional information: Laminas\\ServiceManager\\Exception\\ServiceNotFoundException File: {projectPath}/laminas/laminas-servicemanager/src/ServiceManager.php:{lineNumber} Message: Unable to resolve service \"Blog\\Controller\\ListController\" to a factory; are you certain you provided it during configuration? This exception message indicates that the service container could not resolve the service to a factory, and asks if we provided it during configuration. We did, so the end result is that the factory must not exist. Let's write the factory now.","title":"Bringing the Service into the Controller"},{"location":"in-depth-guide/models-and-servicemanager/#writing-a-factory-class","text":"Factory classes for laminas-servicemanager may implement either Laminas\\ServiceManager\\Factory\\FactoryInterface , or be callable classes (classes that implement the __invoke() method); FactoryInterface itself defines the __invoke() method. The first argument is the application container, and is required; if you implement the FactoryInterface , you must also define a second argument, $requestedName , which is the service name mapping to the factory, and an optional third argument, $options , which will be any options provided by the controller manager at instantiation. In most situations, the last argument can be ignored; however, you can create re-usable factories by implementing the second argument, so this is a good one to consider when writing your factories! For our purposes, this is a one-off factory, so we'll only use the first argument. Let's implement our factory class: // In /module/Blog/src/Factory/ListControllerFactory.php: namespace Blog\\Factory; use Blog\\Controller\\ListController; use Blog\\Model\\PostRepositoryInterface; use Interop\\Container\\ContainerInterface; use Laminas\\ServiceManager\\Factory\\FactoryInterface; class ListControllerFactory implements FactoryInterface { /** * @param ContainerInterface $container * @param string $requestedName * @param null|array $options * @return ListController */ public function __invoke(ContainerInterface $container, $requestedName, array $options = null) { return new ListController($container->get(PostRepositoryInterface::class)); } } The factory receives an instance of the application container, which, in our case, is a Laminas\\ServiceManager\\ServiceManager instance. The container also conforms to Interop\\Container\\ContainerInterface , allowing re-use in other dependency injection systems if desired. We pull a service matching the PostRepositoryInterface fully qualified class name and pass it directly to the controller's constructor. There's no magic happening; it's just PHP code. Refresh your browser and you will see this error message: An error occurred An error occurred during execution; please try again later. Additional information: Laminas\\ServiceManager\\Exception\\ServiceNotFoundException File: {projectPath}/vendor/laminas/laminas-servicemanager/src/ServiceManager.php:{lineNumber} Message: Unable to resolve service \"Blog\\Model\\PostRepositoryInterface\" to a factory; are you certain you provided it during configuration? Exactly what we expected. Within our factory, the service Blog\\Model\\PostRepositoryInterface is requested but the ServiceManager doesn't know about it yet. Therefore it isn't able to create an instance for the requested name.","title":"Writing a Factory Class"},{"location":"in-depth-guide/models-and-servicemanager/#registering-services","text":"Registering other services follows the same pattern as registering a controller. We will modify our module.config.php and add a new key called service_manager ; the configuration of this key is the same as that for the controllers key. We will add two entries, one for aliases and one for factories , as follows: // In module/Blog/config/module.config.php namespace Blog; // Re-add the following import: use Laminas\\ServiceManager\\Factory\\InvokableFactory; return [ // Add this section: 'service_manager' => [ 'aliases' => [ Model\\PostRepositoryInterface::class => Model\\PostRepository::class, ], 'factories' => [ Model\\PostRepository::class => InvokableFactory::class, ], ], 'controllers' => [ /** Controller Config */ ], 'router' => [ /** Router Config */ ], 'view_manager' => [ /** View Manager Config */ ], ]; This aliases PostRepositoryInterface to our PostRepository implementation, and then creates a factory for the PostRepository class by mapping it to the InvokableFactory (like we originally did for the ListController ); we can do this as our PostRepository implementation has no dependencies of its own.","title":"Registering Services"},{"location":"in-depth-guide/models-and-servicemanager/#using-the-repository-in-our-controller","text":"Let's now use the PostRepository within our ListController . For this we will need to overwrite the default indexAction() and return a view with the results from the PostRepository . Modify ListController as follows: // In module/Blog/src/Controller/ListController.php: namespace Blog\\Controller; use Blog\\Model\\PostRepositoryInterface; use Laminas\\Mvc\\Controller\\AbstractActionController; // Add the following import statement: use Laminas\\View\\Model\\ViewModel; class ListController extends AbstractActionController { /** * @var PostRepositoryInterface */ private $postRepository; public function __construct(PostRepositoryInterface $postRepository) { $this->postRepository = $postRepository; } // Add the following method: public function indexAction() { return new ViewModel([ 'posts' => $this->postRepository->findAllPosts(), ]); } } First, please note that our controller imported another class, Laminas\\View\\Model\\ViewModel ; this is what controllers will usually return within laminas-mvc applications. ViewModel instances allow you to provide variables to render within your template, as well as indicate which template to use. In this case we have assigned a variable called $posts with the value of whatever the repository method findAllPosts() returns (an array of Post instances). Refreshing the browser won't change anything yet because we haven't updated our template to display the data.","title":"Using the repository in our controller"},{"location":"in-depth-guide/models-and-servicemanager/#accessing-view-variables","text":"Let's modify our view to display a table of all blog posts that our repository returns: <!-- Filename: module/Blog/view/blog/list/index.phtml --> <h1>Blog</h1> <?php foreach ($this->posts as $post): ?> <article> <h1 id=\"post<?= $post->getId() ?>\"><?= $post->getTitle() ?></h1> <p><?= $post->getText() ?></p> </article> <?php endforeach ?> In the view script, we iterate over the posts passed to the view model. Since every single entry of our array is of type Blog\\Model\\Post , we can use its getter methods and render it.","title":"Accessing View Variables"},{"location":"in-depth-guide/models-and-servicemanager/#summary","text":"In this chapter, we learned: An approach to building the models for an application. A little bit about dependency injection. How to use laminas-servicemanager to implement dependency injection in laminas-mvc applications. How to pass variables to view scripts from the controller. In the next chapter, we will take a first look at the things we should do when we want to get data from a database.","title":"Summary"},{"location":"in-depth-guide/preparing-databases/","text":"Preparing for Different Databases In the previous chapter, we created a PostRepository that returns some data from blog posts. While the implementation was adequate for learning purposes, it is quite impractical for real world applications; no one would want to modify the source files each time a new post is added! Fortunately, we can always turn to databases for the actual storage of posts; all we need to learn is how to interact with databases within our application. There's one small catch: there are many database backend systems, including relational databases, documentent databases, key/value stores, and graph databases. You may be inclined to code directly to the solution that fits your application's immediate needs, but it is a better practice to create another layer in front of the actual database access that abstracts the database interaction. The repository approach we used in the previous chapter is one such approach, primarily geared towards queries . In this section, we'll expand on it to add command capabilities for creating, updating, and deleting records. What is database abstraction? \"Database abstraction\" is the act of providing a common interface for all database interactions. Consider a SQL and a NoSQL database; both have methods for CRUD (Create, Read, Update, Delete) operations. For example, to query the database against a given row in MySQL you might use $results = mysqli_query('SELECT foo FROM bar')`; However, for MongoDB, for example you'd use something like: $results = $mongoDbClient->app->bar->find([], ['foo' => 1, '_id' => 0])`; Both engines would give you the same result, but the execution is different. So if we start using a SQL database and write those codes directly into our PostRepository and a year later we decide to switch to a NoSQL database, the existing implementation is useless to us. And in a few years later, when a new persistence engine pops up, we have to start over yet again. If we hadn't created an interface first, we'd also likely need to change our consuming code! On top of that, we may find that we want to use some sort of distributed caching layer for read operations (fetching items), while write operations will be written to a relational database. Most likely, we don't want our controllers to need to worry about those implementation details, but we will want to ensure that we account for this in our architecture. At the code level, the interface is our abstraction layer for dealing with differences in implementations. However, currently, we only deal with queries. Let's expand on that. Adding command abstraction Let's first think a bit about what possible database interactions we can think of. We need to be able to: find a single blog post find all blog posts insert new blog post update existing blog posts delete existing blog posts At this time, our PostRepositoryInterface deals with the first two. Considering this is the layer that is most likely to use different backend implementations, we probably want to keep it separate from the operations that cause changes. Let's create a new interface, Blog\\Model\\PostCommandInterface , in module/Blog/src/Model/PostCommandInterface.php , and have it read as follows: namespace Blog\\Model; interface PostCommandInterface { /** * Persist a new post in the system. * * @param Post $post The post to insert; may or may not have an identifier. * @return Post The inserted post, with identifier. */ public function insertPost(Post $post); /** * Update an existing post in the system. * * @param Post $post The post to update; must have an identifier. * @return Post The updated post. */ public function updatePost(Post $post); /** * Delete a post from the system. * * @param Post $post The post to delete. * @return bool */ public function deletePost(Post $post); } This new interface defines methods for each command within our model. Each expects a Post instance, and it is up to the implementation to determine how to use that instance to issue the command. In the case of an insert operation, our Post does not require an identifier (which is why the value is nullable in the constructor), but will return a new instance that is guaranteed to have one. Similarly, the update operation will return the updated post (which may be the same instance!), and a delete operation will indicate if the operation was successful. Conclusion We're not quite ready to use the new interface; we're using it to set the stage for the next few chapters, where we look at using laminas-db to implement our persistence, and later creating new controllers to handle blog post manipulation.","title":"Preparing for Different Databases"},{"location":"in-depth-guide/preparing-databases/#preparing-for-different-databases","text":"In the previous chapter, we created a PostRepository that returns some data from blog posts. While the implementation was adequate for learning purposes, it is quite impractical for real world applications; no one would want to modify the source files each time a new post is added! Fortunately, we can always turn to databases for the actual storage of posts; all we need to learn is how to interact with databases within our application. There's one small catch: there are many database backend systems, including relational databases, documentent databases, key/value stores, and graph databases. You may be inclined to code directly to the solution that fits your application's immediate needs, but it is a better practice to create another layer in front of the actual database access that abstracts the database interaction. The repository approach we used in the previous chapter is one such approach, primarily geared towards queries . In this section, we'll expand on it to add command capabilities for creating, updating, and deleting records.","title":"Preparing for Different Databases"},{"location":"in-depth-guide/preparing-databases/#what-is-database-abstraction","text":"\"Database abstraction\" is the act of providing a common interface for all database interactions. Consider a SQL and a NoSQL database; both have methods for CRUD (Create, Read, Update, Delete) operations. For example, to query the database against a given row in MySQL you might use $results = mysqli_query('SELECT foo FROM bar')`; However, for MongoDB, for example you'd use something like: $results = $mongoDbClient->app->bar->find([], ['foo' => 1, '_id' => 0])`; Both engines would give you the same result, but the execution is different. So if we start using a SQL database and write those codes directly into our PostRepository and a year later we decide to switch to a NoSQL database, the existing implementation is useless to us. And in a few years later, when a new persistence engine pops up, we have to start over yet again. If we hadn't created an interface first, we'd also likely need to change our consuming code! On top of that, we may find that we want to use some sort of distributed caching layer for read operations (fetching items), while write operations will be written to a relational database. Most likely, we don't want our controllers to need to worry about those implementation details, but we will want to ensure that we account for this in our architecture. At the code level, the interface is our abstraction layer for dealing with differences in implementations. However, currently, we only deal with queries. Let's expand on that.","title":"What is database abstraction?"},{"location":"in-depth-guide/preparing-databases/#adding-command-abstraction","text":"Let's first think a bit about what possible database interactions we can think of. We need to be able to: find a single blog post find all blog posts insert new blog post update existing blog posts delete existing blog posts At this time, our PostRepositoryInterface deals with the first two. Considering this is the layer that is most likely to use different backend implementations, we probably want to keep it separate from the operations that cause changes. Let's create a new interface, Blog\\Model\\PostCommandInterface , in module/Blog/src/Model/PostCommandInterface.php , and have it read as follows: namespace Blog\\Model; interface PostCommandInterface { /** * Persist a new post in the system. * * @param Post $post The post to insert; may or may not have an identifier. * @return Post The inserted post, with identifier. */ public function insertPost(Post $post); /** * Update an existing post in the system. * * @param Post $post The post to update; must have an identifier. * @return Post The updated post. */ public function updatePost(Post $post); /** * Delete a post from the system. * * @param Post $post The post to delete. * @return bool */ public function deletePost(Post $post); } This new interface defines methods for each command within our model. Each expects a Post instance, and it is up to the implementation to determine how to use that instance to issue the command. In the case of an insert operation, our Post does not require an identifier (which is why the value is nullable in the constructor), but will return a new instance that is guaranteed to have one. Similarly, the update operation will return the updated post (which may be the same instance!), and a delete operation will indicate if the operation was successful.","title":"Adding command abstraction"},{"location":"in-depth-guide/preparing-databases/#conclusion","text":"We're not quite ready to use the new interface; we're using it to set the stage for the next few chapters, where we look at using laminas-db to implement our persistence, and later creating new controllers to handle blog post manipulation.","title":"Conclusion"},{"location":"in-depth-guide/review/","text":"Reviewing the Blog Module Throughout the tutorial, we have created a fully functional CRUD module using a blog as an example. While doing so, we've made use of several different design patterns and best-practices. Now it's time to reiterate and take a look at some of the code samples we've written. This is going to be done in a Q&A fashion. Do we always need all the layers and interfaces? Short answer: no. Long answer: The importance of interfaces increases the bigger your application becomes. If you can foresee that your application will be used by other people or should be extendable, then you should strongly consider creating interfaces and coding to them. This is a very common best-practice that is not tied to Laminas specifically, but rather more general object oriented programming. The main role of the multiple layers that we have introduced are to provide a strict separation of concerns for our application. It is tempting to include your database access directly in your controllers. We recommend splitting it out to other objects, and providing interfaces for the interactions whenever you can. Doing so helps decouple your controllers from the implementation, allowing you to swap out the implementation later without changing the controllers. Using interfaces also simplifies testing, as you can provide mock implementations easily. Why are there so many controllers? With the exception of our ListController , we created a controller for each route we added. We could have combined these into a single controller. In practice, we have observed the following when doing so: Controllers grow in complexity, making maintenance and additions more difficult. The number of dependencies grows with the number of responsibilities. Many actions may need only a subset of the dependencies, leading to needless performance and resource overhead. Testing becomes more difficult. Re-use becomes more difficult. The primary problem is that such controllers quickly break the Single Responsibility Principle , and inherit all the problems that principle attempts to combat. We recommend a single action per controller whenever possible. Do you have more questions? PR them! If there's anything you feel that's missing in this FAQ, please create an issue or send a pull request with your question!","title":"Reviewing the Blog Module"},{"location":"in-depth-guide/review/#reviewing-the-blog-module","text":"Throughout the tutorial, we have created a fully functional CRUD module using a blog as an example. While doing so, we've made use of several different design patterns and best-practices. Now it's time to reiterate and take a look at some of the code samples we've written. This is going to be done in a Q&A fashion.","title":"Reviewing the Blog Module"},{"location":"in-depth-guide/review/#do-we-always-need-all-the-layers-and-interfaces","text":"Short answer: no. Long answer: The importance of interfaces increases the bigger your application becomes. If you can foresee that your application will be used by other people or should be extendable, then you should strongly consider creating interfaces and coding to them. This is a very common best-practice that is not tied to Laminas specifically, but rather more general object oriented programming. The main role of the multiple layers that we have introduced are to provide a strict separation of concerns for our application. It is tempting to include your database access directly in your controllers. We recommend splitting it out to other objects, and providing interfaces for the interactions whenever you can. Doing so helps decouple your controllers from the implementation, allowing you to swap out the implementation later without changing the controllers. Using interfaces also simplifies testing, as you can provide mock implementations easily.","title":"Do we always need all the layers and interfaces?"},{"location":"in-depth-guide/review/#why-are-there-so-many-controllers","text":"With the exception of our ListController , we created a controller for each route we added. We could have combined these into a single controller. In practice, we have observed the following when doing so: Controllers grow in complexity, making maintenance and additions more difficult. The number of dependencies grows with the number of responsibilities. Many actions may need only a subset of the dependencies, leading to needless performance and resource overhead. Testing becomes more difficult. Re-use becomes more difficult. The primary problem is that such controllers quickly break the Single Responsibility Principle , and inherit all the problems that principle attempts to combat. We recommend a single action per controller whenever possible.","title":"Why are there so many controllers?"},{"location":"in-depth-guide/review/#do-you-have-more-questions-pr-them","text":"If there's anything you feel that's missing in this FAQ, please create an issue or send a pull request with your question!","title":"Do you have more questions? PR them!"},{"location":"in-depth-guide/understanding-routing/","text":"Understanding the Router Our module is coming along nicely. However, we're not really doing all that much yet; to be precise, all we do is display all blog entries on one page. In this chapter, you will learn everything you need to know about the Router in order to route to controllers and actions for displaying a single blog post, adding a new blog post, editing an existing post, and deleting a post. Different route types Before we go into details on our application, let's take a look at the most often used route types. Literal routes As mentioned in a previous chapter, a literal route is one that exactly matches a specific string. Examples of URLs that can utilize literal routes include: http://domain.com/blog http://domain.com/blog/add http://domain.com/about-me http://domain.com/my/very/deep/page Configuration for a literal route requires you to provide the path to match, and the \"defaults\" to return on a match. The \"defaults\" are then returned as route match parameters; one use case for these is to specify the controller to invoke and the action method on that controller to use. As an example: 'router' => [ 'routes' => [ 'about' => [ 'type' => \\Laminas\\Router\\Http\\Literal::class, 'options' => [ 'route' => '/about-me', 'defaults' => [ 'controller' => 'AboutMeController', 'action' => 'aboutme', ], ], ], ], ], Segment routes Segment routes allow you to define routes with variable parameters; a common use case is for specifying an identifier in the path. Examples of URLs that might require segment routes include: http://domain.com/blog/1 (parameter \"1\" is dynamic) http://domain.com/blog/details/1 (parameter \"1\" is dynamic) http://domain.com/blog/edit/1 (parameter \"1\" is dynamic) http://domain.com/blog/1/edit (parameter \"1\" is dynamic) http://domain.com/news/archive/2014 (parameter \"2014\" is dynamic) http://domain.com/news/archive/2014/january (parameter \"2014\" and \"january\" are dynamic) Configuring a segment route is similar to that of a literal route. The primary differences are: The route will have one or more :<varname> segments, indicating items that will be dynamically filled. <varname> should be a string, and will be used to identify the variable to return when routing is successful. The route may also contain optional segments, which are items surrounded by square braces ( [] ), and which can contain any mix of literal and variable segments internally. The \"defaults\" can include the names of variable segments; in case that segment is missing, the default will be used. (They can also be completely independent; for instance, the \"controller\" rarely should be included as a segment!). You may also specify \"constraints\" for each variable segment; each constraint will be a regular expression that must pass for matching to be successful. As an example, let's consider a route where we want to specify a variable \"year\" segment, and indicate that the segment must contain exactly four digits; when matched, we should use the ArchiveController and its byYear action: 'router' => [ 'routes' => [ 'archives' => [ 'type' => \\Laminas\\Router\\Http\\Segment::class, 'options' => [ 'route' => '/news/archive[/:year]', 'defaults' => [ 'controller' => ArchiveController::class, 'action' => 'byYear', 'year' => date('Y'), ], 'constraints' => [ 'year' => '\\d{4}', ], ], ], ], ], This configuration defines a route for a URL such as //example.com/news/archive/2014 . The route contains the variable segment :year , which has a regex constraint defined as \\d{4} , indicating it will match if and only if it is exactly four digits. As such, the URL //example.com/news/archive/123 will fail to match, but //example.com/news/archive/1234 will. The definition marks an optional segment, denoted by [/:year] . This has a couple of implications. First, it means that we can also match: //example.com/news/archive //example.com/news/archive/ In both cases, we'll also still receive a value for the :year segment, because we defined a default for it: the expression date('Y') (returning the current year). Segment routes allow you to dynamically match paths, and provide extensive capabilities for how you shape those paths, matching variable segments, and providing constraints for them. Different routing concepts When thinking about an entire application, you'll quickly realize that you may have many, many routes to define. When writing these routes you have two options: Spend less time writing routes that in turn are a little slow in matching. Write very explicit routes that match faster, but require more work to define. Generic routes A generic route is greedy, and will match as many URLs as possible. A common approach is to write a route that matches the controller and action: 'router' => [ 'routes' => [ 'default' => [ 'type' => \\Laminas\\Router\\Http\\Segment::class, 'options' => [ 'route' => '/[:controller[/:action]]', 'defaults' => [ 'controller' => Application\\Controller\\IndexController::class, 'action' => 'index', ], 'constraints' => [ 'controller' => '[a-zA-Z][a-zA-Z0-9_-]*', 'action' => '[a-zA-Z][a-zA-Z0-9_-]*', ], ], ], ], ], Let's take a closer look as to what has been defined in this configuration. The route part now contains two optional parameters, controller and action . The action parameter is optional only when the controller parameter is present. Both have constraints that ensure they only allow strings that would be valid PHP class and method names. The big advantage of this approach is the immense time you save when developing your application; one route, and then all you need to do is create controllers, add action methods to them, and they are immediately available. The downsides are in the details. In order for this to work, you will need to use aliases when defining your controllers, so that you can alias shorter names that omit namespaces to the fully qualified controller class names; this sets up the potential for collisions between different application modules which might define the same controller class names. Second, matching nested optional segments, each with regular expression constraints, adds performance overhead to routing. Third, such a route does not match any additional segments, constraining your controllers to omit dynamic route segments and instead rely on query string arguments for route parameters — which in turn leaves parameter validation to your controllers. Finally, there is no guarantee that a valid match will result in a valid controller and action. As an example, if somebody requested //example.com/strange/nonExistent , and no controller maps to strange , or the controller has no nonExistentAction() method, the application will use more cycles to discover and report the error condition than it would if routing had simply failed to match. This is both a performance and a security consideration, as an attacker could use this fact to launch a Denial of Service. Basic routing By now, you should be convinced that generic routes, while nice for prototyping, should likely be avoided. That means defining explicit routes. Your initial approach might be to create one route for every permutation: 'router' => [ 'routes' => [ 'news' => [ 'type' => \\Laminas\\Router\\Http\\Literal::class, 'options' => [ 'route' => '/news', 'defaults' => [ 'controller' => NewsController::class, 'action' => 'showAll', ], ], ], 'news-archive' => [ 'type' => \\Laminas\\Router\\Http\\Segment::class, 'options' => [ 'route' => '/news/archive[/:year]', 'defaults' => [ 'controller' => NewsController::class, 'action' => 'archive', ], 'constraints' => [ 'year' => '\\d{4}', ], ], ], 'news-single' => [ 'type' => \\Laminas\\Router\\Http\\Segment::class, 'options' => [ 'route' => '/news/:id', 'defaults' => [ 'controller' => NewsController::class, 'action' => 'detail', ], 'constraints' => [ 'id' => '\\d+', ], ], ], ], ], Routing is done as a stack, meaning last in, first out (LIFO). The trick is to define your most general routes first, and your most specific routes last. In the example above, our most general route is a literal match against the path /news . We then have two additional routes that are more specific, one matching /news/archive (with an optional segment for the year), and another one matching /news/:id . These exhibit a fair bit of repetition: In order to prevent naming collisions between routes, each route name is prefixed with news- . Each routing string contains /news . Each defines the same default controller. Clearly, this can get tedious. Additionally, if you have many routes with repitition such as this, you need to pay special attention to the stack and possible route overlaps, as well as performance (if the stack becomes large). Child routes To solve the problems detailed in the last section, laminas-router allows defining \"child routes\". Child routes inherit all options from their respective parents; this means that if an option, such as the controller default, doesn't change, you do not need to redefine it. Additionally, child routes match relative to the parent route. This provides several optimizations: You do not need to duplicate common path segments. Routing will ignore the child routes unless the parent matches , which can provide enormous performance benefits during routing. Let's take a look at a child routes configuration using the same example as above: 'router' => [ 'routes' => [ 'news' => [ // First we define the basic options for the parent route: 'type' => \\Laminas\\Router\\Http\\Literal::class, 'options' => [ 'route' => '/news', 'defaults' => [ 'controller' => NewsController::class, 'action' => 'showAll', ], ], // The following allows \"/news\" to match on its own if no child // routes match: 'may_terminate' => true, // Child routes begin: 'child_routes' => [ 'archive' => [ 'type' => \\Laminas\\Router\\Http\\Segment::class, 'options' => [ 'route' => '/archive[/:year]', 'defaults' => [ 'action' => 'archive', ], 'constraints' => [ 'year' => '\\d{4}', ], ], ], 'single' => [ 'type' => \\Laminas\\Router\\Http\\Segment::class, 'options' => [ 'route' => '/:id', 'defaults' => [ 'action' => 'detail', ], 'constraints' => [ 'id' => '\\d+', ], ], ], ], ], ], ], At its most basic, we define a parent route as normal, and then add an additional key, child_routes , which is normal routing configuration for additional routes to match if the parent route matches. The may_terminate configuration key is used to determine if the parent route is allowed to match on its own; in other words, if no child routes match, is the parent route a valid route match? The flag is false by default; setting it to true allows the parent to match on its own. The child_routes themselves look like standard routing at the top-level, and follow the same rules; they themselves can have child routes, too! The thing to remember is that any routing strings defined are relative to the parent . As such, the above definition allows matching any of the following: /news /news/archive /news/archive/2014 /news/42 (If may_terminate was set to false , the first path above, /news , would not match .) You'll note that the child routes defined above do not specify a controller default. Child routes inherit options from the parent, however, which means that, effectively, each of these will use the same controller as the parent! The advantages to using child routes include: Explicit routes mean fewer error conditions with regards to matching controllers and action methods. Performance; the router ignores child routes unless the parent matches. De-duplication; the parent route contains the common path prefix and common options. Organization; you can see at a glance all route definitions that start with a common path segment. The primary disadvantage is the verbosity of configuration. A practical example for our blog module Now that we know how to configure routes, let's first create a route to display only a single blog entry based on internal identifier. Given that ID is a variable parameter, we need a segment route. Furthermore, we know that the route will also match against the same /blog path prefix, so we can define it as a child route of our existing route. Let's update our configuration: // In module/Blog/config/module.config.php: namespace Blog; use Laminas\\Router\\Http\\Literal; use Laminas\\Router\\Http\\Segment; use Laminas\\ServiceManager\\Factory\\InvokableFactory; return [ 'service_manager' => [ /* ... */ ], 'controllers' => [ /* ... */ ], 'router' => [ 'routes' => [ 'blog' => [ 'type' => Literal::class, 'options' => [ 'route' => '/blog', 'defaults' => [ 'controller' => Controller\\ListController::class, 'action' => 'index', ], ], 'may_terminate' => true, 'child_routes' => [ 'detail' => [ 'type' => Segment::class, 'options' => [ 'route' => '/:id', 'defaults' => [ 'action' => 'detail', ], 'constraints' => [ 'id' => '[1-9]\\d*', ], ], ], ], ], ], ], 'view_manager' => [ /* ... */ ], ]; With this we have set up a new route that we use to display a single blog entry. The route defines a parameter, id , which needs to be a sequence of 1 or more positive digits, not beginning with 0. The route will call the same controller as the parent route, but using the detailAction() method instead. Go to your browser and request the URL http://localhost:8080/blog/2 ; you'll see the following error message: A 404 error occurred Page not found. The requested controller was unable to dispatch the request. Controller: Blog\\Controller\\ListController No Exception available This is due to the fact that the controller tries to access the detailAction() , which does not yet exist. We'll create this action now; go to your ListController and add the following action, which will return an empty view model // In module/Blog/src/Controller/ListController.php: /* .. */ class ListController extends AbstractActionController { /* ... */ public function detailAction() { return new ViewModel(); } } Refresh your browser, which should result in the familiar message that a template was unable to be rendered. Let's create this template now and assume that we will get a Post instance passed to the template to see the details of our blog. Create a new view file under module/Blog/view/blog/list/detail.phtml : <h1>Post Details</h1> <dl> <dt>Post Title</dt> <dd><?= $this->escapeHtml($this->post->getTitle()) ?></dd> <dt>Post Text</dt> <dd><?= $this->escapeHtml($this->post->getText()) ?></dd> </dl> The above template is expecting a $post variable referencing a Post instance in the view model. We'll now update the ListController to provide that: public function detailAction() { $id = $this->params()->fromRoute('id'); return new ViewModel([ 'post' => $this->postRepository->findPost($id), ]); } If you refresh your application now, you'll see the details for our Post are displayed. However, there is one problem with what we have done: while we have our repository set up to throw an InvalidArgumentException when no post is found matching a given identifier, we do not check for it in our controller. Go to your browser and open the URL http://localhost:8080/blog/99 ; you will see the following error message: An error occurred An error occurred during execution; please try again later. Additional information: InvalidArgumentException File: {projectPath}/module/Blog/src/Model/LaminasDbSqlRepository.php:{lineNumber} Message: Blog post with identifier \"99\" not found. This is kind of ugly, so our ListController should be prepared to do something whenever an InvalidArgumentException is thrown by the PostService . Let's have the controller redirect to the blog post overview. First, add a new import to the ListController class file: use InvalidArgumentException; Now add the following try-catch statement to the detailAction() method: public function detailAction() { $id = $this->params()->fromRoute('id'); try { $post = $this->postRepository->findPost($id); } catch (\\InvalidArgumentException $ex) { return $this->redirect()->toRoute('blog'); } return new ViewModel([ 'post' => $post, ]); } Now whenever a user requests an invalid identifier, you'll be redirected to the route blog , which is our list of blog posts!","title":"Understanding the Router"},{"location":"in-depth-guide/understanding-routing/#understanding-the-router","text":"Our module is coming along nicely. However, we're not really doing all that much yet; to be precise, all we do is display all blog entries on one page. In this chapter, you will learn everything you need to know about the Router in order to route to controllers and actions for displaying a single blog post, adding a new blog post, editing an existing post, and deleting a post.","title":"Understanding the Router"},{"location":"in-depth-guide/understanding-routing/#different-route-types","text":"Before we go into details on our application, let's take a look at the most often used route types.","title":"Different route types"},{"location":"in-depth-guide/understanding-routing/#different-routing-concepts","text":"When thinking about an entire application, you'll quickly realize that you may have many, many routes to define. When writing these routes you have two options: Spend less time writing routes that in turn are a little slow in matching. Write very explicit routes that match faster, but require more work to define.","title":"Different routing concepts"},{"location":"in-depth-guide/understanding-routing/#a-practical-example-for-our-blog-module","text":"Now that we know how to configure routes, let's first create a route to display only a single blog entry based on internal identifier. Given that ID is a variable parameter, we need a segment route. Furthermore, we know that the route will also match against the same /blog path prefix, so we can define it as a child route of our existing route. Let's update our configuration: // In module/Blog/config/module.config.php: namespace Blog; use Laminas\\Router\\Http\\Literal; use Laminas\\Router\\Http\\Segment; use Laminas\\ServiceManager\\Factory\\InvokableFactory; return [ 'service_manager' => [ /* ... */ ], 'controllers' => [ /* ... */ ], 'router' => [ 'routes' => [ 'blog' => [ 'type' => Literal::class, 'options' => [ 'route' => '/blog', 'defaults' => [ 'controller' => Controller\\ListController::class, 'action' => 'index', ], ], 'may_terminate' => true, 'child_routes' => [ 'detail' => [ 'type' => Segment::class, 'options' => [ 'route' => '/:id', 'defaults' => [ 'action' => 'detail', ], 'constraints' => [ 'id' => '[1-9]\\d*', ], ], ], ], ], ], ], 'view_manager' => [ /* ... */ ], ]; With this we have set up a new route that we use to display a single blog entry. The route defines a parameter, id , which needs to be a sequence of 1 or more positive digits, not beginning with 0. The route will call the same controller as the parent route, but using the detailAction() method instead. Go to your browser and request the URL http://localhost:8080/blog/2 ; you'll see the following error message: A 404 error occurred Page not found. The requested controller was unable to dispatch the request. Controller: Blog\\Controller\\ListController No Exception available This is due to the fact that the controller tries to access the detailAction() , which does not yet exist. We'll create this action now; go to your ListController and add the following action, which will return an empty view model // In module/Blog/src/Controller/ListController.php: /* .. */ class ListController extends AbstractActionController { /* ... */ public function detailAction() { return new ViewModel(); } } Refresh your browser, which should result in the familiar message that a template was unable to be rendered. Let's create this template now and assume that we will get a Post instance passed to the template to see the details of our blog. Create a new view file under module/Blog/view/blog/list/detail.phtml : <h1>Post Details</h1> <dl> <dt>Post Title</dt> <dd><?= $this->escapeHtml($this->post->getTitle()) ?></dd> <dt>Post Text</dt> <dd><?= $this->escapeHtml($this->post->getText()) ?></dd> </dl> The above template is expecting a $post variable referencing a Post instance in the view model. We'll now update the ListController to provide that: public function detailAction() { $id = $this->params()->fromRoute('id'); return new ViewModel([ 'post' => $this->postRepository->findPost($id), ]); } If you refresh your application now, you'll see the details for our Post are displayed. However, there is one problem with what we have done: while we have our repository set up to throw an InvalidArgumentException when no post is found matching a given identifier, we do not check for it in our controller. Go to your browser and open the URL http://localhost:8080/blog/99 ; you will see the following error message: An error occurred An error occurred during execution; please try again later. Additional information: InvalidArgumentException File: {projectPath}/module/Blog/src/Model/LaminasDbSqlRepository.php:{lineNumber} Message: Blog post with identifier \"99\" not found. This is kind of ugly, so our ListController should be prepared to do something whenever an InvalidArgumentException is thrown by the PostService . Let's have the controller redirect to the blog post overview. First, add a new import to the ListController class file: use InvalidArgumentException; Now add the following try-catch statement to the detailAction() method: public function detailAction() { $id = $this->params()->fromRoute('id'); try { $post = $this->postRepository->findPost($id); } catch (\\InvalidArgumentException $ex) { return $this->redirect()->toRoute('blog'); } return new ViewModel([ 'post' => $post, ]); } Now whenever a user requests an invalid identifier, you'll be redirected to the route blog , which is our list of blog posts!","title":"A practical example for our blog module"},{"location":"migration/to-v3/application/","text":"Upgrading Applications If you have an existing Laminas v2 application, and want to update it to the latest versions, you will have some special considerations. Upgrading Laminas Since the 2.5 release, the laminas package has been essentially a \"metapackage\", defining no code, and only dependencies on the various component packages. This means that when you install laminas/laminas , you get the full set of components, at the latest 2.* versions. With the release of version 3, we recommend: Removing the laminas/laminas package. Installing the laminas/laminas-component-installer package. Installing the laminas/laminas-mvc package. Installing each Laminas component package you actually use in your application. The process would look like this: $ composer remove laminas/laminas $ composer require laminas/laminas-component-installer $ composer require laminas/laminas-mvc # Repeat as necessary for components you use if not already installed When you install laminas-mvc, it will prompt you to add configuration for components; choose either application.config.php or modules.config.php , and re-use your selection for all other packages. This step ensures that the various components installed, and any news ones you add later, are configured in your application correctly. This approach will ensure you are only installing what you actually need. As an example, if you are not using laminas-barcode, or laminas-permissions-acl, or laminas-mail, there's no reason to install them. Keeping the laminas package If you want to upgrade quickly, and cannot easily determine which components you use in your application, you can upgrade your laminas requirement. When you do, you should also install the laminas-component-installer, to ensure that component configuration is properly injected in your application. $ composer require laminas/laminas-component-installer \"laminas/laminas:^3.0\" During installation, it will prompt you to add configuration for components; choose either application.config.php or modules.config.php , and re-use your selection for all other packages. This step ensures that the various components installed, and any news ones you add later, are configured in your application correctly. This will upgrade you to the latest releases of all Laminas components at once; it will also install new components developed as part of the version 3 initiative. We still recommend reducing your dependencies at a later date, however. Integration packages During the Laminas initiative, one goal was to reduce the number of dependencies for each package. This affected the MVC in particular, as a number of features were optional or presented deep integrations between the MVC and other components. These include the following: Console tooling If you were using the MVC console tooling, and are doing a partial update per the recommendations, you will need to install laminas-mvc-console . Forms integration If you were using the forms in your MVC application, and are doing a partial update per the recommendations, you will need to install laminas-mvc-form . i18n integration If you were using i18n features in your MVC application, and are doing a partial update per the recommendations, you will need to install laminas-mvc-i18n . Plugins If you were using any of the prg() , fileprg() , identity() , or flashMessenger() MVC controller plugins, and are doing a partial update per the recommendations, you will need to install laminas-mvc-plugins . laminas-di integration If you were using the laminas-servicemanager <-> laminas-di integration within your application, you will need to install laminas-servicemanager-di . Autoloading If you are doing a partial upgrade per the above recommendations (vs. upgrading the full laminas package), one change is that laminas-loader is no longer installed by default, nor recommended. Instead, we recommend using Composer for autoloading . As such, you will need to setup autoloading rules for each module specific to your application. As an example, if you are still defining the default Application module, you can add autoloading for it as follows in your project's composer.json : \"autoload\": { \"psr-4\": { \"Application\\\\\": \"module/Application/src/Application/\" }, \"files\": [ \"module/Application/Module.php\" ] } The above creates a PSR-4 autoloading rule for the Application module, telling it to look in the module/Application/src/Application/ directory. Since the Application\\Module class is defined at the module root, we specify it in the files configuration. To improve on this, and simplify autoloading, we recommend adopting a complete PSR-4 directory structure for your module class files. As an example, to change the existing Application module to PSR-4, you can do the following: $ cd module/Application $ mv src temp $ mv temp/Application src $ rm -Rf ./temp $ mv Module.php src/ Update your Module.php file to do the following: Remove the getAutoloaderConfig() method entirely, if defined. Update the getConfig() method from include __DIR__ . '/config/module.config.php to include _DIR__ . '/../config/module.config.php . You can then update the autoload configuration to: \"autoload\": { \"psr-4\": { \"Application\\\\\": \"module/Application/src/\" } } Afterwards, run the following to update the generated autoloader: $ composer dump-autoload The updated application skeleton already takes this approach. Bootstrap Because version 3 requires usage of Composer for autoloading, you can simplify your application bootstrap. First, if you were using an init_autoloader.php file, you can now remove it. Second, update your public/index.php to read as follows: <?php use Laminas\\Mvc\\Application; /** * This makes our life easier when dealing with paths. Everything is relative * to the application root now. */ chdir(dirname(__DIR__)); // Decline static file requests back to the PHP built-in webserver if (php_sapi_name() === 'cli-server') { $path = realpath(__DIR__ . parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH)); if (__FILE__ !== $path && is_file($path)) { return false; } unset($path); } // Composer autoloading include __DIR__ . '/../vendor/autoload.php'; if (! class_exists(Application::class)) { throw new RuntimeException( \"Unable to load application.\\n\" . \"- Type `composer install` if you are developing locally.\\n\" ); } // Run the application! Application::init(require __DIR__ . '/../config/application.config.php')->run(); Scripts The skeleton application for version 2 shipped three scripts with it: bin/classmap_generator.php bin/pluginmap_generator.php bin/templatemap_generator.php If you are upgrading an existing application, these will still be present. However, if you are starting a new application, and used these previously, they are no longer present. classmap_generator.php was removed as it's unnecessary when using Composer for autoloading. When preparing a production installation, run composer dump-autoload -o and/or composer dump-autoload -a ; both will generate optimized class map autoloading rules for you. pluginmap_generator.php was essentially obsolete due to the presence of classmap_generator.php anyways. templatemap_generator.php was moved to the laminas-view component with the 2.8.0 release of that component, and is now available via ./vendor/bin/templatemap_generator.php . Additionally, its usage signature has changed; please use the --help or -h switches on first invocation to discover how to use it. Development mode Version 3 of the skeleton application adds a requirement on laminas/laminas-development-mode , which provides a way to store common development-specific settings in your repository and then selectively enable/disable them during development. If you are upgrading from an existing application, you can install this feature: $ composer require laminas/laminas-development-mode Please refer to the package documentation for details on how to setup your application configuration to make use of this feature.","title":"Applications"},{"location":"migration/to-v3/application/#upgrading-applications","text":"If you have an existing Laminas v2 application, and want to update it to the latest versions, you will have some special considerations.","title":"Upgrading Applications"},{"location":"migration/to-v3/application/#upgrading-laminas","text":"Since the 2.5 release, the laminas package has been essentially a \"metapackage\", defining no code, and only dependencies on the various component packages. This means that when you install laminas/laminas , you get the full set of components, at the latest 2.* versions. With the release of version 3, we recommend: Removing the laminas/laminas package. Installing the laminas/laminas-component-installer package. Installing the laminas/laminas-mvc package. Installing each Laminas component package you actually use in your application. The process would look like this: $ composer remove laminas/laminas $ composer require laminas/laminas-component-installer $ composer require laminas/laminas-mvc # Repeat as necessary for components you use if not already installed When you install laminas-mvc, it will prompt you to add configuration for components; choose either application.config.php or modules.config.php , and re-use your selection for all other packages. This step ensures that the various components installed, and any news ones you add later, are configured in your application correctly. This approach will ensure you are only installing what you actually need. As an example, if you are not using laminas-barcode, or laminas-permissions-acl, or laminas-mail, there's no reason to install them.","title":"Upgrading Laminas"},{"location":"migration/to-v3/application/#integration-packages","text":"During the Laminas initiative, one goal was to reduce the number of dependencies for each package. This affected the MVC in particular, as a number of features were optional or presented deep integrations between the MVC and other components. These include the following:","title":"Integration packages"},{"location":"migration/to-v3/application/#autoloading","text":"If you are doing a partial upgrade per the above recommendations (vs. upgrading the full laminas package), one change is that laminas-loader is no longer installed by default, nor recommended. Instead, we recommend using Composer for autoloading . As such, you will need to setup autoloading rules for each module specific to your application. As an example, if you are still defining the default Application module, you can add autoloading for it as follows in your project's composer.json : \"autoload\": { \"psr-4\": { \"Application\\\\\": \"module/Application/src/Application/\" }, \"files\": [ \"module/Application/Module.php\" ] } The above creates a PSR-4 autoloading rule for the Application module, telling it to look in the module/Application/src/Application/ directory. Since the Application\\Module class is defined at the module root, we specify it in the files configuration. To improve on this, and simplify autoloading, we recommend adopting a complete PSR-4 directory structure for your module class files. As an example, to change the existing Application module to PSR-4, you can do the following: $ cd module/Application $ mv src temp $ mv temp/Application src $ rm -Rf ./temp $ mv Module.php src/ Update your Module.php file to do the following: Remove the getAutoloaderConfig() method entirely, if defined. Update the getConfig() method from include __DIR__ . '/config/module.config.php to include _DIR__ . '/../config/module.config.php . You can then update the autoload configuration to: \"autoload\": { \"psr-4\": { \"Application\\\\\": \"module/Application/src/\" } } Afterwards, run the following to update the generated autoloader: $ composer dump-autoload The updated application skeleton already takes this approach.","title":"Autoloading"},{"location":"migration/to-v3/application/#bootstrap","text":"Because version 3 requires usage of Composer for autoloading, you can simplify your application bootstrap. First, if you were using an init_autoloader.php file, you can now remove it. Second, update your public/index.php to read as follows: <?php use Laminas\\Mvc\\Application; /** * This makes our life easier when dealing with paths. Everything is relative * to the application root now. */ chdir(dirname(__DIR__)); // Decline static file requests back to the PHP built-in webserver if (php_sapi_name() === 'cli-server') { $path = realpath(__DIR__ . parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH)); if (__FILE__ !== $path && is_file($path)) { return false; } unset($path); } // Composer autoloading include __DIR__ . '/../vendor/autoload.php'; if (! class_exists(Application::class)) { throw new RuntimeException( \"Unable to load application.\\n\" . \"- Type `composer install` if you are developing locally.\\n\" ); } // Run the application! Application::init(require __DIR__ . '/../config/application.config.php')->run();","title":"Bootstrap"},{"location":"migration/to-v3/application/#scripts","text":"The skeleton application for version 2 shipped three scripts with it: bin/classmap_generator.php bin/pluginmap_generator.php bin/templatemap_generator.php If you are upgrading an existing application, these will still be present. However, if you are starting a new application, and used these previously, they are no longer present. classmap_generator.php was removed as it's unnecessary when using Composer for autoloading. When preparing a production installation, run composer dump-autoload -o and/or composer dump-autoload -a ; both will generate optimized class map autoloading rules for you. pluginmap_generator.php was essentially obsolete due to the presence of classmap_generator.php anyways. templatemap_generator.php was moved to the laminas-view component with the 2.8.0 release of that component, and is now available via ./vendor/bin/templatemap_generator.php . Additionally, its usage signature has changed; please use the --help or -h switches on first invocation to discover how to use it.","title":"Scripts"},{"location":"migration/to-v3/application/#development-mode","text":"Version 3 of the skeleton application adds a requirement on laminas/laminas-development-mode , which provides a way to store common development-specific settings in your repository and then selectively enable/disable them during development. If you are upgrading from an existing application, you can install this feature: $ composer require laminas/laminas-development-mode Please refer to the package documentation for details on how to setup your application configuration to make use of this feature.","title":"Development mode"},{"location":"migration/to-v3/components/","text":"Component migration documentation The following is a list of migration documents for components we ship. laminas-code laminas-eventmanager laminas-hydrator laminas-json laminas-math laminas-mvc laminas-mvc-console (for migrating MVC-based console functionality) laminas-mvc-i18n (for migrating MVC-based console functionality) laminas-router (for migrating MVC-based router functionality) laminas-servicemanager laminas-servicemanager-di (for migrating laminas-servicemanager <-> laminas-di integration) laminas-stdlib","title":"Components"},{"location":"migration/to-v3/components/#component-migration-documentation","text":"The following is a list of migration documents for components we ship. laminas-code laminas-eventmanager laminas-hydrator laminas-json laminas-math laminas-mvc laminas-mvc-console (for migrating MVC-based console functionality) laminas-mvc-i18n (for migrating MVC-based console functionality) laminas-router (for migrating MVC-based router functionality) laminas-servicemanager laminas-servicemanager-di (for migrating laminas-servicemanager <-> laminas-di integration) laminas-stdlib","title":"Component migration documentation"},{"location":"migration/to-v3/overview/","text":"Migration from Laminas v2 to v3 Laminas v2 to v3 has been intended as an incremental upgrade. We have even made efforts in the past year to provide forwards compatibility features in v2 versions of components, to allow users to prepare their code for upgrade. This is not a comprehensive migration guide, however. While we know the majority of the areas where breakage can and will occur, we also know that only when developers are actually updating will we see the full situation. As such, treat this as a work in progress, and please feel free to propose updates or changes via issues or pull requests so we can improve!","title":"Overview"},{"location":"migration/to-v3/overview/#migration-from-laminas-v2-to-v3","text":"Laminas v2 to v3 has been intended as an incremental upgrade. We have even made efforts in the past year to provide forwards compatibility features in v2 versions of components, to allow users to prepare their code for upgrade. This is not a comprehensive migration guide, however. While we know the majority of the areas where breakage can and will occur, we also know that only when developers are actually updating will we see the full situation. As such, treat this as a work in progress, and please feel free to propose updates or changes via issues or pull requests so we can improve!","title":"Migration from Laminas v2 to v3"}]}
\ No newline at end of file
diff --git a/sitemap.xml.gz b/sitemap.xml.gz
index 512686e64dde1695a2a577985229892c6c60fe67..3a9e2c42836b1cc584cdf70fb69251a838ac239c 100644
GIT binary patch
delta 15
WcmeBR>0n`#@8;mxJ#`}+6B7U+Q3K=v
delta 15
WcmeBR>0n`#@8;lG8@!Q?i3tE6vjda>
diff --git a/unit-testing/index.html b/unit-testing/index.html
index 9cb34a2..28631c8 100644
--- a/unit-testing/index.html
+++ b/unit-testing/index.html
@@ -548,9 +548,8 @@
laminas-test provides PHPUnit integration for laminas-mvc, including application scaffolding and custom assertions. You will need to install it:
-$ composer require --dev laminas/laminas-test phpunit/phpunit
-laminas-test package supports very wide range of PHPUnit versions, make sure to -always explicitly require phpunit/phpunit versions that are compatible with your tests.
+$ composer require --dev laminas/laminas-test
+This will also install phpunit/phpunit since it is required by laminas-test.
The above command will update your composer.json
file and perform an update
for you, which will also setup autoloading rules.
$ ./vendor/bin/phpunit
-PHPUnit invocation on Windows
+PHPUnit invocation on Windows Command Shell
On Windows, you need to wrap the command in double quotes:
-+$ "vendor/bin/phpunit"
C:\> "vendor/bin/phpunit"
You should see output similar to the following:
-PHPUnit 9.0.1 by Sebastian Bergmann and contributors.
+PHPUnit 10.5.13 by Sebastian Bergmann and contributors.
-... 3 / 3 (100%)
+.... 4 / 4 (100%)
-Time: 116 ms, Memory: 11.00MB
+Time: 00:00.334, Memory: 16.00 MB
-OK (3 tests, 7 assertions)
-There might be 2 failing tests if you followed the getting started guide. This
+Tests: 4, Assertions: 6, Failures: 0.
+There might be 1 failing test if you followed the getting started guide. This
is because the Application\IndexController
is overridden by the
AlbumController
. This can be ignored for now.
Now it's time to write our own tests!
@@ -602,7 +601,7 @@Next, edit the phpunit.xml.dist
file at the project root; we'll add a new
-test suite to it. When done, it should read as follows:
<?xml version="1.0" encoding="UTF-8"?>
<phpunit colors="true">
<testsuites>
@@ -622,9 +621,10 @@ Windows and PHPUnit
$ "vendor/bin/phpunit" --testsuite Album
You should get similar output to the following:
-PHPUnit 9.0.1 by Sebastian Bergmann and contributors.
+PHPUnit 10.5.13 by Sebastian Bergmann and contributors.
-Time: 0 seconds, Memory: 1.75Mb
+Runtime: PHP 8.3.2
+Configuration: <your_local_path>\phpunit.xml.dist
No tests executed!
Let's write our first test!
@@ -691,13 +691,16 @@ Assert against controller servi
If you run:
$ ./vendor/bin/phpunit --testsuite Album
again, you should see something like the following:
-PHPUnit 9.0.1 by Sebastian Bergmann and contributors.
+PHPUnit 10.5.13 by Sebastian Bergmann and contributors.
+
+Runtime: PHP 8.3.2
+Configuration: <your_local_path>\phpunit.xml.dist
. 1 / 1 (100%)
-Time: 124 ms, Memory: 11.50MB
+Time: 00:00.210, Memory: 14.00 MB
-OK (1 test, 5 assertions)
+OK (1 test, 7 assertions)
A successful first test!
A failing test case
We likely don't want to hit the same database during testing as we use for our
@@ -713,39 +716,73 @@
A failing test case
The above removes the 'db' configuration entirely; we'll be replacing it with
something else before long.
When we run the tests now:
-$ ./vendor/bin/phpunit --testsuite Album
-PHPUnit 9.0.1 by Sebastian Bergmann and contributors.
+$ ./vendor/bin/phpunit --testsuite Album
+PHPUnit 10.5.13 by Sebastian Bergmann and contributors.
-F
+Runtime: PHP 8.3.2
+Configuration: <your_local_path>\phpunit.xml.dist
-Time: 0 seconds, Memory: 8.50Mb
+F 1 / 1 (100%)
+
+Time: 00:00.208, Memory: 12.00 MB
There was 1 failure:
1) AlbumTest\Controller\AlbumControllerTest::testIndexActionCanBeAccessed
Failed asserting response code "200", actual status code is "500"
-{projectPath}/vendor/laminas/laminas-test/src/PHPUnit/Controller/AbstractControllerTestCase.php:{lineNumber}
-{projectPath}/module/Album/test/AlbumTest/Controller/AlbumControllerTest.php:{lineNumber}
+<your_local_path>\vendor\laminas\laminas-test\src\PHPUnit\Controller\AbstractControllerTestCase.php:433
+<your_local_path>\module\Album\test\Controller\AlbumControllerTest.php:38
+
+--
+
+There was 1 risky test:
+
+1) AlbumTest\Controller\AlbumControllerTest::testIndexActionCanBeAccessed
+This test did not perform any assertions
+
+<your_local_path>\module\Album\test\Controller\AlbumControllerTest.php:35
+
+--
+
+1 test triggered 1 PHP warning:
+
+1) <your_local_path>\vendor\laminas\laminas-db\src\Adapter\AdapterServiceFactory.php:21
+Undefined array key "db"
+
+Triggered by:
+
+* AlbumTest\Controller\AlbumControllerTest::testIndexActionCanBeAccessed
+ <your_local_path>\module\Album\test\Controller\AlbumControllerTest.php:35
FAILURES!
-Tests: 1, Assertions: 0, Failures: 1.
+Tests: 1, Assertions: 0, Failures: 1, Warnings: 1, Risky: 1.
The failure message doesn't tell us much, apart from that the expected status
code is not 200, but 500. To get a bit more information when something goes
wrong in a test case, we set the protected $traceError
member to true
(which
-is the default; we set it to false
to demonstrate this capability). Modify the
+is the default; we set it to false
to demonstrate this capability).
+We also got risky test and warning report. The warning was expected since we removed the db
+key from the configuration which is expected by the AdapterServiceFactory
database adapter factory.
+And since the test case does not execute beyond the response status code assertion, we get a risk
+test indication that the test did not performed any assertions.
+Modify the
following line from just above the setUp
method in our AlbumControllerTest
class:
protected $traceError = true;
Running the phpunit
command again and we should see some more information
about what went wrong in our test. You'll get a list of the exceptions raised,
along with their messages, the filename, and line number:
-1) AlbumTest\Controller\AlbumControllerTest::testIndexActionCanBeAccessed
+There was 1 failure:
+
+1) AlbumTest\Controller\AlbumControllerTest::testIndexActionCanBeAccessed
Failed asserting response code "200", actual status code is "500"
Exceptions raised:
-Exception 'Laminas\ServiceManager\Exception\ServiceNotCreatedException' with message 'Service with name "Laminas\Db\Adapter\AdapterInterface" could not be created. Reason: createDriver expects a "driver" key to be present inside the parameters' in {projectPath}/vendor/laminas/laminas-servicemanager/src/ServiceManager.php:{lineNumber}
+Exception 'Laminas\ServiceManager\Exception\ServiceNotCreatedException' with message 'Service with name "Laminas\Db\Adapter\AdapterInterface" could not be created. Reason: The supplied
+ or instantiated driver object does not implement Laminas\Db\Adapter\Driver\DriverInterface' in <your_local_path>\vendor\laminas\laminas-servicemanager\src\ServiceManager.php:649
-Exception 'Laminas\Db\Adapter\Exception\InvalidArgumentException' with message 'createDriver expects a "driver" key to be present inside the parameters' in {projectPath}/vendor/laminas/laminas-db/src/Adapter/Adapter.php:{lineNumber}
+Exception 'Laminas\Db\Adapter\Exception\InvalidArgumentException' with message 'The supplied or instantiated driver object does not implement Laminas\Db\Adapter\Driver\DriverInterface'
+ in <your_local_path>\vendor\laminas\laminas-db\src\Adapter\Adapter.php:78
+
Based on the exception messages, it appears we are unable to create a laminas-db
adapter instance, due to missing configuration!
Configuring the service manager for the tests
@@ -762,8 +799,8 @@ Configuring the service m
approach, but creating the adapter mock is tedious (but no doubt we will have to
create it at some point).
The best thing to do would be to mock out our Album\Model\AlbumTable
class
-which retrieves the list of albums from the database. Remember, we are now
-testing our controller, so we can mock out the actual call to fetchAll
and
+which retrieves the list of albums from the database. Remember, we are now
+testing our controller, so we can mock out the actual call to fetchAll
and
replace the return values with dummy values. At this point, we are not
interested in how fetchAll()
retrieves the albums, but only that it gets called
and that it returns an array of albums; these facts allow us to provide mock
@@ -777,12 +814,12 @@
Configuring the service m
Now add the following property to the test class:
protected $albumTable;
Next, we'll create three new methods that we'll invoke during setup:
-protected function configureServiceManager(ServiceManager $services)
+protected function configureServiceManager(ServiceManager $services): void
{
$services->setAllowOverride(true);
$services->setService('config', $this->updateConfig($services->get('config')));
- $services->setService(AlbumTable::class, $this->mockAlbumTable()->reveal());
+ $services->setService(AlbumTable::class, $this->mockAlbumTable());
$services->setAllowOverride(false);
}
@@ -793,9 +830,9 @@ Configuring the service m
return $config;
}
-protected function mockAlbumTable()
+protected function mockAlbumTable(): AlbumTable
{
- $this->albumTable = $this->prophesize(AlbumTable::class);
+ $this->albumTable = $this->createMock(AlbumTable::class);
return $this->albumTable;
}
By default, the ServiceManager
does not allow us to replace existing services.
@@ -803,12 +840,8 @@
Configuring the service m
overriding services, and then we inject specific overrides we wish to use.
When done, we disable overrides to ensure that if, during dispatch, any code
attempts to override a service, an exception will be raised.
-
The last method above creates a mock instance of our AlbumTable
using
-Prophecy, an object mocking framework
-that's bundled and integrated in PHPUnit. The instance returned by
-prophesize()
is a scaffold object; calling reveal()
on it, as done in the
-configureServiceManager()
method above, provides the underlying mock object
-that will then be asserted against.
+The last method above creates a mock instance of our AlbumTable
. The instance returned by
+$this->createMock()
is a mock AlbumTable
object that will then be asserted against.
With this in place, we can update our setUp()
method to read as follows:
protected function setUp() : void
{
@@ -828,10 +861,14 @@ Configuring the service m
$this->configureServiceManager($this->getApplicationServiceLocator());
}
Now update the testIndexActionCanBeAccessed()
method to add a line asserting
-the AlbumTable
's fetchAll()
method will be called, and return an array:
+the AlbumTable
's fetchAll()
method will be called, and return an array. This is achieved
+by configuring the mock AlbumTable
to expect the saveAlbum
method to be called once and to return
+an empty array.
public function testIndexActionCanBeAccessed()
{
- $this->albumTable->fetchAll()->willReturn([]);
+ $this->albumTable->expects($this->once())
+ ->method('fetchAll')
+ ->willReturn([]);
$this->dispatch('/album');
$this->assertResponseStatusCode(200);
@@ -843,55 +880,61 @@ Configuring the service m
Running phpunit
at this point, we will get the following output as the tests
now pass:
$ ./vendor/bin/phpunit --testsuite Album
-PHPUnit 9.0.1 by Sebastian Bergmann and contributors.
+PHPUnit 10.5.13 by Sebastian Bergmann and contributors.
+
+Runtime: PHP 8.3.2
+Configuration: <your_local_path>\phpunit.xml.dist
. 1 / 1 (100%)
-Time: 105 ms, Memory: 10.75MB
+Time: 00:00.219, Memory: 12.00 MB
-OK (1 test, 5 assertions)
+OK (1 test, 8 assertions)
Testing actions with POST
A common scenario with controllers is processing POST data submitted via a form,
as we do in the AlbumController::addAction()
. Let's write a test for that.
-public function testAddActionRedirectsAfterValidPost()
+public function testAddActionRedirectAfterValidPost()
{
- $this->albumTable
- ->saveAlbum(Argument::type(Album::class))
- ->shouldBeCalled();
+ $this->albumTable->expects($this->once())
+ ->method('saveAlbum')
+ ->with($this->isInstanceOf(Album::class));
$postData = [
- 'title' => 'Led Zeppelin III',
+ 'title' => 'Lez Zeppelin III',
'artist' => 'Led Zeppelin',
- 'id' => '',
+ 'id' => '',
];
+
$this->dispatch('/album/add', 'POST', $postData);
$this->assertResponseStatusCode(302);
$this->assertRedirectTo('/album');
}
-This test case references two new classes that we need to import; add the
-following import statements at the top of the class file:
-use Album\Model\Album;
-use Prophecy\Argument;
-Prophecy\Argument
allows us to perform assertions against the values passed as
-arguments to mock objects. In this case, we want to assert that we received an
-Album
instance. (We could have also done deeper assertions to ensure the
+
This test case references the Album
class that we need to import; add the following import statement
+ at the top of the class file:
+use Album\Model\Album;
+For this test case, the AlbumTable
mock is configured to expect the saveAlbum
+method to be called once with an argument that must be an instance of Album
.
+(We could have also done deeper assertions to ensure the
Album
instance contained expected data.)
When we dispatch the application this time, we use the request method POST, and
pass data to it. This test case then asserts a 302 response status, and
introduces a new assertion against the location to which the response redirects.
Running phpunit
gives us the following output:
$ ./vendor/bin/phpunit --testsuite Album
-PHPUnit 9.0.1 by Sebastian Bergmann and contributors.
+PHPUnit 10.5.13 by Sebastian Bergmann and contributors.
+
+Runtime: PHP 8.3.2
+Configuration: <your_local_path>\phpunit.xml.dist
.. 2 / 2 (100%)
-Time: 1.49 seconds, Memory: 13.25MB
+Time: 00:00.236, Memory: 14.00 MB
-OK (2 tests, 8 assertions)
+OK (2 tests, 11 assertions)
Testing the editAction()
and deleteAction()
methods can be performed
similarly; however, when testing the editAction()
method, you will also need
to assert against the AlbumTable::getAlbum()
method:
-$this->albumTable->getAlbum($id)->willReturn(new Album());
+$this->albumTable->expects($this->once())->method('getAlbum')->willReturn(new Album());
Ideally, you should test all the various paths through each method. For example:
- Test that a non-POST request to
addAction()
displays an empty form.
@@ -1019,13 +1062,16 @@ Testing model entities
If we run phpunit
again, we will get the following output, confirming that our
model is indeed correct:
$ ./vendor/bin/phpunit --testsuite Album
-PHPUnit 9.0.1 by Sebastian Bergmann and contributors.
+PHPUnit 10.5.13 by Sebastian Bergmann and contributors.
+
+Runtime: PHP 8.3.2
+Configuration: <your_local_path>\phpunit.xml.dist
....... 7 / 7 (100%)
-Time: 186 ms, Memory: 13.75MB
+Time: 00:00.319, Memory: 14.00 MB
-OK (7 tests, 24 assertions)
+OK (7 tests, 27 assertions)
Testing model tables
The final step in this unit testing tutorial for laminas-mvc applications is
writing tests for our model tables.
@@ -1036,27 +1082,31 @@ Testing model tables
Create a file AlbumTableTest.php
in module/Album/test/Model/
with the
following contents:
<?php
+
namespace AlbumTest\Model;
use Album\Model\AlbumTable;
-use Album\Model\Album;
-use PHPUnit\Framework\TestCase;
-use RuntimeException;
use Laminas\Db\ResultSet\ResultSetInterface;
use Laminas\Db\TableGateway\TableGatewayInterface;
+use PHPUnit\Framework\TestCase;
class AlbumTableTest extends TestCase
{
- protected function setUp() : void
+ private $tableGateway;
+ private $albumTable;
+
+ protected function setUp(): void
{
- $this->tableGateway = $this->prophesize(TableGatewayInterface::class);
- $this->albumTable = new AlbumTable($this->tableGateway->reveal());
+ $this->tableGateway = $this->createMock(TableGatewayInterface::class);
+ $this->albumTable = new AlbumTable($this->tableGateway);
}
- public function testFetchAllReturnsAllAlbums()
+ public function testFetchAllReturnsAllAlbums(): void
{
- $resultSet = $this->prophesize(ResultSetInterface::class)->reveal();
- $this->tableGateway->select()->willReturn($resultSet);
+ $resultSet = $this->createMock(ResultSetInterface::class);
+ $this->tableGateway->expects($this->once())
+ ->method('select')
+ ->willReturn($resultSet);
$this->assertSame($resultSet, $this->albumTable->fetchAll());
}
@@ -1070,13 +1120,14 @@ Testing model tables
we expect that this same ResultSet
object will be returned to the calling
method. This test should run fine, so now we can add the rest of the test
methods:
-public function testCanDeleteAnAlbumByItsId()
+public function testCanDeleteAnAlbumByItsId(): void
{
- $this->tableGateway->delete(['id' => 123])->shouldBeCalled();
+ $this->tableGateway->expects($this->once())
+ ->method('delete');
$this->albumTable->deleteAlbum(123);
}
-public function testSaveAlbumWillInsertNewAlbumsIfTheyDontAlreadyHaveAnId()
+public function testSaveAlbumWillInsertNewAlbumsIfTheyDontAlreadyHaveAnId(): void
{
$albumData = [
'artist' => 'The Military Wives',
@@ -1085,11 +1136,13 @@ Testing model tables
$album = new Album();
$album->exchangeArray($albumData);
- $this->tableGateway->insert($albumData)->shouldBeCalled();
+ $this->tableGateway->expects($this->once())
+ ->method('insert');
+
$this->albumTable->saveAlbum($album);
}
-public function testSaveAlbumWillUpdateExistingAlbumsIfTheyAlreadyHaveAnId()
+public function testSaveAlbumWillUpdateExistingAlbumsIfTheyAlreadyHaveAnId(): void
{
$albumData = [
'id' => 123,
@@ -1099,31 +1152,37 @@ Testing model tables
$album = new Album();
$album->exchangeArray($albumData);
- $resultSet = $this->prophesize(ResultSetInterface::class);
- $resultSet->current()->willReturn($album);
-
- $this->tableGateway
- ->select(['id' => 123])
- ->willReturn($resultSet->reveal());
- $this->tableGateway
- ->update(
- array_filter($albumData, function ($key) {
- return in_array($key, ['artist', 'title']);
- }, ARRAY_FILTER_USE_KEY),
- ['id' => 123]
- )->shouldBeCalled();
+ $resultSet = $this->createMock(ResultSetInterface::class);
+ $resultSet->expects($this->once())
+ ->method('current')
+ ->willReturn($album);
+
+ $this->tableGateway->expects($this->once())
+ ->method('select')
+ ->with(['id' => 123])
+ ->willReturn($resultSet);
+ $this->tableGateway->expects($this->once())
+ ->method('update')
+ ->with(
+ array_filter($albumData, function ($key) {
+ return in_array($key, ['artist', 'title']);
+ }, ARRAY_FILTER_USE_KEY),
+ ['id' => 123]
+ );
$this->albumTable->saveAlbum($album);
}
-public function testExceptionIsThrownWhenGettingNonExistentAlbum()
+public function testExceptionIsThrownWhenGettingNonExistentAlbum(): void
{
- $resultSet = $this->prophesize(ResultSetInterface::class);
- $resultSet->current()->willReturn(null);
+ $resultSet = $this->createMock(ResultSetInterface::class);
+ $resultSet->expects($this->once())
+ ->method('current')
+ ->willReturn(null);
- $this->tableGateway
- ->select(['id' => 123])
- ->willReturn($resultSet->reveal());
+ $this->tableGateway->expects($this->once())
+ ->method('select')
+ ->willReturn($resultSet);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Could not find row with identifier 123');
@@ -1143,13 +1202,16 @@ Testing model tables
Running phpunit
one last time, we get the output as follows:
$ ./vendor/bin/phpunit --testsuite Album
-PHPUnit 9.0.1 by Sebastian Bergmann and contributors.
+PHPUnit 10.5.13 by Sebastian Bergmann and contributors.
+
+Runtime: PHP 8.3.2
+Configuration: <your_local_path>\phpunit.xml.dist
-............. 13 / 13 (100%)
+............ 12 / 12 (100%)
-Time: 151 ms, Memory: 14.00MB
+Time: 00:00.326, Memory: 14.00 MB
-OK (13 tests, 31 assertions)
+OK (12 tests, 37 assertions)
Conclusion
In this short tutorial, we gave a few examples how different parts of a laminas-mvc
application can be tested. We covered setting up the environment for testing,