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

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.

Related

PHP/MVC/PDO - beginTransaction outside of Database class

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

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

Twig error on WebProfiler with Doctrine filter enable

I have a strange error with Twig and the WebProfiler when I enable a Doctrine filter.
request.CRITICAL: Uncaught PHP Exception Twig_Error_Runtime: "An exception has been thrown
during the rendering of a template ("Error when rendering "http://community.localhost:8000/
_profiler/e94abf?community_subdomain=community&panel=request" (Status code is 404).")." at
/../vendor/symfony/symfony/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Profiler/
layout.html.twig line 103
This {{ render(path('_profiler_search_bar', request.query.all)) }} causes the error.
My doctrine filter allows to add filter constraint on some classes (multi tenant app with dynamic subdomains)
<?php
namespace AppBundle\Group\Community;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Query\Filter\SQLFilter;
/**
* Class CommunityAwareFilter
*/
class CommunityAwareFilter extends SQLFilter
{
/**
* Gets the SQL query part to add to a query.
*
* #param ClassMetadata $targetEntity
* #param string $targetTableAlias
*
* #return string The constraint SQL if there is available, empty string otherwise.
*/
public function addFilterConstraint(ClassMetadata $targetEntity, $targetTableAlias)
{
if (!$targetEntity->reflClass->implementsInterface(CommunityAwareInterface::class)) {
return '';
}
return sprintf('%s.community_id = %s', $targetTableAlias, $this->getParameter('communityId')); // <-- error
// return ''; <-- no error
}
}
I have also extended Symfony Router to add subdomain placeholder automatically in routing.
Do you have any idea what can cause this ?
UPDATE
<?php
namespace AppBundle\Routing;
use AppBundle\Group\Community\CommunityResolver;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Routing\Exception\MethodNotAllowedException;
use Symfony\Component\Routing\Exception\ResourceNotFoundException;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Routing\RequestContext;
use Symfony\Component\Routing\RouteCollection;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Bundle\FrameworkBundle\Routing\Router as BaseRouter;
class Router implements RouterInterface
{
/**
* #var BaseRouter
*/
private $router;
/**
* #var RequestStack
*/
private $request;
/**
* #var CommunityResolver
*/
private $communityResolver;
/**
* Router constructor.
*
* #param BaseRouter $router
* #param RequestStack $request
* #param CommunityResolver $communityResolver
*/
public function __construct(BaseRouter $router, RequestStack $request, CommunityResolver $communityResolver)
{
$this->router = $router;
$this->request = $request;
$this->communityResolver = $communityResolver;
}
/**
* Sets the request context.
*
* #param RequestContext $context The context
*/
public function setContext(RequestContext $context)
{
$this->router->setContext($context);
}
/**
* Gets the request context.
*
* #return RequestContext The context
*/
public function getContext()
{
return $this->router->getContext();
}
/**
* Gets the RouteCollection instance associated with this Router.
*
* #return RouteCollection A RouteCollection instance
*/
public function getRouteCollection()
{
return $this->router->getRouteCollection();
}
/**
* Tries to match a URL path with a set of routes.
*
* If the matcher can not find information, it must throw one of the exceptions documented
* below.
*
* #param string $pathinfo The path info to be parsed (raw format, i.e. not urldecoded)
*
* #return array An array of parameters
*
* #throws ResourceNotFoundException If the resource could not be found
* #throws MethodNotAllowedException If the resource was found but the request method is not allowed
*/
public function match($pathinfo)
{
return $this->router->match($pathinfo);
}
public function generate($name, $parameters = array(), $referenceType = UrlGeneratorInterface::ABSOLUTE_PATH)
{
if (null !== ($community = $this->communityResolver->getCommunity())) {
$parameters['community_subdomain'] = $community->getSubDomain();
}
return $this->router->generate($name, $parameters, $referenceType);
}
}
I found the solution, in fact I passed my "tenant" (here my "community") object in the Session like this (in a subscriber onKernelRequest)
if (null === ($session = $request->getSession())) {
$session = new Session();
$session->start();
$request->setSession($session);
}
$session->set('community', $community);
I changed to store this object in a service and it works. Maybe using the Session to store data is a bad practice.
I think your Symmfony Router override may cause the problem. Can you paste us the code ?

CodeIgniter Models vs Symfony/Doctrine Models

