I am new to OOP/MVC and I do have a basic understanding of a controller which interacts with an underlying model. Basically, a controller acts as a "CRUD gateway" to a model. However, consider an e-commerce marketplace object: Order.
An e-commerce order in a marketplace would interact with multiple tables and hence an order can be thought of as a join of multiple tables: orders, order_items, order_sellers, order_buyer (and more perhaps).
If I understand it correctly, each one of these tables would have a controller allowing CRUD operations (OrderInfoController, OrderItemController,OrderSellerController,OrderBuyerController etc.).
However, could I also create a controller for 'Orders' which then instantiates the Controller Object for each of the tables involved in an Order?
OrderController {
$this->orderInfo = OrderInfo Object;
$this->orderItems = array of Order Item Objects;
$this->orderSellers = array of Order Seller Objects;
$this->orderBuyer = OrderBuyer Object;
function create($arr_order)
//create the order object by calling each of the member controllers.
function get($orderId)
//get the complete order by order Id....
function update($orderId)
function delete($orderId)
}
I have gone through a few MVC docs but I have not come across a solution to this problem. My question is then: Is this the correct approach to write a controller which interacts with multiple tables?
To
If I understand it correctly, each one of these tables would have a controller allowing CRUD operations [...].
In a web MVC-based application, each request is, indeed, served by a controller (the "C" in "MVC").
Though, the controller delegates the whole processing of the request to one or more application services (e.g. use cases, e.g actions - see resources list below), as part of the service layer. These services interact with the model (the "M" in "MVC"), e.g. domain model, e.g. model layer, which, in turn, interact with the database.
The final result of the processing of the request data, e.g. the response object, is either returned to the controller, in order to be passed and printed on screen by the view (the "V" in "MVC"), or directly to the view, for the same reason.
After watching both videos in the resources list below, you will understand, that the model doesn't need to know anything about the database. So, the components of the model layer (mostly interfaces) should not know where and how the data passed to them by the services is saved. Therefore, the services and the controllers should also know nothing about the database.
All informations regarding the database should be located in data mappers only - as part of the infrastructure layer. These objects should be the only ones understanding the database API. Therefore, the only ones containing and beeing able to execute SQL statements.
To
Is this the correct approach to write a controller which interacts with multiple tables?
No. But it's not a problem. Just keep learning about MVC.
Resources:
Keynote: Architecture the Lost Years by Robert Martin.
Sandro Mancuso : Crafted Design
Here is some code of mine. At first sight, it's maybe a lot of it, but I'm confident, that it will help you to better understand.
For simplicity, follow the definition of the method getAllUsers in the view class SampleMvc\App\View\Template\Users\Users.
First of all, here is a not so important note (yet): In my code, the controller only updates the model layer, and the view only fetches data from the model layer. Only the response returned by the view is, therefore, printed. The controller and the view are called by a class RouteInvoker, like this:
<?php
namespace MyPackages\Framework\Routing;
//...
class RouteInvoker implements RouteInvokerInterface {
//...
public function invoke(RouteInterface $route): ResponseInterface {
$controller = $this->resolveController($route);
$view = $this->resolveView($route);
$parameters = $route->getParameters();
$this->callableInvoker->call($controller, $parameters);
return $this->callableInvoker->call($view, $parameters);
}
//...
}
The result ($response) of RouteInvoker:invoke is printed like this:
$responseEmitter->emit($response);
And from here follows an example of a code invoked by RouteInvoker:invoke:
A controller to handle the users:
<?php
namespace SampleMvc\App\Controller\Users;
use function sprintf;
use SampleMvc\App\Service\Users\{
Users as UserService,
Exception\UserExists,
};
use Psr\Http\Message\ServerRequestInterface;
/**
* A controller to handle the users.
*/
class Users {
/**
*
* #param UserService $userService A service to handle the users.
*/
public function __construct(
private UserService $userService
) {
}
/**
* Add a user.
*
* #param ServerRequestInterface $request A server request.
* #return void
*/
public function addUser(ServerRequestInterface $request): void {
$username = $request->getParsedBody()['username'];
try {
$this->userService->addUser($username);
} catch (UserExists $exception) {
//...
}
}
/**
* Remove all users.
*
* #return void
*/
public function removeAllUsers(): void {
$this->userService->removeAllUsers();
}
}
A view to handle the users:
Notice, that controller and view share the same UserService instance.
<?php
namespace SampleMvc\App\View\Template\Users;
use SampleMvc\App\{
View\Layout\Primary,
Service\Users\Users as UserService,
Components\Service\MainNavigation,
};
use Psr\Http\Message\{
ResponseInterface,
ResponseFactoryInterface,
};
use AlePackages\Template\Renderer\TemplateRendererInterface;
/**
* A view to handle the users.
*/
class Users extends Primary {
/**
*
* #param UserService $userService A service to handle the users.
*/
public function __construct(
ResponseFactoryInterface $responseFactory,
TemplateRendererInterface $templateRenderer,
MainNavigation $mainNavigationService,
private UserService $userService
) {
parent::__construct($responseFactory, $templateRenderer, $mainNavigationService);
}
/**
* Display the list of users.
*
* #return ResponseInterface The response to the current request.
*/
public function default(): ResponseInterface {
$bodyContent = $this->templateRenderer->render('#Templates/Users/Users.html.twig', [
'activeNavItem' => 'Users',
'users' => $this->getAllUsers(),
]);
$response = $this->responseFactory->createResponse();
$response->getBody()->write($bodyContent);
return $response;
}
/**
* Add a user.
*
* #return ResponseInterface The response to the current request.
*/
public function addUser(): ResponseInterface {
$bodyContent = $this->templateRenderer->render('#Templates/Users/Users.html.twig', [
'activeNavItem' => 'Users',
'message' => 'User successfully added',
'users' => $this->getAllUsers(),
]);
$response = $this->responseFactory->createResponse();
$response->getBody()->write($bodyContent);
return $response;
}
/**
* Remove all users.
*
* #return ResponseInterface The response to the current request.
*/
public function removeAllUsers(): ResponseInterface {
$bodyContent = $this->templateRenderer->render('#Templates/Users/Users.html.twig', [
'activeNavItem' => 'Users',
'message' => 'All users successfully removed',
'users' => $this->getAllUsers(),
]);
$response = $this->responseFactory->createResponse();
$response->getBody()->write($bodyContent);
return $response;
}
/**
* Get a list of users.
*
* #return (string|int)[][] The list of users.
*/
private function getAllUsers(): array {
$users = $this->userService->findAllUsers();
$usersFormatted = [];
foreach ($users as $user) {
$usersFormatted[] = [
'id' => $user->getId(),
'username' => $user->getUsername(),
];
}
return $usersFormatted;
}
}
A service to handle the users:
<?php
namespace SampleMvc\App\Service\Users;
use SampleMvc\Domain\Model\User\{
User,
UserCollection,
};
use SampleMvc\App\Service\Users\Exception\UserExists;
/**
* A service to handle the users.
*/
class Users {
/**
*
* #param UserCollection $userCollection A collection of users.
*/
public function __construct(
private UserCollection $userCollection
) {
}
/**
* Find a user by id.
*
* #param int $id An id.
* #return User|null The found user or null.
*/
public function findUserById(int $id): ?User {
return $this->userCollection->findById($id);
}
/**
* Find all users.
*
* #return User[] The list of users.
*/
public function findAllUsers(): array {
return $this->userCollection->all();
}
/**
* Add a user.
*
* #param string|null $username A username.
* #return User The added user.
*/
public function addUser(?string $username): User {
$user = $this->createUser($username);
return $this->storeUser($user);
}
/**
* Remove all users.
*
* #return void
*/
public function removeAllUsers(): void {
$this->userCollection->clear();
}
/**
* Create a user.
*
* #param string|null $username A username.
* #return User The user.
*/
private function createUser(?string $username): User {
$user = new User();
$user->setUsername($username);
return $user;
}
/**
* Store a user.
*
* #param User $user A user.
* #return User The stored user.
* #throws UserExists A user already exists.
*/
private function storeUser(User $user): User {
if ($this->userCollection->exists($user)) {
throw new UserExists('Username "' . $user->getUsername() . '" already used');
}
return $this->userCollection->store($user);
}
}
An exception indicating that a user already exists:
<?php
namespace SampleMvc\App\Service\Users\Exception;
/**
* An exception indicating that a user already exists.
*/
class UserExists extends \OverflowException {
}
An interface to a collection of users:
Notice, that this is an interface.
Notice, that this interface is a component of the domain model!
Notice, that its implementation (e.g. SampleMvc\Domain\Infrastructure\Repository\User\UserCollection further down below) is not part of the domain model, but of the infrastructure layer!
<?php
namespace SampleMvc\Domain\Model\User;
use SampleMvc\Domain\Model\User\User;
/**
* An interface to a collection of users.
*/
interface UserCollection {
/**
* Find a user by id.
*
* #param int $id An id.
* #return User|null The found user or null.
*/
public function findById(int $id): ?User;
/**
* Get all users from the collection.
*
* #return User[] All users in the collection.
*/
public function all(): array;
/**
* Store a user.
*
* #param User $user A user.
* #return User The stored user.
*/
public function store(User $user): User;
/**
* Check if a user exists in the collection.
*
* #param User $user A user.
* #return bool True if the user exists, or false otherwise.
*/
public function exists(User $user): bool;
/**
* Remove all users from the collection.
*
* #return static
*/
public function clear(): static;
}
A collection of users:
<?php
namespace SampleMvc\Domain\Infrastructure\Repository\User;
use SampleMvc\Domain\Model\User\{
User,
UserCollection as UserCollectionInterface,
};
use SampleMvc\Domain\Infrastructure\Mapper\User\UserMapper;
/**
* A collection of users.
*/
class UserCollection implements UserCollectionInterface {
/**
*
* #param UserMapper $userMapper A user mapper.
*/
public function __construct(
private UserMapper $userMapper
) {
}
/**
* #inheritDoc
*/
public function findById(int $id): ?User {
return $this->userMapper->fetchUserById($id);
}
/**
* #inheritDoc
*/
public function all(): array {
return $this->userMapper->fetchAllUsers();
}
/**
* #inheritDoc
*/
public function store(User $user): User {
return $this->userMapper->saveUser($user);
}
/**
* #inheritDoc
*/
public function exists(User $user): bool {
return $this->userMapper->userExists($user);
}
/**
* #inheritDoc
*/
public function clear(): static {
$this->userMapper->deleteAllUsers();
return $this;
}
}
An interface to a user mapper:
Notice that this is the interface of a data mapper.
<?php
namespace SampleMvc\Domain\Infrastructure\Mapper\User;
use SampleMvc\Domain\Model\User\User;
/**
* An interface to a user mapper.
*/
interface UserMapper {
/**
* Fetch a user by id.
*
* Note: PDOStatement::fetch returns FALSE if no record is found.
*
* #param int $id A user id.
* #return User|null The user or null.
*/
public function fetchUserById(int $id): ?User;
/**
* Fetch all users.
*
* #return User[] The list of users.
*/
public function fetchAllUsers(): array;
/**
* Save a user.
*
* #param User $user A user.
* #return User The saved user.
*/
public function saveUser(User $user): User;
/**
* Check if a user exists.
*
* Note: PDOStatement::fetch returns FALSE if no record is found.
*
* #param User $user A user.
* #return bool True if the user exists, or false otherwise.
*/
public function userExists(User $user): bool;
/**
* Delete all users.
*
* #return static
*/
public function deleteAllUsers(): static;
}
A PDO user mapper:
Notice, that this component is the implementation of a data mapper.
Notice, that this component is the only one understanding the database API. Therefore, the only one containing and beeing able to execute SQL statements.
Notice, that this component is not part of the domain model, but of the infrastructure layer!
(1) Notice, that you can write any SQL statements that you want, including JOIN statements. So, the fetched data can come from multiple tables as well.
(2) Notice also, that the result of a method of this class could be a list of objects of a type defined by you (!), independent of the underlying table(s) data..
The conclusion from (1) and (2) above: The database structure does NOT affect in any way the way in which your application is structured.
<?php
namespace SampleMvc\Domain\Infrastructure\Mapper\User;
use SampleMvc\Domain\{
Model\User\User,
Infrastructure\Mapper\User\UserMapper,
};
use PDO;
/**
* A PDO user mapper.
*/
class PdoUserMapper implements UserMapper {
/**
*
* #param PDO $connection A database connection.
*/
public function __construct(
private PDO $connection
) {
}
/**
* #inheritDoc
*/
public function fetchUserById(int $id): ?User {
$sql = 'SELECT * FROM users WHERE id = :id LIMIT 1';
$statement = $this->connection->prepare($sql);
$statement->execute([
'id' => $id,
]);
$dataArray = $statement->fetch(PDO::FETCH_ASSOC);
return ($dataArray === false) ? null : $this->convertDataArrayToUser($dataArray);
}
/**
* #inheritDoc
*/
public function fetchAllUsers(): array {
$sql = 'SELECT * FROM users';
$statement = $this->connection->prepare($sql);
$statement->execute();
$listOfDataArrays = $statement->fetchAll(PDO::FETCH_ASSOC);
return $this->convertListOfDataArraysToListOfUsers($listOfDataArrays);
}
/**
* #inheritDoc
*/
public function saveUser(User $user): User {
return $this->insertUser($user);
}
/**
* #inheritDoc
*/
public function userExists(User $user): bool {
$sql = 'SELECT COUNT(*) as cnt FROM users WHERE username = :username';
$statement = $this->connection->prepare($sql);
$statement->execute([
':username' => $user->getUsername(),
]);
$data = $statement->fetch(PDO::FETCH_ASSOC);
return ($data['cnt'] > 0) ? true : false;
}
/**
* #inheritDoc
*/
public function deleteAllUsers(): static {
$sql = 'DELETE FROM users';
$statement = $this->connection->prepare($sql);
$statement->execute();
return $this;
}
/**
* Insert a user.
*
* #param User $user A user.
* #return User The user, with updated id.
*/
private function insertUser(User $user): User {
$sql = 'INSERT INTO users (username) VALUES (:username)';
$statement = $this->connection->prepare($sql);
$statement->execute([
':username' => $user->getUsername(),
]);
$user->setId($this->connection->lastInsertId());
return $user;
}
/**
* Update a user.
*
* #param User $user A user.
* #return User The user.
*/
private function updateUser(User $user): User {
$sql = 'UPDATE users SET username = :username WHERE id = :id';
$statement = $this->connection->prepare($sql);
$statement->execute([
':username' => $user->getUsername(),
':id' => $user->getId(),
]);
return $user;
}
/**
* Convert the given data array to a user.
*
* #param array $dataArray A data array.
* #return User The user.
*/
private function convertDataArrayToUser(array $dataArray): User {
$user = new User();
$user
->setId($dataArray['id'])
->setUsername($dataArray['username'])
;
return $user;
}
/**
* Convert the given list of data arrays to a list of users.
*
* #param array[] $listOfDataArrays A list of data arrays.
* #return User[] The list of users.
*/
private function convertListOfDataArraysToListOfUsers(array $listOfDataArrays): array {
$listOfUsers = [];
foreach ($listOfDataArrays as $dataArray) {
$listOfUsers[] = $this->convertDataArrayToUser($dataArray);
}
return $listOfUsers;
}
}
Related
currently i have a problem which don't allow me to continue adding features to my mvc website without do any sort of spaghetti code.
i have two classes, one is ModModel and the other is ModUploadModel. both are extended with the Model class.
ModModel contains all the methods about "mods", as ModModel->doesModNameExists(), ModModel->getModDetails() etc...
ModUploadModel contains all the methods for the uploading of a mod, as ModUploadModel->upload(), ModUploadModel->isModNameValid() etc...
in some cases i have to call some ModModel methods from ModUploadModel, and to do so i have to create a new instance of ModModel inside the ModUploadController and to pass it as an argument to ModUploadModel->upload().
for example: the ModUploadController creates two new objects, $modModel = new ModModel() and $modUploadModel = new ModUploadModel(), then calls $modUploadModel->upload($modModel).
this is the ModUploadController, which creates the two objects and call the ModUploadModel->upload() method
class ModUploadController extends Mvc\Controller {
public function uploadMod(): void {
$modUploadModel = new ModUploadModel()
$modModel = new ModModel();
// $modModel needs to be passed because the ModUploadModel needs
// one of its methods
if ($modUploadModel->upload("beatiful-mod", $modModel)) {
// success
} else {
// failure
}
}
}
ModUploadModel->upload() checks if the input is valid (if the mod name isn't already taken etc), and finally upload the mod data into the db. obviously it's all suddivise in more sub private methods, as ModUploadModel->isModNameValid() and ModUploadModel->insertIntoDb().
the problem is that i don't structured my classes with all static methods, and everytime i have to pass objects as parameters, like with ModModel (for example i need its isModNameValid() method).
i thought about making all the ModModel methods static, but that's not as simple as it seems, because all its methods query the db, and they use the Model->executeStmt() method (remember that all the FooBarModel classes are extended with the Model class, which contains usefull common methods as executeStmt() and others), and calling a non static method from a static one is not a good practice in php, so i should make static the Model methods too, and consequently also the Dbh methods for the db connection (Model is extended with Dbh).
the ModModel class:
class ModModel extends Mvc\Model {
// in reality it queries the db with $this->executeStmt(),
// which is a Model method
public function doesModNameExists($name) {
if (/* exists */) {
return true;
}
return false;
}
}
the ModUploadModel class:
class ModUploadModel extends Mvc\Model {
private $modName;
public function upload($modName, $modModel) {
$this->modName = $modName;
if (!$this->isModNameValid($modModel)) {
return false;
}
if ($this->insertIntoDb()) {
return true;
}
return false;
}
// this methods needs to use the non static doesModNameExists() method
// which is owned by the ModModel class, so i need to pass
// the object as an argument
private function isModNameValid($modModel) {
if ($modModel->doesModNameExists($this->modName)) {
return false;
}
// other if statements
return true;
}
private function insertIntoDb() {
$sql = "INSERT INTO blabla (x, y) VALUES (?, ?)";
$params = [$this->modName, "xxx"];
if ($this->executeStmt($sql, $params)) {
return true;
}
return false;
}
}
the alternative would be to create a new instance of Model inside the ModModel methods, for example (new Model)->executeStmt(). the problem is that it's not a model job to create new objects and generally it's not the solution i like most.
Some observations and suggestions:
[a] You are passing a ModModel object to ModUploadModel to validate the mod name before uploading. You shouldn't even try to call ModUploadModel::upload() if a mod with the provided name already exists. So you should follow steps similar to this:
class ModUploadController extends Mvc\Controller {
public function uploadMod(): void {
$modUploadModel = new ModUploadModel()
$modModel = new ModModel();
$modName = 'beatiful-mod';
try {
if ($modModel->doesModNameExists($modName)) {
throw new \ModNameExistsException('A mod with the name "' . $modName . '" already exists');
}
$modUploadModel->upload($modName);
} catch (\ModNameExistsException $exception){
// ...Present the exception message to the user. Use $exception->getMessage() to get it...
}
}
}
[b] Creating objects inside a class is a bad idea (like in ModUploadController). Use dependency injection instead. Read this and watch this and this. So the solution would look something like this:
class ModUploadController extends Mvc\Controller {
public function uploadMod(ModUploadModel $modUploadModel, ModModel $modModel): void {
//... Use the injected objects ($modUploadModel and $modModel ) ...
}
}
In a project, all objects that need to be injected into others can be created by a "dependency injection container". For example, PHP-DI (which I recommend), or other DI containers. So, a DI container takes care of all dependency injections of your project. For example, in your case, the two objects injected into ModUploadController::uploadMod method would be automatically created by PHP-DI. You'd just have to write three lines of codes in the file used as the entry-point of your app, probably index.php:
use DI\ContainerBuilder;
$containerBuilder = new ContainerBuilder();
$containerBuilder->useAutowiring(true);
$container = $containerBuilder->build();
Of course, a DI container requires configuration steps as well. But, in a couple of hours, you can understand how and where to do it.
By using a DI container, you'll be able to concentrate yourself solely on the logic of your project, not on how and where various components should be created, or similar tasks.
[c] Using static methods is a bad idea. My advise would be to get rid of all static methods that you already wrote. Watch this, read this, this and this. So the solution to the injection problem(s) that you have is the one above: the DI, perfomed by a DI container. Not at all creating static methods.
[d] You are using both components to query the database (ModModel with doesModNameExists() and ModUploadModel with insertIntoDb()). You should dedicate only one component to deal with the database.
[e] You don't need Mvc\Model at all.
[f] You don't need Mvc\Controller at all.
Some code:
I wrote some code, as an alternative to yours (from which I somehow "deduced" the tasks). Maybe it will help you, seeing how someone else would code. It would give you the possibility of "adding features to my mvc website without do any sort of spaghetti code". The code is very similar to the one from an answer that I wrote a short time ago. That answer also contains additional important suggestions and resources.
Important: Note that the application services, e.g. all components from Mvc/App/Service/, should communicate ONLY with the domain model components, e.g. with the components from Mvc/Domain/Model/ (mostly interfaces), not from Mvc/Domain/Infrastructure/. In turn, the DI container of your choice will take care of injecting the proper class implementations from Mvc/Domain/Infrastructure/ for the interfaces of Mvc/Domain/Model/ used by the application services.
Note: my code uses PHP 8.0. Good luck.
Project structure:
Mvc/App/Controller/Mod/AddMod.php:
<?php
namespace Mvc\App\Controller\Mod;
use Psr\Http\Message\{
ResponseInterface,
ServerRequestInterface,
};
use Mvc\App\Service\Mod\{
AddMod As AddModService,
Exception\ModAlreadyExists,
};
use Mvc\App\View\Mod\AddMod as AddModView;
class AddMod {
/**
* #param AddModView $addModView A view for presenting the response to the request back to the user.
* #param AddModService $addModService An application service for adding a mod to the model layer.
*/
public function __construct(
private AddModView $addModView,
private AddModService $addModService,
) {
}
/**
* Add a mod.
*
* The mod details are submitted from a form, using the HTTP method "POST".
*
* #param ServerRequestInterface $request A server request.
* #return ResponseInterface The response to the current request.
*/
public function addMod(ServerRequestInterface $request): ResponseInterface {
// Read the values submitted by the user.
$name = $request->getParsedBody()['name'];
$description = $request->getParsedBody()['description'];
// Add the mod.
try {
$mod = $this->addModService->addMod($name, $description);
$this->addModView->setMod($mod);
} catch (ModAlreadyExists $exception) {
$this->addModView->setErrorMessage(
$exception->getMessage()
);
}
// Present the results to the user.
$response = $this->addModView->addMod();
return $response;
}
}
Mvc/App/Service/Mod/Exception/ModAlreadyExists.php:
<?php
namespace Mvc\App\Service\Mod\Exception;
/**
* An exception thrown if a mod already exists.
*/
class ModAlreadyExists extends \OverflowException {
}
Mvc/App/Service/Mod/AddMod.php:
<?php
namespace Mvc\App\Service\Mod;
use Mvc\Domain\Model\Mod\{
Mod,
ModMapper,
};
use Mvc\App\Service\Mod\Exception\ModAlreadyExists;
/**
* An application service for adding a mod.
*/
class AddMod {
/**
* #param ModMapper $modMapper A data mapper for transfering mods
* to and from a persistence system.
*/
public function __construct(
private ModMapper $modMapper
) {
}
/**
* Add a mod.
*
* #param string|null $name A mod name.
* #param string|null $description A mod description.
* #return Mod The added mod.
*/
public function addMod(?string $name, ?string $description): Mod {
$mod = $this->createMod($name, $description);
return $this->storeMod($mod);
}
/**
* Create a mod.
*
* #param string|null $name A mod name.
* #param string|null $description A mod description.
* #return Mod The newly created mod.
*/
private function createMod(?string $name, ?string $description): Mod {
return new Mod($name, $description);
}
/**
* Store a mod.
*
* #param Mod $mod A mod.
* #return Mod The stored mod.
* #throws ModAlreadyExists The mod already exists.
*/
private function storeMod(Mod $mod): Mod {
if ($this->modMapper->modExists($mod)) {
throw new ModAlreadyExists(
'A mod with the name "' . $mod->getName() . '" already exists'
);
}
return $this->modMapper->saveMod($mod);
}
}
Mvc/App/View/Mod/AddMod.php:
<?php
namespace Mvc\App\View\Mod;
use Mvc\{
App\View\View,
Domain\Model\Mod\Mod,
};
use Psr\Http\Message\ResponseInterface;
/**
* A view for adding a mod.
*/
class AddMod extends View {
/** #var Mod A mod. */
private Mod $mod = null;
/**
* Add a mod.
*
* #return ResponseInterface The response to the current request.
*/
public function addMod(): ResponseInterface {
$bodyContent = $this->templateRenderer->render('#Templates/Mod/AddMod.html.twig', [
'activeNavItem' => 'AddMod',
'mod' => $this->mod,
'error' => $this->errorMessage,
]);
$response = $this->responseFactory->createResponse();
$response->getBody()->write($bodyContent);
return $response;
}
/**
* Set the mod.
*
* #param Mod $mod A mod.
* #return static
*/
public function setMod(Mod $mod): static {
$this->mod = $mod;
return $this;
}
}
Mvc/App/View/View.php:
<?php
namespace Mvc\App\View;
use Psr\Http\Message\ResponseFactoryInterface;
use SampleLib\Template\Renderer\TemplateRendererInterface;
/**
* A view.
*/
abstract class View {
/** #var string An error message */
protected string $errorMessage = '';
/**
* #param ResponseFactoryInterface $responseFactory A response factory.
* #param TemplateRendererInterface $templateRenderer A template renderer.
*/
public function __construct(
protected ResponseFactoryInterface $responseFactory,
protected TemplateRendererInterface $templateRenderer
) {
}
/**
* Set the error message.
*
* #param string $errorMessage An error message.
* #return static
*/
public function setErrorMessage(string $errorMessage): static {
$this->errorMessage = $errorMessage;
return $this;
}
}
Mvc/Domain/Infrastructure/Mod/PdoModMapper.php:
<?php
namespace Mvc\Domain\Infrastructure\Mod;
use Mvc\Domain\Model\Mod\{
Mod,
ModMapper,
};
use PDO;
/**
* A data mapper for transfering Mod entities to and from a database.
*
* This class uses a PDO instance as database connection.
*/
class PdoModMapper implements ModMapper {
/**
* #param PDO $connection Database connection.
*/
public function __construct(
private PDO $connection
) {
}
/**
* #inheritDoc
*/
public function modExists(Mod $mod): bool {
$sql = 'SELECT COUNT(*) as cnt FROM mods WHERE name = :name';
$statement = $this->connection->prepare($sql);
$statement->execute([
':name' => $mod->getName(),
]);
$data = $statement->fetch(PDO::FETCH_ASSOC);
return ($data['cnt'] > 0) ? true : false;
}
/**
* #inheritDoc
*/
public function saveMod(Mod $mod): Mod {
if (isset($mod->getId())) {
return $this->updateMod($mod);
}
return $this->insertMod($mod);
}
/**
* Update a mod.
*
* #param Mod $mod A mod.
* #return Mod The mod.
*/
private function updateMod(Mod $mod): Mod {
$sql = 'UPDATE mods
SET
name = :name,
description = :description
WHERE
id = :id';
$statement = $this->connection->prepare($sql);
$statement->execute([
':name' => $mod->getName(),
':description' => $mod->getDescription(),
]);
return $mod;
}
/**
* Insert a mod.
*
* #param Mod $mod A mod.
* #return Mod The newly inserted mod.
*/
private function insertMod(Mod $mod): Mod {
$sql = 'INSERT INTO mods (
name,
description
) VALUES (
:name,
:description
)';
$statement = $this->connection->prepare($sql);
$statement->execute([
':name' => $mod->getName(),
':description' => $mod->getDescription(),
]);
$mod->setId(
$this->connection->lastInsertId()
);
return $mod;
}
}
Mvc/Domain/Model/Mod/Mod.php:
<?php
namespace Mvc\Domain\Model\Mod;
/**
* Mod entity.
*/
class Mod {
/**
* #param string|null $name (optional) A name.
* #param string|null $description (optional) A description.
*/
public function __construct(
private ?string $name = null,
private ?string $description = null
) {
}
/**
* Get id.
*
* #return int|null
*/
public function getId(): ?int {
return $this->id;
}
/**
* Set id.
*
* #param int|null $id An id.
* #return static
*/
public function setId(?int $id): static {
$this->id = $id;
return $this;
}
/**
* Get the name.
*
* #return string|null
*/
public function getName(): ?string {
return $this->name;
}
/**
* Set the name.
*
* #param string|null $name A name.
* #return static
*/
public function setName(?string $name): static {
$this->name = $name;
return $this;
}
/**
* Get the description.
*
* #return string|null
*/
public function getDescription(): ?string {
return $this->description;
}
/**
* Set the description.
*
* #param string|null $description A description.
* #return static
*/
public function setDescription(?string $description): static {
$this->description = $description;
return $this;
}
}
Mvc/Domain/Model/Mod/ModMapper.php:
<?php
namespace Mvc\Domain\Model\Mod;
use Mvc\Domain\Model\Mod\Mod;
/**
* An interface for various data mappers used to
* transfer Mod entities to and from a persistence system.
*/
interface ModMapper {
/**
* Check if a mod exists.
*
* #param Mod $mod A mod.
* #return bool True if the mod exists, false otherwise.
*/
public function modExists(Mod $mod): bool;
/**
* Save a mod.
*
* #param Mod $mod A mod.
* #return Mod The saved mod.
*/
public function saveMod(Mod $mod): Mod;
}
could someone help me on this? I have following classes (all functional, abbreviated here for sake of legibility):
class Database {
private $host = DB_HOST;
// etc...
public function __construct() {
$dsn = 'mysql:host=' . $this->host . ';dbname=' . $this->dbname;
$options = array(PDO::ATTR_PERSISTENT => true, PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION);
try {
$this->dbh = new PDO($dsn, $this->user, $this->pass, $options);
} catch (PDOException $e) {
$this->error = $e->getMessage();
echo $this->error;
}
}
public function beginTransaction() {
$this->stmt = $this->dbh->beginTransaction();
}
and a class for let’s say books;
class Books extends Controller {
public function __construct() {
$this->model = $this->loadModel('BookModel');
}
// etc.
$this->model->beginTransaction();
and the BookModel looks like:
class BookModel {
protected $db;
public function __construct() {
$this->db = new Database;
}
public function beginTransaction() {
$this->db->beginTransaction();
}
I know I can only access the PDO beginTransaction inside of the Database class, but is there another way, or I have to use this complicated path, call the method that calls the method that calls the PDO method?
I have a feeling I’m doing something very stupid here. Maybe extending the BookModel to the Database class, but that doesn’t feel right either.
Thanks!
Some suggestions:
[a] You shouldn't create objects (with "new") inside class methods. Instead you should inject existent instances into constructors/setters. This is named dependency injection and can be applied with a dependency injection container.
Dependency Injection and Dependency Inversion in PHP - James Mallison - PHPTour 2017 Nantes
PHP-DI The dependency injection container for humans
[b] As #YourCommonSense noted, Database would greatly benefit from a single PDO instance, injected in the constructor. The injection task would be the job of the DI container. For example, if you'd use PHP-DI, there would be a definition entry for creating a database connection:
return [
'database-connection' => function (ContainerInterface $container) {
$parameters = $container->get('database.connection');
$dsn = $parameters['dsn'];
$username = $parameters['username'];
$password = $parameters['password'];
$connectionOptions = [
PDO::ATTR_EMULATE_PREPARES => false,
PDO::ATTR_PERSISTENT => false,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
];
$connection = new PDO($dsn, $username, $password, $connectionOptions);
return $connection;
},
];
and another definition entry to inject it in Database:
return [
Database::class => autowire()
->constructorParameter('connection', get('database-connection')),
];
The Database contructor would look like:
public function __construct(PDO $connection) {
$this->dbh = $connection;
}
[c] The model is not a class (like BookModel). It is a layer (model layer, or domain model), composed of multiple components: entities (or domain objects), value objects, data mappers, repositories, domain services. Your BookModel is a combination btw. an entity and a data mapper (at least). Note: inheriting it from Database is wrong, because a model can't be a database.
How should a model be structured in MVC?
The difference between domains, domain models, object models and domain objects
[d] You shouldn't inject models into controllers. Instead, controllers should use so-called application services (also named use cases, or actions, or interactors). These services contain the so-called application logic and are the proper way to decouple the presentation layer (or delivery mechanism) - which, among other components, includes the controllers and the views - from the domain model. The application services also assure the communication btw. the two layers. Note: there could also be domain services, specific to the domain and separated from the application services, which are specific to the application.
Sandro Mancuso : Crafted Design
Ruby Midwest 2011 - Keynote: Architecture the Lost Years by Robert Martin
Robert "Uncle Bob" Martin - Architecture: The Lost Years
How should a model be structured in MVC?
[e] Database class is not needed at all! You already have the very elegant & powerful PDO at disposal, to handle the database operations.
[f] Actually, it is not wrong to "call the method that calls the method that calls the PDO method". Each method in this chain encapsulates a certain behavior, specific to the current object. Though, the functionality of each method should add some plus value. Otherwise, it wouldn't make sense to have this chain, indeed. An example: In an application service, you can directly use a data mapper to fetch a book by id from the database:
class FindBooksService {
public function __construct(
private BookMapper $bookMapper
) {
}
public function findBookById(?int $id = null): ?Book {
return $this->bookMapper->fetchBookById($id);
}
}
class BookMapper {
public function __construct(
private PDO $connection
) {
}
public function fetchBookById(?int $id): ?Book {
$sql = 'SELECT * FROM books WHERE id = :id LIMIT 1';
// Fetch book data from database; convert the record to a Book object ($book).
//...
return $book;
}
}
Now, you could use a repository instead, to hide even the fact that the queried data comes from a database. This makes sense, since a repository object is seen as a collection of objects of a certain type (here Book) by other components. Therefore, the other components think that the repository is a collection of books, not a bunch of data in some database, and they ask the repository for them correspondingly. The repository will, in turn, interogate the data mapper to query the database. So, the previous code becomes:
class FindBooksService {
/**
* #param BookCollection $bookCollection The repository: a collection of books, e.g. of Book instances.
*/
public function __construct(
private BookCollection $bookCollection
) {
}
public function findBookById(?int $id = null): ?Book {
return $this->bookCollection->findBookById($id);
}
}
class BookCollection {
private array $books = [];
public function __construct(
private BookMapper $bookMapper
) {
}
/**
* This method adds a plus value to the omolog method in the data mapper (fetchBookById):
* - caches the Book instances in the $books list, therefore reducing the database querying operations;
* - hides the fact, that the data comes from a database, from the external world, e.g. other components.
* - provides an elegant collection-like interface.
*/
public function findBookById(?int $id): ?Book {
if (!array_key_exists($id, $this->books)) {
$book = $this->bookMapper->fetchBookById($id);
$this->books[id] = $book;
}
return $this->books[$id];
}
}
class BookMapper {
// the same...
}
[g] A "real" mistake would be to pass an object through other objects, just to be used by the last object.
Alternative example code:
I wrote some code as an alternative to yours. I hope it will help you better understand, how the components of an MVC-based application could work together.
Important: Notice the namespace SampleMvc/Domain/Model/: that's the domain model. Note that the application services, e.g. all components from SampleMvc/App/Service/, should communicate ONLY with the domain model components, e.g. with the components from SampleMvc/Domain/Model/ (mostly interfaces), not from SampleMvc/Domain/Infrastructure/. In turn, the DI container of your choice will take care of injecting the proper class implementations from SampleMvc/Domain/Infrastructure/ for the interfaces of SampleMvc/Domain/Model/ used by the application services.
Notice the method updateBook() in SampleMvc/Domain/Infrastructure/Book/PdoBookMapper.php. I included a transaction code in it, along with two great links. Have fun.
Project structure:
SampleMvc/App/Controller/Book/AddBook.php:
<?php
namespace SampleMvc\App\Controller\Book;
use Psr\Http\Message\{
ResponseInterface,
ServerRequestInterface,
};
use SampleMvc\App\Service\Book\{
AddBook as AddBookService,
Exception\BookAlreadyExists,
};
use SampleMvc\App\View\Book\AddBook as AddBookView;
/**
* A controller for adding a book.
*
* Let's assume the existence of this route definition:
*
* $routeCollection->post('/books/add', SampleMvc\App\Controller\Book\AddBook::class);
*/
class AddBook {
/**
* #param AddBookView $view The view for presenting the response to the request back to the user.
* #param AddBookService $addBookService An application service for adding a book to the model layer.
*/
public function __construct(
private AddBookView $view,
private AddBookService $addBookService
) {
}
/**
* Add a book.
*
* The book details are submitted from a form, using the HTTP method "POST".
*
* #param ServerRequestInterface $request A server request.
* #return ResponseInterface The response to the current request.
*/
public function __invoke(ServerRequestInterface $request): ResponseInterface {
$authorName = $request->getParsedBody()['authorName'];
$title = $request->getParsedBody()['title'];
try {
$book = $this->addBookService($authorName, $title);
$this->view->setBook($book);
} catch (BookAlreadyExists $exception) {
$this->view->setErrorMessage(
$exception->getMessage()
);
}
$response = $this->view->addBook();
return $response;
}
}
SampleMvc/App/Controller/Book/FindBooks.php:
<?php
namespace SampleMvc\App\Controller\Book;
use Psr\Http\Message\ResponseInterface;
use SampleMvc\App\View\Book\FindBooks as FindBooksView;
use SampleMvc\App\Service\Book\FindBooks as FindBooksService;
/**
* A controller for finding books.
*
* Let's assume the existence of this route definition:
*
* $routeCollection->post('/books/find/{authorName}', [SampleMvc\App\Controller\FindBooks::class, 'findBooksByAuthorName']);
*/
class FindBooks {
/**
* #param FindBooksView $view The view for presenting the response to the request back to the user.
* #param FindBooksService $findBooksService An application service for finding books by querying the model layer.
*/
public function __construct(
private FindBooksView $view,
private FindBooksService $findBooksService
) {
}
/**
* Find books by author name.
*
* The author name is provided by clicking on a link of some author name
* in the browser. The author name is therefore sent using the HTTP method
* "GET" and passed as argument to this method by a route dispatcher.
*
* #param string|null $authorName (optional) An author name.
* #return ResponseInterface The response to the current request.
*/
public function findBooksByAuthorName(?string $authorName = null): ResponseInterface {
$books = $this->findBooksService->findBooksByAuthorName($authorName);
$response = $this->view
->setBooks($books)
->findBooksByAuthorName()
;
return $response;
}
}
SampleMvc/App/Service/Book/Exception/BookAlreadyExists.php:
<?php
namespace SampleMvc\App\Service\Book\Exception;
/**
* An exception thrown if a book already exists.
*/
class BookAlreadyExists extends \OverflowException {
}
SampleMvc/App/Service/Book/AddBook.php:
<?php
namespace SampleMvc\App\Service\Book;
use SampleMvc\Domain\Model\Book\{
Book,
BookMapper,
};
use SampleMvc\App\Service\Book\Exception\BookAlreadyExists;
/**
* An application service for adding a book.
*/
class AddBook {
/**
* #param BookMapper $bookMapper A data mapper for transfering books
* to and from a persistence system.
*/
public function __construct(
private BookMapper $bookMapper
) {
}
/**
* Add a book.
*
* #param string|null $authorName An author name.
* #param string|null $title A title.
* #return Book The added book.
*/
public function __invoke(?string $authorName, ?string $title): Book {
$book = $this->createBook($authorName, $title);
return $this->storeBook($book);
}
/**
* Create a book.
*
* #param string|null $authorName An author name.
* #param string|null $title A title.
* #return Book The newly created book.
*/
private function createBook(?string $authorName, ?string $title): Book {
return new Book($authorName, $title);
}
/**
* Store a book.
*
* #param Book $book A book.
* #return Book The stored book.
* #throws BookAlreadyExists The book already exists.
*/
private function storeBook(Book $book): Book {
if ($this->bookMapper->bookExists($book)) {
throw new BookAlreadyExists(
'A book with the author name "' . $book->getAuthorName() . '" '
. 'and the title "' . $book->getTitle() . '" already exists'
);
}
return $this->bookMapper->saveBook($book);
}
}
SampleMvc/App/Service/Book/FindBooks.php:
<?php
namespace SampleMvc\App\Service\Book;
use SampleMvc\Domain\Model\Book\{
Book,
BookMapper,
};
/**
* An application service for finding books.
*/
class FindBooks {
/**
* #param BookMapper $bookMapper A data mapper for transfering books
* to and from a persistence system.
*/
public function __construct(
private BookMapper $bookMapper
) {
}
/**
* Find a book by id.
*
* #param int|null $id (optional) A book id.
* #return Book|null The found book, or null if no book was found.
*/
public function findBookById(?int $id = null): ?Book {
return $this->bookMapper->fetchBookById($id);
}
/**
* Find books by author name.
*
* #param string|null $authorName (optional) An author name.
* #return Book[] The found books list.
*/
public function findBooksByAuthorName(?string $authorName = null): array {
return $this->bookMapper->fetchBooksByAuthorName($authorName);
}
}
SampleMvc/App/View/Book/AddBook.php:
<?php
namespace SampleMvc\App\View\Book;
use SampleMvc\{
App\View\View,
Domain\Model\Book\Book,
};
use Psr\Http\Message\ResponseInterface;
/**
* A view for adding a book.
*/
class AddBook extends View {
/** #var Book The added book. */
private Book $book = null;
/**
* Add a book.
*
* #return ResponseInterface The response to the current request.
*/
public function addBook(): ResponseInterface {
$bodyContent = $this->templateRenderer->render('#Templates/Book/AddBook.html.twig', [
'activeNavItem' => 'AddBook',
'book' => $this->book,
'error' => $this->errorMessage,
]);
$response = $this->responseFactory->createResponse();
$response->getBody()->write($bodyContent);
return $response;
}
/**
* Set the book.
*
* #param Book $book A book.
* #return static
*/
public function setBook(Book $book): static {
$this->book = $book;
return $this;
}
}
SampleMvc/App/View/Book/FindBooks.php:
<?php
namespace SampleMvc\App\View\Book;
use SampleMvc\{
App\View\View,
Domain\Model\Book\Book,
};
use Psr\Http\Message\ResponseInterface;
/**
* A view for finding books.
*/
class FindBooks extends View {
/** #var Book[] The list of found books. */
private array $books = [];
/**
* Find books by author name.
*
* #return ResponseInterface The response to the current request.
*/
public function findBooksByAuthorName(): ResponseInterface {
$bodyContent = $this->templateRenderer->render('#Templates/Book/FindBooks.html.twig', [
'activeNavItem' => 'FindBooks',
'books' => $this->books,
]);
$response = $this->responseFactory->createResponse();
$response->getBody()->write($bodyContent);
return $response;
}
/**
* Set the books list.
*
* #param Book[] $books A list of books.
* #return static
*/
public function setBooks(array $books): static {
$this->books = $books;
return $this;
}
}
SampleMvc/App/View/View.php:
<?php
namespace SampleMvc\App\View;
use Psr\Http\Message\ResponseFactoryInterface;
use SampleLib\Template\Renderer\TemplateRendererInterface;
/**
* View.
*/
abstract class View {
/** #var string The error message */
protected string $errorMessage = '';
/**
* #param ResponseFactoryInterface $responseFactory Response factory.
* #param TemplateRendererInterface $templateRenderer Template renderer.
*/
public function __construct(
protected ResponseFactoryInterface $responseFactory,
protected TemplateRendererInterface $templateRenderer
) {
}
/**
* Set the error message.
*
* #param string $errorMessage An error message.
* #return static
*/
public function setErrorMessage(string $errorMessage): static {
$this->errorMessage = $errorMessage;
return $this;
}
}
SampleMvc/Domain/Infrastructure/Book/PdoBookMapper.php:
<?php
namespace SampleMvc\Domain\Infrastructure\Book;
use SampleMvc\Domain\Model\Book\{
Book,
BookMapper,
};
use PDO;
/**
* A data mapper for transfering Book entities to and from a database.
*
* This class uses a PDO instance as database connection.
*/
class PdoBookMapper implements BookMapper {
/**
* #param PDO $connection Database connection.
*/
public function __construct(
private PDO $connection
) {
}
/**
* #inheritDoc
*/
public function bookExists(Book $book): bool {
$sql = 'SELECT COUNT(*) as cnt FROM books WHERE author_name = :author_name AND title = :title';
$statement = $this->connection->prepare($sql);
$statement->execute([
':author_name' => $book->getAuthorName(),
':title' => $book->getTitle(),
]);
$data = $statement->fetch(PDO::FETCH_ASSOC);
return ($data['cnt'] > 0) ? true : false;
}
/**
* #inheritDoc
*/
public function saveBook(Book $book): Book {
if (isset($book->getId())) {
return $this->updateBook($book);
}
return $this->insertBook($book);
}
/**
* #inheritDoc
*/
public function fetchBookById(?int $id): ?Book {
$sql = 'SELECT * FROM books WHERE id = :id LIMIT 1';
$statement = $this->connection->prepare($sql);
$statement->execute([
'id' => $id,
]);
$record = $statement->fetch(PDO::FETCH_ASSOC);
return ($record === false) ?
null :
$this->convertRecordToBook($record)
;
}
/**
* #inheritDoc
*/
public function fetchBooksByAuthorName(?string $authorName): array {
$sql = 'SELECT * FROM books WHERE author_name = :author_name';
$statement = $this->connection->prepare($sql);
$statement->execute([
'author_name' => $authorName,
]);
$recordset = $statement->fetchAll(PDO::FETCH_ASSOC);
return $this->convertRecordsetToBooksList($recordset);
}
/**
* Update a book.
*
* This method uses transactions as example.
*
* Note: I never worked with transactions, but I
* think the code in this method is not wrong.
*
* #link https://phpdelusions.net/pdo#transactions (The only proper) PDO tutorial: Transactions
* #link https://phpdelusions.net/pdo (The only proper) PDO tutorial
* #link https://phpdelusions.net/articles/error_reporting PHP error reporting
*
* #param Book $book A book.
* #return Book The updated book.
* #throws \Exception Transaction failed.
*/
private function updateBook(Book $book): Book {
$sql = 'UPDATE books SET author_name = :author_name, title = :title WHERE id = :id';
try {
$this->connection->beginTransaction();
$statement = $this->connection->prepare($sql);
$statement->execute([
':author_name' => $book->getAuthorName(),
':title' => $book->getTitle(),
':id' => $book->getId(),
]);
$this->connection->commit();
} catch (\Exception $exception) {
$this->connection->rollBack();
throw $exception;
}
return $book;
}
/**
* Insert a book.
*
* #param Book $book A book.
* #return Book The newly inserted book.
*/
private function insertBook(Book $book): Book {
$sql = 'INSERT INTO books (author_name, title) VALUES (:author_name, :title)';
$statement = $this->connection->prepare($sql);
$statement->execute([
':author_name' => $book->getAuthorName(),
':title' => $book->getTitle(),
]);
$book->setId(
$this->connection->lastInsertId()
);
return $book;
}
/**
* Convert the given record to a Book instance.
*
* #param array $record The record to be converted.
* #return Book A Book instance.
*/
private function convertRecordToBook(array $record): Book {
$id = $record['id'];
$authorName = $record['author_name'];
$title = $record['title'];
$book = new Book($authorName, $title);
$book->setId($id);
return $book;
}
/**
* Convert the given recordset to a list of Book instances.
*
* #param array $recordset The recordset to be converted.
* #return Book[] A list of Book instances.
*/
private function convertRecordsetToBooksList(array $recordset): array {
$books = [];
foreach ($recordset as $record) {
$books[] = $this->convertRecordToBook($record);
}
return $books;
}
}
SampleMvc/Domain/Model/Book/Book.php:
<?php
namespace SampleMvc\Domain\Model\Book;
/**
* Book entity.
*/
class Book {
/**
* #param string|null $authorName (optional) The name of an author.
* #param string|null $title (optional) A title.
*/
public function __construct(
private ?string $authorName = null,
private ?string $title = null
) {
}
/**
* Get id.
*
* #return int|null
*/
public function getId(): ?int {
return $this->id;
}
/**
* Set id.
*
* #param int|null $id An id.
* #return static
*/
public function setId(?int $id): static {
$this->id = $id;
return $this;
}
/**
* Get the author name.
*
* #return string|null
*/
public function getAuthorName(): ?string {
return $this->authorName;
}
/**
* Set the author name.
*
* #param string|null $authorName The name of an author.
* #return static
*/
public function setAuthorName(?string $authorName): static {
$this->authorName = $authorName;
return $this;
}
/**
* Get the title.
*
* #return string|null
*/
public function getTitle(): ?string {
return $this->title;
}
/**
* Set the title.
*
* #param string|null $title A title.
* #return static
*/
public function setTitle(?string $title): static {
$this->title = $title;
return $this;
}
}
SampleMvc/Domain/Model/Book/BookMapper.php:
<?php
namespace SampleMvc\Domain\Model\Book;
use SampleMvc\Domain\Model\Book\Book;
/**
* An interface for various data mappers used to
* transfer Book entities to and from a persistence system.
*/
interface BookMapper {
/**
* Check if a book exists.
*
* #param Book $book A book.
* #return bool True if the book exists, false otherwise.
*/
public function bookExists(Book $book): bool;
/**
* Save a book.
*
* #param Book $book A book.
* #return Book The saved book.
*/
public function saveBook(Book $book): Book;
/**
* Fetch a book by id.
*
* #param int|null $id A book id.
* #return Book|null The found book, or null if no book was found.
*/
public function fetchBookById(?int $id): ?Book;
/**
* Fetch books by author name.
*
* #param string|null $authorName An author name.
* #return Book[] The found books list.
*/
public function fetchBooksByAuthorName(?string $authorName): array;
}
As I understand using repositories restricts controller from accessing database layer, and all queries goes through repository. But can controller use model (laravel can inject model instead of ID in a controller) to pass it to repository or service - for example to make a transaction between users? Or better to send IDs to repository, to find users and apply business logic (do user have money, or is he banned).
And more generic question, can you use models outside of the repository, because if you change some tables from postgres or mysql to something else your models will change also. And this means your repository should have get method to send back some DTO object?
Note: This is a general perspective on the matter, appliable to any application based on MVC, not only to Laravel.
An application based on the MVC pattern should be composed of three parts:
delivery mechanism: UI logic (user request handling and server response creation),
service layer: application logic,
domain model: business logic.
Here are some graphical representations (of my own making):
As shown above (and described in detail in the resources below), the controllers and the views are part of the delivery mechanism. They should interact with the domain model only through the service layer objects (services). Consequently, they should have no knowledge of the domain model components (entities - also known as domain objects, data mappers, repositories, etc). More of it, the controllers should have only one responsibility: to pass the values of the user request to the service layer, in order for it to update the model.
So, to answer your first question: No, controllers should not be able to create any instances of elements of the domain model (so instances of what you're calling "models" - in respect of Laravel's Active Record), or even to pass such objects to other components (like repositories, services, etc). Instead, the controllers should just pass the values of the request (the user id, for example) to the corresponding services. These services will then create the proper domain model objects and use the proper repositories, data mappers, etc, in order to save/fetch to/from database.
As for the second question (if I understood it correctly): The repositories are to be seen as collections of entities - which are domain model components. As such, elements (e.g. entity instances) can be fetched, stored, altered, or removed to/from them. So, by definition, the entities must be defined/used separately from the repositories. In regard of Laravel, the same should apply: The "models" should be defined/used separately from the repositories.
A "general" MVC implementation (for more clarity):
Controller:
<?php
namespace MyApp\UI\Web\Controller\Users;
use MyApp\Domain\Service\Users;
use Psr\Http\Message\ServerRequestInterface;
/**
* Add a user.
*/
class AddUser {
/**
* User service.
*
* #var Users
*/
private $userService;
/**
*
* #param Users $userService User service.
*/
public function __construct(Users $userService) {
$this->userService = $userService;
}
/**
* Invoke.
*
* #param ServerRequestInterface $request Request.
* #return void
*/
public function __invoke(ServerRequestInterface $request) {
// Read request values.
$username = $request->getParsedBody()['username'];
// Call the corresponding service.
$this->userService->addUser($username);
}
}
Service:
<?php
namespace MyApp\Domain\Service;
use MyApp\Domain\Model\User\User;
use MyApp\Domain\Model\User\UserCollection;
use MyApp\Domain\Service\Exception\UserExists;
/**
* Service for handling the users.
*/
class Users {
/**
* User collection (a repository).
*
* #var UserCollection
*/
private $userCollection;
/**
*
* #param UserCollection $userCollection User collection.
*/
public function __construct(UserCollection $userCollection) {
$this->userCollection = $userCollection;
}
/**
* Find a user by id.
*
* #param int $id User id.
* #return User|null User.
*/
public function findUserById(int $id) {
return $this->userCollection->findUserById($id);
}
/**
* Find all users.
*
* #return User[] User list.
*/
public function findAllUsers() {
return $this->userCollection->findAllUsers();
}
/**
* Add a user.
*
* #param string $username Username.
* #return User User.
*/
public function addUser(string $username) {
$user = $this->createUser($username);
return $this->storeUser($user);
}
/**
* Create a user.
*
* #param string $username Username.
* #return User User.
*/
private function createUser(string $username) {
$user = new User();
$user->setUsername($username);
return $user;
}
/**
* Store a user.
*
* #param User $user User.
* #return User User.
*/
private function storeUser(User $user) {
if ($this->userCollection->userExists($user)) {
throw new UserExists('Username "' . $user->getUsername() . '" already used');
}
return $this->userCollection->storeUser($user);
}
}
Repository:
<?php
namespace MyApp\Domain\Infrastructure\Repository\User;
use MyApp\Domain\Model\User\User;
use MyApp\Domain\Infrastructure\Mapper\User\UserMapper;
use MyApp\Domain\Model\User\UserCollection as UserCollectionInterface;
/**
* User collection.
*/
class UserCollection implements UserCollectionInterface {
/**
* User mapper (a data mapper).
*
* #var UserMapper
*/
private $userMapper;
/**
*
* #param UserMapper $userMapper User mapper.
*/
public function __construct(UserMapper $userMapper) {
$this->userMapper = $userMapper;
}
/**
* Find a user by id.
*
* #param int $id User id.
* #return User|null User.
*/
public function findUserById(int $id) {
return $this->userMapper->fetchUserById($id);
}
/**
* Find all users.
*
* #return User[] User list.
*/
public function findAllUsers() {
return $this->userMapper->fetchAllUsers();
}
/**
* Store a user.
*
* #param User $user User.
* #return User User.
*/
public function storeUser(User $user) {
return $this->userMapper->saveUser($user);
}
/**
* Check if the given user exists.
*
* #param User $user User.
* #return bool True if user exists, false otherwise.
*/
public function userExists(User $user) {
return $this->userMapper->userExists($user);
}
}
Entity:
<?php
namespace MyApp\Domain\Model\User;
/**
* User.
*/
class User {
/**
* Id.
*
* #var int
*/
private $id;
/**
* Username.
*
* #var string
*/
private $username;
/**
* Get id.
*
* #return int
*/
public function getId() {
return $this->id;
}
/**
* Set id.
*
* #param int $id Id.
* #return $this
*/
public function setId(int $id) {
$this->id = $id;
return $this;
}
/**
* Get username.
*
* #return string
*/
public function getUsername() {
return $this->username;
}
/**
* Set username.
*
* #param string $username Username.
* #return $this
*/
public function setUsername(string $username) {
$this->username = $username;
return $this;
}
}
Data mapper:
<?php
namespace MyApp\Domain\Infrastructure\Mapper\User;
use PDO;
use MyApp\Domain\Model\User\User;
use MyApp\Domain\Infrastructure\Mapper\User\UserMapper;
/**
* PDO user mapper.
*/
class PdoUserMapper implements UserMapper {
/**
* Database connection.
*
* #var PDO
*/
private $connection;
/**
*
* #param PDO $connection Database connection.
*/
public function __construct(PDO $connection) {
$this->connection = $connection;
}
/**
* Fetch a user by id.
*
* Note: PDOStatement::fetch returns FALSE if no record is found.
*
* #param int $id User id.
* #return User|null User.
*/
public function fetchUserById(int $id) {
$sql = 'SELECT * FROM users WHERE id = :id LIMIT 1';
$statement = $this->connection->prepare($sql);
$statement->execute([
'id' => $id,
]);
$data = $statement->fetch(PDO::FETCH_ASSOC);
return ($data === false) ? null : $this->convertDataToUser($data);
}
/**
* Fetch all users.
*
* #return User[] User list.
*/
public function fetchAllUsers() {
$sql = 'SELECT * FROM users';
$statement = $this->connection->prepare($sql);
$statement->execute();
$data = $statement->fetchAll(PDO::FETCH_ASSOC);
return $this->convertDataToUserList($data);
}
/**
* Check if a user exists.
*
* Note: PDOStatement::fetch returns FALSE if no record is found.
*
* #param User $user User.
* #return bool True if the user exists, false otherwise.
*/
public function userExists(User $user) {
$sql = 'SELECT COUNT(*) as cnt FROM users WHERE username = :username';
$statement = $this->connection->prepare($sql);
$statement->execute([
':username' => $user->getUsername(),
]);
$data = $statement->fetch(PDO::FETCH_ASSOC);
return ($data['cnt'] > 0) ? true : false;
}
/**
* Save a user.
*
* #param User $user User.
* #return User User.
*/
public function saveUser(User $user) {
return $this->insertUser($user);
}
/**
* Insert a user.
*
* #param User $user User.
* #return User User.
*/
private function insertUser(User $user) {
$sql = 'INSERT INTO users (username) VALUES (:username)';
$statement = $this->connection->prepare($sql);
$statement->execute([
':username' => $user->getUsername(),
]);
$user->setId($this->connection->lastInsertId());
return $user;
}
/**
* Update a user.
*
* #param User $user User.
* #return User User.
*/
private function updateUser(User $user) {
$sql = 'UPDATE users SET username = :username WHERE id = :id';
$statement = $this->connection->prepare($sql);
$statement->execute([
':username' => $user->getUsername(),
':id' => $user->getId(),
]);
return $user;
}
/**
* Convert the given data to a user.
*
* #param array $data Data.
* #return User User.
*/
private function convertDataToUser(array $data) {
$user = new User();
$user
->setId($data['id'])
->setUsername($data['username'])
;
return $user;
}
/**
* Convert the given data to a list of users.
*
* #param array $data Data.
* #return User[] User list.
*/
private function convertDataToUserList(array $data) {
$userList = [];
foreach ($data as $item) {
$userList[] = $this->convertDataToUser($item);
}
return $userList;
}
}
View:
<?php
namespace MyApp\UI\Web\View\Users;
use MyApp\UI\Web\View\View;
use MyApp\Domain\Service\Users;
use MyLib\Template\TemplateInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ResponseFactoryInterface;
/**
* Add a user.
*/
class AddUser extends View {
/**
* User service.
*
* #var Users
*/
private $userService;
/**
*
* #param ResponseFactoryInterface $responseFactory Response factory.
* #param TemplateInterface $template Template.
* #param Users $userService User service.
*/
public function __construct(ResponseFactoryInterface $responseFactory, TemplateInterface $template, Users $userService) {
parent::__construct($responseFactory, $template);
$this->userService = $userService;
}
/**
* Display a form for adding a user.
*
* #return ResponseInterface Response.
*/
public function index() {
$body = $this->template->render('#Template/Users/add-user.html.twig', [
'activeMainMenuItem' => 'addUser',
'action' => '',
]);
$response = $this->responseFactory->createResponse();
$response->getBody()->write($body);
return $response;
}
/**
* Add a user.
*
* #return ResponseInterface Response.
*/
public function addUser() {
$body = $this->template->render('#Template/Users/add-user.html.twig', [
'activeMainMenuItem' => 'addUser',
'message' => 'User successfully added.',
]);
$response = $this->responseFactory->createResponse();
$response->getBody()->write($body);
return $response;
}
}
Resources:
How should a model be structured in MVC?
Keynote: Architecture the Lost Years
GeeCON 2014: Sandro Mancuso - Crafted Design
This is an opiniated answer but here's my take. What I suggest is to not add a repository layer for the sake of having a repository in Laravel. whatever methods you need, add them to the model classes, When they are bloated/expect it to be bloated then only think about repositories (Most probably you would need a service class or some other abstraction here).
Since all these eloquent model classes can be resolved from container its easy to use them. it's accessible anywhere and even in the controller like you have mentioned can be injected which provides a great level of ease.
And repositories help to change for example the underlying database, But eloquent provides us with that flexibility already. And when you plan to change your database, I don't think its going to be a simple change so why wrap the logic up in another layer of abstraction (unneccessarily).
At least from my experience the repository pattern doesn't suite well with Active Record Pattern. Which Laravel follows. Where repository suites very well for data mapper pattern (for example Symfony uses it). Thats why in laravel documentation you don't see them embracing the repository pattern. Rather in symfony documentation you can see it.
So I suggest to embrace the framework than to fight it
I have a laravel API project. I want to be able to send a login request and get back a token depending on some custom logic. I am not using a database so i cant use the default auth.
I have set up a provider called AuthCustomProvider.
namespace App\Providers;
use Auth;
use App\Authentication\UserProvider;
use Illuminate\Support\ServiceProvider;
class AuthCustomProvider extends ServiceProvider
{
/**
* Perform post-registration booting of services.
*
* #return void
*/
public function boot()
{
Auth::provider('custom_auth', function($app, array $config) {
return new UserProvider();
});
}
/**
* Register bindings in the container.
*
* #return void
*/
public function register()
{
//
}
}
I have then added this to the config/app.php file in the providers array:
'providers' => [
App\Providers\AuthCustomProvider::class,
Then i added my custom providers driver to the config/auth.php file in the providers array:
'providers' => [
'users' => [
'driver' => 'custom_auth',
],
],
As im not using a database, I took out the model property
Lastly I created a folder called App/Authentication which i put my UserProvider.php file in which is this:
<?php
namespace App\Authentication;
use Illuminate\Contracts\Auth\UserProvider as IlluminateUserProvider;
class UserProvider implements IlluminateUserProvider
{
/**
* #param mixed $identifier
* #return \Illuminate\Contracts\Auth\Authenticatable|null
*/
public function retrieveById($identifier)
{
// Get and return a user by their unique identifier
}
/**
* #param mixed $identifier
* #param string $token
* #return \Illuminate\Contracts\Auth\Authenticatable|null
*/
public function retrieveByToken($identifier, $token)
{
// Get and return a user by their unique identifier and "remember me" token
}
/**
* #param \Illuminate\Contracts\Auth\Authenticatable $user
* #param string $token
* #return void
*/
public function updateRememberToken(Authenticatable $user, $token)
{
// Save the given "remember me" token for the given user
}
/**
* Retrieve a user by the given credentials.
*
* #param array $credentials
* #return \Illuminate\Contracts\Auth\Authenticatable|null
*/
public function retrieveByCredentials(array $credentials)
{
// Get and return a user by looking up the given credentials
}
/**
* Validate a user against the given credentials.
*
* #param \Illuminate\Contracts\Auth\Authenticatable $user
* #param array $credentials
* #return bool
*/
public function validateCredentials(Authenticatable $user, array $credentials)
{
// Check that given credentials belong to the given user
}
}
So lastly i make a function on the login controller. This is what the api call goes to:
public function Login(Request $request)
{
$user = Consultant::lookup('UserId', 1);
//Returns collection of user details (user id, username etc)
//Logic will go here in the future
$logThemIn = true;
if ($logThemIn)
{
auth()->login($user);
//return oauth2 token
}
}
So this is where im at now, if i run this, im getting the error:
'Declaration of App\Authentication\UserProvider::updateRememberToken(App\Authentication\Authenticatable $user, $token) must be compatible with Illuminate\Contracts\Auth\UserProvider::updateRememberToken(Illuminate\Contracts\Auth\Authenticatable $user, $token)'
Im new to laravel and there isnt alot of tutorials for what im trying to do that i can find. Any help is greatly appriciated
Change your UserProvider to this which uses Illuminate\Contracts\Auth\Authenticatable instead of App\Authentication\Authenticatable, php will load a class from the current namespace if one isn't specified.
<?php
namespace App\Authentication;
use Illuminate\Contracts\Auth\UserProvider as IlluminateUserProvider;
use Illuminate\Contracts\Auth\Authenticatable;
class UserProvider implements IlluminateUserProvider
{
/**
* #param mixed $identifier
* #return \Illuminate\Contracts\Auth\Authenticatable|null
*/
public function retrieveById($identifier)
{
// Get and return a user by their unique identifier
}
/**
* #param mixed $identifier
* #param string $token
* #return \Illuminate\Contracts\Auth\Authenticatable|null
*/
public function retrieveByToken($identifier, $token)
{
// Get and return a user by their unique identifier and "remember me" token
}
/**
* #param \Illuminate\Contracts\Auth\Authenticatable $user
* #param string $token
* #return void
*/
public function updateRememberToken(Authenticatable $user, $token)
{
// Save the given "remember me" token for the given user
}
/**
* Retrieve a user by the given credentials.
*
* #param array $credentials
* #return \Illuminate\Contracts\Auth\Authenticatable|null
*/
public function retrieveByCredentials(array $credentials)
{
// Get and return a user by looking up the given credentials
}
/**
* Validate a user against the given credentials.
*
* #param \Illuminate\Contracts\Auth\Authenticatable $user
* #param array $credentials
* #return bool
*/
public function validateCredentials(Authenticatable $user, array $credentials)
{
// Check that given credentials belong to the given user
}
}
You forgot to import Authenticatable. Just add:
use Illuminate\Contracts\Auth\Authenticatable;
I'm working on a members import batch (with insertions and updates) for a big project with a lot of entities such as Member, Client, Group, ....
After reading the chapter related to bulk imports in Doctrine doc, I've implemented this code :
$batchSize = 20;
$i = 0;
foreach ($entities as $entity)
{
$this->getEntityManager()->persist($entity);
if (($i % $batchSize) === 0)
{
$this->getEntityManager()->flush();
$this->getEntityManager()->clear();
}
}
$this->getEntityManager()->flush();
$this->getEntityManager()->clear();
Now, when I want to bulk handle an array of Member entities, Doctrine try to insert null data into a completely other table related to the Group entity and an exception is thrown An exception occurred while executing 'INSERT INTO groups ...
There are not any relations between Member and Group ...
Any idea about this weird behavior ?
EDIT
Short mapping details :
/**
* #ORM\Entity
* #ORM\Table(name="members")
*/
class Member
{
// some properties ...
/**
* #ORM\ManyToOne(targetEntity="Client", inversedBy="members", cascade={"persist", "merge"})
* #ORM\JoinColumn(name="client_id", referencedColumnName="id", onDelete="CASCADE")
*/
protected $client;
/**
* #return Client
*/
public function getClient()
{
return $this->client;
}
/**
* #param Client $client
*
* #return $this
*/
public function setClient(Client $client)
{
$this->client = $client;
return $this;
}
}
/**
* #ORM\Entity
* #ORM\Table(name="clients")
*/
class Client
{
/**
* #ORM\OneToMany(targetEntity="Member", mappedBy="client", cascade={"persist", "remove", "merge"}, fetch="EXTRA_LAZY")
*/
protected $members;
/**
* #ORM\ManyToOne(targetEntity="Group", inversedBy="clients", cascade={"persist", "merge"})
* #ORM\JoinColumn(name="clients_id", referencedColumnName="id", onDelete="SET NULL")
*/
protected $group;
public function __construct()
{
$this->members = new ArrayCollection();
}
/**
* #return ArrayCollection
*/
public function getMembers()
{
return $this->members;
}
/**
* #param $members
*
* #return $this
*/
public function setMembers($members)
{
$this->members = new ArrayCollection();
return $this->addMembers($members);
}
/**
* #param $members
*
* #return $this
*/
public function addMembers($members)
{
foreach ($members as $member)
{
$this->addMember($member);
}
return $this;
}
/**
* #param Member $member
*
* #return $this
*/
public function addMember(Member $member)
{
$this->members->add($member);
$member->setClient($this);
return $this;
}
/**
* #param Member $member
*
* #return $this
*/
public function removeMember(Member $member)
{
if ($this->members->contains($member))
{
$this->members->removeElement($member);
}
return $this;
}
/**
* #param $members
*
* #return $this
*/
public function removeMembers($members)
{
foreach ($members as $member)
{
$this->removeMember($member);
}
return $this;
}
/**
* #param Group $group
*
* #return $this
*/
public function setGroup(Group $group = null)
{
$this->group = $group;
return $this;
}
/**
* #return Group
*/
public function getGroup()
{
return $this->group;
}
}
/**
* #ORM\Entity
* #ORM\Table(name="groups")
*/
class Group
{
/**
* #ORM\OneToMany(targetEntity="Client", mappedBy="group")
*/
protected $clients;
public function __construct()
{
$this->clients = new ArrayCollection();
}
/**
* #return ArrayCollection
*/
public function getClients()
{
return $this->clients;
}
/**
* #param $clients
*
* #return $this
*/
public function setClients($clients)
{
$this->clients = new ArrayCollection();
return $this->addClients($clients);
}
/**
* #param $clients
*
* #return $this
*/
public function addClients($clients)
{
foreach ($clients as $client)
{
$this->addClient($client);
}
return $this;
}
/**
* #param Client $client
*
* #return $this
*/
public function addClient(Client $client)
{
if (!$this->clients->contains($client))
{
$this->clients->add($client);
$client->setGroup($this);
}
return $this;
}
/**
* #param $clients
*
* #return $this
*/
public function removeClients($clients)
{
foreach ($clients as $client)
{
$this->removeClient($client);
}
return $this;
}
/**
* #param Client $client
*
* #return $this
*/
public function removeClient(Client $client)
{
if ($this->clients->contains($client))
{
$this->clients->removeElement($client);
$client->setGroup(null);
}
return $this;
}
}
And the error is type of :
An exception occurred while executing 'INSERT INTO groups ... SQLSTATE[23502]: Not null violation: 7 ERROR: null value in column "label" violates not-null constraint
DETAIL: Failing row contains (60, null, f, null, f, null, null).
EDIT2
This is the table creation description (using postgresql) :
CREATE TABLE groups (
id integer NOT NULL,
tempref character varying(255) DEFAULT NULL::character varying,
prorated_basis boolean NOT NULL,
fixed_price_amount double precision,
is_indexed boolean,
pricing_grid pricing[],
label character varying(255) NOT NULL
);
CREATE SEQUENCE groups
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE groups_id_seq OWNED BY groups.id;
ALTER TABLE ONLY pricing_groups ALTER COLUMN id SET DEFAULT nextval('groups_id_seq'::regclass);
ALTER TABLE ONLY groups
ADD CONSTRAINT groups_pkey PRIMARY KEY (id);
I can describe what is causing the error, but only guess why it is caused and give some hints on what to look for when debuging this.
As you described, you are updating members, that are part of a client, that in turn is part of a group. As you specified on the relations by cascade=persist, clients and groups are saved as well when persisting a member. That means, groups are either updated or created when inserting members. In your case, you are creating a new group by this mechanism. Yet this group does not have the label property set, resulting in a NULL value in the database, which is not allowed by the scheme.
As you said, this error is already occuring during the best batch. One of the first 20 members you update implicity creates a new group with no label. To find out which one it is I'd suggest using a debugger and inspecet each member before persistence to see what the group of this member is part of, and if it exists in the database. If it does not exist (by ID), you should investigate why this group does not the required label set.
If all groups actually do exist in the database already, things do get a bit more tricky and this depends on how the members you are updating are loaded. Are they fetched from the EntityManager (managed state) or are they loaded from some different source (e.g. serialized) and hence in a unmanaged state? If they are unmanaged, they will become manage upon peristence, and by specification of the relation cascade=merge, client, and group, will become managed as well. There is an important thing to know here though, merge will return a new (managed) entity which is then persisted (see the accepted answer here). As this is a new object, there might be the chance that this object is not fully initialized and can contain undefined values (which then would translate to NULL).
So when loading the member data from a different source than the EntityManager, you might have to connect them with the EntityManager first to avoid this problem.
Debugging the last one is quite difficult and you'd need to step into the UnitOfWork->doPersist method to see how each individual entity is actual persisted.