On this page
In-Depth Tutorial
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 aViewModel
. 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.