Background:
I have build my web application using CodeIgniter because it was the only framework I could grasp easily enough to get going quickly. Now seeing the unbelievably advanced functionality of symfony and the PSR standards I am hyped to get into it all.
Dialemma
I am not sure how to approach the model layer with symfony/doctrine. As I understand it: doctrine generates an entity class for a database table like so...
This class contains a bunch of setter/getter functions.
My mental block at the moment is that I don't understand how I am supposed to add to functionality to my model layer.
To understand where I am coming from take a look at a typical CodeIgniter Model that I am currently working with. This one handles discount coupons.
<?php
/**
* This class handles all coupon codes
*/
class Coupon_Model extends CI_Model
{
/**
* gets a specific coupon
* #param string $coupon_code
* #return obj
*/
public function getCoupon($coupon_code)
{
$this->db->where('coupon_code', $coupon_code);
$query = $this->db->get('coupons');
return $query->row();
}
/**
* gets all coupons associated with a course
* #param int $course_id
* #return array
*/
public function getCourseCoupons($course_id)
{
$this->db->where('course_id', $course_id);
$query = $this->db->get('coupons');
return $query->result();
}
/**
* generates a string of 10 random alphanumeric numbers
* #return string
*/
public function generateCouponCode()
{
return strtoupper(substr(base_convert(sha1(uniqid(mt_rand())), 16, 36), 0, 10));
}
/**
* creates a new active coupon
* #param array $data
* #param string $coupon_code
* #return bool
*/
public function createCoupon($data, $coupon_code = null)
{
if ($coupon_code !== '') {
$data['coupon_code'] = $coupon_code;
} else {
$data['coupon_code'] = $this->generateCouponCode();
}
return $this->db->insert('coupons', $data);
}
/**
* checks if a coupon is valid
* #param string $coupon_code
* #param int $course_id
* #return bool
*/
public function checkCoupon($coupon_code, $course_id = null)
{
$this->db->where('coupon_code', $coupon_code);
$query = $this->db->get('coupons');
$coupon = $query->row();
// if coupon code exists
if ($coupon === null) {
return false;
}
// if coupon is for the right course
if ($coupon->course_id !== $course_id && $course_id !== null) {
return false;
}
// if coupon code has not expired
if ($coupon->expiry_date <= $this->Time_Model->getCarbonNow()->timestamp) {
return false;
}
return true;
}
/**
* deletes a coupon record
* #param int coupon_id
* #return bool
*/
public function deleteCoupon($coupon_id)
{
$this->db->where('coupon_id', $coupon_id);
return $this->db->delete('coupons');
}
/**
* applys the coupon discount
* #param int $price
* #param float $discount (percentage)
*/
public function applyDiscount($price, $discount)
{
$price = $price - (($discount / 100) * $price);
return $price;
}
}
As you can see it is pretty straight forward, if I wanted to add functionality I would literally just create a new function.
To use this model I would simply load it on the Controller like this:
$this->model->load('coupons/Coupon_Model');
$this->Coupon_Model->getCoupon($coupon_code);
Simple, done and dusted... unfortunately I am not sure how to implement this sort of functionality with symfony/doctrine.
Will I need to create a new class separate from the entity and add extra functionality to this class? Or should I add more functions to the entity class?
Take for example my simple function which generates the coupon code:
/**
* generates a string of 10 random alphanumeric numbers
* #return string
*/
public function generateCouponCode()
{
return strtoupper(substr(base_convert(sha1(uniqid(mt_rand())), 16, 36), 0, 10));
}
Where would be the best place to put this function? Under AppBundle/models/coupons?
I have clearly picked up bad habits from CodeIgniter and have a feeling that I am approaching this the wrong way.
Symfony + Doctrine ORM comes with a lot of the default needs for the replacement of CodeIgniter models by using the EntityManager within your Controller(s).
For example
namespace AppBundle\Controller;
use Symfony\Component\HttpFoundation\Request;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
class DefaultController extends Controller
{
/**
* #Route("/{id}/show", name="app_show", defaults={"id" = 1})
*/
public function showAction(Request $request, $id)
{
$em = $this->getDoctrine()->getManager();
if (!$coupon = $em->find('AppBundle:Coupon', $id)) {
throw new NotFoundException('Unknown Coupon Specified');
}
//see below to see how this was implemented
$similarCoupons = $em->getRepository('AppBundle:Coupon')
->filterCourse($coupon->course);
return $this->render('AppBundle:template.twig', [
'coupon' => $coupon,
'similarCoupons' => $similarCoupons
]);
}
/**
* #Route("/new", name="app_new")
*/
public function newAction(Request $request)
{
//use Symfony Form Component instead
$em = $this->getDoctrine()->getManager();
$coupon = new \AppBundle\Entity\Coupon;
//calls __construct to call generateCouponCode
$coupon->setName($request->get('name'));
$em->persist($coupon);
$em->flush();
return $this->redirectToRoute('app_show', ['id' => $coupon->getId()]);
}
//...
}
You want to specify the functionality you want each entity to have when working with it from within the Entity class.
That it becomes available without needing to revisit the repository, since an Entity should never be aware of the EntityManager.
In effect, each Entity can be considered their own models.
For example $coupon->generateCouponCode(); or $this->generateCouponCode() from within the entity.
Otherwise you would use a Repository of your Doctrine Database Entity(ies) to add more complex functionality.
// /src/AppBundle/Entity/Coupon.php
namespace AppBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
/**
* #ORM\Entity(repository="CouponRepository")
*/
class Coupon
{
/**
* #var integer
* #ORM\Column(name="id", type="integer", nullable=false)
* #ORM\Id
* #ORM\GeneratedValue(strategy="IDENTITY")
*/
private $id;
/**
* #var string
* #ORM\Column(name="name", type="string", length=50)
*/
private $name;
/**
* #var string
* #ORM\Column(name="coupon_code", type="string", length=10)
*/
private $couponCode;
/**
* #var Course
* #ORM\ManyToOne(targetEntity="Course", inversedBy="coupons")
* #ORM\JoinColumn(name="course", referencedColumnName="id")
*/
private $course;
//...
public function __construct()
{
//optionally create code when persisting a new database entry by using LifeCycleCallbacks or a Listener instead of this line.
$this->couponCode = $this->generateCouponCode();
}
//...
/**
* generates a string of 10 random alphanumeric numbers
* #return string
*/
public function generateCouponCode()
{
return strtoupper(substr(base_convert(sha1(uniqid(mt_rand())), 16, 36), 0, 10));
}
}
Then your custom queries would go into your Repository.
// /src/AppBundle/Entity/CouponRepository.php
namespace AppBundle\Entity;
use Doctrine\ORM\EntityRepository;
class CouponRepository extends EntityRepository
{
/**
* filters a collection of Coupons that matches the supplied Course
* #param Course $course
* #return array|Coupons[]
*/
public function filterCourse(Course $course)
{
$qb = $this->createQueryBuilder('c');
$expr = $qb->expr();
$qb->where($expr->eq('c.course', ':course'))
->setParameter('course', $course);
return $qb->getQuery()->getResult();
}
}
Additionally you can filter collections of an association (Foreign Key) reference within your entity.
namespace AppBundle\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Criteria;
//...
class Course
{
//...
/**
* #var ArrayCollection|Coupon[]
* #ORM\OneToMany(targetEntity="Coupon", mappedBy="course")
*/
private $coupons;
public function __construct()
{
$this->coupons = new ArrayCollection;
}
/**
* #return ArrayCollection|Coupon[]
*/
public function getCoupons()
{
return $this->coupons;
}
/**
* #param string $name
* #return \Doctrine\Common\Collections\Collection|Coupon[]
*/
public function getCouponsByName($name)
{
$criteria = Criteria::create();
$expr = $criteria::expr();
return $this->coupons->matching($criteria->where($expr->eq('name', $name)));
}
}

