Pre-requisites
Field configuration on bundles provides field values on nodes
In Drupal terminology the word "fields" is used a lot to mean slightly different things, all related. This is partly a consequence of the way the concept has grown organically, through Content Construction Kit in Drupals 4-6 and fields in Drupal 7 core. Let's clarify one or two ideas, through a real-world example.
Example: an event
Let's say we've created an event content type (we might also call "event" a bundle for node content entities). We want to model this simple real-world situation.
An event has a start date and a location.
Let's try to continue using this "domain-specific" terminology below, while we talk about what Drupal needs to do in order to flexibly model the domain.
Field configuration associated with the event bundle
As we've discussed previously, the "event" node bundle is itself a configuration entity. Alongside the bundle, we represent field configuration so that:
An event bundle (for node content entities) has associated with it:
- A start date field configuration, containing information about the label and granularity for all event start dates (field.field.node.event.field_start_date).
- A location field configuration, containing similar information, but also details of how e.g. the longitude and latitude might be represented (field.field.node.event.field_location).
These configurations provide semantic meaning for the underlying start date and location: the start date is a point in time; the location is a point in space. In brackets at the end are the name
s of each configuration entity, as it would be stored in the config
table. This dot-separated name comes from the fact that the configuration is, respectively: managed by the field module; pertains to a field configuration; restricted to node entities; associated with an event node; and has a name "field_start_date" or "field_location."
Field storage associated with the node entity type
When a field has been added to one bundle (for a given entity type), it could be added in future to a different bundle. So events might have a start date; and a notice for some kind of special offer might also have a start date: in a sense, these are the same "date-ish" concepts, such that you could envisage (for example) all active content on the site being sorted by start date, or compared by location.
This results in separate configuration, independent of bundles, for field storage:
Node content entities have associated with them:
- A start date field storage configuration, containing information about the storage type (which in turn determines the SQL columns used) and the cardinality (field.storage.node.field_start_date).
- A location field storage configuration, containing similar information (field.storage.node.field_location).
The dot-separated notation of the config names at the end of each line above imply that the field storage configuration no longer depends on event, and thus could be shared across bundles; but it does depend on the entity type, and so could not be associated with a user, or a taxonomy term.
(Note that earlier versions of Drupal used to refer to field storage as "field", and field configuration as "field instance", so you might still see this terminology.)
Optional: field display options for a bundle in form and view mode
The following isn't essential, and doesn't depend on any one field, but will ultimately appear when display modes are saved:
An event node bundle's display has associated with it:
- A view display mode, of type default, containing information about how both start date and location might appear to the site visitor (core.entity_view_display.node.event.default).
- A form display mode, of type default, containing information about how both start date and location might appear to the content editor (core.entity_form_display.node.event.default).
Again we look at the dotted names, and it's clear that: there's no longer any field-dependent element to these configuration entities; and entity display modes, and types, are provided by core.
Field values on a node
Finally, now that field configuration tells us what fields mean, field storage tells us how they can be stored, and entity view mode configuration tells us how they can be displayed, we can represent our original real-world example in a PHP abstraction:
A node entity object in PHP, of bundle event, can be populated with field values:
- A start date field value.
- A location field value.
Data stored in these field values can be accessed using both a longhand syntax and a shortcut notation, by means of the typed data API which will be discussed later.
In summary, then: any one event node has a unique start date (location) field value; all event nodes share (start date/location) field configuration and display modes; and all nodes, of all types, have the potential to access shared (start date/location) field storage.
Enabling bundles for contact entities
A bundle (which defines a [sub-]type of content entity) is itself a config entity. Although these are simpler than content entities, we'll still need to do some setup work to define them and make them creatable and editable:
- An entity class, which we call
ContactType
, with annotations and any other methods as discussed earlier; modifications toContact
to reference this. - Any custom controllers required for creating new config entities e.g.
ContactTypeForm
. - New routes for the new config entity type; modifications to existing routes for
Contact
content entity type to support choosing a contact type first.
We need to modify our existing routes, to introduce an intermediate step for creating a new contact: the contact type must be selected first, in the same way as creating a new node first involves choosing what content type will be added.
1. Create config entity class ContactType
and change Contact
's annotation
We define the ContactType
config entity in src/Entity/ContactType.php
:
<?php namespace Drupal\d8api\Entity; use Drupal\Core\Config\Entity\ConfigEntityBundleBase; /** * Defines the Contact Type configuration entity. * * @ConfigEntityType( * id = "contact_type", * label = @Translation("Contact type"), * handlers = { * "view_builder" = "Drupal\Core\Entity\EntityViewBuilder", * "list_builder" = "Drupal\d8api\Controller\ContactTypeListBuilder", * "form" = { * "add" = "Drupal\d8api\Form\ContactTypeForm", * "edit" = "Drupal\d8api\Form\ContactTypeForm", * "delete" = "Drupal\Core\Entity\ConfigEntityDeleteForm", * }, * "access" = "Drupal\d8api\Access\ContactAccessControlHandler", * }, * admin_permission = "administer contact types", * config_prefix = "contact_type", * config_export = { * "id", * "label", * }, * bundle_of = "contact", * entity_keys = { * "id" = "id", * "label" = "label" * }, * links = { * "canonical" = "/contact-type/{contact_type}", * "edit-form" = "/contact-type/{contact_type}/edit", * "delete-form" = "/contact-type/{contact_type}/delete", * "collection" = "/contact-type/list" * }, * ) */ class ContactType extends ConfigEntityBundleBase { /** * {@inheritdoc} */ public function isLocked() { return ($this->id() !== NULL); } }
This is very similar to the Contact
entity we defined previously: most of the heavy lifting is done by the annotation comment, which defines some extra fields specific to config entities (config_prefix
and config_export
). Later, you should be able to see config entities stored in the config
table, whose name and content satisfy those two annotations respectively.
By inheriting from ConfigEntityBundleBase
we also inherit all the relevant behaviours and interfaces from config entities; the only remaining method we define is isLocked()
. This just prevents editing of the config entity (e.g. its ID) after it's been saved: this is referenced by contacts as their bundle identifier, so might otherwise lead to orphaned content.
Once our ContactType
exists, we must complete the connection between it and Contact
. In the bundle's annotation comment, we already declared it was a bundle_of = "contact"
: however, we need to make this declaration bi-directional.
Modify Contact
's comment annotation to include the following:
<?php /* (unchanged) */ /** * Defines the Contact content entity. * * @ContentEntityType( * (unchanged) * entity_keys = { * (unchanged) * "bundle" = "type" * }, * bundle_entity_type = "contact_type", * bundle_label = @Translation("Contact type"), * ) */ class Contact extends ContentEntityBase implements ContentEntityInterface { /* (unchanged) */ }
Because baseFieldDefinitions()
already calls its parent::
method, we don't need to do anything else for bundle-specific fields to be applied to a Contact
entity.
2. Extend existing controllers for ContactType
routes
As with the Contact
entity, we need to extend two controllers; first, a ContactTypeForm
for adding and editing contact types:
<?php namespace Drupal\d8api\Form; use Drupal\Core\Entity\BundleEntityFormBase; use Drupal\Core\Entity\EntityTypeInterface; use Drupal\Core\Form\FormStateInterface; /** * Form controller: add/edit form for contact types. */ class ContactTypeForm extends BundleEntityFormBase { /** * {@inheritdoc} */ public function form(array $form, FormStateInterface $form_state) { $form = parent::form($form, $form_state); $type = $this->entity; if ($this->operation == 'add') { $form['#title'] = $this->t('Add contact type'); } else { $form['#title'] = $this->t('Edit %label contact type', ['%label' => $type->label()]); } $form['label'] = [ '#title' => $this->t('Name'), '#type' => 'textfield', '#default_value' => $type->label(), '#required' => TRUE, '#size' => 30, ]; $form['id'] = [ '#type' => 'machine_name', '#default_value' => $type->id(), '#maxlength' => EntityTypeInterface::BUNDLE_MAX_LENGTH, '#disabled' => $type->isLocked(), '#machine_name' => array( 'exists' => ['Drupal\d8api\Entity\ContactType', 'load'], 'source' => ['label'], ), ]; return $this->protectBundleIdElement($form); } /** * {@inheritdoc} */ public function save(array $form, FormStateInterface $form_state) { // Save the entity, so it has an ID. parent::save($form, $form_state); // Redirect to the entity's own page. $form_state->setRedirect( 'entity.contact_type.canonical', ['contact_type' => $this->entity->id()] ); } }
Second, a ContactTypeListBuiler
, to list all contact types:
<?php namespace Drupal\d8api\Controller; use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\EntityListBuilder; use Drupal\Core\Url; /** * Controller: list of contact type entity. */ class ContactTypeListBuilder extends EntityListBuilder { /** * {@inheritdoc} */ public function buildHeader() { $header['id'] = $this->t('ID'); $header['label'] = $this->t('Label'); return $header + parent::buildHeader(); } /** * {@inheritdoc} */ public function buildRow(EntityInterface $entity) { $row['id'] = $entity->id(); $row['label'] = $entity->link(); return $row + parent::buildRow($entity); } }
Although this seems like a lot of extra code, it's very similar to the Contact
controllers implemented previously (changing the behaviour of the submit form, and extending the columns used in the listing table, respectively), so we don't discuss it further here.
3. Add new routing configuration and modify existing routing
We trivially add five routes for the new contact type CRUD(i) actions:
entity.contact_type.canonical: path: '/contact-type/{contact_type}' defaults: _entity_view: 'contact_type' _title: 'Contact type' requirements: _entity_access: 'contact_type.view' entity.contact_type.collection: path: '/contact-type/list' defaults: _entity_list: 'contact_type' _title: 'Contact type list' requirements: _permission: 'view contact_type entry' entity.contact_type.add_form: path: '/contact-type/add' defaults: _entity_form: 'contact_type.add' _title: 'Contact type' requirements: _entity_create_access: 'contact_type' entity.contact_type.edit_form: path: '/contact-type/{contact_type}/edit' defaults: _entity_form: 'contact_type.edit' _title: 'Edit contact type' requirements: _entity_access: 'contact_type.edit' entity.contact_type.delete_form: path: '/contact-type/{contact_type}/delete' defaults: _entity_form: 'contact_type.delete' _title: 'Delete contact type' requirements: _entity_access: 'contact_type.delete'
These are almost a cut-and-paste of the routes we added for contacts previously, so we don't discuss them here.
We also modify the existing "add contact" route, to expect a bundle ID; and add an intermediate step where the bundle can be chosen:
entity.contact.add_list_types: path: '/contact/add' defaults: _controller: '\Drupal\d8api\Controller\TutorialController::listContactTypes' _title: 'Add contact by type' requirements: _entity_create_access: 'contact' entity.contact.add_form: path: '/contact/{type}/add' defaults: _entity_form: 'contact.add' _title: 'Create contact' requirements: _entity_create_access: 'contact'
The new method to choose a bundle could be added anywhere; but we add it to our existing TutorialController
for simplicity's sake:
<?php /* (unchanged) */ class TutorialController extends ControllerBase { /* (unchanged) */ /** * Controller: /contact/add */ public function listContactTypes() { $markup = ''; foreach ($this->entityManager()->getStorage('contact_type')->loadMultiple() as $type) { // Later, we can use theming to write this HTML properly. $markup .= $this->t( "<li><a href='/contact/{$type->id()}/add'>Add @type</a></li>", ['@type' => $type->label()] ); } if (!$markup) { $markup = t('There are no contact types defined yet.'); } else { $markup = "<ul>$markup</ul>"; } return [ '#type' => 'markup', '#markup' => $markup, ]; } }
In this new method, we take advantage of the inherited dependency injection (DI) on ControllerBase
to retrieve an entity manager service: we then ask this service for all the contact_type
entities (referencing the ContactType
's @ConfigEntityType(id)
annotation property) and list them. Right now, we assemble the markup tag by tag: later we'll look at theming in Drupal, which provides better methods for doing this.
With the routing in place, you can also add new actions (quick links below a page's title) and tasks (local tab groups) in d8api.links.action.yml
and d8api.links.task.yml
; this is exactly the equivalent of the existing actions and tasks for contact entities:
# Entity bundle action links. d8api.contact_type_add: route_name: entity.contact_type.add_form title: 'Add contact type' appears_on: - entity.node_type.collection - entity.contact_type.collection - entity.contact_type.canonical d8api.contact_type_list: route_name: entity.contact_type.collection title: 'View contact types' appears_on: - entity.node_type.collection - entity.contact_type.canonical
# Entity bundle tasks. d8api.contact_type.view: route_name: entity.contact_type.canonical base_route: entity.contact_type.canonical title: 'View' d8api.contact_type.edit_form: route_name: entity.contact_type.edit_form base_route: entity.contact_type.canonical title: 'Edit' d8api.contact_type.delete_form: route_name: entity.contact_type.delete_form base_route: entity.contact_type.canonical title: 'Delete'
Note! You should also change the existing "Add contact" action to use the entity.contact.add_list_types
route:
d8api.contact_add: route_name: entity.contact.add_list_types # (unchanged)
This allows the action button to access the intermediate "pick a contact type" page, which is now a requirement for adding a new contact.
Adding a bundle, and adding fields to the bundle
We now have a UI for adding new bundles; but if you wanted to add some bundles and field configurations initially, the recommended method is to use configuration YAML files as below.
However, you might sometimes want to manipulate fields programmatically, so further below is an example of how to manipulate field configuration associated with a bundle directly, using PHP.
Adding bundle and fields with YAML
Let's use YAML to create a "Person" contact type, with a "job title" field that (for now) won't appear on other contact types. For this, we need at least three and ideally five YAML files:
- The
person
bundle, for person contacts. - The
field_jobtitle
field configuration, and field storage configuration. - (Optional) two lots of display configuration for person fields, for both the editor's form and the site visitor's view.
While the last item is technically optional, you should always provide display configuration for fields, as otherwise they might not be editable, or visible. For nodes and content types, Drupal's UI does that for you; building such a UI for contacts isn't really in scope here, though.
Below are the five YAML files, each with a very brief discussion (we'll talk about configuration management and export files later.) You can put these YAML files in the config/install/
folder of the d8api
module; but note that, to install the configuration, you'd need to disable and then re-enable the module: if so, delete all existing contact entities first.
Note also that, if you want to generate any of your own YAML files from scratch, you'll need a unique UUID for each configuration file: duplicating them accidentally will cause an install to fail. You could use an online generator, but Drupal also provides a uuid
service which will generate them for you:
drush php-eval 'print \Drupal::service("uuid")->generate() . "\n";'
Remember that, if you want to use this service in a class, you should use DI, as discussed previously.
1. The person contact type
This is the simplest YAML file; save it to d8api.contact_type.person.yml
in the subfolder:
uuid: db09a040-edfd-4ac6-80d8-3bbf9931b3ea langcode: en status: true dependencies: { } id: person label: Person
As we're discussing configuration management later, we won't go into any further detail about this here. Between the configuration file name(-spacing), and the file itself, there's no need to state any further dependencies.
2 & 3. The job title field and field storage configuration
We start with the storage, because that underpins the field itself; field.storage.contact.field_jobtitle.yml
should read:
uuid: 9de4eef5-c3af-4df2-8e23-616f6e4906c6 langcode: en status: true dependencies: module: - d8api id: contact.field_jobtitle field_name: field_jobtitle entity_type: contact type: string settings: max_length: 255 is_ascii: false case_sensitive: false module: core locked: false cardinality: 1 translatable: false indexes: { } persist_with_no_fields: false custom_storage: false
The configuration defines things like the cardinality and translatability of the field. You can see that it declares dependencies: module: d8api
(for the contact entity type), and specifies storage configuration for a field on entity_type: contact
; but it doesn't include any dependency on the person contact type: yet. Such a dependency arises in the field configuration instead, which is in field.field.contact.person.field_jobtitle.yml
:
uuid: bbf7091a-2d16-4b11-8668-2f5a9bcf0099 langcode: en status: true dependencies: config: - d8api.contact_type.person - field.storage.contact.field_jobtitle id: contact.person.field_jobtitle field_name: field_jobtitle entity_type: contact bundle: person label: Job title description: '' required: false translatable: false default_value: { } default_value_callback: '' settings: { } field_type: string
The field configuration depends on both of our previous configuration YAML files: the person contact type, and the field storage; the file itself defines things like the label and default value for the field.
4 & 5. The person field display configuration
Form and view both ideally need display configuration. These are long files, because they have small elements of configuration for each field (including both the person-specific field_jobtitle
and the contact-wide name
field. However, they should hopefully be self-explanatory.
core.entity_form_display.contact.person.default.yml
:
uuid: 3bc6a78e-e67d-43fe-a379-3ebaede024f2 langcode: en status: true dependencies: config: - d8api.contact_type.person - field.field.contact.person.field_jobtitle module: - text id: contact.person.default targetEntityType: contact bundle: person mode: default content: field_jobtitle: type: string_textfield weight: 10 settings: size: 60 placeholder: '' third_party_settings: { } name: type: string_textfield settings: size: 60 placeholder: '' third_party_settings: { } hidden: { }
core.entity_view_display.contact.person.default.yml
:
uuid: 971a595e-0832-4b25-ba04-22f9910e63cb langcode: en status: true dependencies: config: - d8api.contact_type.person - field.field.contact.person.field_jobtitle id: contact.person.default targetEntityType: contact bundle: person mode: default content: field_jobtitle: type: string weight: 0 label: above settings: link_to_entity: false third_party_settings: { } name: label: above type: string settings: link_to_entity: false third_party_settings: { } hidden: { }
Adding bundle and fields manually and/or programmatically
Let's create a new form controller, which will permit an administrator to select an existing contact type through Form API, and either add or remove a "phone" field from it. This isn't a replacement for the fully-featured UI that node content types usually have for adding and removing fields, but should hopefully demonstrate some of the important principles.
The following code can be saved to src/Form/AddRemoveFieldForm.php
:
<?php namespace Drupal\d8api\Form; use Drupal\Core\Entity\EntityManagerInterface; use Drupal\Core\Entity\EntityFieldManagerInterface; use Drupal\Core\Form\FormBase; use Drupal\Core\Form\FormStateInterface; use Symfony\Component\DependencyInjection\ContainerInterface; /** * Form: add/remove field on contact type bundle. */ class AddRemoveFieldForm extends FormBase { /** * Implements __construct(). */ public function __construct( EntityManagerInterface $entityManager, EntityFieldManagerInterface $entityFieldManager ) { $this->entityManager = $entityManager; $this->entityFieldManager = $entityFieldManager; } /** * {@inheritDoc} */ public static function create(ContainerInterface $container) { return new static( $container->get('entity.manager'), $container->get('entity_field.manager') ); } /** * {@inheritDoc} * * From FormInterface, via FormBase. */ public function getFormId() { return 'add_remove_field_form'; } /** * {@inheritDoc} * * From FormInterface, via FormBase. */ public function buildForm(array $form, FormStateInterface $form_state) { // Save contact types in form state for later. $types = $this->entityManager->getStorage('contact_type')->loadMultiple(); $form_state->set('types', $types); // Use them, right now, to populate the dropdown. $form['type'] = [ '#title' => $this->t('Contact type'), '#type' => 'select', '#options' => array_map(function($type) { return $type->label(); }, $types), ]; $form['go'] = [ '#type' => 'submit', '#value' => $this->t('Go'), ]; return $form; } /** * {@inheritDoc} * * From FormInterface, via FormBase. */ public function submitForm(array &$form, FormStateInterface $form_state) { // Retrieve bundle ID and related bundle. $bundle_id = $form_state->getValue('type'); $types = $form_state->get('types'); $bundle = $types[$bundle_id]; // If field already exists, delete it. $fields = $this->entityFieldManager->getFieldDefinitions('contact', $bundle_id); if (isset($fields['field_phone'])) { $fields['field_phone']->delete(); drupal_set_message($this->t( 'Deleted field from @type contact type', ['@type' => $bundle->label()] )); } // Otherwise, add it. else { // Add the field storage configuration, and field configuration. $field_storage_values = [ 'field_name' => 'field_phone', 'entity_type' => 'contact', 'type' => 'string', 'translatable' => false, ]; $field_values = [ 'field_name' => 'field_phone', 'entity_type' => 'contact', 'bundle' => $bundle_id, 'label' => 'Phone', 'translatable' => false, ]; $this->entityManager->getStorage('field_storage_config') ->create($field_storage_values)->save(); $this->entityManager->getStorage('field_config') ->create($field_values)->save(); // Configure form and view displays to show the field. entity_get_form_display('contact', $bundle_id, 'default') ->setComponent( 'field_phone', ['type' => 'text_textfield', 'weight' => 20] )->save(); entity_get_display('contact', $bundle_id, 'default') ->setComponent( 'field_phone', ['type' => 'string'] )->save(); drupal_set_message($this->t( 'Added field to @type contact type', ['@type' => $bundle->label()] )); } } }
This controller uses an entity manager to retrieve all the different contact types to populate a dropdown; on submission, it uses an entity field manager to either save new field configuration and field storage configuration, or delete it. A few notes:
- The two manager services are provided using DI, as discussed previously.
- Form state is used to both temporarily store the contact types (to avoid having to look them up more than once) and also to store the values submitted via the user.
- Non-object methods like
entity_get_display()
anddrupal_set_message()
have crept in, for simplicity. These are well-tested methods and so are less of a problem than they might otherwise be, but you should consider factoring them out later. - Field storage type and display formatter types ("string", "text_textfield" and "string" respectively) need to match field storage and formatter plugins (we've discussed the plugin architecture previously. If in doubt, you can use the Plugin module, or examine the dropdowns in the content types/add field UI and use the values for the options there.
- Field configuration, and field storage configuration, persists through a two-step process of first creating, then saving: we've seen this before for nodes, and it's the case for other entities.
- In the display configuration, while we do set a single component, the
save()
method is acting on the entire configuration. You should be aware of this, because this step might fail with no clear reason, because of a mistaken display formatter type in e.g. theContact#baseFieldDefinitions()
method.
Finally, you should add another entry to d8api.routing.yml
d8api.add_remove_field_form: path: '/tutorial/add-remove-field' defaults: _title: 'Add/remove phone field from a contact type' _form: '\Drupal\d8api\Form\AddRemoveFieldForm' requirements: _permission: 'access content'
This permits a route to our new form controller, as discussed previously.
What you should see
To get Drupal to recognize the ContactType
class and other changes, you would usually clear caches. However, because we've made substantial changes elsewhere, you should disable and reinstall the d8api
module completely. Note that you might need to delete all existing contacts to do this.
First of all, navigate to /contact-type/list
. You should see a list consisting of a single contact type, the Person provided by the YAML:
Because we've not bothered (for reasons of brevity) to implement good navigation between all the steps for contact types, you'll need to navigate manually to /contact-type/add
; when you do so, you can add a new "Organization" contact type:
After submission, this new type will be listed alongside Person:
If you now navigate through the "Admin content" UI, to add a new contact, you're presented with the intermediate screen where you can pick a contact type:
Select "Person"; you're invited to create a contact with a job title as defined in the YAML:
When you save the person, the job title is displayed as per the view display configuration:
Separately now, if you navigate to /tutorial/add-remove-field
, you will see a dropdown with your two new contact types:
You could add the field to the Person contact type! but instead, add it to the Organization type:
Then, when you navigate back to "Add contact" and choose to add a new organization contact, you will see the "phone" field:
You can create new Organization contacts with the phone field in place; but be aware that, once any entities use the field storage for "phone", it will no longer be possible to remove it from any contact type: core Drupal objects, given the storage contains values; and our custom form isn't smart enough to work around it (I leave that as an exercise for the reader.)
If you see all this then congratulations! you have added, configured and otherwise manipulated Drupal fields through both the fields API and through YAML files.
Further reading
- Drupal 8 API: field API
- Defining and using Content Entity Field definitions
- Creating field types with multiple columns (creating a wholly new field type, with internal data granularity like longitude/latitude.)
- When entity_get_form_display setComponent throws “plugin does not exist” regardless of type