In this third article about Bolt CMS we will explore how to work with Bolt CMS as developers. We are going to create a project we call The Elephpant Experience. We will build a widget for the backend to fetch a random elephant picture and have it being displayed on our homepage. Topics we are going to touch on are:
- Installing Bolt CMS on a development machine
- Building a Controller and add a route for it
- Creating and storing ContentType based from API-calls
- Build a Bolt Widget for controlling when to fetch a new picture
- Modify a theme to display the picture
If you just want the code, you can explore the Github Repo
Warning! Do not use this code in a production environment. We do not delve into security issues in this one, but as little homework for you - can you spot what would need to be secured in the code?
Answer
The routes are open for anyone! We can solve this by adding a single row to the beginning of the
storeImage
-method in our controller:
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
Installing Bolt CMS on a development machine
Requirements
On the Getting started page of Bolt CMS we find most of any questions we have on how to set up our local development environment. We are going to be using Bolt version 5 and its basic requirements are PHP >7.2, SQLite, and a few PHP-extensions.
PHP-extensions
- pdo
- openssl
- curl
- gd
- intl (optional but recommended)
- json
- mbstring (optional but recommended)
- opcache (optional but recommended)
- posix
- xml
- fileinfo
- exif
- zip
We can run php -m
to see a list of all the extensions installed and enabled. If anything is missing, it may be a question of enabling them in php.ini
or otherwise download them from PECL.
If you find it difficult setting up PHP-extensions on a Windows machine, you can find an instruction of installing extensions in the official PHP docs. Feel free to ask any questions about this, I know I had many when starting out.
CLI tools
- Composer - A tool for managing dependencies for PHP projects.
- Symfony CLI - A tool for managing Symfony projects, comes bundled with a great virtual server. Nice to have.
Installing Bolt CMS
Having Composer as a CLI tool we can use it to install Bolt CMS. From a terminal we run composer create-project bolt/project elephpant-experience
. This will install the latest stable version of Bolt CMS into a new folder called elephpant-experience
.
Moving into the folder we just created, we can change any configurations needed. For the purpose of this article we will leave the defaults as they are. If you don't want to use SQLite you need to change the .env file to use a different database.
Next up we will initialize our database and populate it with some content. Let's add an admin user and then leverage the fixtures that Bolt provides. We do this with php bin/console bolt:setup
.
We can now launch the project. We can run symfony serve -d
directly from our project-root or php -S localhost:8000
from ./public
. When the server is running we can visit localhost:8000
in our browser. The first load of the page will take a few seconds to load. This will build up a cache so the next time we visit the page it will be faster.
A benefit of using
symfony serve
is that it provides us with SSL-certificate if that is something you would prefer to have. It will prompt you with a notice if you have not installed a certificate for Symfony and the necessary command to run if you want it.
Good job! We have successfully installed Bolt CMS on our development machine ✔
Building a Controller and add a route for it
As Bolt CMS is built on top of Symfony there are many things we can do the Symfony-way. Let's start by creating the file ElephpantController.php
in the src/Controller/
folder. We will use the @Route
annotation for each function we want to return a response. So here's a basic example:
<?php
namespace App\Controller;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
class ElephpantController
{
/**
* @Route("/random-elephpant", name="random_elephpant")
*/
public function index()
{
return new Response('Hello Elephpants!');
}
}
When we visit localhost:8000/elephpant
we will see the text Hello Elephpants!
. As simple as that, we can add our own logic to our Bolt-project.
The reason Bolt manages to connect the route to our controller is because Bolt has a configuration in
routes.yaml
to look for @Route-annotations in files located in thesrc/Controller/
folder.
Knowing the basics of how to build a controller, we want to expand on it and build a way to fetch and store an elephant picture. It will return a json-response with a status-code. This will be useful when we build a widget for the backend - as we want to know if we were successful or not. We are going to do a simple crawl on Unsplash and fetch a random picture from the result set where we have searched on elephant.
First we are going to get a new dependency to the project. Have Composer get this for us:
composer require symfony/dom-crawler
This dependency will work in tandem with Symfony's HttpClient. This comes with Bolt as one of its dependencies and is used to fetch data from the internet. It can make cURL request which we will use to fetch a response from Unsplash. Before we do anything fancy like storing and building a widget to switch out the image, we are expanding our controller to fetch sush an image randomly and display it on the URI for /random-elephant
. Each time we reload that page, another image will be loaded.
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DomCrawler\Crawler;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class ElephpantController extends AbstractController
{
/**
* @Route("/random-elephpant", name="random_elephpant")
*/
public function index(Request $request, HttpClientInterface $httpClient): Response
{
$response = $httpClient->request('GET', 'https://unsplash.com/s/photos/elephants');
$crawler = new Crawler($response->getContent());
$crawler = $crawler->filterXPath('descendant-or-self::img');
$imageArray = [];
$crawler->each(function (Crawler $node) use (&$imageArray) {
$src = $node->attr('src');
$alt = $node->attr('alt');
if ($src && $alt) {
$imageArray[] = [
'src' => $src,
'alt' => $alt
];
}
});
$imageIndex = rand(0, count($imageArray) - 1);
$imageAtRandom = false;
if ($imageIndex > 0) {
$imageAtRandom = $imageArray[$imageIndex];
}
return new Response('<html><body><h1>Elephpant</h1><img src="'. $imageAtRandom['src'] .'" alt="' . $imageAtRandom['alt'] . '"> </body></html>');
}
}
This gets us a simple random image:
Putting a pin in the controller for now, we are going to build a contentType of it where we will store the reference of a single random image.
Creating and storing ContentType based on API-calls
We are going to create a ContentType that will be a singleton to store just the URL/source and alt-text of the image we fetched in the previous step. We will call this type Elephpant
. In ./config/bolt/contenttypes.yaml
we will create this type:
elephpant:
name: Elephpant
singular_name: Elephpant
fields:
name:
type: text
label: Name of this elephpant
src:
type: text
alt:
type: text
slug:
type: slug
uses: [ name ]
group: Meta
default_status: published
icon_one: "fa:heart"
icon_many: "fa:heart"
singleton: true
After updating contenttypes
we want to update Bolt's database to use it. We do this by running php bin\console cache:clear
. This will lay the ground for us to be able to store the image-reference in Bolt. Which leads us into the next step where we will do just that.
We return to the controller and its index
-function. We will continue to fetch an image when we visit the route. We will return a json-response with the image-url and alt-text when there's a GET-request. Upon a POST-request (which we will use later on) we will store the image-url and alt-text in Bolt. Let's build this out in the controller:
<?php
namespace App\Controller;
use Bolt\Factory\ContentFactory;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DomCrawler\Crawler;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class ElephpantController extends AbstractController
{
/**
* @Route("/random-elephpant", name="random_elephpant", methods={"GET"})
*/
public function index(Request $request, HttpClientInterface $httpClient): Response
{
$response = $httpClient->request('GET', 'https://unsplash.com/s/photos/elephants');
$crawler = new Crawler($response->getContent());
$crawler = $crawler->filterXPath('descendant-or-self::img');
$imageArray = [];
$crawler->each(function (Crawler $node) use (&$imageArray) {
$src = $node->attr('src');
$alt = $node->attr('alt');
if ($src && $alt) {
$imageArray[] = [
'src' => $src,
'alt' => $alt
];
}
});
$imageIndex = rand(0, count($imageArray) - 1);
$imageAtRandom = false;
if ($imageIndex >= 0) {
$imageAtRandom = $imageArray[$imageIndex];
}
if ($imageAtRandom) {
return $this->json([
'status' => Response::HTTP_OK,
'image' => $imageAtRandom,
]);
}
return $this->json([
'status' => Response::HTTP_NOT_FOUND,
'message' => 'No image found',
]);
}
/**
* @Route("/random-elephpant", name="store_elephpant", methods={"POST"})
*/
public function storeImage(Request $request, ContentFactory $contentFactory): Response
{
$query = $request->getContent();
$query = json_decode($query, true);
$src = "";
$alt = "";
if (isset($query['src'])) {
$src = $query['src'];
}
if (isset($query['alt'])) {
$alt = $query['alt'];
}
if ($src && $alt) {
$elephpantContent = $contentFactory->upsert('elephpant',
[
'name' => 'Random Elephpant',
]
);
$elephpantContent->setFieldValue('src', $src);
$elephpantContent->setFieldValue('alt', $alt);
$elephpantContent->setFieldValue('slug', 'random-elephpant');
$contentFactory->save($elephpantContent);
return $this->json(['status' => Response::HTTP_OK, 'image' => ['src' => $src, 'alt' => $alt]]);
}
return $this->json(['status' => Response::HTTP_INTERNAL_SERVER_ERROR]);
}
}
In this updated version of the controller we specify that the index-version will be used for GET
-requests and the storeImage
-version for POST
-requests. We also use the ContentFactory
to create a new elephpant
-content. We then save the content and return a json-response with the status. This will be used in a widget for the admin interface, which we are going to build next.
Build a Bolt Widget for controlling when to fetch a new picture
Next up is for us to build a widget to show on the dashboard. This will enable us to control when to switch out our random image. For this to work, three different parts are required of us. One is the elephpant.html.twig
file, which will be used to render the widget. The other two are the ElephpantExtension
and ElephpantWidget
files which will be responsible to inject this template to the admin interface. We start with the twig
-template.
The twig-template will be responsible for displaying our widget, asynchroneously fetch a random image (with the help of our controller) and display it, and also asynchroneously store the image (again with the help of our controller) if we like it.
./templates/elephpant-widget.html.twig
<style>
.elephpant img {
width: auto;
height: 10rem;
max-width: 20rem;
}
</style>
<div class="widget elephpant">
<div class="card mb-4">
<div class="card-header">
<i class="fas fa-plug"></i> {{ extension.name }}
</div>
<div class="card-body">
<p>Update the random image?</p>
{% setcontent elephpant = 'elephpant' %}
<div>
<p>Current Image</p>
<div class="image card-img-top">
<div id="elephpant-img-container">
{% if elephpant is defined and elephpant is not null %}
<img src="{{ elephpant.src }}" alt="{{ elephpant.alt }}" />
{% else %}
<p>No image</p>
{% endif %}
</div>
</div>
</div>
<div class="mt-4">
<button class="btn btn-secondary" onclick="fetchElephpantImg()">Fetch new image</button>
<div>
<div id="elephpant-img-preview"></div>
<button id="elephpant-img-store" class="btn btn-secondary d-none" onclick="storeElephpantImg()">Store</button>
</div>
</div>
</div>
</div>
</div>
<script>
const elephpantImgContainer = document.getElementById('elephpant-img-container');
const elephpantImgPreview = document.getElementById('elephpant-img-preview');
const storeButton = document.getElementById('elephpant-img-store');
/**
* Fetch a new image from our controller's GET-route
*/
function fetchElephpantImg() {
fetch('/random-elephpant')
.then(response => response.json())
.then(data => {
elephpantImgPreview.innerHTML = `<img src="${data.image.src}" alt="${data.image.alt}" />`;
storeButton.classList.remove('d-none');
})
.catch(error => {
console.error(error);
});
}
/**
* Store the current preview-image through our controller's POST-route
*/
function storeElephpantImg() {
const elephpantImg = elephpantImgPreview.querySelector('img');
const randomImg = elephpantImgPreview.querySelector('img');
const randomImgSrc = randomImg.getAttribute('src');
const randomImgAlt = randomImg.getAttribute('alt');
fetch('/random-elephpant', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
src: randomImgSrc,
alt: randomImgAlt
})
})
.then(response => response.json())
.then(data => {
storeButton.classList.add('d-none');
elephpantImgContainer.innerHTML = `<img src="${data.image.src}" alt="${data.image.alt}" />`;
elephpantImgPreview.innerHTML = '';
})
.catch(error => {
console.error(error);
});
}
</script>
Next we need to create the ElephpantExtension
-class. This class will be responsible for registering our widget. We create a new folder called Extension
in our src
-folder and create a new ElephpantExtension.php
-file there:
<?php
namespace App\Extension;
use App\Extension\ElephpantWidget;
use Bolt\Extension\BaseExtension;
class ElephpantExtension extends BaseExtension
{
public function getName(): string
{
return 'elephpant extension';
}
public function initialize($cli = false): void
{
$this->addWidget(new ElephpantWidget($this->getObjectManager()));
}
}
If you have intellisense or a reasonable sane IDE, you should get some kind of warning because we do not have the ElephpantWidget-class yet. Let's fix that. In the same folder, create the ElephpantWidget.php
-file:
<?php
namespace App\Extension;
use Bolt\Widget\BaseWidget;
use Bolt\Widget\Injector\RequestZone;
use Bolt\Widget\Injector\AdditionalTarget;
use Bolt\Widget\TwigAwareInterface;
class ElephpantWidget extends BaseWidget implements TwigAwareInterface
{
protected $name = 'Elephpant Experience';
protected $target = ADDITIONALTARGET::WIDGET_BACK_DASHBOARD_ASIDE_TOP;
protected $priority = 300;
protected $zone = REQUESTZONE::BACKEND;
protected $template = '@elephpant-experience/elephpant-widget.html.twig';
}
This file is pretty minimal and is responsible for directing Bolt on where to use its Widget and how to render it. We set the template
-property to the elephpant-widget.html.twig
-file, which is located in the @elephpant-experience
-namespace. This namespace is directing to the project root's templates
-folder. Just be sure to update the namespace if you choose to change the $name
-property of this file.
On the backend we will have the following experience in the dashboard:
We are now on the final stretch. For the last touch we are going to display our image when there is one. We will add it to the homepage for the theme of our choice.
Set up a theme to display the picture
The default theme for Bolt is currently base-2021
. Usually we would copy this theme and make it our own, but for this tutorial we will modify it directly. Go to ./public/theme/base-2021/index.twig
and add the following after the second include
, on line 8, add:
{# The ELEPHPANT Experience #}
{% setcontent elephpant = 'elephpant' %}
{% if elephpant is defined and elephpant is not null %}
<section class="bg-white border-b py-6">
<div class="container max-w-5xl mx-auto m-8">
<div class="flex flex-col">
<h3 class="text-3xl text-gray-800 font-bold leading-none mb-3 text-center">The Elephpant Experience</h3>
{% if elephpant %}
<div class="w-full sm:w-1/2 p-6 mx-auto">
<img class="w-full object-cover object-center"
src="{{elephpant.src }}" alt="{{ elephpant.alt }}">
</div>
{% endif %}
</div>
</div>
</section>
{% endif %}
The Bolt-2021 theme uses Tailwind CSS, therefore we see its utility classes being used here. We get the elephpant contentType and display the image if it exists by using
setContent
. Then we use the elephpant variable to accesssrc
andalt
for the image.
We have finally arrived at the goal. We have:
- Installed Bolt CMS
- Built a Controller for our routes
- Created a custom ContentType
- Used a web crawler to fetch a random image (of elephants)
- Added a route for storing the image
- Built a dashboard widget
- Display the random image on our homepage
It's quite a lot, and if you have any questions or viewpoints you are more than welcome to share them. That's the wrap-up, thank you for reading about The Elephpant Experience.
Top comments (2)
Hey Anders,
That was absolutely great, thank you.
I really felt in love with Bolt, but it's documentation is very thin when it comes to add functionalities. Your article really helped me.
I would really love to read more on this topic.
Thanks Thomas! I wouldn't mind exploring how it has evolved over the last couple of years. I've been up in arms with Sylius and SilverStripe lately, so it would be about time to do it.