From a general perspective, a web application based on the MVC concept is composed of two layers: the model layer and the presentation layer. Their implementation achieves the - goal of - separation of concerns.
The model layer consists of three sublayers:
- The domain layer (domain model), consisting of the domain objects (the in-memory objects) - also known as models. They are the entities that encapsulate the business logic. So, through their structure and interdependence with each other, they are an abstraction of the real world (business) objects/entities. This layer could also contain structures like collections of domain objects.
- The storage layer, composed from the classes responsible with the transfer of the domain objects into/from the underlying storage system (may it be RDBMS, session, file system etc): repositories, (data) mappers, adapters, data-access abstraction classes (PDO, MySQLi - and their wrappers), etc. The use of these structures also achieve the purpose of making the domain objects (completely) agnostic, unknowledgeable to the storage type and the way in which it is addressed.
- The service layer is built from classes (e.g. services) that execute operations involving the structures from the upper two sublayers. For example, a service fetches a domain object from the storage system, make some validations based on it's state (properties) and returns corresponding results.
The presentation layer consists of:
- Views.
- Controllers.
- [View-models]
Notice that I didn't complete the description of this layer. I did it on purpose, because I think it's better for you to follow this links, in order to gain the correct perspective on this subject:
About your second question: Indeed, ORMs automatize the mapping between domain layer and database. They are useful, but also come with disadvantages, because they are forcing you to think in terms of business logic PLUS database structure. "One class per table" as in (Table Data Gateway), "protected $tableName;" as in the parent class Mapper of DMM, "class User extends ActiveRecord" as in Active Record, etc, are signs of flexibility limitations. For example, as I saw in the DMM code, it forces you to provide a $tableName
and $identityFields
in Mapper constructor. That's a big limitation.
Anyway, if you want to be really flexible in tasks involving (complex) querying of database, then keep it simple:
- Keep the domain objects completely unaware of storage system.
- Implement the data mapper pattern without inheriting the specific mappers from any parent mapper! In the methods of the specific data mapppers (save, update, insert, delete, find, findByXXX, etc) you can then use pure SQL, infinitely complex. Read PHP MVC: Data Mapper pattern: class design. Of course, this way you'll write a bit more sql... and become an SQL-virtuoso! :-) Please notice that any other "solution" will reduce the sql flexibility.
- If you really need to abstract from the sql language (SQL, T-SQL, PL/SQL, etc), you can even implement your own query builder class and use it's instance inside the data mapper methods, instead of the sql statements. Read PHP MVC: Query builder class for Data Mapper layer.
- Implement an Adapter class, 90% similar to the one in DMM. Things like
tableName
should not appear there.
- Create a PDO connection and inject it in the constructor of the Adapter object. NB: Don't create PDO inside Adapter, as DMM does, because their adapter class is then tight coupled to PDO. Yours should be - correctly - loosely coupled. You're achieving this through dependency injection - see The Clean Code Talks - Don't Look For Things!.
- Try to use a dependency injection container. Like Auryn. It will take care of the instantiation and sharing of all classes at the entry point of your application (index.php, or bootstrap.php) only. Best example is the PDO connection, shared through the complete cycle of a web MVC. It's very powerful, very easy to learn and use, and will make your MVC really slim. Watch Dependency Injection and Dependency Inversion in PHP first.
Later you'll want to create repositories and services too.
So, to close your first question: there is a very good explained article series, exactly about what you're interested in. After you'll read them, you'll have no doubt anymore about how all model layer components work together. NB: You'll see there the same $tableName
as property, but now you know from which perspective to consider it. So:
And here is a version of a mapper, inspired from the above articles. Notice the absence of inheritance from a parent/abstract class. To find out the reasons, read the great answer from PHP MVC: Data Mapper pattern: class design.
Data mapper class:
<?php
/*
* User mapper.
*
* Copyright © 2017 SitePoint
* THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
* INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
* PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
namespace App\Modules\Connects\Models\Mappers;
use App\Modules\Connects\Models\Models\User;
use App\Modules\Connects\Models\Models\UserInterface;
use App\Modules\Connects\Models\Mappers\UserMapperInterface;
use App\Modules\Connects\Models\Collections\UserCollectionInterface;
use App\Core\Model\Storage\Adapter\Database\DatabaseAdapterInterface;
/**
* User mapper.
*/
class UserMapper implements UserMapperInterface {
/**
* Adapter.
*
* @var DatabaseAdapterInterface
*/
private $adapter;
/**
* User collection.
*
* @var UserCollectionInterface
*/
private $userCollection;
/**
*
* @param DatabaseAdapterInterface $adapter Adapter.
* @param UserCollectionInterface $userCollection User collection.
*/
public function __construct(DatabaseAdapterInterface $adapter, UserCollectionInterface $userCollection) {
$this
->setAdapter($adapter)
->setUserCollection($userCollection)
;
}
/**
* Find user by id.
*
* @param int $id User id.
* @return UserInterface User.
*/
public function findById($id) {
$sql = "SELECT * FROM users WHERE id=:id";
$bindings = [
'id' => $id
];
$row = $this->getAdapter()->selectOne($sql, $bindings);
return $this->createUser($row);
}
/**
* Find users by criteria.
*
* @param array $filter [optional] WHERE conditions.
* @return UserCollectionInterface User collection.
*/
public function find(array $filter = array()) {
$conditions = array();
foreach ($filter as $key => $value) {
$conditions[] = $key . '=:' . $key;
}
$whereClause = implode(' AND ', $conditions);
$sql = sprintf('SELECT * FROM users %s'
, !empty($filter) ? 'WHERE ' . $whereClause : ''
);
$bindings = $filter;
$rows = $this->getAdapter()->select($sql, $bindings);
return $this->createUserCollection($rows);
}
/**
* Insert user.
*
* @param UserInterface $user User.
* @return UserInterface Inserted user (saved data may differ from initial user data).
*/
public function insert(UserInterface $user) {
$properties = get_object_vars($user);
$columnsClause = implode(',', array_keys($properties));
$values = array();
foreach (array_keys($properties) as $column) {
$values[] = ':' . $column;
}
$valuesClause = implode(',', $values);
$sql = sprintf('INSERT INTO users (%s) VALUES (%s)'
, $columnsClause
, $valuesClause
);
$bindings = $properties;
$this->getAdapter()->insert($sql, $bindings);
$lastInsertId = $this->getAdapter()->getLastInsertId();
return $this->findById($lastInsertId);
}
/**
* Update user.
*
* @param UserInterface $user User.
* @return UserInterface Updated user (saved data may differ from initial user data).
*/
public function update(UserInterface $user) {
$properties = get_object_vars($user);
$columns = array();
foreach (array_keys($properties) as $column) {
if ($column !== 'id') {
$columns[] = $column . '=:' . $column;
}
}
$columnsClause = implode(',', $columns);
$sql = sprintf('UPDATE users SET %s WHERE id = :id'
, $columnsClause
);
$bindings = $properties;
$this->getAdapter()->update($sql, $bindings);
return $this->findById($user->id);
}
/**
* Delete user.
*
* @param UserInterface $user User.
* @return bool TRUE if user successfully deleted, FALSE otherwise.
*/
public function delete(UserInterface $user) {
$sql = 'DELETE FROM users WHERE id=:id';
$bindings = array(
'id' => $user->id
);
$rowCount = $this->getAdapter()->delete($sql, $bindings);
return $rowCount > 0;
}
/**
* Create user.
*
* @param array $row Table row.
* @return UserInterface User.
*/
public function createUser(array $row) {
$user = new User();
foreach ($row as $key => $value) {
$user->$key = $value;
}
return $user;
}
/**
* Create user collection.
*
* @param array $rows Table rows.
* @return UserCollectionInterface User collection.
*/
public function createUserCollection(array $rows) {
$this->getUserCollection()->clear();
foreach ($rows as $row) {
$user = $this->createUser($row);
$this->getUserCollection()->add($user);
}
return $this->getUserCollection()->toArray();
}
/**
* Get adapter.
*
* @return DatabaseAdapterInterface
*/
public function getAdapter() {
return $this->adapter;
}
/**
* Set adapter.
*
* @param DatabaseAdapterInterface $adapter Adapter.
* @return $this
*/
public function setAdapter(DatabaseAdapterInterface $adapter) {
$this->adapter = $adapter;
return $this;
}
/**
* Get user collection.
*
* @return UserCollectionInterface
*/
public function getUserCollection() {
return $this->userCollection;
}
/**
* Set user collection.
*
* @param UserCollectionInterface $userCollection User collection.
* @return $this
*/
public function setUserCollection(UserCollectionInterface $userCollection) {
$this->userCollection = $userCollection;
return $this;
}
}
Data mapper interface:
<?php
/*
* User mapper interface.
*/
namespace App\Modules\Connects\Models\Mappers;
use App\Modules\Connects\Models\Models\UserInterface;
/**
* User mapper interface.
*/
interface UserMapperInterface {
/**
* Find user by id.
*
* @param int $id User id.
* @return UserInterface User.
*/
public function findById($id);
/**
* Find users by criteria.
*
* @param array $filter [optional] WHERE conditions.
* @param string $operator [optional] WHERE conditions concatenation operator.
* @return UserCollectionInterface User collection.
*/
public function find(array $filter = array(), $operator = 'AND');
/**
* Insert user.
*
* @param UserInterface $user User.
* @return UserInterface Inserted user (saved data may differ from initial user data).
*/
public function insert(UserInterface $user);
/**
* Update user.
*
* @param UserInterface $user User.
* @return UserInterface Updated user (saved data may differ from initial user data).
*/
public function update(UserInterface $user);
/**
* Delete user.
*
* @param UserInterface $user User.
* @return bool TRUE if user successfully deleted, FALSE otherwise.
*/
public function delete(UserInterface $user);
/**
* Create user.
*
* @param array $row Table row.
* @return UserInterface User.
*/
public function createUser(array $row);
/**
* Create user collection.
*
* @param array $rows Table rows.
* @return UserCollectionInterface User collection.
*/
public function createUserCollection(array $rows);
}
Adapter class:
<?php
namespace App\Core\Model\Storage\Adapter\Database\Pdo;
use PDO;
use PDOStatement;
use PDOException as Php_PDOException;
use App\Core\Exception\PDO\PDOException;
use App\Core\Exception\SPL\UnexpectedValueException;
use App\Core\Model\Storage\Adapter\Database\DatabaseAdapterInterface;
abstract class AbstractPdoAdapter implements DatabaseAdapterInterface {
/**
* Database connection.
*
* @var PDO
*/
private $connection;
/**
* Fetch mode for a PDO statement. Must be one of the PDO::FETCH_* constants.
*
* @var int
*/
private $fetchMode = PDO::FETCH_ASSOC;
/**
* Fetch argument for a PDO statement.
*
* @var mixed
*/
private $fetchArgument = NULL;
/**
* Constructor arguments for a PDO statement when fetch mode is PDO::FETCH_CLASS.
*
* @var array
*/
private $fetchConstructorArguments = array();
/**
* For a PDOStatement object representing a scrollable cursor, this value determines<br/>
* which row will be returned to the caller.
*
* @var int
*/
private $fetchCursorOrientation = PDO::FETCH_ORI_NEXT;
/**
* The absolute number of the row in the result set, or the row relative to the cursor<br/>
* position before PDOStatement::fetch() was called.
*
* @var int
*/
private $fetchCursorOffset = 0;
/**
* @param PDO $connection Database connection.
*/
public function __construct(PDO $connection) {
$this->setConnection($connection);
}
/**
* Fetch data by executing a SELECT sql statement.
*
* @param string $sql Sql statement.
* @param array $bindings [optional] Input parameters.
* @return array An array containing the rows in the result set, or FALSE on failure.
*/
public function select($sql, array $bindings = array()) {
$statement = $this->execute($sql, $bindings);
$fetchArgument = $this->getFetchArgument();
if (isset($fetchArgument)) {
return $statement->fetchAll(
$this->getFetchMode()
, $fetchArgument
, $this->getFetchConstructorArguments()
);
}
return $statement->fetchAll($this->getFetchMode());
}
/**
* Fetch the next row from the result set by executing a SELECT sql statement.<br/>
* The fetch mode property determines how PDO returns the row.
*
* @param string $sql Sql statement.
* @param array $bindings [optional] Input parameters.
* @return array An array containing the rows in the result set, or FALSE on failure.
*/
public function selectOne($sql, array $bindings = array()) {
$statement = $this->execute($sql, $bindings);
return $statement->fetch(
$this->getFetchMode()
, $this->getFetchCursorOrientation()
, $this->getFetchCursorOffset()
);
}
/**
* Store data by executing an INSERT sql statement.
*
* @param string $sql Sql statement.
* @param array $bindings [optional] Input parameters.
* @return int The number of the affected records.
*/
public function insert($sql, array $bindings = array()) {
$statement = $this->execute($sql, $bindings);
return $statement->rowCount();
}
/**
* Update data by executing an UPDATE sql statement.
*
* @param string $sql Sql statement.
* @param array $bindings [optional] Input parameters.
* @return int The number of the affected records.
*/
public function update($sql, array $bindings = array()) {
$statement = $this->execute($sql, $bindings);
return $statement->rowCount();
}
/**
* Delete data by executing a DELETE sql statement.
*
* @param string $sql Sql statement.
* @param array $bindings [optional] Input parameters.
* @return int The number of the affected records.
*/
public function delete($sql, array $bindings = array()) {
$statement = $this->execute($sql, $bindings);
return $statement->rowCount();
}
/**
* Prepare and execute an sql statement.
*
* @todo I want to re-use the statement to execute several queries with the same SQL statement
* only with different parameters. So make a statement field and prepare only once!
* See: https://www.sitepoint.com/integrating-the-data-mappers/
*
* @param string $sql Sql statement.
* @param array $bindings [optional] Input parameters.
* @return PDOStatement The PDO statement after execution.
*/
protected function execute($sql, array $bindings = array()) {
// Prepare sql statement.
$statement = $this->prepareStatement($sql);
// Bind input parameters.
$this->bindInputParameters($statement, $bindings);
// Execute prepared sql statement.
$this->executePreparedStatement($statement);
return $statement;
}
/**
* Prepare and validate an sql statement.<br/>
*
* ---------------------------------------------------------------------------------
* If the database server cannot successfully prepare the statement,
* PDO::prepare() returns FALSE or emits PDOException (depending on error handling).
* ---------------------------------------------------------------------------------
*
* @param string $sql Sql statement.
* @return PDOStatement If the database server successfully prepares the statement,
* return a PDOStatement object. Otherwise return FALSE or emit PDOException
* (depending on error handling).
* @throws Php_PDOException
* @throws PDOException
*/
private function prepareStatement($sql) {
try {
$statement = $this->getConnection()->prepare($sql);
if (!$statement) {
throw new PDOException('The sql statement can not be prepared!');
}
} catch (Php_PDOException $exc) {
throw new PDOException('The sql statement can not be prepared!', 0, $exc);
}
return $statement;
}
/**
* Bind the input parameters to a prepared PDO statement.
*
* @param PDOStatement $statement PDO statement.
* @param array $bindings Input parameters.
* @return $this
*/
private function bindInputParameters($statement, $bindings) {
foreach ($bindings as $key => $value) {
$statement->bindValue(
$this->getInputParameterName($key)
, $value
, $this->getInputParameterDataType($value)
);
}
return $this;
}
/**
* Get the name of an input parameter by its key in the bindings array.
*
* @param int|string $key The key of the input parameter in the bindings array.
* @return int|string The name of the input parameter.
*/
private function getInputParameterName($key) {
return is_int($key) ? ($key + 1) : (':' . ltrim($key, ':'));
}
/**
* Get the PDO::PARAM_* constant, e.g the data type of an input parameter, by its value.
*
* @param mixed $value Value of the input parameter.
* @return int The PDO::PARAM_* constant.
*/
private function getInputParameterDataType($value) {
$dataType = PDO::PARAM_STR;
if (is_int($value)) {
$dataType = PDO::PARAM_INT;
} elseif (is_bool($value)) {
$dataType = PDO::PARAM_BOOL;
}
return $dataType;
}
/**
* Execute a prepared PDO statement.
*
* @param PDOStatement $statement PDO statement.
* @return $this
* @throws UnexpectedValueException
*/
private function executePreparedStatement($statement) {
if (!$statement->execute()) {
throw new UnexpectedValueException('The statement can not be executed!');
}
return $this;
}
/**
* Get the ID of the last inserted row or of the sequence value.
*
* @param string $sequenceObjectName [optional] Name of the sequence object<br/>
* from which the ID should be returned.
* @return string The ID of the last row, or the last value retrieved from the specified<br/>
* sequence object, or an error IM001 SQLSTATE If the PDO driver does not support this.
*/
public function getLastInsertId($sequenceObjectName = NULL) {
return $this->getConnection()->lastInsertId($sequenceObjectName);
}
public function getConnection() {
return $this->connection;
}
public function setConnection(PDO $connection) {
$this->connection = $connection;
return $this;
}
public function getFetchMode() {
return $this->fetchMode;
}
public function setFetchMode($fetchMode) {
$this->fetchMode = $fetchMode;
return $this;
}
public function getFetchArgument() {
return $this->fetchArgument;
}
public function setFetchArgument($fetchArgument) {
$this->fetchArgument = $fetchArgument;
return $this;
}
public function getFetchConstructorArguments() {
return $this->fetchConstructorArguments;
}
public function setFetchConstructorArguments($fetchConstructorArguments) {
$this->fetchConstructorArguments = $fetchConstructorArguments;
return $this;
}
public function getFetchCursorOrientation() {
return $this->fetchCursorOrientation;
}
public function setFetchCursorOrientation($fetchCursorOrientation) {
$this->fetchCursorOrientation = $fetchCursorOrientation;
return $this;
}
public function getFetchCursorOffset() {
return $this->fetchCursorOffset;
}
public function setFetchCursorOffset($fetchCursorOffset) {
$this->fetchCursorOffset = $fetchCursorOffset;
return $this;
}
}
About your first question: There is no convention about where you should store your classes. Choose whatever file sytem structure you wish. But make sure that:
1) You're using an autoloader and namespaces, as recommended in the PSR-4 Autoloading Standard.
2) You can uniquely identify each component class at any time. You can achieve this in two ways: Either by applying a corresponding suffix to each class (UserController
, UserMapper
, UserView
, etc), or by defining corresponding class aliases in the use
statements, like:
namespace App\Controllers;
use App\Models\DomainObjects\User;
use App\Models\Mappers\User as UserMapper;
use App\Models\Repositories\User as UserRepository;
The file system structure could be something like the following - it's the one used in my project, so sorry if it's too complex at first sight:
In App/Core
:

In App/
:

Good luck!