So you've inherited a Zend Framework 2 project, and you opened a controller for the first time, and now you're scrolling.
And scrolling.
That single indexAction is 240 lines. The editAction underneath it is another 380. There's a private helper at the bottom that builds a database connection by hand. Somewhere in the middle of saveAction, an entity is being constructed field by field from $request->getPost(), with isset() checks every other line. There are three different ways validation gets done: one with if statements, one with a half-finished Zend\Validator, and one that just trusts the input.
You're not the first person to find this. ZF2 controllers attract weight the way a long flight attracts checked bags. The framework gives you a ServiceManager, a Form component, a Hydrator system, and dependency injection, but it never forces you to use them, so a tired team under deadline pressure stuffs everything into the controller and ships.
This piece walks through how to pull that mess apart. Not with a rewrite, and not with a clever pattern that secretly creates four more layers. Just the basic move: controllers route, services decide, forms validate, hydrators map. Each piece does one thing. By the end, your editAction is twelve lines and you can actually unit-test the business logic without spinning up the whole HTTP stack.
What A Fat ZF2 Controller Usually Looks Like
Before we refactor anything, let's stare at the patient. Here's a representative UserController::editAction from a real-world ZF2 codebase, simplified down to the shape you'll recognise.
namespace User\Controller;
use Zend\Mvc\Controller\AbstractActionController;
use Zend\View\Model\ViewModel;
class UserController extends AbstractActionController
{
public function editAction()
{
$id = (int) $this->params()->fromRoute('id', 0);
if (! $id) {
return $this->redirect()->toRoute('user');
}
$sm = $this->getServiceLocator();
$dbAdapter = $sm->get('Zend\Db\Adapter\Adapter');
$sql = 'SELECT * FROM users WHERE id = ?';
$statement = $dbAdapter->createStatement($sql, [$id]);
$result = $statement->execute();
$row = $result->current();
if (! $row) {
return $this->redirect()->toRoute('user');
}
$user = new \User\Model\User();
$user->id = $row['id'];
$user->email = $row['email'];
$user->firstName = $row['first_name'];
$user->lastName = $row['last_name'];
$user->role = $row['role'];
$request = $this->getRequest();
if ($request->isPost()) {
$post = $request->getPost();
$errors = [];
if (empty($post['email']) || ! filter_var($post['email'], FILTER_VALIDATE_EMAIL)) {
$errors['email'] = 'Email is invalid';
}
if (empty($post['firstName'])) {
$errors['firstName'] = 'First name is required';
}
if (! in_array($post['role'], ['admin', 'editor', 'viewer'])) {
$errors['role'] = 'Invalid role';
}
if (! $errors) {
$user->email = $post['email'];
$user->firstName = $post['firstName'];
$user->lastName = $post['lastName'] ?? null;
$user->role = $post['role'];
$update = 'UPDATE users SET email = ?, first_name = ?, last_name = ?, role = ? WHERE id = ?';
$stmt = $dbAdapter->createStatement($update, [
$user->email,
$user->firstName,
$user->lastName,
$user->role,
$user->id,
]);
$stmt->execute();
// Send a notification email — direct mail call, no queue.
$mailService = $sm->get('Application\Mail\Mailer');
$mailService->send($user->email, 'Your profile was updated', 'Hi ' . $user->firstName);
return $this->redirect()->toRoute('user');
}
return new ViewModel(['user' => $user, 'errors' => $errors]);
}
return new ViewModel(['user' => $user]);
}
}
Count what this one action is doing.
It's reading a route parameter. It's pulling a service from the locator at request time. It's writing raw SQL. It's hydrating an entity field by field. It's running ad-hoc validation with PHP's built-in filters. It's mutating the entity from $_POST again, field by field, with a different set of names this time. It's writing raw SQL again. It's sending an email. It's deciding which view to render and what data to pass.
This is eight responsibilities in one method. Every one of them is a reason for this action to change. Add a new field, change the email template, swap MySQL for Postgres, tighten the role list, and they all touch this method. That's the textbook smell.
The fix is not a magic pattern. It's just routing each responsibility to the part of ZF2 that exists to handle it.