Constants in Doctrine 2 entities

Suppose i'm having the following Doctrine 2 entity:
/**
* #ORM\Entity
* #ORM\Table(name="users")
*/
class User {
/**
* #ORM\Id
* #ORM\Column(type="integer")
* #ORM\GeneratedValue
*
* #var int
*/
protected $id;
/**
* #ORM\Column(length=100)
*
* #var string
*/
protected $name;
/**
* #ORM\Column(type="integer")
*
* #var int
*/
protected $status;
}
The User can have several statuses, for example: Pending, Active, Suspended. These statuses are needed throughout the code (services, repositories, etc.) and also in the UI layer (a User edit form would display them in a dropdown).
In order to avoid defining them in multiple places, what i've done so far was to use a class to hold them all (all the application's constants), and it looks somewhat like this:
class App_Constants extends Zrzr_Constants
{
protected static $_constants = array(
'users' => array(
'status' => array(
0 => 'Pending',
1 => 'Active',
2 => 'Suspended' ) ) );
}
The base class (Zrzr_Constants) would offer some methods to retrieve them, and it looks like this:
class Zrzr_Constants
{
protected static $_constants = array();
public static function getConstantValues( $key, $subkey )
{
// ...
}
public static function getConstantByName( $name )
{
// ...
}
}
Common usage would be:
// example of retrieval by constant name ... it would return an integer
$pendingStatus = App_Constants::getConstantByName( 'USERS.STATUS.PENDING' );
// example of retrieval for UI display purposes ... would return an array
$statuses = App_Constants::getConstantValues('users', 'status');
Of course this means that there are some limitations in that the constant labels cannot contain dots, but i can live with it.
Using Doctrine 2 and going the DDD way however, tells me that the 'status' field should be in fact a 'value object' (but Doctrine 2 does not support value objects yet), or at least that i should have the constants defined within the entity (using const).
My question is how would i do this so that i avoid constant redefinition for the UI layer? I need to have access to the constant by name (in the code) and to have all the possible values for such a field in the case of a UI dropdown (for example).
I think, you can do it this way:
class User {
const STATUS_PENDING = 'Pending';
const STATUS_ACTIVE = 'Active';
const STATUS_SUSPENDED = 'Suspended';
public static function getStatusList() {
return array(
self::STATUS_PENDING,
self::STATUS_ACTIVE,
self::STATUS_SUSPENDED
);
}
public function getStatus() {...}
public function setStatus($value) {...}
public function isStatusPending() {...} //If you need it
}
On the UI layer, you can get text versions of your statuses using localization service (if status constants are numbers, UI layer can convert them to strings by adding prefix, for example user_status_0). In Symfony2 views you can use trans Twig filter for that to get text version of user status from user localization domain.
If your website is just in one language, then just User::STATUS_XXX will do fine, I think. I don't think you should overcomplicate the matter by creating a new class to hold statuses of the user.
If you will end up having many statuses or some other related things, I think you will have to create a separate entity for them.
you can define your class as in the following example
class ContactResource
{
const TYPE_PHONE = 1;
const TYPE_EMAIL = 2;
const TYPE_BIRTDAY = 3;
const TYPE_ADDRESS = 4;
const TYPE_OTHER = 5;
const TYPE_SKYPE = 6;
const TYPE_LINKEDIN = 7;
const TYPE_MEETUP = 8;
const TYPE_TELEGRAM = 9;
const TYPE_INSTAGRAM = 10;
const TYPE_TWITTER = 11;
public static $resourceType = array(
ContactResource::TYPE_PHONE => "Phone",
ContactResource::TYPE_EMAIL => "Email",
ContactResource::TYPE_BIRTDAY => "Birtday",
ContactResource::TYPE_ADDRESS => "Address",
ContactResource::TYPE_OTHER => "Other",
ContactResource::TYPE_SKYPE => "Skype",
ContactResource::TYPE_LINKEDIN => "LinkedIn",
ContactResource::TYPE_MEETUP => "Meetup",
ContactResource::TYPE_TELEGRAM => "Telegram",
ContactResource::TYPE_INSTAGRAM => "Instagram",
ContactResource::TYPE_TWITTER => "Twitter",
);
/**
* #var integer
*
* #ORM\Column(type="integer", length=2)
*
*/
private $type;
public function __toString()
{
return (string)$this->getType();
}
public function getType()
{
if (!is_null($this->type)) {
return self::$resourceType[$this->type];
} else {
return null;
}
}
public static function getTypeList() {
return self::$resourceType;
}
}
If you need to get the type in Twig
{{ entity.type }}
For the list of choices
ContactResource::getTypeList()
Hope works for you!
Several years later and some more experience, what I consider to be the proper answer has changed. The initial question is about domain constants used in the UI layer, but the given example and the discussions actually refer to the following concepts: enums, enum maps and value objects. I did not have these concepts back then and the answers to my question did not provide them.
When you see or think of constants like STATUS_PENDING, STATUS_ACTIVE, STATUS_SUSPENDED you should be thinking of an enum. The standard PHP enum is insufficient so I like to use a third party library like marc-mabe/php-enum. Here's how it would look like:
use MabeEnum\Enum;
/**
* #method static UserStatus PENDING()
* #method static UserStatus ACTIVE()
* #method static UserStatus SUSPENDED()
*/
class UserStatus extends Enum
{
const PENDING = 0;
const ACTIVE = 1;
const SUSPENDED = 2;
}
It's easy to turn this into a value object if you need to add functionality to it (I recommend doing it through composition, not inheritance). Coming back to the User entity, using the above enum the entity would end up like this:
/**
* #ORM\Entity
* #ORM\Table(name="users")
*/
class User {
/**
* #ORM\Id
* #ORM\Column(type="integer")
* #ORM\GeneratedValue
*
* #var int
*/
protected $id;
/**
* #ORM\Column(length=100)
*
* #var string
*/
protected $name;
/**
* #ORM\Column(type="user_status")
*
* #var UserStatus
*/
protected $status;
}
Notice the column type is "user_status". To get this to work you need to define a custom Doctrine type and register it with Doctrine. Such a type would look like this:
/**
* Field type mapping for the Doctrine Database Abstraction Layer (DBAL).
*
* UserStatus fields will be stored as an integer in the database and converted back to
* the UserStatus value object when querying.
*/
class UserStatusType extends Type
{
/**
* #var string
*/
const NAME = 'user_status';
/**
* {#inheritdoc}
*
* #param array $fieldDeclaration
* #param AbstractPlatform $platform
*/
public function getSQLDeclaration(array $fieldDeclaration, AbstractPlatform $platform)
{
return $platform->getIntegerTypeDeclarationSQL($fieldDeclaration);
}
/**
* {#inheritdoc}
*
* #param string|null $value
* #param AbstractPlatform $platform
*/
public function convertToPHPValue($value, AbstractPlatform $platform)
{
if (empty($value)) {
return null;
}
if ($value instanceof UserStatus) {
return $value;
}
try {
$status = UserStatus::get((int)$value);
} catch (InvalidArgumentException $e) {
throw ConversionException::conversionFailed($value, self::NAME);
}
return $status;
}
/**
* {#inheritdoc}
*
* #param UserStatus|null $value
* #param AbstractPlatform $platform
*/
public function convertToDatabaseValue($value, AbstractPlatform $platform)
{
if (empty($value)) {
return null;
}
if ($value instanceof UserStatus) {
return $value->getValue();
}
throw ConversionException::conversionFailed($value, self::NAME);
}
/**
* {#inheritdoc}
*
* #return string
*/
public function getName()
{
return self::NAME;
}
/**
* {#inheritdoc}
*
* #param AbstractPlatform $platform
*
* #return boolean
*/
public function requiresSQLCommentHint(AbstractPlatform $platform)
{
return true;
}
}
Finally, when it comes to satisfying the needs of the user interface, you can end up using enum maps. Remember that the UI could need additional functionality such as multiple language support, so you cannot mash such concerns into the domain, hence the separation:
use MabeEnum\EnumMap;
class UserStatusMap extends EnumMap
{
public function __construct()
{
parent::__construct(UserStatus::class);
$this[UserStatus::PENDING] = ['name' => 'Pending'];
$this[UserStatus::ACTIVE] = ['name' => 'Active'];
$this[UserStatus::SUSPENDED] = ['name' => 'Suspended'];
}
}
You can just add as many keys you want beside 'name'. In the UI you can make use of such a map like this:
// if you want to display the name when you know the value
echo (new UserStatusMap ())[UserStatus::PENDING]['name'];
// or
echo (new UserStatusMap ())[UserStatus::PENDING()]['name'];
// if you want to build a list for a select (value => name)
$list = (new UserStatusMap ())->toArray('name');
The toArray function is not available in MabeEnum\EnumMap but you can make your own:
use MabeEnum\EnumMap as BaseEnumMap;
class EnumMap extends BaseEnumMap
{
/**
* #param string|null $metadataKey
*
* #return array
*/
public function toArray($metadataKey = null)
{
$return = [];
$flags = $this->getFlags();
$this->setFlags(BaseEnumMap::KEY_AS_VALUE | BaseEnumMap::CURRENT_AS_DATA);
if ($metadataKey) {
foreach ($this as $key => $value) {
$return[$key] = $value[$metadataKey];
}
} else {
$return = iterator_to_array($this, true);
}
$this->setFlags($flags);
return $return;
}
}
To summarize:
Use an Enum to define a list of alternative values for a single field.
Create a Value Object which receives this Enum in the constructor if you want to add VO specific functionality to this field.
Use an Enum Map to serve the UI needs.

Categories