Bolt CMS for developers
Tags: php, symfony
Published: 2021-11-11 07:05:57

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:  

* <a href="#installing">Installing Bolt CMS on a development machine</a>
* <a href="#controller">Building a Controller and add a route for it</a>
* <a href="#content-type">Creating and storing ContentType based from API-calls</a>
* <a href="#widget">Build a Bolt Widget for controlling when to fetch a new picture</a>
* <a href="#theme">Modify a theme to display the picture</a>

*If you just want the code, you can explore the [Github Repo](https://github.com/andersbjorkland/elephpant-experience)*

> **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? {% details 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'); ```{% enddetails %}

## <span id="installing">Installing Bolt CMS on a development machine</span>  
### Requirements

On the [Getting started page of Bolt CMS](https://docs.boltcms.io/5.0/getting-started/introduction) 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](https://pecl.php.net/).

> 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](https://www.php.net/manual/en/install.pecl.windows.php). Feel free to ask any questions about this, I know I had many when starting out.

#### CLI tools
* [Composer](https://getcomposer.org/) - A tool for managing dependencies for PHP projects.
* [Symfony CLI](https://symfony.com/download) - 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/tfsf82h0xh95mzyr7dkf.gif)

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/da7d25rfly0xubszvf7t.gif)  
  
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 ✔

## <span id="controller">Building a Controller and add a route for it</span>
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
<?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 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:  
```bash
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
<?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:  
![A random image loaded from Unsplash of an elephant.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/swlhgpxpv9ri4oi1o5oo.png)  
  
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.

## <span id="content-type">Creating and storing ContentType based on API-calls</span>  
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:  
```yaml	
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
<?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.


## <span id="widget">Build a Bolt Widget for controlling when to fetch a new picture</span>  
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`  
```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
<?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
<?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:  
![The widget is displayed on the dashboard and can fetch and store a random image.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/cgd71iijjagte7eoo6jk.gif)  
  
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.


## <span id="theme">Set up a theme to display the picture</span>
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:  

```twig
  {# 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 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ypndrqilkql612kvj3ty.png)