The Plan: Four Boxes, One Direction
We'll move responsibilities out of the controller in this order, because each step makes the next one easier.
- Services. Anything that "decides" or "does" (update a user, send a notification, charge a card) moves into a service class behind the
ServiceManager. The controller gets the service via constructor injection through a factory. - Forms with
InputFilter. All validation goes into aZend\Form\Formsubclass that exposes anInputFilter. The controller stops writingif (empty(...)). - Hydrators. Mapping
$post↔ entity ↔ database row stops being done by hand. AZend\Hydrator\Hydratorinstance owns it. - Repositories. SQL leaves the controller for good. A small repository wraps the
Zend\Db\Adapter\Adapterand exposes intention-revealing methods likefindById($id)andsave(User $user).
By the end, the controller doesn't know how validation works, doesn't know how the user is stored, doesn't know that email is delivered with SMTP. It knows: "given a route param, ask the service to fetch a user; if this is a POST, hand the form data to the form; if the form is valid, ask the service to save the result; pick a view; return."
Step 1: Move Business Logic Into A Service
The first cut is the most valuable. Everything inside editAction that isn't strictly about HTTP (fetching, updating, emailing) becomes a method on a service.
namespace User\Service;
use User\Model\User;
use User\Repository\UserRepositoryInterface;
use Application\Mail\MailerInterface;
class UserService
{
private $users;
private $mailer;
public function __construct(UserRepositoryInterface $users, MailerInterface $mailer)
{
$this->users = $users;
$this->mailer = $mailer;
}
public function findById(int $id): ?User
{
return $this->users->findById($id);
}
public function update(User $user): void
{
$this->users->save($user);
$this->mailer->send(
$user->getEmail(),
'Your profile was updated',
'Hi ' . $user->getFirstName()
);
}
}
Two methods. Two intentions. Notice that the service depends on interfaces, not concretions: UserRepositoryInterface and MailerInterface. That single decision is what makes the service unit-testable later. You can pass in test doubles without booting the framework.
The service gets wired up through a factory class, which is the ZF2 idiom that replaces the deprecated getServiceLocator() call inside controllers.
namespace User\Service;
use Interop\Container\ContainerInterface;
use Zend\ServiceManager\Factory\FactoryInterface;
use User\Repository\UserRepositoryInterface;
use Application\Mail\MailerInterface;
class UserServiceFactory implements FactoryInterface
{
public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
{
return new UserService(
$container->get(UserRepositoryInterface::class),
$container->get(MailerInterface::class)
);
}
}
Register it in the module config:
return [
'service_manager' => [
'factories' => [
\User\Service\UserService::class => \User\Service\UserServiceFactory::class,
\User\Repository\UserRepositoryInterface::class => \User\Repository\UserRepositoryFactory::class,
],
],
// ...
];
The pattern is the same for every service in the module: a class with explicit constructor dependencies, a factory that wires them, one line in the config. Once you've done it twice, it becomes muscle memory.
Step 2: Inject The Service Into The Controller
The controller now needs the service, not the locator. Same factory pattern.
namespace User\Controller;
use Zend\Mvc\Controller\AbstractActionController;
use User\Service\UserService;
use User\Form\UserForm;
class UserController extends AbstractActionController
{
private $userService;
private $userForm;
public function __construct(UserService $userService, UserForm $userForm)
{
$this->userService = $userService;
$this->userForm = $userForm;
}
}
And the controller factory:
namespace User\Controller;
use Interop\Container\ContainerInterface;
use Zend\ServiceManager\Factory\FactoryInterface;
use User\Service\UserService;
use User\Form\UserForm;
class UserControllerFactory implements FactoryInterface
{
public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
{
return new UserController(
$container->get(UserService::class),
$container->get('FormElementManager')->get(UserForm::class)
);
}
}
Register the controller factory under controllers => factories (not service_manager):
return [
'controllers' => [
'factories' => [
\User\Controller\UserController::class => \User\Controller\UserControllerFactory::class,
],
],
'form_elements' => [
'factories' => [
\User\Form\UserForm::class => \Zend\ServiceManager\Factory\InvokableFactory::class,
],
],
// ...
];
FormElementManager is the dedicated plugin manager for forms. Pulling forms through it rather than the main service manager gives you correctly initialised form elements with their InputFilter wired up, which matters as soon as we add the form in the next step.
Step 3: Move Validation Into A Form
Now we kill the hand-rolled validation. A ZF2 form is two things glued together: a set of Element definitions (which drive both rendering and binding) and an InputFilter (which drives validation and filtering). You can keep them in one class.
namespace User\Form;
use Zend\Form\Form;
use Zend\InputFilter\InputFilter;
use Zend\Validator;
use Zend\Filter;
class UserForm extends Form
{
public function __construct($name = 'user')
{
parent::__construct($name);
$this->add([
'name' => 'email',
'type' => 'Email',
'options' => ['label' => 'Email'],
]);
$this->add([
'name' => 'firstName',
'type' => 'Text',
'options' => ['label' => 'First name'],
]);
$this->add([
'name' => 'lastName',
'type' => 'Text',
'options' => ['label' => 'Last name'],
]);
$this->add([
'name' => 'role',
'type' => 'Select',
'options' => [
'label' => 'Role',
'value_options' => [
'admin' => 'Admin',
'editor' => 'Editor',
'viewer' => 'Viewer',
],
],
]);
$this->setInputFilter($this->buildInputFilter());
}
private function buildInputFilter(): InputFilter
{
$filter = new InputFilter();
$filter->add([
'name' => 'email',
'required' => true,
'filters' => [
['name' => Filter\StringTrim::class],
['name' => Filter\StringToLower::class],
],
'validators' => [
['name' => Validator\EmailAddress::class],
],
]);
$filter->add([
'name' => 'firstName',
'required' => true,
'filters' => [['name' => Filter\StringTrim::class]],
'validators' => [
[
'name' => Validator\StringLength::class,
'options' => ['min' => 1, 'max' => 80],
],
],
]);
$filter->add([
'name' => 'lastName',
'required' => false,
'filters' => [['name' => Filter\StringTrim::class]],
]);
$filter->add([
'name' => 'role',
'required' => true,
'validators' => [
[
'name' => Validator\InArray::class,
'options' => [
'haystack' => ['admin', 'editor', 'viewer'],
],
],
],
]);
return $filter;
}
}
A few things to notice.
The element definitions and the input filter are colocated. You don't have to chase rules across files. When somebody asks "what does the email field accept?", the answer lives one method away.
Validators are stacked. The email field runs StringTrim → StringToLower → EmailAddress. Filters change the value; validators inspect it; their order matters. The framework runs filters before validators, so by the time EmailAddress sees the input, whitespace is gone and casing is normalised.
role is locked to a known set via InArray. The hand-written check in the original controller did the same thing, but now the list lives next to the Select options, which means rendering and validation can't drift apart, because they read from the same form definition.
Step 4: Hydrate Instead Of Hand-Mapping
The line you wrote ten times in the original ($user->firstName = $post['firstName']) is what hydrators erase.
A hydrator is an object that knows how to move data between an associative array and an entity, both ways. ZF2 ships a handful: ArraySerializableHydrator, ClassMethodsHydrator, ObjectPropertyHydrator, ReflectionHydrator. For a normal entity with getters and setters, ClassMethodsHydrator is the right default.
First, give your User model proper getters and setters so the hydrator has something to call.
namespace User\Model;
class User
{
private $id;
private $email;
private $firstName;
private $lastName;
private $role;
public function getId(): ?int { return $this->id; }
public function setId(int $id): self { $this->id = $id; return $this; }
public function getEmail(): ?string { return $this->email; }
public function setEmail(string $email): self { $this->email = $email; return $this; }
public function getFirstName(): ?string { return $this->firstName; }
public function setFirstName(string $firstName): self { $this->firstName = $firstName; return $this; }
public function getLastName(): ?string { return $this->lastName; }
public function setLastName(?string $lastName): self { $this->lastName = $lastName; return $this; }
public function getRole(): ?string { return $this->role; }
public function setRole(string $role): self { $this->role = $role; return $this; }
}
Now tell the form to use a hydrator and bind it to a User:
use Zend\Hydrator\ClassMethodsHydrator;
use User\Model\User;
// inside __construct, after element definitions:
$this->setHydrator(new ClassMethodsHydrator(false));
$this->setObject(new User());
The false flag turns off the underscore-to-camelCase conversion, so the hydrator pairs an array key named firstName directly with setFirstName, instead of expecting first_name and converting it. Pick whichever convention matches your model and form field names.
Once a form has both a hydrator and a bound object, two things happen automatically.
When you call $form->bind($user) before populating, the form pre-fills its elements from the entity (getFirstName() → firstName field). When the form is valid and you call $form->getData(), you get back not an array but the same User instance, with setFirstName(...) etc. already called on it.
In a controller, that pair turns five lines of $user->firstName = $post['firstName'] into one method call.
The repository can use the same hydrator in reverse to load rows from the database:
namespace User\Repository;
use Zend\Db\Adapter\Adapter;
use Zend\Db\Sql\Sql;
use Zend\Hydrator\HydratorInterface;
use User\Model\User;
class UserRepository implements UserRepositoryInterface
{
private $db;
private $hydrator;
public function __construct(Adapter $db, HydratorInterface $hydrator)
{
$this->db = $db;
$this->hydrator = $hydrator;
}
public function findById(int $id): ?User
{
$sql = new Sql($this->db);
$select = $sql->select('users')->where(['id' => $id]);
$row = $sql->prepareStatementForSqlObject($select)->execute()->current();
if (! $row) {
return null;
}
return $this->hydrator->hydrate($this->normalizeRow($row), new User());
}
public function save(User $user): void
{
$sql = new Sql($this->db);
$data = $this->hydrator->extract($user);
unset($data['id']);
if ($user->getId()) {
$update = $sql->update('users')->set($this->denormalize($data))->where(['id' => $user->getId()]);
$sql->prepareStatementForSqlObject($update)->execute();
return;
}
$insert = $sql->insert('users')->values($this->denormalize($data));
$sql->prepareStatementForSqlObject($insert)->execute();
}
private function normalizeRow(array $row): array
{
return [
'id' => (int) $row['id'],
'email' => $row['email'],
'firstName' => $row['first_name'],
'lastName' => $row['last_name'],
'role' => $row['role'],
];
}
private function denormalize(array $data): array
{
return [
'email' => $data['email'],
'first_name' => $data['firstName'],
'last_name' => $data['lastName'],
'role' => $data['role'],
];
}
}
The two small normalizeRow / denormalize methods are doing the column-to-property translation in one obvious place. You could push that into a dedicated DbToEntityHydrator (extending AbstractHydrator and implementing a naming strategy), and for a project with many tables it's worth doing. For one entity, inline is fine.
Step 5: The Refactored Controller
After all that, the controller shrinks to this.
namespace User\Controller;
use Zend\Mvc\Controller\AbstractActionController;
use Zend\View\Model\ViewModel;
use User\Service\UserService;
use User\Form\UserForm;
class UserController extends AbstractActionController
{
private $userService;
private $userForm;
public function __construct(UserService $userService, UserForm $userForm)
{
$this->userService = $userService;
$this->userForm = $userForm;
}
public function editAction()
{
$id = (int) $this->params()->fromRoute('id', 0);
$user = $id ? $this->userService->findById($id) : null;
if (! $user) {
return $this->redirect()->toRoute('user');
}
$this->userForm->bind($user);
$request = $this->getRequest();
if ($request->isPost()) {
$this->userForm->setData($request->getPost());
if ($this->userForm->isValid()) {
$this->userService->update($this->userForm->getData());
return $this->redirect()->toRoute('user');
}
}
return new ViewModel([
'form' => $this->userForm,
]);
}
}
Compare the two editActions side by side and you can read the difference in your head without counting lines. The first one describes what should happen. The new one delegates it. There's no SQL, no validation, no entity mutation, no mailer call, no service locator. The controller only knows three verbs: findById, bind/setData/isValid/getData, and update.
That's the shape every action in your module should converge toward.
What This Buys You
Refactoring is only worth doing if it pays. Here's what changes in practice once a module looks like this.
Testing stops requiring the framework. UserService takes a UserRepositoryInterface and a MailerInterface. In a unit test you pass two in-memory fakes, call $service->update($user), and assert what happened. Zero MVC, zero database, zero email server. The same test that used to need a full ZF2 bootstrap now runs in milliseconds.
Adding a field is one change in one place per layer. When a nickname column is added to users, you update the entity (getter/setter), add it to the form, add it to the input filter rules, and add it to the row mapper. Each of those edits is small and obvious. In the old controller, you'd be hunting through one 600-line method to find every spot that mentions firstName so you can copy-paste the pattern.
Roles change at the rule, not the call site. Want to add a support role? Edit value_options and InArray::options.haystack in UserForm. Done. Every action that uses this form gets the new role, every view that renders the select shows it, every validation that runs rejects anything else.
Storage swaps stay below the service. When the team decides Doctrine ORM was a better idea after all, UserRepository is replaced and UserRepositoryInterface stays. The service doesn't notice, the controller doesn't notice, the views don't notice. That's the whole point of the interface boundary.
When Not To Split
A note before you go on a refactoring spree.
If a module has one tiny controller with three actions and no business logic, splitting it into a service-form-hydrator-repository stack is overkill. You'll create four classes that each have one method. The framework's not going to thank you, and the next maintainer will wonder what you were defending against.
The split pays off when the controller is doing real work: validation, persistence, side effects, branching by state. A HelloWorldController::indexAction returning a fixed ViewModel doesn't need a service. Use the same judgment you'd use anywhere else: extract when it earns it, leave it alone when it doesn't.
The fat-controller problem isn't that controllers are big. It's that they're doing somebody else's job. Once the form is doing form work and the service is doing business work, your controllers stop growing, even when the system does.





