PHP/MVC/PDO - beginTransaction outside of Database class - php

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;
}

Related

How would one construct a complex/composite controller for multiple controllers

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;
}
}

static and non static methods in mvc websites

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;
}

ApiPlatform - Passing DTO's fields to different service after normalization

I have the structure like below.
----------------
MESSAGE
----------------
id
subject
body
----------------
----------------
USER
----------------
id
name
category
region
----------------
----------------
RECIPIENT
----------------
user_id
message_id
is_read
read_at
----------------
So Message 1:n Recipient m:1 User.
Recipient is not an #ApiResource.
A Backoffice user will "write" a message and choose the audience by a set of specific criteria (user region, user category, user tags...).
To POST the message i'm using a Dto
class MessageInputDto
{
/**
* #var string
*
* #Groups({"msg_message:write"})
*/
public string $subject;
/**
* #var string
*
* #Groups({"msg_message:write"})
*/
public string $body;
/**
* #var bool
*
* #Groups({"msg_message:write"})
*/
public bool $isPublished;
/**
* #var DateTimeInterface
*
* #Groups({"msg_message:write"})
*/
public DateTimeInterface $publishDate;
/**
* #var DateTimeInterface|null
*
* #Groups({"msg_message:write"})
*/
public ?DateTimeInterface $expiryDate = null;
/**
* #var MessageCategory|null
*
* #Groups({"msg_message:write"})
*/
public ?MessageCategory $category = null;
/**
* #var array
*/
public array $criteria = [];
}
The $criteria field is used to choose the audience of that message and is skipped by the DataTransformer as it is not a mapped field, a property of Message Entity that is returned by the transformer.
class MessageInputDataTransformer implements \ApiPlatform\Core\DataTransformer\DataTransformerInterface
{
/**
* #var MessageInputDto $object
* #inheritDoc
*/
public function transform($object, string $to, array $context = [])
{
$message = new Message($object->subject, $object->body);
$message->setIsPublished($object->isPublished);
$message->setPublishDate($object->publishDate);
$message->setExpiryDate($object->expiryDate);
$message->setCategory($object->category);
return $message;
}
/**
* #inheritDoc
*/
public function supportsTransformation($data, string $to, array $context = []): bool
{
// in the case of an input, the value given here is an array (the JSON decoded).
// if it's a book we transformed the data already
if ($data instanceof Message) {
return false;
}
return Message::class === $to && null !== ($context['input']['class'] ?? null);
}
}
As side effect, will be performed a bulk insert in the join table (Recipient) that keeps the m:n relations between Message and User.
My problem is how/where to perform this bulk insert and how pass the $criteria to the service that will manage it.
The only solution that i've found now (and it's working but i don't think is a good practice) is to put the bulk insert procedure in the POST_WRITE event of the Message, get the Request object and process the $criteria contained there.
class MessageSubscriber implements EventSubscriberInterface
{
/**
* #inheritDoc
*/
public static function getSubscribedEvents()
{
return [
KernelEvents::VIEW => [
['handleCriteria', EventPriorities::POST_WRITE]
],
];
}
public function handleCriteria(ViewEvent $event)
{
/** #var Message $message */
$message = $event->getControllerResult();
$method = $event->getRequest()->getMethod();
$e = $event->getRequest();
$collectionOperation = $e->get('_api_collection_operation_name');
if (!$message instanceof Message ||
$method !== Request::METHOD_POST ||
$collectionOperation !== 'post') {
return;
}
$content = json_decode($event->getRequest()->getContent(), true);
if(array_key_exists('audienceCriteria', $content)){
$criteria = Criteria::createFromArray($content['audienceCriteria']);
// Todo: Create the audience
}
}
}
So the idea is that, when the Message is persisted, the system must generate the "relations" public.
This is why i think that the post write event could be a good choice, but as i said i'm not sure this could be a good practice.
Any idea? Thanks.
As the docs on DTO's state: "in most cases the DTO pattern should be implemented using an API Resource class representing the public data model exposed through the API and a custom data provider. In such cases, the class marked with #ApiResource will act as a DTO."
IOW specifying an Input or an Output Data Representation and a DataTransformer is the exception. It does not work if the DTO holds more data then the entity or if the dto's are not one to one with the entities (for example with a report that does a group by).
Here is your DTO class as a resource:
namespace App\DTO;
use ApiPlatform\Core\Annotation\ApiResource;
use Symfony\Component\Serializer\Annotation\Groups;
use App\Entity\Message;
/**
* Class defining Message data transfer
*
* #ApiResource(
* denormalizationContext= {"groups" = {"msg_message:write"}},
* itemOperations={
* },
* collectionOperations={
* "post"={
* "path"="/messages",
* "openapi_context" = {
* "summary" = "Creates a Message",
* "description" = "Creates a Message"
* }
* }
* },
* output=Message::class
* )
*/
class MessageInputDto
{
/**
* #var string
*
* #Groups({"msg_message:write"})
*/
public string $subject;
/**
* #var string
*
* #Groups({"msg_message:write"})
*/
public string $body;
/**
* #var bool
*
* #Groups({"msg_message:write"})
*/
public bool $isPublished;
/**
* #var \DateTimeInterface
*
* #Groups({"msg_message:write"})
*/
public \DateTimeInterface $publishDate;
/**
* #var \DateTimeInterface|null
*
* #Groups({"msg_message:write"})
*/
public ?\DateTimeInterface $expiryDate = null;
/**
* #var MessageCategory|null
*
* #Groups({"msg_message:write"})
*/
public ?MessageCategory $category = null;
/**
* #var array
* #Groups({"msg_message:write"})
*/
public array $criteria = [];
}
Make sure the folder your class is in is in the paths list in api/config/packages/api_platform.yaml. There usually is the following configuration:
api_platform:
mapping:
paths: ['%kernel.project_dir%/src/Entity']
If MessageInputDto is in /src/DTO make it like:
api_platform:
mapping:
paths:
- '%kernel.project_dir%/src/Entity'
- '%kernel.project_dir%/src/DTO'
The post operation may have the same path as dhe default post operation on your Message resource. Remove that by explicitly defining collectionOperations for your Message resource without "post".
The post operation of MessageInputDto will deserialize the MessageInputDto. Your DataTransformer will not act on it so that it will arrive as is to the DataPersister:
namespace App\DataPersister;
use ApiPlatform\Core\DataPersister\ContextAwareDataPersisterInterface;
use App\DTO\MessageInputDto;
use App\Entity\Message;
use Doctrine\Persistence\ManagerRegistry;
use App\DataTransformer\MessageInputDataTransformer;
use ApiPlatform\Core\Exception\InvalidArgumentException;
class MessageDataPersister implements ContextAwareDataPersisterInterface
{
private $dataPersister;
private $entityManager;
private $dataTransformer;
public function __construct(ContextAwareDataPersisterInterface $dataPersister, ManagerRegistry $managerRegistry, MessageInputDataTransformer $dataTransformer)
{
$this->dataPersister = $dataPersister;
$this->entityManager = $managerRegistry->getManagerForClass(Message::class);
$this->dataTransformer = $dataTransformer;
}
public function supports($data, array $context = []): bool
{
$transformationContext = ['input' => ['class' => Message::class]];
return get_class($data) == MessageInputDto::class
&& $this->dataTransformer->supportsTransformation($data, Message::class, $transformationContext)
&& null !== $this->entityManager;
}
public function persist($data, array $context = [])
{
$message = $this->dataTransformer->transform($data, Message::class);
// dataPersister will flush the entityManager but we do not want incomplete data inserted
$this->entityManager->beginTransaction();
$commit = true;
$result = $this->dataPersister->persist($message, []);
if(!empty($data->criteria)){
$criteria = Criteria::createFromArray($data->criteria);
try {
// Todo: Create the audience, preferably with a single INSERT query SELECTing FROM user_table WHERE meeting the criteria
// (Or maybe better postpone until message is really sent, user region, category, tags may change over time)
} catch (\Exception $e) {
$commit = false;
$this->entityManager->rollback();
}
}
if ($commit) {
$this->entityManager->commit();
}
return $result;
}
public function remove($data, array $context = [])
{
throw new InvalidArgumentException('Operation not supported: delete');
}
}
(Maybe it should have been called MessageInputDtoDataPersister - depending on how you look at it)
Even with service autowiring and autoconfiguration enabled, you must still configure it to get the right dataPersister to delegate to:
# api/config/services.yaml
services:
# ...
'App\DataPersister\MessageDataPersister':
arguments:
$dataPersister: '#api_platform.doctrine.orm.data_persister'
This way you do not need MessageSubscriber.
Be aware that all the other phases inbetween deserialization and data persist (validation, security post denormalize) work on the MessageInputDto.
One solution when you have to generate multiple custom entities is to use data persisters: https://api-platform.com/docs/core/data-persisters/
There you have 2 options:
Decorate the doctrine persister - meaning the message will still be saved by Doctrine, but you can do something before or afterwards.
Implement a custom persister - saving both message and other related entities that you like. Or doing something completely custom, without calling Doctrine at all.

