Constants in Doctrine 2 entities - php

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.

Related

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.

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

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

Using one class's properties in another OOP PHP

I have the following class
namespace PG\Referrer\Single\Post;
class Referrer implements ReferrerInterface
{
/**
* #var $authorReferrer = null
*/
protected $isAuthorReferrer = null;
/**
* #var $dateReferrer = null
*/
protected $isDateReferrer = null;
/**
* #var $searchReferrer = null
*/
protected $isSearchReferrer = null;
/**
* #var $taxReferrer = null
*/
protected $isTaxReferrer = null;
/**
* #param array $values = null;
*/
public function __construct(array $values = null)
{
if ($values)
$this->setBulk($values);
}
/**
* Bulk setter Let you set the variables via array or object
*/
public function setBulk($values)
{
if (!is_array($values) && !$values instanceof \stdClass) {
throw new \InvalidArgumentException(
sprintf(
'%s needs either an array, or an instance of \\stdClass to be passed, instead saw %s',
__METHOD__,
is_object($values) ? get_class($values) : gettype($values)
)
);
}
foreach ($values as $name => $value) {//create setter from $name
global $wp_query;
if (array_key_exists($value, $wp_query->query_vars)) { //Check that user don't set a reserved query vars
throw new \InvalidArgumentException(
sprintf(
'%s is a reserved query_vars and cannot be used. Please use a unique value',
$value
)
);
}
$setter = 'set' . $name;
$condition = isset($_GET[$value]);
if ($setter !== 'setBulk' && method_exists($this, $setter)) {
$this->{$setter}($condition);//set value (bool)
}
}
return $this;
}
/**
* #param bool $authorReferrer
* #return $this
*/
public function setAuthorReferrer($isAuthorReferrer)
{
$this->isAuthorReferrer = $isAuthorReferrer;
return $this;
}
/**
* #param bool $dateReferrer
* #return $this
*/
public function setDateReferrer($isDateReferrer)
{
$this->isDateReferrer = $isDateReferrer;
return $this;
}
/**
* #param bool $searchReferrer
* #return $this
*/
public function isSearchReferrer($isSearchReferrer)
{
$this->isSearchReferrer = $isSearchReferrer;
return $this;
}
/**
* #param bool $taxReferrer
* #return $this
*/
public function setTaxReferrer($isTaxReferrer)
{
$this->isTaxReferrer = $isTaxReferrer;
return $this;
}
}
with its interface
namespace PG\Referrer\Single\Post;
interface ReferrerInterface
{
/**
* #param array $values
* #return $this
*/
public function setBulk($values);
/**
* #param bool $authorReferrer
* #return $this
*/
public function setAuthorReferrer($isAuthorReferrer);
/**
* #param bool $dateReferrer
* #return $this
*/
public function setDateReferrer($isDateReferrer);
/**
* #param bool $searchReferrer
* #return $this
*/
public function isSearchReferrer($isSearchReferrer);
/**
* #param bool $taxReferrer
* #return $this
*/
public function setTaxReferrer($isTaxReferrer);
}
This class sets up 4 conditionals that I need to use in another class. The values that is used in this class is also set from the other class, so basically the user sets values in the other class (lets call it class b) that is then used by class Referrer and returns the 4 conditionals which is then used by class b.
The reason why I'm doing it this way is because there will be two other classes that will need to do the same, but will returns different info
What is the more correct way to achieve this?
EDIT
To clear this up
class Referrer
The properties $isAuthorReferrer, $isDateReferreretc will either have a value of null or a boolean value depending on what is set by the user.
Example:
$q = new Referrer(['authorReferrer' => 'aq']);
In the code above, $isAuthorReferrer is set via the setBulk() method in the class to true when the variable aq is available in the URL or false when not present. The three other properties will return null because they are not set in the example.
The above works as expected, but I need to do this in another class, lets again call it class b. The arguments will be set to class b, and in turn, class b will set this arguments to class Referrer, class Referrer will use this arguments and return the proper values of its properties, and class b will use this results to do something else
Example:
$q = new b(['authorReferrer' => 'aq']);
Where class b could be something like this (it is this part that I'm not sure how to code)
class b implements bInterface
{
protected $w;
protected $other;
public function __construct($args = [])
{
//Do something here
// Do something here so that we can use $other in other classes or functions
}
public function a()
{
$w = new Referrer($args);
}
public function b()
{
// use $w properties here
// return $other for usage in other classes and functions
}
}
The best way is to inject the referrer to your classes in order to do loose coupling between them and the referrer (this pattern use the benefit of your ReferrerInterface):
class b implements bInterface
{
protected $referrer;
public function __construct(ReferrerInterface $referrer, array $values = array())
{
$this->referrer = $referrer;
$this->referrer->setBulk($values);
}
public function getReferrer()
{
return $this->referrer;
}
public function b()
{
// use $this->referrer properties here
}
}
// Instantiation (use your dependency injection if you have one):
$referrer = new Referrer();
$b = new b($referrer, ['authorReferrer' => 'aq']);
I do not understand what is $other so I removed it but explain me if you want me to I add it again.
If you need to use the properties of the referrer in b, you should add some getters in your ReferrerInterface to allow that. I would use setAuthorReferrer($isAuthorReferrer) to set the value and isAuthorReferrer() to get it for instance.

Categories