How to add a new field to an entity

Open Loyalty contains two main types of entities: ORM and Event source aggregate entities (according to CQRS pattern). You can find more details about architecture solutions in architecture section of our documentation.

ORM entities ane entities which are stored in database (PostgreSQL). Doctrine is persistence manager for that entities. Event source aggregate entities are managed by Broadway library and objects of this type are stored in ElasticSearch as projections and in database (PostgreSQL) as event store.

ORM entity

One of examples of ORM entity in Open Loyalty is the Campaign object. Let’s assume that we want to add a new field to the campaign entity with name title and type string.

Entity object

Add a property to src/Domain/Campaign/Campaign.php.

/**
 * @var string|null
 */
protected $title;

/**
 * @return string|null
 */
public function getTitle(): ?string
{
    return $this->title;
}

/**
 * @param string|null $title
 */
public function setTitle(?string $title): void
{
    $this->title = $title;
}

Note

If you want to add a translatable field you will need to follow instructions from How to create a translatable field

Then we have to extend the functions responsible for creation of an object from array data.

public function setFromArray(array $data)
{
    ...
    if (array_key_exists('title', $data)) {
        $this->title = $data['title'];
    }
    ...
}

Next open the class src/Infrastructure/Campaign/Model/Campaign.php to extend the function responsible for

public function toArray(): array
{
    ...
    'title' => $this->title,
    ...
}

Note

Not all classes require extending these functions.

Doctrine

Next, we have to let Doctrine know about the new field. Find the file src/Infrastructure/Campaign/Persistence/Doctrine/ORM/Campaign.orm.yml and in the fields section add:

title:
    type: string
    nullable: true
    column: title

Now we can persist schema changes to the database. Execute the following Symfony command in the console:

bin/console doctrine:schema:update --force

After successful execution, the field is ready to use by the backend application, but it is not used in controllers and is not visible in the frontend application.

Serialization

In the next step, we will let serialization know how to treat our new field. In the file src/Infrastructure/Campaign/Resources/config/serializer/Campaign.yml in section properties, add a clause to publicize the new field, as they are excluded as default.

title:
  exclude: false

Note

Modification serialization config files usually requires remove cache in order to work.

Controllers

Campaign entity has a possibility to store data in the new field, but now we need a way to pass its value from the user interface. In order to do that we need to find controllers and actions responsible for adding and editing new campaigns.

In the first line of src/Ui/Rest/Controller/Campaign/Post.php file we see that data is taken from CampaignFormType object. Let’s open it and add the following to the build function:

$builder->add('title', TextType::class, [
    'required' => false,
]);

Add a field to UI

Add the following to the frontend/src/modules/admin.campaign/templates/add-campaign.html file:

<div class="row">
    <div class="medium-2 small-3 columns">
        <label>{{ "campaign.more_information_title" | translate }} </label>
    </div>
    <div class="medium-10 small-9 columns" form-validation="validate.title.errors">
        <input type="text" ng-model="newCampaign.title"/>
        <span class="prompt">{{ "campaign.title_prompt" | translate }} </span>
    </div>
</div>

To the file frontend/src/modules/admin.campaign/templates/edit-campaign.html add:

<div class="row">
    <div class="medium-2 small-3 columns">
        <label>{{ "campaign.more_information_title" | translate }} </label>
    </div>
    <div class="medium-10 small-9 columns" form-validation="validate.title.errors">
        <input type="text" ng-model="editableFields.title"/>
        <span class="prompt">{{ "campaign.title_prompt" | translate }} </span>
    </div>
</div>

Event source aggregate entities

An example of an event source aggregate entity in Open Loyalty is Customer object. Let’s assume that we want to add a new field to the Customer entity with name code and type string.

Domain entity

Like in the example above, let’s start with domain object src/Domain/User/Customer.php. As you might have noticed this class extends SnapableEventSourcedAggregateRoot - it’s confirmation that this entity is an aggregate entity and uses CQRS pattern. Add an entity property code with getter to this class.

/**
 * @var string|null
 */
protected $code;

/**
 * @return string|null
 */
public function getCode(): ?string
{
    return $this->code;
}

Additionally, let’s assume that we want to set the value of this field only during the registration process. To do that, we need to find the method responsible for applying changes to domain object when customer is being registered. The method below is executed when application is going to register a customer.

private function register(CustomerId $userId, array $customerData): void

Calling this method delegates control to another method which should update domain object:

protected function applyCustomerWasRegistered(CustomerWasRegistered $event): void
{
    ...
    if (array_key_exists('code', $data)) {
        $this->code = $data['code'];
    }
    ...
}

Controllers

Controller responsible for registering a customer is located in the file backend/src/Ui/Rest/Controller/User/Customer/PostRegister.php. FormType associated with register customer is src/Infrastructure/User/Form/Type/CustomerRegistrationFormType.php. There, we need to add our new field:

$builder->add(
    'code',
    TextType::class,
    [
        'label' => 'Code',
        'required' => true,
    ]
);

Now Open Loyalty is ready to persist the new field when customer is being registered, but we have to make a few more adjustments.

Projections

When event CustomerWasRegistered is thrown, projectors handle the event and update/create projections. In order to find all listeners which are listening for this event, you have to find all services with tag broadway.domain.event_listener and with method applyCustomerWasRegistered in them. One of that listeners is src/Domain/User/ReadModel/CustomerDetailsProjector.php. Projector does not persist a domain object, but operates on a read model object. For example Customer is persisted in projections using src/Domain/User/ReadModel/CustomerDetails.php.

Let’s open this file and update it.

/**
 * @var string|null
 */
protected $code;

/**
 * @return string|null
 */
public function getCode(): ?string
{
    return $this->code;
}

/**
 * @param string|null $code
 */
public function setCode(?string $code): void
{
    $this->code = $code;
}
public function serialize(): array
{
    ...
    'code' => $this->getCode(),
    ...
}
public static function deserialize(array $data)
{
    ...
    if (array_key_exists('code', $data)) {
        $customer->code = $data['code'];
    }
    ...
}

Then we have to update projector:

protected function applyCustomerWasRegistered(CustomerWasRegistered $event): void
{
    ...
    $readModel->setCode($customer->getCode());
    ...
}

Last thing is to update ElasticSearch index for Customer Details projection. Go to backend/src/Infrastructure/User/Repository/Elasticsearch/CustomerIndex.php and add a new field to the index.

'code' => [
    'type' => 'keyword',
],

Note

Changing the index in ElasticSearch requires recreating the read models in order to apply changes to an index.

Add field to UI

Adding the field to the user interface is analogous to the process presented in ORM Entites section above.