DEV Community

Anders Björkland
Anders Björkland

Posted on

Bolt CMS for developers

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:

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.

Installing Bolt CMS with Composer

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.

Initialize the database, create an admin user and load fixtures

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!');
    }
}
Enter fullscreen mode Exit fullscreen mode

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 the src/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
Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

This gets us a simple random image:

A random image loaded from Unsplash of an elephant.

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
Enter fullscreen mode Exit fullscreen mode

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]);
    }
}
Enter fullscreen mode Exit fullscreen mode

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>

Enter fullscreen mode Exit fullscreen mode

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

    }
}

Enter fullscreen mode Exit fullscreen mode

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

}
Enter fullscreen mode Exit fullscreen mode

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:

The widget is displayed on the dashboard and can fetch and store a random image.

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 %}
Enter fullscreen mode Exit fullscreen mode

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 access src and alt 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.
The Elephpant Experience

Oldest comments (2)

Collapse
 
tompich profile image
Thomas

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.

Collapse
 
andersbjorkland profile image
Anders Björkland

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.