Learning Drupal 9 As A Framework
Learning Drupal 9 As A Framework
Description
This course will teach you advanced concepts of drupal 9, Object-oriented PHP and
symphony components. After the course, you’ll be able to build robust and scalable
software solutions of many kinds.
In this hands-on course a drupal expert with 10 years experience with the software
will give you a deep-dive in the power that drupal core has to offer.
Advanced topics like custom entities, entity forms, access control, events, caching,
workflows and many more are discussed while building an actual software.
With +2400 lines of custom code, the author offers you powerful and ready-to-use
snippets for your next drupal projects.
Fun fact: you’ll not even be using nodes at all but only custom entities.
1
Foreword 4
Part 3: Custom entities 101, CRUD operations, workflow states and access 51
Content entities 52
Building our first content entity 53
Securing access of our entities 58
Adding the create/edit/delete forms (CRUD) 63
Views integration of our custom entity to add a listing 69
Getting up-to-speed: making the entity fieldable 75
Media field with library 82
Adding fields to the custom entity via the UI 87
Adding workflows and moderation to custom entities 92
Change entity access based on workflow states 95
Adding views plugins: custom fields and operation links 96
Adding a 105
custom controller for direct publishing 105
Building an overview page 109
Adding a custom views filter based on moderation state 114
Optimize the entity teaser with custom variables for twig 120
2
Adding css to views 123
Adding a user-friendly multistep form for entity creation 127
Add custom actions to the form 134
Conditional fields in the Form API 140
Updating our data seeds 142
Part 4: building the application. Storing, validating and rendering data 145
Add dynamic menu links with Menu plugins 145
Building the offer page with twig: theming a custom content entity 148
Adding a dynamic bidding form to our page with an advanced block plugin 150
Adding site-wide css and javascript 160
Adding a code-only bid entity 164
Saving the bid entities on form submission 168
Form validation based on highest bids 170
Add dynamic variables to our entity teaser 173
Validating the entity with constraints 176
Displaying all bids in a dynamically rendered table 179
Integrating the core revision system into the bidding process to raise a bid 185
Deleting a bid with a core dialog pop-up 191
3
Foreword
The history of drupal as a content management system has been interesting in many
ways. This open-source project hit an incredible 1 million powered websites
worldwide in 2014 because of its truly great CMS features for developers and
webmasters.
Since then, it has chosen an interesting path. While in 2014 the main competitors
were Wordpress and Joomla, the community took the radical decision of a complete
rewrite of the software to meet the challenges of the future. The result was the
release of drupal 8: an object-oriented framework with Symfony components. It
resulted in software that was ready to meet the high standards of the industry on
security, scalability, cost-effectiveness, and reliability.
In 2021, with Drupal 9, it has become an enterprise software for ambitious and
custom-tailored solutions that is comparable with Ruby on Rails and Django.
Especially for startups, it offers a quick-to-market solution.
I’ve been around in the drupal ecosystem for ten years and have supported in many
ways: code contributing, blogging, sharing solutions on DrupalAnswers, and
attending conferences. With this book, I share almost all of the knowledge I’ve built
up so far since using Drupal 8 and 9.
Allow me to say a few topics that do not get covered in this book. The ecosystem is
so big that I had to let out chapters about these:
● Unit testing (would need a separate book)
● Server set-up (but take a look at Lando!)
● JSON-API and webservices (would need a separate book)
● Drupal theming guide
● How to install drupal
In this hands-on course, we’ll be building a bidding software platform. While the
platform will not be 100% finished, the backbone and layers are ready to use for your
production websites. I hope you enjoy it as much as I did.
4
Part 1: Drupal developer essentials
👉 You may understand that building larger platforms also requires a more
thorough development setup. In this section you will learn the more advanced
developer tools real-life experts use to build their solutions.
First, we'll take a look at composer and how we’ll manage installation, versioning and
upgrades of the core and the modules.
After this we'll discuss Git. Interesting to know is that thanks to composer we do not
have to add our modules to version control.
Then I want you to get to know Xdebug, which we will need to intercept the software
at certain breakpoints to take a look at all the available data at that moment. I use
this all the time during PHP development.
Webprofiler is a tool for using while developing. It indicates the current route, the
load time of the controller and a lot of other stuff.
You might have heard of drush before, the cli for drupal developers. In this course,
we’ll get to know this drupal command-line language a bit better and use it more and
more while building custom modules.
Drupal is not meant for a simple blog anymore. The framework and CMS can be
pretty complex and needs to get updated regularly and without downtime.
We will develop the platform in a way that when other developers of our team run a
clean drupal install and import configuration, the platform is ready on-the-fly. But we
do not want an empty box every time, so we’ll provide some seed data like users,
offers, bids etc. that can be imported on installation.
We will develop the platform in a way that when you install, the platform is ready
on-the-fly, with real-world content filled in. No more empty boxes or production data!
In the drupal 7 days, going from staging to production was hard. Drupal 8 has made
a huge progression on this and even made it a strength. That’s why so many
enterprises are now interested in drupal: it got easy to roll in new features in it while
5
in production. Configuration management is a topic we’ll discuss more in depth at
the end of this chapter.
6
Composer
In this section we’ll talk about composer and how it manages versioning.
In short, this will make sure you don’t install something that is
not compatible. In addition to the core drupal installation, every
module or theme can add requirements and dependencies in
it’s .info file via a composer.json file.
Composer was added in drupal 8 and is mandatory. Make sure you have it installed
globally on your machine.
Make sure you have composer installed, go to your terminal and type:
Your terminal will go ahead and download all the required packages:
7
After this, run
composer update
to make sure you have the latest packages available.
The provided composer.json file contains the best practice way of structuring a
drupal project. It will automatically make sure your modules, themes and libraries get
downloaded in the right folder. The created composer.lock file keeps track of every
exact version you have installed.
You see a lot of symfony components go to your vendor map, but drupal also
requires quite a lot of other extra packages.
Now that installation is ready, take a look at the folder structure we now have:
8
● web is the webroot of the project where we can see a fresh new drupal 9
installation . This is the folder where your domain name will be pointed to
● vendor is the folder with all the dependencies
Notice that the composer.json file will always stay one level above our webroot, thus
in the same level as web and vendor.
It is nice to have the vendor packages separated from the drupal core. This makes it
cleaner in the long run. Also, dependencies are only accessible from the server
because they are a level higher than the web root.
This will download the redirect module to modules/contrib and a reference to it gets
added to our composer.json and composer.lock file. If the module has dependencies
defined, they will get downloaded and put in the vendor map.
9
composer remove drupal/redirect
This removes the module. Always make sure you disabled the module first in drupal.
This will get us a specific version of the module, instead of the latest one.
Thanks to the scaffolding in the composer.json file the system knows which is the
theme directory, it gets placed in the right folder, which is themes/custom/bootstrap.
Why not just download the module from drupal.org, like we used to do?
10
Here’s the deal. If we want to be able to professionally use drupal as a software
platform, you really really have to get comfortable with composer.
You’ll definitely get yourself into trouble if you are ignoring this way of working
because mixing the wrong dependencies will get you bugs soon or late.
Second, by using composer you do not have to add all these modules to version
control which will dramatically decrease your git repository size. Because a server
can install everything that is in composer.json by himself. By this we keep everything
clean and in the long-term it is just the only option to keep the software healthy when
it gets bigger.
composer update
11
"patches": {
"drupal/redirect": {
"Duplicate button in the node":
"https://www.drupal.org/files/issues/2020-12-15/remove-duplicate-b
utton.patch"
}
}
Now run
composer install
again. It will remove the module, reïnstall it and let you know if the patch was
correctly applied.
If you have created patches yourself, add a patch to a folder you name patches in
your root directory. Your patches section would look like this:
"patches": {
"drupal/redirect": {
"Fixing a PHP notice": "patches/redirect-php-notice-fix.patch"
}
}
12
Git best practices
Version control systems are software tools that help a software team manage
changes to source code over time. The current industry standard is Git.
This course will not be about best practices on git or how to organize your git
workflow in a team. But I’ll provide you some tips on how to keep our repository
small and clean.
This section will not give you a git introduction, I assume you know the very basics.
I would always recommend using version control when developing software. It is just
one of the things you’ll heavily use for the rest of your career as a software
developer.
In this section I’ll discuss what the best way of working with git and drupal is.
In the source code you’ll see there is a .gitignore file that was added to our code.
This file says to git which files need to be ignored. Below you see the typical drupal
folders we want to ignore.
The first section are all the directories that get generated by Composer. Like was
said in the previous section, composer helps us to dramatically reduce our repository
size:
# Site specific
/web/sites/*
The following are my IDE files, of course I do not want them in version control
13
# IDE specific
/.idea
I would like to emphasize on how composer and git are the two main ingredients for
creating a whole new way of working in comparison with the older drupal way.
Composer is key for downloading packages and avoiding conflicts when upgrading, git
is there for keeping track of history in changes of our custom modules, configuration
and theme code.
14
CLI tools
Drush is a command line shell and Unix scripting interface for Drupal. It is the most
used cli, and although there is a second and similar one, called drupal console, drush
is the most popular by far.
A note here: if you are running your site in the sites folder, and not the standard
sites/default folder, or running a multi-site installation you have to go to the sites
folder of your installation.
cd web/sites/mysite
This way, drush knows which site you’re talking about. To install a module
drush en views
While drush is the “standard”, drupal console is interesting in some cases as well.
Because it was introduced with the adoption of symfony, it is less opinionated about
drupal than drush. Console is both a building tool as a debug tool. There is a lot of
similar functionality, but I like to share this nice feature console has. You can get all
information of a route with a simple command.
First:
15
Path /admin/people/roles/manage/{user_role}
Defaults
_entity_form user_role.default
_title Edit role
Requirements
_entity_access user_role.update
Options
compiler_class Drupal\Core\Routing\RouteCompiler
utf8 1
parameters user_role:
type: 'entity:user_role'
converter:
drupal.proxy_original_service.paramconverter.configentity_admin
_admin_route 1
_access_checks access_check.entity
This is great. With one command, all of the routes info gets returned.
Another example is debugging of services containers. Let’s lookup all the service
containers we can find for sessions:
16
Drupal\Core\Session\WriteSafeSessionHandler
session_manager
Drupal\Core\Session\SessionManager
session_manager.metadata_bag
Drupal\Core\Session\MetadataBag
Almost all of the things come from drupal core, but also some are symfony
components. Interesting, right? Digging a little makes the layers more visible.
Apart from debugging, we can use console to generate custom modules, controllers,
… etc.
I’ll show this by generating a custom module called offer,, the name of our platform.
It will ask us a few things.
Fyi: the drupal console will always ask for a valid uri, like
17
> 9.x
Generated lines: 29
Creation of nodes:
drupal create:nodes article \
--limit="5" \
--title-words="5" \
--time-range="1" \
--revision \
--language="und"
18
Exporting the configuration of a content type to a module
drupal config:export:entity node_type page \
--module="demo"
drupal config:export:entity node_type page \
--module="demo" \
--optional-config \
--remove-uuid \
--remove-config-hash
(more on configuration in a later chapter)
drupal generate:plugin:block \
--module="modulename" \
--class="DefaultBlock" \
--label="Default block" \
--plugin-id="default_block" \
--theme-region="header" \
--inputs='"name":"inputtext", "type":"text_format",
"label":"InputText", "options":"", "description":"Just an input
text", "maxlength":"", "size":"", "default_value":"",
"weight":"0", "fieldset":""'
In this course we will make a choice for drush. But we discussed some interesting
drupal console commands as well. Make sure you check the official docs for more
commands for console at https://drupalconsole.com/docs and for drush at
https://www.drush.org/latest
19
Development & debug
In this section we’ll talk about debugging techniques experts use while programming
in drupal. These tools help you to get real deep into the layers of the software, and
will accelerate quick development of custom modular development.
We’ll discuss Xcode and Webprofiler, but first some typical drupal development
setup tricks.
You enable “development mode” for a drupal site like this with console::
20
------------------- ---------------- ----------------
// cache:rebuild
parameters:
http.response.debug_cacheability_headers: true
twig.config: { debug: true, auto_reload: true, cache: false }
services:
cache.backend.null: { class:
Drupal\Core\Cache\NullBackendFactory }
This will disable all entity caching and twig caching so we do not have to bother
about it while developing.
21
If you don’t want to use drupal console for this you can
● copy example.settings.local.php in sites/ folder to settings.local.php
● Put it in your sites/mysite/ folder.
The end result is the same as running development mode in drupal console.
Xdebug
A real eye opener for me after a few years of programming with PHP was
intercepting code with Xdebug. Most IDE’s have integration with Xdebug.
I use IntelliJ and have Xdebug connected with the IDE. This way, I can put my
breakpoint anywhere in code and when I refresh the browser it intercepts the request
and it keeps hanging right at my breakpoint. From there, I can take a look at my
available variables, objects, etc.
22
The interceptor stops exactly where I set my breakpoint. In my dummy line $a = ‘b’; I
see that my $mail variable is correct. So I can proceed coding further.
This kind of debugging is not only great for variables or objects. It can also give you
a lot of knowledge about which stuff gets rendered in which order.
23
Webprofiler
Drupal’s well known Devel module contains a toolbar especially for developers. Let’s
install the module and enable Webprofiler.
After enabling the module, you’ll get a toolbar at the bottom of your page on every
request.
The toolbar gives me a ton of information about the current request. The one I use a
lot is the Route name and Controller method used:
Take a look at the current controller. We can see what the current controller name is,
and which method is used.
The “Devel menu”, the second button in the row, offers a lot of options:
24
The “Routes info” page, for example, let’s you lookup any registered route for
debugging. This is the debug for the /admin/content page::
25
On the Configure page of this submodule
(/admin/config/development/devel/webprofiler) there is even more information to
display.
In the webprofiler toolbar you can check Events, Routing en Services. Click on an icon
and go to the profiler reports page. Click around to see all routes, events, etc. that are
registered.
26
Configuration management
Drupal 8 radically changed the way configuration is handled in comparison with the
previous versions. Because this is a rather important topic to get, we start with some
theoretical information on how drupal stores its configuration.
Since drupal 8 configuration and content are separated. Configuration can be seen
as literally ALL the settings of your platform. Some examples:
● Site name and slogan
● Which modules are enabled
● The settings of each module
● Placement of your blocks, block settings
● All your views settings
● Your entity types and fields
● Your google TagManager code
● … and so on
27
The configuration storage exists of an Active store and a Sync store. The Active
store is the current configuration that exists in your database. It can be different
from the Sync store, which are yaml-files in a directory. We use this to import and
export configuration between a development, staging and production version of
websites.
$settings['config_sync_directory']
$settings['config_sync_directory'] = '../config/global';
Export all our current configuration with drush (drush cex is an alias for drush
config-export)::
drush cex
28
Your terminal will return an overview of all the differences between your active
storage (your database) and your sync storage (your config folder). Because your
config folder is currently empty, it will list every setting your website has.
Our config folder now contains all the configuration our website has. If we’d create a
clean drupal installation with the same composer file and config folder, run ‘composer
install’ and import our configuration we have a complete software platform with all
functionalities ready. Except for the content, but we will use a custom drush command
for that.
29
The structure of a configuration file, for example system.site.yml, looks like this:
uuid: e4a14ea3-a3fc-4e6f-b11a-e14fe1d8xxx
name: Offer platform
mail: [email protected]
slogan: ''
page:
403: ''
404: ''
front: /user/login
Let’s change the slogan and see what happens. We can log in and fill it in at
/admin/config/system/site-information, but we can also do it in the terminal with
drush:
Note that this change only lives in the database (active store) for now. For every
change we make, we have to make sure that if you like to keep the change or move the
change to production you have to export the configuration to the config folder (sync
store).
drupal cex
drupal cim
30
Putting configuration in yaml files is a smart thing to do. By running a config import
command on production, all the new settings get imported and the site is updated. A
huge improvement with earlier drupal versions, where we would have to do pretty
advanced stuff for something simple as updating some settings.
This may look a very easy example, but it is an extremely scalable functionality.
Whether it is only a simple change like a site name, or multiple entities, fields and
module settings, configuration management will do the heavy lifting.
Drupal core and (almost) every module come with configuration files. Most of these
have settings that can be edited in the /admin/config section of the site. Let’s add a
custom settings form for a Google Tagmanager code, a typical thing we would like to
have on a software platform. If you use drupal console you can do the following:
31
● offer
○ offer.info.yml
To the buildForm() method I add a section for saving a textarea with a snippet.
<?php
namespace Drupal\offer\Form;
use Drupal\Core\Form\ConfigFormBase;
use Drupal\Core\Form\FormStateInterface;
/**
* Class CustomConfigForm.
*/
class CustomConfigForm extends ConfigFormBase {
/**
* {@inheritdoc}
*/
protected function getEditableConfigNames() {
return [
'offer.customconfig',
];
32
}
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'custom_config_form';
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state)
{
$config = $this->config('offer.customconfig');
$form['analytics'] = array(
'#type' => 'details',
'#title' => $this->t('Marketing & analytics'),
'#open' => TRUE,
);
$form['analytics']['tagmanager'] = [
'#type' => 'textarea',
'#title' => $this->t('Tagmanager code'),
'#default_value' => $config->get('tagmanager'),
'#maxlength' => NULL,
];
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface
$form_state) {
parent::submitForm($form, $form_state);
$this->config('offer.customconfig')
->set('tagmanager', $form_state->getValue('tagmanager'))
->save();
}
33
We now need to add offer.links.menu.yml to add the menu links in our back-end:
offer.config:
title: 'Offer global settings'
route_name: offer.config
description: 'Global settings for the offer platform'
parent: system.admin_config_system
weight: 99
Then add a offer.routing.yml file to register the controller and route to the form:
offer.config:
path: '/admin/config/offer/adminsettings'
defaults:
_form: '\Drupal\offer\Form\CustomConfigForm'
_title: 'Offer platform global settings'
requirements:
_permission: 'administer site configuration'
We see our custom menu link appearing under the “system” section. When we click
we see our form:
34
When we save a value and export our configuration, we’ll see a file called
offer.customconfig.yml containing a key: ‘tagmanager’.
Note that we could add a config/install folder to the offer module. This way, we can
add a default setting for our tagmanager when enabling the module here. This is
optional.
But there are more use cases where difference between configuration matters. Here
are four examples. Note that in reality we would have a staging environment as well,
but let’s assume we only have development and production:
35
Development Production
C Webform module and Enabled, bit not Enabled, but not Config filter module
webforms fields saved as saved as
configuration configuration
Since drupal 8.8 there is a core setting to exclude a module without any contrib
modules.
The first step is to install devel and webprofiler module and make sure your sync
store is up-to-date by doing drush config-export -y.
After you’ve setup the modules you would like to exclude from your configuration,
export your config again:
36
You see that all of the configuration that was added by devel and webprofiler gets
removed from the sync store. On a production site, there will be no devel and
webprofiler.
We could go further and also make sure there is no devel module in our modules
folder on production. For this, we would want to exclude it from our composer.json
file by using the --dev flag. We would do the following. Note that it is best to do this
while setting up your environments the first time:
This results in those dependencies being added into the composer.json file under
require-dev:
"require-dev": {
"drupal/devel": "^4.0"
}
If you install the site without your dev modules you then would use:
You saw a lot of this in the development & debug chapter. Some can be solved by
adding a development.services.yml file. But the following can be done by overwriting
configuration on development. So in this case you enable all the caching
mechanisms in your configuration (sync store) but you overwrite them locally in your
settings.local.php:
$config['system.performance']['css']['preprocess'] = FALSE;
$config['system.performance']['js']['preprocess'] = FALSE;
Uncomment these lines to disable the render cache and disable dynamic page
cache:
$settings['cache']['bins']['render'] = 'cache.backend.null';
37
$settings['cache']['bins']['dynamic_page_cache'] =
'cache.backend.null';
$settings['cache']['bins']['page'] = 'cache.backend.null';
Disabling caching has some drawbacks. The whole site responds slower, and
rebuilding all caches takes a while too. Doing this a lot, and all those seconds waiting
for a response add up. For an approach to developing with the cache enabled, see
Drupal 8 development with caching on.
One bonus tip I can give you is to enable verbose error messaging on your local
development. By doing this you get rich error messages instead of the default
“something went wrong” message:
$config['system.logging']['error_level'] = 'verbose';
Scenario C: enable a module on both development and production, but ignore its
configuration.
A real-life scenario you will be confronted with is when webmasters have access to
modules that create configuration in your active store. An example is the webform
module. Every form, form field or setting a webmaster adds will create additional
configuration. The risk here is that when a developer deploys his development
changes, and this configuration, he risks removing a newly created form.
The solution for this scenario is the config filter module. With this module you can
tell both your development and production environment to ignore parts of your
configuration.
First, install and enable the module on both development and production.
38
Add webform.* to the textarea and save.
Third, export your configuration and import it on production. This is important: both
environments need to have the module installed before you can start ignoring certain
config.
Fourth, enable the webform module and start adding forms. If you now export your
content, it will still export your yaml files BUT your production will not read them
when you import them.
The config filter module is great, but you must get comfortable using it. Start with
easy configuration. I would also recommend either to ignore the entire module or not
ignoring it. Ignoring parts of configuration is possible, but can be dangerous.
39
Reading configuration objects in your code
We know that our active configuration resides in the database. Often we want to read
out configuration in our custom code. Below is a snippet on how to read out the
google tagmanager snippet we’ve created in our previous chapter about custom
configuration:
With this example snippet you make the variables available in your page.twig.html
file. You can use hook_preprocess_hook() in your mytheme.theme file or like below In
your mymodule.module file:
/**
* Implements hook_preprocess_html().
*/
function MYMODULE_preprocess_html(&$variables) {
$variables['tagmanager'] =
\Drupal::config('offer.customconfig')->get('tagmanager');
}
Now, in your html.html.twig file of your theme, just use the following in your <head>
section:
{{ tagmanager }}
40
Part 2: Project code and set-up
Seed data
Before we start setting up the project, I would like to talk about so-called seed data.
Drupal now has become an option for platforms that were typically built with
tailor-made software solutions.
But one thing it lacks is seed data. Seed data is data that you populate the database
with at the time it is created. You use seeding to provide initial values for lookup lists,
for demo purposes, proof of concepts and of course for development.
We always want to achieve the following: a new developer comes into our team and
installs everything that is in the composer file. After this, he imports the configuration
(more on configuration later).
But then there is an empty software platform. Pretty annoying for debugging and
programming.
What we did in the earlier drupal days, was use a database with production data.
This is in many ways a bad practice:
● Privacy: when people put their data into a platform, they do not want their
data to be visible for every developer. We should avoid this.
● Security: what about sending out emails to production customers by
accident. Ask 10 developers and 5 of them will tell you this happened some
time.
While there are some things we can do, console for example has a content
generation function, we’ll do this with a custom console command. I prefer realistic
seed data instead of Lorem ipsum. Let’s start by writing a class.
The class will for now just create a dummy user. In the project files it has become a
large function to import all of our entity data with dummy users, offers and bids.
For now we’ll just add a test user. in This is what our class looks like:
41
<?php
namespace Drupal\offer\SeedData;
use Drupal\user\Entity\User;
/**
* Class SeedGenerator
* @package Drupal\offer
*/
Class SeedDataGenerator {
/**
* Function to create the Seed data
* @param string $entity
* The type of entity that needs to be created.
* @return integer $count
* The number of entities created.
*/
public function Generate($entity) {
$count = 0;
switch ($entity) {
case 'user':
// USER SEEDS
$user = User::create();
$user->setUsername('test');
$user->setPassword('test');
$user->setEmail('[email protected]');
$user->activate();
$user->enforceIsNew();
if($user->save()) {
$count += 1;
return $count;
}
break;
}
return null;
42
}
}
Proceed with adding a drush command that will trigger the class:
<?php
namespace Drupal\offer\Commands;
use Drush\Commands\DrushCommands;
use Drupal\offer\SeedData\SeedDataGenerator;
use Drush\Drush;
/**
* Class SeedGeneratorCommand
* @package Drupal\offer\Commands
*/
class SeedGeneratorCommand extends DrushCommands {
/**
* Runs the OfferCreateSeeds command. Will create all data for
the Offer platform.
*
* @command offer-create-seeds
* @aliases offercs
* @usage drush offer-create-seeds
* Display 'Seed data created'
*/
public function OfferCreateSeeds() {
$seed = new SeedDataGenerator();
$count = $seed->Generate('user');
Drush::output()->writeln($count . ' user(s) created');
}
}
43
services:
offer.commands:
class: \Drupal\offer\Commands\SeedGeneratorCommand
tags:
- { name: drush.command }
That’s it! Clear cache and see if our system has registered our command. Lookup all
available commands for our offer module like this:
This is nice. This way we can add commands for every module in our system.
Developers can “grep” for every name of the module and see all the commands it
has.
Let’s run the command. If everything goes well we would create a new user with the
command:
We see we have imported the test user. We’re forever done with using production
databases. Extending the class after each entity we create won’t take long and we’ll
just add 2 to 5 items per entity type.
This will become easier with every project you’ll start. This way, we truly separated
configuration from content, a necessary step to advanced software development!.
With drush it is easy to start playing to create and delete content. If you have created
your user(s), you can easily delete them as well. Just type this in your terminal:
44
Note that this will not delete the admin user, luckily!
Project set-up
This chapter is your installation guide on how to set up the project code. Look at the
last page of the book for the link to download the files.
1. Past the project files into a directory. Make sure the web and drush folder are
in the root of the project.
2. Make sure your local domain is pointed to the web folder
3. Run composer install in the root of your installation. This will download all the
required packages for the platform, and add the map structure.
4. Fill in your database credentials via the UI and install your drupal site.
5. In your settings.php, as read in the configuration management chapter, at the
bottom add
$settings['config_sync_directory'] = '../config/global';
45
6. Run drush config-import -y to enable the required modules and import all
configuration that comes with them
46
Clear the caches. You now have the full platforms ready-to-go that should look like
this:
47
Go to /user/register to register and directly log in with user “test” and password
“test. Place a bid on an offer to see how it works.
48
Click on “My offers” to add your own offer via a multi-step interface.
In the following chapters we will be building the platform (modules, settings, …) from
scratch, step by step. You can use this course in two ways:
49
1. Learn by reading the chapters and looking into the final platform and code
2. Learn by doing: follow the course and create every file yourself. There are only
a few additional things that were added to the final code, which is discussed
at the end of this course. This second way is probably the most effective way
to learn!
💻 If you follow this course by doing you will now and then see a grey box like this
with additional info like when to enable a new module etc.
Having problems setting up the platform? Click the link below for some
troubleshooting and how to contact me:
https://stefvanlooveren.me/troubleshooting
50
Part 3: Custom entities 101, CRUD operations, workflow
states and access
👉 In this 3rd part of the course you will learn everything concerning custom
entities. At the end you will able to create your own entities, master how to add
functionality using annotations to add views support, make the entities
fieldable, add a workflow with transitions and importantly, detailed access
handling.
On a software point of view this teaches you how to add data entry points using
form modes and view modes. You will be able to create multistep forms for user
friendly user input (CRUD) using drupal’s typical field api and the core media
module for images.
With our setup ready and the theory covered, it is time to get up speed with our
platform. In this section we’ll dive into the main core concepts we’ll need to start
adding data into our software. We start with diving into a very interesting core
concept: entities.
A brief history: since drupal 7 the software contains generic data models that can be
extended for specific purposes. Some examples in core are:
● Nodes
● Blocks
● Comments
● Taxonomy
● Users
Moreover these fields are translatable and can have multiple instances as well
(multifield).
51
I believe there is no other software that has such a strong fieldable model as drupal
9. This makes me prefer drupal over a solely Symfony or Django platform in the
majority of use cases. I hope I can show you why in this course.
Content entities
A content entity is a generic fieldable model that is defined in code. It inherits all
functionality from the base Entity model defined in drupal core.
This Object-oriented way of data-modelling makes sense. All the classes that inherit
from EntityBase get a ton of functionality for free:
● Makes the entity fieldable if desired (start adding fields via the ui on-the-go)
● Makes the entity translatable, if desired
● Inherits various generic methods for obtaining data
● Can make use of the core workflow (for moderation status of the entities:
draft, published, expired, ...)
● Can make use of the powerful core revisioning system (keeps a history of the
entity on every change)
● Create, read, update, and delete (CRUD) functionality with the desired security.
● Views integration by default. Views is the core module for creating lists with
filtering, search etc.
The following generic methods for content entities are provided in core:
Entity::create()
Entity::load()
Entity::save()
Entity::id()
Entity::bundle()
Entity::isNew()
Entity::label()
52
● We add bundles to the entity (f.e. Node entity has bundle ‘article’, ‘page’ in the
standard profile). This is not what we will do in this course.
● We add fieldable functionality to the entity (this is an option when creating
content entities) to add fields via the interface. We will do this in this course.
When one of the previous conditions is met, the extra configuration gets stored in the
database and comes with yaml files when exporting configuration.
👉 This section teaches you how to define a custom entity and create it in the
database. At the end you will be able to create your own custom entity with
custom tailored base fields and revisions support.
Make gin the default theme (also the administration theme) via
admin/settings/appearance. Also in the theme settings, set the toolbar as
“horizontal, modern toolbar” and disable the “Users can override Gin settings”.
The first question that gets raised is why would we use custom content entities. Isn’t
the core node entity with it’s subtypes (bundles) enough?
53
If we’d have a simple website with just some blog posts and a portfolio, I’d always
recommend to use the core Node content entity. It is the de facto out-of-the-box
solution for this.
But our platform aims to have full control over all pages that create, edit and delete
content, as well as the overviews. Custom entities give us more power to define our
own access functions.
We build a platform that allows users to create offers as well as to make a bid on
offers. It would not make sense to use Nodes with bundles like this:
Entity Node
Bundle Offer
Bundle Bid
I’d have to add numerous access checks to make sure users only get access to their
own Offer entities and only their own Bids because they are from the same Entity.
Drupal’s node behaviour wasn’t really meant to separate access between these kind
of node types as well. No, instead we do:
Entity Offer
Entity Bid
...
Proper modelling of our data is crucial. The Entity API provides us all the tools
for doing this.
<?php
/**
* @file
* Contains \Drupal\offer\Entity\Offer.
*/
namespace Drupal\offer\Entity;
54
use Drupal\Core\Entity\EditorialContentEntityBase;
use Drupal\Core\Field\BaseFieldDefinition;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\EntityStorageInterface;
/**
* Defines the offer entity.
*
* @ingroup offer
*
* @ContentEntityType(
* id = "offer",
* label = @Translation("Offer"),
* base_table = "offer",
* data_table = "offer_field_data",
* revision_table = "offer_revision",
* revision_data_table = "offer_field_revision",
* entity_keys = {
* "id" = "id",
* "uuid" = "uuid",
* "label" = "title",
* "revision" = "vid",
* "status" = "status",
* "published" = "status",
* "uid" = "uid",
* "owner" = "uid",
* },
* revision_metadata_keys = {
* "revision_user" = "revision_uid",
* "revision_created" = "revision_timestamp",
* "revision_log_message" = "revision_log"
* },
* )
*/
$fields['user_id'] = BaseFieldDefinition::create('entity_reference')
->setLabel(t('User'))
55
->setDescription(t('The user that created the offer.'))
->setSetting('target_type', 'user')
->setSetting('handler', 'default')
->setDisplayOptions('view', [
'label' => 'hidden',
'type' => 'author',
'weight' => 0,
])
->setDisplayOptions('form', [
'type' => 'entity_reference_autocomplete',
'weight' => 5,
'settings' => [
'match_operator' => 'CONTAINS',
'size' => '60',
'autocomplete_type' => 'tags',
'placeholder' => '',
],
])
->setDisplayConfigurable('form', TRUE)
->setDisplayConfigurable('view', TRUE);
$fields['title'] = BaseFieldDefinition::create('string')
->setLabel(t('Title'))
->setDescription(t('The title of the offer'))
->setSettings([
'max_length' => 150,
'text_processing' => 0,
])
->setDefaultValue('')
->setDisplayOptions('view', [
'label' => 'above',
'type' => 'string',
'weight' => -4,
])
->setDisplayOptions('form', [
'type' => 'string_textfield',
'weight' => -4,
])
->setDisplayConfigurable('form', TRUE)
->setDisplayConfigurable('view', TRUE);
$fields['message'] = BaseFieldDefinition::create('string_long')
->setLabel(t('Message'))
->setRequired(TRUE)
->setDisplayOptions('form', [
56
'type' => 'string_textarea',
'weight' => 4,
'settings' => [
'rows' => 12,
],
])
->setDisplayConfigurable('form', TRUE)
->setDisplayOptions('view', [
'type' => 'string',
'weight' => 0,
'label' => 'above',
])
->setDisplayConfigurable('view', TRUE);
$fields['status'] = BaseFieldDefinition::create('boolean')
->setLabel(t('Publishing status'))
->setDescription(t('A boolean indicating whether the Offer entity
is published.'))
->setDefaultValue(TRUE);
$fields['created'] = BaseFieldDefinition::create('created')
->setLabel(t('Created'))
->setDescription(t('The time that the entity was created.'));
$fields['changed'] = BaseFieldDefinition::create('changed')
->setLabel(t('Changed'))
->setDescription(t('The time that the entity was last edited.'));
return $fields;
}
}
After clearing cache our entity is created and two extra database tables were added:
57
Now let’s proceed with the CRUD operations. For every offer, we’d like to have an add,
edit and delete form. But first, we secure the access.
👉 In this important section you will be taught how entity access works. At the end
58
of the section you will be able to create custom permissions for users and
translate it towards your entity. In the use case of this project it means that
authors can only see/edit/delete their own created entities.
There is something worth noticing about our entities. While the author of the entity
will be the owner (thanks to the PreCreate() function in our Offer entity) he has no
exclusive access towards viewing, or even editing and deleting the entity.
While drupal will typically provide separate “view”, “create”, “edit” and “delete” options
we will (for now) make this 1 single permission: administer own offers.
But we did not specify which access this means towards the entity itself. Let us
make sure that everyone with this access can create offers and more importantly
that they can only edit and delete their own offers and not those of others.
/**
* {@inheritdoc}
*
* Makes the current user the owner of the entity
*/
public static function preCreate(EntityStorageInterface
$storage_controller, array &$values) {
parent::preCreate($storage_controller, $values);
$values += array(
'user_id' => \Drupal::currentUser()->id(),
);
}
/**
* {@inheritdoc}
*/
59
public function getOwner() {
return $this->get('user_id')->entity;
}
/**
* {@inheritdoc}
*/
public function getOwnerId() {
return $this->get('user_id')->target_id;
}
In the end we want full CRUD access for our authenticated users. When accessing an
entity in drupal, there are 4 operations that can be requested:
● view
● update
● edit
● delete
* handlers = {
* "access" = "Drupal\offer\OfferAccessControlHandler",
* }
This file will handle access towards our entity. Add a file called
OfferAccessControlHandler.php inside modules/custom/offer/src:
<?php
namespace Drupal\offer;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Entity\EntityAccessControlHandler;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Session\AccountInterface;
/**
* Access controller for the offer entity. Controls create/edit/delete
access for entity and fields.
*
* @see \Drupal\offer\Entity\Offer.
60
*/
class OfferAccessControlHandler extends EntityAccessControlHandler {
/**
* {@inheritdoc}
*
* Link the activities to the permissions. checkAccess is called with
the
* $operation as defined in the routing.yml file.
*/
protected function checkAccess(EntityInterface $entity, $operation,
AccountInterface $account) {
$access = AccessResult::forbidden();
switch ($operation) {
case 'view':
if ($account->hasPermission('administer own offers')) {
$access = AccessResult::allowedIf($account->id() ==
$entity->getOwnerId())->cachePerUser()->addCacheableDependency($entity);
}
break;
case 'update': // Shows the edit buttons in operations
if ($account->hasPermission('administer own offers')) {
$access = AccessResult::allowedIf($account->id() ==
$entity->getOwnerId())->cachePerUser()->addCacheableDependency($entity);
}
break;
case 'edit': // Lets me in on the edit-page of the entity
if ($account->hasPermission('administer own offers')) {
$access = AccessResult::allowedIf($account->id() ==
$entity->getOwnerId())->cachePerUser()->addCacheableDependency($entity);
}
break;
case 'delete': // Shows the delete buttons + access to delete this
entity
if ($account->hasPermission('administer own offers')) {
$access = AccessResult::allowedIf($account->id() ==
$entity->getOwnerId())->cachePerUser()->addCacheableDependency($entity);
}
break;
}
return $access;
}
61
/**
* {@inheritdoc}
*
* Separate from the checkAccess because the entity does not yet exist,
it
* will be created during the 'add' process.
*/
protected function checkCreateAccess(AccountInterface $account, array
$context, $entity_bundle = NULL) {
return AccessResult::allowedIfHasPermission($account, 'administer own
offers');
}
?>
This access controller gives us a variety of power towards our entity. We now have full
control in code on who can access different modes of our entity.
If you take a closer look, it is here that we integrate our permission (administer own
offers) with our view/edit/update/delete access. As an extra we add a check to make
sure there is only access to own entities.
💻 Add the newly created “administer own offers” permission to all authenticated
users via admin/people/permissions.
In a later stage of the software, we can create different user roles for which entire
access to the CRUD section can be granted with one click.
62
With our entity access completely nailed, we are about to use these access checks in
our routing and crud forms. Let’s move on to the next chapter!
👉 This section highlights the entity CRUD (create, read, edit, delete) process. At the
end you will have learnt the ins and outs of how to show a user an entity form
for creation, editing and deleting of a custom entity.
For our CRUD operations, the system needs to know where to put them. Let’s update
our modules/custom/offer/src/Entity/offer.php once again by adding the forms and
links:
* handlers = {
* "access" = "Drupal\offer\OfferAccessControlHandler",
* "form" = {
* "add" = "Drupal\offer\Form\OfferForm",
* "edit" = "Drupal\offer\Form\OfferForm",
* "delete" = "Drupal\offer\Form\OfferDeleteForm",
* },
* },
* links = {
* "canonical" = "/offers/{offer}",
* "delete-form" = "/offer/{offer}/delete",
* "edit-form" = "/offer/{offer}/edit",
* "create" = "/offer/create",
* },
You may understand what we’re adding here. The links are the routes we want to use
for CRUD operations on our offer entity. Inside the handlers directory we placed the
add/edit and delete form statements.
offer.add:
path: '/offers/create'
defaults:
_entity_form: offer.add
_title: 'Add offer'
requirements:
63
_entity_create_access: 'offer'
entity.offer.edit_form:
path: '/offers/{offer}/edit'
defaults:
_entity_form: offer.edit
_title: 'Edit offer'
requirements:
_entity_access: 'offer.edit'
entity.offer.delete_form:
path: '/offers/{offer}/delete'
defaults:
_entity_form: offer.delete
_title: 'Delete offer'
requirements:
_entity_access: 'offer.delete'
entity.offer.canonical:
path: '/offer/{offer}'
defaults:
_entity_view: 'offer'
_title: 'Offer'
requirements:
_entity_access: 'offer.view'
Check the _entity_access parameters. These will check the access conditions inside
OfferAccessControlHandler (checkAccess and checkCreateAccess). By this, we
control exactly who can access the /offers/create form for example.
Now we’ll add a generic form for adding and editing. It inherits from
ContentEntityForm: custom/offer/src/Form/OfferForm.php:
<?php
/**
* @file
* Contains Drupal\offer\Form\OfferForm.
*/
namespace Drupal\offer\Form;
use Drupal\Core\Entity\ContentEntityForm;
use Drupal\Core\Form\FormStateInterface;
64
/**
* Form controller for the offer entity edit forms.
*
* @ingroup content_entity_example
*/
class OfferForm extends ContentEntityForm {
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state)
{
/* @var $entity \Drupal\offer\Entity\Offer */
$form = parent::buildForm($form, $form_state);
return $form;
}
/**
* {@inheritdoc}
*/
public function save(array $form, FormStateInterface $form_state) {
// Redirect to offer list after save.
$form_state->setRedirect('entity.offer.collection');
$entity = $this->getEntity();
$entity->save();
}
Clear cache and head to /offers/create. There it is! We’ve gotten ourselves access to
the create and edit mode of this entity.
65
💻 If you get an “Access denied”, make sure authenticated users have permission
to ‘administer own offers’ via admin/people/permissions.
Saving an entity will not work at the moment. We get this error:
This is not a readable error when developing. Add this to your settings.php:
66
$config['system.logging']['error_level'] = 'verbose';
This tells us the page we redirect to after saving does not exist yet, We will fix
this in the views integration chapter.
At /offers/1/edit you should see your created entity in an edit form with your
submitted values filled in.
Apart from the overview page, a missing step towards full CRUD operations is the
delete form. We’ve already added the definition in our entity and routing, so now add
custom/offer/src/Form/OfferDeleteForm.php:
<?php
/**
* @file
* Contains \Drupal\offer\Form\OfferDeleteForm.
*/
namespace Drupal\offer\Form;
use Drupal\Core\Entity\ContentEntityConfirmFormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Url;
/**
* Provides a form for deleting a content_entity_example entity.
*
* @ingroup offer
*/
class OfferDeleteForm extends ContentEntityConfirmFormBase {
/**
* {@inheritdoc}
67
*/
public function getQuestion() {
return $this->t('Are you sure you want to delete %name?',
array('%name' => $this->entity->label()));
}
/**
* {@inheritdoc}
*
* If the delete command is canceled, return to the offer.
*/
public function getCancelUrl() {
return Url::fromRoute('entity.offer.edit_form', ['offer' =>
$this->entity->id()]);
}
/**
* {@inheritdoc}
*/
public function getConfirmText() {
return $this->t('Delete');
}
/**
* {@inheritdoc}
*
* Delete the entity
*/
public function submitForm(array &$form, FormStateInterface
$form_state) {
$entity = $this->getEntity();
$entity->delete();
$this->logger('offer')->notice('deleted %title.',
array(
'%title' => $this->entity->label(),
));
// Redirect to offer list after delete.
$form_state->setRedirect('entity.offer.collection');
}
68
We are now able to delete an offer with a confirmation form before deleting. If you
tried creating an entity via the create form, you will now be able to visit
offers/1/delete.
We get an error after deletion because again, we get redirected towards the
collection of this entity:
$form_state->setRedirect('entity.offer.collection');
The error happens because the entity has not defined a collection (“listing”) class
yet. Typically, drupal offers an entity listing functionality for entities that get defined
in a list builder. Personally I think there is a better option because the ux and
flexibility are not that great. Also, there is a much more powerful option for this. We
will use the core Views module to offer a listing of our entities in the next chapter to
fix the error and finalize our CRUD operations.
👉 In this section we go over the possibility of adding views support for custom
entities. At the end you will be able to create powerful listings with views in the
same way you can do with nodes.
69
💻 Enable the views and views_ui module.
First, to ensure that our entities have integration with views we have to add an
annotation with a link to the class.
* handlers = {
* "access" = "Drupal\offer\OfferAccessControlHandler",
* "views_data" = "Drupal\offer\OfferViewsData",
* "form" = {
* "add" = "Drupal\offer\Form\OfferForm",
* "edit" = "Drupal\offer\Form\OfferForm",
* "delete" = "Drupal\offer\Form\OfferDeleteForm",
* },
* },
<?php
namespace Drupal\offer;
use Drupal\views\EntityViewsData;
/**
* Provides views data for Offer entities.
*
*/
class OfferViewsData extends EntityViewsData {
/**
* Returns the Views data for the entity.
*/
public function getViewsData() {
$data = parent::getViewsData();
70
return $data;
}
}
71
We add title, edit and a delete button as fields.
72
Important
Views is an extremely powerful tool. But think about it as a querying interface and a
tool for building easy and advanced entity listings. If your entities are secured like we
did with the offers, this does not mean they can not appear in views listings for other
users.
For that, always make sure you only show entities that have the logged in user as
author (Advanced > Contextual filter > User > Provide default value > User id from logged
in user). Of course, if you make the users author of their own entities like we are doing.
Also, check for permissions on the listing. In this case, we gave permission to users
that have “administer own offers“ (Create/edit/delete own offers) permission.
We’ve got our listing! We will tweak it further in a later chapter, but this is the
functionality we want at this moment.
73
💻 Time to get our CRUD operations complete by adding our entity listing to as the
collection of this entity. To offer/offer.routing.yml, add:
entity.offer.collection:
path: '/offers'
requirements:
_permission: 'administer own offers'
Test by adding, editing and deleting an offer. But rebuild caches first!
drush cex -y
dependencies:
- drupal:views
We’ll disable the module and reïnstall to see if it all works. Delete our entities
first like this:
We have now full CRUD operations up and working in a generic and reusable module.
To make our lives a bit more comfortable, we add a menu link to the toolbar for
direct access. To custom/modules/offer/offer.links.menu.yml add:
74
offer.toolbar.my_offers:
title: 'My offers'
route_name: entity.offer.collection
parent: system.admin
weight: 99
This shows a link to the “My offers” overview page in the toolbar.
💻 Give authenticated users access to the toolbar first (via permissions), but note
that this is only for the development phase of the platform.
Next, this listing needs a big “Add an offer” button. Drupal has a system for this. Add
offer.links.action.yml and add:
offer.add_offer:
route_name: offer.add
title: 'Add an offer'
appears_on:
- entity.offer.collection
👉 This section is about making your custom entity fieldable. At the end you will be
able to connect you custom entity with the power of the drupal user-interface
for adding fields, form modes and view modes. At the end you will be able to
add fields to your entity via the UI and manage the display. The created fields will
75
be exportable as configuration.
As told earlier, the entity data structure in drupal made the software famous. We can
extend our custom entity with this dynamic and extremely feature-rich UI.
With that UI, we can add the desired fields to our entity by just clicking:
● Images
● A PDF upload
● Description
● Short text
● Tags
● … There are hundreds of modules available that define all sorts of fields!
Note that we already have a description defined in our Offer.php entity structure.
We’re about to delete it. We’ll delete our entities first and uninstall again:
76
To the annotations in modules/custom/offer/src/Entity/Offer.php add a
fiel_ui_base_route key:
* "create" = "/offer/c...
* },
* field_ui_base_route = "entity.offer.settings",
* revision_me….
The route entity.offer.settings will be the landing page of the ui settings of our entity.
If we configure this route to /admin/structure/offer, the following routes will become
available automatically:
● admin/structure/offer/fields (fields ui)
● admin/structure/offer/form-display (add form display)
● admin/structure/offer/display (view modes display management)
The settings landing page is usually a settings form. While we will not use additional
settings in this form, let’s keep the good practice and add this route as a form.
To modules/custom/offer/routing.yml we add:
entity.offer.settings:
path: 'admin/structure/offer'
defaults:
_form: '\Drupal\offer\Form\OfferSettingsForm'
_title: 'Offer settings'
requirements:
_role: 'administer own offers'
To modules/custom/offer/src/Form/OfferSettingsForm:
<?php
/**
* @file
* Contains \Drupal\offer\Form\OfferSettingsForm.
*/
namespace Drupal\offer\Form;
use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
77
/**
* Class OfferSettingsForm.
*
* @package Drupal\offer\Form
*
* @ingroup offer
*/
class OfferSettingsForm extends FormBase {
/**
* Returns a unique string identifying the form.
*
* @return string
* The unique string identifying the form.
*/
public function getFormId() {
return 'offer_settings';
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface
$form_state) {
// Empty implementation of the abstract submit class.
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state)
{
$form['offer_settings']['#markup'] = 'Settings form for offer. We
don\'t need additional entity settings. Manage field settings with the
tabs above.';
return $form;
}
We want to do this clean, with adding a link on the admin/structure page to our offer
settings form. With the webprofiler toolbar (see developer tools) we check the route
name for admin/structure.
78
💻 Enable the weprofiler module, which is part of the devel module and re-nable the
offer module.
offer.admin.structure.settings:
title: Offer settings
description: 'Configure Offer entity'
route_name: entity.offer.settings
parent: system.admin_structure
💻 Always clear the caches after adding/editing new routes, menu links or other
YAML config!
79
The link appeared in our structure tree, and the build-up is the same way as “content
types”, which we know from the Node entity.
💻 If you still see “Content types”, the time is here to uninstall the Node entity type:
If you export config, this will delete some default settings from the node entity
type as well.
80
Finally, we add a “local tasks”-link to the settings page. This will activate the tasks
tabs above to get the structure that is known when administering nodes. By this I
mean the “Edit”, “Manage fields”, “Manage form display” and “Manage display” tabs.
To modules/custom/offer/offer.links.task.yml, add:
81
We re-enable the module and see the desired entity ui extensions on
admin/structure/offer.
👉 This section shows how to add a media field to your entity, using the media
library, and to only show media items you have uploaded.
💻 Enable the core media module as well as the core media library module:
82
💻 Define a media type at Structure > Media types > Add media Type and
choose this:
Name: Image
Media source: Image
After saving the media type, you’ll discover ‘Image’ is a bundle of entity media. It has
fields which are preconfigured at admin/structure/media/manage/image/fields. In
this case, the entity has an image field that is shipped with the media source: image.
83
84
💻 We’ll add a media field for our offer at /admin/structure/offer/fields > add
field
Save and head back to our offer create form (/offers/create) we now have a widget
with a media library that works for us out-of-the box!
85
💻 You’ll notice that when adding media items to your library, also media items of
others are visible. This is because the media library is using a view to display the
content. At /admin/structure/views/view/media_library add the same
contextual filter as we did with the offers view: contextual filters >
media:authored by > provide default value > User ID from logged in user.
This will only make sure we don’t see media items from other users inside our library.
Media entities are not secured further in this course. If you would like more security,
you could overwrite the Media module AccesControlHandler() by adding a
offer_entity_access() hook to your modules/custom/offer/offer.module. You could
also use private files for uploads.
But we’ll leave this public, as this is not a feature that is needed.
86
Adding fields to the custom entity via the UI
👉 In this section, the author shows how to add a textarea with a rich-text editor
(CKEditor) so that users have a few options to optimize their content. Further, a
price field and an options field.
Drupal ships with a core CKEditor module, which is the industry standard for rich text
editing. We want to add a description field to our entity.
87
Now choose the rich text buttons you’d like to enable and save.
We’ll add a description field for our offer at /admin/structure/offer/fields > add field
Next, we want the users to choose between a minimum price or without a minimum
price.
88
with_minimum|Set minimum price
no_minimum|No minimum price
If a user wants to ask for a minimum price, he needs a field to enter the price. We’ll
add a last field for this:
89
The fields appear automatically in the entity creation form at /offers/create. Via
/admin/structure/offer/form-display we can manipulate order and display settings.
90
We have now added all fields to our offer entities. We learned that besides fields
defined in code, there is also a way to add them via the UI. For the first time, this will
create extra configuration via configuration management for our offer entity. Export it
with drush to see which files are created.
91
Adding workflows and moderation to custom entities
👉 This section teaches you the power of using workflows. We extend our custom
entity with a state of Draft, Published and Expired. Based on these states, the
published state (yes/no) is set..
After years of building, the workflows and content moderation modules were added
to core. These enormous powerful modules give us the chance to add workflow
states like “draft”, “review” and “published” to our entities. Additionally we can give
permissions to the different states or add actions when states change.
92
Moderation state Is published? Description
Under the states tab, draft and published are default shipped with the content
moderation module. We need to add ‘Expired’. Click ‘Add a new state’:
93
Under the transitions tab, we see “Create new draft” and “Publish” are already
enabled. We add “Make expired” by clicking the “Add a new transition” button.
💻 Use the machine name “expired” here. We will need this for our events later.
Finally, in the this workflow applies to - tab, select the offer entity. The default
moderation state should be set to Draft.
94
It will activate a transition button on the edit forms:
In this chapter we learned to add workflow and transition to our entity. Via the the UI
we can set up these pretty advanced workflows. In the rest of this course we’ll
discover what we can do with them in terms of access, visibility, etc.
👉 This short section aims to change the existing access on our custom entity to
an access based on workflow state of the entity.
At this moment, offer entities are only viewable for the owner (creator) of the offer.
Of course we want our offers to be visible for everyone once it is in a published state.
95
case 'view':
if ($account->hasPermission('administer own offers')) {
$access = AccessResult::allowedIf($account->id() ==
$entity->getOwnerId())->cachePerUser()->addCacheableDependency($entity);
}
break;
to:
case 'view':
// owners of the offer can always view
if ($account->id() == $entity->getOwnerId()) {
$access =
AccessResult::allowed()->cachePerUser()->addCacheableDependency($entity)
;
} else {
// Other users only when published or expired
$allowed = ['published', 'expired'];
$access =
AccessResult::allowedIf(in_array($entity->get('moderation_state')->getSt
ring(), $allowed))->addCacheableDependency($entity);
}
break;
This will make my offer visible, even for anonymous visitors of our platform if the
offer is in the published or expired state. With a second check, we ensure authors
(owners) can see their offers anytime (in all states). This way, we secured the draft
(unpublished) state for outside viewers.
👉 This section is meant to teach you how to add custom fields to your views. At
the end you will be able to show custom output in views listings like tables.
More specifically you will master how to add your own operation links in a
dropbutton to add a direct publishing button, for example.
96
Our entity already has views access defined. We can now extend
custom/offer/src/OfferViewsData.php with custom fields. First we want to add a
nice badge to the table to show the moderation states:
<?php
namespace Drupal\offer;
use Drupal\views\EntityViewsData;
/**
* Provides views data for Offer entities.
*
*/
class OfferViewsData extends EntityViewsData {
/**
* Returns the Views data for the entity.
*/
public function getViewsData() {
$data = parent::getViewsData();
$data['offer']['offer_entity_moderation_state_views_field'] = [
'title' => t('Moderation status'),
'field' => array(
'title' => t('Moderation status'),
'help' => t('Shows the state of the offer entity.'),
'id' => 'offer_entity_moderation_state_views_field',
),
];
return $data;
}
}
<?php
97
namespace Drupal\offer\Plugin\views\field;
use Drupal\views\Plugin\views\field\FieldPluginBase;
use Drupal\views\ResultRow;
use Drupal\views\Plugin\views\display\DisplayPluginBase;
use Drupal\views\ViewExecutable;
/**
* A handler to provide a field that is completely custom by the
administrator.
*
* @ingroup views_field_handlers
*
* @ViewsField("offer_entity_moderation_state_views_field")
*/
class OfferEntityModerationState extends FieldPluginBase {
/**
* The current display.
*
* @var string
* The current display of the view.
*/
protected $currentDisplay;
/**
* {@inheritdoc}
*/
public function init(ViewExecutable $view, DisplayPluginBase
$display, array &$options = NULL) {
parent::init($view, $display, $options);
$this->currentDisplay = $view->current_display;
}
/**
* {@inheritdoc}
*/
public function usesGroupBy() {
return FALSE;
}
98
/**
* {@inheritdoc}
*/
public function query() {
// Do nothing -- to override the parent query.
}
/**
* {@inheritdoc}
*/
protected function defineOptions() {
$options = parent::defineOptions();
$options['hide_alter_empty'] = ['default' => FALSE];
return $options;
}
/**
* {@inheritdoc}
*/
public function render(ResultRow $values) {
$entity = $values->_entity;
$state = $entity->get('moderation_state')->getString();
return $state;
}
💻 Clear caches and add the moderation state to the offer views table. Also, make
sure you remove the “Published status = TRUE”-filter from the views filters so
draft offers also appear in the listing.
99
Now our listing shows the moderation status as well at /offers:
Let’s move on with a second field. What I always advise people when doing custom
applications with drupal, is to use the power of the core render arrays. There are so
many core things you can render, like dropdowns, tables, links, …
100
A render array is an associative array which conforms to the standards and data
structures used in Drupal's Render API. The Render API is also integrated with the
Theme API.
The advantage of using them is they stay consistent when upgrading. Plus when you
change your layout with a theme from the community, you are sure they will look nice
as well.
We want to make another custom views plugin field that shows a dropbutton for
editing and deleting. This makes our table a bit more compact. When an offer is still
in draft mode, we also want a “Publish” option added to the dropbutton. We start by
extending the custom/offer/src/OfferViewsData.php getViewsData() function with a
new field:
$data['offer']['offer_dynamic_operation_links'] = [
'title' => t('Dynamic operations'),
'field' => array(
'title' => t('Dynamic operations'),
'help' => t('Shows a dropbutton with dynamic operations for
offers.'),
'id' => 'offer_dynamic_operation_links',
),
];
<?php
namespace Drupal\offer\Plugin\views\field;
use Drupal\views\Plugin\views\field\FieldPluginBase;
use Drupal\views\ResultRow;
use Drupal\views\Plugin\views\display\DisplayPluginBase;
use Drupal\views\ViewExecutable;
use Drupal\core\Url;
/**
* A handler to provide a field that is completely custom by the
administrator.
101
*
* @ingroup views_field_handlers
*
* @ViewsField("offer_dynamic_operation_links")
*/
class OfferDynamicOperationLinks extends FieldPluginBase
{
/**
* The current display.
*
* @var string
* The current display of the view.
*/
protected $currentDisplay;
/**
* {@inheritdoc}
*/
public function init(ViewExecutable $view, DisplayPluginBase $display,
array &$options = NULL)
{
parent::init($view, $display, $options);
$this->currentDisplay = $view->current_display;
}
/**
* {@inheritdoc}
*/
public function usesGroupBy()
{
return FALSE;
}
/**
* {@inheritdoc}
*/
public function query()
{
// Do nothing -- to override the parent query.
}
/**
* {@inheritdoc}
*/
102
protected function defineOptions()
{
$options = parent::defineOptions();
$options['hide_alter_empty'] = ['default' => FALSE];
return $options;
}
/**
* {@inheritdoc}
*/
public function render(ResultRow $values)
{
$entity = $values->_entity;
$state = $entity->get('moderation_state')->getString();
switch ($state) {
case 'draft':
$operations['publish'] = [
'title' => $this->t('Publish'),
'url' => Url::fromRoute('entity.offer.edit_form', ['offer' =>
$entity->id()])
];
break;
}
$operations['edit'] = [
'title' => $this->t('Edit'),
'url' => Url::fromRoute('entity.offer.edit_form', ['offer' =>
$entity->id()])
];
$operations['delete'] = [
'title' => $this->t('Delete'),
'url' => Url::fromRoute('entity.offer.delete_form', ['offer' =>
$entity->id()])
];
$dropbutton = [
'#type' => 'dropbutton',
'#links' => $operations
];
return render($dropbutton);
103
}
After we save, you see we’ve added dynamic dropdown buttons based on the entity
state. When an entity has the ‘draft’ state, a publish button is visible. At this moment,
the ‘Publish’ button is just linked to the edit page. In the next chapter we’ll add a
custom controller in order for this to work.
104
Adding a
💻 Now all our actions are in the Actions column, we can delete the Edit and Delete
column in views as well.
👉 This section focuses on adding a custom controller. At the end you will master
the possibilities of routing in drupal, know how parameter upcasting works and
how to validate the slug.
The ‘publish’ button is pointed to the edit page. We would like to provide a direct
publish controller, with redirect to the published offer.
offer.publish:
path: '/offers/publish/{offer}'
defaults:
_controller:
'\Drupal\offer\Controller\OfferPublishController::Render'
105
_title_callback: 'Publish offer'
requirements:
_custom_access:
'\Drupal\offer\Controller\OfferPublishController::Access'
options:
parameters:
offer:
type: entity:offer
Key Explanation
offer.publish This is the uri that gets registered in drupal for this page.
path The url of this page. We add a slug with {id} to identify the id of
the offer we want to publish.
_controller The class that gets called when visiting the page.
_title callback The title of the page. We can add a string or a method inside the
class.
_custom_access That method can then check access and return an access result
object.
The advantage of this is that menu links that point to the
controller can automatically check access and decide to hide
the link when necessary.
parameters: A route can have route parameters. In this case our slug is
offer: {offer}. We tell the system these are of type entity:offer. This is
type: entity:offer called route parameter upcasting.This way our routing is
automatically secured against wrong, non-numerical calls etc.
A bonus is that we can use the entity directly in our Render()
and Access() functions like this:
Render(Offer $offer) {}
<?php
106
namespace Drupal\offer\Controller;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Entity\RevisionLogInterface;
use Drupal\Core\Url;
use Drupal\offer\Entity\Offer;
use Symfony\Component\HttpFoundation\RedirectResponse;
/**
* Class OfferPublishController
*/
class OfferPublishController extends ControllerBase {
$publishedOffer = Url::fromRoute('entity.offer.canonical',
['offer' => $offer->id()]);
\Drupal::messenger()->addMessage($offer->label() . ' is
published.');
107
if($offer->get('moderation_state')->getString() != 'draft') {
$access = AccessResult::forbidden();
}
return $access;
}
}
In the Render() method, we programmatically set the moderation state to published
and redirect to the collection overview. Thanks to the Access() method, we secure
the page for abuse: we do an $offer->access() check that verifies the visitor (it goes
over the OfferAccessControlHandler.php) is the owner and we check if the
moderation state is draft.
💻 In the previous chapter we added a publish button in the view that was
linked to the edit page. Link it to the publish page at
custom/offer/src/Plugin/views/field/OfferDynamicOperationLinks.php
on line 71:
switch ($state) {
case 'draft':
$operations['publish'] = [
'title' => $this->t('Publish'),
'url' => Url::fromRoute('offer.publish', ['offer'
=> $entity->id()])
];
break;
}
108
Building an overview page
👉 In this section you will be taught how to make entity listings with views using
view modes.
We need an overview page where all the offers are viewable for users that want to
bid. The core views module comes back into play.
But first, we want a teaser view mode for the offer entities. At
/admin/structure/display-modes/view/add/offer. we add a new one named teaser.
109
💻 Also, add a display mode called ‘full’, for later in the course.
110
At /admin/structure/offer/display/teaser we add the fields we want to use in our
twig files for the teasers of offer entities. For the teaser, we need title, offer type,
price, and image.
111
At the fields section, add “rendered entity”, choos display mode teaser.
112
We’ll take a look at what we have now:
113
(
There is a bit of a problem here. We only want the ones with moderation state
‘published’ in the overview. Not the ones with ‘expired’ status. For a solution for this,
head on to the next chapter.
👉 This section spotlights custom views filters. At the end you will be able to use a
self-defined filter in the user interface of views. More specifically an example
with a filter on certain moderation state (filter only on offers with moderation
state “Published”) is elaborated.
114
Moderation state is not an available filter in drupal core (yet?). But this is a good
opportunity to learn how to add a custom filter in views, based on a state of the
entity.
$data['offer']['offer_moderation_state_filter'] = [
'title' => t('Moderation state'),
'filter' => [
'title' => t('Moderation state'),
'help' => 'Filters on moderation state',
'field' => 'id',
'id' => 'offer_moderation_state_filter',
]
];
<?php
namespace Drupal\offer\Plugin\views\filter;
use Drupal\Core\Database\Connection;
use Drupal\views\Plugin\views\filter\InOperator;
use Drupal\views\ViewExecutable;
use Drupal\views\Plugin\views\display\DisplayPluginBase;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Filter class which filters by the available ModerationStates.
*
* @ViewsFilter("offer_moderation_state_filter")
*/
class ModerationStateFilter extends InOperator {
/**
* @var \Drupal\Core\Database\Connection
*/
protected $database;
115
/**
* Constructs a Bundle object.
*
* @param array $configuration
* A configuration array containing information about the plugin
instance.
* @param string $plugin_id
* The plugin_id for the plugin instance.
* @param mixed $plugin_definition
* The plugin implementation definition.
*/
public function __construct(array $configuration, $plugin_id,
$plugin_definition, Connection $database) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->database = $database;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array
$configuration, $plugin_id, $plugin_definition) {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('database')
);
}
/**
* Override the query so that no filtering takes place if the user
doesn't
* select any options.
*/
public function query() {
$configuration = [
'table' => 'content_moderation_state_field_data',
116
'field' => 'content_entity_id',
'left_table' => 'offer',
'left_field' => 'id',
'operator' => '='
];
$join =
\Drupal::service('plugin.manager.views.join')->createInstance('standard'
, $configuration);
$this->query->addRelationship('content_moderation_state_field_data',
$join, 'offer');
$this->query->addWhere('AND',
'content_moderation_state_field_data.moderation_state', $selected,
'IN');
}
}
/**
* {@inheritdoc}
*/
public function init(ViewExecutable $view, DisplayPluginBase $display,
array &$options = NULL) {
parent::init($view, $display, $options);
$this->valueTitle = t('Moderation state');
$this->definition['options callback'] = [$this,
'getModerationStates'];
}
/**
* Generates the list of ModerationStates that can be used in the
filter.
*/
public function getModerationStates() {
$result = [
'draft' => 'Draft',
'published' => 'Published',
'expired' => 'Expired'
];
return $result;
}
}
117
Inside getModerationStates(), we define the search options. We could dynamically
get the available moderation states on an offer entity, but this would take us too far.
For the sake of example, we offer the options in an hard-coded array.
Inside query(), we add a relationship to the moderation table and left join it on the
offer id.
Select ‘published’:
118
One last thing we need to to before saving the view is add a file to
custom/modules/offer/config/schema named offer.schema.yml:
views.filter.offer_moderation_state_filter:
type: views.filter.in_operator
label: 'Filter for moderation state'
Views plugins all need to have their own configuration schema in order for them to
work.
119
Clear caches, save the view and you’ll see it works! If you check ‘Show SQL query’ at
admin/structure/views/settings you can see the actual query views does:
SELECT offer.id AS id
FROM
{offer} offer
LEFT JOIN {content_moderation_state_field_data}
content_moderation_state_field_data ON offer.id =
content_moderation_state_field_data.content_entity_id
WHERE content_moderation_state_field_data.moderation_state IN
('published')
LIMIT 11 OFFSET 0
Before drupal 8, we always used the ‘Published’ status which is a built-in boolean for
being published or not. Here we use states.
Draft No
Published Yes
Expired Yes
So while the expired offers are published, we do not show them in our view. On a
software level we want to keep our expired offers online, for SEO and archiving.
Users will not be able to place a bid on expired offers anymore.
👉 In this chapter we will learn how to optimize rendered output of an entity. You
will see how we prepare variables for output with twig. For example, if an offer
has no bids yet, show a message. Or show the amount of bids the offer has so
far.
120
Variable name Description
/**
* Prepares variables for templates.
* implements hook_preprocess_HOOK()
*/
function template_preprocess_offer(array &$variables) {
foreach (Element::children($variables['elements']) as $key) {
$variables['content'][$key] = $variables['elements'][$key];
}
$offer = $variables['elements']['#offer'];
// The full offer object
$variables['offer'] = $offer;
121
<a href="{{ path('entity.offer.canonical', {'offer': offer.id()})
}}" {{ attributes.addClass('offer-teaser') }}>
{% if promo %}<div class="badge">{{promo }}</div>{% endif %}
<div class="product-tumb">
{{ content.field_image }}
</div>
<div class="product-details">
<h4>{{ offer.label() }}</h4>
<p>{{ bid_amount }} bids</p>
<div class="product-bottom-details">
<div class="product-price">{{ price }}</div>
</div>
</div>
</a>
For drupal to be able to recognize this template, extend the offer_theme() hook inside
custom/offer/offer.module, we add the offer__teaser key. We also add the default
offer key, and a offer__full for the offer page which we will use later:
/**
* Provides a theme definition for custom content entity offer
* {@inheritdoc}
*/
function offer_theme($existing, $type, $theme, $path) {
return [
'offer' => [
'render element' => 'elements',
],
'offer__full' => [
'base hook' => 'offer',
],
'offer__teaser' => [
'base hook' => 'offer'
]
];
}
Clear caches and take a view. Drupal now recognizes the teaser file and shows the
correct values in the teasers.
122
Head to the next chapter to add some styling to them.
👉 In this chapter we will learn how to add a libraries.yml file with css to be
included when rendering a specific view. At the end you will be able to include
styles where a certain view is loaded.
offer_overview_page:
css:
theme:
css/overview.css: {}
123
To add the css only when the view is showed, we can use the following hook that we
add to our custom/offer/offer.module file:
/**
* Implements hook_views_pre_render().
*/
function offer_views_pre_render(ViewExecutable $view) {
if (isset($view) && ($view->storage->id() == 'offer_overview')) {
$view->element['#attached']['library'][] =
'offer/offer_overview_page';
}
}
We use hook_views_pre_render() to tell the system to always attach the extra library
whenever this view is rendered.
.offer-teaser {
width: 90%;
position: relative;
box-shadow: 0 2px 7px #dfdfdf;
margin: 50px 0;
background: #fafafa;
display:block;
}
.offer-teaser * {
text-underline:none;
}
.badge {
position: absolute;
left: 0;
top: 20px;
text-transform: uppercase;
font-size: 13px;
font-weight: 700;
124
background: #C48904;
color: #fff;
padding: 3px 10px;
}
.product-tumb {
display: flex;
align-items: center;
justify-content: center;
height: auto;
width: 100%;
}
.product-tumb img {
max-width: 100%;
max-height: 100%;
}
.product-details {
padding: 30px;
}
.product-details h4 {
font-weight: 500;
display: block;
margin-bottom: 18px;
text-transform: uppercase;
color: #363636;
text-decoration: none;
transition: 0.3s;
}
.product-details p {
font-size: 15px;
line-height: 22px;
margin-bottom: 18px;
color: #999;
}
.product-bottom-details {
overflow: hidden;
125
border-top: 1px solid #eee;
padding-top: 20px;
}
.product-price {
font-size: 12px;
color: #C43218;
font-weight: 600;
}
This looks nice and appealing. Later in this course we will make the variables more
dynamic. Let’s move forward!
Sidenote here: if the use case is to show the teasers on other pages also, it would be
better to attach the css to the teasers and not the overview. In general, you should
style your view modes in a way that they look the same everywhere on the platform.
126
Adding a user-friendly multistep form for entity creation
👉 In this chapter you will learn how to create form modes for your data entry
forms. More specifically we will show how to add a multistep form for creating
offers in a user-friendly way.
If we head over to offers/create we see the default form that can be configured under
admin/structure/offer/form-display.
But we want a more user-friendly creation form. This is the flow we want:
Add step_1 (Step 1), step_2 (Step 2), step_3 (Step 3):
127
At /admin/structure/offer/form-display go to “Custom display settings” and enable
all three:
Next, we drag the desired fields to each of the steps. In the case of “Step 1”, this is
only the title field.
128
Do the same for step 2 and step 3.
We have our new form modes, but this will initially do nothing. First, we have to
“inform” our custom entity that there are three new form modes available. Let’s add
them to the offer entity right after our default form:
* handlers = {
* "access" = "...",
* "views_data" = "...",
* "form" = {
* "default" = "Drupal\offer\Form\OfferForm",
* "step_1" = "Drupal\offer\Form\OfferAddFormStep1",
* "step_2" = "Drupal\offer\Form\OfferAddFormStep2",
* "step_3" = "Drupal\offer\Form\OfferAddFormStep3",
* "edit" = "Drupal\offer\Form\OfferForm",
We’ve informed drupal that there are now new forms for our entity. Make sure your
keys in the annotations (“step_1”, “step_2”, …) are the same as the machine name of
your custom form modes.
129
Now copy the content entity form under Form\Offerform.php to
Form\OfferAddFormStep1, Form\OfferAddFormStep2.php, and
Form\OfferAddFormStep3.php. This way you copied the default entity form to the
steps. This way we can customize them later.
offer.add:
path: '/offers/create'
defaults:
_entity_form: offer.step_1
_title: 'Step 1: set your title'
requirements:
_entity_create_access: 'offer'
offer.step1:
path: '/offers/create/{offer}'
defaults:
_entity_form: offer.step_1
_title: 'Step 1: set your title'
requirements:
_entity_create_access: 'offer'
offer.step2:
path: '/offers/create/step2/{offer}'
defaults:
_entity_form: offer.step_2
_title: 'Step 2: add a description and image'
requirements:
_entity_access: 'offer.edit'
offer.step3:
path: '/offers/create/step3/{offer}'
defaults:
_entity_form: offer.step_3
_title: 'Step 3: choose the type of offer'
requirements:
130
_entity_access: 'offer.edit'
The entity forms remain basically the same (although they have different form
modes), but the difference is in the Save() function. We change it in
src\Form\OfferAddFormStep1.php like this:
/**
* {@inheritdoc}
*/
public function save(array $form, FormStateInterface $form_state) {
// Redirect to offer overview after save.
$form_state->setRedirect('entity.offer.collection');
\Drupal::messenger()->addMessage('Your offer was created. Click the
publish button to start earning.');
$entity = $this->getEntity();
$entity->save();
}
131
In the buildForm() function of each of the forms, you can tweak some more. I
changed the submit button text like this:
We’re done! We’ve created an advanced multi-step form, which saves our entity on
each step. Let’s enhance the process a bit for further user experience.
132
If we click Save and proceed we go to the next step:
133
💻 There is still a field “Revision log” visible. This is because the entity is
revisionable. But we do not want to use it here. We make it invisible with this
snippet. Clear caches and you’ll see it is gone.
/**
* Implements hook_form_alter().
*/
function offer_form_alter(array &$form, FormStateInterface
$form_state, $form_id) {
$forms = ['offer_step_1_form', 'offer_step_2_form',
'offer_step_3_form', 'offer_edit_form'];
if (in_array($form_id, $forms)) {
// Prevent revision log box access
$form['revision_log']['#access'] = FALSE;
}
}
👉 In this chapter you will learn how to add additional form buttons (actions) like a
cancel button or a “Go back to step 1” redirect on entity forms.
Actions are the typical buttons drupal groups below a form. Because we use multiple
steps we want to add functionality to be able to navigate inside the form as well.
In our first step, we’d like to provide a ‘Cancel’ button next to the ‘Save and proceed’
button. Also, remove the ‘delete’ button.
To src\Form\OfferAddFormStep1.php we add:
134
'#value' => $this->t('Cancel'),
'#submit' => ['::cancelSubmit'],
'#weight' => 90,
'#limit_validation_errors' => []
];
if (array_key_exists('delete', $actions)) {
unset($actions['delete']);
}
$actions['#prefix'] = '<i>Step 1 of 3</i>';
return $actions;
}
This is Object Oriented code in practice. The actions method allows us to extend and
add an extra action. In the callback we specify a redirect to cancel the operation.
135
To src\Form\OfferAddFormStep2.php we add:
136
public function goBack(array $form, FormStateInterface
$form_state) {
$entity = $this->getEntity();
$id = $entity->id();
$form_state->setRedirect('offer.step1', ['offer' => $id]);
}
You’ll understand what I did here. The goBack() function will get called when clicking
the button ‘Back to step 1’. We’ll get redirected to /offer/create/{offer}.
137
$id = $entity->id();
$form_state->setRedirect('offer.step2', ['offer' => $id]);
}
Because this is the last step in the form, we redirect to our overview and add a
message via the Messenger API Drupal provides us:
138
After we finished our offer, we get redirected to the My offers page:
139
The last step (step 3) is not yet how we want it. We want to show the Price field only if
the Offer type is set to ‘Set minimum price’ and hidden when the Offer type is set to ‘No
minimum price’. We’ll do this in the next chapter.
👉 In this chapter we show how to use conditional fields with the core form API. By
the end of this chapter you will know how to show/hide fields based on user
input on another field.
While there were quite a lot of new things introduced last year, one of the most stable
things is the Form API. It grew out to be one of the best API’s drupal has. In our step
3, we want to have our price field only visible when users select ‘Set minimum price’.
$form['field_price']['#states'] = [
'visible' => [
['select[name="field_offer_type"]' => ['value' => 'with_minimum']],
]
];
return $form;
}
This is actually quite readable. WHEN the offer type has the selection
‘with_minimum’ (this is the key of ‘Set minimum price’) selected, THEN make the
price field visible.
In action:
140
When selecting ‘Set a minimum price’:
141
This is it! Our multi step form is completely done.
We’ll use this to loop over our offer entities to create. We add this to the switch
statement under ‘user’ in de generate() method:
case 'offer':
142
$offers = $this->getOfferList();
foreach($offers as $offerListItem) {
$offer = Offer::create();
$offer->set('title', $offerListItem['title']);
$offer->set('field_description', ['value' =>
$offerListItem['field_description'], 'format' => 'html']);
$offer->set('field_offer_type',
$offerListItem['field_offer_type']);
$offer->set('field_price', $offerListItem['field_price']);
$directory = 'public://';
$url = $offerListItem['field_image'];
$file = system_retrieve_file($url, $directory, true);
$drupalMedia = Media::create([
'bundle' => 'image',
'uid' => '0',
'field_media_image' => [
'target_id' => $file->id(),
],
]);
$drupalMedia->setPublished(TRUE)
->save();
$offer->set('field_image', ['target_id' =>
$drupalMedia->id()]);
$offer->set('user_id', 1);
$offer->set('moderation_state',
$offerListItem['moderation_state']);
if($offer->save()) {
$count += 1;
}
}
return $count;
break;
$count = $seed->Generate('offer');
Drush::output()->writeln($count . ' offer(s) created');
143
In the final project code, you’ll see I’ve ended up with multiple offers that get
created this way. Now developers who install the platform locally can work with
real-life offers instead of an empty installation.
144
Part 4: building the application. Storing, validating and
rendering data
👉 In this chapter we go over custom menu links defined in code. At the end you
will be able to create dynamic menu links with counters such as “My offers (2)”.
If we log in with a user who is not the administrator we lack navigation. Drupal has a
built-in menu system which content managers can use to add content to menus. But
because this is more a framework course than a CMS course we want to add these
in code. A nice advantage is that you can cache these items individually and cleverly.
More about this in the chapter about caching. A second advantage is you can add
variable data such as counters.
To modules/custom/offer/offer.links.menu.yml, we add:
offer.account.my_offers:
route_name: entity.offer.collection
menu_name: main
class: Drupal\offer\Plugin\Menu\MyOffers
weight: -50
We need to add a Menu Plugin to our module so the Menu module can detect our
menu link. Add modules/custom/offer/Plugin/Menu/MyOffers.php:
<?php
namespace Drupal\offer\Plugin\Menu;
use Drupal\Core\Menu\MenuLinkDefault;
/**
* displays number of offers.
*/
class MyOffers extends MenuLinkDefault {
/**
* {@inheritdoc}
145
*/
public function getTitle() {
$count = 0;
if(\Drupal::currentUser()->isAuthenticated()) {
$offers = \Drupal::entityTypeManager()
->getStorage('offer')
->loadByProperties(['user_id' => \Drupal::currentUser()->id()]);
$count = count($offers);
return $this->t('My offers (<span
class="count-badge">(@count)</span>)', ['@count' => $count]);
} else {
return null;
}
}
/**
* {@inheritdoc}
*/
public function getCacheMaxAge() {
return 0;
}
If we clear the caches, we see the ‘My offers’ menu link appearing on top.
146
The link is not cached as it needs to show an updated value after adding a new offer.
See the chapter about caching for details on how to provide custom caching
mechanisms for this. We will style this main menu in a further chapter.
Next, we add a link to the overview page (to the main menu) and the notifications (to
the account menu):
To custom/offer/offer.links.menu.yml
offer.overview:
title: 'Offers'
route_name: view.offer_overview.page_1
menu_name: main
weight: 5
147
Building the offer page with twig: theming a custom content
entity
Now it's time for us to start building the offer page. We’ll use twig files, because this
is the standard in drupal 9. I would recommend not to install contributed modules for
styling as they may cause troubles in the long run (upgrading?).
If you followed this course, thanks to the instruction in the rendering a teaser the full
template should already be registered in our custom/offer/offer.module:
/**
* Provides a theme definition for custom content entity offer
* {@inheritdoc}
*/
function offer_theme($existing, $type, $theme, $path) {
return [
'offer' => [
'render element' => 'elements',
],
'offer__full' => [
'base hook' => 'offer',
],
'offer__teaser' => [
'base hook' => 'offer'
]
];
}
First, we want to theme the full view, for that we clear caches and add a file to
custom/offer/templates named offer--full.html.twig. To the file, add:
{{ content }}
148
Clear caches to make sure this template is rendered. Add some dummy sentence
before the content statement to test it.
149
We can play along with the fields at admin/structure/offer/display/full to change the
order of the fields and hiding or showing the label. But I prefer to be more specific
when using twig, so I edit my offer--full.html.twig like this:
{{ content.field_image }}
{{ content.field_description }}
<?php
namespace Drupal\offer\Form;
use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\offer\Entity\Offer;
150
class OfferBiddingForm extends FormBase {
/**
* @return string
* The unique string identifying the form.
*/
public function getFormId() {
return 'offer_bid_form';
}
/**
* Form constructor.
*
* @param array $form
* An associative array containing the structure of the form.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
* @param \Drupal\offer\Entity\Offer $offer
* The offer entity we're viewing
*
* @return array
* The form structure.
*/
public function buildForm(array $form, FormStateInterface $form_state,
$offer = NULL) {
$form['bid'] = [
'#type' => 'textfield',
'#attributes' => array(
' type' => 'number', // this validates it as a number in
front-end
),
'#title' => $this->t('Your bid'),
'#description' => $this->t('Prices in $.'),
'#required' => TRUE,
];
151
$form['actions']['submit'] = [
'#type' => 'submit',
'#value' => $this->t('Submit'),
];
return $form;
}
}
We add the validateForm() and submitForm() functions but keep them empty for now:
How do we show the form on the entity page? This is an important architectural
decision. Our entities are cached as a whole, so we need to keep this out of our entity.
A block plugin is the wisest decision here.
We’ll use an advanced technique here. Because the form in the block needs to be
dependent on the current offer, we will use the RequestStack service.
<?php
namespace Drupal\offer\Plugin\Block;
use Drupal\Core\Block\BlockBase;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Session\AccountProxyInterface;
152
/**
* @Block(
* id = "offer_bidding_block",
* admin_label = @Translation("Offer bid block"),
* category = @Translation("Shows the bidding block to an offer"),
* )
*/
/**
* @var $account \Drupal\Core\Session\AccountProxyInterface
*/
protected $account;
/**
* The request object.
*
* @var \Symfony\Component\HttpFoundation\RequestStack
*/
protected $requestStack;
/**
* The entity storage.
*
* @var \Drupal\Core\Entity\EntityStorageInterface
*/
protected $entityStorage;
/**
* Constructs a new OfferBiddingBlock instance.
*
* @param string $plugin_id
* The plugin_id for the plugin instance.
* @param mixed $plugin_definition
* The plugin implementation definition.
* @param \Symfony\Component\HttpFoundation\RequestStack $request_stack
* The request stack object.
* @param \Drupal\Core\Entity\EntityStorageInterface $entity_storage
* The entity storage.
*/
public function __construct(array $configuration, $plugin_id,
$plugin_definition, RequestStack $request_stack, EntityStorageInterface
153
$entity_storage, AccountProxyInterface $account) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->requestStack = $request_stack;
$this->entityStorage = $entity_storage;
$this->account = $account;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array
$configuration, $plugin_id, $plugin_definition) {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('request_stack'),
$container->get('entity_type.manager')->getStorage('offer'),
$container->get('current_user')
);
}
/**
* {@inheritdoc}
*/
public function build() {
// Make sure this is an offer page
$offer = $this->requestStack->getCurrentRequest()->get('offer');
if(!$offer) {
return null;
}
// Make sure the current user is not owner of the offer
if($this->account->id() == $offer->getOwnerId()) {
return null;
}
$form =
\Drupal::formBuilder()->getForm('\Drupal\offer\Form\OfferBiddingForm',
$offer);
return $form;
}
/**
* A form for authenticated users never gets cached.
154
*/
public function getCacheMaxAge() {
return 0;
}
A lot happens here. This is actually the best practice way to get the current entity
loaded in your block. A fine piece of Object oriented programming. The more you dive
in it, the more comfortable you will be with it. One advantage of it is that via the
create(), construct() and build() methods you will receive objects from memory and not
need to load again objects with some direct functions like:
$offerId = \Drupal::routeMatch()->getParameter('offer');
$offer = Offer::load($offerId);
We pass the full offer entity object as a parameter to the form, it gets picked up by our
buildForm method inside this piece:
Also check out the getCacheMaxAge() which is set to 0. This means the block will
never be cached. More about this in the chapter about caching.
💻 Clear caches and add the block to the content region (below the main content)
at admin/structure/blocks.
Make sure you check this block will be only available for authenticated users:
155
And restrict it to these pages only: /offer/*. This means the block will only be
visible when we are on an offer page.
156
Clear caches and take a look.
Looks good!
157
💻 Of course you are not able to place a bid on your own offers. Make sure you test
it on an offer of a different user.
Remember from the adding fields chapter there were two options in field_offer_type:
● with_minimum|Set minimum price
● no_minimum|No minimum price
switch($offer->get('field_offer_type')->getString()) {
case 'with_minimum':
$price = $offer->get('field_price')->getString();
break;
case 'no_minimum';
$price = '0';
break;
}
$form['price'] = [
'#markup' => '<h2>' . $this->t('Start bidding at @price$', ['@price' =>
$price]) . '</h2>',
];
158
Now we extend the bid textfield with two HTML5 attributes, for a minimum price and
for a numeric field. This will offer front-end validation in the browser.
switch($offer->get('field_offer_type')->getString()) {
case 'with_minimum':
$price = $offer->get('field_price')->getString();
break;
case 'no_minimum';
$price = '0';
break;
}
$form['price'] = [
'#children' => '<h2>' . $this->t('Start bidding at @price$', ['@price'
=> $price]) . '</h2>',
];
$form['bid'] = [
'#type' => 'textfield',
'#attributes' => [
' type' => 'number', // note the space before attribute key
' min' => $price
],
'#title' => $this->t('Your bid'),
'#description' => $this->t('Prices in $.'),
'#required' => TRUE,
];
159
Be careful. Front-end validation is not enough. We validate the input on server-side as
well with the ValidateForm() method. For now we’ll validate for integers. Further
along we’ll check to make sure the bid exceeds the highest bid so far.:
We need to submit the form in the submitForm() method. For now, we just submit
without saving anything. In a further chapter, we’ll save the bid.
👉 In this chapter you will learn how to add libraries containing css and load it on
all pages At the end you will have discovered how to apply global styling to your
project.
While in this course we will not focus (at all) on drupal theming we need to define a
css file that gets used site-wide in order to do some small tweaks to make the
bidding page look good. In order to add css or javascript to pages we need to define
libraries. We will define this inside custom/offer/offer.libraries.yml:
platform:
css:
theme:
css/platform.css: {}
Now we need to tell the system when to include the library. There are ways to add
them specifically to:
● Controllers
160
● Forms
● Blocks
● Views
But in this case, we always want it. For this we can use hook_page_attachments() in
custom/offer/offer.module:
/**
* Implements hook_page_attachments().
*/
function offer_page_attachments(array &$attachments) {
$attachments['#attached']['library'][] = 'offer/platform';
}
Below this, we add an extra body class to the offer detail page using
hook_preprocess_html:
/**
* Add a "offer-detail-page" class to the body on a offer detail page
*/
function offer_preprocess_html(&$variables) {
$offer = \Drupal::routeMatch()->getParameter('offer');
if($offer) {
$variables['attributes']['class'][] = 'offer-detail-page';
}
}
We add custom/offer/css/platform.css:
.offer-detail-page .block-system-main-block {
width: 55%;
float:left;
}
.offer-detail-page #block-bid-block {
width: 30%;
float:right;
}
Clear caches, and you’ll see the blocks showing more nicely next to each other.
161
💻 Add the user account menu as well to the breadcrumbs region (choose
“hide title”).
162
}
.navigation.menu--account {
float:right;
}
.navigation.menu--main {
float:left;
}
.navigation.menu--main ul li, .navigation.menu--account ul li {
float:left;
list-style:none;
}
.navigation.menu--main .menu, .navigation.menu--account .menu {
margin: 0;
}
.navigation.menu--main ul li a, .navigation.menu--account ul li a
{
color: #0444C4;
padding: 20px;
text-decoration:none;
font-size: 0.8125rem;
}
.navigation.menu--main ul li a.is-active, .navigation.menu--main
ul li a:hover, .navigation.menu--account ul li a.is-active,
.navigation.menu--account ul li a:hover {
background: #E5EDFD;
color:black;
}
/** breadcrumbs **/
.block-system-breadcrumb-block {
clear:both;
margin-left: min(5vw,48px);
margin-right: min(5vw,48px);
}
.count-badge {
color: red;
font-weight: 600;
}
163
Note again that for professional platforms we would do a more advanced theming
(tailwind, bootstrap, …), with a primary focus on mobile experience. But this is not the
purpose of this course.
👉 This chapter teaches you how to define an entity that will only live in our
database and is not visible in our UI. After this chapter you will be able to create
custom entities with fields like entity reference.
For storing our bids, we need a second entity which will have a minimum of code. We
start a new module adding the following files:
● bid.info.yml
● src/Entity/Bid.php
name: bid
type: module
description: bid entity
core: 8.x
core_version_requirement: ^8 || ^9
Next, our bid entity. We’ll add the following database fields:
164
● user_id (entity_reference): the owner of the bid
● offer_id (entity_reference): the offer the bid is for
● bid (decimal): the amount of the bid
● created (created): the time the bid was done
● changed (changed): the updated time of the bid
Once again, we include the revisioning system which allows us to show a history (e.g.
“Person x has raised his bid with 4$”).
To custom/bid/src/Entity/Bid.php:
<?php
/**
* @file
* Contains \Drupal\bid\Entity\bid.
*/
namespace Drupal\bid\Entity;
use Drupal\Core\Entity\EditorialContentEntityBase;
use Drupal\Core\Field\BaseFieldDefinition;
use Drupal\Core\Entity\EntityTypeInterface;
/**
* Defines the bid entity.
*
* @ingroup bid
*
* @ContentEntityType(
* id = "bid",
* label = @Translation("bid"),
* base_table = "bid",
* data_table = "bid_field_data",
* fieldable = TRUE,
* revision_table = "bid_revision",
* revision_data_table = "bid_field_revision",
* entity_keys = {
* "id" = "id",
* "uuid" = "uuid",
* "label" = "title",
* "owner" = "uid",
* "revision" = "vid",
* "published" = "status",
165
* "uid" = "uid",
* "owner" = "uid",
* },
* handlers = {
* "access" = "Drupal\bid\BidAccessControlHandler",
* },
* revision_metadata_keys = {
* "revision_user" = "revision_uid",
* "revision_created" = "revision_timestamp",
* "revision_log_message" = "revision_log"
* }
* )
*/
$fields['user_id'] = BaseFieldDefinition::create('entity_reference')
->setLabel(t('User'))
->setDescription(t('The user that created the bid.'))
->setSetting('target_type', 'user')
->setSetting('handler', 'default');
$fields['offer_id'] =
BaseFieldDefinition::create('entity_reference')
->setLabel(t('Offer'))
->setDescription(t('The offer the bid is for.'))
->setSetting('target_type', 'offer')
->setSetting('handler', 'default');
$fields['bid'] = BaseFieldDefinition::create('float')
->setLabel(t('Bid amount'))
->setRevisionable(TRUE)
->setDescription(t('The bid amount in $.'));
$fields['created'] = BaseFieldDefinition::create('created')
->setLabel(t('Created'))
->setDescription(t('The time that the entity was created.'));
$fields['changed'] = BaseFieldDefinition::create('changed')
->setLabel(t('Changed'))
166
->setDescription(t('The time that the entity was last edited.'));
return $fields;
}
/**
* {@inheritdoc}
*/
public function getOwner() {
return $this->get('user_id')->entity;
}
/**
* {@inheritdoc}
*/
public function getOwnerId() {
return $this->get('user_id')->target_id;
}
When double checking our database, we see our columns correctly created:
167
We’ve created our bid entity. This entity will only live in our database. It is fully
powered by the drupal entity API. But because we don’t make it “fieldable”.
👉 This chapter will teach you how to save custom entity values on a form
submission. This is a very common use case for saving data from a custom
form. At the end you will be able to save values such as an entity reference and
integer values to an entity that only lives in our database.
We now have everything in place to start saving the bids of the users.
168
use Drupal\bid\Entity\Bid;
One thing we’ll need for saving the bid is the current offer id. We’ll send it along in the
buildForm() function as a hidden and inaccessible field:
$form['offer_id'] = [
'#type' => 'hidden',
'#value' => $offer->id(),
'#access' => FALSE
];
169
After submission, we get a success message.
We now have successfully saved our bid to the custom entity. But let us build in
some security so our database stays clean.
👉 In this chapter we will add custom methods to our entity. More specifically
these are functions to validate input on our bidding form server-side.
170
There are still things left undone. The form needs to anticipate the latest bid so now
offer lower or equal than the highest bid gets accepted.
use Drupal\bid\Entity\Bid;
/**
* Returns the highest bid on an offer
* @return integer $price
* The price
*/
public function getOfferHighestBid() {
$bids = [];
$id = $this->id();
$query = \Drupal::entityQuery('bid')
->condition('offer_id', $id)
->sort('bid', 'ASC')
->range(NULL, 1);
$bidIds = $query->execute();
$price = null;
foreach($bidIds as $id) {
$bid = Bid::load($id);
$price = $bid->get('bid')->getString();
}
return $price;
}
Also, add a second function loading all the bids of a given offer:
/**
* Returns all bids of an offer
* @return array $bids
* Array of bid entities
*/
public function getOfferBids() {
171
$bids = [];
$id = $this->id();
$query = \Drupal::entityQuery('bid')
->condition('offer_id', $id)
->sort('bid', 'DESC');
$bidIds = $query->execute();
foreach($bidIds as $id) {
$bid = Bid::load($id);
$bids[] = $bid;
}
return $bids;
}
$OfferHasBid = $offer->getOfferHighestBid();
if($OfferHasBid) {
$price = $OfferHasBid + 1;
}
$form['price'] = [
'#children' => '<h2>' . $this->t('Start bidding at @price$', ['@price'
=> $price]) . '</h2>',
];
This will change the minimum price for both our title and our front-end validation. To
definitely make sure we’re not accepting lower bids than our highest bid, we validate
server-side too in our validateForm() function. Always remember to do this, because
front-end validation can be skipped easily.
use Drupal\offer\Entity\Offer;
172
// Load the offer and make sure no higher bid was done in the meantime
$offer_id = $form_state->getValue('offer_id');
$offer = Offer::load($offer_id);
$OfferHasBid = $offer->getOfferHighestBid();
switch($offer->get('field_offer_type')->getString()) {
case 'with_minimum':
$minium_price = isset($OfferHasBid) ? $OfferHasBid :
$offer->get('field_price')->getString();
break;
case 'no_minimum';
$minium_price = isset($OfferHasBid) ? $OfferHasBid : 0;
break;
}
if($minium_price >= $form_state->getValue('bid')) {
$form_state->setErrorByName('bid', t('Minimum bid needs to be @price',
['@price' => (@$minium_price + 1) . '$' ]));;
}
We now validate server-side that no bid can pass that is lower than the current
highest bid.
👉 In this chapter we will add dynamic variables such as the current amount of bids
to our teaser overview. At the end you will be able to call custom entity methods
and process them into variable output of your teasers in twig.
/**
173
* Prepares variables for templates.
* implements hook_preprocess_HOOK()
*/
function template_preprocess_offer(array &$variables) {
foreach (Element::children($variables['elements']) as $key) {
$variables['content'][$key] = $variables['elements'][$key];
}
$offer = $variables['elements']['#offer'];
// The full offer object
$variables['offer'] = $offer;
// a promo badge
if(($bid_amount == 0) && (\Drupal::currentUser()->id() !=
$offer->getOwnerId())) {
$variables['promo'] = 'Be the first!';
}
}
174
attributes.addClass('offer-teaser') }}>
{% if promo %}<div class="badge">{{promo }}</div>{% endif %}
<div class="product-tumb">
{{ content.field_image }}
</div>
<div class="product-details">
<h4>{{ offer.label() }}</h4>
<p>{{ bid_amount }} bids</p>
<div class="product-bottom-details">
<div class="product-price">{{ price }}</div>
<div class="product-user-profile">{{ user_profile }}</div>
</div>
</div>
</a>
We now have more dynamic teasers, showing the current amount of bids, the highest
bid so far, and a promo badge if there are no bids yet.
175
You might stumble upon the fact that this overview not always get updated when
bidding, we’ll fix this in the chapter about caching.
👉 In this chapter we explore the Entity Validation API. With this API we set
constraints on saving entities to keep our database clean at all times.
What if something goes wrong and a bid gets saved with incorrect values?
The Entity Validation API ensures that all entities pass the validation criteria. We can
add validation on an entity level, or on a field level. For our bids we will first validate
to make sure all values are set and are not empty.
We could choose to do these checks in our submitForm() function, but using
constraints is more a good practice way because this way other classes or modules
can use the same validators.
namespace Drupal\bid\Plugin\Validation\Constraint;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\Exception\MissingOptionsException;
/**
* Requires an offer entity to have all fields required to save as an
object.
*
* @Constraint(
* id = "AllFieldsRequired",
* label = @Translation("All fields required.", context =
"Validation"),
* type = "entity:bid"
* )
*/
class AllFieldsRequiredConstraint extends Constraint {
176
public $message = 'At least one field was empty and prevented saving
the bid.';
This file registers the constraint on the bid entity. It would be used to give feedback
in an entity form. This means it will go over this constraint if we ask to validate input
for an entire entity. This is also possible for one field only.
But when validating on custom forms, we have to make sure the constraint is also
set in custom/bid/src/Entity/Bid.php. To the annotations, add the following:
* constraints = {
* "AllFieldsRequired" = {}
* }
AllFieldsRequiredConstraintValidator.php:
<?php
namespace Drupal\bid\Plugin\Validation\Constraint;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
/**
* Validates the AllFieldsRequired constraint.
*/
class AllFieldsRequiredConstraintValidator extends ConstraintValidator {
/**
* {@inheritdoc}
*/
public function validate($entity, Constraint $constraint) {
if ($entity->get('user_id')->isEmpty()) {
$this->context->addViolation($constraint->message);
}
if ($entity->get('bid')->isEmpty()) {
$this->context->addViolation($constraint->message);
}
if ($entity->get('offer_id')->isEmpty()) {
177
$this->context->addViolation($constraint->message);
}
}
The code itself is quite readable. If one of the fields ‘bid’, ‘offer_id’, ‘user_id’ is empty,
it will not validate.
A final step is to clear the caches so the constraint gets picked up.
if($validation === 0) {
$bid->save();
\Drupal::messenger()->addMessage($this->t('Your bid was
successfully submitted.'));
} else {
\Drupal::messenger()->addWarning($violations[0]->getMessage());
}
}
This is pretty neat. We made sure our entities will be complete on save with custom
constraint validators.
💻 You can check if the constraints works like expected if you comment out the
validateForm() and the ‘#attributes’ and ‘#required’ key in $form[‘bid’] inside the
178
OfferBiddingForm.php
In a way, this is adding extra security towards malicious input on your database. In
addition to server-side validation in our form, we added constraints to saving our entity.
The Entity validation API can also be used to do all kinds of other checks. For example
to check if the offer is still published when the form gets submitted.
👉 This chapter will teach you how to use render arrays for displaying processed
data. At the end you will know how to add tables or other ways to display items
to a block. By using the RequestStack service, you will be able to receive data of
the current entity in your block.
179
We need an overview of previously done bids to the offer. In this section we’ll be
rendering a drupal table render array. The advantage of using these render arrays is
that their layout will be consistent over all themes. Therefore, I like using them.
Because we’ll be doing quite some preprocessing on the bids, I add an extra class for
properly returning my data. This way we keep things a bit more readable.
<?php
namespace Drupal\offer\OfferPreprocess;
use Drupal\bid\Entity\Bid;
use Drupal\offer\Entity\Offer;
class OfferPreprocess {
/**
* Returns rendered table below an offer
* @param entity $offer
* The offer entity
* @return array $table
* Drupal table render array
*/
public static function offerTable(Offer $offer) {
$bids = $offer->getOfferBids();
$rows = [];
foreach($bids as $bid) {
$price = $bid->get('bid')->getString();
$owner = $bid->getOwner();
$ownerName = $owner->getDisplayName();
$time =
\Drupal::service('date.formatter')->formatTimeDiffSince($bid->created->v
alue);
$row = [
$ownerName . ' - ' . $time . ' ago', $price . '$'
];
$rows[] = $row;
}
180
$build['table'] = [
'#type' => 'table',
'#rows' => $rows,
'#empty' => t('This offer has no bids yet. Grab your chance!'),
];
return [
'#type' => '#markup',
'#markup' => render($build)
];
}
Next, in order to show our table with the latest offers we add it to a new block inside
custom/offer/src/Plugin/Block/OfferBiddingTableBlock.php:
<?php
namespace Drupal\offer\Plugin\Block;
use Drupal\Core\Block\BlockBase;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\offer\OfferPreprocess\OfferPreprocess;
/**
* @Block(
* id = "offer_bidding_table_block",
* admin_label = @Translation("Bidding table block"),
* category = @Translation("Shows the bidding table to an offer"),
* )
*/
/**
* The request object.
*
* @var \Symfony\Component\HttpFoundation\RequestStack
*/
protected $requestStack;
181
/**
* The entity storage.
*
* @var \Drupal\Core\Entity\EntityStorageInterface
*/
protected $entityStorage;
/**
* Constructs a new OfferBiddingTableBlock instance.
*
* @param string $plugin_id
* The plugin_id for the plugin instance.
* @param mixed $plugin_definition
* The plugin implementation definition.
* @param \Symfony\Component\HttpFoundation\RequestStack $request_stack
* The request stack object.
* @param \Drupal\Core\Entity\EntityStorageInterface $entity_storage
* The entity storage.
*/
public function __construct(array $configuration, $plugin_id,
$plugin_definition, RequestStack $request_stack, EntityStorageInterface
$entity_storage) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->requestStack = $request_stack;
$this->entityStorage = $entity_storage;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array
$configuration, $plugin_id, $plugin_definition) {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('request_stack'),
$container->get('entity_type.manager')->getStorage('offer')
);
}
/**
* {@inheritdoc}
182
*/
public function build() {
$offer = $this->requestStack->getCurrentRequest()->get('offer');
if(!$offer) {
return null;
}
$bid_table = OfferPreprocess::offerTable($offer);
return $bid_table;
}
/**
* Never cache this block (for now)
*/
public function getCacheMaxAge() {
return 0;
}
We get the table from our OfferPreprocess class and render it in the same way we
did with our form: by making use of the request stack services to get the current
offer.
💻 Clear caches and add the block to the bottom of the content region at
/admin/structure/blocks. Restrict for authenticated users and set page
visibility only at /offer/* like we did for the bidding form.
183
Update the custom/offer/css/platform.css file to give the block a width:
184
You’ll now see a nicely rendered table with the bids. We will add a nice looking profile
picture and some layout later on.
👉 In this important chapter we integrate the core revisioning system into our
platform. This allows us to modify our entities while keeping a history of
changes.
When a user raises his bid we will not save this as a new entity but as a revision of
his current bid.
This allows us to nicely show a “user x raised his bid with 4$” message to the table.
It also adds a variety of options when our software platforms grows larger. Because
ultimately, a raising of a bid is a revision of a current bid and not a new bid.
First, we need a new method to check if the offer the user is looking at already has
bids from him/her. To custom/modules/offer/src/Entity/Offer.php we add a method
for it:
/**
* Checks if the current user has bids on the current offer
* @return bool
* True if it has, false if it doesn't
*/
public function CurrentUserHasBids() {
$user_id = \Drupal::currentUser()->id();
$id = $this->id();
185
$query = \Drupal::entityQuery('bid')
->condition('offer_id', $id)
->condition('user_id', $user_id);
$count = $query->count()->execute();
if($count > 0) {
return true;
} else {
return false;
}
}
Now, in our form we change the label of the submit button dependent on the
previous function to from “Submit” to “Raise my bid”
Once again we need a helper method on the offer entity to get the bid entity from the
current user. To custom/modules/offer/src/Entity/Offer.php we add:
/**
* Returns the current users bid on the offer
* @return Drupal\bid\Entity\Bid Bid
* The offer entity
*/
function currentUserBid() {
$user_id = \Drupal::currentUser()->id();
$id = $this->id();
186
$query = \Drupal::entityQuery('bid')
->condition('offer_id', $id)
->condition('user_id', $user_id);
$result = $query->execute();
$bidId = reset($result);
$bid = Bid::load($bidId);
return $bid;
}
/**
* Returns list of revision entity ids of the bid. Key is the revision
ID.
* @return array
187
*/
public function getRevisionsList() {
$id = $this->id();
$query = \Drupal::entityQuery('bid')
->condition('id', $id);
$revisions = $query->allRevisions()->execute();
return $revisions;
}
Drupal cores revision system does a great deal of the heavy lifting. We will now add a
‘raise’ icon to our bid table when a bid has been raised. Another possible feature, for
example, is to add actions like sending emails to all other bidders based on this.
To include revision information to our bid table, we’ll add some helper methods to
our entities first. To our custom/bid/src/Entity/Bid.php, we add two methods. The
first is to track if a bid has revisions, the second is a list of all the revision ids:
/**
* Checks if the bid has revisions
* @return bool
* True if it has, false if it does not
*/
public function hasRevisions() {
$id = $this->id();
$query = \Drupal::entityQuery('bid')
->condition('id', $id);
$count = $query->allRevisions()->count()->execute();
if($count > 1) {
return true;
} else {
return false;
}
}
/**
* Returns list of revision entity ids of the bid. Key is the revision
ID.
* @return array
*/
public function getRevisionsList() {
$id = $this->id();
$query = \Drupal::entityQuery('bid')
->condition('id', $id);
$revisions = $query->allRevisions()->execute();
188
return $revisions;
}
/**
* Returns rendered table below an offer
* @param entity $offer
* The offer entity
* @return array $table
* Drupal table render array
*/
public static function offerTable(Offer $offer) {
$bids = $offer->getOfferBids();
$rows = [];
foreach($bids as $bid) {
$price = $bid->get('bid')->getString();
$owner = $bid->getOwner();
$ownerName = $owner->getDisplayName();
$time =
\Drupal::service('date.formatter')->formatTimeDiffSince($bid->created->v
alue);
$updates = '';
$link = '';
if($bid->hasRevisions()) {
$revisions = $bid->getRevisionsList();
// We now have the list of revisions.
// Let's compare the latest bid with the last revision
$current_revision_id = $bid->getLoadedRevisionId();
// We now know the current, we want the one before the current
// We remove the current from the revisions list
unset($revisions[$current_revision_id]);
// And take the last one from the revisions list
$last_revision_id = max(array_keys($revisions));
$revisionBid = \Drupal::entityTypeManager()
->getStorage('bid')
189
->loadRevision($last_revision_id);
$revisionAmount = $revisionBid->get('bid')->getString();
$priceDifference = $price - $revisionAmount;
$updates = '<svg width="24px" height="18px" viewBox="0 0 24 24"
fill="#61f70a" xmlns="http://www.w3.org/2000/svg">
<path d="M6.1018 16.9814C5.02785 16.9814 4.45387 15.7165 5.16108
14.9083L10.6829 8.59762C11.3801 7.80079 12.6197 7.80079 13.3169
8.59762L18.8388 14.9083C19.5459 15.7165 18.972 16.9814 17.898
16.9814H6.1018Z" fill="#61f70a"/>
</svg><small style="color:#0444C4">Last raise was ' .
$priceDifference .'$</small>';
}
$row = [
Markup::create($ownerName . ' - ' . $time . ' ago'),
Markup::create($price . '$' . $updates)
];
$rows[] = $row;
}
$build['table'] = [
'#type' => 'table',
'#rows' => $rows,
'#empty' => t('This offer has no bids yet. Grab your chance!'),
];
return [
'#type' => '#markup',
'#markup' => render($build)
];
}
The result of all this is that in the bid table a notice appears that indicates that the
bid was raised.
190
Revisions are complete entities stored aside from our bid entities. We now took
advantage of this quite complex system to store differences, like a raise of a bid.
Think about the possibilities of this. You could show a detailed table overview or
graphic of the bidding history.
👉 In this chapter we explore the drupal API system further. First, we will create a
delete form to make it possible to delete your own bids. Second, we will open
the form with a core dialog pop-up, to make sure you stay on the page.
Users need to have the possibility to delete a bid when they want to. Again, we use
the standard entity behaviours for the deletion. To custom/bid/src/Entity/bid.php, we
add a delete link:
* handlers = {
* "access" = "Drupal\bid\BidAccessControlHandler",
* "form" = {
* "delete" = "Drupal\bid\Form\BidDeleteForm",
* },
* },
* links = {
* "delete-form" = "/bid/{bid}/delete",
* },
entity.bid.delete_form:
path: '/bid/{bid}/delete'
defaults:
_entity_form: bid.delete
_title: 'Delete bid'
requirements:
_entity_access: 'bid.delete'
191
<?php
/**
* @file
* Contains \Drupal\bid\Form\BidDeleteForm.
*/
namespace Drupal\bid\Form;
use Drupal\Core\Entity\ContentEntityConfirmFormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Url;
use Drupal\offer\Entity\Offer;
/**
* Provides a form for deleting a content_entity_example entity.
*
* @ingroup bid
*/
class BidDeleteForm extends ContentEntityConfirmFormBase {
/**
* {@inheritdoc}
*/
public function getQuestion() {
return $this->t('Are you sure you want to delete your bid of
%price$?', array('%price' => $this->entity->get('bid')->getString()));
}
/**
* {@inheritdoc}
*
* If the delete command is canceled, return to the bid list.
*/
public function getCancelUrl() {
$offer_id = $this->entity->get('offer_id')->getString();
$url = new Url('entity.offer.canonical', ['offer' => $offer_id]);
return $url;
}
/**
* {@inheritdoc}
*/
public function getConfirmText() {
return $this->t('Delete');
192
}
/**
* {@inheritdoc}
*
* Delete the entity and log the event. logger() replaces the watchdog.
*/
public function submitForm(array &$form, FormStateInterface
$form_state) {
// Redirect to offer after delete.
$offer_id = $this->entity->get('offer_id')->getString();
$entity = $this->getEntity();
$entity->delete();
This is a standard entity delete form with the exception of the getCancelUrl() and
SubmitForm() methods. There we needed to get the offer ID from the bid to generate
a url to redirect to after deletion or cancel action.
<?php
namespace Drupal\bid;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Entity\EntityAccessControlHandler;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Session\AccountInterface;
/**
* Access controller for the bid entity. Controls create/edit/delete
access for entity and fields.
193
*
* @see \Drupal\bid\Entity\Bid.
*/
class BidAccessControlHandler extends EntityAccessControlHandler {
return $access;
}
}
?>
This code determines if I am the owner of the bid. This is an important security check!
The final part of this section is to provide a delete link to the bid when a user is the
owner of the bid.
194
if($bid->access('delete')) {
$url = $bid->toUrl('delete-form');
$link = Link::fromTextAndUrl('Remove bid', $url)->toString();
}
$row = [
Markup::create($ownerName), Markup::create($time.' ago'),
Markup::create($price . '$' . $updates), Markup::create($link)
];
$rows[] = $row;
We clear caches and now we have a delete link on our own bids:
When we click the link, we get redirected to a delete form with cancel and delete link.
We took advantage of the core entity delete form to do this. No need to to access
checks on the form, Drupal core provides us these by default thanks to the Entity API!
195
The confirm page is quite nice. But I prefer to stay on the same page when deleting a
bid. Drupal core offers a nice option to do this: an ajax dialog to render the very same
form in. Let’s see how this works.
We only need to add extra attributes to the delete link. Therefore we’ll use a link
render array. We’ve already used drupal core’s render arrays for tables and drop
buttons. The link render array offers possibilities to add attributes we’ll need to
ajaxify dialogs. We change the offerTable() part where we define the delete link:
if($bid->access('delete')) {
$url = $bid->toUrl('delete-form');
$deleteLink = [
'#type' => 'link',
'#title' => 'Remove bid',
'#url' => $url,
'#attributes' => [
'class' => ['use-ajax', 'button', 'button--small',
'button--danger'],
'data-dialog-type' => 'modal',
'data-dialog-options' =>
\Drupal\Component\Serialization\Json::encode(['title' => t('Remove
bid?'), 'width' => 800,]),
],
];
$link = render($deleteLink);
}
One last thing is that we need to make sure the core drupal ajax wrappers are loaded
as well. To offer/offer.libraries.yml we must add the following dependencies to our
platform library:
platform:
css:
theme:
css/platform.css: {}
dependencies:
- core/drupal.ajax
- core/jquery
- core/drupal
Now clear caches..When we now click ‘Remove bid’, we stay on the same page and
get this very nicely looking dialog:
196
Again a nice example of where drupal does the heavy lifting for us. The dialogs
underlying architecture can change in the future, but will still work because we make
use of the API.
197
Part 5: Transitions, Events, Caching and user
registration
👉 Now that we’ve built the main parts of our software we have the chance to start
with automated tasks like sending emails and the expiration of a bid. But we’ll
also discuss caching and how to handle user registration.
I’ve quickly added a third code-only entity named “notification” in a new module. It
stores these values:
Key Description
I’ve also added a handler for access and for views. This way we can add a views
page at url /notifications, to show the notifications for this user.
💻 If you are following along, copy the notification module into your project and
install. Building an entity is something that was covered already!
198
We add a hook_entityType_update() function to custom/offer/offer.module:
use Drupal\notification\Entity\Notification;
/**
* Implements hook_ENTITY_TYPE_update().
*/
function offer_offer_update(Drupal\Core\Entity\EntityInterface
$entity) {
What this snippet does is the following: it checks if the offer goes from a ‘published’
to a ‘expired’ state. Then it will loop over all the bids on the offer, and create
notification entities for every bidder.
Next I’ve added view support for notification entities, just like we did for our offer
entities.
I’ve added a view and a menu-link for the /notifications page, which will show the
notifications, filtered on the current user.
199
💻 If you have installed the module from the full project code, the view should be
created on install. This is because the config of this view was added via
custom/notification/config/install/views.view.notifications.yml
// on top:
use Drupal\offer\Entity\Offer;
use Drupal\Core\Url;
use Drupal\Core\Link;
This result in the following notification for the bids that I did:
200
We’ve now shown how to create notifications based on workflow states. It’s not hard
to imagine the following possible features with this:
● Send email when someone bids higher than your bid
● Send email to all bidders when offer has expired
● Send email when new offers are available
● ….
offer.account.notifications:
title: 'Notifications'
menu_name: account
route_name: view.notifications.page_1
weight: -40
👉 In this chapter you will learn how to update a view with a ajax callback. At the
end you will be able to delete/insert/update records, reload a view without
leaving your page.
Ajax is a great technology to refresh parts of a page without actually refreshing the
full page. In general this enhances user experience. The difference between just
using javascript is a server-side callback is included in the call where actual
database operations can be done.
201
For this project, we want to be able to delete notifications in the notifications view. To
start, we create a controller which will be used to delete a notification. This is a
regular controller, but instead of visiting it, only our ajax call will be visiting the class.
notification.delete:
path: '/notification/delete/{method}/{id}'
defaults:
_controller:
'\Drupal\notification\Controller\NotificationDeleteController::Render'
_title: 'Delete notification?'
requirements:
_access: 'TRUE'
id: '\d+'
method: 'nojs|ajax'
Check out the {method} slug. This can be either ‘nojs’ or ‘ajax’. In the actual link, we
will use ‘nojs’. Drupal will use ajax if it has detected javascript is enabled. This is
good for accessibility. Further, we use a regex for id to be sure it is numeric. Access
checks are done in the controller itself.
To custom/notification/src/Controller/NotificationDeleteController.php:
<?php
namespace Drupal\notification\Controller;
use Drupal\Core\Controller\ControllerBase;
use Drupal\notification\Ajax\DeleteNotificationCommand;
use Drupal\Core\Ajax\AjaxResponse;
use Drupal\notification\Entity\Notification;
use Drupal\Core\Access\AccessResult;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Drupal\Core\Url;
/**
* Class NotificationDeleteController.php.
*/
class NotificationDeleteController extends ControllerBase {
202
$notification = Notification::load($id);
if($method == 'ajax') {
$response = new AjaxResponse();
$response->addCommand(new DeleteNotificationCommand());
} else {
// no javascript: send back to page
$path = Url::fromRoute('view.notifications.page_1');
$response = new RedirectResponse($path);
$response->send();
}
return $response;
}
What stands out is that in this controller the notification gets deleted (if owner) and
our response is an AjaxResponse(). In fact a “deleteNotificationCommand”, which is
a custom one. If javascript is disabled, it sends back a redirect to the view.
<?php
namespace Drupal\notification\Ajax;
use Drupal\Core\Ajax\CommandInterface;
// Implements Drupal\Core\Ajax\CommandInterface:render().
public function render() {
203
return array(
'command' => 'DeleteNotification',
'selector' => $this->selector,
);
}
}
With this Command, the system knows what to send to javascript. Not that it is also
possible to send data in an extra key. If we would need this, we would call New
DeleteNoficiationCommand($extra) in NotificationDeleteController.php. And then
add ‘extra’ next to ‘command’ and ‘selector’ in our DeleteNotificationCommand. In
the javascript file we’re about to add, the ‘extra’ would be available in the response
argument. But we’ll do another handy trick: refreshing the view after deletion.
( function ($) {
})(jQuery);
This was the final bit. The DeleteNotification javascript function gets called in the
front-end to make a visible change to the users page. Here, after a successful
204
deletion call, we refresh the view using a built-in trigger. Make sure the ‘use ajax’
option is set to ‘ON’ in your view settings.
/**
* {@inheritdoc}
*/
public function render(ResultRow $values) {
$entity = $values->_entity;
$type = $entity->get('type')->getString();
$offer_id = $entity->get('offer_id')->getString();
$offer = Offer::load($offer_id);
if($type == 'expired') {
$url = Url::fromRoute('entity.offer.canonical', array('offer' =>
$offer->id()));
$link = Link::fromTextAndUrl($offer->label(), $url)->toRenderable();
$text = 'Offer '. render($link) .' has expired.';
// Add delete link for removing notifications
$deleteUrl = Url::fromRoute('notification.delete', ['method' =>
'nojs', 'id' => $entity->id()]);
$deleteLink = Link::fromTextAndUrl('Remove',
$deleteUrl)->toRenderable();
$deleteLink['#attributes'] = ['class' => 'use-ajax'];
$deleteText = render($deleteLink);
$output = $text . ' ' . $deleteText;
}
return [
'#children' => $output
];
}
Note we just have to add the ‘use-ajax’ class to trigger the ajax call. We’re done!
205
If we now remove a notification, our view gets updated automatically while staying in
the page.
👉 In this chapter we will teach how you can remove entities that are linked to other
entities using the preDelete() functions on the entity.
We should think through all of the necessary use-cases of our software: what
happens to bids when somebody deletes his offer? This is a relevant question. The
following entities rely on an offer:
● The bids to the offer
● The notifications about the offer
We must make sure these are deleted as well, when deleting an offer.
/**
* {@inheritdoc}
*/
public function delete() {
if (!$this->isNew()) {
$this->entityTypeManager()->getStorage($this->entityTypeId)->delete([$th
is->id() => $this]);
}
}
206
On line 463 we find this function:
/**
* {@inheritdoc}
*/
public static function preDelete(EntityStorageInterface $storage, array
$entities) {
}
An empty function that gets called right before deletion of an entity. This is the guy
we need! Let’s add this one to our custom/offer/src/Entity/Offer.php, below the
preCreate() function:
/**
* {@inheritdoc}
*/
public static function preDelete(EntityStorageInterface $storage, array
$entities) {
parent::preDelete($storage, $entities);
// Delete all bids and notifications of the offer that will be deleted
foreach ($entities as $entity) {
$entity->deleteAllLinkedBids();
$entity->deleteAllLinkedNotifications();
}
}
/**
* Deletes all bids linked to the offer.
* @param bool $delete
* @throws \Drupal\Core\Entity\EntityStorageException
*/
public function deleteAllLinkedBids($delete = FALSE) {
$id = $this->id();
$query = \Drupal::entityQuery('bid')
->condition('offer_id', $id);
$bidIds = $query->execute();
foreach($bidIds as $id) {
$bid = Bid::load($id);
$bid->delete();
}
}
207
/**
* Deletes all notifications linked to the offer.
* @param bool $delete
* @throws \Drupal\Core\Entity\EntityStorageException
*/
public function deleteAllLinkedNotifications($delete = FALSE) {
$id = $this->id();
$query = \Drupal::entityQuery('notification')
->condition('offer_id', $id);
$notificationIds = $query->execute();
foreach($notificationIds as $id) {
$notification = Notification::load($id);
$notification->delete();
}
}
Now, when a user deletes an offer in the UI, all linked dataparts get removed as well.
This keeps our database clean and prevents orphan entities which could lead to errors.
A good example of the power of Object Oriented programming (OO): we inherited the
preDelete() function and use it in our offer entities. Make sure you take a look at all
the other methods that can be used in EntityBase as well.
As an extra we could add a new notification type to inform the user that an offer he
had done a bid on was deleted, but this would lead us too far away for this course.
Caching in-depth
👉 In this important chapter we dig deeper on the powerful drupal caching API.
After this chapter you will be able to implement complex caching mechanisms
on entities, views and controllers.
Caching is an important aspect that was improved drastically when the drupal
community built drupal 8 from scratch (it was a complete rewrite of drupal 7). More
specifically it added great control over which portions of your page you would like to
have cached and how.
208
Cache API What Example Example code
types
👉 This short and theoretical chapter explains how caching works on view listings.
For our offering platform, let’s start with the /offer page. This is a views page. It has
caching support in the settings. On the right bottom corner of
/admin/structure/views/view/offer:
209
Set “Caching” to “Tag based”. Because this view only looks for entities of type ‘offer’,
it will automatically invalidate when new offers are added.
💻 I can check this in the “headers” tab of the network profile in my developer tools:
So, the first time this views page gets rendered it goes in the cache until a new
offer is added. This way I’m sure the page will be loaded very quickly.
210
👉 In this chapter we explore the caching mechanisms behind the detail page of an
entity.
Great news for our custom entity detail pages. Drupal 8 has an automatic caching
system by default that is pretty amazing, which makes it possible to provide caching
without any configuration.
But be sure to check this. It is why in an earlier chapter I advised to keep forms in
blocks instead of trying to render them IN your entity. If I had added the form in my
preprocess() functions I would have got this header:
http.response.debug_cacheability_headers: true
Refreshing the page resulted in the following response headers (with my form inside
my entity):
The nice part about this is we get more information about all the cache contexts and
cache tags of our page. The drupal entity cache should work by default so when I use
the block to show my form I get the desired result:
211
I have my cached entity again. This works pretty smart. Any update on the entity will
clear the caches for it. For our form and bidding table, we need to use more
advanced caching techniques like contexts and cache tags.
Caching was a pain-point in drupal 7, in drupal 8 and later it is definitely one of the
strengths!
Use of cache contexts and cache tags for caching custom blocks
👉 This chapter digs deeper on cache tags and how to invalidate caches on a
custom block, when something happens like an entity update.
How can we properly cache our bid table for our users? We want two things:
● A table cached uniquely for our page (i.e. based on the entity we are looking
at)
● A table that gets updated when a new bid is added
To make it uniquely for our page, remove the max-age:0 based caching that was
currently set in custom/offer/src/Plugin/Block/OfferBiddingTableBlock.php and
add:
/**
* Cache per page
*/
public function getCacheContexts() {
return ['url.path'];
}
This way we are sure we cache a bidding table that is different per page. But of
course we need to have it changed once there are new bids.
212
/**
* Invalidate caches when there are new bids
*/
public function getCacheTags() {
$offer = $this->requestStack->getCurrentRequest()->get('offer');
$offerId = $offer->id();
return Cache::mergeTags(parent::getCacheTags(),
array('offer:'.$offerId));
}
While this will update the table when our offer entity gets updated, this won’t update
the table when there is a new bid, yet.
We add an invalidateTags() command on the offer whenever there is a new bid on the
offer. This way we have two nice advantages:
● We automatically update offer teasers to show an update on the amount of
bids
● We automatically invalidate caches of the OfferBiddingTableBlock, to show
the latest bids
if($validation === 0) {
$bid->save();
Cache::invalidateTags($offer->getCacheTags());
\Drupal::messenger()->addMessage($this->t('Your bid was successfully
submitted.'));
} else {
\Drupal::messenger()->addWarning($violations[0]->getMessage());
}
With rather easy commands, we are now able to have custom-tailored caching
working!
213
Use of cacheable dependencies in render arrays
👉 This chapter will expand your knowledge on caching a little more. More
specifically Cacheable Dependencies. With this API we can add caching
dependent of certain situations.
To make dealing with cacheability metadata (cache tags, cache contexts and
max-age) easier, Drupal 8 has CacheableDependencyInterface. It is implemented by
a majority of objects you interact with while writing Drupal 8 code!
Because not only controllers or blocks need to have caching, also access results,
menu links, context plugins, condition plugins, and so on.
$access = AccessResult::allowedIf($account->id() ==
$entity->getOwnerId())->cachePerUser()->addCacheableDependency($en
tity);
This means that access to the entity gets cached for each user and this will be
invalidated when the entity changes. That’s why we get access to an offer as soon its
workflow state changes from draft to published.
We’ll use this in the following example. Remember our form with global configuration
from the chapter on configuration management? Let’s extend it with a textarea field.
We want to create a “About” page with the content of that textarea field. I only want
the caches to invalidate when the content of the textarea (which is config) is
changed.
$form['content'] = array(
'#type' => 'details',
'#title' => $this->t('Content'),
'#open' => TRUE,
);
$form['content']['about'] = [
'#type' => 'text_format',
'#title' => $this->t('About page'),
214
'#default_value' => $config->get('about'),
'#maxlength' => NULL,
];
$this->config('offer.customconfig')
->set('tagmanager', $form_state->getValue('tagmanager'))
->set('about', $form_state->getValue('about')['value'])
->save();
Notice the use of ‘text_format’ instead of textarea. This makes my textarea render
with a ckeditor wysiwyg overlay at /admin/config/offer/adminsettings. I add some
text to it and save:
offer.about:
path: '/about'
defaults:
_controller: '\Drupal\offer\Controller\AboutPageController::Render'
title: 'About us'
requirements:
_access: 'TRUE'
215
The page will be accessible for all users, hence the _access: ‘True’ parameter. Next, I
add custom/offer/src/Controller/AboutPageController.php:
<?php
namespace Drupal\offer\Controller;
use Drupal\Core\Controller\ControllerBase;
/**
* Class AboutPageController
*/
class AboutPageController extends ControllerBase {
$build = [
'#markup' => $config->get('about'),
];
$renderer = \Drupal::service('renderer');
$renderer->addCacheableDependency($build, $config);
return $build;
}
}
offer.main.about:
title: 'About'
menu_name: main
route_name: offer.about
weight: 80
Clear caches and visit your page. Like always, the very first time this page ever gets
visited it goes into the cache. Next time, everyone gets a correctly caches page:
But when I add a new line of text to the About configuration textarea and save:
216
Very nice. Again we controlled precisely when and where the caches needed to be
emptied.
💻 We’ve now learned that we can use cacheable dependencies for every
renderable object we use in our drupal installation.
👉 This chapter is about how to add caching mechanisms to plugins. At the end
you will be able to provide your system with a menu item with a counter, that is
cached the way you want it.
Remember the section about custom menu links. We added a menu link with a
counter, to show the amount of offers this user has. This is a query for this user only.
One strategy could be to never cache this. But doing a custom query on every page
load is not good practice.
This is where custom cache tags come into play. Let’s head back to the
custom/offer/src/Plugin/Menu/MyOffers.php and we remove the
getCacheMaxAge() function. Instead we add:
/**
* {@inheritdoc}
*/
public function getCacheTags() {
return Cache::mergeTags(parent::getCacheTags(),
array('my_offers_user_'. \Drupal::currentUser()->id()));
}
This is a unique cache tag for this user only. The menu item will only get queried
again when this specific cache tag gets invalidated.
217
We must make sure to invalidate this whenever:
A) A new offer is added
B) A new offer is deleted
/**
* {@inheritdoc}
*/
public function postCreate(EntityStorageInterface $storage) {
Cache::invalidateTags(['my_offers_user_'. $this->getOwnerId()]);
}
/**
* {@inheritdoc}
*/
public static function postDelete(EntityStorageInterface $storage,
array $entities) {
parent::preDelete($storage, $entities);
That’s it! Whenever an offer is added or deleted, the custom cache tag for this user
gets invalidated. Again, another example of the power of Object oriented coding and
the great caching system of drupal.
218
linked entities.
On building the platform, I stumbled upon the following. Whenever I added bids to an
offer, the teaser kept saying ‘0 offers so far’. This makes sense, because my offer did
not know about new bids. Bids are just entities being stored in its own table.
To solve this issue I inherit the postSave() and postDelete() method from
ContentEntityBase() to my bid entity. To custom/offer/src/Entity/bid.php add the
following:
/**
* {@inheritdoc}
*/
public function postSave(EntityStorageInterface $storage,
$update = TRUE) {
parent::postSave($storage, $update);
$offer = Offer::load($this->get('offer_id')->target_id);
Cache::invalidateTags($offer->getCacheTagsToInvalidate());
}
/**
* {@inheritdoc}
*/
public static function postDelete(EntityStorageInterface
$storage, array $entities) {
parent::preDelete($storage, $entities);
// Invalidate all caches of offers whenever bids are deleted
foreach ($entities as $entity) {
$offer = Offer::load($entity->get('offer_id')->target_id);
if($offer) {
Cache::invalidateTags($offer->getCacheTagsToInvalidate());
}
}
}
219
With these changes, after a bid gets saved or deleted it will invalidate the caches of
the offer the bid is for. This way, our offer teaser gets updated wherever it is visible in
the system whether it is on the entity detail page or in a views listing.
We got an insight into the various possibilities of caching in drupal 9. I dare to say the
caching system is one of the most advanced out there. Once you get comfortable, it is
set up very rapidly.
👉 In this chapter more about the Event system in drupal. Moreover you get an
insight into how to add an EventSubscriber and how to dispatch the event. At the
end you will be able to define and catch certain events and attach actions to
them.
There are still a few things left unfinished about our software. Our homepage still
redirects to our users page. We set the standard homepage to our offer page at
/admin/config/system/site-information. Fill in “/offer” at the default front page
textfield. This way, we get the overview with the offers as our frontpage.
We’ve told earlier that drupal is moving away (slowly) from the hook system. We will
learn how to redirect users on login with an EventSubscriber.
Symfony triggers several events related to the kernel while processing the HTTP
Request. Third-party bundles may also dispatch events, and you can even dispatch
custom events from your own code.
For dispatching own events, we have to add an Event and an EventSubscriber service
to our module.
<?php
220
namespace Drupal\offer\Event;
use Drupal\user\UserInterface;
use Symfony\Contracts\EventDispatcher\Event;
/**
* Event fired when a user logs in.
*/
class UserLoginEvent extends Event {
/**
* The user account.
*
* @var \Drupal\user\UserInterface
*/
public $account;
/**
* Constructs the object.
*
* @param \Drupal\user\UserInterface $account
* The account of the user logged in.
*/
public function __construct(UserInterface $account) {
$this->account = $account;
}
This is simply telling our system this event exists (in the same way we added files to
the Ajax directory). Next we have to add a ‘subscriber’ which handles the aftermath
of when the event is triggered, like a redirection or a log:
services:
offer_login.event_subscriber:
class: Drupal\offer\EventSubscriber\UserLoginSubscriber
arguments: [ '@path.matcher', '@current_user' ]
tags:
- { name: event_subscriber }
221
To a new folder we add
custom/offer/src/EventSubscriber/UserLoginSubscriber.php:
<?php
namespace Drupal\offer\EventSubscriber;
use Drupal\offer\Event\UserLoginEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
/**
* Class UserLoginSubscriber.
*
* @package Drupal\custom_events\EventSubscriber
*/
class UserLoginSubscriber implements EventSubscriberInterface
{
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents()
{
return [
UserLoginEvent::EVENT_NAME => ['onUserLogin', 29]
];
/**
* Subscribe to the user login event dispatched.
*
* @param \Drupal\custom_events\Event\UserLoginEvent $event
* Dat event object yo.
*/
public function onUserLogin(UserLoginEvent $event)
{
$username = \Drupal::currentUser()->getDisplayName();
\Drupal::messenger()->addStatus(t('Welcome %name, happy bidding!', [
'%name' => $username,
]));
$response = new RedirectResponse("/");
$response->send();
}
222
}
An event subscriber is a PHP class that’s able to tell the dispatcher exactly which
events it should subscribe to. It implements the
Symfony\Component\EventDispatcher\EventSubscriberInterface interface, which
requires a single static method called getSubscribedEvents() which shows the
priority of each listener method. The higher the number, the earlier the method is
called. Note that we added a higher number than dynamic page cache, otherwise the
system could miss the event!
The last thing we need to do is telling the system when to dispatch the event. In our
case it is when a user logs in. Therefore we hook into the user login form in
custom/offer/offer.module:
/*
* Implements hook_user_login()
*/
function offer_user_login($account) {
// Instantiate our event.
$event = new UserLoginEvent($account);
// Get the event_dispatcher service and dispatch the event to fire the
event.
$event_dispatcher = \Drupal::service('event_dispatcher');
$event_dispatcher->dispatch(UserLoginEvent::EVENT_NAME, $event);
}
Make sure to clear caches the first time to make sure everything is registered
correctly. Next time you login, we get redirected to the frontpage and get a
welcoming message:
We learned to create custom Events and how to subscribe to them. The nice thing
about custom events is that we can fire them whenever we would like to. In this case
an event on logging in, but we could also fire logging events or e-mail events on
multiple occasions. A very powerful feature of drupal 9, with as special thanks to
symfony!
223
Customize the user registration process with a
RouteSubscriber
With all the things we learned so far, it is interesting to take a look at the core user
module. You’ll find out that users are nothing more than an entity. The core
registration, login and password forms are entity forms with a bit of custom logic
attached.
224
This form is not exactly what we wanted. We’d like a registration with name, e-mail
address and password only. I want to on-board users more rapidly and worry about
verification later. How do we change this?
Using a typical drupal hook hook_form_alter() could do the job, but is tricky in my
opinion because we do not have control on how the default form will evolve in the
future. Well, it’s not that tricky I guess but I prefer to keep control in code. Thus, a
safer option would be to show a different form on this page.
225
Drupal core combines User name and password in one form field, and what we want
is a form that asks first for an e-mail and then automatically offers access to the
platform. We want to grant a 1-hour access before the user has to validate it’s
address.
<?php
namespace Drupal\offer\Form;
226
use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\user\Entity\User;
use Drupal\Component\Utility\Xss;
/**
* @return string
* The unique string identifying the form.
*/
public function getFormId() {
return 'offer_registration_form';
}
/**
* Form constructor.
*
* @param array $form
* An associative array containing the structure of the form.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
* @param \Drupal\offer\Entity\Offer $offer
* The offer entity we're viewing
*
* @return array
* The form structure.
*/
public function buildForm(array $form, FormStateInterface $form_state,
$offer = NULL) {
$form['email'] = [
'#type' => 'email',
'#attributes' => array(
' type' => 'email', // note the space before attribute name
),
'#title' => $this->t('Your email address'),
'#required' => TRUE,
];
$form['username'] = [
'#type' => 'textfield',
'#attributes' => array(
' minlength' => 2, // note the space before attribute name
227
),
'#title' => $this->t('Your name'),
'#required' => TRUE,
];
$form['password'] = [
'#type' => 'password',
'#attributes' => array(
' type' => 'password', // note the space before attribute name
' minlength' => 8
),
'#title' => $this->t('Your password'),
'#description' => $this->t('Should be minimum 8 characters.'),
'#required' => TRUE,
];
$form['actions']['submit'] = [
'#type' => 'submit',
'#value' => $this->t('Register'),
];
return $form;
/**
* Validate the input values of the form
*
* @param array $form
* @param \Drupal\Core\Form\FormStateInterface $form_state
*
*/
public function validateForm(array &$form, FormStateInterface
$form_state)
{
parent::validateForm($form, $form_state);
228
// Server side validation for email
if
(!\Drupal::service('email.validator')->isValid($form_state->getValues()[
'email'])) {
$form_state->setErrorByName('Email', $this->t('Email address is
not a valid.'));
}
/**
* Form submission handler.
*
* @param array $form
* An associative array containing the structure of the form.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
*/
public function submitForm(array &$form, FormStateInterface
$form_state) {
229
$user = User::create();
$user->enforceIsNew();
$user->setEmail($form_state->getValues()['email']);
$user->setUsername($form_state->getValues()['email']); //This
username must be unique and accept only a-Z,0-9, - _ @ .
$user->setPassword(Xss::filter($form_state->getValues()['password']));
$user->activate();
$user->save();
user_login_finalize($user); // logs a new session etc.
// This will redirect with UserLoginEvent
}
This form asks for a name, mailing address and password and then creates the user
and automatically logs the user in.
Note: on a production website we would have to verify the email of the user and
probably add a reCaptcha spam detection. We will skip this in this course.
But how do we get this form at the core /user/register place? We can dispatch a
RouteSubscriber event for this. It allows custom code to listen for dynamic routing
events.
Any route - whether statically defined in a YAML file, as seen in the introductory
example, or a dynamic route as described in Providing dynamic routes - can be
altered. You can do so by modifying a RouteCollection using an EventSubscriber
triggered by the RoutingEvents::ALTER event.
230
<?php
namespace Drupal\offer\Routing;
use Drupal\Core\Routing\RouteSubscriberBase;
use Symfony\Component\Routing\RouteCollection;
/**
* Listens to the dynamic route events.
*/
class RouteSubscriber extends RouteSubscriberBase {
/**
* {@inheritdoc}
*/
protected function alterRoutes(RouteCollection $collection) {
$entityUserRegisterFormRoute = $collection->get('user.register');
if ($entityUserRegisterFormRoute) {
$entityUserRegisterFormRoute->setDefaults([
'_form' => '\Drupal\offer\Form\RegistrationForm',
'_title' => 'Create your offer platform account',
]);
}
}
}
231
This is powerful stuff. With this routeSubscriber we can basically override any of the
default routes in drupal core. We basically changed the behaviour of registered
routes in drupal.
The validateForm() and submitForm() methods guard our input and make sure the
user does not exist yet. Because we already listen to the login event (remember this
from a previous tutorial), the new user gets redirected to the homepage
automatically.
For our platform, this 1-step log in will on-board many more users than the core user
register form.
232
We could attach an Event to send out an email for verifications and so forth. This leads
us too far for this course but is something you would do on a software in production.
In the meantime you could let users have a bid and put an unverified label to their
profile.
● I’ve created a view mode ‘compact’ for the users, added a profile image and
added them to the offer teaser and full template, and to the bid table. A good
example on how to render a custom entity. I spiced it up a little with css. Take
a look at offer.module and user--compact.html.twig to see how I did it. The
trick was to alter the template suggestion so that we could override the twig
of the user module. Last step was to edit the OfferBiddingTable to add the
user teasers. This is how the integration looks:
233
● Copy the SeedDataGenerator.php from the final platform code to yours. It is
extended with some extra users and offers. Remove your own test users,
offers (drush entity:delete offer) with drush and create your offers and users
like in the Seed Data chapter:
drush offer-create-seeds
Log in with username “test” and password “test” and add a profile image.
Place a bid on an offer and it will look like this:
Also, check out the changelog and subscribe to the mailing list at
https://stefvanlooveren.me/courses/drupal-9-framework to stay updated
about changes and new chapters in the future.
234