Using models in a controller alongside with repository

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

Does this concept to add methods to an existing PHP interface scale?

I am using Nicolas Widart's Laravel Modules package to help manage a large app, and keep everything separated into logical modules. I would like to be able to drop in different modules and have them play nicely without any extra configuration.
All of my modules will define interfaces and default implementations that allow the application (the system controlling which modules are loaded) to specify that it wants to use a specific implementation instead, through dependency injection.
I am able to make some assumptions by having some modules require others, for example a payment processing module (Module PP) can assume that a payment is tied to a user (with which the interface for a user is defined in another module, Module U).
My ideal scenario is that I could add to an existing PHP interface that is defined in another required module. For example, being able to retrieve a user from a repository defined in Module U and call a method on it that was defined in Module PP.
Once Module PP resolves the interface (again, through dependency injection) from Module U to a class, I want my method from Module PP to be callable on that class.
I have been able to achieve this using the __call magic method as below.
Extensions Module
This module defines the core operations to add to an existing interface.
IsExtendable Interface
<?php
namespace Modules\Extensions\Contracts;
interface IsExtendable
{
/**
* Get the list of extensions for this entity.
*
* #return array
*/
public static function getExtensions();
/**
* Adds an extension to this entity.
*
* #param string $name
* #param mixed $function
*/
public static function addExtension($name, $function);
/**
* Checks whether the entity has the given extension.
*
* #param string $name
*
* #return bool
*/
public static function hasExtension($name);
/**
* Call the extension if it exists, or pass it further up the chain.
*
* #param string $name
* #param mixed $arguments
*
* #return mixed
*/
public function __call($name, $arguments);
}
IsExtendable Trait
<?php
namespace Modules\Extensions;
trait IsExtendable
{
/** #var $extensions */
private static $extensions = [];
/**
* Get the list of extensions for this entity.
*
* #return array
*/
public static function getExtensions()
{
return self::$extensions;
}
/**
* Adds an extension to this entity.
*
* #param string $name
* #param mixed $function
*/
public static function addExtension($name, $function)
{
if(is_callable($function) == FALSE)
{
throw new \InvalidArgumentException('Function must be callable.');
}
self::$extensions[$name] = $function;
}
/**
* Checks whether the entity has the given extension.
*
* #param string $name
*
* #return bool
*/
public static function hasExtension($name)
{
return array_key_exists($name, self::getExtensions()) == TRUE;
}
/**
* Calls the extension if it exists, or passes it further up the chain.
*
* #param string $name
* #param mixed $arguments
*
* #return mixed
*/
public function __call($name, $arguments)
{
if(self::hasExtension($name) == TRUE)
{
$callable = self::getExtensions()[$name];
return call_user_func_array($callable, array_merge(array($this), $arguments));
}
else
{
return parent::__call($name, $arguments);
}
}
}
Service Provider
<?php
namespace Modules\Extensions\Providers;
use Illuminate\Support\ServiceProvider;
use Modules\Extensions\Contracts\IsExtendable as IsExtendableContract;
class ExtensionServiceProvider extends ServiceProvider
{
/**
* #param string $implementation
* #param string $functionName
*
* #return callable
*/
public function prepareExtension($implementation, $functionName)
{
return $implementation . '::' . $functionName;
}
/**
* #param string $contract
* #param string $implementation
*
* #return void
*/
public function extractExtensions($contract, $implementation)
{
$reflection = new \ReflectionClass($implementation);
$methods = [];
foreach($reflection->getMethods(\ReflectionMethod::IS_STATIC) as $method)
{
// TODO: May be able to use $method->getClosure() here
// https://stackoverflow.com/questions/8299886/php-get-static-methods
$methods[] = $method->getName();
}
$this->registerExtensions($contract, $methods, $implementation);
}
/**
* #param string $contract
* #param string $name
* #param string $function
*
* #return void
*/
public function registerExtension($contract, $name, $function)
{
// Resolve the contract to an implementation
$base = app($contract);
// Check that it is suitable for extension
if($base instanceof IsExtendableContract)
{
$base::addExtension($name, $function);
}
}
/**
* #param string $contract
* #param array $extensions
* #param string|null $implementation
*
* #return void
*/
public function registerExtensions($contract, array $extensions = [], $implementation = NULL)
{
// Resolve the contract to an implementation
$base = app($contract);
// Check that it is suitable for extension
if($base instanceof IsExtendableContract)
{
foreach($extensions as $name => $function)
{
if(is_int($name) == TRUE)
{
if(is_string($function) == TRUE)
{
$name = $function;
}
else
{
throw new \InvalidArgumentException('All extensions must have a valid name.');
}
}
if(is_string($function) == TRUE)
{
if(strpos($function, '::') === FALSE && $implementation != NULL)
{
$function = $this->prepareExtension($implementation, $function);
}
}
$base::addExtension($name, $function);
}
}
}
}
Module U
User Interface
<?php
namespace Modules\Auth\Contracts\Entities;
interface User
{
/**
* #return int
*/
public function getId();
/**
* #return string
*/
public function getName();
/**
* #return string
*/
public function getEmail();
/**
* #return \DateTime
*/
public function getCreatedAt();
/**
* #return \DateTime
*/
public function getUpdatedAt();
}
User Implementation
<?php
namespace Modules\Auth\Entities;
use Modules\Extensions\Contracts\IsExtendable as IsExtendableContract;
use Modules\Auth\Contracts\Entities\User as UserContract;
use Modules\Extensions\IsExtendable;
class User implements
IsExtendableContract,
UserContract
{
use IsExtendable;
/**
* #return int
*/
public function getId()
{
return $this->id;
}
/**
* #return string
*/
public function getName()
{
return $this->name;
}
/**
* #return string
*/
public function getEmail()
{
return $this->email;
}
/**
* #return \DateTime
*/
public function getCreatedAt()
{
return $this->created_at;
}
/**
* #return \DateTime
*/
public function getUpdatedAt()
{
return $this->updated_at;
}
}
Module PP
User Extension
<?php
namespace Modules\Test\Entities\Extensions;
use Modules\Auth\Contracts\Entities\User;
class UserExtension
{
/**
* #param User $context
*/
public static function getCardLastFour($context)
{
return $context->card_last_four;
}
/**
* #param User $context
*/
public static function getCardBrand($context)
{
return $context->card_brand;
}
/**
* #param User $context
*/
public static function getStripeId($context)
{
return $context->stripe_id;
}
}
Service Provider
<?php
namespace Modules\Test\Providers\Extensions;
use Modules\Auth\Contracts\Entities\User as UserContract;
use Modules\Test\Entities\Extensions\UserExtension;
use Modules\Extensions\Providers\ExtensionServiceProvider;
class StripeExtensionProvider extends ExtensionServiceProvider
{
public function boot()
{
// TODO: Set the contract as a static field on the extension to then automatically extract from all extension files in a folder
$this->extractExtensions(UserContract::class, UserExtension::class);
}
}
My question is, is this method scalable (across maybe 10 modules), and can you foresee any issues with it? Or is there a better/more popular (and supported) way to do this? I don't want to get 2 years into a project and discover that I really hate the way I've implemented this.
I know that this concept won't support IDE autocompletion out of the box but I could build in a way to generate the PHPDocs similar to this package.
I have researched the Decorator pattern but this feels clunky in that I would always need to rely on a new implementation within each module, instead of just adding to the existing one.
I realise this is a big question so my sincere thanks to anyone willing to have a look at it!
Check out Laravel's macroable trait. It's basically the same idea, and Laravel uses it all over the place.
So yes, it scales - up to a certain point. Like almost everything else, this is a tool that can be abused. Use it with a dash of common sense, and you should be OK.

Categories