Implementing the Domain Model - Entries and Authors
Chapter 6

Implementing the Domain Model - Entries and Authors

At a Glance

This chapter puts the model theory into practice by implementing Entry and Author domain entities, building data mapper classes for persistence, defining entity relationships, and wiring the domain layer into controllers and views.

What Changed Since Zend Framework 1

Modern PHP codebases typically handle entity relationships through ORMs like Doctrine (with annotations or attributes for mapping) or Eloquent (with relationship methods). The manual mapper approach shown here gives you a clearer understanding of what those ORMs do under the hood and helps you debug the inevitable edge cases that automated tools handle poorly.

Theory is worthless until you write code. The previous chapter explained why domain entities, data mappers, and persistence separation matter. This chapter implements them. As part of the Survive The Deep End: PHP and Zend Framework book, it takes the blogging application from earlier and refactors it with proper Entry and Author entity classes, dedicated mapper objects for each, relationship loading between entities, collection handling, and the controller wiring that connects the domain layer to HTTP requests and view rendering. Every pattern here - entity design, mapper persistence, lazy-loaded relationships, testable domain objects - translates directly to how modern ORMs like Doctrine and Eloquent work internally.

The Database Schema

The entries table from the previous chapter gets an author_id column, and we add an authors table:

1
2
3
4
5
6
7
8
9
10
11
12
CREATE TABLE authors (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(255) NOT NULL,
url VARCHAR(255) DEFAULT NULL,
UNIQUE KEY (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

ALTER TABLE entries
ADD COLUMN author_id INT NOT NULL AFTER id,
ADD CONSTRAINT fk_entries_author
FOREIGN KEY (author_id) REFERENCES authors(id);

The foreign key constraint enforces referential integrity at the database level. Every entry must have a valid author. This is a database-level rule, but the domain model should enforce it too. You do not rely on the database alone for business rules.

The Author Entity

The Author entity is straightforward. It holds author data and enforces invariants:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
<?php
class Author
{
private $id;
private $name;
private $email;
private $url;

public function __construct($name, $email)
{
$this->setName($name);
$this->setEmail($email);
}

public function setName($name)
{
$name = trim($name);
if (strlen($name) === 0) {
throw new DomainException('Author name cannot be empty');
}
if (strlen($name) > 100) {
throw new DomainException('Author name cannot exceed 100 characters');
}
$this->name = $name;
}

public function getName()
{
return $this->name;
}

public function setEmail($email)
{
$email = trim($email);
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new DomainException('Invalid email address');
}
$this->email = strtolower($email);
}

public function getEmail()
{
return $this->email;
}

public function setUrl($url)
{
if ($url !== null && !filter_var($url, FILTER_VALIDATE_URL)) {
throw new DomainException('Invalid URL');
}
$this->url = $url;
}

public function getUrl()
{
return $this->url;
}

public function getId()
{
return $this->id;
}

public function setId($id)
{
$this->id = (int) $id;
}
}

The email validation uses filter_var(), which is sufficient for most cases. The email is normalised to lowercase on set. The URL is optional and validated only when provided. These are domain rules, not form rules. Even if you create an Author programmatically without a form, these rules still apply.

Refactoring the Entry Entity

The Entry class from the previous chapter needs to support an author relationship:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
<?php
class Entry
{
private $id;
private $title;
private $body;
private $createdAt;
private $author;

public function __construct($title, $body, Author $author)
{
$this->setTitle($title);
$this->setBody($body);
$this->setAuthor($author);
$this->createdAt = new DateTime();
}

public function setAuthor(Author $author)
{
$this->author = $author;
}

public function getAuthor()
{
return $this->author;
}

public function setTitle($title)
{
$title = trim($title);
if (strlen($title) === 0) {
throw new DomainException('Entry title cannot be empty');
}
if (strlen($title) > 255) {
throw new DomainException('Entry title cannot exceed 255 characters');
}
$this->title = $title;
}

public function getTitle()
{
return $this->title;
}

public function setBody($body)
{
if (trim($body) === '') {
throw new DomainException('Entry body cannot be empty');
}
$this->body = $body;
}

public function getBody()
{
return $this->body;
}

public function getId()
{
return $this->id;
}

public function setId($id)
{
$this->id = (int) $id;
}

public function getCreatedAt()
{
return $this->createdAt;
}

public function setCreatedAt(DateTime $date)
{
$this->createdAt = $date;
}
}

The constructor now requires an Author object. You cannot create an entry without an author. The type hint enforces this at the PHP level. If someone passes null or a string, PHP throws a fatal error before your code even runs. This is a deliberate design choice: the entity makes invalid states impossible to represent.

The Author Mapper

The Author mapper follows the same pattern as the Entry mapper from the previous chapter:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
<?php
class AuthorMapper
{
protected $_dbTable;

public function __construct(Zend_Db_Table_Abstract $dbTable = null)
{
$this->_dbTable = $dbTable ?: new Application_Model_DbTable_Author();
}

public function save(Author $author)
{
$data = array(
'name' => $author->getName(),
'email' => $author->getEmail(),
'url' => $author->getUrl(),
);

if ($author->getId() === null) {
$id = $this->_dbTable->insert($data);
$author->setId($id);
} else {
$this->_dbTable->update($data, array('id = ?' => $author->getId()));
}
}

public function find($id)
{
$row = $this->_dbTable->find($id)->current();
if (!$row) {
return null;
}
return $this->_rowToEntity($row);
}

public function findByEmail($email)
{
$row = $this->_dbTable->fetchRow(
$this->_dbTable->select()->where('email = ?', strtolower($email))
);
if (!$row) {
return null;
}
return $this->_rowToEntity($row);
}

protected function _rowToEntity($row)
{
$author = new Author($row->name, $row->email);
$author->setId($row->id);
if ($row->url) {
$author->setUrl($row->url);
}
return $author;
}
}

The findByEmail method is a domain-specific query. Not everything is a simple find($id). Your mapper should expose methods that match how the application actually looks up data. If the blogging app needs to find authors by email (for login, for duplicate checking), the mapper provides that method.

The Entry Mapper with Relationship Loading

The Entry mapper now needs to load the associated Author when converting database rows to entities:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
<?php
class EntryMapper
{
protected $_dbTable;
protected $_authorMapper;

public function __construct(
Zend_Db_Table_Abstract $dbTable = null,
AuthorMapper $authorMapper = null
) {
$this->_dbTable = $dbTable ?: new Application_Model_DbTable_Entry();
$this->_authorMapper = $authorMapper ?: new AuthorMapper();
}

public function save(Entry $entry)
{
$data = array(
'title' => $entry->getTitle(),
'body' => $entry->getBody(),
'author_id' => $entry->getAuthor()->getId(),
'created_at' => $entry->getCreatedAt()->format('Y-m-d H:i:s'),
);

if ($entry->getId() === null) {
$id = $this->_dbTable->insert($data);
$entry->setId($id);
} else {
$this->_dbTable->update($data, array('id = ?' => $entry->getId()));
}
}

public function find($id)
{
$row = $this->_dbTable->find($id)->current();
if (!$row) {
return null;
}
return $this->_rowToEntity($row);
}

public function fetchAll()
{
$rows = $this->_dbTable->fetchAll(
$this->_dbTable->select()->order('created_at DESC')
);
$entries = array();
foreach ($rows as $row) {
$entries[] = $this->_rowToEntity($row);
}
return $entries;
}

public function fetchByAuthor(Author $author)
{
$rows = $this->_dbTable->fetchAll(
$this->_dbTable->select()
->where('author_id = ?', $author->getId())
->order('created_at DESC')
);
$entries = array();
foreach ($rows as $row) {
$entries[] = $this->_rowToEntity($row, $author);
}
return $entries;
}

protected function _rowToEntity($row, Author $author = null)
{
if ($author === null) {
$author = $this->_authorMapper->find($row->author_id);
}

$entry = new Entry($row->title, $row->body, $author);
$entry->setId($row->id);
$entry->setCreatedAt(new DateTime($row->created_at));
return $entry;
}
}

The _rowToEntity method accepts an optional Author parameter. When loading entries for a specific author (via fetchByAuthor), the author is already known, so passing it in avoids a redundant database lookup for each row. When loading a single entry or all entries, the mapper fetches the author from the AuthorMapper.

This is the N+1 query problem in its raw form. Loading 50 entries triggers 50 author lookups. For a blog with a single author, this is fine - the same author gets loaded once and cached by the mapper if you add caching. For a multi-author blog at scale, you would batch-load the authors in a single query. ORMs like Doctrine handle this with eager loading and proxy objects. Here, you handle it manually, which means you see exactly what queries run and when.

Wiring the Domain Layer into Controllers

The controller changes are minimal. Instead of calling Zend_Db_Table directly, you call the mapper:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
<?php
class EntryController extends Zend_Controller_Action
{
protected $_entryMapper;
protected $_authorMapper;

public function init()
{
$this->_entryMapper = new EntryMapper();
$this->_authorMapper = new AuthorMapper();
}

public function indexAction()
{
$this->view->entries = $this->_entryMapper->fetchAll();
}

public function viewAction()
{
$id = $this->getRequest()->getParam('id');
$entry = $this->_entryMapper->find($id);

if (!$entry) {
throw new Zend_Controller_Action_Exception('Entry not found', 404);
}

$this->view->entry = $entry;
}

public function createAction()
{
$form = new Application_Form_Entry();

if ($this->getRequest()->isPost() && $form->isValid($this->getRequest()->getPost())) {
$author = $this->_authorMapper->find($form->getValue('author_id'));
if (!$author) {
throw new Zend_Controller_Action_Exception('Invalid author', 400);
}

$entry = new Entry(
$form->getValue('title'),
$form->getValue('body'),
$author
);
$this->_entryMapper->save($entry);
$this->_helper->flashMessenger->addMessage('Entry created.');
return $this->_helper->redirector('index');
}

$this->view->form = $form;
}
}

The controller is thinner now. It coordinates between the HTTP request, the form, the mapper, and the view. It does not contain business logic. It does not contain SQL. The createAction loads the author, constructs the entity (which validates the data), and asks the mapper to save. If any step fails, an exception propagates to the error handler.

Testing Domain Objects

Entity tests remain fast and database-free:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<?php
class EntryTest extends PHPUnit_Framework_TestCase
{
private $_author;

public function setUp()
{
$this->_author = new Author('Test Author', 'test@example.com');
$this->_author->setId(1);
}

public function testRequiresAuthor()
{
$entry = new Entry('Title', 'Body content', $this->_author);
$this->assertSame($this->_author, $entry->getAuthor());
}

public function testEmptyTitleThrowsException()
{
$this->setExpectedException('DomainException');
new Entry('', 'Body content', $this->_author);
}

public function testAuthorRelationship()
{
$entry = new Entry('Test', 'Body', $this->_author);
$this->assertEquals('Test Author', $entry->getAuthor()->getName());
}
}

Mapper tests use mock Zend_Db_Table objects to verify that the mapper calls the right methods with the right data, without touching a real database. Integration tests against a test database verify the full round trip.

Frequently Asked Questions

Why not just use JOINs and return flat arrays?
You can. For read-only reporting queries, flat arrays are often appropriate. But flat arrays do not enforce business rules. They do not validate data on construction. They do not protect invariants. Domain entities give you a place to put that logic. If you skip entities, the validation and rule enforcement scatters across controllers, forms, and ad hoc checks throughout the codebase.

How do I handle collections of entities?
In this example, fetchAll() returns a plain PHP array of Entry objects. For more sophisticated needs - lazy loading, filtering, sorting without hitting the database - you can wrap results in a collection class. Doctrine uses ArrayCollection and PersistentCollection. For a ZF1 application, a simple array is usually sufficient until you have a specific reason to add collection behaviour.

Should the Author mapper be injected into the Entry mapper?
Yes, and this example does that through the constructor. It is basic dependency injection. If you wanted to test the Entry mapper in isolation, you would pass a mock Author mapper that returns predictable Author objects without touching the database. This makes tests fast and reliable.

What if I need to update both the entry and the author in one operation?
That is a service layer concern. The controller would call a service method like $entryService->updateEntryWithAuthor($entryData, $authorData), and the service would coordinate the two mapper calls inside a database transaction. Neither mapper knows about the other’s transactions. The service wraps both in a single beginTransaction / commit / rollBack block.